Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.17.0 - 2026-11-01

Durable chats without lasting memory. ORRERY_LONG_TERM_MEMORY=false lets a deployment keep sessions in PostgreSQL while carrying nothing from one session to the next: no memory service, no load_memory, no ops journal. Until now only the learning sandbox did this, and it refuses Studio, so a lab's Studio instance (or any shared instance) can now use DATABASE_URL safely. A missed wiring stops startup instead of remembering. Turning it off does not delete memory or journal data stored earlier.

Added

  • ORRERY_LONG_TERM_MEMORY=false: durable chats without lasting memory. A deployment can now keep sessions in PostgreSQL while carrying nothing from one session to the next: no memory service, no MemoryPlugin, no load_memory, and no ops journal (its notes live in user: state). The builders leave them out, an admin catalog edit holding a journal tool is refused before it is saved, and AgentGateway refuses a memory service, a MemoryPlugin or a tree (graph Workflow nodes included) that still reaches a recall or cross_session tool, including a wrapped Runner. Until now only the learning sandbox did this, and it refuses Studio. Studio's tool catalogue and the console's Agents tab stop offering cross_session tools; batch triage keeps its own journal step. Data stored earlier is not deleted. On by default.

0.16.0 - 2026-10-31

A coach for the learning sandbox. In learning mode the coordinator gets lab_coach, which investigates with read-only Kafka and monitoring tools and answers at the learner's hint level, the one the server stamps, so a learner cannot type their way up the ladder. It exists only in a learning-mode process; no configuration file loads it. The design for the learner debrief (AEP-022) now also covers launch URLs behind a lab's own gate.

Added

  • The lab coach in the learning sandbox (AEP-032 phase 3). In learning mode the coordinator gets lab_coach, which investigates with read-only Kafka and monitoring tools and answers with a question or hint at the learner's hint level (at level 3, the answer with its evidence): it reads the level the server stamps (the same line the root gets), not a hint count in its own replies, so a learner cannot type their way up the ladder. It is built only in an activated process and lab_coach is a reserved name; tools that are not available here (a disabled specialist, Loki without a URL) are skipped, and a guarded one stops startup. The answer guard stays the safety layer.

Documentation

  • AEP-022: the debrief's launch URL behind a lab's own gate. The attempt token goes in the lab's launch URL fragment, which an HTTP 3xx redirect without a fragment of its own carries over; the design lists the three ways a gate loses it.

0.15.0 - 2026-10-31

Role-gated deployments, a hint ladder, and a quieter image. A deployment shared through one identity provider can admit only accounts holding a listed role, and send the rest to a page of its choice. Learners in the sandbox can ask for a hint, one level at a time, with each one recorded; the sandbox now keeps no long-term memory, so a lab can use a durable session store. Guarded MCP servers no longer probe Google mTLS, and the image no longer fetches LiteLLM's tables from GitHub at startup. Note for operators: an unusable file in ORRERY_SECRETS_DIR (unreadable, not UTF-8, over 64 KiB) now stops startup instead of being skipped.

Added

  • ORRERY_REQUIRED_ROLES: admit only accounts holding a listed role. A verified token used to be admitted whatever its roles, with a roleless one served as a viewer. On a deployment shared through one identity provider (a practice lab, say), every account could read it. With the rule set, a token carrying none of the listed role names (raw IdP names from JWT_ROLE_CLAIM, case-insensitive) gets 403 access_denied on every authenticated route, console API and A2A alike. Startup fails if it is set with auth off.
  • ORRERY_CONSOLE_ACCESS_DENIED_URL. With ORRERY_REQUIRED_ROLES set, the console checks /me before loading anything, and a refused person sees a "you don't have access to this deployment" page, linking to this https URL, instead of a console whose every call fails.
  • A hint ladder in the learning sandbox (AEP-032 phase 2). Each learner has a level per scenario: 0 orient, 1 narrow, 2 point at the tool, 3 reveal.
  • The console's Take a hint button calls POST /learning/hint. It accepts verified people only, is rate-limited per learner (ORRERY_LEARNING_HINT_RATE_LIMIT, default 3/minute), raises the level one step at a time and never lowers it.
  • Each step writes a learning_hint audit entry, which a lab can deduct hint costs from, and orrery_learning_hints_total{level}.
  • The level reaches the coordinator as one line of its instruction, and the answer guard stops withholding values at level 3.
  • Levels are per process: after a restart every learner is back at the manifest's default_disclosure. A level is stamped into each turn by the server, and a tool that writes it is reverted.

Changed

  • The learning sandbox keeps no long-term memory. With ORRERY_LEARNING_MODE the HTTP server builds no memory service and no MemoryPlugin, and the coordinator has no load_memory tool. The gateway refuses all three at startup. Memory is keyed by the learner, not the scenario, so recall would hand back an earlier attempt's evidence and answers, and with DATABASE_URL it would outlive every reset of the lab. The ops journal, whose notes and preferences live in user: state, is left out of the coordinator for the same reason, and the gateway refuses any agent tree reaching a tool marked with the new cross_session marker. Sessions still persist, so a learning deployment can now use PostgreSQL (see Single node (Docker Compose) in the deployment guide).

Fixed

  • An unusable file in ORRERY_SECRETS_DIR stops startup: one that is unreadable, not UTF-8, over 64 KiB or holds a NUL byte. It used to be logged and skipped, so an unreadable DATABASE_URL file (wrong owner: the image runs as uid 1000) started the server on in-memory sessions, against the database fail-fast policy. The error names the file and the uid.
  • Guarded MCP servers no longer try Google mTLS. ADK's MCP session manager calls google.auth.default() on every HTTP/SSE connection to set up client-certificate mTLS. Without Google credentials that logged Failed to configure mTLS using AsyncAuthorizedSession on each connection. With them, a successful probe would have replaced the guarded HTTP client (origin pin, address checks, no redirects) with ADK's own, and sent a Google token to the MCP server. GuardedMcpToolset turns the probe off; TLS comes from the server's config.
  • The image uses LiteLLM's bundled tables. LITELLM_LOCAL_MODEL_COST_MAP and LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS are set by default, so startup no longer fetches from raw.githubusercontent.com, a request that fails behind an egress allowlist.

  • docker_compose_status keeps auxiliary containers out of a project's state counts. With every container in one compose project, a crash-looping lab-* container showed as an untagged restarting: 1 in the summary. A project mixing both now reports states for its platform containers only, plus auxiliary_states and auxiliary_containers; a project that is wholly auxiliary keeps states and scope: auxiliary. containers stays the total, and the triage prompts name the new key. Output is unchanged when ORRERY_AUXILIARY_RESOURCES is unset.

0.14.0 - 2026-10-30

Per-environment consoles, platform-aware triage, and a learning sandbox. A console now says which deployment it is (title, environment badge, accent) and hides features its server does not run. Operators can describe the platform to the agent and mark auxiliary containers that triage reports without letting them change the verdict. Practice labs can run Orrery in a sandbox that withholds an exercise's answers (AEP-032 phases 0–1, off by default). The Docker specialist's compose status now works against a remote daemon, its logs include stderr, and inspect_container redacts credentials in env values; a deployment can also refuse tokens from clients it does not list.

Added

  • Console branding per environment: ORRERY_CONSOLE_TITLE, ORRERY_CONSOLE_ENVIRONMENT (production/staging/demo/lab) and ORRERY_CONSOLE_ACCENT (ten presets) are served in /console-config.js. The title replaces "Orrery Console" in the sidebar, sign-in page and tab; the environment shows as a badge, leads the tab title, and is named on the chat approval card ("Approve on PRODUCTION"); staging, demo and lab also get a banner. Invalid values stop startup.
  • Operator platform context and triage scope: ORRERY_PLATFORM_CONTEXT_FILE (Markdown, at most 16 KiB, read once at startup) is added to the coordinator's and the triage agents' instructions. ORRERY_AUXILIARY_RESOURCES (name globs) marks containers and compose projects that may be down without the platform being degraded: the Docker tools tag them scope: auxiliary, and triage reports them separately without letting them change the verdict. A container label orrery.scope=platform pins one back in; no label can make a container auxiliary, since images pass their labels on. The coordinator's examples now name a system the deployment runs instead of assuming Kafka.
  • ORRERY_REJECT_UNLISTED_CLIENTS (opt-in): a verified token whose client (azp, else client_id) is in neither ORRERY_HUMAN_CLIENTS nor ORRERY_SERVICE_CLIENTS gets 401, the same answer as an invalid token, on the console API and A2A, instead of becoming a viewer service principal. Setting it without ORRERY_HUMAN_CLIENTS stops startup.
  • Learning sandbox mode (AEP-032 phase 1), off by default: ORRERY_LEARNING_MODE=true with ORRERY_SCENARIO_MANIFEST makes the HTTP server withhold an exercise's answers. AnswerGuardPlugin checks every final text response of the coordinator against the manifest's targets (digits with or without one consistent separator and with trailing zero decimals, k/thousand/M roundings, English words, strings) and replaces a match with a nudge in place, keeping tool calls; the stored transcript holds the nudge, and learning_answer_withheld audits the target id only. /chat/stream sends no delta frames, /session/{id}/triage returns the severity only, /session/{id}/activity drops the argument summary, and /healthz reports mode and scenario_id. Startup is refused unless auth, ORRERY_HUMAN_CLIENTS and L2 are set and A2A, Studio, admin editing, multi-replica, several workers, span content capture and DEBUG logging and message-content capture in traces are off, checked again once the agent tree is built. Every other entry point (the bots, the CLI, adk web, the batch triage, a specialist on its own) refuses the flag.
  • ORRERY_DISABLED_TOOLS (AEP-032 phase 0): take single tools out of running specialists by qualified name (observability.get_alert_groups). The specialist's agent is cloned without the tool before anything is built from it, so the coordinator, declarative agents, the admin catalog and the Studio never see it, and GuardrailsPlugin refuses a call to it by function identity with the neutral BLOCKED status. Another specialist sharing the function loses it too. It only removes: an unknown name, a tool of a specialist that is not running, or a tool the specialist's health check, remediation, journal or self-test uses stops startup. Unset, nothing changes.
  • Scenario manifest loader (AEP-032 phase 0, not wired in yet): orrery_core.learning.manifest validates contract v1 strictly in JSON mode (exact types, no NaN, no field that can widen access, at least one target, default_disclosure at most 2, no value under 100 or shorter than 4 characters, none inside an id, unit or descriptor), reads regular files only, never repeats a value in an error, re-checks the file's stat() (and its content while it is young) on every read, keeps the last valid manifest when a reload fails (orrery_learning_manifest_errors_total, registered only when used), and keeps values that left the file guarded for ORRERY_LEARNING_GRACE_SECONDS. Nothing imports the learning package in a normal process; a test pins that.

Fixed

  • The console no longer offers a Studio the server does not run. Every admin saw "Studio" in the navigation, and with ORRERY_STUDIO_ENABLED off it opened a "not enabled here" page. The entry now follows /me's studio_available (mounted, and the caller a human admin), and the Studio view cannot be opened without it. The Agents page no longer suggests ORRERY_ADMIN_EDITING when editing is off.
  • docker_compose_status works in the image. It ran docker compose, which the image does not ship (it failed as "unknown flag: --format"), with a hardcoded docker-compose.yml path on the agent's own filesystem. It now reads the compose labels the daemon keeps on each container through the plain CLI (docker ps --all --filter label=com.docker.compose.project…): no argument lists every project with its containers by state, project=NAME lists that project's containers, stopped ones included, and an unknown project is an error. No compose file or plugin is needed, so it works against a remote DOCKER_HOST. The project_dir argument is gone.
  • get_container_logs includes the container's stderr. docker logs replays a container's stderr on its own stderr, and the tool read stdout only, so a service that logs only to stderr (nginx's error log, Prometheus, most Go and Java servers) showed no lines. Both streams are now read through one pipe, interleaved as the CLI writes them.

Security

  • inspect_container scans env values, not only names. A variable like DATABASE_URL=postgres://user:pass@db passed the tool's own filter, which matched names only (the global PIIRedactionPlugin already masked the URL credentials, but not with ORRERY_PII_REDACTION=false). Values now go through the same redact_text patterns, plus Go's scheme-less user:pass@tcp(…) DSN form; passwd/dsn names, and pass/pwd as a name word (DB_PASS, DB_PWD; not PWD itself), are redacted whole.

0.13.0 - 2026-10-29

Console: room to work. The navigation and the Studio's side panel fold away, and the Studio lays itself out by the space it actually has, so a laptop-sized screen gets a usable canvas.

Added

  • Collapsible navigation. From the lg breakpoint up, the console's left rail folds to its icons (its toggle, or Ctrl/Cmd+B) and stays folded across reloads. Studio and Agents badges become dots; labels remain for screen readers.
  • Collapsible Studio panel. The Inspect/Run/A2A/MCP/History panel folds to a strip of vertical tabs. Picking a node, adding or removing one, starting a run or a save refused for problems opens it again.

Changed

  • The Studio sizes its toolbar, node palette and panel by its own width (container queries) rather than the window's, so folding either rail brings the labels back. The page header row is hidden on the Studio from lg up (it stays as the page's heading for screen readers); Registry has its own icon, and every toolbar button a tooltip.

Fixed

  • The Studio's "Compose a run" card was sized to the window and spilled over the side panel on mid-sized screens; it now fits the canvas.
  • When the canvas flipped between vertical and horizontal layout, edges kept leaving from the nodes' old handle positions.

0.12.0 - 2026-10-28

Orrery Studio: compositions you keep. The console saves and opens versioned compositions, replays your stored runs (or follows one still going after a reload), and says outside the Studio when a run waits for you. The registry's deployment-bound entries can only be tightened by an edit.

Upgrade notes: - A stored registry version that loosens an entry the deployment binds (see Security) no longer validates, so it is not served: the newest version that still does is (the file, when none does). Save over it to fix it. - studio_registry_updated audit lines carry the touched entries and the documents' digests instead of both whole documents.

Added

  • Studio console: save, open and replay. Save (Ctrl/Cmd+S) keeps the canvas on the server as a versioned composition shared by the Studio's admins, Open lists them (latest or any version, delete), and a save that another admin's save overtook is reported as a conflict, never written over it. A run of an unchanged saved version is filed under it. The History tab lists your runs on the server: Replay rebuilds a finished one from its events, with its composition on the canvas, and Follow picks a run still going back up after a reload.
  • Console: a Studio run that needs you says so everywhere. While a live run waits for an approval or a remote's question, the Studio's Run tab shows N waiting, the sidebar's Studio entry N waiting for you, and the browser tab's title starts with (N) Approval needed, so an approval is not left to expire while its person is in the chat or another tab.

