Repository navigation
A minor release from master, following v3.2.0, by operator decision
(2026-10-08). Several defaults, most of them identity defaults, tighten in
ways an existing install can notice. Each has a setting that restores the old behavior or a one-time step,
and COMPATIBILITY.md carries the policy.
Upgrading. Check these before restarting on 3.3.0; the entries below give
the detail.
- Strict identity is the default. A bare
identity(agent_uuid=..., resume=true)without a continuity token, and a write that resolves no
identity, are now refused, and credentials go only to a caller that proved
ownership. Clients that mint withstart_session(force_new=true)and pass
backclient_session_idare unaffected. To find clients that depend on the
old behavior, setUNITARES_CREDENTIAL_ISSUANCE=log; to restore it, set
STRICT_IDENTITY_REQUIRED=falseandUNITARES_IDENTITY_STRICT=log. On
Docker Compose, set these in.env:docker-compose.ymlnow passes them,
andUNITARES_LEGACY_SESSION_IDS, into the server container (before this
release.envalone did not reach it). - Legacy session ids are refused. A presented
agent-{uuid12}id is
refused by default; ids the server issues now carry a keyed tag. Clients that
store and echo the returned id pick up the new one at their next
start_session(force_new=true)or continuity-token resume; presenting a
stored legacy id is refused rather than converted. Set
UNITARES_LEGACY_SESSION_IDS=logduring a migration to accept and log legacy
ids. The lease plane digests session ids too, so upgrade it with the server. - The continuity key is the server's own. If
UNITARES_CONTINUITY_TOKEN_SECRETwas unset, blank or the Compose default,
the server now generates its own key, so outstanding continuity tokens and
effect grants stop verifying once: clients onboard again. For each resident
with an anchor, move the anchor file aside and re-provision it with
scripts/ops/provision_resident_anchor.py --apply. A deployment that set its
own secret sees no change. - Cross-agent lifecycle needs an operator token. Archiving, deleting or
resuming another agent now needsX-Unitares-Operatorwith a token from
UNITARES_OPERATOR_TOKENS; give one to any operator script that does this. - Dialectic reviewer approvals fail closed. With
UNITARES_DIALECTIC_REVIEWER_HOSTorUNITARES_DIALECTIC_REVIEWER_HOSTS
set, the local model may now object but not approve, an unknown host name
makes the list invalid (no host is called), and anexternalhost at a
local, loopback or trusted-network address is refused. A paused agent that a
local fallback used to clear can now stay paused: check the configured hosts
after upgrading. - Binding diagnostics show references.
list_process_bindingsrows carry
client_session_refinstead ofclient_session_id, and
admin(action='debug_context')reports session references and only the size
of the uuid prefix index. Update any script that read the old field. - Old model setting names are gone.
UNITARES_OLLAMA_BASE,
UNITARES_OLLAMA_BASE_URLandUNITARES_LLM_MODELare ignored without a
warning. Rename them toUNITARES_MODEL_BASE_URLandUNITARES_MODEL_ID
(./scripts/unitares modelrewrites.env). - Native installs bind to loopback.
scripts/ops/start_unitares.shand
the LaunchAgent template no longer bind all interfaces. If clients on other
machines connect directly, setUNITARES_BIND_ALL_INTERFACES=1. Docker
Compose installs are unchanged. - Migration 073. It stops the weekly partition maintenance from deleting
check-ins and outcome events and dropping old audit months. Until it is
applied, that deletion continues. A Docker Compose install applies it with
scripts/unitares update, which backs up the database first; an operator
install runspython3 scripts/dev/apply_migrations.py --apply. Afterwards
audit.eventsandaudit.tool_usagegrow until an operator archives old
months withscripts/ops/archive-audit-partitions.py. - Prebuilt images. On a release checkout,
docker compose up -d --wait
pulls the server and database images from GHCR instead of building them.
Pass--buildto build from the checkout;scripts/unitares updatestill
rebuilds from source.
The skills bundle changed, and the governance plugin carries it as v0.4.20.
The v3.2.0 entry also declared its bundle as v0.4.20, but that bundle was never
released on its own; v0.4.20 carries this release's bundle, which includes
v3.2.0's skill changes.
Added
- dialectic reviewer: an ordered host list.
UNITARES_DIALECTIC_REVIEWER_HOSTS
names up to three reviewer hosts (codex,claude,antigravity,
external) in the order the orchestrated reviewer tries them. A host that
does not answer (an error, a timeout, an empty reply) is skipped for the
next; a host that answers ends the list even when its reply holds no verdict
object, and the repair goes back to it. The order is never changed. After
the list, the local model answers and may object but not approve. An
approval now needsvouched: truein its provenance (a listed host that may
approve answered, or no list is set and the local model is the reviewer), so
a verdict whose provenance does not say so is withheld. The local endpoint
cannot be listed, by name or by URL, and neither can anexternalhost at a
local address; an invalid list calls no host. The external host no longer
follows HTTP redirects, so a redirect cannot reach the local model under
its authority, and the dispatching server sends its host selection even
when empty, so the orchestrator's own setting never stands in. The
repair and every reconsideration go to the host that answered first. Each
verdict records the list, a configuration digest, every attempt and the
answering host's family.UNITARES_DIALECTIC_REVIEWER_HOSTstill works as
a one-item list; with neither set, behavior is unchanged. Step 1 of
docs/proposals/active/dialectic-reviewer-hosts-v0.md; it does not yet
check model-family independence or share cooldowns. (#2652) - distribution surfaces:
docs/operations/distribution-surfaces.tomllists every place UNITARES is published or described outside this repo (Hermes catalog pin, awesome-list PRs, Glama, Claude Code and Codex plugin manifests, PyPI, GHCR, repo descriptions, sites, Hugging Face datasets, Zenodo), plus the directories checked and found absent.scripts/ci/distribution_surfaces.py --offlineruns on every PR throughtests/test_distribution_surfaces.pyand fails when a published manifest (glama.json,server.json,smithery.yaml,plugin.json,llms.txt, ...) lands unregistered. A new weeklydistributionjob inpositioning-surfaces.ymlreads each live surface and fails on drift: a catalog pin behind the adapter's latest tag with no bump PR open, a contract-test pin that is not the catalog's, a listing PR closed without the line landing, plugin manifests disagreeing on version, a hand-verified listing older than its limit. A surface it cannot reach reports SKIP, never OK. Registering the surfaces also retired one: the forkedunitares-governanceskill underintegrations/hermes/skills/is removed. It was 52 lines against the canonical skill's 152, had missed the strict-identity text, and no sync reached it. Its one Hermes-specific rule (report the model provider inprovenance_context, never inharness_type) is now in the package README; catalog installs useunitares-host-adapterand are unaffected. (#2699) - distribution surfaces: official MCP Registry listing. UNITARES gets a listing in the official MCP Registry (registry.modelcontextprotocol.io) as
io.github.cirwel/unitares, where clients and agents increasingly look for servers.server.jsonis metadata only: name, title, the README tagline as its description, repository and website. It deliberately has no package entry. The GHCR server image needs the Compose Postgres (AGE and pgvector) and Redis to start, so a package entry would tell a client to launch something that cannot run on its own; the README's Compose install stays the install path..github/workflows/publish-mcp-registry.ymlpublishes through GitHub OIDC, so no token or operator login is stored. It runs on every master push that touchesPUBLISHED_VERSIONorserver.json, stamps the version fromPUBLISHED_VERSION, skips a version the registry already holds (the registry refuses to republish one), runs only onrefs/heads/master(a manual dispatch from another ref is skipped, because the OIDC token can publish anyio.github.cirwel/*entry), and pinsmcp-publisherv1.8.1 by its release SHA-256. Indocs/operations/distribution-surfaces.tomlthe registry moves fromnot_listedto a probed hosted-listing surface, whosehttp-okprobe reads the per-server endpoint and reports DRIFT until the first publish lands. (#2710)
Changed
- docker quickstart: a release checkout now pulls prebuilt images for the server (
ghcr.io/cirwel/unitares) and the database (ghcr.io/cirwel/unitares-postgres, Postgres with Apache AGE and pgvector), as it already did for the lease plane, sodocker compose up -d --waitbuilds nothing locally. Each service keeps its build block: a missing tag, or--build, builds from the checked-out source, and CI's Docker quickstart still builds from source.Publish Containerpublishes the database image from the same tag with the same hardening, and no published release tag is replaced by a re-dispatch any more (previously only the lease plane's was kept).make version-bumpmoves all three pins.Promote Releaseverifies the database image wherever the tagged Compose file pulls one and requires the tagged file's server and database pins to name that release; a release cut before this change builds both locally and still promotes. A newPostgres Imageworkflow builds the database image for both platforms on pull requests that change it.unitares updatestill rebuilds from source. The first release that publishes the database image needs its GHCR package made public once. (#2656) - tool descriptions: routing lines. The first line of the descriptions of
consult,call_model,delegate_inference,list_inference_hosts,describe_inference_host,leave_note,search_shared_memory,store_findingandupdate_findingis now a short routing line that names the nearest neighbouring tool. The defaulttools/listserves only that first line; the full text stays indescribe_tool.consult's line now says thateffort='thorough'needs cloud privacy and an operator extension, anddelegate_inference's that it is an operator extension, off by default. Text only: no behavior or input schema changes.scripts/dev/check_tool_descriptions.pyreports descriptions that break the contract indocs/proposals/tool-surface-legibility-v0.md; it is report-only unless run with--strict, and most advertised descriptions do not yet meet it. (#2680, #2685) scripts/migration/reembed_corpus.pyembeds the same text window the server does (EMBED_DETAILS_WINDOW, 6000 characters, not the first 500), so a backfilled vector matches what the server would have produced. A bare run now embeds only discoveries with no vector in the target table; it used to rewrite every row. Pass the new--rebuildto rewrite every vector, for example after an embedding model change.--only-missingis still accepted and does nothing, and combining it with--rebuildis an error. A vector left stale by a refresh skipped while embeddings were unavailable is repaired only by--rebuild. (#2687)- retention:
audit.partition_maintenance()no longer deletes anything except expired sessions (migration 073). It used to delete everycore.agent_statecheck-in older than 90 days except each identity's latest, and dropaudit.eventsmonths older than 180 days,audit.tool_usageolder than 90 andoutcome_eventsolder than 365, both on its weekly run and whenever an outcome insert found its partition missing. On the reference deployment, running since November 2025, that left check-ins from 2026-07-08 onward only. Check-ins and outcome events are now kept. Oldaudit.eventsandaudit.tool_usagemonths move toscripts/ops/archive-audit-partitions.py: a dry run by default,--exportwrites each month to a gzipped CSV under~/backups/archive/audit-partitions/with a manifest (rows, sha256, source table and a content fingerprint taken in the export's own snapshot), and--applydrops a month only after its archive has been read back and counted, and only if the partition's OID and fingerprint still match under an exclusive lock, so a change between export and drop stops the drop. Nothing schedules the script yet, so those two tables grow until an operator runs it. The drop and cleanup functions remain for that script and for deliberate manual use. The migration is opt-in at deploy, like every migration. (#2711)
Fixed
-
dialectic: the stuck-session sweeper no longer flags a session that just made progress as awaiting facilitation. The sweeper read the stuck set and later wrote
awaiting_facilitation, guarding only terminal status, so a participant message landing in between still got the session flagged, fireddialectic_facilitation_needed, and could leave the flag set on a session that was not stalled. The write is now a compare-and-set on thephaseandupdated_atthe sweeper observed (both ANTITHESIS and SYNTHESIS stalls); a row that moved on is refused like any other refused sweeper write and re-read next sweep. The write still never touchesupdated_at. (#2688)The race was inherited from the ANTITHESIS
reviewer_unresponsivepath and was widened in reach when the SYNTHESISsynthesis_stalledentry was routed through the same shared write block, so one change toDialecticDB.mark_awaiting_facilitationand its sweeper call site covers both phases. Callers that pass no expected values, such as tests or tools that want the unconditional guarded write, get exactly the previous behaviour. The caller-drivenupdate_session_awaiting_facilitationhandler writer is unchanged: it is a deliberate transition on a live session and still bumpsupdated_at. Regression tests simulate a participant message and a phase move landing between the sweeper's read and its write, for both phases, and pin the new SQL predicate at the database layer. -
identity(force_new=...)reads its flag consistently, and the identity handler passes it to the resolver (#2704). Theidentitytool readforce_newwithout coercion, so a string"false"from an MCP client counted as true: the call skipped its session-key resume and minted a fresh identity against the caller's intent. The adapter and the handler now coerce the flag the same way. The handler also skipped its session-key resume forforce_newand then resolved the same key without passing it, so the resolver ran checks that belong to a resume; since keyed session ids (#2702) such a check can return a refusal, which the handler then read as an identity and failed on.force_newnow rides through to the resolver, which mints without consulting the key. Through the public tool path nothing changes for a forged or refused session id: the identity middleware validates the presented id before any handler runs and refuses it withstable_session_id_rejected,force_newor not, so a tampered credential is refused rather than replaced by a fresh identity. Calls withoutforce_neware unchanged. -
identity: a strict-mode onboard that declares lineage is no longer refused. Under
STRICT_IDENTITY_REQUIRED, anonboardorstart_sessioncall with no ownership proof and noforce_newis refused withlineage_declaration_required, and the refusal's hint names two ways forward: passparent_agent_idto continue prior work, or passforce_new=truefor a fresh process-instance. The first of those did not work. The freshness gate counted only ownership proofs, a caller-asserted session id or a display name as signals, so a call that followed the hint withparent_agent_idandspawn_reason="explicit"but noforce_newwas refused again by the same gate, before it reached the later check that does permit declared lineage. A declaredparent_agent_idnow passes that gate and is promoted toforce_new=true, because a declared-lineage onboard is a fresh mint by definition; the later lineage checks, such aslineage_coincidental_rejectedfor a live parent, still apply. A bare call with neither field is refused as before, and the permissive posture is unchanged. (#2669) -
uds: starting and stopping the UDS listener no longer blocks the event loop while another process holds the socket path's lock; the locked probe, bind and shutdown unlink run in a worker thread. The socket is now bound inside a private 0700 directory and linked into place already 0600 and listening, so the start no longer changes the process-wide umask, and a node that takes the path during the bind is refused rather than replaced (#2701). A listener start that fails no longer hands its socket path to shutdown, which then unlinked a socket this process never bound, possibly a live server's; a start that finds a live listener on the path refuses with
EADDRINUSE, and only a stale socket file is removed. On a restart while the previous process is still draining its listener, the new process therefore runs HTTP-only rather than displacing the old socket (#2678). -
/ws/eisvauthentication no longer refuses legitimate WebSocket clients because of header casing. The ASGI specification requires lowercased header names, and Starlette'sHeadersrelies on that when it looks a header up. uvicorn's websockets-sansio protocol, which serves the live EISV feed, instead passes the client's casing through unchanged. A browser or library that sendsAuthorization,OriginorCookiewith a capital letter therefore had that header silently ignored on the WebSocket handshake. As a result, a valid bearer token was refused, the passkeyOriginpin never matched, and a signed-in dashboard session cookie was not seen, so the dashboard's live feed could fail for a signed-in operator reaching the server through the public listener. The server now lowercases WebSocket header names in a small middleware registered outermost, before any check reads them. HTTP requests already arrive lowercased and are not touched. The failure was fail-closed: no request was ever wrongly admitted, so this restores access for legitimate clients and changes no trust decision. Operators do not need to change any configuration. (#2660) -
knowledge graph: related-entry links fire on the Postgres backend. On the default Postgres knowledge backend, a store ran
find_similarbefore the new discovery was saved; the lookup read the discovery's tags by id, found no row and returned nothing, sorelated_tolinks,similar_discoveriesand the consolidation hint never fired. It now matches on the discovery's own tags, as the AGE backend does. This makes the link step run; it makes no retrieval-quality claim, and existing discoveries are not relinked. A historical count of these links on a Postgres-backed install counts a step that could not run. (#2686) -
observe: the operator refusal no longer calls the anomaly scan read-only. When an unbound caller asks for an operator
observeaction (audit_events,telemetry,outcome_evidence,bridge), theidentity_requiredrefusal'ssafe_optionslistedobserve(action='anomalies')understay_read_only, but that action writes ananomaly_detectedaudit row for each newly detected anomaly.stay_read_onlynow offers onlyobserve(action='aggregate'), and the anomaly scan moves to a newscan_anomaliesoption that says it writes. Only the labels change:anomaliesstill works without an identity. (#2668) -
scripts/analysis/unitares_lite.pyexits nonzero when a command fails: a connection failure, an HTTP error or a strict-identity refusal now exits 1, andstatusalso fails when its identity lookup fails. It used to exit 0 whatever happened. (#2677) -
dialectic: a nonfinite reviewer timeout no longer breaks orchestrated dispatch.
inf,nanor1e309in a per-host reviewer timeout or inUNITARES_DIALECTIC_CONTINUATION_WAIT_Smade the spawned reviewer's runtime cap raiseOverflowError(ValueErrorfornan) while the spec was being built, before the dispatcher's own error handling, so the call raised instead of falling back to the in-process reviewer. Such a value, or any override above seven days (a finite1e308still overflowed once the cap was summed and scaled to milliseconds), now counts as unset and the setting's default applies, both in the cap and in the value forwarded to the reviewer, which would otherwise have passedinftoasyncio.wait_forand waited on a hung host until the reaper instead of trying the next one. (#2713)float()accepts all of these spellings, and1e309overflows to infinity without any error, so the old parser'sexcept (TypeError, ValueError)guard never saw them; onlyint()of the finished cap noticed. One helper,_seconds()inorchestrator_dispatch.py, now parses every seconds override that feeds the cap, so a new host timeout cannot reintroduce the hole. Negative values keep their previous meaning: a negative host timeout adds no extra lifetime, matching what the reviewer itself does with it, and a negative continuation wait still falls back to the default window so it cannot disable the orchestrator's reaper. The per-host half arrived with the reviewer host list (#2652); the continuation-wait half was already on master. Regression tests cover each spelling for both settings and pin that the spec still builds.
Removed
- Older local-model setting names (compatibility: settings removed, as announced in v3.2.0).
UNITARES_OLLAMA_BASEandUNITARES_OLLAMA_BASE_URL(replaced byUNITARES_MODEL_BASE_URL) andUNITARES_LLM_MODEL(replaced byUNITARES_MODEL_ID) are no longer read by the server, the orchestrated dialectic reviewer, the local residents orscripts/client/ollama_bridge.py, and Docker Compose no longer passes them into the server container. An install that still sets only the old names silently falls back to the defaults (http://localhost:11434/v1andgemma4:latest): there is no error and no warning, and the doctor no longer prints a line for them. Inside a container that default usually reaches no model, so consult reports no model and reviews wait for a peer. Rename the settings in.env, the LaunchAgent plist or wherever the environment is set. An Ollama root URL can move over unchanged, because a base with no path gets/v1added;./scripts/unitares modelrewrites.envwith the new names and removes the oldUNITARES_OLLAMA_BASEandUNITARES_LLM_MODELlines. The alias table insrc/local_inference_env.pystays, empty, for the next rename. (#2714)
Security
-
The continuity token secret is the server's own. Continuity tokens prove ownership of an agent, and under strict identity they are what lets a caller resume one by UUID. Their signing key used to fall back to
UNITARES_HTTP_API_TOKEN(thenUNITARES_API_TOKEN), so anyone holding that bearer could sign an ownership proof for any agent, anddocker-compose.ymlset it to a published default. Now, whenUNITARES_CONTINUITY_TOKEN_SECRETis unset, blank or that published value, the server generates a key at startup indata/secrets/continuity_token_secret(mode 0600;UNITARES_CONTINUITY_TOKEN_SECRET_FILEmoves it) and signs with that. A key file that is a symlink, owned by another user or readable by group or others is refused, and continuity tokens stay off until it is fixed. The API tokens are never used. Effect grants share the key and change with it. Upgrading an install that relied on either fallback invalidates its outstanding continuity tokens and effect grants once: clients onboard again (start_session(force_new=true)). A resident anchor holding such a token stops resuming: move the anchor file aside, then re-provision it withscripts/ops/provision_resident_anchor.py --applyfrom the server's checkout (the provisioner leaves an anchor that already names the same UUID untouched, so it does not refresh the token in place). Deleting the generated file and restarting rotates the key. A deployment that already setsUNITARES_CONTINUITY_TOKEN_SECRETto its own value sees no change, and its tokens keep verifying across the upgrade. (#2674) -
A continuity token's
expmust be one the server could have written. Token resolution readexpwithint(), so a finite but absurd value (1e308, a 400-digit integer) read as a token that never expires, and a non-integer one (1.5,true) was truncated into a readable expiry. Resolution now refuses a token whoseexpis not a JSON integer or lies more than the one-hour continuity TTL plus the 30 s clock-skew tolerance ahead of now, and the PATH 0 freshness observation reports the same token as expired withtoken_exp: null, as its docstring promised.create_continuity_tokencapsttl_secondsat that TTL, so no token the server mints falls outside the bound. Tokens are HMAC-signed with the server's key, so reaching this needed a token signed with that key; it is hardening, not a closed hole. Tokens the server minted resolve as before, and PATH 0 still accepts an expired or unreadable-exptoken as ownership proof, because it never readsexp. A token with a non-finiteexp(1e309,nan) no longer makes the server skip the freshness observation and itspath0_token_accept_observedrow; it is reported as expired. (#2679, #2697) -
Credentials only for a caller that proved ownership.
identity()andonboard()return the agent'sclient_session_idand a signedcontinuity_token, and either one lets the holder act as that agent. They were returned to any call the server matched to an agent, including a match by inference: an onboard pin keyed on the User-Agent alone, a transport fingerprint, or a name or unverifiedagent_idon onboard. On a Docker bridge or behind a tunnel every client arrives from one address, and a User-Agent is whatever a client sends, so one client could obtain another's credentials. Under strict identity, the default, credentials now go only to a mint, a session or token the caller itself sent, a UDS substrate attestation, or an operator token. An inferredidentity()returnsclient_session_id: null, no token, andcredentials_withheldwith the reason, and skips binding the stable session for that caller; an inferredonboard()resume is refused withresume_proof_requiredbefore anything is bound. The SDK, the governance plugin, the Hermes host adapter and the reference residents mint withforce_new, resume with their own session or token, or attest over UDS, so they are unaffected.UNITARES_CREDENTIAL_ISSUANCE=logissues as before and logs each response the rule would withhold, for finding dependent clients before enforcing. A deployment withSTRICT_IDENTITY_REQUIRED=falsekeeps its permissive behavior. Proof is recorded per agent, for a session key the caller sent that is not an inferred fingerprint, an OAuth client id or anX-Client-Id(those name an application, not a caller). This closes the inference routes only: a session key is a bearer secret only as strong as its value, so a legacyclient_session_idof the formagent-{uuid[:12]}, computable from the public UUID, or a fixedX-Session-IDa client chose for itself, can still be presented by someone who computed or guessed it. The keyed session ids in the next item close the first case for ids the server issues (legacy ids are refused by default); the second remains. Interface contract 1.30.0. (#2681) -
Stable session ids can no longer be derived from an agent's UUID. The
client_session_idthatonboardandidentityreturn wasagent-{uuid[:12]}, a pure function of the public UUID, so anyone who knew an agent's UUID could compute it and act as that agent. It is nowagent-{uuid[:12]}-{tag}, where the 20-character tag is an HMAC keyed with a value derived from the server's continuity key (src/mcp_handlers/identity/stable_session.py), and rotating that key rotates every id. A keyed id authenticates itself: the resolver accepts it when exactly one identity that is not deleted has that uuid prefix and the tag verifies (an archived agent resolves as archived, so reactivation still works), with no stored binding needed and nothing written, so residents that resume by token or UDS attestation keep working however their bindings expire. A forged tag, an unknown or ambiguous prefix, or a deleted agent is refused withstable_session_id_rejected. Because the id is now a credential, surfaces other callers can read store acsid:digest of it instead: audit event rows (database and JSONL, including governed-effect rows the lease plane writes), tool usage and outcome event rows, knowledge-graph writer provenance (session_id_at_write), and every lease'saudit_session, which the lease plane now stores as the digest at acquire whichever client sent it (a lease-plane release is part of this change). A legacyagent-{uuid12}id followsUNITARES_LEGACY_SESSION_IDS:refuseby default,logto accept and log[LEGACY_SESSION_ID]during a migration, oraccept; a server with no continuity key still issues and accepts legacy ids. Clients that store and echo the returned id are unaffected and pick up the new id at their next onboard or rebind; a verified continuity token that embeds a legacy id converts to the keyed one, and runtime observations accept a verifying keyed id without a stored session row. Interface contract 1.31.0. (#2702) -
A knowledge write from a caller the server only inferred no longer writes as the agent it inferred. Dispatch fills
agent_idfrom the session binding even when that binding is server-inferred (transport fingerprint, onboard pin, transport-injectedclient_session_id, context fallback). Every client behind one address with one User-Agent shares a fingerprint, so on a Docker bridge or behind a tunnel a fresh connection that passed noclient_session_idhad its findings credited to whichever agent last matched. A cold-start install test found this when a second connection's finding landed on the first connection's agent. Low- and medium-severity stores, notes and updates on such a binding now take the stable anonymous writer id (anonkg_*) that the tool description already promised for unbound callers. Under strict identity, a high or critical store or update on such a binding is refused with the strict check-in gate'sidentity_requiredrefusal, which tells the caller to retry with itsclient_session_id. Agents the strict check-in gate exempts (substrate-earned, ordedicated_substrate) keep their attribution, decided by the same predicate the gate uses, which fails closed. Callers that prove their session (client_session_id,Mcp-Session-Id,X-Session-ID, OAuth orX-Client-Id, continuity token) are unaffected. (#2695) -
Archive, delete and resume act only for the agent itself or an operator.
agent(action="archive"|"delete"|"resume")reaches its handlers through a tool that may name another agent as the target, and the handlers did no check of their own, so any bound caller could archive, delete or unpause any other agent (GHSA-r9q5-7j8h-82rr). They now require the session binding to resolve to the target, or the request to carry a validX-Unitares-Operatortoken fromUNITARES_OPERATOR_TOKENS; a bearer API key alone, an unbound caller, and a token not on the allowlist are refused withLIFECYCLE_NOT_OWNER_OR_OPERATORand the target is left as it was.operator_resume_agentkeys on the same token instead of the caller's ownoperatorlabel or tag, both of which were self-claimed at onboard, andoperatoris now a server-granted tag thatupdate_agent_metadatarefuses. The Lumen check-in doctor presentsUNITARES_OPERATOR_TOKENon its resume of Lumen, which was the only cross-agent caller of these actions found. Interface contract 1.28.0: no schema or digest changes, the version records the behavior change (#2667). -
Diagnostics no longer hand out other sessions' ids. A
client_session_idis a bearer credential: whoever presents it acts as the session it names.list_process_bindingsaccepts anyagent_uuidand returned, for each live process bound to that agent, theclient_session_idit onboarded with, so any caller could read another agent's session id and use it.admin(action='debug_context'), open to any bound caller, listed up to ten raw keys from the in-memory session cache and up to ten 12-character uuid prefixes, and such a prefix is the legacyagent-{uuid12}session id itself. Binding rows now carryclient_session_ref, an opaque reference: it reveals no character of the id and is keyed with a secret held only by the server process, so it cannot be presented and is no oracle for guessing ids offline. References are stable for the life of the process, enough to tell rows apart. The debug view shows the same references, and reports the uuid prefix index only by its size. Interface contract 1.29.0 records the output change; no input schema moves. Nothing internal read either value, so concurrency detection and lineage checks are unchanged. (#2676) -
New native installs bind to loopback.
scripts/ops/start_unitares.shand the LaunchAgent templatescripts/ops/com.unitares.governance-mcp.plistnow defaultUNITARES_BIND_ALL_INTERFACESto0, and the launcher no longer passes--host 0.0.0.0, so the server's own setting decides the bind address. Clients on other machines needUNITARES_BIND_ALL_INTERFACES=1in addition to the Host and Origin allowlists; a reverse proxy or tunnel on the same host reaches loopback and needs only the allowlists. A plist already installed from the old template keeps its value, and the container image still binds all interfaces inside the container, so Docker Compose installs are unchanged. (#2659) -
Strict identity is now the default.
STRICT_IDENTITY_REQUIREDandUNITARES_IDENTITY_STRICTwere opt-in. Unset, they meant that on a fresh install:- a bare
identity(agent_uuid=..., resume=true)with no continuity token resumed that agent, logging a warning, and returned its session id and a new token; - a write that resolved no identity minted one instead of being refused.
Unset now means strict:
- a bare UUID resume is refused unless it carries a continuity token bound to that UUID;
- an unresolved write gets the typed
identity_requiredrefusal, not a fresh identity.
STRICT_IDENTITY_REQUIREDnow disables strict identity only for an explicit0,false,nooroff; any other value, a typo included, keeps it on. An invalidUNITARES_IDENTITY_STRICTvalue now falls back tostrict.Clients that onboard with
start_session(force_new=true)and pass back the returnedclient_session_id, as the SDK, the governance plugin and the Hermes adapter do, are unaffected. A client that relied on the permissive behavior can restore it withSTRICT_IDENTITY_REQUIRED=falseandUNITARES_IDENTITY_STRICT=log. This completes the staged rollout that ran local, then residents, then dispatch, before this default flip. (#2666) - a bare
-
A failed substrate-claims lookup no longer lets a known resident resume over HTTP (#2703). A substrate-anchored agent (one with a
core.substrate_claimsrow) may resume only over its attested UDS socket, and three gates in identity resolution refuse its UUID over HTTP: the pre-session continuity-token gate, the token rebind (PATH 2.8), and the session and prefix paths (_substrate_http_reject). Each caught any exception from the claims lookup and fell through to the ordinary resume, so a database timeout at the wrong moment removed that layer for exactly the agents it protects. The server now keeps the set of claimed UUIDs: every lookup that finds a claim records it (entries are never removed, so a stale read cannot erase a newer one), and a background task reloads the whole table every minute, so an enrollment is known within a minute even if nothing looks it up (a resident enrolled in the minute before an outage, and not yet looked up, is the remaining gap; no enrollment can be committed during one, since enrollment writes to the same database). When the lookup fails on an HTTP request, a UUID in that set is refused withsubstrate_check_unavailable. Until the table has loaded once (a restart during a database outage), the server cannot tell a resident from anyone else, so every such request is refused with the same code and a retry message; after the first load, any other UUID falls through as before, so an unreachable claims table does not block ordinary agents (including the degraded keyed-session resume that relies on Redis while Postgres is down). A caller with a kernel-attested peer PID (UDS) is never refused by this path. -
A session key no longer resolves by its shape. The synchronous identity lookup (
_get_identity_record_sync, behindget_bound_agent_idwhen no request context has bound an agent) treated any key starting withagent-as a uuid prefix: it looked the rest up in the prefix index and, failing that, scanned agent metadata and bound the key to the first agent whose uuid started with it. Soagent-aresolved to whichever agent's uuid began witha, andagent-to any agent at all, with no stored binding and no proof, and the lookup cached the result and recorded the caller's fingerprint as the binding's. A key now resolves only through a binding stored for it, which is what the async resolver already required. Callers reach this path for diagnostics and audit labels (list_process_bindingswithout anagent_uuid, lifecycle audit entries,record_progress_pulsebefore its explicit fallback); a request whose identity middleware already bound an agent is unaffected, because the bound agent wins first. (#2696)
Documentation
- docs: agent session walkthrough.
docs/guides/AGENT_SESSION_WALKTHROUGH.mdshows one task across two processes: every call an agent makes, and the response a fresh Docker quickstart install returned, captured over MCP Streamable HTTP rather than written by hand. Process A searches shared memory before it has an identity, starts withstart_session(force_new=true), confirms threading withcheck_working_state, records a finding, checks in, grades the check-in against a test result, closes the finding withclosure_class="fix_verified"and its evidence, and exits. Process B finds A's finding before redoing the work and declares A as its predecessor withspawn_reason="explicit". Two steps that the integrating-agents chapter does not spell out are shown with the server's own message for the case where they are skipped. First,record_resultneeds theprediction_idfrom thesync_stateit grades; without it the outcome is recorded but excluded from calibration. Second, a clean exit callsagent(action="release_presence"); without it a successor that declares the process as its parent within the presence TTL is refused as co-located (rejected_coincidental). The manual's integrating-agents chapter and the docs index link to the walkthrough. (#2709) - README install section names the port overrides and the first client call. A cold-start install test (a fresh agent following only the README, on a host where 8767 and 8788 were already taken) found two gaps. First, the README printed
http://localhost:8767and never said the host ports can move, so the agent had to findGOVERNANCE_HOST_PORTandLEASE_PLANE_HOST_PORTin the install manual and the Compose file comments. The lease-plane port, 8788, was not mentioned at all. Second, the README never showed what a client does first, so the agent assembled its first call from the integrating-agents chapter. The install section now names both overrides with a link to the Docker quickstart, and says that an agent starts withstart_session(force_new=true)and passes the returnedclient_session_idon every later call, because that is what ties its writes to its own process. The one-command install, the README's single install block and its length budget are unchanged. The full workflow, includingsync_state,record_resultand declared handoffs, stays in the operator manual's integrating-agents chapter. (#2694)
Evidence. Every merged change in v3.2.0..v3.3.0 is cited (47 of 47, with 14 declared no-user-effect). CI is green on the release head, including the Docker quickstart. The upgrade notes were checked claim by claim against the code before the cut.
Rollback. The release adds migration 073, which only removes the weekly retention step, so rolling back with it applied keeps the record. scripts/unitares update --to v3.2.0 refuses a newer schema, so a Docker Compose rollback means checking out v3.2.0 by hand.