Skip to content

v3.3.0

Latest

Choose a tag to compare

@cirwel cirwel released this 08 Oct 10:38
· 145 commits to master since this release
f351177

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 with start_session(force_new=true) and pass
    back client_session_id are unaffected. To find clients that depend on the
    old behavior, set UNITARES_CREDENTIAL_ISSUANCE=log; to restore it, set
    STRICT_IDENTITY_REQUIRED=false and UNITARES_IDENTITY_STRICT=log. On
    Docker Compose, set these in .env: docker-compose.yml now passes them,
    and UNITARES_LEGACY_SESSION_IDS, into the server container (before this
    release .env alone 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=log during 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_SECRET was 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 needs X-Unitares-Operator with a token from
    UNITARES_OPERATOR_TOKENS; give one to any operator script that does this.
  • Dialectic reviewer approvals fail closed. With
    UNITARES_DIALECTIC_REVIEWER_HOST or UNITARES_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 an external host 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_bindings rows carry
    client_session_ref instead of client_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_URL and UNITARES_LLM_MODEL are ignored without a
    warning. Rename them to UNITARES_MODEL_BASE_URL and UNITARES_MODEL_ID
    (./scripts/unitares model rewrites .env).
  • Native installs bind to loopback. scripts/ops/start_unitares.sh and
    the LaunchAgent template no longer bind all interfaces. If clients on other
    machines connect directly, set UNITARES_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 runs python3 scripts/dev/apply_migrations.py --apply. Afterwards
    audit.events and audit.tool_usage grow until an operator archives old
    months with scripts/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 --build to build from the checkout; scripts/unitares update still
    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 needs vouched: true in 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 an external host 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_HOST still 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.toml lists 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 --offline runs on every PR through tests/test_distribution_surfaces.py and fails when a published manifest (glama.json, server.json, smithery.yaml, plugin.json, llms.txt, ...) lands unregistered. A new weekly distribution job in positioning-surfaces.yml reads 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 forked unitares-governance skill under integrations/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 in provenance_context, never in harness_type) is now in the package README; catalog installs use unitares-host-adapter and 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.json is 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.yml publishes through GitHub OIDC, so no token or operator login is stored. It runs on every master push that touches PUBLISHED_VERSION or server.json, stamps the version from PUBLISHED_VERSION, skips a version the registry already holds (the registry refuses to republish one), runs only on refs/heads/master (a manual dispatch from another ref is skipped, because the OIDC token can publish any io.github.cirwel/* entry), and pins mcp-publisher v1.8.1 by its release SHA-256. In docs/operations/distribution-surfaces.toml the registry moves from not_listed to a probed hosted-listing surface, whose http-ok probe 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, so docker compose up -d --wait builds 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 Container publishes 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-bump moves all three pins. Promote Release verifies 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 new Postgres Image workflow builds the database image for both platforms on pull requests that change it. unitares update still 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_finding and update_finding is now a short routing line that names the nearest neighbouring tool. The default tools/list serves only that first line; the full text stays in describe_tool. consult's line now says that effort='thorough' needs cloud privacy and an operator extension, and delegate_inference's that it is an operator extension, off by default. Text only: no behavior or input schema changes. scripts/dev/check_tool_descriptions.py reports descriptions that break the contract in docs/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.py embeds 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 --rebuild to rewrite every vector, for example after an embedding model change. --only-missing is still accepted and does nothing, and combining it with --rebuild is 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 every core.agent_state check-in older than 90 days except each identity's latest, and drop audit.events months older than 180 days, audit.tool_usage older than 90 and outcome_events older 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. Old audit.events and audit.tool_usage months move to scripts/ops/archive-audit-partitions.py: a dry run by default, --export writes 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 --apply drops 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, fired dialectic_facilitation_needed, and could leave the flag set on a session that was not stalled. The write is now a compare-and-set on the phase and updated_at the 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 touches updated_at. (#2688)

    The race was inherited from the ANTITHESIS reviewer_unresponsive path and was widened in reach when the SYNTHESIS synthesis_stalled entry was routed through the same shared write block, so one change to DialecticDB.mark_awaiting_facilitation and 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-driven update_session_awaiting_facilitation handler writer is unchanged: it is a deliberate transition on a live session and still bumps updated_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). The identity tool read force_new without 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 for force_new and 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_new now 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 with stable_session_id_rejected, force_new or not, so a tampered credential is refused rather than replaced by a fresh identity. Calls without force_new are unchanged.

  • identity: a strict-mode onboard that declares lineage is no longer refused. Under STRICT_IDENTITY_REQUIRED, an onboard or start_session call with no ownership proof and no force_new is refused with lineage_declaration_required, and the refusal's hint names two ways forward: pass parent_agent_id to continue prior work, or pass force_new=true for 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 with parent_agent_id and spawn_reason="explicit" but no force_new was refused again by the same gate, before it reached the later check that does permit declared lineage. A declared parent_agent_id now passes that gate and is promoted to force_new=true, because a declared-lineage onboard is a fresh mint by definition; the later lineage checks, such as lineage_coincidental_rejected for 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/eisv authentication no longer refuses legitimate WebSocket clients because of header casing. The ASGI specification requires lowercased header names, and Starlette's Headers relies 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 sends Authorization, Origin or Cookie with a capital letter therefore had that header silently ignored on the WebSocket handshake. As a result, a valid bearer token was refused, the passkey Origin pin 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_similar before the new discovery was saved; the lookup read the discovery's tags by id, found no row and returned nothing, so related_to links, similar_discoveries and 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 observe action (audit_events, telemetry, outcome_evidence, bridge), the identity_required refusal's safe_options listed observe(action='anomalies') under stay_read_only, but that action writes an anomaly_detected audit row for each newly detected anomaly. stay_read_only now offers only observe(action='aggregate'), and the anomaly scan moves to a new scan_anomalies option that says it writes. Only the labels change: anomalies still works without an identity. (#2668)

  • scripts/analysis/unitares_lite.py exits nonzero when a command fails: a connection failure, an HTTP error or a strict-identity refusal now exits 1, and status also 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, nan or 1e309 in a per-host reviewer timeout or in UNITARES_DIALECTIC_CONTINUATION_WAIT_S made the spawned reviewer's runtime cap raise OverflowError (ValueError for nan) 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 finite 1e308 still 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 passed inf to asyncio.wait_for and waited on a hung host until the reaper instead of trying the next one. (#2713)

    float() accepts all of these spellings, and 1e309 overflows to infinity without any error, so the old parser's except (TypeError, ValueError) guard never saw them; only int() of the finished cap noticed. One helper, _seconds() in orchestrator_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_BASE and UNITARES_OLLAMA_BASE_URL (replaced by UNITARES_MODEL_BASE_URL) and UNITARES_LLM_MODEL (replaced by UNITARES_MODEL_ID) are no longer read by the server, the orchestrated dialectic reviewer, the local residents or scripts/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/v1 and gemma4: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 /v1 added; ./scripts/unitares model rewrites .env with the new names and removes the old UNITARES_OLLAMA_BASE and UNITARES_LLM_MODEL lines. The alias table in src/local_inference_env.py stays, 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 (then UNITARES_API_TOKEN), so anyone holding that bearer could sign an ownership proof for any agent, and docker-compose.yml set it to a published default. Now, when UNITARES_CONTINUITY_TOKEN_SECRET is unset, blank or that published value, the server generates a key at startup in data/secrets/continuity_token_secret (mode 0600; UNITARES_CONTINUITY_TOKEN_SECRET_FILE moves 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 with scripts/ops/provision_resident_anchor.py --apply from 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 sets UNITARES_CONTINUITY_TOKEN_SECRET to its own value sees no change, and its tokens keep verifying across the upgrade. (#2674)

  • A continuity token's exp must be one the server could have written. Token resolution read exp with int(), 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 whose exp is 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 with token_exp: null, as its docstring promised. create_continuity_token caps ttl_seconds at 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-exp token as ownership proof, because it never reads exp. A token with a non-finite exp (1e309, nan) no longer makes the server skip the freshness observation and its path0_token_accept_observed row; it is reported as expired. (#2679, #2697)

  • Credentials only for a caller that proved ownership. identity() and onboard() return the agent's client_session_id and a signed continuity_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 unverified agent_id on 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 inferred identity() returns client_session_id: null, no token, and credentials_withheld with the reason, and skips binding the stable session for that caller; an inferred onboard() resume is refused with resume_proof_required before anything is bound. The SDK, the governance plugin, the Hermes host adapter and the reference residents mint with force_new, resume with their own session or token, or attest over UDS, so they are unaffected. UNITARES_CREDENTIAL_ISSUANCE=log issues as before and logs each response the rule would withhold, for finding dependent clients before enforcing. A deployment with STRICT_IDENTITY_REQUIRED=false keeps 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 an X-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 legacy client_session_id of the form agent-{uuid[:12]}, computable from the public UUID, or a fixed X-Session-ID a 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_id that onboard and identity return was agent-{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 now agent-{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 with stable_session_id_rejected. Because the id is now a credential, surfaces other callers can read store a csid: 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's audit_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 legacy agent-{uuid12} id follows UNITARES_LEGACY_SESSION_IDS: refuse by default, log to accept and log [LEGACY_SESSION_ID] during a migration, or accept; 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_id from the session binding even when that binding is server-inferred (transport fingerprint, onboard pin, transport-injected client_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 no client_session_id had 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's identity_required refusal, which tells the caller to retry with its client_session_id. Agents the strict check-in gate exempts (substrate-earned, or dedicated_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 or X-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 valid X-Unitares-Operator token from UNITARES_OPERATOR_TOKENS; a bearer API key alone, an unbound caller, and a token not on the allowlist are refused with LIFECYCLE_NOT_OWNER_OR_OPERATOR and the target is left as it was. operator_resume_agent keys on the same token instead of the caller's own operator label or tag, both of which were self-claimed at onboard, and operator is now a server-granted tag that update_agent_metadata refuses. The Lumen check-in doctor presents UNITARES_OPERATOR_TOKEN on 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_id is a bearer credential: whoever presents it acts as the session it names. list_process_bindings accepts any agent_uuid and returned, for each live process bound to that agent, the client_session_id it 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 legacy agent-{uuid12} session id itself. Binding rows now carry client_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.sh and the LaunchAgent template scripts/ops/com.unitares.governance-mcp.plist now default UNITARES_BIND_ALL_INTERFACES to 0, 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 need UNITARES_BIND_ALL_INTERFACES=1 in 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_REQUIRED and UNITARES_IDENTITY_STRICT were 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_required refusal, not a fresh identity.

    STRICT_IDENTITY_REQUIRED now disables strict identity only for an explicit 0, false, no or off; any other value, a typo included, keeps it on. An invalid UNITARES_IDENTITY_STRICT value now falls back to strict.

    Clients that onboard with start_session(force_new=true) and pass back the returned client_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 with STRICT_IDENTITY_REQUIRED=false and UNITARES_IDENTITY_STRICT=log. This completes the staged rollout that ran local, then residents, then dispatch, before this default flip. (#2666)

  • A failed substrate-claims lookup no longer lets a known resident resume over HTTP (#2703). A substrate-anchored agent (one with a core.substrate_claims row) 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 with substrate_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, behind get_bound_agent_id when no request context has bound an agent) treated any key starting with agent- 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. So agent-a resolved to whichever agent's uuid began with a, and agent- 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_bindings without an agent_uuid, lifecycle audit entries, record_progress_pulse before 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.md shows 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 with start_session(force_new=true), confirms threading with check_working_state, records a finding, checks in, grades the check-in against a test result, closes the finding with closure_class="fix_verified" and its evidence, and exits. Process B finds A's finding before redoing the work and declares A as its predecessor with spawn_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_result needs the prediction_id from the sync_state it grades; without it the outcome is recorded but excluded from calibration. Second, a clean exit calls agent(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:8767 and never said the host ports can move, so the agent had to find GOVERNANCE_HOST_PORT and LEASE_PLANE_HOST_PORT in 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 with start_session(force_new=true) and passes the returned client_session_id on 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, including sync_state, record_result and 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.