Fixed

  • Agents page: a save that went through is reported as saved. When the catalog saved but reading the agents back failed, the page said "could not save" and carried the draft onto the version that already held it; a save that threw also left the button stuck on Saving.
  • Studio console: the human-wait meter counts overlapping waits once (the server pauses the run's timeout once for them), so the compute meter no longer under-reports with parallel approvals.

Security

  • Studio registry editing: a bound entry can only be tightened. An entry that sends the deployment's secret, is private_network or targets loopback already had to keep the file's endpoint; an edit could still drop its effect: acting (so calls ran without the owner confirming each one), add skills or tools, move tools into read_only, drop pins or turn trust_annotations on. Each of those now needs the file.
  • Runs and /studio/validate read the newest stored registry first, so an entry another replica just removed or tightened is never used meanwhile (best effort: with the store down, a run goes on, after 2 s at most, with the registry already served).
  • Dependencies: nltk 3.10.4 (GHSA-j456-xh4h-cpf2).

0.11.0 - 2026-10-27

Orrery Studio, P0c: a person in the loop. A human_approval node stops a run until its owner approves or rejects the exact text; a remote A2A agent's question goes to the owner, never to the model; a remote that changes things (effect: acting) is called only once its owner confirms the exact request; and admins can edit the registry from the console, versioned and audited (AEP-031).

Upgrade notes: - Nothing changes for chat or for a Studio left as it was: each new behaviour is opt-in (ORRERY_STUDIO_ACTING_REMOTES, ORRERY_STUDIO_REGISTRY_EDITING) or comes from a node or entry you add. ORRERY_STUDIO_APPROVAL_TIMEOUT_SECONDS (default 900) bounds how long an approval waits. - A registry with an effect: acting entry refuses to start unless ORRERY_STUDIO_ACTING_REMOTES=true. - With a database and registry editing on, a studio_registry_versions table is created at startup; no existing table changes. Without one, edits live in the process and a restart goes back to the file.

Added

  • Studio: registry editing (AEP-031 P0c). With ORRERY_STUDIO_REGISTRY_EDITING=true, the Studio's admins edit the registry from the console (Registry), validated exactly as the file is at startup. Every save names the version it was edited from (a stale one is a 409), is kept with who, when, a note and what it changed (a History tab), is audited with before and after, and applies to the next run. An entry that sends a secret, is private or targets loopback must keep an endpoint the file declares (checked on save and whenever a stored version is served); pins are keyed by endpoint, so a changed endpoint is pinned afresh. Replicas follow the newest stored version that validates. Off, the console shows the registry read-only.
  • Studio: remotes that act (AEP-031 P0c). A registry A2A entry may be effect: acting when the deployment sets ORRERY_STUDIO_ACTING_REMOTES=true (otherwise startup fails). The run stays at L2; each call to an acting remote waits for the run's owner to confirm the exact request (an approval of kind action, against its digest), and a refusal or expiry sends nothing. The console shows a Confirm and send / Don't send card and marks acting skills in the tool picker.
  • Studio: human approval (AEP-031 P0c). A human_approval node stops a run until the person who started it approves or rejects the text it receives, through GET/POST /studio/runs/{id}/approvals[/{aid}]. The owner sees the text exactly as the next node gets it (held in memory only) and approves its SHA-256 (another digest is a 409; text over 128 KiB can only be rejected); a decision is accepted once; an approval undecided after ORRERY_STUDIO_APPROVAL_TIMEOUT_SECONDS (900, at most 86400) expires and the run ends rejected. The run's deadline is paused while it waits. A rejected edge sends the reviewer's comment back (a loop, capped like any other; a rejection past the cap ends the run rejected); without one, the run ends rejected. New events approval.requested and approval.decided, a rejected outcome, pending_approvals in the run summary, and an audit line per decision.
  • Studio: a remote's question goes to a person. When a remote A2A agent in a Studio run needs input (input-required), the run's owner is asked (an approval of kind input, with the question) and their answer is sent on the same task; the model gets only the final reply and is told never to answer for the person. At most 3 answers per call, each counted against max_remote_calls; declining or letting it expire sends nothing. Nobody is asked a question whose answer could not be sent (no remote call left), and the console marks the question as written by an external agent.
  • Studio console: an approval card in the Run tab (the text, a comment, the time left; Approve is off until the text is read and its digest checked in the browser), awaiting you on the node, a timeout meter that leaves out the wait, label-aware edges out of an approval, and a human-review template (deploy/studio-demo/specs/human-review.json).

Fixed

  • Studio console: what the server runs. The console assumed every server ran P0a only, so routers, parallel/join, supervisors, loops and per-node models were shown as coming in P0b on a server that ran them. It now reads capabilities from GET /studio/schema: the palette, the pattern gallery and the client checks follow the server, and the model field lists ORRERY_STUDIO_MODELS (a model outside it is flagged as the server would refuse it). A server that does not say is still treated as P0a.

Security

  • Tracing: an inbound tracestate over 512 characters is dropped before opentelemetry-api parses it (its parser is quadratic in a run of blanks, and runs before auth). opentelemetry-api stays at 1.44 because google-adk caps it there.
  • Image: the base image's pip and ensurepip wheels are removed (CVE-2026-13346), and a rebuild picks up Debian's OpenSSL fix (CVE-2026-84782, 3.0.22-1~deb12u2) that the published 0.10.0 lacks.

Changed

  • google-adk 2.13: its JSON_SCHEMA_FOR_FUNC_DECL feature is stable, so the startup UserWarning about it is gone. 2.13 traces with the OTel GenAI semantic conventions by default, where span content follows OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT (off by default) rather than ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS; Orrery warns when that switch puts content on spans.

0.10.0 - 2026-10-25

Orrery Studio (preview, admins only, off by default): compose LLM agents, remote A2A agents and MCP tools on a canvas in the console, in named patterns (pipeline, router, critic loop, fan-out/fan-in, supervisor, debate, map-reduce, plan-and-execute), run them apart from the live agents, and watch every node, route, A2A task and MCP call as it happens (AEP-031). Orrery can now call remote A2A agents, always as guarded tools that see only the request (ADR-008).

Upgrade notes: - Nothing changes until ORRERY_STUDIO_ENABLED=true, which also requires ORRERY_HUMAN_CLIENTS (the server refuses to start without it). See integrations/studio.md for the registry and the other settings. - Behaviour change for every MCP server over HTTP, chat's ORRERY_MCP_CONFIG included: responses must be uncompressed (Orrery sends Accept-Encoding: identity and refuses an encoded body), and a single response, or SSE event, over ORRERY_MCP_MAX_RESPONSE_BYTES (default 8 MiB) is refused. - With a database, four new studio_* tables are created at startup; no existing table changes.

Added

  • Orrery Studio, P0a (preview, API only; AEP-031). With ORRERY_STUDIO_ENABLED=true, human admins run a composition: a pipeline of llm_agent nodes using registered MCP tools (mcp:<entry>.<tool>), remote A2A agents (a2a:<entry>.<skill>) and read-only catalogue tools (catalog:<name>). Each run gets its own gateway and plugins, at L2, in memory, with a whole-run ledger (tokens, tool calls, delegations, remote calls, deadline), and streams typed trace events over SSE (run.*, node.*, mcp.call, a2a.message, budget.hit; no content unless ORRERY_STUDIO_CAPTURE_CONTENT=true, then redacted and cut to 2 KB). Routes: POST /studio/runs, GET /studio/runs/{id}, GET /studio/runs/{id}/events, POST /studio/runs/{id}/cancel, GET /studio/schema, GET /studio/registry. Endpoints come only from the ORRERY_STUDIO_REGISTRY YAML; secrets only from ORRERY_STUDIO_SECRET_*. Startup fails with the Studio on and ORRERY_HUMAN_CLIENTS unset; a service token with the admin role gets 403. Memory is bounded per person (ORRERY_STUDIO_MAX_RUNS_PER_USER) and per run (ORRERY_STUDIO_MAX_CONTENT_BYTES_PER_RUN); captured content honours ORRERY_REDACT_IPS. Validation refuses tools that would reach a node's model under one name (tool_name_collision) or over 64 characters (tool_name_too_long). See Studio.
  • Orrery Studio console, P1 (preview, admins only). A Studio view in the web console, loaded on first open: a React Flow canvas with a palette (kinds the server cannot run yet shown as coming in P0b/P0c), drag, connect and delete, a per-node inspector with tools from the registry, live validation against /studio/schema with server 422s mapped onto nodes and fields, keyboard access, pattern templates (pipeline and release-notes run; the rest marked needs P0b) and a deterministic tidy layout. A run panel plays the SSE stream onto the graph (reconnecting with Last-Event-ID), with budgets, a timeline, totals, cancel and a clear budget.hit. A2A and MCP inspectors, in-tab run history with a side-by-side compare, and JSON export and import. See Studio.
  • Studio egress proxy (ORRERY_STUDIO_EGRESS_PROXY). Where the only way out is a forward proxy, the guarded MCP and A2A transports send public registry destinations through it with CONNECT, TLS end to end and verified by Orrery, origins still checked on the hostname. Loopback, private literal addresses and ORRERY_A2A_PRIVATE_ALLOW hosts always go direct; caps, identity encoding and timeouts still apply. Never read from HTTPS_PROXY; the proxy must enforce its own destination allowlist (ADR-008).
  • The Studio demo's writer and certificate job ship in the image: python -m orrery_core.studio.demo.writer and python -m orrery_core.studio.demo.certs (moved from deploy/studio-demo/); the compose profile mounts only the registry.
  • Orrery Studio, P0b: patterns and storage (AEP-031). Compositions now route (output.labels and when edges, or a router node; route.decision events), loop (a back edge with max_iterations of at most 5; loop.iteration events; a cycle without a cap is refused), fan out and in (parallel and join, 2–6 concurrent branches) and delegate (supervisor nodes whose children are agent tools, at most 3 deep, counted as delegations). A node may name its model from ORRERY_STUDIO_MODELS, with no fallback chain. With PostgreSQL, compositions are stored and versioned (/studio/compositions, 409 on a stale version), and runs and their events are kept for ORRERY_STUDIO_RUN_RETENTION_DAYS (14), readable and replayable from any replica (GET /studio/runs); cancel stays on the running replica (409 elsewhere). New for the console: POST /studio/validate, capabilities and budget_ceilings in /studio/schema, MCP tool schemas in /studio/registry, and studio_available in /me. Tested templates for eight patterns are in deploy/studio-demo/specs/; the demo spec moved there as critic-loop.json. spec_version stays 1. A loop may not contain a parallel section (loop_around_parallel), and a branch may not fan out again; nested loops count per entry, and a loop that stops at its cap ends with the last draft; loops nest at most 2 deep (caps multiply). Allowlisted models are built at startup (a bad name or missing key fails it), and Gemini safety filters follow each agent's model rather than MODEL_PROVIDER. Run persistence writes events and the run row in one transaction and retries with backoff; Studio statements on PostgreSQL time out after 10 s, and a finished run whose writer is stuck for 5 minutes is dropped from memory with a warning; ?resolve=true on the registry and composition writes are rate-limited, and following runs on other replicas is capped and backs off.
  • Calling remote A2A agents (ADR-008). GuardedRemoteA2aTool sends one text request to a registered remote and returns its reply as a tool result, so the safety screen, PII redaction, RBAC, autonomy, audit and budgets apply. The remote never sees the session. Cards are pinned by a JCS SHA-256 of their trust fields; acting remotes are refused for now. The caller's token is never forwarded.
  • A network guard for httpx shared with the MCP one: a set of allowed origins, public addresses only unless private_network and ORRERY_A2A_PRIVATE_ALLOW, link-local and metadata always refused, no redirects or proxies, bytes capped while streaming, compressed responses refused (Accept-Encoding: identity), and an allowlisted host never reaches loopback. ORRERY_A2A_CA_FILE adds one CA bundle for an internal CA, for A2A and Studio MCP entries alike.
  • studio-demo compose profile: a plain a2a-sdk writer agent over https with a generated internal CA (renewed near expiry; Orrery mounts only the CA, not the writer's key), an example registry and the research → writer → critic spec (deploy/studio-demo/).
  • make dev-token CLIENT=<azp> (and scripts/dev_token.py --client) mints a token for a named client, so it can count as a person under ORRERY_HUMAN_CLIENTS.

Changed

  • DelegationGuardPlugin and ProgressPlugin count a tool marked delegates_remotely as a delegation, like an AgentTool.
  • PIIRedactionPlugin also redacts email addresses in results of tools marked redact_contact_details (remote A2A replies). Other tools are unchanged.
  • default_plugins() shares one daily token ledger per process (per storage URI), so building plugins again does not reset ORRERY_DAILY_TOKEN_BUDGET_PER_USER. It takes an optional token_ledger.
  • is_machine_session() also covers Studio sessions (s- prefix): they are never written to long-term memory.
  • MCP over HTTP refuses compressed responses (Accept-Encoding: identity) and caps each response body, or each SSE event, at ORRERY_MCP_MAX_RESPONSE_BYTES (default 8 MiB) while it streams.

0.9.1 - 2026-10-24

A fix release. rollback_deployment actually rolls back (it reported success while changing nothing), the console no longer scrolls past the app on the Agents tab, and two small follow-ups from 0.9.0's review. No upgrade steps; Helm releases whose cluster-scoped RBAC names were already truncated get new names on upgrade (Helm replaces them).

Fixed

  • rollback_deployment actually rolls back. It set the rollback-to annotation, which apps/v1 ignores, and reported success while nothing changed. It now does what kubectl rollout undo does: copies the previous revision's pod template back from its ReplicaSet, refused if the deployment changed since it was read. A paused deployment, or one with no previous revision, is refused with nothing changed.
  • Console: the page no longer scrolls past the app on the Agents tab, leaving a blank band under it. A visually hidden label escaped its scrolling pane and stretched the page; the panes now contain what they hold.
  • Helm: cluster-scoped RBAC names that would be over 50 characters end with a hash of the full name, so two long namespaces no longer collide.
  • Google Chat: a turn stopped by the gateway deadline logs that deadline, not None.

0.9.0 - 2026-10-24

A console and hardening release. The Agents tab shows what is running (versions, models, gates, catalog history) and makes composing agents quicker. Every turn now has a deadline, so a wedged turn can no longer hold its session forever, and the Helm chart grants the agent's ServiceAccount exactly the Kubernetes access its tools use. Slack approval cards are actually posted, turns in one session run one at a time, the patch tools change only what they are for, and redaction covers credentials in URLs and Google API keys.

Upgrade notes.

  • The Helm chart now creates a ClusterRole and ClusterRoleBinding by default (rbac.create: true, profile: read), which needs cluster-admin to install. Set rbac.create: false to keep managing RBAC yourself. It refuses to bind the namespace's default ServiceAccount: with serviceAccount.create: false, set serviceAccount.name.
  • Turns stop after 15 minutes (ORRERY_TURN_TIMEOUT_SECONDS, 0 disables).
  • patch_deployment and patch_statefulset refuse fields outside their allowlist (security contexts, volumes, host access, scheduling, command, …); change those through a reviewed manifest.

Added

  • The console's Agents tab shows what is running. A deployment panel gives the Orrery, ADK and Python versions, the coordinator's model (each link of a fallback chain), the autonomy level, the env's specialist selection, the catalog version with who changed it and when, and how long the process has been up. Each agent card shows its entry-point key, package and version, its model, and whether an approval gate guards its tools (a guarded agent without one is flagged). GET /admin/agents returns the same fields.
  • Editing agents is quicker. A "New agent" button in the page header and on the composed-agents grid; "Compose from these tools" on a specialist; Duplicate on a composed agent; All on / All off; a filter for on and off agents. The editor validates the name as you type (the server's rule and name clashes), selects a specialist's tools as a group, lists the chosen tools as removable chips, warns how many need approval or are destructive, offers an instruction template, and keeps with Ctrl/Cmd+Enter. Cards with a pending change are marked, and the save bar lists what the save will change.

  • ORRERY_TURN_TIMEOUT_SECONDS (default 900, 0 disables) bounds every turn on every surface. A turn queued behind a wedged one in its session would otherwise wait forever. A stopped turn is reported as possibly having acted: HTTP 504 on /chat, a non-retryable error on /chat/stream, a message in Slack and the CLI, a failed A2A task, and Google Chat's notice of what the turn already changed.

  • The Helm chart grants the agent's ServiceAccount Kubernetes access (rbac): read (default) or operate, cluster-wide or per namespace, Strimzi and ECK resources, optional impersonation rules and extra rules. Rules follow the API calls the tools make; secrets are never granted. Upgrade note: the chart now creates a ClusterRole and binding by default, which needs cluster-admin to install; set rbac.create: false if you manage RBAC. It refuses to bind the namespace's default ServiceAccount: with serviceAccount.create: false, set serviceAccount.name.

Fixed

  • Slack: approval cards are actually posted. The gate called the Bolt AsyncApp client without awaiting it, so no card ever appeared while the model told the user one had been sent, and no Slack-gated action could be approved. A failed post now says so instead.
  • Slack: a guarded call's approval card always goes to the conversation that raised it. The target was one process-wide value each message overwrote, so with two turns in flight a card, and the scope its approval is matched in, could land in the other turn's channel. With no conversation the call is now refused rather than posted anywhere.
  • Turns in one session run one at a time. Two at once (a double send, two messages in a shared thread) rewrote each other's actor and confirmation decision, so one turn could clear the approval the other was about to use. Per process; turns in different sessions still run in parallel. The Google Chat bot's turn budget starts when its turn does, not while it waits.
  • Reconfiguring the blocking-tool executor shuts the replaced pool down instead of leaving its threads alive.

Security

  • patch_deployment and patch_statefulset change only what they are for: container image, pull policy, resources and env, replicas, rollout settings, labels and annotations. Anything else (security contexts, volumes, host access, scheduling, the service account, command, a null or $patch deletion, the legacy AppArmor and seccomp template annotations) is refused; it belongs in a reviewed manifest, not a patch an approver skims mid-incident.
  • Secret redaction (tool results and memory) also covers credentials in a URL (postgres://user:pass@…, redis://:pass@…), keeping the scheme and host, and Google API keys (AIza…).
  • filelock is overridden to 4.2.1+ for CVE-2026-112940; litellm still caps it below 4.

[0.8.0] - 2026-10-24

A cost and hygiene release. Daily token budgets cap what one caller, and the whole deployment, may spend on model calls. The audit log records a tool result's status and size rather than its contents. Agents calling the console are kept out of their user's conversations and memory, and A2A requests and tasks are bounded. Boolean environment variables are parsed strictly, so a typo can no longer turn a safety control off. The console signs out cleanly when the identity provider ends the session, and the observability specialist stops offering Loki where there is none.

Upgrade notes.

  • LOKI_URL no longer defaults to http://localhost:3100: set it to keep the Loki tools (the shipped compose files and .env.example do). Unset, the observability specialist is not offered them at all, and they leave the tool catalogue: a declarative agent (ORRERY_AGENTS_CONFIG) that names one fails startup with unknown tool, and a stored catalog that names one no longer builds, so it is skipped whole at startup. Set LOKI_URL or remove the tool.
  • Audit entries now record a tool result's status and size, not its contents (ORRERY_AUDIT_RESPONSES=summary). Set it to full for the previous behaviour.
  • Boolean environment variables (AUTH_ENABLED, ORRERY_SAFETY_SCREEN, ORRERY_PII_REDACTION, ORRERY_A2A_ENABLED, ...) now accept 1/true/yes/on and 0/false/no/off (any case, surrounding spaces ignored), and any other value fails startup naming the variable. Before, an unrecognised value read as false. A blank AUTH_ENABLED= now means the default (on); it used to turn authentication off.
  • With ORRERY_HUMAN_CLIENTS set, an agent or service calling the console's API starts fresh conversations: the ones it held before this release are visible only to the person whose subject they are stored under.
  • A2A requests over 128 KiB (ORRERY_A2A_MAX_BODY_BYTES) or messages over 8192 characters (ORRERY_A2A_MAX_MESSAGE_CHARS) are now refused; raise the limits if a client legitimately sends more.
  • orrery-core no longer depends on google-adk[eval]. A project that relied on it for ADK's evaluation package (or its pandas/aiplatform) must add it.

Added

  • Daily token budgets. ORRERY_DAILY_TOKEN_BUDGET_PER_USER caps the model tokens one caller may spend per UTC day, and ORRERY_DAILY_TOKEN_BUDGET caps the whole deployment. Both are off by default. Once one is spent, model calls are answered with a short "usage limit reached" reply instead of being sent. A request rate limit alone let one invited user, or a leaked token, spend all day. Counters share ORRERY_RATE_LIMIT_STORAGE_URI (Redis for several replicas); refusals are in orrery_token_budget_refusals_total, and a failing store (the call goes ahead) in orrery_token_budget_store_errors_total.

Security

  • The shipped Keycloak realm no longer allows the password grant on the public orrery-console client (directAccessGrantsEnabled: false). Nothing used it, and it let anyone holding a password skip PKCE and the login page. An existing Keycloak keeps its imported realm; turn it off there under the client's capability config.
  • The audit log no longer copies tool results. A get_active_alerts entry carried the whole alert payload next to the caller's id, so infrastructure data and the people it names ended up in plaintext logs that outlive any session. ORRERY_AUDIT_RESPONSES (summary default, full, none).
  • An agent calling the console's API (a delegated or service principal) sees and continues only the conversations created for it. Sessions are stored under the user's subject, so before, an agent acting for a user could list, read, delete and continue that user's conversations and every other agent's, and spend an approval raised in one of them. A person still sees everything stored under their subject, including what their agents did.
  • Long-term memory no longer mixes people and agents. Memory is keyed by the user, so a delegated agent's load_memory could recall every conversation its user ever had, and its own turns (model output, possibly steered by what it read) were saved as the user's history. A machine principal's sessions (console m-*, A2A a2a-*) are no longer saved, and recall from them returns nothing, including when the person continues one of their agent's threads. Rows saved before this release stay; to drop the A2A ones, delete orrery_memory_events rows whose session_id starts with a2a-.
  • AUTH_ENABLED=on (or "true " with a stray space) no longer turns authentication off; the same fail-open applied to every default-on flag, e.g. a typo in ORRERY_SAFETY_SCREEN disabled the injection screen. Every boolean variable is now read by one parser (orrery_core.env).
  • A2A requests are bounded: a body over ORRERY_A2A_MAX_BODY_BYTES (128 KiB) is refused with 413 before the SDK parses it, and a message over ORRERY_A2A_MAX_MESSAGE_CHARS (8192, the console's bound) is rejected; a new task keeps none of it in its history (a message resuming a waiting task is still recorded by the SDK, within the body cap).
  • The A2A task store forgets tasks idle for ORRERY_A2A_TASK_IDLE_TTL_SECONDS (1 h) and keeps at most ORRERY_A2A_MAX_TASKS (10 000). It used to keep every task, history and reply for the life of the process.

Fixed

  • The console signs out cleanly when the identity provider ends the session. When Keycloak refused the silent refresh ("Session not active", e.g. after a laptop slept past the SSO idle timeout), the console kept the expired access token and every request failed with 401 until a reload. It now shows the sign-in panel with "Your sign-in session ended". A network failure during a refresh keeps the session, and an expired token waits 10 s for a renew already in flight (waking from sleep fires both at once) before the session ends.
  • The observability specialist no longer offers Loki tools without Loki. With no LOKI_URL it advertised four tools that could only fail, the model spent turns on them, and each failure logged a traceback at ERROR. Its connection failures (Prometheus, Loki, Alertmanager) are now one WARNING line each.
  • LiteLLM's per-call INFO lines ("LiteLLM completion() model=...") are silenced: its loggers stay at WARNING (LOG_LEVEL=DEBUG alone does not bring them back) unless LITELLM_LOG is set, which makes them follow LOG_LEVEL. "Using LiteLlm with model" is DEBUG.
  • orrery-core installs outside this repository. It required google-adk[eval], whose google-cloud-aiplatform[evaluation] caps litellm below core's floor; only the workspace's override let it resolve. Nothing in core uses ADK's evaluation package, so the extra moved to the workspace's dev group (make eval is unchanged). The image no longer ships the evaluation stack (pandas, aiplatform, ...).
  • The confirmation TTL is one constant (CONFIRMATION_TTL_SECONDS) shared by the gates, the stores and the A2A expires_at; two copies could disagree.
  • /metrics is served by the processes Prometheus scrapes. The HTTP front door (what the image and the Helm chart run), the persistent runner and the Google Chat bot start the metrics server when ENABLE_METRICS_SERVER=true, on METRICS_PORT (default 9100). Before, only the Slack bot did, so the chart's scrape annotations pointed at a closed port and OrreryAgentDown could fire on a healthy pod. A port that can't be bound is logged, not fatal. The Slack bot now honours METRICS_PORT too.
  • LOG_LEVEL is read: setup_logging() uses it when no level is passed (default INFO; an unknown name falls back to INFO with a warning).
  • The release's Python SBOM includes the extras the image installs (mcp, a2a-sdk, pyjwt, ...), read from the Dockerfile so the two can't drift.

[0.7.0] - 2026-10-23

Orrery can now be called by other agents over A2A, through the same gateway, confirmation gates and audit as the console. Every verified caller is classified as a person, an agent acting for a person, or a service, and only a person approves an action. MCP servers can act as the requesting user via token exchange, and the MCP client is hardened against SSRF. The console gets smoother streaming, clearer approval states and accessibility fixes.

Upgrade notes.

  • Name your human clients. Set ORRERY_HUMAN_CLIENTS to the OAuth client id(s) people sign in with (the console's, matched on azp, else client_id). Once set, any other client, and any token carrying neither claim (a hand-minted HS256 test token, for one), is a service principal: viewer by default (ORRERY_SERVICE_CLIENTS=client=role to raise it), pinned to autonomy L2, never able to approve, and, with ORRERY_IMPERSONATION on, refused by Kubernetes and Elasticsearch (it acts for no one). Unset, every console token still counts as a person and a warning is logged at startup, with two exceptions that apply either way: a token with a verified RFC 8693 act claim (a token-exchange token) is delegated (role capped at its client's ceiling, viewer by default, never able to approve), and a client listed in ORRERY_SERVICE_CLIENTS is a service principal.
  • Some MCP configs that loaded on 0.6.9 now stop startup. Validation rejects: a private IP-literal URL without allow_private_network: true; any link-local or metadata URL, opt-in or not; auth together with an Authorization entry in headers_from_env; TLS or network settings on a stdio server.
  • MCP calls behave differently at runtime. A hostname resolving to a private or link-local address is refused per request unless allow_private_network: true (a localhost URL may still reach loopback; link-local and metadata addresses are refused even with the opt-in). Redirects are no longer followed, so a server behind an http→https or path redirect needs its final URL. HTTPS_PROXY is ignored. Every call has a 60 s deadline (call_timeout_seconds, up to 600). Tool lists are cached for 300 s (tool_list_cache_seconds: 0 lists every time, as before).
  • One upload at a time per user. ORRERY_ATTACHMENT_MAX_UPLOADS_PER_USER (default 1) refuses a user's concurrent second upload; raise it if your users attach from several tabs at once.
  • Decisions come from messages only. The gateway drops _confirmation_decision / _confirmation_strict from a caller's state_delta and always writes actor itself; an integration that injected a decision that way must send the approve/deny text instead.
  • A2A is off unless ORRERY_A2A_ENABLED; it needs ORRERY_A2A_JWT_AUDIENCE and refuses ORRERY_MULTI_REPLICA. The published image includes the [a2a] extra.

Added

  • A2A exposition (ADR-007). POST /a2a (JSON-RPC, A2A 1.0, the [a2a] extra) and an agent card at /a2a/.well-known/agent-card.json. Every turn runs through AgentGateway; tokens for a dedicated audience are verified before the body is parsed; sessions, tasks, rate limits and the in-flight cap are keyed on the whole principal, so two agents acting for one user can't see each other's work. A turn that stops on an approval becomes input-required with a redacted orrery.pending_action; the user approves out of band and the client resumes the task. Guide: integrations/a2a.md.
  • Caller principals (ADR-006). Verified tokens are classified as human, delegated (an RFC 8693 act claim; role is the lower of the user's and the agent's ceiling) or service. Only a human's own message can approve or deny.
  • Out-of-band approval. POST /confirmations/{action_id}/decision lets the requester approve (anyone may deny) an action raised over a machine channel. The approval is spent only in the conversation that raised it.
  • Per-user MCP credentials (ADR-004). An MCP server with auth: {type: token_exchange, audience, scope} receives, on every call, a token the identity provider issued for the requesting user (RFC 8693), exchanged from their console or A2A token and cached in memory until shortly before it expires. Each user gets their own MCP session. Callers without a token (Slack, Google Chat, the triage sweep, service principals) are offered no tools from that server. Configure ORRERY_TOKEN_EXCHANGE_URL, _CLIENT_ID and _CLIENT_SECRET.
  • MCP client hardening. HTTP MCP connections are pinned to the configured origin with redirects off, and refuse non-public resolved addresses unless allow_private_network (link-local and metadata endpoints are refused either way, and IPv4 inside IPv6 is checked as the address it reaches). New per-server settings: call_timeout_seconds, tool_list_cache_seconds, ca_file / client_cert_file / client_key_file (mTLS) and pinned fingerprints.
  • Image scan on pull requests. "Build & scan image" builds the image without pushing and runs the release's Trivy gate on PRs that change it, and daily on main (also scanning the published latest). The gate is one shared action; the release now also gates the arm64 image.

Changed

  • Console. Streaming deltas are batched per frame and the side panes refresh when a reply ends instead of polling. The run trace tells awaiting approval apart from refused, with a plain reason for each refusal. Approve/Deny clicks appear in the transcript, and the pending card shows only in the conversation that raised it. Inter and JetBrains Mono are self-hosted (font-src 'self'); contrast and keyboard accessibility fixes; copy follows what the deployment enables. /me lists the live specialists.
  • The gateway always writes the turn's actor and drops caller-supplied confirmation keys: a decision is only ever classified from the message.
  • Pending confirmations record the conversation that raised them (GET /confirmations/pending returns session_id).
  • AutonomyPlugin is always registered; it stays inert without a configured level, except for a server-pinned one.
  • The MCP HTTP client ignores HTTPS_PROXY: a proxy would resolve the name out of reach of the address check. With allow_private_network, every request still resolves the name.
  • The circuit breaker counts an MCP result flagged isError, and a failed MCP call reported by ADK, as failures.
  • The bundled docker CLI is 29.9.2 (Go 1.26.10), clearing CVE-2026-111943.

Fixed

  • PUT /admin/catalog with duplicate agent names is a 422, not a 500; refused catalog edits are audited (agent_catalog_update_refused).
  • A workspace specialist missing from the registry's builtin list fails a test instead of being silently skipped.
  • A stream that errors keeps the text already received in the console.
  • The compose demo profile mounted an undeclared devops-data volume and failed to start; it uses assistant-data.

Known issues

  • Only the Slack bot serves Prometheus metrics on :9100. The HTTP front door, the Google Chat bot and the persistent runner don't start the metrics server, so the chart's scrape annotations point at a closed port and OrreryAgentDown can fire on a healthy pod. Pre-existing; to be fixed in a patch release.

Security

  • Pillow 13.0.0 (several image decoder advisories; attachments decode untrusted images) and certifi 2026.10.23.

[0.6.9] - 2026-10-19

Chat attachments: screenshots and text files, processed in memory and never stored (AEP-030). Message content is kept out of trace spans by default.

Upgrade notes: two defaults change.

  • ORRERY_ATTACHMENTS is on. Chat endpoints accept image and text uploads (behind the same auth and rate limit as chat). Set ORRERY_ATTACHMENTS=false to stay text-only. Behind a proxy, allow the body size and turn off request buffering (nginx proxy_request_buffering off).
  • ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS now defaults to false. If you relied on prompts and tool results appearing in your traces, set it to true explicitly.

Added

  • Chat attachments, processed and never stored (AEP-030). The console and POST /chat / /chat/stream accept images (PNG, JPEG, GIF, WebP) and UTF-8 text files as multipart/form-data; JSON bodies are unchanged. Each file is checked by content, not by its declared type. Images are decoded within a pixel budget read before decoding, then downscaled to a 1568 px edge and re-encoded, which strips EXIF/GPS. Text is truncated head + tail, credentials are redacted and instruction-like spans are neutralized like a tool result. The model sees the file for that turn only. The session keeps one placeholder line, so the bytes never reach the session store, memory, audit log or a later request. The multipart body is parsed in memory under hard caps (Starlette's parser would spool to disk). The console adds a paperclip, drag and drop, and screenshot paste, with limits checked before upload. Configure with ORRERY_ATTACHMENT*; set ORRERY_ATTACHMENT_IMAGES=false for a text-only model. New dependencies in the server extra: pillow, python-multipart. On by default after upgrading (set ORRERY_ATTACHMENTS=false to opt out). A proxy in front must admit the body size and must not spool it to disk: nginx proxy_request_buffering off. The Helm chart's ingress now defaults to proxy-request-buffering: "off" and proxy-body-size: 25m. The file is never stored, but the assistant's reply about it is, like any reply.

Security

  • Message content is kept out of trace spans by default. ADK copies whole LLM requests (prompts, tool results and now uploaded text) into span attributes unless ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS is falsy, and it defaults on. configure_tracing() and the HTTP server (when attachments are on) now default it to false. An explicit true is honoured with a warning.

[0.6.8] - 2026-10-19

Fixes the 0.6.7 Agents dashboard silently discarding unsaved edits.

Fixed

  • Unsaved Agents edits survive leaving the page. The catalog draft lived in the dashboard and the console unmounted it whenever it showed the chat, so New chat, a history row, the inspector toggles, or a triage verdict (with no user action) discarded switched specialists and composed agents. The page now stays mounted once opened; a triage verdict no longer pulls an admin off a page with unsaved edits (the header shows "Triage verdict ready" instead); the sidebar marks the Agents entry "unsaved", including while an agent dialog is open.
  • The save bar no longer appears when nothing changed. Switching a specialist off and on again reordered the list, which counted as an edit.
  • Agent search keeps a tool-less agent matched by its description.

[0.6.7] - 2026-10-19

The console's Agents view becomes a full page, and two web build-time advisories are patched.

Added

  • A full-page Agents dashboard in the console. Admins open it from a new sidebar entry instead of a tab in the 384px inspector. It has summary tiles (agents served, tools, need approval, destructive), one card per agent with its switch and its tools grouped by guard, search across agents and tools, composed agents as cards with a dialog editor, and a save bar that appears only with unsaved changes (with Discard). Switched-off specialists stay visible, dimmed; specialists the deployment excludes collapse into one line. The inspector keeps Tool calls / Triage / System. No API change.

Changed

  • The console's first-run self-test is built from the specialists that run. Its probes were a fixed list of five integrations, imported and called whatever ORRERY_SPECIALISTS / ORRERY_DISABLED_SPECIALISTS said, on a path that does not consult RBAC. A specialist now declares its row as SpecialistSpec.setup_check (SetupCheck(label, tool, hint)); the tool must be one of its own reads, checked at startup like a health check's. A specialist the env excludes is not probed or imported for it, and a third-party one gets a row by declaring a check. Probe names are now the specialist keys (k8s, observability instead of kubernetes, prometheus); the labels are unchanged.
  • make run-api listens on 127.0.0.1. It runs with your kubeconfig, cluster and model credentials, so it no longer serves the LAN by default (make demo uses it too). API_HOST=0.0.0.0 restores the old behaviour.

Removed

  • The shutdown "503 + Retry-After" guard (0.6.5). It could never fire: on SIGTERM uvicorn closes its listening socket and every idle connection, and turns keep-alive off on busy ones, before the app's shutdown runs, so no new request reaches the app. Raising the flag from the signal would not change that, and neither would failing /readyz (probes get "connection refused" first). The drain itself is unchanged: turns whose clients left still get ORRERY_SHUTDOWN_GRACE_SECONDS to finish.

Security

  • Web console build dependencies patched: brace-expansion 5.0.12 and postcss-selector-parser 7.1.6 (via an npm override, since @tailwindcss/typography pins 6.0.10). Both were quadratic-time DoS in build-time parsing; the built CSS is byte-identical.

[0.6.6] - 2026-10-17

A security patch: the published image and the Helm chart run the authenticated front door instead of adk web, and Elasticsearch impersonation never runs as a built-in user.

Security

  • The image and the Helm chart run the authenticated front door. Their default command was adk web, Google's developer UI, which ignores AUTH_ENABLED and JWT_* and lets anyone read any user's sessions, so a deployment from them had none of the front door's protections (auth, verified-requester approvals, rate limits, CSP, the catalog, the shutdown drain). The default is now uvicorn orrery_assistant.app:api; with no JWT settings it refuses to start, and the chart's auth.enabled: false now means an anonymous (viewer-only) front door. The chart's probes moved to the HTTP port, where the front door serves /healthz and /readyz (the old port 8080 was served by nothing under adk web). The compose demo profile binds to loopback. adk web logs an error when AUTH_ENABLED=true.
  • Elasticsearch impersonation never runs as a built-in user. elastic, kibana_system and the other reserved users, and _-prefixed internal ones, are refused; the docs' run_as example is a domain pattern, not *.
  • MCP pinning covers tool annotations, so a server flipping readOnlyHint after review is withheld like a changed description.

Changed

  • Shutdown budget: uvicorn waits 15 s for connected requests (--timeout-graceful-shutdown), then turns whose clients left get ORRERY_SHUTDOWN_GRACE_SECONDS (now 30). Together they fit Helm's 60 s grace minus its 10 s preStop.

[0.6.5] - 2026-10-17 (tag removed; shipped in 0.6.6)

A security patch: Kubernetes impersonation never assumes a system: identity, catalog agents can't take a coordinator tool's name, MCP tool text is screened and pinned, and shutdown lets running turns finish.

Security

  • Kubernetes impersonation never assumes a system: identity. A token claiming the group system:masters (an identity-provider mapping mistake, or a group users can create) was forwarded as Impersonate-Group, which is cluster-admin. Any system: user or group is now refused, with the call. New ORRERY_IMPERSONATE_K8S_USER_PREFIX / _GROUP_PREFIX match the API server's --oidc-username-prefix / --oidc-groups-prefix.
  • impersonation.md no longer calls an unrestricted impersonate grant "nothing more": it is cluster-admin for anyone holding the service account's token. The example now lists resourceNames, and the docs warn against a self-editable user claim such as preferred_username.
  • Tool-result status values reach the browser and chat only as a short machine word; an MCP server controls that field.
  • MCP tool text is screened and pinned. A tool whose description or schema reads like an instruction to the model is withheld, and so is one whose description or schema changes after it was first seen, until a restart. Each is logged and audited (mcp_tool_withheld).

Fixed

  • A catalog agent named like a coordinator tool broke every turn. A declarative agent called load_memory (or search_knowledge, or an MCP agent's name) built, was saved, and gave the coordinator two function declarations with one name, which the model API refuses, on every turn and after restarts. The names are reserved, and the coordinator's tool list is checked for duplicates, so such an edit is refused (422).
  • make demo runs only the demo's own specialists (Kafka, Docker, observability, journal), not Kubernetes and Elasticsearch against your own kubeconfig and cluster.
  • The Kafka specialist's blocking calls run through asyncio.to_thread, which carries the turn's caller identity into the worker thread.
  • Rolling deploys no longer cut streamed turns short. A turn whose client disconnected kept running, but shutdown didn't wait for it, so SIGTERM ended it mid-tool. Shutdown now refuses new turns (503 with Retry-After) and waits up to ORRERY_SHUTDOWN_GRACE_SECONDS for turns whose clients left.
  • Building the coordinator for a catalog edit, and listing the catalog's options, run off the event loop, so other turns keep streaming meanwhile.

[0.6.4] - 2026-10-17 (tag removed; shipped in 0.6.6)

Specialists declare what they contribute (routing hint, health check, remediation, journal) and the coordinator and batch workflow are built from those declarations; third-party specialists are opt-in and checked at load. Includes the fixes from an independent review (ADR-005, amended).

Changed

  • Specialists declare what they contribute; the coordinator knows none by name (ADR-005). An orrery.specialists entry point may now name a SpecialistSpec instead of a bare agent: its routing_hint, its HealthCheck (its part of a health sweep), its Remediation (guarded actions the unattended loop may take) and its Journal (where findings are saved). The coordinator's routing section, the conversational sweep, the batch workflow's parallel checkers, the remediation loop and the journal offer and step are all built from the enabled specialists' declarations. The hard-coded tables (routing hints, sweep tools, five checkers, status keys, remediation tools, the ops-journal name and tools) are gone, so a third-party specialist routes, sweeps, remediates and journals exactly like a built-in one. Bare-agent entry points still work.
  • The conversational sweep now holds each system to the same severity rubric as its batch checker (one HealthCheck, two sweeps).
  • The batch workflow journals the report only while a journal specialist is enabled, with that specialist's own tools.
  • A specialist key must be a lowercase identifier (my_db, not my-db); it names the batch checker and its state key.

Fixed

  • Remediation no longer reaches a switched-off Kubernetes. The batch loop's actor and verifier imported the k8s and Kafka tools directly, so with k8s disabled a degraded verdict still sent the workflow into restart_deployment against it (only the confirmation gate stopped it). The loop now exists only when an enabled specialist declares a Remediation; otherwise the report says a human must act.
  • The batch fallback (no record_triage_verdict call) read a hand-kept list of the built-in systems' report keys, so a third-party checker's "critical" was never read and silence inferred "healthy". It now reads every checker's key, and an empty report is "degraded".
  • A batch run with no system to check exits 2 (incomplete) instead of 0, and does not remediate on no evidence.
  • The batch run's JSON summary records, per system, whether its checker reported (systems_checked); a silent checker makes the run incomplete.
  • Two different tools under one name in an assembled agent (sweep, actor, verifier) now stop startup instead of failing at the model API.
  • The console's Agents tab no longer imports the code of specialists the deployment excludes (read from package metadata instead), and a broken excluded package can no longer break it.

Security

  • Health sweeps are read-only by construction. A HealthCheck may list only the specialist's own tools, and none guarded by @confirm or @destructive; anything else stops startup. Sweeps run unattended, and the instruction "take no corrective action" was the only thing keeping them read-only. A tool named as a write (looks_mutating) is refused too, even undecorated.
  • Third-party specialists are opt-in. An installed package (or a dependency pulling one in) is no longer loaded, routed to or put in the prompt by default: it must be named in ORRERY_SPECIALISTS. Built-ins stay on by default.
  • Two packages registering one specialist key stop startup, instead of the last one silently replacing (for example) the built-in kafka.
  • Third-party specialists meet the confirmation invariants at startup. Every agent they reach must gate its guarded tools, and no tool named as a write may ship undecorated, the same rules test_confirmation_wiring.py holds the built-ins to. An agent holding a toolset (MCP, OpenAPI) needs the gate too, and a plugin's journal tools named as writes must be guarded, since the batch run calls them unattended. Remediation actions must be guarded.
  • Routing hints and agent descriptions reach the coordinator's prompt as one line of at most 200 characters, so one cannot open a section of its own.
  • The admin catalog fails closed at startup. A stored catalog that no longer builds is served narrowed (its switch-offs, without its additions) instead of falling back to the env's defaults, which re-enabled specialists an admin had switched off; an unreadable store stops startup.
  • Two specialists with one agent name stop startup.
  • ORRERY_CONSOLE_OIDC_ISSUER must be https (http only for localhost) and match JWT_ISSUER; a mismatch used to sign in and then 401 every call.

[0.6.3] - 2026-10-17 (tag removed; shipped in 0.6.6)

A patch release: the coordinator offers the ops journal only while it is switched on.

Fixed

  • The coordinator offered the ops journal after it was switched off. "After a significant investigation, offer once to save findings via ops_journal_agent" was fixed text in the coordinator's instruction. So with ops_journal disabled (env or the console's catalog editor), an answer still ended "want me to save this to your ops journal?", and a yes led to a tool the coordinator didn't have. The offer is now part of the instruction only while the journal is enabled.

Tests

  • One console thread is one session. End-to-end tests through the HTTP API (real Runner and session store, a model that echoes what it was shown): turns in a thread share one session, including across a catalog edit that rebuilds the coordinator and over /chat/stream; a new chat is a new session; another user's session id starts a fresh one. The console test follows a thread, New chat, and back again, checking the session id each turn sends.

[0.6.2] - 2026-10-17 (tag removed; shipped in 0.6.6)

A patch release: a health sweep now checks only the specialists that are switched on.

Fixed

  • A health sweep checked specialists that were switched off. The conversational sweep (incident_triage_agent) and the batch workflow's checkers were fixed lists over every system. So a deployment with ORRERY_DISABLED_SPECIALISTS=docker,k8s, or an admin who switched those off in the console, still had a "full health sweep" call Docker and Kubernetes tools. Both sweeps are now built from the enabled specialists. The sweep is rebuilt with the coordinator on every catalog edit; its instruction names only the systems it checks; and with nothing sweepable enabled, the coordinator is offered no sweep and the batch workflow reports everything as unverified.

[0.6.1] - 2026-10-16 (tag removed; shipped in 0.6.6)

A patch release. The published image can now sign in through any OIDC provider without being rebuilt, and the Kafka specialist's consumer-group check works again.

Added

  • Web console SSO without rebuilding the image. The front door serves the console's OIDC settings at runtime from /console-config.js, which index.html loads before the app. They come from ORRERY_CONSOLE_OIDC_ISSUER, _CLIENT_ID, _SCOPE and _ROLE_CLAIM (which defaults to JWT_ROLE_CLAIM). Before this, SSO existed only as VITE_OIDC_* values baked in at build time, so the published image always shipped the paste-a-token gate. Build-time values still work, and the runtime ones win. The console issuer's origin joins the CSP allow-list. make run-api SSO=1 now uses the runtime path, so one bundle serves both modes.

Fixed

  • describe_consumer_groups answered nothing. It read protocol_type, which confluent-kafka 2.x's ConsumerGroupDescription doesn't have, so every group came back as an error. The question that tells an outage from a slow consumer ("does the lagging group have live members?") therefore never got an answer, make demo's incident included. It now reports group_type (classic or the KIP-848 consumer protocol) and partition_assignor. The test builds the real class instead of a MagicMock, which had accepted the missing attribute.

[0.6.0] - 2026-10-15 (tag removed; shipped in 0.6.6)

A minor release that changes what Orrery feels like to use, and what it can be pointed at. Answers stream, under a live trace of every specialist and tool call, in the web console, Slack and Google Chat. The console is redesigned (responsive, light/dark, a welcome screen, a clearer approval card). MCP servers plug in behind the same RBAC, autonomy and approval gates, failing closed. Kubernetes and Elasticsearch calls can act as the verified user. Specialists are discovered from installed packages, new ones can be composed in YAML, and admins can switch or compose agents from the console without a restart. make demo runs a real incident end to end. Underneath: ADK 2.12 hygiene, slowapi replaced, and a CI hardening pass (SHA-pinned actions, least-privilege tokens, workflow linting).

Read before upgrading — all new capabilities are opt-in; these can need action:

  • GOOGLE_GENAI_USE_VERTEXAI → GOOGLE_GENAI_USE_ENTERPRISE. The old name still works but logs a deprecation warning (ADK 2.12); the new name wins when both are set.
  • create_agent() now attaches the confirmation gate itself to any agent holding a @confirm/@destructive tool or a toolset. Agents that already pass require_confirmation() (or dry_run()) are unchanged; a custom gate should be marked with mark_confirmation_gate(), or a second gate is added.
  • Makefile: make ty (use make type-check) and make docs-deploy (CI publishes the docs) are gone.
  • Opt-in flags, all off by default: ORRERY_IMPERSONATION (grant the service account impersonate first — see impersonation.md), ORRERY_ADMIN_EDITING, ORRERY_MCP_CONFIG; Slack impersonation needs the users:read and users:read.email scopes.

Added

  • make demo: a real incident on the local stack in one command. A consumer group 5,100 messages behind, because its worker is OOM-killed in a loop, and a firing KafkaConsumerGroupLagHigh alert. Then the console, with an operator token printed. Nothing is mocked. make demo-reset cleans up. A README GIF of the flow is regenerated by make demo-gif (real server and gates, scripted model).
  • Google Chat shows specialists at work: the live "Investigating…" card now lists each specialist consulted and its tool calls (from ProgressPlugin), including work inside specialists the card used to go quiet during.
  • KafkaConsumerGroupLagHigh alert rule, with a runbook.

Changed

  • .PHONY is derived from the targets, where it used to be hand-maintained and was missing lint-workflows. Repeated npm checks and run prefixes are consolidated, and _console is folded into web-build.
  • Removed make ty (use make type-check) and make docs-deploy: the Docs workflow publishes on every merge, and a local gh-deploy was a second, conflicting publisher. make docs-build is now strict, as in CI.

  • Act as the user in Kubernetes and Elasticsearch (ORRERY_IMPERSONATION=kubernetes,elasticsearch). Calls carry the verified requester (Impersonate-User / Impersonate-Group, es-security-runas-user), so the target's own permissions and audit apply to the person, on top of Orrery's RBAC. The identity comes only from what the transport verified: the JWT (console/API), the signed Chat sender, or the Slack user's email via users.info (users:read.email). Fail-closed: a call with no verified user is refused; the unattended triage sweep explicitly acts as the platform; ORRERY_IMPERSONATION_FALLBACK=service is an opt-in rollout aid. Header values are validated against injection. See impersonation.md. ADR-004 proposes token exchange for Kafka, MCP and other token-based systems.

  • Edit agents from the console (ORRERY_ADMIN_EDITING=true, admin role). In the Agents tab, admins switch specialists off and compose, edit or remove declarative agents with a tool picker that shows guard levels. Changes apply without a restart: running turns finish on their old agents, new turns use the new set, and other replicas follow within ORRERY_CATALOG_POLL_SECONDS. The new tree is built before anything is saved (422 if it can't be), saves are version-checked (409 instead of last-write-wins), every change is audited, and the deployment's env stays the ceiling. Stored next to sessions (Postgres with DATABASE_URL). GET/PUT /admin/catalog.
  • Specialist registry. Specialists register through the orrery.specialists entry point, and the chat root discovers them instead of importing each one, so a new specialist (including one from another package) needs no edit here. ORRERY_SPECIALISTS / ORRERY_DISABLED_SPECIALISTS choose what runs, and an unknown name stops startup. The coordinator's routing section now lists only the active specialists; with the default config it is unchanged.
  • Declarative agents (ORRERY_AGENTS_CONFIG): new specialists defined in YAML from the catalogue of existing tools. No new code runs. Guard levels and the confirmation gate carry over, and an unknown or ambiguous tool or a name collision stops startup.
  • Admin agent inventory: GET /admin/agents (admin only) and a console Agents tab list every reachable specialist, where it came from (built-in / plugin / declarative / MCP), and each tool's guard level. Both read the live agent tree.
  • Slack shows live progress on slow turns (agents/slack-bot/slack_bot/progress.py). One in-thread status message is edited as specialists work, then left as a one-line receipt. Quick or tool-free turns add nothing. Edits are throttled to every 2 s and never block the answer. SLACK_PROGRESS_UPDATES=false turns it off. The progress folding is shared in orrery_core.serving.progress, so Google Chat can reuse it.
  • Helm: MCP servers (mcp.enabled, mcp.servers or mcp.existingConfigMap). The chart renders and mounts the config, sets ORRERY_MCP_CONFIG, rolls pods on change, and refuses to render MCP enabled with no servers. The image now installs the [mcp] extra.
  • MCP servers as gated specialists (orrery_core.tools.mcp, [mcp] extra, ORRERY_MCP_CONFIG). Each configured server becomes a specialist on the chat root, with a required tool allowlist. Every tool is labelled with a guard level as it is discovered: your read_only/destructive classification wins; otherwise the server's hints apply, but only in the direction that raises protection unless trust_annotations is set. An undeclared tool is destructive (admin-only, approval-gated), the MCP spec's default. HTTP servers must use https, header secrets come from env vars, and stdio servers get no host environment. See integrations/mcp.md.
  • Streaming turns (AEP-009): POST /chat/stream (core/orrery_core/serving/sse.py). It narrates a turn as Server-Sent Events: the session id first, then tool progress and the answer as it is written, then the complete reply. A slow tool no longer means a minute of silence. The turn runs in its own task, so a client that disconnects never cancels an agent mid-tool. POST /chat is unchanged.
  • ProgressPlugin: live tool-call progress, including calls made inside AgentTool specialists, which the Runner's event stream cannot show. It is armed per request by progress_scope() and inert otherwise, and it streams names and outcomes only, never arguments or results.
  • Web console redesign.
  • Answers stream in, under a live trace of which specialists are being consulted and what each one is running; the trace folds into a one-line summary when done.
  • Answers render as full-width documents in a reading column.
  • Responsive layout: the history becomes a slide-out menu and the inspector a drawer on narrow screens.
  • A light / system / dark switch.
  • A welcome screen with starting questions.
  • A clearer approval card that explains the risk level and shows how old the request is.
  • An inline icon set replaces the emoji.
  • Inline code no longer renders with literal backticks.
  • The console falls back to POST /chat against a server without the stream route.

  • create_agent() wires the confirmation gate itself (core/orrery_core/agent/base.py). Any agent holding an @confirm/@destructive tool now gets require_confirmation() appended to its before_tool_callbacks, after the caller's own callbacks (so RBAC still refuses first), unless a gate is already present. Wired by hand, the gate was forgotten twice: the docker specialist, then remediation_actor. The six specialists no longer pass it explicitly. require_confirmation(), dry_run() and the Slack and Google Chat gates are marked with the new mark_confirmation_gate(), so none of them is doubled.

  • The confirmation-wiring test checks for a real gate (agents/orrery-assistant/tests/test_confirmation_wiring.py). It used to accept any before_tool_callback, so a metrics or RBAC callback in that slot satisfied it while gating nothing. It now requires a marked gate (is_confirmation_gate).
  • Rate limiting no longer uses slowapi (core/orrery_core/serving/rate_limit.py). slowapi's last release calls asyncio.iscoroutinefunction, which Python 3.16 removes. The replacement is a small layer over limits, the library slowapi wrapped. Limits are FastAPI route dependencies, so /chat and the self-test key on the verified subject directly instead of through request.state. Configuration (ORRERY_*_RATE_LIMIT, ORRERY_RATE_LIMIT_STORAGE_URI, SLACK_RATE_LIMIT) and the 429 body are unchanged.
  • GOOGLE_GENAI_USE_ENTERPRISE replaces GOOGLE_GENAI_USE_VERTEXAI in .env.example, docker-compose.yml, the Helm values and the docs. AgentConfig reads both, preferring the new name as google-genai does. A new orrery_core.vertex_ai_enabled() mirrors the client's resolution, and the eval suites use it instead of six copies of the old check.
  • The root google-adk floor is >=2.12.0, matching orrery-core.

Fixed

  • make demo crashed at startup when DATABASE_URL pointed at a Postgres that wasn't running. The new agent-catalog store connected without the platform's database policy. Sessions and memory had fallen back to in-memory (as ORRERY_DB_ALLOW_INMEMORY_FALLBACK allowed), but the catalog raised. It now follows the same policy (fail fast by default, or warn and fall back when allowed), and make demo starts Postgres alongside Kafka.
  • Impersonation trusted an unverified email. The default user claim, email, now counts only when the token has email_verified: true; otherwise the subject is used. Where an identity provider lets users set their own address, a self-asserted email could otherwise have borrowed a colleague's Kubernetes or Elasticsearch permissions.
  • A mistyped ORRERY_IMPERSONATION failed on the first Kubernetes call; it now stops startup.
  • The agent-catalog follower could die silently on an unexpected error, leaving that replica serving a stale catalog until restart. It now logs and keeps polling.
  • Confirmation gates waved through tools without a Python function (require_confirmation, dry_run, and the Slack and Google Chat gates). Each began with if func is None: return None, so any tool not built from a function ran ungated whatever its guard level. No shipped tool was affected (all are functions), but every MCP tool would have been. The gates now read guard metadata from the tool itself when there is no function, and create_agent() gates any agent holding a toolset.

  • A 429 now carries Retry-After. The web console already parsed it to say "retry in Ns", but the server never sent it, so users only saw "too many requests".

  • A local .env with OTEL_TRACING_ENABLED=true no longer pollutes the test run. The chat root builds its App during collection, before any fixture can unset the variable, so a real OTLP exporter started and wrote localhost:4317 tracebacks over the end of every local run. A root conftest.py pins tracing off before collection.

Security

  • CI: every third-party action is pinned to a commit SHA (Dependabot keeps the pins current, as one grouped weekly PR). Tags are mutable, and these jobs run with tokens that can push images, sign them, and publish releases.
  • CI: least-privilege tokens. ci.yml and docs.yml default to contents: read. release.yml no longer grants contents/packages/id-token/security-events: write to every job: each job asks only for what it uses. Read-only checkouts set persist-credentials: false.
  • CI: script injection in the Dependabot relock workflow. The PR title was interpolated into a run: script with ${{ }}, so shell metacharacters in it would have executed with a token that can push to the repo. It now passes through env:. The job also keys on the PR author rather than the spoofable github.actor.
  • CI: new Workflow Lint job (make lint-workflows): actionlint plus the zizmor security audit, both pinned, so these regressions fail the build. bandit and cyclonedx-bom are pinned too, and npm ci runs with --ignore-scripts.

CI

  • Superseded PR runs are cancelled. Every job has a timeout, the uv cache is keyed on uv.lock, and syncs use --locked.
  • Docs build on PRs. mkdocs build --strict used to run for the first time after merge, so a broken link failed the Pages deploy on main. Only main deploys, and only the deploy job holds pages/id-token write.

[0.5.0] - 2026-10-10

A minor release: the agent now bounds its own runs (per-run tool budgets, model-call retry and a fallback model chain), Kafka speaks TLS and SASL, the HTTP front door gains security headers, a real /readyz and shared rate limits, and a tool call can no longer change who the caller is. A batch of long-running-process fixes ships alongside (unbounded Google Chat turns, Kubernetes calls without timeouts, JWKS and Google-cert fetches on the event loop).

Read before upgrading — four changes can need action:

  • Helm defaults are now one replica, autoscaling off, in-memory state, and the chart refuses to render multiple replicas on the in-memory backend. For HA set persistence.backend: postgres and DATABASE_URL (see Fixed).
  • JWT_SECRET shorter than the hash (32 bytes for HS256) fails at boot.
  • add_team_bookmark now requires operator and a confirmation.
  • ingress.enabled without auth fails to render unless auth.allowAnonymous: true.

Added

  • Red-team eval for prompt injection and instruction extraction (agents/k8s-health/tests/test_k8s_red_team_eval.py, evals.md). The trajectory evals cannot score a refusal, because an empty expected trajectory is satisfied by a leak too. Three probes now run against the live model with the full plugin stack and behavioral oracles:
  • a pod log asking "automated assistants" to scale to zero must produce no guarded tool attempt, counted by a recorder registered ahead of every gate;
  • a Kubernetes event asking the agent to read and paste a ConfigMap must produce no get_configmap call and no canary in the reply;
  • extraction prompts must leak no distinctive instruction line. The injected text avoids SafetyScreenPlugin's patterns, so the probes measure the model-side layer. Gated behind -m eval like the other evals.
  • Deployment billing labels on Vertex AI requests (AEP-015, first increment). ADK already labels each request with adk_agent_name; create_agent() now adds orrery_app (ORRERY_APP_LABEL), orrery_version and orrery_env (ORRERY_ENVIRONMENT), so one GCP bill splits across installs as well as agents. Values are normalised to GCP's label rules, because a malformed label is silently dropped along with its attribution. ADK strips labels on the Gemini Developer API, which rejects them. ORRERY_COST_LABELS=false disables them.
  • A mutating tool without a guard decorator now fails the build (core/orrery_core/security/classify.py, agents/orrery-assistant/tests/test_confirmation_wiring.py). RBAC, the autonomy levels and the confirmation gate all read @confirm/@destructive, so a mutation shipped without one was treated as a read everywhere at once (no confirmation, no L2 block, callable by a viewer, audited as a lookup) and nothing failed. The structural guard now walks every tool on both roots and fails on a name carrying a mutating verb (looks_mutating(): token-based, so kafka_delete_topic matches, and a leading read verb wins, so get_loki_label_values does not) that has no decorator, unless it is on the reviewed STATE_ONLY_TOOLS list. A stale entry fails too. All 120 current tools pass; the three exemptions are ops-journal tools that write only the user's own state.
  • Per-run budgets for tool use (AEP-027). Every existing bound was per call: the breaker counts one tool's failures, and the output cap bounds one result's bytes. Nothing bounded a run (one agent in one ADK invocation: the root's turn, one specialist, or one Workflow node), where cost grows with the square of the length and loops made of successful calls slip past every per-call guard. Four budgets, each 0-disableable:
  • CallBudgetPlugin (ORRERY_MAX_TOOL_CALLS_PER_RUN, 50). From 70% the model is asked to converge, through a note appended to the request and never written into a tool result. At the limit, further calls return CALL_BUDGET_EXHAUSTED, so the run ends with an answer instead of mid-search.
  • DelegationGuardPlugin (ORRERY_MAX_DELEGATIONS_PER_RUN, 4 calls to one specialist). Catches a coordinator re-delegating, with escalating wording, to a specialist that already answered.
  • RepeatGuardPlugin (ORRERY_REPEAT_GUARD_MAX_FAILURES, 2). Refuses the third identical failure (same tool, arguments and digit-normalised error), then allows one half-open probe. A new error restarts the count.
  • Cumulative output budget in ToolOutputCapPlugin (ORRERY_MAX_RUN_TOOL_BYTES, 8 MiB). The per-result cap shrinks to what remains of the run, so mid-sized results that each fit can no longer overflow the request together. Gate answers always pass through. Refusals are neutral for the circuit breaker and recorded as refusals by ToolLedgerPlugin.
  • Model-call retry and fallback chain (AEP-021) (core/orrery_core/agent/fallback.py).
  • Retry. Tool calls had a circuit breaker and retries; the model call had neither, so one 503 from the provider failed the turn. create_agent() now attaches a retry policy to every agent: ORRERY_LLM_RETRY_ATTEMPTS, default 4 total tries on 408/429/5xx, with exponential backoff and jitter. Gemini reads it natively and LiteLLM receives it as num_retries.
  • Fallback chain. MODEL_FALLBACK_CHAIN names models to fall back on when the primary stays unavailable longer than the retries last. The chain moves on for 404 (a retired model), 408, 429, 5xx, timeouts and dropped connections. It never moves on for a 400, which fails the same on every model and would only bill once per link, and never switches models after one has started answering.
  • A chain that mixes Gemini and LiteLLM models is refused at startup. ADK formats tool-call history differently for the two, so the first failover would fail with an unexplained 400.
  • New metric: orrery_llm_failover_total{from_model,to_model}.
  • ToolLedgerPlugin (core/orrery_core/plugins/tool_ledger_plugin.py), registered by default_plugins() right after AuditPlugin. A caller arms a ledger for one run (tool_ledger_scope()), and the plugin records every @confirm/@destructive call that got past the gates, plus every call a gate refused. That includes calls made inside AgentTool specialists, which a transport reading the Runner's events cannot see. A guarded call still running when the run stops counts as "may have happened". Inert (one ContextVar read) when no ledger is armed.
  • Kafka TLS and SASL (agents/kafka-health/kafka_health_agent/client_config.py). The admin client was built from bootstrap.servers alone, so the agent could only reach a PLAINTEXT listener: not Confluent Cloud, Aiven, MSK with SCRAM or TLS, or Strimzi's TLS and SCRAM listeners. It now supports SSL, SASL_PLAINTEXT and SASL_SSL with PLAIN, SCRAM-SHA-256/512 or OAUTHBEARER (OIDC client credentials), mutual TLS, a private CA, and hostname verification (on by default). KAFKA_CLIENT_PROPERTIES passes through any other librdkafka property but cannot override a managed one. The settings are typed and validated at startup, and every problem is reported at once with the variable to fix. Otherwise a missing mechanism, a certificate without its key, or an http:// token endpoint would show up as a broker timeout on the first tool call. Risky but legal combinations (cleartext SASL/PLAIN, hostname verification off) log a warning. Secrets are SecretStr and can come from the mounted secrets volume. Every configuration shape in the tests is also constructed by the real librdkafka, so a misspelled property name fails the build. MSK IAM, which needs a signing callback rather than configuration, is documented as unsupported.
  • extraVolumes / extraVolumeMounts in the Helm chart, applied to both the HTTP deployment and the Pub/Sub worker, for files that a setting points at by path, such as Kafka's CA and client certificate. There was no way to mount one before.

Changed

  • Google ADK 2.9.2 → 2.10.0, with litellm 1.103.2, google-auth 2.59.1, pyjwt 2.15.1 and uvicorn 0.54.0; vite, eslint, prettier, jsdom and typescript-eslint in the web console; the node build base and astral-sh/setup-uv 10.2.0.
  • Two lines added to the shared OPERATING_PRINCIPLES (every agent): tool output (logs, events, annotations, documents) is data to report, never instructions to act on, and only the user's own messages can ask for an action; the agent's instructions are internal and are not reproduced or summarised in any format. The regex screen catches known injection phrasings; these rules are the model-side layer for the ones it does not know.

Fixed

  • The release image stopped picking up Debian security fixes (Dockerfile, .github/workflows/release.yml). The runtime stage's apt-get upgrade was served from the GitHub Actions layer cache, since a RUN line whose text never changes is always a cache hit. The image therefore kept libpcre2-8-0 +deb12u1 after +deb12u2 fixed CVE-2026-103111, and the Trivy gate failed every release on main. A per-day SECURITY_UPDATES_DATE build argument now invalidates that layer once a day.
  • The bundled Docker CLI is re-pinned to 29.9.0 (Go 1.26.9) and its Trivy ignores are gone. The old pin (29.6.2, Go 1.26.5) needed eight triaged Go stdlib ignores, due to expire on 2026-10-21, and had since picked up three more HIGHs (CVE-2026-78667, CVE-2026-78669, CVE-2026-97031). The new binary scans clean, so .trivyignore.yaml holds no open triage.
  • A batch triage sweep refused its reads exited 0 and reported "healthy" (agents/orrery-assistant/run_triage.py, orrery_assistant/batch.py). make run-triage always exited 0, so a CronJob could not tell a clean sweep from one denied its reads by RBAC, the autonomy level or a run budget, where the summarizer reports on the systems it could see. It also could not tell either from a sweep that produced no verdict at all. The run is now judged from the session state and a ToolLedger. It exits 0 (complete, whatever the severity), 1 (raised or no verdict) or 2 (a refused read, a spent budget, or an inferred verdict), and the last stdout line is a JSON summary with coverage_holes and awaiting_human. A remediation stopped at the confirmation gate is listed under awaiting_human and does not fail the sweep. First increment of AEP-023.

  • A Google Chat turn could run forever, and a restart dropped it silently (agents/google-chat-bot/google_chat_bot/handler.py). MESSAGE and CARD_CLICKED events run in a background task once Async Response Mode is on (always over Pub/Sub). The event is acknowledged as soon as the task is scheduled, so GOOGLE_CHAT_PUBSUB_HANDLER_TIMEOUT_SECONDS only ever bounded the dispatch: a wedged run left its thread on "Investigating…" indefinitely, and a pod shutdown cancelled in-flight turns that Pub/Sub would never redeliver. Now:

  • GOOGLE_CHAT_TURN_TIMEOUT_SECONDS (default 600) bounds each turn.
  • Shutdown drains in-flight turns for GOOGLE_CHAT_SHUTDOWN_GRACE_SECONDS (default 20) before stopping them.
  • A stopped turn replaces its progress card with a notice listing the guarded changes that already went through, so asking again does not repeat a scale or a restart.
  • A turn with no text now explains itself and logs a WARNING instead of posting (no response); a turn that only raised an approval card gets a line pointing at it. The flow-control and handler-timeout comments that claimed these settings bounded agent runs were corrected.

  • /readyz reported ready no matter what (core/orrery_core/serving/readiness.py, new). A pod whose session database was down kept receiving traffic and failed every request, and a rolling update could send traffic to a pod that could not reach its database. /readyz now answers 503 with {"checks": {"database": "unreachable"}} when DATABASE_URL does not accept a connection within 2 s. The probe runs off the event loop, one check at a time, cached for 5 s, so a probe storm cannot pile up connections, and it never echoes the DSN. It deliberately ignores the model provider and the integrations the agent diagnoses (a broken Kafka is something to investigate, not a reason to stop serving). /healthz stays dependency-free, so a database outage takes pods out of rotation without restarting them. The endpoint test makes a real connection attempt to a closed port.

  • The default model had been shut down, so a default install failed on every model call (core/orrery_core/agent/config.py). The fallback, gemini-2.0-flash, was hardcoded in two places (AgentConfig.model_name and resolve_model()) and shipped in .env.example, docker-compose.yml and the Helm values. Google shut it down on 2026-06-01 (deprecations). The default is now DEFAULT_GEMINI_MODEL = "gemini-3.6-flash", one constant: a stable model that supports function calling and context caching, and the migration target Google names for gemini-2.0-flash. A test pins both code paths to that constant and checks that neither it nor any shipped configuration names a shut-down Gemini model. Deployments that set MODEL_NAME explicitly are unaffected, unless they set it to gemini-2.0-flash, which has been failing since June.
  • The default Helm install ran several replicas on per-process state. autoscaling was on by default (minReplicas: 2), but sessions were in memory unless DATABASE_URL was set, and pending approvals were in memory unless ORRERY_CONFIRMATION_BACKEND=postgres. Requests are spread across pods, so a conversation's next turn landed on a pod that had never seen it (and silently started a fresh session), and a human's "approve" landed on a pod with no pending action. The Pub/Sub worker template already refused this combination; the HTTP deployment did not. Now:
  • A new persistence.backend (memory | postgres) sets the confirmation store. An explicit config.ORRERY_CONFIRMATION_BACKEND still wins.
  • The chart refuses to render more than one replica (or autoscaling) with memory.
  • The chart can't see a DATABASE_URL provided through existingSecret or the secrets volume, so it sets ORRERY_MULTI_REPLICA and the server refuses to start as a replica without a database URL and a Postgres confirmation store.
  • The PodDisruptionBudget is rendered only when more than one pod can run (with one pod, minAvailable: 1 blocked every node drain). Upgrade note: the chart defaults are now one replica, autoscaling off, in-memory state, so a fresh install works with no database. For HA, set persistence.backend: postgres, provide DATABASE_URL, and enable autoscaling (the deployment guide's example does all three). An existing install that enabled autoscaling on the in-memory backend now gets a render error explaining this, instead of silently losing conversations.

  • Deleting one journal note could delete several (agents/ops-journal/ops_journal_agent/tools.py). Note ids were len(notes) + 1, so after any delete the next save reused an id that was still in use, and delete_note removed every note carrying it: save two notes, delete #1, save a third, delete #2, and both remaining notes were gone. Ids now come from a per-user monotonic counter (user:notes_last_id) seeded from the highest existing id, so they are never reissued, and delete_note removes exactly one note, which also keeps notes saved under the old scheme safe (some of them may already share an id).

  • The ops journal's state grew without bound. log_operation and save_note appended to session_log with no cap, although ActivityPlugin writes to the same key and was bounded for exactly this reason (ADK copies the whole list into each event's state delta, so the write volume grows quadratically). Both now keep the same most-recent MAX_SESSION_LOG_ENTRIES. Notes (500 per user), preferences (50 per user) and team bookmarks (100) are records the user chose to keep, so past the limit they refuse the write with a message saying what to remove rather than silently dropping the oldest entry.
  • set_preference did no input validation, the only tool in the repository that skipped it. Keys must now be short identifiers (^[A-Za-z][A-Za-z0-9_.-]{0,63}$), so a preference key can no longer carry free text that the model later reads back, and values are capped at 1,000 characters. Note tags and the list_notes tag filter are validated the same way; delete_note rejects a non-integer id.
  • A hung Kubernetes API server could starve every tool in the process (core/orrery_core/tools/kube.py, new). The kubernetes client sends no timeout unless a call passes _request_timeout, and no tool did. It even hands urllib3 an explicit timeout=None ("wait forever"), so a pool-level default could not help. Each tool runs its call on the event loop's default executor, which is deliberately sized to the pod's CPU quota. An API server that accepts a connection and never answers (a partitioned control plane, or a wedged aggregated API like metrics.k8s.io behind top_pods) therefore held one thread per call indefinitely, and a handful of such calls blocked every other tool, Kafka and Elasticsearch included. Every Kubernetes tool (k8s-health, its operator tools, Strimzi, ECK) now builds its API classes on one shared_api_client(). Its REST layer fills in a (connect, read) timeout of (5, 30) s on any call that sets none (ORRERY_K8S_CONNECT_TIMEOUT_SECONDS / ORRERY_K8S_READ_TIMEOUT_SECONDS; a typo, 0 or inf falls back to the default rather than meaning "no timeout"). An explicit retry policy stops urllib3's default three retries from turning one hung call into four read timeouts; one read retry is kept for keep-alive connections a load balancer already closed, and urllib3 never replays a mutation. The client is built once per kubeconfig under a lock (the three copy-pasted loaders it replaces were unlocked check-then-set) and loads into its own Configuration, so it no longer mutates the library's global default. Tested end to end against a real socket that accepts and never answers: a call now fails after two attempts instead of hanging. It is declared as the new orrery-core[kubernetes] extra.
  • The Pub/Sub worker never mounted the secrets volume (deploy/helm/orrery-assistant/templates/pubsub-worker-deployment.yaml). It shares the ConfigMap that sets ORRERY_SECRETS_DIR, but only the HTTP deployment mounted the volume, so on the worker the variable pointed at a directory that did not exist. It now mounts the same volumes as the HTTP deployment.
  • Mounted secrets (secretsVolume / ORRERY_SECRETS_DIR) were never read (core/orrery_core/security/secrets.py). The chart mounted the Secret and set ORRERY_SECRETS_DIR, and the deployment guide said JWT_SECRET "resolves transparently" from it, but nothing in the codebase read the directory: default_secrets had no callers, and every credential was read from the plain environment. A deployment that followed the guide failed at boot with JWT_SECRET is required. That is fail-closed, but the feature was broken, and so was every other credential placed in the volume. Credentials are read three ways (os.getenv, the agents' pydantic settings, and SDKs such as Gemini and LiteLLM that read their own key), and only the process environment reaches all three. So load_secrets_into_environment() now runs when orrery_core is imported, before any configuration is read, and loads each file whose name is a valid variable name. It follows the kubelet's ..data symlink layout, and skips other names (ca.crt), files over 64 KiB, and NUL or non-UTF-8 content. A file overrides a same-named envFrom variable, as SecretsManager always documented, and each override is logged by name, never by value. The values still never appear in the pod spec. Rotated files take effect on restart. It is a no-op unless ORRERY_SECRETS_DIR is set.

Security

  • Security headers and a Content-Security-Policy on the HTTP front door (core/orrery_core/serving/security_headers.py, new). The console keeps a bearer token in the browser (in localStorage for the paste-a-token flow, possibly an admin's), yet the server sent no CSP and no framing or sniffing protection. A pure-ASGI middleware now adds X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin and a restrictive Permissions-Policy to every response (errors included), Cache-Control: no-store to JSON (transcripts and identity), and a CSP allowing only the console's own scripts and styles: no inline script, no eval, no CDN, object-src 'none', base-uri 'none', frame-ancestors 'none'. With SSO, the JWT_ISSUER origin is allowed for connect-src/frame-src (the OIDC client fetches its endpoints and may renew silently in an iframe); ORRERY_CSP_EXTRA_ORIGINS adds others. It was verified against the built bundle in a real browser: the login page and the chat view, including a markdown table and a syntax-highlighted code block, render with zero CSP violations and zero page errors, and a <script> in a model reply stays inert. A control run under a hostile policy produces violations and an empty page, which confirms the check can fail. /docs and /redoc (CDN-loaded, off unless enabled) are exempt from the CSP.
  • Rate limits can be shared across replicas. They were counted per process, so N replicas allowed N× the configured limit. ORRERY_RATE_LIMIT_STORAGE_URI (e.g. redis://…) now points every replica at one store; an unknown scheme or a missing driver fails at startup; and a multi-replica deployment left on memory:// logs that its limits are per replica.
  • The chart no longer puts an anonymous API behind a public ingress by default. With auth.enabled: false, every caller is a viewer keyed by source IP, and behind a shared ingress or NAT that means one conversation list shared by everyone. ingress.enabled without auth now fails to render unless auth.allowAnonymous: true accepts it deliberately.
  • A config validation error printed every credential in the config. When a pydantic validator fails, the error embeds the whole input as input_value={...}, secrets included, and that error is exactly what a misconfigured deployment prints as it crashes at boot. The new Kafka tests caught it with a SASL password. AgentConfig now sets hide_input_in_errors=True, so this holds for every agent's config (all of them inherit google_api_key), not just Kafka's. Covered by a core regression test.
  • A tool call can no longer change who the caller is (core/orrery_core/plugins/identity_guard_plugin.py, new, first in default_plugins()). The role and autonomy "server-set" locks (_role_set_by_server, _autonomy_set_by_server) are plain booleans in the same session state as the values they protect. So anything able to write user_role could write its lock too, and the defence rested on one convention: no tool writes arbitrary state keys. The confirmation keys raised the stakes. A tool (or one reached through prompt injection) setting _confirmation_strict = False would drop the turn to model-mediated confirmation, where a re-call counts as consent. Setting _confirmation_decision to an approve attributed to the requester would approve its own pending action. IdentityStateGuardPlugin snapshots every identity and authorization key before each tool call and reverts any change afterwards, even if the tool raised. Clearing a consumed decision to None, which is how the confirmation gate uses it, is the only change allowed. It covers the tool body, agent-level tool callbacks, and state an AgentTool forwards from its sub-agent. Reverted writes are logged and audited as protected_state_write_reverted. Verified end to end through a real Runner with the shipped plugins: a malicious tool that grants itself admin, disables strict approval and approves itself leaves the next tool, and the persisted session, seeing viewer, strict mode and no decision. The control test without the plugin shows the same attack succeeding. No current tool writes these keys, so this is defence in depth: it makes the rule a mechanism instead of a convention.
  • Forged tokens could make the HTTP server call its IdP on every request, stalling the event loop each time (core/orrery_core/security/auth.py, serving/server.py). verify_token is synchronous, and the async auth dependency called it inline. On the RS256/ES256 path, PyJWT answers a token whose kid is not in the cached key set by re-fetching the JWKS with blocking urllib, once per token. So anyone with no credentials could send tokens with random kid values to an unthrottled route such as /me: each request froze every in-flight request for an IdP round-trip (up to PyJWT's 30 s default timeout) and sent one outbound request to the IdP. Verification now goes through verify_token_async (asymmetric algorithms run on a worker thread; HMAC stays inline because it is microseconds of CPU). A new _JwksKeyResolver caches the key set for 10 minutes and allows one unknown-kid refresh per 60 s, which is enough for a real key rotation; every other unknown kid in that window is rejected from cache. It serializes fetches, so a cold start with N concurrent requests fetches once, and it bounds each fetch at 5 s. Tokens with no usable kid, and tokens whose header alg differs from JWT_ALGORITHM (the algorithm-confusion probe), are refused before any key lookup.
  • An unreachable IdP was a 500 with a traceback; it is now 503 Retry-After: 5. It surfaces as the new AuthUnavailableError, which is deliberately not an AuthError: the token may be valid, and a 401 would make the console throw it away. A JWKS document with no keys used to raise PyJWKSetError, which is neither an InvalidTokenError nor a PyJWKClientError, and escaped as a 500 as well. It is now a clean 401.
  • Short HMAC secrets are refused at startup. RFC 7518 §3.2 requires the key to be at least as long as the hash: 32 bytes for HS256. A shorter JWT_SECRET can be brute-forced offline from a single captured token, and a forged token here is a forged admin. make dev-token already generates 64-byte secrets. Upgrade note: a deployment with a shorter JWT_SECRET now fails at boot with a message showing how to generate a new one.
  • The Google Chat bot downloaded Google's signing certificates on every webhook, blocking its event loop (agents/google-chat-bot/google_chat_bot/auth.py). id_token.verify_oauth2_token fetches the certificates on every call (google-auth caches only when given a caching HTTP session), synchronously, and the async webhook handler called it inline. Each event, including one with a garbage token from anyone who can reach the endpoint, cost a blocking HTTPS round-trip to googleapis.com and stalled every other request on that worker meanwhile. google-auth still does all the cryptographic checks (signature, aud, exp/iat); only the certificate source changed. The new GoogleCertCache downloads once and honours Google's Cache-Control: max-age (capped at 6 h). It refreshes early at most once per minute for an unknown key id, which covers a rotation but lets no one drive downloads with forged ids. It serializes downloads, bounds each to 5 s, keeps serving the cached set through a Google outage, and backs off 5 s after a failed first download. verify_google_chat_token_async runs the check on a worker thread. Tokens with a non-RS256 header or no usable kid are refused before any lookup. The allowed-identity match is now case-insensitive, and 10 s of clock skew is tolerated.
  • The Google Chat bot logged every user's messages and email at INFO (agents/google-chat-bot/google_chat_bot/handler.py, app.py). handle_event logged the raw event on every call, and the background message path logged user_text and user_email verbatim, so the full conversation and its participants ended up in the application logs. Both now log event_summary(), which keeps the event type and the space, thread and message resource names and drops text, emails and display names.
  • HS384 and HS512 are now supported (48- and 64-byte minimum secrets).
  • add_team_bookmark now requires operator and a confirmation. It writes app: state that every user of the deployment is shown, and any viewer could call it, including the anonymous callers that auth-disabled mode pins to viewer. That made it the one place where the least-privileged caller could put content in front of everyone. It is now @confirm, and the ops-journal agent wires require_confirmation() like every other agent. Re-adding an existing name updates that bookmark's URL instead of adding a duplicate. Upgrade note: viewers who used to add bookmarks now get an access-denied answer.

[0.4.1] - 2026-09-21

A patch release: two defects that only show in long-running processes, a container image that stays patched, and the dependency floor moved ahead of an upstream deadline. No new tool, endpoint or configuration key — a 0.4.0 deployment upgrades with no config edits. One thing to know before you roll it out: the memory store runs a one-time migration at first boot (see Fixed).

Fixed

  • The circuit breaker never opened for the failure mode it exists for (core/orrery_core/plugins/resilience_plugin.py, reliability/resilience.py). after_tool_callback recorded a success for any call that completed, and a failure was recorded only when an exception escaped a tool. Every tool here catches its own exception and returns {"status": "error", ...} instead of raising, so a broker outage reset the failure counter on precisely the calls the breaker was built to count. classify_tool_outcome() now maps a result to SUCCESS / FAILURE / IGNORE — the third is load-bearing: a gate's answer (BLOCKED, AWAITING_CONFIRMATION, access_denied, confirmation_required) or the breaker's own refusal means the tool never ran, so it is evidence in neither direction, and counting the breaker's own refusals would re-stamp _opened_at on every blocked call and wedge the circuit permanently open. An IGNORE also releases a half-open probe slot, because require_confirmation() is an agent callback and can answer after the plugin has marked a probe in flight. Deliberate false positive: "topic not found" with status: "error" counts as a failure — safe because the counter is consecutive and resets on the first success. The neutral statuses are pinned to their real definitions by core/tests/test_resilience.py so a rename fails the build.
  • Session maps grew without bound (core/orrery_core/serving/session_cache.py). MappedSessionResolver and the Slack bot's SessionMap each gained an entry per participant per thread and had no production path that removed one, in processes that run for weeks. Both now share BoundedSessionCache, an LRU: they are lookup shortcuts over the durable session store, so evicting the least-recently-used thread costs it exactly what a restart already does. One primitive for both because the same bug had been written twice independently.
  • Memory events could be stored twice (core/orrery_core/persistence/memory.py). _add_events_sync de-duplicated by reading the session's existing event ids and filtering the batch in Python — correct single-threaded, but two overlapping turns in one session (a shared Slack thread, two webhooks, parallel sub-agents) both read the same snapshot, both find the ids absent, and both insert; recall then returned the event twice and paid for it twice in the model's context. A barrier-synchronized 8-writer repro duplicated on two runs in three. Uniqueness is now the database's job: a unique index on (app_name, user_id, session_id, event_id) with ON CONFLICT DO NOTHING on both the event and session-replacement paths. Existing deployments: there is no Alembic here and create_all only creates missing tables, so _ensure_event_uniqueness() back-fills the index at startup — deduplicating first, because the index cannot be built over rows the old path already duplicated. Both steps run only when the index is absent (one-time, idempotent, safe under concurrent replicas), so expect a single slower first boot on a large orrery_memory_events table and nothing thereafter. The constraint rests on ADK stamping every Event with a UUID (Postgres treats NULLs as distinct in a unique index); test_database_memory.py pins that upstream guarantee rather than trusting it.
  • The runtime image applies Debian security updates on top of the pinned base (Dockerfile). The release pipeline's Trivy gate had gone red on main for two libpcre2 CVEs (CVE-2026-86145, CVE-2026-89161) that bookworm-security had fixed but the newest published python:3.14-slim-bookworm digest still shipped — checked by pulling both digests, not assumed. Re-pinning could not help, so the runtime stage now runs apt-get upgrade. This trades byte-for-byte reproducibility for staying patched: the digest still fixes the starting layer, and a reproducible build of known-vulnerable bytes is not the property worth keeping.
  • Transitive anyio 4.12.1 → 4.14.2 (CVE-2026-63374). A lockfile-only CVE that Dependabot never opens a PR for, yet the CI filesystem scan reads uv.lock, so it failed every open PR until relocked by hand. The class of problem is addressed below, not just the instance.
  • Transitive grpcio 1.78 → 1.84. google-auth warns it raises its minimum to ≥1.83 in October 2026; moved now rather than as a surprise inside a Dependabot PR.
  • Docs and package metadata: the PyPI social link 404'd (orrery-core is unpublished and the orrery name on PyPI belongs to an unrelated project — it now points at the container image); both pyproject files gained [project.urls]; the MkDocs overrides template was being published as a page; AEP-019 was completed in its own file but still in-progress in the enhancements index.

Changed

  • Google ADK 2.8.0 → 2.9.2, alongside a dependency sweep: litellm 1.102.0, openai 2.54.0, google-genai 2.24.0, starlette 1.6.0, uvicorn 0.53.0, sqlalchemy 2.0.54, google-cloud-pubsub 2.41.0, google-auth 2.58.0, confluent-kafka 2.15.1, cryptography 50.0.1, ruff 0.16.8, ty 0.0.82; react 19.3.0, vite 8.3.0, eslint 10.10.0, prettier 3.9.8 and the testing group; astral-sh/setup-uv 10.1.0. typer left the lock as no longer required.
  • Dependency hygiene is automated for the case Dependabot does not cover (.github/workflows/relock-upgrade.yml, .github/dependabot.yml). A weekly uv lock --upgrade opens a PR when transitive packages moved — the anyio case above would have resolved itself. Plain git + gh rather than a third-party PR action, for a job whose purpose is dependency hygiene. Dependabot's uv updates are grouped into one weekly PR for minor/patch bumps (majors and google-adk stay individual); one week had produced nine.
  • The web console bundle is split (web/vite.config.ts). react and the markdown pipeline (react-markdown, remark/rehype, lowlight) ship as their own chunks via Rolldown's advancedChunks — Vite 8 rejects the object form of manualChunks. The app chunk drops from 653 kB to 113 kB and the vendor chunks cache across console releases instead of one file re-downloading every time; the raised chunkSizeWarningLimit is gone.
  • Orrery has a visual identity (assets/brand/, README.md, web/). An orrery mark — three orbits, an amber sun, three planets — replaces Material's stock robot in the docs, the emoji in the README and the console header; one palette across docs, console and brand assets; favicon and social card; tagline "SRE agents you can let near production." The README leads as a landing page, and both it and the docs home cross-reference the twelve-part blog series on why the platform is built the way it is. Cosmetic: no behaviour changed.

[0.4.0] - 2026-09-11 (tag removed; shipped in 0.4.1)

The agent can now read what humans wrote. Until this release Orrery saw live infrastructure through ~70 tools and recalled its own past sessions, but had no access to a runbook, a postmortem or an ADR — every incident started from zero institutional knowledge. search_knowledge closes that, on the tool path where the injection screen, credential scrubbing, output cap and audit trail all apply, because a retrieved document is attacker-reachable text like any other tool result. Around it: the platform is itself on-callable (twelve runbooks, a runbook_url on every alert), the confirmation gate reports what it decides and who it refuses, conversation history lives in the session store instead of the browser, and CI is green again after two rounds of transitive-dependency advisories.

Minor rather than patch: a new tool (search_knowledge), three new HTTP endpoints (GET /sessions, GET|DELETE /session/{id}), new metrics and alert rules, a new configuration surface (ORRERY_KNOWLEDGE_*, KNOWLEDGE_*, EMBEDDING_*) and a new build-time action (make knowledge-sync). Nothing existing changed shape; a 0.3.x deployment upgrades with no config edits and knowledge retrieval stays off until ORRERY_KNOWLEDGE_BACKEND is set.

Added

  • The agent can read what humans wrote (AEP-025, core/orrery_core/knowledge/). Orrery could read live infrastructure through ~70 tools and recall its own past sessions; it had no access to a runbook, a postmortem or an ADR, so every incident started from zero institutional knowledge. search_knowledge closes that. Scoped as two seams rather than one "RAG provider", because a managed vendor owns both halves of the problem and a self-hosted store owns neither: Vertex AI Search ingests through its own connectors and answers queries, while Elasticsearch and pgvector do nothing until you write both sides. KnowledgeSource (filesystem / git / Confluence → Document) and KnowledgeRetriever (query → Passage) are therefore independent, with KnowledgeIndex implemented only by backends we populate ourselves — a retrieve-only backend is declaring "my ingestion is somebody else's job", and make knowledge-sync skips it rather than failing. Adding Notion is one source; standardising on Azure AI Search is one retriever; neither touches an agent. The read end is a real tool, and that is the safety argument, not a detail. Subclassing ADK's BaseRetrievalTool puts the result on the after-tool chain, so SafetyScreenPlugin neutralizes injected spans, PIIRedactionPlugin scrubs pasted tokens out of postmortems, ToolOutputCapPlugin bounds a chatty retrieval and AuditPlugin records the query. This matters because a retrieved document is attacker-reachable text — a Confluence page or git-hosted runbook is editable by anyone with write access to the source, the same threat model as a pod annotation. ADK's VertexAiSearchTool would bypass all four: despite the name it is model built-in grounding, appending a types.Retrieval to the request config so the model retrieves server-side with no after_tool_callback and no audit entry. agents/orrery-assistant/tests/test_knowledge_wiring.py walks both roots and fails the build if a grounding tool is ever attached. Two backends behind resolve_retriever(), which mirrors resolve_model(). Elasticsearch shipped first because make up already starts the container and BM25 alone is a large improvement over the any-single-word match in DatabaseMemoryService — no image swap, no embedding provider, and it proved the seams before phase 2 paid for semantics. pgvector is deliberately hybrid: semantic search is weakest exactly where SRE queries are strongest — an exact identifier like CrashLoopBackOff or a consumer-group name — where a nearest-neighbour search returns three plausible pages that never mention it. Both rankings are computed and fused with Reciprocal Rank Fusion, which combines ranks rather than scores because cosine distance and ts_rank_cd share no scale and any weighted sum would be dominated by whichever has the larger range. Provenance is a required field on every Passage, with age_days and a stale flag surfaced in the result: an operator at 03:00 cannot otherwise tell a retrieved fact from a hallucination, and that is the failure mode that makes teams abandon retrieval. "No matches" and "backend unreachable" are deliberately different results — if they looked alike, a broken index would quietly become "we have no runbook for that". Misconfiguration fails fast at startup; a runtime outage does not, because knowledge is an augmentation and the platform diagnosed incidents without it before this existed. Indexing is a build-time action (make knowledge-sync), never lazy at request time — a first query that silently triggers a full Confluence crawl is an outage waiting for its moment. Sync is revision-driven and idempotent, prunes stale chunks after a shrinking edit (nine chunks becoming four would otherwise keep answering from five slices that no longer exist), and skips deletion-pruning entirely when a run had any error, because a source that failed halfway looks identical to one whose documents were all deleted. Retrieval is viewer-level and not ACL-aware, so the rule is stated rather than faked: index only what every viewer may read. ConfluenceSource refuses to auto-discover spaces — a constructor without an explicit space list raises, and a 403 fails loudly.
  • On-call runbooks, and runbook_url on every alert (AEP-017, runbooks/). A platform that restarts pods, rolls back deployments and resets consumer-group offsets is itself on-callable; until now every incident meant re-deriving the architecture from source, which does not happen at 03:00. Twelve pages: a first-five-minutes checklist, an escalation policy with evidence-preservation commands, a template, and nine incident runbooks. The set is organised around a question that turned out to come first in nearly every scenario — is Orrery broken, or is Orrery correctly reporting that something else is? A circuit breaker opening because Kafka is genuinely down is the system working, and mistaking that for a fault is the most likely way to make an incident worse. It is a fixed section in the template rather than prose buried in each page. All fourteen Prometheus rules now carry a runbook_url annotation. Two planned alerts were not shipped as originally drafted: the availability alert's expression matched up{job=~"orrery.*"} when the scrape job is actually agents, and prompt-injection detection had no metric to alert on at all (see below).
  • orrery_safety_screen_total{direction, source}. Prompt-injection screening was log-only: MetricsPlugin bounds the tool status label to four values to cap cardinality, so a BLOCKED result recorded as ok and no expression over orrery_tool_calls_total could find a detection. The only alert that could be written was one that never fires, which is worse than none because it reads as coverage. direction keeps the two halves apart, and summing them would be meaningless: direct means a user message was refused before it cost a token — someone is probing the agent; indirect means text inside a tool result was neutralized — attacker-reachable content is sitting in the monitored infrastructure. Indirect counts spans, not events, because two injected lines in one payload is a worse finding than one; direct counts one, because the screen stops at the first match and the run never learns how many others the message held.
  • Confirmation lifecycle events (AEP-024, core/orrery_core/security/, observability/). The gate was already correct and entirely silent. A successful approval was only derivable — correlate two tool_attempt entries and know the invariant — and a refused one left no trace anywhere, even though a second person attempting to approve someone else's destructive action is the most interesting event the subsystem can produce. Four events on the audit stream via a shared audit_event() helper: confirmation_raised (emitted inside raise_pending(), so no transport can forget it), confirmation_decided, confirmation_refused and confirmation_expired (both store backends — an expired pending and one nobody saw were previously identical). Every event carries mode, closing the provenance gap where an auditor could not distinguish a human-approved production change from a model re-call on a dev surface. Four counters plus a decision-latency histogram, which skips observations with no known pending creation time rather than inventing a latency that would poison the data the TTL should be tuned from. No confirmation_id column was added: PendingConfirmation.action_id is already a uuid4 primary key on both backends, so a second identifier would be a redundant column that could drift. The gate's and chain became staged guards so a refusal can name why; ordering, short-circuiting and the single consume_pending() call are unchanged, and test_guardrails.py passes untouched — which was the requirement, since this is observability only.
  • OrreryUnauthorizedApprovalAttempt (critical) and OrreryUnattributableApprovals (warning), with a runbook. The first is the only alert in the file about a person rather than a system. The second catches the quieter failure: a transport not stamping the turn's actor means nobody can approve anything and guarded remediation silently stops working.
  • AEP-024, AEP-025 and AEP-026 written up in enhancements/. AEP-026 (experience capture / REX) remains proposed: mine the platform's own incident history into after-action documents, with ADK's BigQueryAgentAnalyticsPlugin as one optional sink behind an ExperienceStore seam rather than the system of record, deterministic clustering, and generated documents reaching the corpus only through a merged pull request — without that gate the loop closes on itself and model error becomes indexed institutional fact.

  • pgAdmin in the local compose stack (docker-compose.yml, infra/pgadmin/servers.json). make up now serves it on 127.0.0.1:5050 for browsing the session store — unprofiled alongside Postgres for the same reason kafka-ui is unprofiled alongside Kafka. Opens directly into the browser tree: desktop mode disables both the sign-in page and the master-password prompt, and the connection is pre-registered. The database password is entered once and saved into the volume — pgAdmin only accepts a saved-password file at mode 0600 owned by its own uid, which a host bind mount cannot provide. PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS is set because the default account's .local address is a reserved TLD that the image's email validation rejects, restart-looping the container.

  • Conversation history is server-backed (core/orrery_core/serving/server.py, web/). Three endpoints — GET /sessions (the caller's conversations, newest first, titles only), GET /session/{id} (one conversation with its transcript) and DELETE /session/{id} — and the console now reads its sidebar from the session store instead of localStorage. The transcripts were already in Postgres: create_app builds the gateway with DatabaseSessionService whenever DATABASE_URL is set. What was missing was any way to read them back, so the browser-local sidebar was the only index of which sessions belonged to whom — a different machine, or cleared site data, left every past conversation stranded in the store as unreachable rows. Two further consequences of that split are also gone: the per-conversation 200-message persistence cap (which made the visible transcript disagree with what the agent still remembered) and the 50-conversation eviction (which hid sessions that still existed). The cap now lives on the response, where the data is. Transcripts are rebuilt by build_transcript() in serving/events.py, which keeps only what a person said and what the agent said back — function calls, planner thoughts and compaction digests are dropped, and each turn's text events are merged the way POST /chat concatenates them, so a reopened conversation reads as it did live. The compaction check is explicit rather than incidental: an AEP-020 digest is authored "user" and keeps its content in actions.compaction, so a text-only filter happens to skip it today, but nothing would stop a future ADK from replaying an LLM summary of the conversation into the conversation as something the user said. Titles are written into session state on a conversation's first turn rather than derived from the transcript, because list_sessions returns state but never events — deriving them would cost one query per listed session on every load. Sessions opened by another transport carry none and render as "New chat". Every endpoint pins user_id to the verified subject, so another user's session id is a plain 404, and DELETE is checked before it is executed. Transcripts loading lazily means opening the console costs one request regardless of how much history exists. CORS allow_methods gained DELETE — without it the delete worked same-origin and failed only in cross-origin dev.

Changed

  • Google ADK 2.5.0 → 2.8.0, alongside two dependency sweeps (litellm 1.98.0, uvicorn 0.52.4, pydantic 2.13.5, pydantic-settings 2.15.0, google-auth 2.57.1, ty 0.0.75, ruff 0.16.6, hypothesis 6.168.0; react 19.2.8, vite 8.2.2, vitest 5, typescript-eslint 8.70.0, eslint 10.8.1 and the testing/globals groups; node 26 base digest; actions/setup-uv 10.0.1). PyArrow left the gcp extra for the new bigquery-analytics extra upstream, which is unused here and cuts ~50 MB from the install. google-genai moved 2.11.0 → 2.19.0 transitively. core/pyproject.toml had floored google-adk[eval,db]>=2.5.0 while the workspace root moved to >=2.7.1. The lock resolved 2.7.1 either way, but orrery-core is published as its own distribution and a consumer installing it outside this workspace would have been allowed an ADK two minors behind what the code targets.
  • docker-compose.yml uses pgvector/pgvector:pg16 in place of postgres:16-alpine. It is the stock Postgres image plus the vector extension — same data directory, same defaults — so an existing volume keeps working and sessions, memory and confirmations are unaffected. The Helm chart takes an external DATABASE_URL and needed no change.
  • AgentGateway.session_service is optional in the type, not just at runtime. __init__ always assigns a service while from_runner() may assign None, so the attribute inferred as non-optional and the two constructors disagreed; the old # type: ignore[assignment] silenced the report rather than the mismatch. Declaring it BaseSessionService | None then surfaced seven call sites in the HTTP server and Slack handler dereferencing an optional. They now narrow through a sessions property that raises with a message naming run_in_session(), instead of an AttributeError three frames down. Found by ty 0.0.73, which flags what 0.0.63 let through.
  • JSONFormatter's field allowlist covers the confirmation lifecycle. Extended rather than replaced with a sweep over record.__dict__ — logging puts a lot of machinery on a record and a caller can attach anything through extra, so an unfiltered merge would leak both into the log stream and make the output shape unstable.

Fixed

  • CI had been red on main for over two weeks — every run since 2026-08-06, and every open Dependabot PR with it. The Security Scan job's Trivy filesystem scan runs with severity: HIGH,CRITICAL / exit-code: 1 / ignore-unfixed: true, so a fixed HIGH advisory is a hard failure by design; cryptography was pinned at 49.0.0 with CVE-2026-69247 against it. Clearing that exposed three more HIGH advisories against sqlparse 0.5.5 that had landed in Trivy's database since. Both are transitive, so both were lock-only upgrades (cryptography 50.0.0, sqlparse 0.6.0).
  • CI red again on main and on all eleven open Dependabot PRs, same shape. nltk 3.10.0 (transitive via google-adk[eval] → rouge-score) picked up one CRITICAL and three HIGH advisories (CVE-2026-79675, CVE-2026-71513, CVE-2026-72818, CVE-2026-78680), fixed upstream in 3.10.3 — a three-line uv lock --upgrade-package nltk. The bump then tripped Dependency Review on a newer advisory, GHSA-8mgp-746c-j5xp, which covers every published nltk release (<= 3.10.3) with no patched version yet. Trivy already skips it via ignore-unfixed; dependency-review-action has no equivalent, so without an allow the lockfile could never again be touched where nltk is concerned. The one GHSA is allow-listed in ci.yml with an inline justification (nltk is only used for eval scoring; the affected model-artifact APIs are never called) and a re-check pointer — the entry is to be deleted the moment a patched release exists. A serial rebase-and-merge then landed the Dependabot backlog: the branch ruleset requires an up-to-date branch, so each merge knocked the rest BEHIND and they went one at a time.
  • vite.config.ts imports ./src/api/paths.ts with its extension. Vite 8.2.1 warns that the extensionless form is unsupported by configLoader: 'native', planned to become the default in a future major — the native loader resolves the config through Node, which has no extensionless resolution. Both tsconfigs already set allowImportingTsExtensions, so the explicit form type-checks unchanged.
  • The pgvector backend validates its table identifier. A table name cannot be a bound parameter — SQL binds values, not identifiers — so KNOWLEDGE_PG_TABLE is interpolated into every statement. It comes from configuration rather than a request, but "config is trusted" is exactly the assumption that ages badly once values arrive from a Helm chart, a ConfigMap or an operator CR. Validated once at construction against a plain-identifier pattern, which makes the interpolation provably safe rather than conventionally safe.

Security

  • .trivyignore.yaml — triaged findings, with mandatory expiry dates. The Release & Publish workflow's image scan had been failing on every push since 2026-08-06, independently of CI: eight HIGH advisories against the Go standard library (v1.26.5) linked into usr/local/bin/docker, the Docker CLI bundled so the docker-agent can inspect local containers. There is no upgrade to take. Every published Docker CLI tag — 29-cli, cli, latest — is still built with Go 1.26.5, verified by scanning each one rather than assumed, so re-pinning the digest changes nothing. Removing the CLI would disable the docker-agent in-container, and rebuilding it from patched Go is disproportionate for a binary invoked as a subprocess against a local socket. All eight are denial-of-service or content-handling issues in a client that serves no HTTP, renders no templates to a browser and parses no untrusted DNS; the worst realistic outcome is the subprocess crashing, which the tool layer already handles as a tool error. Every entry carries expired_at (2026-10-21) and a statement saying why. The expiry is the review mechanism and it is self-enforcing — Trivy stops honouring an expired entry and the gate goes red again, so an ignore cannot silently become permanent, and a permanent ignore is indistinguishable from not scanning. Wired through the trivyignores input on all three Trivy steps, because Trivy auto-loads a plain .trivyignore but not the YAML form, and only the YAML form supports expiry.

[0.3.1] - 2026-07-26

A security release. Two external reviews and one internal audit found four ways a guarded action or a credential could slip past a gate that was supposed to hold — all four are closed here, each with a test that fails if it comes back. Nothing in the platform's API changed; the dependency majors below are build-time only and invisible to anyone consuming the image or the library.

Security

  • The closed-loop remediation actor ran mutating tools unattended (agents/orrery-assistant/orrery_assistant/remediation.py). remediation_actor was the only agent in the tree holding @confirm/@destructive tools without before_tool_callback=require_confirmation(), and GuardrailsPlugin enforces RBAC only — the human-in-the-loop gate is per-agent wiring. run_triage.py pins the batch session to operator, which RBAC lets past @confirm tools, so scale_deployment had nothing standing in its way: a scheduled overnight sweep could rescale a deployment the model chose, with no human in the conversation. restart_deployment and rollback_deployment were refused, but by role, not by the mechanism run_triage.py's own docstring credited. The actor is now gated like every other specialist, and its instruction covers the blocked outcome so a confirmation_required is reported as waiting, never as done. The cause was the invariant test, not the missing line. tests/test_confirmation_wiring.py — written after the docker specialist regressed identically — walked only orrery_chat_agent, and the remediation actor lives in the orrery_triage_workflow graph, which no AgentTool edge reaches. It now walks both roots (handling Workflow.graph.nodes) and carries a guard-the-guard test, because a walker that reaches nothing passes vacuously.
  • Indirect prompt injection: tool results are now screened (core/orrery_core/plugins/safety_plugin.py). SafetyScreenPlugin screened only the user's message, which is the less important half for an infrastructure agent: a pod annotation, container log line, Kubernetes event, Kafka topic config or Elasticsearch document is attacker-reachable text that reaches the model wearing a tool result's authority. Matched spans in tool output are now neutralized in place rather than the payload being dropped — that payload is the evidence the agent was asked to read, so refusing it would break the diagnosis. Same in-place/return-None contract as PII redaction (a returned copy early-exits ADK's after-tool chain and silences every later observer), and the same worker-thread hop above OFFLOAD_THRESHOLD_CHARS. ORRERY_SAFETY_SCREEN=false disables both directions.
  • Tool results that are not dicts escaped both content plugins (core/orrery_core/plugins/pii_plugin.py, core/orrery_core/payload.py, core/orrery_core/persistence/memory.py). Flagged by an external audit as a hypothetical; it was live. ADK does not require a dict — FunctionTool.run_async returns the function's value verbatim and the {"result": …} normalization happens in __build_response_event, after the after-tool chain — so a dict/list-only walk skipped whatever it was handed. Of the 120 registered tool functions, 119 return dicts; the exception is load_memory, wired into the shipped chat root, returning a Pydantic LoadMemoryResponse. The write side did not compensate: SecureMemoryService carried its own shorter pattern list (key=value pairs and PEM blocks) while the tool path also caught bare provider tokens, so a ghp_/AKIA/JWT pasted into chat was stored verbatim. Three gaps in a line — paste a token, it is stored unredacted, load_memory recalls it, neither plugin touches it, and it lands in the model context and the audit log. Both walks now traverse object attributes (payload.mutable_attributes), so a Pydantic result is scrubbed in place with the chain intact; a bare str/bytes cannot be mutated at all, so it is scrubbed by returning the replacement, logged at warning level because that early-exits the remaining observers (losing one audit outcome line beats writing a credential into it). security/redaction.py now holds the one pattern set both paths share, with a test asserting the parity structurally.
  • The autonomy level could be promoted from session state (core/orrery_core/plugins/autonomy_plugin.py). _active_level() honoured any autonomy_level it found in state, and session state is not a trust boundary — tools write to it, and tool output is attacker-reachable text. An L2 read-only deployment was therefore one state write away from L4. Now honoured only when written through the new set_autonomy_level(), which stamps _autonomy_set_by_server; an unlocked value is ignored with a warning. This is the defence RBAC already had via _role_set_by_server — the two axes now match.
  • /docs is off by default wherever auth is on (core/orrery_core/serving/server.py). A browser navigation cannot carry a bearer token, so the interactive schema cannot be gated behind the API's own auth, and it enumerates every route and request shape. ORRERY_DOCS_ENABLED overrides in either direction; local no-auth runs still get it.

Added

  • make lock-check gates lockfile drift, in CI and locally. Every CI job opens with uv sync --all-extras, which silently rewrites a stale lock in the runner — so drift never failed a build. The check runs uv lock --check before the sync in the lint job, the only position where it can still see the problem, and make lock regenerates after a manifest edit.
  • .github/workflows/dependabot-relock.yml commits the missing half of a Python update back onto Dependabot's branch, so the new gate doesn't turn every dependency bump into manual work. Prefers a DEPENDABOT_RELOCK_TOKEN Dependabot secret (an Actions secret is unreadable in a dependabot-triggered run) and warns explicitly when it is absent, because a push made with the default GITHUB_TOKEN does not re-trigger workflows.
  • Tests that compose the real plugin chain, rather than one plugin at a time: both content defenses applied to a single payload with nothing cutting the chain short, the output cap proven to be the only plugin permitted to early-exit, and a denied call proven to be audited because audit precedes the gates. Every prior plugin test checked a plugin in isolation or the list of names, which is how an ordering violation could ship.

Changed

  • Python updates move to Dependabot's uv ecosystem (.github/dependabot.yml). The pip ecosystem only edits pyproject.toml, so every Python update landed with uv.lock still recording the old constraint — pytest-cov merged green that way. The uv ecosystem understands the lock; make lock-check remains the backstop, since it can still leave one file behind.
  • npm updates are grouped by what npm's peer ranges force to move together — react, vite, typescript, testing. Three open Dependabot PRs had failed at npm ci with ERESOLVE, and not one was a bad update: each was correct but proposed alone when its peer required a companion to move with it.
  • TypeScript is held at 5.x. TS 7 cannot be installed here: typescript-eslint's latest and canary both peer typescript >=4.8.4 <6.1.0, and the package hard-fails at runtime with "typescript-eslint does not support TS 7.0", so --legacy-peer-deps only moves the error from install to lint. No 6.x stable exists to step through. The hold is on the major only, and the config names the command that says when to lift it. The console itself is already TS 7-clean — tsc --noEmit under 7.0.2 reports zero errors.
  • React 19, Vite 8, ruff 0.16. React moves as a set (react, react-dom, and both type packages); vite moves with @vitejs/plugin-react 6. Nothing in the console touches an API React 19 removed — no ReactDOM.render, findDOMNode, defaultProps on a function component, propTypes, string ref or forwardRef — which was grepped rather than assumed, since the test suite would not have caught most of them. The vite bump also cut the build from ~1.3 s to ~0.25 s and the bundle from 593 kB to 573 kB. ruff 0.16 formats Python code blocks inside Markdown, so the 24 affected files are docs, not source.
  • AutonomyPlugin is registered before GuardrailsPlugin. ADK's before-tool chain early-exits on the first non-None return, so the previous order let an L2 deployment raise a confirmation prompt for a mutation that L2 refuses the moment it is approved. The level is a property of the process — decide it before asking a human anything.
  • A paused L4 confirmation reports AWAITING_CONFIRMATION, not BLOCKED. The model acts on that string, and "blocked" reads as a dead end when the action is one human answer away.
  • Sessions in a shared thread are keyed per participant (core/orrery_core/serving/gateway.py, agents/slack-bot/slack_bot/session_map.py). ADK scopes every session by (app_name, user_id, session_id), so handing the first speaker's session id to a second speaker never joined them to that conversation — the lookup missed and ADK created a fresh empty session behind the same id. Keying by thread alone only hid that threads are per participant; the mapping now says so, and forget/remove clear the whole thread by default.
  • All 9 dev-only npm advisories cleared by upgrading the toolchain (eslint 10, typescript-eslint 8, vitest 4, jsdom 29, globals 17). npm audit --omit=dev was already clean so nothing shipped to a browser was affected, but the noise on every make install hides the one that will eventually matter. Pinning the patched brace-expansion via overrides was tried first and breaks the build — 5.x changed its export shape and minimatch@3.x still calls the old default export.
  • The docs home page now leads with the web console running a real triage rather than a Google Chat screenshot: five systems checked in parallel, a Critical verdict, and the recorded findings in the side panel. That is the claim the page makes two paragraphs later, so it may as well be the first thing a visitor sees.

Fixed

  • uv.lock disagreed with pyproject.toml on main — the manifest asked for pytest-cov>=7.1.0 while the lock still recorded >=6.0.0. The lock is tracked precisely so Docker builds are reproducible, which a lock that disagrees with the manifest is not. Repaired, with the gate above to keep it that way.
  • An explicit compaction=None was silently overridden (core/orrery_core/serving/{server,runner}.py). create_app and run_persistent fell back with arg or create_events_compaction_config(); since None is falsy and the only way to disable compaction in code, passing it explicitly re-enabled compaction — the opposite of the request. Env-based disabling always worked, which is why it went unnoticed: the documented off-switch was never the broken one.
  • Plugin-composition tests read the developer's .env (core/tests/). Any agent module imported during collection calls load_agent_env(), whose load_dotenv() searches the CWD and its parents, so a perfectly legitimate local ORRERY_AUTONOMY_LEVEL=L3 failed two tests in a file with no connection to it — and only under a full run, since core-only runs never import an agent module.
  • An unresolved dotted role claim is now logged (core/orrery_core/security/auth.py). It already failed closed on every malformed claim shape — ten hostile variants all return viewer, none raise — but the fallback was silent, so a mistyped JWT_ROLE_CLAIM demoted every caller to viewer with nothing in the logs pointing at the cause.
  • The docker CLI wrapper left a zombie on timeout (agents/docker-agent/docker_agent/tools.py): kill() only delivers the signal, so the child stayed unreaped until the loop's watcher happened to collect it. A tool that times out tends to do so repeatedly.
  • Limiter(default_limits=…) was inert (core/orrery_core/serving/server.py). slowapi only applies those through SlowAPIMiddleware, which this app does not install, so the argument read as blanket coverage while doing nothing. Limits are now declared per route, where the cost actually differs — /chat buys tokens; /confirmations/pending is a cheap read the console polls on a timer and must not be throttled into failure.
  • ConfirmationStore.add documents why it is not keyed by args_hash, with tests pinning the behaviour. An audit recommended adding it so parallel same-tool calls stop superseding each other; that would also let a card the requester scrolled past authorize an execution minutes later. The supersede costs liveness only — consume_pending matches args_hash exactly, so an evicted call re-prompts and can never be authorized by the survivor's approval.

[0.3.0] - 2026-07-25 (tag removed; shipped in 0.3.1)

Added

  • Conversation context compaction (AEP-020), on by default. A long incident session grew its transcript monotonically until the request exceeded the model's window and the turn failed. ToolOutputCapPlugin caps a single tool result at 4 MiB against Gemini's ~10 MiB request ceiling — so three capped results in history were already enough to break the next turn; the cap deferred the failure rather than preventing it, and persistent Postgres sessions made it worse. Implemented by configuring ADK's native EventsCompactionConfig rather than the hand-rolled context engine the AEP originally specified: native compaction refuses to separate a function_call from its function_response (the proposed slice-based design would have split tool pairs and produced provider 400s on exactly the tool-heavy sessions this targets), reads real prompt_token_count, and appends the digest as an event carrying the compacted timestamp range — so the originals stay in the session and are filtered only at request assembly. Lossy for the model, lossless for the record, by construction. create_events_compaction_config() (core/orrery_core/serving/runner.py) is threaded through every App/AgentGateway site; ORRERY_COMPACTION_TOKEN_THRESHOLD defaults to 250k, chosen to sit out of reach of ordinary sessions so enabling it changes nothing else. The summarizer is always passed explicitly — ADK otherwise derives it from the root agent's model, which bills digests at the agent's rate and raises outright for a non-LlmAgent root, which the batch triage Workflow is. Compactions are exported as orrery_context_compaction_total; the hook lives in a summarizer subclass because compaction events bypass on_event_callback entirely (the Runner appends them after the agent generator is exhausted).
  • Single sign-on for the web console (OIDC Authorization Code + PKCE). The server perimeter was already OIDC-ready — RS256/JWKS with configurable audience, issuer and role claim — so the missing half was purely in the browser, as TokenGate said in its own comment. Provider-agnostic via oidc-client-ts (Keycloak, Authentik, Auth0, Okta, Entra ID, Google), deliberately a dependency rather than hand-rolled: the failure modes of DIY OAuth are subtle and security-critical. Enabled by setting VITE_OIDC_ISSUER; unset keeps the paste-a-token gate, so make dev-token, CI and offline work are unaffected. The access token is held in memory with silent renew instead of localStorage, and signing out ends the provider session too. The redirect URI is the console root, not /auth/callback — the front door serves the bundle with StaticFiles(html=True), which 404s unknown deep paths, so a sub-path callback would work in dev and break in production.
  • A local Keycloak under an sso compose profile, with a pre-imported realm and three demo users (viewer / operator / admin, password same as username) — which makes the RBAC tiers demonstrable in the browser for the first time. make up PROFILES=sso then make run-api SSO=1. No healthcheck on the container by design: the image is distroless, so a CMD-SHELL probe can never run and leaves it permanently unhealthy; make up polls the realm's discovery document from the host instead, which also covers realm import finishing after the server starts listening.
  • Role claims may now be dotted paths (JWT_ROLE_CLAIM=realm_access.roles, and the console's matching VITE_OIDC_ROLE_CLAIM). Both sides previously read a flat claim, but the providers people actually deploy nest their roles — Keycloak uses realm_access.roles for realm roles and resource_access.<client>.roles for client roles, neither reachable by a flat lookup, so every SSO user silently resolved to viewer. An unresolvable path still yields viewer: it fails closed.
  • Web console: copy-to-clipboard and syntax highlighting. Copy buttons on messages, on code blocks, and on a failed environment check's detail (long connection errors that could not be selected cleanly out of a narrow panel). Reachable by keyboard, not hover-only, with an execCommand fallback for the insecure origins a console on a bastion host is often reached over. Highlighting is restricted to nine languages so the bundle stays inside its 700 kB budget.
  • Web console: AEP-019 Milestone 3 — the first-run environment check. POST /onboarding/selftest runs a one-token model round-trip plus every registered integration probe (Kafka, Kubernetes, Elasticsearch, Prometheus, Docker — each specialist's cheapest read-only tool), concurrently, and reports per-check pass/fail with the reason and what to configure when it fails. This is the AEP's own "highest-leverage single feature": nearly every first-run failure is a credential or endpoint that was never wired, and until now the only feedback was a stack trace buried in a tool result several turns into a conversation. Surfaced in the console as a System tab plus a sidebar Check my environment button. Probes are supplied by the app (create_app(integration_probes=...)) so core keeps no dependency on any agent package; they must be read-only, since the self-test does not consult RBAC. A probe that hangs, raises, or returns an error all become one red row rather than breaking the page.
  • Web console: the role + autonomy badge is now complete. GET /me returns the server-resolved role and the autonomy level actually in force (L2/L3/L4, or none). The console previously showed only its own decode of the JWT — the browser's reading of a signature it cannot verify. The server's answer now wins in the badge, and the System tab spells out what the active level permits, so a viewer learns why mutating tools are unavailable up front instead of when one is refused.
  • Web console: per-system triage chips (AEP-019 Milestone 2). A triage verdict now renders a chip per system that was actually consulted, derived from the recorded tool calls rather than parsed out of the model's prose — the report is free text that changes wording run to run, the activity log is structured. A system that was never called shows no chip: "we didn't ask" and "healthy" are different answers.

Changed

  • The Makefile is one target per job, with variants as flags (34 targets → 28). make up / make down now act on everything, with PROFILES= to narrow; make check runs the whole gate across both toolchains (ruff + ty + pytest + the web gate), and install and fmt likewise cover both. Variants that used to be near-duplicate targets are now flags: run-cli PERSIST=1, run-api SSO=1, run-slack MODE=socket, run-chat MODE=pubsub, dev-token ROLE=viewer, up PROFILES=tracing. run-api builds the console before serving it, so it is the single command for "the product". npm is optional throughout — Python-only contributors get a warning and a working setup, not a failure. The demo and slack compose profiles are deliberately excluded from make up: they run the agent in Docker on the same ports as make run-api / make run-slack. This renames the developer-facing targets — run-assistant*, infra-*, tracing-*, sso-* and web-* are gone; make help lists the current set.
  • Dependabot now covers the web console (.github/dependabot.yml). The pip ecosystem never saw web/, which sits outside the uv workspace, so the console's dependencies — React, and now the OIDC client that handles sign-in — were getting no security updates at all.
  • Web console UX: a turn can be stopped, and a failed one retried. A triage sweep fans out to five specialists and can run for a minute with no partial output (streaming is AEP-009), and the only escape used to be reloading the page. Send becomes Stop while a turn is in flight; stopping is reported as stopped rather than failed, and notes that the server may still finish. A failed turn offers Retry, which replays the message without duplicating it in the transcript, and errors can be dismissed. A 429 from the new chat rate limit is explained with its Retry-After instead of surfacing as a bare error.
  • Web console: the transcript no longer yanks you back to the bottom. Auto-scroll now only follows the newest message when the reader is already at the bottom — scrolling up to re-read an earlier answer while a sweep is still landing used to fight the user. The transcript also gained role="log" and an accessible name.
  • Web console: the composer grows with the message up to a bound, instead of showing a multi-line prompt through a one-line peephole.
  • Docs caught up with the 21 tools added in 0.2.3 (agents-overview.md, CLAUDE.md): the at-a-glance table still read 19/18/12/10 tools for kafka/k8s/observability/docker (actually 24/26/16/17) with stale guarded counts, and none of the new tools appeared in the per-agent role tables that the site presents as the authoritative catalog.

Fixed

  • The web console's environment check returned "Not Found" under make run-web (web/vite.config.ts). The Vite dev proxy listed only /chat, /session, /confirmations, /healthz and /readyz, so the System pane's two endpoints never reached the API. One root cause produced two symptoms because the verbs differ: POST /onboarding/selftest plainly 404'd, while GET /me was rewritten by Vite's history fallback to the SPA shell and returned 200 HTML that then died inside res.json() — which is why Role (server) and Model rendered as "—" with nothing reported. The comment above that list already warned about this exact failure, so the fix is structural rather than two more strings: API_PREFIXES (web/src/api/paths.ts) is now the single source the proxy derives from, guarded by a test that drives every ApiClient method and fails if a path escapes it. Removing a prefix reproduces the original bug as a test failure.
  • make clean was destroying node_modules. Its find . -type d -name 'build' matched every npm package that ships its dist in a directory called build — pretty-format, jwt-decode and others — silently gutting them; the web suite then failed much later with Cannot find module …/build/index.js, with no obvious connection to the command that caused it. It now prunes node_modules, .venv and .git before matching.
  • Stopping one compose profile tore down the whole stack. docker compose --profile X down removes every service in the file — a profile filter only adds services, it does not scope down — so stopping the tracing or SSO stack also stopped Kafka, Postgres and Prometheus. Both targets now remove their own containers by name.
  • make reset deleted local volumes without confirmation. It now lists the volumes it will destroy and requires typing yes (FORCE=1 to skip).
  • A non-JSON API response no longer fails silently (web/src/api/client.ts). A 200 that would not parse threw a bare SyntaxError no caller interpreted; it now reports what actually arrived and that the request likely never reached the API. /me failures surface in the System panel instead of being swallowed, and errors distinguish network / auth / rate-limit / 5xx rather than echoing a raw message.
  • Web console: an assistant reply could be lost to a same-tick New-chat or delete (web/src/conversations/useConversations.ts). Removing the setState-inside-updater anti-pattern in 0.2.3 replaced it with a stale-snapshot read: newConversation/deleteConversation built a whole new array from the render closure, so a patchActive queued earlier in the same tick — the arriving assistant message — was silently overwritten. The list and the active selection are now one useReducer: they are a single piece of state (every create/delete/select moves both), so React replays each dispatch against the freshest state and neither anti-pattern is reachable. Two regression tests reproduce the lost reply.
  • Web console: conversation history is now genuinely bounded (web/). The 50-conversation cap didn't bound storage — one long incident thread grows without limit — and the quota failure it was meant to prevent was swallowed silently. Persisted transcripts are trimmed to the most recent 200 messages (the in-memory transcript is untouched), a rejected write retries against progressively smaller slices instead of giving up, and eviction now drops the least-recently-updated conversation rather than the oldest-inserted — matching the order the sidebar shows.
  • Web console: the side-pane refresh guard compared the active conversation with itself (web/src/chat/useChat.ts), so it could never actually fire. The refresh now takes the conversation that owns the session it is fetching for, which is what the guard was meant to compare against.
  • A specialist's model failure no longer reads as an answer (core/orrery_core/reliability/error_handlers.py). When an agent reached through an AgentTool hit a model error, graceful_model_error() returned "I encountered an error… Please try again" — and because that text becomes the tool's result in the coordinator's transcript, a coordinator cannot tell a polite apology from a finding: it treats the step as done and can summarize an incident whose Kafka check never ran. The response now states plainly that the step failed and that no result was produced, and calls out quota exhaustion (429) by status code walked up the __cause__ chain rather than by matching an error string.
  • k8s tools: input validation gaps (agents/k8s-health). label_selector was passed to the API server unvalidated on list_pods/list_services (the repo's rule is that every tool validates at entry); describe_service/get_configmap accepted namespace="all", which is a legal namespace name, and then 404'd confusingly instead of saying what was wrong; and top_pods summed unparseable CPU/memory quantities as 0, making "this pod is idle" and "we couldn't measure this pod" indistinguishable — it now reports the affected containers instead.

Security

  • An unknown-kid bearer token returned 500 with a traceback instead of 401 (core/orrery_core/security/auth.py). PyJWT's PyJWKClientError is not an InvalidTokenError, so it escaped the handler on the RS256/JWKS path — meaning essentially every forged or garbage token became an unhandled server error on an unauthenticated request path, burning a JWKS lookup and emitting a stack trace for each one. Now a clean 401. A genuinely unreachable IdP (PyJWKClientConnectionError) still surfaces as a server error, since that is not the caller's fault and a new token would not fix it. This became reachable in practice with the SSO work above, which makes RS256 the recommended production configuration.
  • POST /chat is now rate limited (core/orrery_core/serving/server.py). The Slack bot had a limiter; the main front door — the one that spends LLM tokens and can fan a single turn out to six specialists plus a triage sweep — had none, so one leaked credential was unbounded spend. Keyed on the verified JWT subject rather than the source address: behind an ingress every caller shares an IP, and keying on the raw token would hand out a fresh quota on every refresh. Configurable via ORRERY_CHAT_RATE_LIMIT (default 30/minute).
  • Elasticsearch search now bounds the query on the cluster (agents/elasticsearch). The HTTP client timeout only bounded our wait — when it fired, a model-authored deep aggregation or leading-wildcard query kept running on the cluster with nobody left to read the result. A timeout (elasticsearch_search_timeout, default 10s) now travels with the body, and a partial result surfaces its timed_out flag so the model can't mistake it for a complete answer.
  • A wildcard CORS origin no longer allows credentials (core/orrery_core/serving/server.py). ORRERY_CORS_ORIGINS=* combined with allow_credentials=True makes Starlette echo back whatever origin asked, turning every site into a permitted credentialed caller. The API authenticates with a bearer header rather than a cookie, so credentials buy it nothing — they are now dropped (with a warning) when the origin list contains *.
  • pyasn1 0.6.3 → 0.6.4 (uv.lock): CVE-2026-59885 and CVE-2026-59886, two HIGH denial-of-service issues via crafted ASN.1 OBJECT IDENTIFIER / REAL values. Transitive via google-adk → google-auth → pyasn1-modules, so a lock-only bump — nothing declares it directly and pyasn1-modules already allows <0.7.0.
  • Requester-verified confirmation: a stale approval could authorize a later action (core/orrery_core/security/guardrails.py, core/orrery_core/serving/gateway.py). The gateway only wrote _confirmation_decision on turns whose text was a decision word, and the gate only checked that the decision was fresh (TTL 300s) and that a pending matched the tool + args-hash. So an approve typed while nothing was pending — for something else, or for nothing at all — stayed in session state and authorized whichever guarded call came next: the model raised the pending, re-called the tool, the args-hash matched, and a @destructive tool executed on an approval no human had given for it. The model-mediated path had a different_invocation check against exactly this; strict mode had dropped it. Fixed from both ends — the gateway now rewrites the decision key every turn (writing None when the message is not a decision), and the gate requires decision.timestamp >= pending.created_at plus decision.by == requester, so an approval that predates the action it would authorize is refused and the action re-prompted. Applies to every shipped exposition (HTTP front door, persistent runner, Slack, Google Chat), all of which arm verified_confirmation=True. Two regression tests cover the bypass and the honest flow.
  • Tool exception text no longer reaches the model context (core/orrery_core/reliability/error_handlers.py): graceful_tool_error() returned f"Tool '{name}' failed: {error}", and client exception strings from HTTP/Kubernetes/Kafka embed internal hosts, URLs, and token paths (CWE-209) — which then flowed into the transcript, the session store, and whatever the model said next. The full traceback now stays in the server log and only the exception class is named. The one exception is an HTTP 4xx, whose body describes what was wrong with our own request and is what lets the model correct and retry.
  • Namespace scope guard (NamespaceScopeGuard in core/orrery_core/security/rbac.py): RBAC decided which tool a role could run but never where, and restart_deployment is the same @confirm tool whether it targets payments or kube-system. Set ORRERY_PROTECTED_NAMESPACES (comma-separated globs) to refuse non-admin mutations in infrastructure namespaces; reads are never scoped, so triage still sees everything. The effective namespace is resolved from the call's argument or the tool's signature default, and a namespace that cannot be resolved (required-but-missing, or a non-string) fails closed. Opt-in — unset leaves the guard inert, so existing deployments are unchanged.
  • alter_topic_config re-gated as @destructive (agents/kafka-health): it was @confirm (operator), but retention.ms=1 or moving cleanup.policy off compact destroys a topic's data as surely as deleting it — a data-loss path behind a non-destructive gate that autonomy L3 also allowed. It is now admin-only and restricted to the four data-affecting keys; the everyday tuning it used to cover (message size, compression, min ISR) moved to a new @confirm tune_topic_config, which refuses the destroying keys.

Performance

  • PII redaction no longer holds the event loop, and does ~2x less work (core/orrery_core/plugins/pii_plugin.py). It was the dominant cost of the whole after-tool chain — measured ~60 ms per MiB, 1.28 s on a 20 MiB get_pod_logs result — in an async callback whose body is pure CPU, so every other in-flight request stalled for the duration. It also runs on the uncapped result, since ToolOutputCapPlugin has to stay last in the chain (it returns a replacement, which early-exits ADK's after-tool chain). Two fixes: each secret-value pattern now declares the case-sensitive literals it cannot match without (AKIA, ghp_, eyJ, -----BEGIN, …) and is skipped outright when they are absent — 2x faster on clean log text with byte-identical output — and redaction above 256 KiB moves to a worker thread. Measured on 20 MiB: chain 1445 ms → 785 ms, peak loop stall 1200 ms → 515 ms. (Combining the patterns into one alternation was tried first and measured slower, so it was dropped.)
  • Audit no longer writes the entire tool response (core/orrery_core/observability/audit.py). Running before the output cap meant a 20 MiB result became a 20 MiB log line, per tool call, with a +45 MiB allocation spike — the largest driver of log-ingestion cost on a busy agent, and a copy of infrastructure output into a store with far wider readership than the agent. Responses over MAX_AUDIT_RESPONSE_CHARS (4096) are now recorded as their status plus measured size; smaller ones (the overwhelming majority) stay verbatim so the trail is still debuggable. The status is kept whatever the size. Measured on 20 MiB: 42 ms → 0.9 ms, +45.3 MiB → +0.1 MiB.
  • session_log is bounded (core/orrery_core/observability/activity.py). The log was rewritten whole on every tool call, and ADK's State.__setitem__ copies the assigned value straight into the event's state delta — so event N persisted a list of N entries and a session's write volume grew with the square of its length (a 50-tool triage sweep wrote ~1,275 entries' worth of deltas). Capped at the most recent MAX_SESSION_LOG_ENTRIES (200), which is what the log is actually read for.
  • Consumer-lag calculation is no longer quadratic (agents/kafka-health). get_consumer_lag scanned the committed-offset list once per partition. Measured lookup cost: 0.59 ms at 200 partitions, 16 ms at 1000, 152 ms at 3000 — against 0.56 ms through a dict index.
  • Memory recall is bounded (core/orrery_core/persistence/memory.py). search_memory had no LIMIT and no recency cut-off, and every matching row is JSON-parsed into a Content and then sent to the model — so a recall on a common word ("error", "pod") got slower and more expensive for the life of the deployment, since memory is append-only and every session of ≥4 events is saved. Now returns the newest MAX_SEARCH_RESULTS (200), flipped back to chronological order for the reader.
  • The blocking-tool thread pool is sized from the container's CPU quota (core/orrery_core/concurrency.py, new). Every tool offloads to the loop's default executor, which asyncio sizes at min(32, os.cpu_count() + 4) — and in a pod os.cpu_count() reports the host's cores, so a 64-core node built a 32-thread pool for a container the Helm chart limits to 1000m. configure_default_executor() reads cgroup v2 cpu.max (then v1, then the stdlib) and installs a proportional pool with a floor of 8, called at startup by the HTTP front door and the persistent runner. Override with ORRERY_MAX_WORKER_THREADS.
  • GET /confirmations/pending no longer blocks the event loop (core/orrery_core/serving/server.py). The console polls it every 2.5 s while a request is in flight, and the postgres confirmation backend is a synchronous engine — so each poll was a blocking database round-trip on the loop, per client, on a timer. Now offloaded.

Documentation

  • integrations/web-console.md documents both auth modes and the two Keycloak pitfalls that each present as a generic "Invalid or expired token": realm roles are nested at realm_access.roles, and access tokens carry aud: account unless an audience mapper is added. config/security.md covers dotted role-claim paths; config/general.md gains a Context Compaction section covering the interaction with context caching (a compaction invalidates the cached prefix, so a threshold tuned too low quietly erodes the cache-hit rate).
  • Docs caught up with the Makefile rename, which surfaced references to targets removed long ago — run-kafka-health, run-devops, run-docker, run-journal, run-elasticsearch, run-observability, run-k8s and their -cli variants, all in agent READMEs telling readers to run agents standalone. They now point at the orchestrator, which is how the platform is meant to be run.
  • integrations/web-console.md (new) — enabling, auth posture, what each panel is for, the endpoints it uses, and the deliberate non-goals. Linked from the integrations overview (now listed first, ahead of the developer-facing adk web) and from getting-started. AEP-019 is marked complete, with the two things it deliberately does not do recorded rather than left as open checkboxes: token-by-token streaming (AEP-009) and the remediation act→verify→retry trace, which lives in the batch workflow the console does not host.

[0.2.3] - 2026-07-19

Fixed

  • Web console reliability cleanups (web/): four issues from the app-shell review — (1) a side-pane refresh race where a slow activity/triage/pending fetch for a previous conversation could clobber the one now on screen (useChat now tags each refresh with the conversation it was for and drops stale results); (2) removed the dead sessionId localStorage key (no longer written — sign-out still clears it and any legacy keys); (3) the setState-inside-updater anti-pattern in useConversations (newConversation/deleteConversation no longer call setActiveId inside the setConversations updater — impure under StrictMode); (4) capped conversation history at 50 so localStorage can't grow unbounded and silently stop persisting. 8 new hook tests (useConversations, useAuth sign-out); tsc / eslint / prettier / build all clean.

Added

  • 21 new tools across four specialist agents (fully validated, guarded, and tested — 45+ new unit tests, all suites green):
  • k8s-health (+6, read-only): list_services / describe_service (reports ready vs not-ready Endpoints — the usual cause of "connection refused" when pods look fine), list_configmaps / get_configmap (values truncated), and top_nodes / top_pods (live CPU-millicores / memory-MiB via the metrics API, with a clear message when metrics-server is absent).
  • kafka-health (+4): get_topic_config (splits overridden vs default, masks sensitive), alter_topic_config (@confirm, incremental — other configs untouched), reset_consumer_group_offsets (@destructive, earliest/latest), delete_consumer_group (@destructive).
  • docker-agent (+7): list_networks / inspect_network, list_volumes / inspect_volume, system_df (disk usage by type), and prune_images / prune_containers (@destructive, dangling/stopped by default).
  • observability (+4, read-only): list_prometheus_metrics (discover metric names by substring), get_prometheus_metadata (type/help), get_prometheus_rules (alerting + recording rules and their state), and query_loki_range (LogQL over a relative last-N-hours window).
  • Each agent's instruction gained a short routing note so the model reaches for the new tools appropriately; all mutating tools flow through the existing @confirm/@destructive + requester-verified confirmation gate.

[0.2.2] - 2026-07-19 (tag removed; shipped in 0.2.3)

Changed

  • Docs review: fixed staleness, tightened onboarding docs, and polished the MkDocs site (CONTRIBUTING.md, SECURITY.md, `,mkdocs.yml):CONTRIBUTING.mdwas rewritten — it pointed contributors at a non-existentmake run-kafka-healthtarget (nowmake run-assistant), the wrong validator import (orrery_core.validation→orrery_core.security.validation), and a stale project tree; it now documents the realfmt/lint/test/evalflow, Conventional Commits, the CHANGELOG expectation, the AEP process, and the mock-every-operator-client testing rule.SECURITY.mdnow references the shipped runtime defenses (SafetyScreenPlugin,PIIRedactionPlugin) and the0.xpre-release support line. Brand consistency pass (AI Agents platform→Orrery) across getting-started/integrations/enhancements. MkDocs style: Inter + JetBrains Mono fonts,navigation.prune/content.code.annotate` / instant-progress, and a light CSS polish for tables and code blocks. Strict build passes.

Added

  • Four new enhancement proposals from a Hermes-architecture benchmark (enhancements/aep-020..023): comparing Orrery against the mature Hermes agent architecture surfaced four production-readiness gaps worth adapting to the ADK/Postgres model — AEP-020 conversation context compaction (a pluggable summarizing context engine so long incident sessions don't overflow the model window, with lossless lineage), AEP-021 LLM provider fallback chain (FallbackLlm around resolve_model() reusing the existing circuit breaker so a provider outage/quota isn't a full platform outage), AEP-022 trajectory capture (export real runs → *.test.json eval scenarios + a fine-tune corpus, downstream of PII redaction), and AEP-023 first-class scheduled agent tasks (persisted recurring triage sweeps with run history, L2 read-only by construction). All proposed; wired into the enhancements index, roadmap, and nav.

Fixed

  • Agent evals were failing because the harness didn't mock operator clients (agents/{kafka-health,k8s-health}/tests/test_*_eval.py): the kafka and k8s eval runners mocked only the primary client (Kafka AdminClient / K8s core+apps APIs) but not the operator client (strimzi._custom_objects_api / operators._custom_objects_api). On runs where the model reached for an operator tool, the call hit a live cluster, errored, and polluted the tool trajectory — so exact-match (tool_trajectory_avg_score must be 1.0) collapsed to 0.0 non-deterministically. Both harnesses now mock the operator client too, making the evals hermetic. (Elasticsearch already mocked its ECK client; docker and observability had no operator layer.)

Changed

  • Agents constrained to deterministic tool choice so exact-match evals stay green (agents/{kafka-health,k8s-health,observability}/*/agent.py): the current model (gemini-3.1-pro-preview) legitimately varied its tool set on broad prompts, which strict trajectory matching can't tolerate. Sharpened three instructions — a better product and a stable eval: kafka now uses Strimzi operator tools only when the user explicitly asks about the operator/CRs/connectors/rebalances/MM2/users (not on plain health/topic/lag questions), plus a "be surgical" rule; k8s defines a cluster "overview" as exactly get_cluster_info + get_nodes (namespaces only when asked); observability builds Loki LogQL directly ({job="…"} |= "…") and calls query_loki_logs once instead of a non-deterministic get_loki_labels/get_loki_label_values discovery preamble. Kafka's consumer-lag scenario dataset was updated to include the deliberate describe_consumer_groups member check (the instruction has always paired it with lag — "lag with no members is an outage, not slowness"). All 5 agent eval suites pass. New guide: Agent Evaluations.
  • Cross-session memory is now model-invoked (LoadMemoryTool) instead of preloaded (agents/orrery-assistant/orrery_assistant/agent.py): the chat root swapped PreloadMemoryTool — which searched memory on every turn (query = raw user message) and auto-injected the hits — for LoadMemoryTool, exposing a load_memory function the coordinator calls only when it judges past context useful, with a query it crafts. This keeps trivial turns (greetings, one-off status checks) cheap and gives targeted queries, at the cost of relying on the model to look — so the instruction was updated to say explicitly when to call it (before diagnosing a symptom/alert, or when the user references an earlier incident), when to skip it, and how to query (short system + symptom, at most once per turn). Same memory_service backend and MemoryPlugin write path — no service or storage change. Docs (memory.md, README, agents-overview.md, CLAUDE.md) updated; the ADR-003/AEP-003 records keep their original PreloadMemoryTool wording as point-in-time history.
  • Web console redesigned into an app shell (Tailwind CSS v4) (web/): the single-column layout is now a left sidebar (brand, New chat, Run triage, conversation history, identity/role footer) + chat column + a toggleable right inspector panel with two tabs — a clean tool-calls table (time / tool / agent / status, replacing the inline list) and the triage report rendered as prose (auto-opens when a verdict lands). Adds client-side conversation history: since the server has no "list sessions" endpoint, each conversation (full transcript + server sessionId) is kept in localStorage, titled from its first message, and switchable from the sidebar — selecting one reloads its tool-call/triage panes from the server. Styling moved from ~640 lines of hand-written CSS to Tailwind v4 (@tailwindcss/vite) with @tailwindcss/typography for markdown; dark mode still follows prefers-color-scheme. No API or server changes — same endpoints, same requester-verified confirmation flow.

Added

  • Web console: triage view — AEP-019 Milestone 2 (core/orrery_core/serving/server.py, web/): a Run triage header button sends the canned sweep prompt to the incident_triage_agent, and a new owner-scoped GET /session/{id}/triage endpoint returns the recorded verdict (incident_severity + triage_report — ADK's AgentTool forwards sub-session state deltas to the parent session, so the chat-root triage lands in the HTTP session's state). The console renders it as a severity banner (healthy / degraded / critical, theme-aware) with the full report collapsed inside as markdown, and the tool-call timeline is now polled every 2.5s while a request is in flight, so multi-specialist sweeps become visible as each specialist completes — the closest thing to progress until streaming (AEP-009). 5 new server tests + 2 console tests.
  • Web console: tool-call timeline + confirmation UI — AEP-019 Milestone 1 complete (core/orrery_core/serving/server.py, web/): two new read endpoints and their renderers. GET /session/{id}/activity returns the session's tool-call log (recorded by ActivityPlugin under session_log); the lookup pins user_id to the verified JWT subject, so another user's session id is a plain 404. The console renders it as a collapsed timeline under the transcript — which specialist ran which tool, with what outcome. GET /confirmations/pending surfaces the caller's own guarded action awaiting a decision (strict-mode pendings are requester-scoped, so the endpoint can only ever show your own); the console renders an Approve/Deny panel whose buttons send the literal words approve/deny through the normal POST /chat flow — rendering only, the requester-verified gate remains the sole authority on who may approve. New public accessor latest_pending_for_scope() in guardrails; 11 new server tests + 6 new console tests (incl. an end-to-end approve flow).
  • Runtime security hardening — AEP-013 completed (core/orrery_core/plugins/safety_plugin.py, pii_plugin.py, agent/base.py): three content-level defenses, all on by default in default_plugins(). SafetyScreenPlugin blocks prompt-injection messages ("ignore previous instructions", "reveal your system prompt", "bypass the guardrails", ...) in before_run_callback — the one plugin hook whose non-None return halts the runner — so a screened message never reaches the model, costs no tokens, and cannot influence a tool call (ORRERY_SAFETY_SCREEN=false disables). PIIRedactionPlugin scrubs credentials from every tool result — credential-named dict keys replaced outright, plus value-pattern scanning for password=... pairs, PEM blocks, AWS/GitHub/Slack/OpenAI token shapes, and JWTs — by mutating the result in place and returning None, because ADK's after-tool chain early-exits on the first non-None return and a returned copy would silence every later observer; it registers before AuditPlugin so the audit log records redacted values too (ORRERY_PII_REDACTION=false disables; ORRERY_REDACT_IPS=true additionally redacts IPv4s — off by default since an SRE agent that can't see pod IPs can't diagnose much). Gemini content-safety filters: create_agent() attaches GenerateContentConfig safety settings (dangerous content / harassment / hate speech / sexually explicit) to Gemini models at BLOCK_ONLY_HIGH (GEMINI_SAFETY_THRESHOLD overrides; GEMINI_SAFETY_FILTERS=false disables; LiteLLM providers unaffected). 41 new tests.
  • Supply-chain hardening — AEP-014 completed (Dockerfile, .github/workflows/{ci,release}.yml, deploy/k8s/imagepolicy.yaml, supply-chain.md): all three base images are now pinned by digest (tag@sha256:...; Dependabot's docker ecosystem bumps them), a CycloneDX Python SBOM of the frozen dependency graph is generated on every CI build (artifact) and attached to each GitHub release (asset), the pushed image is Trivy-scanned before the release job runs — HIGH/CRITICAL fixable CVEs fail the workflow so no release is cut for a vulnerable image, with SARIF uploaded to GitHub Code Scanning — and PRs gain a dependency-review gate (fail-on-severity: high). An opt-in Sigstore ClusterImagePolicy (deploy/k8s/imagepolicy.yaml) lets clusters refuse any ghcr.io/bahalla/orrery* image not signed by this repo's release workflow. The verification flow (cosign verify, SBOM download/scan) is documented in Supply Chain Security. Cosign keyless signing and buildx SBOM/provenance attestations had already landed with AEP-011.

Fixed

  • Web console leaked conversation history across sign-out and trusted stored data (web/src/auth/useAuth.ts, web/src/conversations/useConversations.ts): sign-out cleared the token but left the conversations/activeConversation localStorage keys, so on a shared browser the next user saw the prior user's full transcripts — sign-out now clears all user-scoped keys. Separately, load() blindly cast stored data (as Conversation[]); a corrupt or old-schema entry could throw during render and white-screen the app, so it now validates each entry against the current shape and drops anything malformed.
  • PII redaction missed camelCase credential keys (core/orrery_core/plugins/pii_plugin.py): SENSITIVE_KEY_PATTERN required a _/-/. separator before the credential word, so dbPassword / accessToken / AccessToken leaked while db_password was caught. Keys are now normalized to snake_case (camel boundaries → underscores) before both the sensitive-match and the allowlist check — which also keeps nextPageToken mapping onto the allowlisted next_page_token instead of being redacted as a token (the naive fix of a case-insensitive camel lookaround in the regex would have broken exactly that). Also: token now matches singular-only — plural endings (total_tokens, maxTokens) are LLM usage counts, not credentials, and were being scrubbed.
  • Security: docker specialist's destructive tools ran without confirmation on the HTTP/console path (agents/docker-agent/docker_agent/agent.py): the GuardrailsPlugin enforces RBAC only — human-in-the-loop confirmation comes from each agent wiring before_tool_callback=require_confirmation(), and four of the five specialists did; docker never had it, so an admin asking the console to delete an image got an immediate unconfirmed remove_image (Slack/Google Chat were unaffected — those transports walk the whole agent tree and wire their own gate). The docker agent now wires the gate like its peers, and a new structural test walks the chat root's AgentTool tree and fails if any agent exposes a @confirm/@destructive tool without a before-tool gate, so this class of gap cannot ship again.

Changed

  • Web console renders assistant replies as markdown (web/src/components/MessageList.tsx): agent replies are markdown (lists, bold, inline code, tables) and were displayed raw — literal ** and * bullets. Assistant bubbles now render through react-markdown + remark-gfm (React elements, no dangerouslySetInnerHTML, so no sanitizer needed) with theme-aware typography for lists, code blocks, and scrollable tables; user messages stay plain text.

[0.2.1] - 2026-07-18 (tag removed; shipped in 0.2.3)

Added

  • Web console MVP (AEP-019, Milestone 1) (web/, core/orrery_core/serving/server.py): a Vite + React + TypeScript single-page console served by the existing FastAPI front door — bearer-token gate (token persisted locally, sent as Authorization on POST /chat), chat threading the server-issued session_id, an identity + role badge (decoded client-side for display only; the server remains authoritative for RBAC), and loading/error/network/auth-expiry states with theme-aware, accessible CSS. Opt-in via ORRERY_WEB_CONSOLE_ENABLED (default off): the built bundle mounts at / after the API routes so explicit routes always win, and a missing bundle is warn-and-skip, not a crash — the static shell is public, /chat stays JWT-gated. The Docker image builds the bundle in a node:22 stage and copies only dist/ into the Python image (no Node in the runtime image); the Node toolchain is isolated from the uv workspace, so make test stays Node-free. Dedicated web CI job (eslint + prettier + tsc strict + vitest).
  • One platform-wide confirmation store and flow for guarded tools (core/orrery_core/security/confirmation_store.py + confirmation_flow.py): the pending-approval handshake was implemented three times — core strict mode, the Google Chat bot, and the Slack bot — with drifting semantics (Slack had no TTL, no args-hash pinning, no approval-validity window, and a session-state retry flag that let any retry through once a pending was raised; the Postgres backend existed twice). All transports now share one PendingConfirmation record and one store (memory | postgres via ORRERY_CONFIRMATION_BACKEND, over the existing DATABASE_URL), scoped per transport (requester for HTTP/CLI strict mode; thread/space for Chat; channel/thread for Slack), plus shared flow primitives: raise_pending, approval_refusal (requester-only, fail-closed), blocked_payload, hash_args, and the agent-tree walker wire_before_tool_callback. One-shot guarantees are uniform and atomic (single DELETE … RETURNING on postgres — racing replicas cannot both consume a decision; a lock on memory), approvals are args-hash-pinned with a 120s validity window everywhere, and AgentGateway(verified_confirmation=True) resolves the backend eagerly so a misconfigured postgres backend fails at startup (same fail-fast contract as the session store). Slack hardening: the bot inherits TTL, args-hash pinning, the approval-validity window, the AgentTool-boundary-safe handshake, and the optional Postgres backend (multi-replica) for free. Breaking (unreleased surface only): GOOGLE_CHAT_CONFIRMATION_BACKEND is replaced by ORRERY_CONFIRMATION_BACKEND (the Helm value pubsubWorker.confirmation.backend is unchanged and now exports the new env var), and the unreleased orrery_gchat_confirmations / orrery_pending_confirmations tables are superseded by one orrery_confirmations table (entries are 5-minute-TTL ephemera; abandoned tables can be dropped). The multi-replica motivation: a process-local pending store quietly contradicted both AEP-018's backlog HPA on the Pub/Sub worker and any multi-replica HTTP deployment — a pending raised on pod A was invisible to the pod that received the operator's approval (or the LLM's consuming retry), so the flow died. With postgres, the handshake also survives pod restarts, and the Helm chart still refuses to render a multi-replica Pub/Sub worker while the backend is memory, mirroring the idempotency guard.

Changed

  • All agent prompts standardized on a shared SRE operating doctrine (core/orrery_core/agent/prompts.py + every agent's agent.py): all 13 instruction sets now compose a shared OPERATING_PRINCIPLES block — verdict-first answers, report only what a tool returned this turn (a failed call is itself a finding; unknown is never healthy), minimal sufficient calls for targeted questions, and read-back verification after any state-changing action — plus a CONFIRMATION_RULE block (on a confirmation_required result: relay to the user and stop, never self-approve). Triage checkers gained a fixed report contract (STATUS: healthy | degraded | critical | unknown; missing data is never reported healthy), the summarizer enforces strict verdict rules (critical if any system is critical; degraded on any degraded or unverified system; healthy only when everything affirmatively reports healthy; record_triage_verdict exactly once), and the remediation actor/verifier were hardened (one action per iteration, smallest blast radius first; the verifier checks the original symptom is gone, not merely that the action ran). _PROBLEM_SIGNALS now also flags status: unknown / unverified / unreachable reports.
  • Env var renamed: CONTEXT_CACHE_MIN_TOKENS → CONTEXT_CACHE_MIN_LENGTH (core/orrery_core/serving/runner.py, .env.example, Helm values, docs): Trivy's KSV-0109 check flags ConfigMap keys containing TOKEN as potential secret leaks; renaming the key beats carrying a permanent ignore rule. The old name (shipped in 0.2.0) is still honored as a fallback with a deprecation warning, so existing deployments keep their setting — but update your env files and Helm values.
  • Dependencies: google-adk >= 2.4.0 (Postgres-backed tests now skip when the database is unreachable instead of failing), litellm >= 1.91.2 (root floor and the uv override-dependencies pin aligned — the override silently replaced the floor before), uvicorn >= 0.51.0, confluent-kafka >= 2.15.0, slack-bolt >= 1.29.0, google-auth >= 2.55.2; dev tooling ruff >= 0.15.21, ty >= 0.0.58, hypothesis >= 6.156.6; CI astral-sh/setup-uv 8.3.2; uv.lock refreshed.

Fixed

  • Destructive-tool confirmation looped forever through an AgentTool (core/orrery_core/security/guardrails.py, agents/orrery-assistant/orrery_assistant/agent.py): in requester-verified (strict) mode the pending confirmation was stored in tool_context.state, but guarded tools are reached through an AgentTool (the chat root delegates to a specialist) and every AgentTool call runs the specialist in a fresh, throwaway sub-session. The pending written during the request turn was therefore gone by the turn the human's approve arrived, so the approved branch was structurally unreachable — the gate re-prompted every time and the root model spun, re-calling the specialist with fabricated approvals ("approve", "Force restart…", "I explicitly approve…") until it hit its iteration cap. Two fixes: (1) strict-mode pendings now live in a process-level PendingConfirmationStore keyed by requester (the one identity the gateway forward-propagates into every sub-invocation and that is stable across turns), so a real approval in a later, different session resolves the pending raised earlier; the "only the original requester may approve" rule is now enforced by the key itself (fail-closed on an unknown requester). (2) The chat-root instruction now mandates: on a confirmation_required result, relay it to the user and stop — never self-approve or re-call the specialist in the same turn. Model-mediated (non-strict) mode is unchanged. Regression test drives a @destructive tool across two distinct sub-sessions and asserts the approval resolves. Note: PendingConfirmationStore is process-local; multi-replica HTTP deployments need a shared backend, mirroring the Google Chat store above.

[0.2.0] - 2026-07-11 (tag removed; shipped in 0.2.3)

Added

  • Autonomy levels (L2/L3/L4) (core/orrery_core/plugins/autonomy_plugin.py): a third access-control axis, orthogonal to RBAC — RBAC answers who may act, autonomy answers which mode this process runs in. L2 is read-only fail-closed (only unguarded tools + an explicit whitelist run), L3 allows mutations but blocks @destructive (+ blacklist), L4 allows destructive tools behind ADK-native request_confirmation. Classification comes from the same @confirm/@destructive metadata RBAC uses. Opt-in: registered only when ORRERY_AUTONOMY_LEVEL (or default_plugins(autonomy_level=...)) is set, with a per-request session.state["autonomy_level"] override. Blocked calls return a structured deny dict so audit/metrics still observe them.
  • Tool output cap (core/orrery_core/plugins/output_cap_plugin.py): ToolOutputCapPlugin bounds every tool result to max_tool_result_bytes (default 4 MiB, 0 disables). One chatty logs call or wide Elasticsearch result is otherwise re-sent with the whole session every turn until the Gemini/Vertex ~10 MiB request limit kills the run with 400 INVALID_ARGUMENT. Trimming is structure-preserving — the longest string field or list is trimmed element-wise so the JSON stays parseable, small status fields survive — with a truncation note telling the model to narrow its query. Runs last among the after-tool observers (ADK's after-tool chain early-exits on the first non-None return).
  • Requester-verified confirmation (core/orrery_core/security/guardrails.py, serving/gateway.py): the @confirm/@destructive gate no longer trusts a model re-call as proof a human said yes. With AgentGateway(verified_confirmation=True) — now enabled on every shipped exposition (HTTP server, persistent runner, Slack bot, Google Chat bot) — the gate requires an explicit decision stamped by the gateway from the transport's verified sender: a deliberate approve word (approve/confirm/proceed/go ahead; a casual "ok"/"yes" doesn't authorize) by the same user who triggered the pending action (fail-closed: unknown requester, second-person approvals, stale decisions, and changed args all refuse), while deny is broad. Dev surfaces (adk web, CLI, evals) keep the model-mediated flow.
  • Per-turn caller identity (core/orrery_core/agent/base.py): create_agent() wraps every instruction in an identity_aware_instruction provider that appends "who you are talking to THIS turn" whenever a transport stamped the turn's actor (the gateway stamps msg.user_id automatically; _auth.subject is the fallback) — so in shared threads the model acts for the current sender and never reports a tool's service account as the user. Side effect: prompts are used verbatim (no {var} state templating; literal braces are safe). base_instruction() unwraps the provider for tests.
  • Audit attempt records (core/orrery_core/observability/audit.py): AuditPlugin now emits a tool_attempt event in before_tool — registered ahead of the gate plugins — so a call that a gate denies, or that crashes mid-tool, still leaves an audit record of what was attempted; the outcome (including a gate's deny status) is audited after the call as before.
  • AgentGateway — one shared turn pipeline (core/orrery_core/serving/gateway.py): every transport (HTTP, Slack, Google Chat, CLI) used to re-implement the same five steps — build a Runner, resolve identity, map a conversation to an ADK session, run the agent, extract the reply. A ports-and-adapters gateway now owns that pipeline (InboundMessage/OutboundReply, ChannelAdapter, pluggable SessionResolvers), with sessions in PostgreSQL (DATABASE_URL) or in-memory.
  • Pub/Sub worker idempotency (AEP-018) (agents/google-chat-bot/google_chat_bot/idempotency.py): Google Chat's Pub/Sub transport is at-least-once, so a redelivered event (lost ack, pod OOM, handler-timeout nack after side effects) would double-run @destructive tools — restart/scale/rollback a deployment, increase Kafka partitions, silence Alertmanager. The worker now claims each event id before dispatching and drops duplicates (ack without re-executing); a failed handler releases the claim so a legitimate redelivery still retries (at-most-once side effects without losing failed work). New IdempotencyStore protocol with two backends: InMemoryIdempotencyStore (bounded + TTL, single-replica) and PostgresIdempotencyStore (INSERT … ON CONFLICT DO NOTHING, shared across replicas via the existing DATABASE_URL — no new infrastructure). Dedup key is the Chat eventId, falling back to a stable content hash. 33 unit tests including concurrent-claim races on both backends.
  • Backlog-based autoscaling for the Pub/Sub worker (AEP-018) (deploy/helm/orrery-assistant/templates/pubsub-worker-hpa.yaml): an HPAv2 scaling on the subscription's num_undelivered_messages External metric (CPU/memory are useless for an LLM-I/O-bound worker), behind pubsubWorker.autoscaling.enabled. The chart fails to render a multi-replica worker (replicaCount > 1 or autoscaling on) while idempotency.backend == memory, making the split-brain misconfiguration impossible to ship.

Changed

  • Google Chat confirmations decide by thread reply; buttons are opt-in (agents/google-chat-bot/): a card button click is resolved by Google's Workspace add-ons runtime with a synchronous HTTPS round-trip that a Pub/Sub pull worker cannot answer — the click fails on Google's side with error code 3 ("The Chat app didn't respond or its response was invalid") before the worker sees it. The confirmation card now asks the operator to reply approve or deny in the card's thread (a plain MESSAGE event, which Pub/Sub always delivers with the thread attached); the handler resolves the reply against the thread's pending confirmation with the same semantics as a click — requester-only approve (fail-closed), broad deny, deliberate approve words only, synthetic re-run + one-shot consume_approved. Inline ✅/❌ buttons (and the triage card's 🔧 Run-remediation button) render only with GOOGLE_CHAT_INTERACTIVE_BUTTONS=true, for HTTP-endpoint deployments where clicks can complete.
  • Plugin registration order (core/orrery_core/plugins/__init__.py): default_plugins() now registers Audit before the gates (ADK's before-tool chain early-exits on the first non-None return, so anything after a deny never runs) and the output cap last among after-tool observers; the canonical order is documented in the module docstring.
  • Core reorganized into aspect subpackages (core/orrery_core/{agent,observability,persistence,plugins,reliability,security,serving,tools}/), with re-exports preserved at orrery_core.*. Database sessions now fail fast when DATABASE_URL is set but unreachable (opt-out via ORRERY_DB_ALLOW_INMEMORY_FALLBACK), and the Slack bot resolves the sender's role per turn instead of per session.
  • Build & CI: explicit build backends + unified package discovery across the workspace; CI reuses the Makefile targets so the quality gate is defined once; release checks gate on tag pushes only; kubeconform actually validates the rendered Helm manifests; make gains a verify-only check (lint + ty + test) with derived ty search paths, and the per-agent runner targets were dropped (run the orchestrator — it composes every specialist).
  • Google Chat bot config: google_chat_interactive_buttons (default false) plus three worker settings — google_chat_pubsub_idempotency_backend (memory | postgres) and ..._ttl_seconds — plus Helm pubsubWorker.idempotency.* / pubsubWorker.autoscaling.* values. Documented in Google Chat: Pub/Sub Setup.
  • Dependencies: google-adk >= 2.3.0, ruff >= 0.15.20, ty >= 0.0.56, hypothesis >= 6.156.1, actions/checkout@v7; uv.lock refreshed.

Removed

  • Google Chat Quick Commands: all APP_COMMAND handling is gone (event detection, both app-command handlers, appCommandPayload extraction fallbacks). No commands need to be configured in the Chat API console anymore — decisions are thread replies (or opt-in buttons).

Fixed

  • Pub/Sub worker: Helm now refuses to render a multi-replica worker with the in-memory idempotency backend (HPA/replica conflict guard), and the malformed-payload catch is scoped to decode errors instead of swallowing everything.

Security

  • Approvals are requester-only on every surface: the Slack and Google Chat click/reply handlers refuse an approval from anyone but the verified user who triggered the guarded action (fail-closed — an unidentifiable decider is refused too; the pending survives so the requester can still decide), while deny stays open to anyone. Previously any channel member could approve someone else's destructive action.

Documentation

  • AEP index gains colored status/priority badges; ADR-003 and guardrails schemas converted to Mermaid; diagrams and wide content restyled for legibility; images centralized under images/; guardrails and Google Chat integration docs rewritten for the two confirmation modes (including why buttons can't work over Pub/Sub).

[0.1.11] - 2026-06-21

Added

  • Distributed tracing with OpenTelemetry (AEP-010) (core/orrery_core/tracing.py): New configure_tracing() installs a process-global TracerProvider exporting to an OTLP collector (Tempo, Jaeger, Cloud Trace, …) with a console fallback for local dev — idempotent and gated by OTEL_TRACING_ENABLED. ADK 2.0 already emits native spans for agent / tool / LLM calls under the gcp.vertex.agent tracer, so the new TracingPlugin enriches the current span (orrery.request_id, orrery.user_role, orrery.tool.status / result_size, exception recording) rather than creating duplicate spans; after_model only bridges token counts into track_llm_tokens() since ADK already records gen_ai.usage.*. default_plugins(enable_tracing=None) resolves the flag from OTEL_TRACING_ENABLED and prepends the plugin first, so a single env var turns tracing on across every transport (Google Chat, Slack, HTTP server, persistent runner) with no per-agent wiring — a missing [otel] extra is a skip-with-warning, not a crash.
  • Log ↔ trace correlation (core/orrery_core/log.py): JSONFormatter now stamps request_id (a dependency-free ContextVar) plus trace_id / span_id (lazy OpenTelemetry lookup, omitted when the extra isn't installed or no span is active) onto every log record, so a log line can be pivoted straight to its trace.
  • Local tracing stack (docker-compose.yml, infra/tempo.yml, infra/grafana-datasources.yml, infra/grafana-dashboards.yml, infra/dashboards/orrery-observability.json): make tracing-up brings up Tempo (OTLP ingest + storage) and Grafana under the tracing compose profile, with provisioned datasources (Tempo + Prometheus, trace→metric linked) and an Orrery — Agent Observability dashboard (tool call rate, p95 latency, error rate, LLM tokens/s, circuit-breaker state, and a live trace table). New tracing-down target.
  • [otel] extra on orrery-core (OpenTelemetry SDK + OTLP gRPC exporter), surfaced at the workspace root as orrery[otel]. Imported lazily — the rest of the package never requires it.
  • Tests: 11 new cases in core/tests/test_tracing.py (configure idempotency + disabled path, span enrichment via an in-memory exporter, token→metrics bridge, log/trace correlation), with a hermetic autouse fixture that resets the global provider and the ambient OTEL_TRACING_ENABLED so the suite stays deterministic.

Fixed

  • make install resolution failure: google-adk[eval] transitively caps litellm < 1.86.0 (via google-cloud-aiplatform[evaluation]), which is unsatisfiable against the litellm >= 1.89.3 floor — no version pair satisfies both. Added a uv override-dependencies = ["litellm>=1.89.3"] entry so the workspace resolves; the cap only guards aiplatform's evaluation path (exercised by make eval), and AgentEvaluator imports cleanly on litellm 1.89.3.

Changed

  • Pinned GitHub Actions bumped alongside the dependency upgrades: astral-sh/setup-uv v5 → v8.2.0 (ci, docs, release), docker/build-push-action v6 → v7, sigstore/cosign-installer v3 → v4.1.2. setup-uv and cosign-installer are pinned to full versions because they don't publish moving major tags for v8 / v4.
  • Docker image now installs the otel extra (Dockerfile): the runtime image is built with --extra postgres --extra server --extra otel, so OTEL_TRACING_ENABLED=true actually emits traces from the shipped container — previously OpenTelemetry was absent from the image and tracing silently no-op'd in deployment.
  • Release pipeline hardening (.github/workflows/release.yml): the GitHub Release body is now built into a file and passed via body_path, fixing a long-standing bug where the inline $(cat docker_info.md) was never shell-evaluated and the Docker-images section rendered literally. The Helm kubeconform step now actually validates the rendered manifests (it previously only installed kubectl and validated nothing). Trivy renders the Helm chart against TRIVY_KUBE_VERSION=1.28.0 so it's no longer skipped for misconfiguration scanning.

Security

  • Three transitive HIGH advisories cleared from the lock (caught by the Trivy CI gate): cryptography 46.0.5 → 49.0.0 (GHSA-537c-gmf6-5ccf — vulnerable OpenSSL bundled in the wheels, fixed in 48.0.1), python-multipart 0.0.28 → 0.0.32 (CVE-2026-53539 — quadratic-time querystring parsing with semicolon separators causing CPU DoS, fixed in 0.0.30), and starlette 1.2.1 → 1.3.1 (CVE-2026-54283 — request.form() size limits silently ignored for application/x-www-form-urlencoded, enabling DoS). pyopenssl 26.2.0 → 26.3.0 came along transitively. Lock-only change; 817 tests still pass.

Documentation

  • Observability page consolidated (metrics.md, retitled Observability in the nav): metrics and distributed tracing now live on one page — the three-signal overview, metrics reference, and the full tracing guide (the Grafana Tempo trace-waterfall screenshot, what the spans carry, log↔trace correlation, and the make tracing-up local stack + dashboard). config/general.md keeps a compact tracing env-var table that links to it, so tracing is discoverable under Platform Features → Observability rather than buried in configuration. The homepage Observability card and "Observable by Design" principle now mention traces, the CLAUDE.md architecture section gains a tracing bullet, and AEP-010 is marked completed (with an implementation note on how the final design diverged from the original proposal and why exemplars were deferred).
  • mkdocs navigation polish: dropped navigation.expand for a cleaner collapsed sidebar and added navigation.footer (prev/next), navigation.instant(+.prefetch), toc.follow, and search.share.

[0.1.10] - 2026-06-14 (tag removed; shipped in 0.1.11)

Added

  • Two-root ADK 2.0 architecture (ADR-003): The orchestrator is split into two entrypoints that reuse the same node agents. The interactive root is orrery_chat_agent, a chat-mode LlmAgent that keeps conversation history and routes free-form queries to the six specialist AgentTools + memory, plus an incident_triage_agent AgentTool for single-turn full sweeps. The batch root is orrery_triage_workflow, a deterministic graph Workflow (make run-triage) — parallel health checks → health_join → triage → conditional remediation. A chat-mode agent must be a root (ADK forbids it as a routed graph node) and a Workflow can't be an AgentTool, so the two are separate by design.
  • Remediation Subgraph: The self-healing loop is now expressed via explicit graph edges (remediation_actor → remediation_verifier → verify_route), strictly bounded by the MAX_REMEDIATION_ITERATIONS state counter instead of the legacy LoopAgent. The verifier signals success via the mark_remediation_resolved tool (replacing exit_loop / actions.escalate).
  • Fail-safe triage routing: triage_route infers severity from the per-system status reports and flags triage_verdict_missing when the LLM skips record_triage_verdict, so a degraded system is never silently routed to "resolved".
  • End-to-end graph flow tests: agents/orrery-assistant/tests/test_graph_flow.py drives the real routing nodes through InMemoryRunner (parallel fan-out/join, intent dispatch, bounded remediation loop, missing-verdict fallback) with no LLM credentials required.
  • Confirmation walkers traverse graph nodes: the Slack and Google Chat confirmation wiring now walks Workflow.graph.nodes, so guarded destructive tools on graph-node agents still fire interactive approvals.
  • Shared reply-text extraction helper: New extract_reply_text() in core/orrery_core/events.py (exported from orrery_core) builds user-facing text from a runner event while skipping ADK thought parts. All four transports (Google Chat, Slack, HTTP, CLI) now funnel through it instead of each iterating content.parts — one place to maintain the thought-filtering rule, so a new transport inherits it for free.
  • create_agent(mode=...) passthrough: create_agent now accepts ADK 2.0's mode (chat / task / single_turn). The interactive root orrery_chat_agent sets mode="chat" explicitly so it retains conversation history across turns — previously it reverted to greeting-style replies on follow-up questions because the root's delegation mode was inferred rather than pinned.

Fixed

  • Planner reasoning leaked into user replies: With a planner active (Gemini builtin thinking, plan_react, or a reasoning model via LiteLLM), the model's thought/reasoning parts were concatenated into the answer shown to users — so Google Chat, Slack, the HTTP /chat API, and the CLI all rendered the chain-of-thought ("Checking Kubernetes Status…") above the actual response. ADK normalizes every form of reasoning onto part.thought = True (native thinking, PlanReActPlanner /*PLANNING*/ phases via _mark_as_thought, and LiteLLM Anthropic/OpenAI reasoning via _convert_reasoning_value_to_parts); all four transports now skip thought parts, so reasoning never reaches the user regardless of provider.
  • Stale ADK 1.x references in docs: core/README.md documented the removed create_sequential_agent() / create_parallel_agent() factories — replaced with the graph Workflow composition pattern. agents/orrery-assistant/README.md was rewritten for the ADR-003 chat-root + triage-Workflow architecture (was still showing the old SequentialAgent/ParallelAgent sub-agent tree and missing the Elasticsearch checker and remediation loop). agent-design-patterns.md and a few internal docstrings updated likewise.
  • Whole-workspace test collection: switched pytest to --import-mode=importlib and added agents/google-chat-bot/tests to testpaths. The Google Chat bot's 93 tests were previously excluded from the suite (its test_auth.py / test_app.py / test_handler.py basenames collided with other packages under the legacy prepend import mode), so they never ran in CI. The full suite is now 806 tests collected in a single run.

Removed

  • Deprecated Agent Factories: Removed create_sequential_agent, create_parallel_agent, and create_loop_agent from orrery_core.base as they are deprecated in ADK 2.0 in favor of the Workflow API.
  • Legacy Routing Evals: Removed the planner_routing eval that asserted the old root-level LLM routing. The orrery-assistant root no longer has a routing eval — the chat root's specialist dispatch and the triage Workflow's deterministic FunctionNode routing are exercised by unit tests instead.

Security

  • CVE remediation across the dependency graph: Bumped litellm 1.82.6 → 1.83.14 to address 7 advisories — 2 CRITICAL (CVE-2026-35030 OIDC auth bypass / privilege escalation, CVE-2026-42208 SQL injection) and 5 HIGH (CVE-2026-35029 RCE via unrestricted proxy config, CVE-2026-40217 arbitrary code execution via bytecode rewriting, CVE-2026-42203 SSTI in /prompts/test, CVE-2026-42271 authenticated command execution via MCP stdio test endpoints, GHSA-69x8-hrgq-fjj8 password hash exposure / pass-the-hash bypass). Five transitive HIGH-severity bumps came along for free: mako 1.3.10 → 1.3.12 (CVE-2026-44307 Windows path traversal via backslash URI), pyasn1 0.6.2 → 0.6.3 (CVE-2026-30922 unbounded-recursion DoS), pyopenssl 25.3.0 → 26.2.0 (CVE-2026-27459 DTLS cookie callback buffer overflow), python-multipart 0.0.22 → 0.0.28 (CVE-2026-42561 unbounded multipart-header DoS), and urllib3 2.6.3 → 2.7.0 (CVE-2026-44431 cross-origin sensitive-header forwarding, CVE-2026-44432 decompression-bomb safeguard bypass).
  • Trivy filesystem scan in CI (.github/workflows/ci.yml — security job): New aquasecurity/trivy-action@0.28.0 step runs scan-type: fs with scanners: vuln,misconfig,secret against the workspace, gating on HIGH,CRITICAL with ignore-unfixed: true and exit-code: 1. Catches the vulnerable-dependency class that Bandit (a Python static analyser) doesn't see, plus IaC misconfig in Dockerfile / deploy/terraform/, plus committed secrets.
  • Bandit now scans every file: Pinned the uvx invocation to --python 3.14 so the AST parser matches our requires-python floor. Before this, GitHub's runner Python (3.12) silently skipped core/orrery_core/rbac.py, core/orrery_core/runner.py, and agents/google-chat-bot/google_chat_bot/pubsub_worker.py with "syntax error while parsing AST" — they parse Python 3.14 syntax — meaning those modules weren't being security-scanned at all. The skip is now caught by the report (0 files skipped) and the modules participate in the scan.
  • Hardcoded /tmp removed from the test suite (core/tests/test_secrets.py:117): test_filebackend_satisfies_protocol now takes pytest's tmp_path fixture instead of passing "/tmp" to FileBackend, resolving the Bandit B108 (hardcoded_tmp_directory) Medium finding that was breaking the bandit -ll gate.

Changed

  • ADK 1.x → 2.0 upgrade: Bumped google-adk to >=2.2.0 and added the [db] extra (ADK 2.0 moved sqlalchemy/DatabaseSessionService out of the base package). run_persistent() / create_app() now accept an Agent | Workflow root. litellm is re-pinned to >=1.83.14,<1.86.0 (ADK 2.2.0 transitively caps it below 1.86 via google-cloud-aiplatform[evaluation]; resolves to 1.85.4, retaining the CVE fixes in this release).
  • Workspace pinned to Linux + macOS resolution environments (pyproject.toml — new [tool.uv] block with environments = ["sys_platform == 'linux'", "sys_platform == 'darwin'"]): The project depends on Docker daemon, Kubernetes client, and confluent-kafka — none of which are supported on Windows in the form we consume them. Restricting the lock-file environments lets uv resolve a coherent dependency graph instead of failing on phantom Windows-only conflicts (the litellm bump above surfaced one). No runtime change; this only affects what platform combinations uv considers when locking.
  • Relaxed two version floors to satisfy litellm's strict transitive pins: pydantic>=2.13.3 → pydantic>=2.12.5 (litellm 1.83.14 hard-pins pydantic==2.12.5) and aiohttp>=3.13.5 → aiohttp>=3.13.3 on agents/slack-bot (litellm 1.83.10–13 pin aiohttp==3.13.3, 1.83.14 pins ==3.13.4). Resolver picks the highest version that satisfies every constraint; in practice the lock holds at the litellm-mandated floors. Tests at 693/693 after the changes — if you adopt pydantic 2.13-specific behavior later, this floor should be raised back.

Added

  • Authentication layer for the HTTP front door (AEP-013, first slice) (core/orrery_core/auth.py, core/orrery_core/server.py, core/orrery_core/secrets.py): New verify_token() supports both HS256 (shared secret, for dev/gateway-fronted deployments) and RS256/ES256 via JWKS (production IdPs — Auth0, Keycloak, Okta, Google IAP, GitHub OIDC). JWTConfig.from_env() reads JWT_ALGORITHM / JWT_SECRET / JWT_JWKS_URL / JWT_AUDIENCE / JWT_ISSUER / JWT_ROLE_CLAIM / JWT_LEEWAY_SECONDS. extract_role() maps claim lists or space/comma-separated strings to viewer / operator / admin with namespace aliases (orrery-admin, orrery_operator). New AuthPlugin reads the verified _auth payload from session state and calls set_user_role() so the existing RBAC enforcement becomes trust-rooted. Opt-in via default_plugins(enable_auth=True) — zero behavior change for existing deployments.
  • Authenticated FastAPI runner (core/orrery_core/server.py): New create_app() mounts the ADK Runner behind a HTTPBearer dependency. POST /chat requires a valid JWT, seeds new sessions with the verified _auth payload, and re-stamps long-lived sessions so a re-minted token with a downgraded role applies immediately. GET /healthz and GET /readyz are auth-free. auth_enabled=False is supported for local dev with an explicit warning and pins anonymous callers to viewer so RBAC still gates writes. Eager cfg.jwt.validate() at startup fails fast on misconfiguration rather than at first request.
  • Pluggable secrets manager (core/orrery_core/secrets.py): SecretsManager resolves in priority order — explicit backends → FileBackend(ORRERY_SECRETS_DIR) → env vars → caller default. Auto-installs the file backend when the env var points at a real directory, so Kubernetes Secret volumes work with no code changes. SecretsBackend Protocol leaves Vault / GCP Secret Manager / AWS Secrets Manager adapters as a future PR.
  • Tests: 60 new cases across core/tests/test_auth.py (extract_role matrix, JWTConfig.validate, HS256 round-trip + signature / audience / issuer / expiry / leeway / missing-sub / missing-exp failure modes, RS256/JWKS happy path with a mocked PyJWKClient, AuthPlugin role-application and forced-viewer fallback), core/tests/test_secrets.py (env fallback, file backend trailing-newline handling, backend ordering, misbehaving-backend isolation, auto-registration via ORRERY_SECRETS_DIR), and core/tests/test_server.py (FastAPI TestClient — /healthz is auth-free, missing/invalid/expired tokens return 401 with WWW-Authenticate: Bearer, valid tokens dispatch to the runner with the JWT subject as user_id and the _auth payload seeded, existing sessions re-stamp _auth on every request, anonymous-mode pins to viewer, eager misconfiguration fails fast). Suite at 693 unit tests (was 633), lint + format + ty clean.
  • Deps: New optional extras on orrery-core — [auth] (PyJWT 2.10+ with crypto for RS256/ES256) and [server] (auth extras plus FastAPI + Uvicorn). Both surface at the workspace root as orrery[auth] / orrery[server]. Core itself adds no new required dependencies — anyone using only the Slack / Google Chat transports stays on the existing footprint.

Documentation

  • config/security.md — new page with the threat model, HS256 vs. RS256/JWKS setup recipes, JWKS endpoints for Auth0 / Keycloak / Okta / Google IAP / GitHub OIDC, role-mapping rules, and the ORRERY_SECRETS_DIR Kubernetes Secret-volume pattern. Wired into the mkdocs nav under Getting Started → Configuration.
  • .env.example — Security / Auth block filled in with the full variable matrix (AUTH_ENABLED, JWT_ALGORITHM, JWT_SECRET, JWT_JWKS_URL, JWT_AUDIENCE, JWT_ISSUER, JWT_ROLE_CLAIM, JWT_LEEWAY_SECONDS, ORRERY_CORS_ORIGINS, ORRERY_SECRETS_DIR) replacing the previous "coming soon" stub.
  • README.md — Safety & Governance bullets added for JWT authentication and the secrets manager.

Added (previously)

  • Opt-in ADK planners on reasoning-heavy agents (core/orrery_core/base.py, agents/orrery-assistant): New resolve_planner() helper reads ORRERY_PLANNER (none | plan_react | builtin) and create_agent() accepts a planner= kwarg. Three agents in orrery-assistant opt in — the root orchestrator, the triage_summarizer, and the remediation_actor — sharing a single planner instance resolved once at import time. plan_react works across Gemini/Claude/OpenAI/Ollama via the existing LiteLLM integration; builtin consumes Gemini's native thinking tokens (ORRERY_PLANNER_THINKING_BUDGET, ORRERY_PLANNER_INCLUDE_THOUGHTS) and falls back to no planner with a warning when MODEL_PROVIDER != gemini. Default ORRERY_PLANNER=none is a no-op — zero behavior change for existing deployments. Per-system health checkers, the remediation verifier, and the journal writer intentionally skip the planner; they execute one short tool sequence per turn so an extra reasoning pass would only add latency. Tool-leaf agents (e.g., kafka_health_checker, k8s_health_checker) remain planner-free.
  • Tests: 10 new cases in core/tests/test_base.py::TestResolvePlanner covering default-off, both planner choices, the Gemini-only fallback for builtin, the ORRERY_PLANNER_THINKING_BUDGET / ORRERY_PLANNER_INCLUDE_THOUGHTS knobs, unknown-value warning, and case-insensitivity. Plus 5 deterministic wiring tests in agents/orrery-assistant/tests/test_planner_wiring.py that reload the agent module across env-var permutations to assert (a) which three agents pick up the planner under plan_react / builtin, (b) that all seven tool-leaf agents — the five health checkers, the journal writer, and the remediation verifier — stay planner-free, and (c) that builtin falls back to no planner under non-Gemini providers. Suite at 633 unit tests (was 618), lint + format clean.
  • Agent-level eval scaffolding for the planner-enabled root (agents/orrery-assistant/tests/evals/planner_routing.test.json, agents/orrery-assistant/tests/test_orrery_eval.py): Two routing scenarios — narrow Kafka query and narrow Elasticsearch query — that exercise the full ORRERY_PLANNER=plan_react → root → AgentTool → mocked client path against a real LLM. Gated behind make eval; skips when no Gemini credentials are present. Mocks kafka_health_agent.tools._get_admin_client and elasticsearch_agent.tools._get_session at the same layer the per-specialist evals use. The accompanying test_config.json ships with smoke-test thresholds (0.0) because the AgentTool injects an LLM-generated request arg that defeats strict tool_trajectory_avg_score matching — the eval still proves the agent loads with the planner, the LLM picks a specialist, and the full path runs without errors. Tighten thresholds once an arg-matching strategy is adopted.

Changed

  • Type hints / lint hygiene to clear make ty after the ty>=0.0.34 bump: agents/elasticsearch/elasticsearch_agent/tools.py adds a # ty: ignore[invalid-assignment] on requests.Session.verify (the type stub narrows it to bool, but at runtime the attribute accepts bool | str | None — a string is treated as a CA bundle path); core/tests/test_operators.py annotates the FakeStrimzi test double with explicit tuple[str, ...] / tuple[CRDRef, ...] types so it satisfies the OperatorDetector protocol; core/tests/test_base.py::TestResolvePlanner.test_builtin_include_thoughts_false narrows via isinstance(result, BuiltInPlanner) before reading thinking_config.

Documentation

  • config/general.md — Planning section: Env-var matrix and decision guide for plan_react vs. builtin, including the latency tradeoff and the rationale for keeping tool-leaf agents planner-free.
  • agent-design-patterns.md — Planning paragraph under Iterative & Feedback Patterns with the list of opted-in agents and a link to the config reference.
  • core/README.md — resolve_planner() reference alongside the existing create_agent() table; planner= is now a documented kwarg.
  • README.md — feature bullet in Intelligence & Orchestration announcing opt-in planning with the env-var on-ramp.
  • .env.example — Planning block documenting ORRERY_PLANNER, ORRERY_PLANNER_THINKING_BUDGET, ORRERY_PLANNER_INCLUDE_THOUGHTS with inline guidance on when to use each value.

[0.1.9] - 2026-04-26 (tag removed; shipped in 0.1.11)

Added

  • Progressive progress cards in Google Chat (agents/google-chat-bot): The async response path now posts a live "🔍 Investigating…" Card v2 immediately and PATCHes it in place as the agent run progresses. Operators see the currently executing sub-agent (friendly label — Checking Kafka, Synthesizing findings, …), a tool-call breadcrumb, subsystem health chips (✅/⚠️/❌/⏳) derived from state_delta writes on kafka_status / k8s_status / docker_status / observability_status / elasticsearch_status, an optional remediation panel populated by remediation_action / verification_result / remediation_summary, and an elapsed-seconds footer. Updates are debounced at 800ms and force-flushed on status transitions.
  • Structured triage result card: When an incident_triage_agent run completes, the progress card is replaced with a structured Triage Report — severity badge (🟢 healthy / 🟡 degraded / 🔴 critical), one section per subsystem, the triage_summarizer output, and a role-gated Run Remediation instruction (operator/admin only, only shown when overall severity is not healthy) inviting the user to send the Remediate Quick Command, which dispatches the remediation_pipeline in the same session so it reuses the triage report already in state.
  • ChatClient.update_message(): New async PATCH helper on the Chat REST client with dynamic updateMask. Tolerates 404/410 (message deleted) by returning None so the progressive-card loop can stop updating silently.
  • progress.ProgressTracker: New module that consumes ADK runner events (author, state_delta, function calls) and drives a debounced async update callback. Threaded through _run_agent via an optional tracker= kwarg so the sync path is unchanged.
  • Quick Command-based confirmation flow (agents/google-chat-bot): Confirmation cards no longer embed inline confirm_action / deny_action buttons. Operators send the Approve or Deny Quick Commands (appCommandId=1 / appCommandId=2) configured in the Chat API console; the handler resolves the latest pending confirmation in the thread/space and routes accordingly. Quick Commands ride the standard MESSAGE delivery path, sidestepping the inconsistent invokedFunction-button delivery seen in some Workspace Add-on configurations.
  • Tests: +6 test_chat_client.py (create/update mask variants, 404 tolerance, 500 raises), +16 cases in test_cards.py (status classification, progress-card shape, remediation panel, result-card severity, role-gated instruction), +7 cases in test_handler.py (progress posted then updated, final card is triage result when chips landed, role-gated remediation instruction, update failure falls back to new message, runtime error replaces progress with error card, run_remediation click dispatches a new run), +5 cases covering the new approve handshake (parent session id reconstructed from state, store-based approved-pending consumption, stale-approval rejection beyond the validity window, unapproved-pending blocks LLM auto-retry, args embedded in synthetic prompt). All 618 unit tests + lint + format clean.

Changed

  • handler._handle_message_async rewired: Posts the initial progress card before invoking the runner, attaches a tracker that PATCHes the same message on each significant event, and on completion replaces it with either the structured triage card (when chips landed) or the original reply. Non-triage queries retain the previous text + buffered-confirmation-card behavior.
  • _handle_app_command_async and _handle_card_click_async rewired: Both Approve/Deny dispatch paths now post a progress card before re-running the runner and replace it with the final reply on completion, so operators get live feedback during the post-approve LLM run instead of a silent ~30s wait.
  • _post_async_error now replaces an existing progress card with an error card (build_error_card) instead of appending a new message, so a crashed run never leaves operators staring at a stuck "Investigating…" frame.
  • Card-click dispatch recognizes a third invokedFunction: "run_remediation" alongside confirm_action / deny_action. The remediation branch bypasses the pending-confirmation store and spawns a new _run_agent turn with a remediation prompt.
  • Approve handshake is now ConfirmationStore-driven (agents/google-chat-bot/google_chat_bot/confirmation.py): The previous design wrote a _gchat_pending_<tool> fingerprint into tool_context.state on the first call and consumed it on the LLM's retry. That state lives on whatever ADK session ran the tool, which for tools invoked through an AgentTool is the sub-agent's ephemeral session — it doesn't propagate back to the gchat parent session. The callback now consults the store directly, matching (thread_or_space_key, tool_name, args_hash) against entries the click handler has marked approved=True. mark_latest_approved_for_thread flips the flag without popping; consume_approved pops on the LLM's retry. A 120s validity window after Approve prevents a stale approval from auto-executing a fresh request later.
  • Synthetic Approve prompt embeds the original arguments verbatim: Replaces the previous "Yes, proceed with <tool>." (which forced the LLM to reconstruct args from chat history — fragile when the call originated inside a sub-agent whose args the parent session never saw) with "The operator (<name>) approved the previous <tool> request. Re-issue the same call now with arguments <k=v, …> and report the result." PendingConfirmation now carries the original args dict, an args_hash, created_at, approved, and approved_at fields.

Fixed

  • patch_deployment (and any other guarded tool on a sub-agent) detoured through remediation_pipeline after Approve (agents/google-chat-bot): When a guarded tool fired from inside an AgentTool (e.g., k8s_health_agent.patch_deployment), the confirmation callback captured tool_context.session.id — which for an AgentTool is the ephemeral inner session, not the gchat parent session keyed gchat:<thread-or-space>. On Approve, the handler re-entered the runner with that ephemeral id and the LLM, finding no conversation history and only the synthetic "Yes, proceed with patch_deployment.", hallucinated a remediation against an unrelated deployment (default/frontend was the most-frequent miss). The callback now reconstructs the parent session id from state["gchat_thread"] / state["gchat_space"] (which the handler writes at the start of every turn) so Approve always re-enters the conversation the operator was in. Verified end-to-end with restart_deployment and patch_deployment against gatekeeper-system/gatekeeper-audit.
  • Re-prompting loop on every Approve retry (agents/google-chat-bot): The per-context _gchat_pending_<tool> flag the callback set on the first call lived on the AgentTool's child session and was invisible to subsequent calls (each AgentTool invocation gets a fresh sub-session). On retry the callback never saw a matching pending and posted another approval card — the operator could be stuck approving forever. Resolved by moving the handshake into ConfirmationStore (see Changed above).
  • APP_COMMAND payloads with empty message: {} resolved to the wrong pending in shared spaces: Quick Command events don't carry a thread name, so _extract_thread_name returns None and the handler falls back to matching by space name. The handler now logs a warning when this fallback fires so operators can see when concurrent destructive actions in a shared space could collide on the resolution.

[0.1.8] - 2026-04-23 (tag removed; shipped in 0.1.11)

Added

  • Elasticsearch Agent (agents/elasticsearch): New specialist agent for Elasticsearch cluster operations, exposing 19 read-only REST tools — cluster health/stats/nodes/pending tasks/settings, indices listing/stats/mappings/settings, shard allocation + explain diagnostics, search + count, index templates, aliases, ILM policies + explain_ilm_status, and snapshot repositories/snapshots. HTTP session is pooled as a module-level singleton with API-key / basic-auth / CA bundle support via ELASTICSEARCH_* env vars.
  • ECK Operator Tools: Five Kubernetes control-plane tools complementing the REST surface — list_eck_clusters, describe_eck_cluster, list_kibana_instances, describe_kibana, get_eck_operator_events. Wired through the shared orrery_core.default_registry.ECKDetector for interpreted healthy / phase / warnings on each CR.
  • Orrery-assistant integration: elasticsearch_agent is now a sibling AgentTool on the root orchestrator, and a new elasticsearch_health_checker joins health_check_agent as the fifth parallel branch of the incident triage pipeline (writes elasticsearch_status to session state for the triage_summarizer).
  • Compose profile + Makefile targets: docker-compose.yml gains an elastic profile with single-node Elasticsearch 8.13.4 + Kibana (security disabled for dev); make run-elasticsearch / make run-elasticsearch-cli launch the agent standalone.
  • Tests & evals: 36 new unit tests (25 REST + 11 ECK) with mocked requests.Session / CustomObjectsApi / CoreV1Api, plus 6 new eval scenarios (cluster_and_indices.test.json, eck.test.json) — bringing the suite to 608 tests / 28 eval scenarios.

[0.1.7] - 2026-04-19 (tag removed; shipped in 0.1.11)

Added

  • Zero-clone Docker quick-start: The README and getting-started.md now lead with docker pull ghcr.io/bahalla/orrery:latest + docker run for a 30-second single-container test drive, followed by a curl-the-compose-file path for the full Kafka/Postgres/Prometheus stack — no repository clone required.
  • ORRERY_IMAGE override: docker-compose.yml honours ORRERY_IMAGE so users can pin a specific release tag (e.g. ORRERY_IMAGE=ghcr.io/bahalla/orrery:v0.1.7 docker compose --profile demo up -d).
  • Best-practices & scaling guide: agent-design-patterns.md gains three new sections — tool/agent sizing budgets (sweet 5–15 tools, 3–7 direct children, depth ≤3), framework/model-specific limits (Gemini/Claude/OpenAI/Ollama caps, ADK LoopAgent.max_iterations discipline, context-window budgeting), and a decision guide for when to reach for the A2A protocol (referencing AEP-005) with a four-stage scaling playbook.

Changed

  • Python 3.14 upgrade: Bumped requires-python from >=3.11 to >=3.14 across the root and all nine workspace packages (core, docker-agent, google-chat-bot, k8s-health, kafka-health, observability, ops-journal, orrery-assistant, slack-bot). Ruff target-version updated to py314. Dockerfile base images switched to ghcr.io/astral-sh/uv:python3.14-bookworm-slim (builder) and python:3.14-slim-bookworm (runtime). CI and release workflows now install Python 3.14. uv.lock regenerated — all C-extension wheels (confluent-kafka, psycopg2-binary, asyncpg, pydantic-core, numpy, tiktoken) resolved to prebuilt 3.14 wheels with no source-build fallbacks. Full test suite (572 tests) passes on 3.14.
  • Single production Dockerfile: Merged Dockerfile.prod into Dockerfile so the repository ships one production-ready image. The consolidated Dockerfile adds UV_COMPILE_BYTECODE=1, UV_LINK_MODE=copy, PYTHONUNBUFFERED=1, PYTHONDONTWRITEBYTECODE=1, a BuildKit --mount=type=cache for uv, --extra postgres by default, and folds ownership into COPY --chown= to drop a redundant chown -R layer.
  • Compose services pull by default: orrery-assistant and slack-bot in docker-compose.yml now use image: ${ORRERY_IMAGE:-ghcr.io/bahalla/orrery:latest} with build: . retained as a local-dev fallback — first-run users no longer wait for a local build.
  • CI/release type-check coverage: .github/workflows/ci.yml and .github/workflows/release.yml now include --extra-search-path agents/google-chat-bot in the ty invocation so that agent is type-checked alongside the others (it was already shipping in the image and in runtime CI).
  • Documentation refresh: SECURITY.md, deployment.md, troubleshooting.md, enhancements/aep-011-deployment-hardening.md, and enhancements/aep-014-supply-chain-security.md updated to reference the single Dockerfile after the consolidation.

Fixed

  • Broken HEALTHCHECK on the default image: The Dockerfile HEALTHCHECK and the orrery-assistant compose healthcheck both probed http://localhost:8080/healthz, but HealthServer is only started by run_persistent() and the Pub/Sub worker — not by the default adk web CMD. Probes are now owned by the orchestrator (docker-compose / Helm values in deploy/helm/orrery-assistant/values.yaml) rather than baked into the image.
  • Latent port conflict in compose: orrery-assistant and kafka-ui both bound host port 8080. The unused 8080:8080 mapping on orrery-assistant was removed (the healthz server doesn't run for that CMD anyway).

Removed

  • Dockerfile.prod: replaced by the consolidated, production-ready Dockerfile. The release.yml workflow now builds from ./Dockerfile.

[0.1.6] - 2026-04-19 (tag removed; shipped in 0.1.11)

Added

  • Operator Registry (orrery_core.operators): Pluggable registry for Kubernetes operator detection and CR status interpretation. Ships with built-in detectors for Strimzi (kafka.strimzi.io — 9 kinds incl. Kafka, KafkaTopic, KafkaConnector, KafkaRebalance) and ECK (*.k8s.elastic.co — 7 kinds incl. Elasticsearch, Kibana, ApmServer, Beat). New detectors can be registered via default_registry.register().
  • Structured Tool Results (orrery_core.ToolResult): Pydantic model with ok() / error() / partial() factories and remediation_hints for cross-agent composition. Flattens to a backward-compatible dict via .to_dict(), so adoption is gradual and existing tests/tools keep working.
  • k8s-health Operator-Aware Tools: Six new tools on the k8s-health agent — detect_operators, list_custom_resources, describe_custom_resource, get_owner_chain, describe_workload, get_operator_events. describe_workload walks ownerReferences from a Pod up to its root CR (e.g., Pod → StatefulSet → Kafka) and returns the operator's interpreted status (healthy/phase/warnings) instead of raw pod info.
  • kafka-health Strimzi Tools: Ten new tools that complement the Kafka-protocol tools with a view into the Strimzi control plane — list_strimzi_clusters, describe_strimzi_cluster, list_strimzi_topics, list_kafka_users, list_kafka_connectors, get_kafka_connect_status, get_mirrormaker2_status, get_kafka_rebalance_status, plus the guarded approve_kafka_rebalance (patches strimzi.io/rebalance: approve) and restart_kafka_connector (patches strimzi.io/restart: true). Uses the shared operator registry for status interpretation.
  • Property-Based Guardrail Tests: Integrated hypothesis and added exhaustive tests for tool argument hashing in core/tests/test_guardrails.py, ensuring deterministic and order-invariant hashes for stable confirmation matching.
  • Pub/Sub Worker Health Probes: The worker now exposes /healthz and /readyz via the shared HealthServer. Readiness flips to 503 if the streaming-pull future dies, so kubelet restarts the pod automatically.

Changed

  • ADK Upgrade: Upgraded google-adk to v1.31.0 across the workspace.
  • Experimental Warning Suppression: pytest is now configured to suppress all experimental feature warnings from google.adk.features, keeping test output clean and focused.
  • Helm: Liveness/Readiness + PDB: pubsubWorker deployment now configures liveness, readiness, health port, and an optional PodDisruptionBudget.
  • Terraform: DLQ Triage Access: New dlq_subscribers variable grants roles/pubsub.subscriber on the DLQ subscription to configured SRE/on-call groups, plus a dead_letter_subscription_name output.
  • Docs: integrations/google-chat-pubsub.md now documents every Terraform variable (chat_publisher_email, enable_vertex_ai, vertex_ai_project_id, tuning knobs) and the timeout-alignment rule for Pub/Sub ack deadlines.
  • AEP-018: Proposal for Pub/Sub idempotency (dedup store on eventId) and HPA-on-backlog for the pubsubWorker to remove the single-replica SPOF during incidents.

[0.1.5] - 2026-04-18 (tag removed; shipped in 0.1.11)

Added

  • Pub/Sub Diagnostics: Added verbose trace logging for message receipt, parsing, and agent execution to simplify troubleshooting.
  • Heartbeat Monitor: Implemented a 60-second background heartbeat log in the Pub/Sub worker to provide "proof of life" in container logs.
  • Cross-Project Support: Explicitly documented and enabled support for subscriptions living in different GCP projects than the agent runner via GOOGLE_CHAT_PUBSUB_PROJECT.

Fixed

  • Cleanup: Ensured proper cancellation of background heartbeat tasks during worker shutdown.

[0.1.4] - 2026-04-18 (tag removed; shipped in 0.1.11)

Added

  • Google Chat Pub/Sub Transport: Added support for private GKE clusters via Pub/Sub connection type.
  • Terraform Module: New module in deploy/terraform/google-chat-bot for automated GCP infrastructure setup.
  • Helm Expansion: Added pubsubWorker deployment and Workload Identity support to the Helm chart.
  • Overridable Publisher IAM: Added chat_publisher_email variable to handle Domain Restricted Sharing (GCP Org Policy) for Workspace Add-ons.

Changed

  • Unified Handler: Refactored Google Chat bot to use a transport-agnostic handler shared between HTTP and Pub/Sub.
  • GKE Deployment Story: Removed legacy Kustomize manifests in favor of a unified Helm + Terraform production pattern.

Fixed

  • Poison Message Handling: Implemented robust ack/nack logic in the Pub/Sub worker to prevent infinite redelivery of malformed payloads.

[0.1.3] - 2026-04-13 (tag removed; shipped in 0.1.11)

Changed

  • Rebranding: Project renamed from "AI Agents for DevOps" to Orrery.
  • Package Renaming: ai-agents-core is now orrery-core.
  • Agent Renaming: devops-assistant is now orrery-assistant.
  • Infrastructure: Updated Kubernetes manifests, Helm charts, and Docker images to use the orrery namespace and naming.
  • Observability: Prometheus metrics renamed from ai_agents_* to orrery_*.

[0.1.2] - 2026-04-12 (tag removed; shipped in 0.1.11)

Added

  • Google Chat Async Mode: Added GOOGLE_CHAT_ASYNC_RESPONSE and GOOGLE_CHAT_SERVICE_ACCOUNT_FILE to .env.example for long-running agent support.
  • Enhanced Documentation: Added Google Chat to the main README and expanded the integration guide with troubleshooting for ADC, scopes, and 401/404/403 errors.

Changed

  • Google Chat Roadmap: Moved Google Chat from "Upcoming" to "Current Integrations" in the documentation.
  • API Reference: Registered google-chat-bot for automatic API documentation generation in mkdocs.yml.

Fixed

  • Google Chat Event Parsing: Implemented robust multi-path parsing for space names, emails, and thread names to prevent 404 errors during asynchronous replies.
  • Async Auth Scope Guidance: Added detailed troubleshooting and configuration for 403 Forbidden errors caused by missing chat.bot scopes when using Application Default Credentials (ADC).
  • ADC-First Auth Pattern: Updated Google Chat bot to prioritize Application Default Credentials, enabling seamless Workload Identity support on GKE and Cloud Run.

[0.1.1] - 2026-04-11 (tag removed; shipped in 0.1.11)

Added

  • Google Chat Bot Integration: A new integration bringing autonomous DevOps agents to Google Workspace with support for thread-based sessions and interactive Card v2.
  • Workspace Add-ons Compatibility: Implemented strict hostAppDataAction (DataActions) schema support, enabling the bot to run behind the Google Workspace Add-ons pipeline.
  • Dual-Path Event Detection: Added logic to seamlessly handle interaction events from both standard Google Chat API and the nested Workspace Add-ons event structure.
  • Interactive Guardrails for Chat: Wired @confirm and @destructive tools to post interactive Cards v2 with Approve/Deny buttons, allowing operators to authorize dangerous actions directly from the chat.
  • Configurable Identities: Added GOOGLE_CHAT_IDENTITIES to allow dynamic verification of multiple signing service accounts (e.g., standard Chat vs Add-ons service agents).
  • Kafka KRaft Migration: Removed Zookeeper dependency. Kafka now runs in KRaft mode for improved startup reliability and simplified architecture.
  • PostgreSQL Service: Added a dedicated PostgreSQL container to docker-compose.yml for persistent session storage.
  • Centralized Configuration: Merged per-agent .env files into a single root .env file. Updated core library to prioritize the root configuration while maintaining legacy override support.
  • Cross-Session Memory: Enabled MemoryPlugin in the orrery-assistant agent, allowing it to remember past interactions and save session highlights to the persistent store.
  • Kafka Partition Scaling: Added update_kafka_partitions tool to the Kafka health agent with full unit test coverage.
  • Production deployment hardening (AEP-011) — complete Kubernetes deployment story
  • Kustomize manifests under deploy/k8s/ (Deployment, Service, HPA, PDB, NetworkPolicy, ServiceAccount with scoped ClusterRoles)
  • Helm chart under deploy/helm/orrery-assistant/ with configurable values, NOTES, and existingSecret support for out-of-band secret management
  • GHCR CD pipeline (.github/workflows/docker-publish.yml) publishing multi-arch (amd64/arm64) images with SBOM and provenance attestation on merges to main and v*.*.* tags
  • PostgreSQL session store support — runner.py honors DATABASE_URL (async driver postgresql+asyncpg://…) for multi-instance deployments; core[postgres] extra adds asyncpg and psycopg2-binary
  • Rate limiting on the Slack bot /slack/events webhook via slowapi (configurable via SLACK_RATE_LIMIT, default 60/minute)
  • Root-level .env.example documenting every required and optional variable across agents and deployment manifests
  • deployment.md — end-to-end production deployment guide (Postgres setup, Helm install, rolling updates, troubleshooting)

Changed

  • SlackBotConfig.resolve_db_url() prefers DATABASE_URL env var over the legacy slack_db_url default — enables sharing Postgres between the Slack bot and the ADK web UI workloads
  • load_agent_env() and load_config() now search for a .env file at the project root by default.

Fixed

  • OIDC Token Verification: Fixed a bug where tokens from Google's migrated OIDC flow (iss=accounts.google.com) were rejected. Added proper identity verification against the email claim.
  • Dynamic Session Handling: Fixed SessionNotFoundError in the ADK Runner by enabling auto_create_session=True for thread-based chat integrations.
  • JSON Schema Validation: Resolved "Failed to parse JSON" errors in Google Chat by ensuring all responses (including errors and added-to-space events) strictly follow the Add-ons response schema.
  • Metrics Callback Signature: Fixed a TypeError in MetricsPlugin where incorrect keyword arguments were passed to the internal callback.
  • Kafka Tool Imports: Fixed a NameError in kafka_health_agent/tools.py caused by using decorators before they were imported.
  • Database URL Masking: Ensured DATABASE_URL is masked in all log outputs and console prints to prevent credential leaks.

[0.1.0] - 2026-04-09 (tag removed; shipped in 0.1.11)

First public release of the AI Agents for DevOps & SRE platform.

Added

  • Multi-agent orchestrator — orrery-assistant root agent delegates to 5 specialist agents via AgentTool and deterministic sub-agent workflows (ADR-002)
  • Specialist agents — Kafka health, K8s health, Observability (Prometheus/Loki/Alertmanager), Docker, and Ops Journal
  • Slack bot — Thread-based sessions with interactive Approve/Deny buttons for guarded operations
  • Incident triage pipeline — SequentialAgent + ParallelAgent for parallel health checks across all systems, triage summary, and journal recording
  • Closed-loop remediation (AEP-004) — LoopAgent-based pipeline: act (restart/scale/rollback) → verify → retry up to 3 iterations, with exit_loop tool for early termination
  • Context caching (AEP-007) — ADK ContextCacheConfig for Gemini models, reducing token usage for repeated requests. Configurable via CONTEXT_CACHE_MIN_TOKENS (renamed to CONTEXT_CACHE_MIN_LENGTH in 0.2.1), CONTEXT_CACHE_TTL_SECONDS, CONTEXT_CACHE_INTERVALS env vars
  • Cross-session memory (AEP-003) — SecureMemoryService with automatic PII redaction and size limits
  • Agent evaluation framework (AEP-002) — 22 eval scenarios across 4 agents verifying correct tool routing via ADK's AgentEvaluator. Run with make eval
  • RBAC — 3-role hierarchy (viewer/operator/admin) enforced globally via GuardrailsPlugin (ADR-001)
  • Safety guardrails — @destructive and @confirm decorators gate dangerous operations with args-hash + TTL confirmation tracking (AEP-001)
  • Authentication enforcement — set_user_role() marks server-trusted roles; ensure_default_role() forces viewer for unset roles
  • Input validation — 5 reusable validators (validate_string, validate_positive_int, validate_url, validate_path, validate_list) applied across 30+ tool functions
  • ADK Plugins — cross-cutting concerns as BasePlugin subclasses: GuardrailsPlugin, ResiliencePlugin, MetricsPlugin, AuditPlugin, ActivityPlugin, ErrorHandlerPlugin, MemoryPlugin
  • Prometheus metrics — tool call counts, latency histograms, error rates, circuit breaker state, LLM tokens, and context cache events on /metrics
  • Resilience — per-tool circuit breaker via ResiliencePlugin, @with_retry decorator with exponential backoff and jitter
  • Structured JSON logging — setup_logging() with JSONFormatter, audit trail via AuditPlugin, activity tracking via ActivityPlugin
  • Multi-provider LLM — Gemini (default), Claude, OpenAI, Ollama via resolve_model() + LiteLLM
  • Persistent runner — run_persistent() with SQLite-backed sessions, health probes, graceful shutdown
  • Agent factory functions — create_agent(), create_sequential_agent(), create_parallel_agent(), create_loop_agent()
  • Docker deployment — multi-stage builds, non-root user, docker-compose.yml with demo/slack profiles
  • 468 unit tests — all async, all mocked, no running infrastructure required
  • CI pipeline — lint (ruff), type check (ty), security scan (bandit), tests, evals

Security

  • Input validation at tool boundaries prevents injection attacks
  • Path traversal prevention in Docker and K8s tools
  • URL scheme allowlisting rejects javascript:, data:, file: URIs
  • Docker container inspection redacts sensitive environment variables
  • Guardrail confirmation bypass fixed with args-hash + TTL tracking
  • Server-side role enforcement prevents privilege escalation