Changelog¶
All notable changes to Trimus are documented in this file.
The format follows Keep a Changelog.
[Unreleased]¶
v5.0.2 — 2026-07-24¶
Fixed¶
- The login page showed the legacy Staple "S" letter-mark; it now uses the Trimus tri-truss on a brand tile. Updated the two stale static logo-mark SVGs (previously unused, still carried the old S-curve) to the tri-truss too.
v5.0.1 — 2026-07-24¶
Fixed¶
- The collapsed left navigation rail showed the legacy Staple "S" glyph; it now uses the Trimus tri-truss mark (matching the rest of the Keystone identity).
v5.0.0 — 2026-07-24¶
The Staple → Trimus rebrand and rehome. The repository moved to
github.com/trimus-ai/trimus and the product is now Trimus. This is a major
release: the module path, environment-variable prefix, binary and image names,
the Helm chart, and dev/test database names all changed. The database schema and
the encrypted-backup format are deliberately unchanged, so no data migration is
required.
⚠️ BREAKING — operator action required¶
- Environment variables: the
STAPLE_*prefix is nowTRIMUS_*(hard rename, no fallback). EverySTAPLE_FOObecomesTRIMUS_FOO— rewrite your service env / compose / Helm values before upgrading, or the server won't see its configuration (e.g.STAPLE_HOME→TRIMUS_HOME,STAPLE_ENCRYPTION_KEY→TRIMUS_ENCRYPTION_KEY). - Binaries are renamed
staple-*→trimus-*(trimus-server/worker/seed/cli/project-module/license). Update your systemd/launchd units — or re-runtrimus-worker service install— and any scripts. The systemdSERVICE_NAMEdefault is nowtrimus. - Container images are now published to
ghcr.io/trimus-ai/trimus-{server,worker,seed}. - Helm chart renamed
staple→trimus(deploy/helm/trimus). - cosign verification identity: release signatures are now issued by
github.com/trimus-ai/trimus; verify with--certificate-identity-regexp '^https://github.com/Trimus-ai/trimus/[.]github/workflows/release.yaml@refs/tags/'. - Docs site is now
https://docs.trimus.ai. - CLI quiet-mode status line: the machine-readable success prefix changed
from
staple-<verb> oktotrimus-<verb> ok— update any monitoring greps. - Repository moved:
github.com/negronjl/staple→github.com/trimus-ai/trimus(old URLs redirect for authenticated users). If you build from source, update your git remote and rungo clean -modcache. - Dev/test databases renamed
staple_dev/staple_test→trimus_dev/trimus_test.
Unchanged (deliberately, for compatibility)¶
- Database schema — table/column names and all 176 migrations are untouched; the rename requires no data migration.
- Encrypted backup format — the
STAPLE_BACKUP_v1magic and its AEAD parameters are byte-stable, so backups written before v5.0.0 remain restorable. - License-seal domain, the Postgres RLS role
staple_app, session/CSRF cookies, Prometheus metric names, JSON payload tags, and inbound webhook HMAC headers — kept stable so existing state, dashboards, and already-configured external webhook senders keep working.
Changed¶
- Applied the Keystone visual identity — brand design tokens (single-source
:root, cheaply re-themeable), self-hosted Space Grotesk / Hanken Grotesk / Martian Mono fonts, and a new tri-truss logo + favicon — to the app and the docs site.
Security¶
- Bump
google.golang.org/grpcv1.80.0 → v1.82.1 (clears GHSA-hrxh-6v49-42gf).
v4.21.1 — 2026-07-24¶
Patch release: clears a freshly-published golang.org/x/text advisory
(GO-2026-5970), ships the company Files nested-listing fixes, and folds in two
CI dependency bumps.
Security¶
golang.org/x/textv0.38.0 → v0.39.0 (GO-2026-5970). A newly published advisory flaggedgolang.org/x/text/unicode/norm(reachable throughdatabase/sql'ssql.Openand the HTTP-proxy path) at v0.38.0, sogovulncheckbegan failing every CI run against the live vulnerability database. Upgrading to the patched v0.39.0 clears it. Dependency bump only — no behavior change.
Fixed¶
- The company Files page now lists files agents wrote into subdirectories.
Agents
write_fileartifacts into nested paths (e.g.docs/roles/vp.md,agents/foo.md), but the HTML Files page listed only the top level and skipped directories — so a company whose files were all nested read "No files" even though they were safely on disk. (The JSON API list was already recursive since #365; the HTML listing had quietly diverged.)UIFileListnow lists recursively through the samewalkFilesDirwalker the JSON API uses, showing each file by its slash-separated relative path. Download already handled nested paths; the UI delete route now does too (trailing-wildcard/delete/*+ traversal-safe resolution), so the Delete button works on nested files instead of failing on the path separator. - The company file store is now reachable from the sidebar. When an agent
runs
write_fileon an issue with no project, the file falls back to the company file store (<company>/files/) and the agent posts a "Company → Files" comment. That page (/companies/{id}/files/ui) existed and was in the ⌘K palette, but had no visible sidebar item — so the comment's instruction was a dead end (projects and agents exposed a Files link, the company-level store did not). Added a Files item to the sidebar's Company section, andUIFileListnow sets the active-nav highlight for it.
Changed¶
- CI dependency bumps (dependabot #421, #422):
actions/setup-pythonv6 → v7 (docs-site build) anddocker/login-actionv4.4.0 → v4.5.0 (release image push).
v4.21.0 — 2026-07-20¶
Three remote-worker + embedding features: workers declare which models they serve (#413), providers can override the embedding model (#409), and workers self-install as a managed service (#414).
Added¶
- Remote workers choose which models/providers they share (#413). An operator
can declare, via
STAPLE_WORKER_SHARED_MODELS="openai:gpt-4o,gpt-4o-mini;anthropic:claude-*", which(provider, model)combinations their worker will serve — the worker's say over what its machine runs (previously it ran whatever a claimed task named, with only the coarseSTAPLE_WORKER_LLM_ALLOWED_HOSTScredential allowlist). Two layers: the worker enforces the set (refuses anllmtask outside it, so it can't be made to run an un-offered model), and it advertises the set at registration (remote_workers.shared_models, migration 176; worker-authoritative, refreshed on every restart) so the server routes only matchingllmtasks to it (ClaimRemoteTaskgains a provider/model dimension alongside tags). Routing is a hint; the worker's enforcement is authoritative. Empty/unset = shares all (backward-compatible); only thellmadapter is filtered. The admin/admin/workerspage shows each worker's advertised set (read-only). - Per-provider embedding-model override (#409 — closes the issue). A provider
can now declare the embedding model it serves (an Embedding model field on
the provider edit page;
company_providers.embedding_model, migration 175), so a designated self-hosted / non-OpenAI endpoint that serves a model other thantext-embedding-3-smallworks instead of failing on an unknown model. The effective model is resolved in Go by the sameproviderrouter.ForEmbeddingpath that selects the provider, and the embedding workers now iterate per company — resolving each company's model and scanning/stamping with it — so the model-aware re-embed gate compares against the same model the worker uses and a custom-model company doesn't re-embed every tick.Provider.EmbeddingModel()is the single source of that value. staple-worker service install— remote workers self-install as a managed service (#414). A newstaple-worker service <install|uninstall|status>subcommand generates and enables a launchd (macOS) or systemd (Linux) unit so a worker starts on boot/login and restarts on failure — no hand-copying unit files from the docs, no server-centric releaseinstall.sh. Defaults to a per-user login service (--user);--systemis a root-owned boot service (Linux/systemd only).--dry-runprints the exact unit and commands without touching anything. The units are rendered from templates modeled onscripts/service/but self-contained around one worker env file.
v4.20.0 — 2026-07-20¶
Adds operator control over which provider computes embeddings (#409).
Added¶
- Operators can now designate which provider computes embeddings (#409). The
AI Providers admin page gained a "Use for embeddings" toggle on each
openai / openai_compatible provider (and an
embeddingsbadge on the chosen one).providerrouter.ForEmbeddinghonors that designation (company_providers.is_embedding_default, migration 174) before its canonical-OpenAI heuristic — so a company that wants embeddings from a specific endpoint (a self-hosted proxy, a non-OpenAI vendor) can say so explicitly rather than relying on the heuristic. A disabled / non-embedding designation safely falls through to the heuristic. (A per-provider embedding-model override — removing the hardcodedtext-embedding-3-smallassumption — remains a follow-up on #409; it entangles the global model-aware re-embed gate.)
v4.19.2 — 2026-07-19¶
A single-fix patch: RAG embeddings now work for Anthropic-primary companies.
Fixed¶
- Embedding workers now pick an embedding-capable provider, so RAG works for
Anthropic-primary companies. The note + knowledge embedding workers resolved
their provider through
providerrouter.For, which returns the first recognized family in label order — for a company whose primary/default is Anthropic (which has no embeddings endpoint), that meant the worker skipped the entire company, leaving the RAG index empty even when a perfectly good OpenAI row was configured. Newproviderrouter.ForEmbeddingnever returns a non-embedding provider: it ignores anthropic/perplexity/unknown families, prefers a canonical OpenAI row (adapteropenai, or anopenai_compatiblerow whose host isapi.openai.com— the endpoint that hosts the default embedding model) over a local Ollama / OpenRouter row, and only reports "no provider" when a company genuinely has no embedding-capable row. (A per-company, operator-chosen embedding provider — vs. this heuristic — is filed as #409.)
v4.19.1 — 2026-07-19¶
_A single-fix patch closing the last #404 residual — the thematic capstone of the
390 knowledge-propagation arc._¶
Fixed¶
- A promoted Compiled-Knowledge article now re-syncs with its origin (#404 item
3 — final #390 residual). Promotion (#220 slice 5) copies an agent/project-tier
article up to the company tier; #390 slice D stopped a compile from clobbering
that copy, but an origin article edited or archived AFTER promotion still left
the company-tier copy serving stale content forever — the "reversed decision
still served as authoritative" failure at the heart of the #390 arc. Promoted
copies now carry an
origin_article_idback-link (migration 173); each compile tick, a set-basedReconcilePromotedArticlespass refreshes a copy from its edited origin and archives it when the origin is archived. Idempotent (a synced copy isn't rewritten; provenance is excluded from the change trigger) and tenant-guarded (origin and copy must share a company). This closes #404.
v4.19.0 — 2026-07-19¶
Headline: the knowledge/resource-propagation epic (#390, slices A–G) — the knowledge-domain twin of the capability-awareness fix. Adding, modifying, or removing a document now reliably reaches every consumer (RAG retrieval, Compiled-Knowledge injection, embeddings) within privilege scope, without a manual step or a process restart. Plus the chat follow-ups: web/multi-agent in-turn re-prompt (#395) and a capability-version fingerprint on the chat capability header (#394).
Fixed¶
- Knowledge-propagation hardening (#404 — residuals of the #390 epic). Four
lower-severity self-heal gaps closed: (1) knowledge embeddings now carry a
source_version(migration 172) so a doc edited mid-embed is re-detected by version rather than lost to the created_at timestamp race; (2) the Compiled-Knowledge recompile gate now fingerprints EVERY active company-wide doc (viaCompanyKnowledgeFingerprint), not just the 500-doc presentation window, so any edit recompiles; (4) the live notes embedding worker is now model-aware (mirrors #390 slice C), so an embedding-model change re-embeds notes too; (5) deleting an agent archives its agent-tier compiled articles (the compile worker never revisits a deleted agent's scope). (Item 3 — full origin→promoted re-sync — still open on #404; it needs a back-link column.) - Out-of-window document edits now trigger a Compiled-Knowledge recompile (#390 slice E). The recompile SHA gate hashed the truncated 48000-char source blob, so an edit to content that fell outside that window never changed the hash and never recompiled. The gate now uses a fingerprint of the full company document set (every doc's id + version, independent of truncation); the LLM still receives the truncated source. (The 500-doc source cap remains a follow-up on #390.)
- A recompile can no longer silently demote a promoted Compiled-Knowledge
article (#390 slice D). If a later compile produced an article with the same
(type, slug)as a manually-promoted company-tier row, the upsert overwrote its body and thepromotedsentinel — demoting it so the next reconcile could archive it.Reconcilenow skips upserting over a row that carries the promoted sentinel, so a promotion survives colliding compiles. (Full origin→promoted re-sync needs a back-link column and is a follow-up on #390.) always-inject knowledge is delivered immediately, without waiting on the embedding pipeline (#390 slice F).inject_mode='always'docs — chosen to guarantee they are always in context — were sourced through a query that JOINsknowledge_embeddings, so a freshly-created always-doc wasn't injected until the async embedding tick ran, and was never injected for a company whose provider can't embed (or with embeddings disabled). Always-inject now sources directly fromcompany_knowledge(no embedding join), so an always-doc is in context the moment it's created regardless of embedding state.- The Compiled-Knowledge compiler skips knowledge-disabled projects (#390 slice
G). The compile worker recompiled every non-archived project via
ListProjectswhile injection (slice B) is gated onKnowledgeIndexed— so an un-indexed project spent LLM calls building articles nothing injects. The compile loop now skips projects that aren'tKnowledgeIndexed, so a single opt-out flag governs both compilation and injection consistently. - Changing the embedding model now re-embeds existing documents automatically
(#390 slice C). The knowledge embedding worker detected work purely by
timestamp (
updated_at > MAX(created_at)) and stored the model name but nothing compared it — so swapping the embedding model left every existing document's vectors in the OLD space, silently compared against new-space query vectors (degraded rankings, no error, and for company knowledge there was no recovery path at all). The needing-embedding scan is now model-aware (… OR MIN(e.model) <> $activeModel), so a model change re-flags every doc on the old model on the next worker tick. The model name is now a single shared constant (adapters.DefaultEmbeddingModel) referenced by both the engine retrieval side and the embedding workers, so the query-vector and document-vector models can never drift apart. (The edit-during-embed wall-clock race — the other half of this finding — needs asource_versioncolumn and is tracked as a follow-up on #390.) - Removing a document now removes it from the Compiled-Knowledge index (#390
slice B).
Reconcileis the only path that archives compiled articles, but the compile worker short-circuited on an empty source before calling it — so clearing a scope's last source doc (company KB emptied, an agent's compression summary cleared) left its distilled articlesactiveand injected into every prompt indefinitely. An empty source is now a reconcile-to-zero: it archives the scope's non-promoted articles with no LLM call (and never compiles an LLM prompt for empty text). Separately, archiving a project now archives that project's compiled articles in the same transaction (the worker skips archived projects, so it would never revisit them), and Compiled-Knowledge injection is gated on project liveness +KnowledgeIndexed— mirroring RAG retrieval — so an archived or knowledge-disabled project can no longer inject stale articles. - Knowledge/Compiled-Knowledge enablement now takes effect at runtime for both
the writer and the reader (#390 slice A). The embedding + compile workers
resolved
STAPLE_EMBEDDING_ENABLED/STAPLE_COMPILED_KNOWLEDGE_ENABLEDthrough the DB-backed runtime flag (instance_flagsrow > env > default, no restart), but the engine consumers (RAG retrieval, note retrieval, Compiled-Knowledge injection) and the admin status card read the raw process env — so flipping the flag at runtime enabled production (embedding/compiling) while consumption (injection) stayed dark, and split-role tiers disagreed. The resolver now lives in a neutralinternal/flagsleaf package that both the workers and the engine share, and all three engine gates + the admin card resolve through it — one authoritative source, restart-free, cross-tier.
Added¶
- Capability-version fingerprint on the chat capability header (#394,
partial). The always-injected "Your current capabilities — AUTHORITATIVE"
header now carries a short
rev <hash>stamp derived from the current chat verb/entity catalog plus this company's live MCP tool set, so it changes on any verb/entity deploy or MCP tool add/remove. It gives a concrete, operator-visible capability version and is the hook a future "capabilities changed since your last turn" note or a compression invalidation can key on. (The recency/change-delta half of #394 stays deferred — see the issue: on the default Anthropic provider system content is position-agnostic, and the already-validated authoritative header solves the anchoring it targeted.)
Fixed¶
- Web and multi-agent chat no longer needs a second message to surface a
read/MCP result ("ask twice") (#395). The Slack/Telegram DM path already did
an in-turn re-prompt after an agent emitted a read verb or
/mcp:call — the web UI and multi-agent fan-out discarded those results, so the answer only appeared on the user's next message, and the agent, seeing nothing, could conclude the capability did nothing. The web UI (streaming + non-streaming) and multi-agent fan-out now run the same single in-turn re-prompt (sharedapplyChatReprompt), re-injecting the live capability context on the second turn. The re-prompt reply is a non-streamed completion persisted in the same turn (avoiding a fragile streaming-within-streaming path), surfaced via the existing since-seq re-query and the SSE nudge.
v4.18.0 — 2026-07-19¶
Headline: the capability-awareness durable fix — chat agents no longer drift from, or deny, the capabilities they actually have. A 5-slice epic (single-source capability catalog + authoritative/supersedes-earlier framing, live capability context on every chat surface, a self-healing MCP tool registry, and compression hygiene), each folded through adversarial review.
Fixed¶
- Registered MCP tools now stay current without a restart (capability-awareness,
slice 4). The per-company typed-tools registry (which feeds the agent's
"Remote MCP tools" list) was cached for the whole process lifetime and only
invalidated inside the worker tier's mcp-sync. So a split-role web process
never saw a tool added/removed after boot, a transient boot-time DB blip could
freeze an empty tool set for the process lifetime (the tools section then
silently vanished — the observed post-restart token drop), and a deleted
server's tools could linger. The registry now carries a cheap MCP-servers
version fingerprint (row count + max
updated_at) and re-checks it on a 60-second TTL: any add/modify/delete/sync makes every tier rebuild within the TTL, regardless of which tier ran the change. A degraded build (server list failed to load, vs. genuinely zero servers) is no longer cached as authoritative — it rebuilds on the next turn instead of freezing empty. -
Context compression no longer freezes stale capability beliefs (capability-awareness, slice 5). When context compression is enabled, the compressed "Prior-context summary" is model-authored from older runs and re-injected at the top of every turn. A summary produced while a capability did not exist could bake in "I told the user I cannot do X" and re-assert it forever, even after X shipped. The summarizer is now instructed to record only events/decisions/open-threads and explicitly not what the agent can or cannot do (capabilities are supplied fresh each turn), and the rendered summary block is stamped as past-only and non-authoritative for capabilities.
-
Chat agents no longer deny capabilities they actually have (capability-awareness, slice 1). A systemic issue let a chat agent's belief about what it can do drift from the platform's real, current capabilities — after a verb/tool was added, a long-lived conversation could keep insisting the capability didn't exist, anchored on its own earlier (pre-capability) refusals. Root cause: the surface was maintained as several independent hand-synced lists with no authoritative, current source the model was told to trust. This first slice:
- Introduces
internal/engine/chatvocabas the single source of truth for chat action verbs and/show///listentities; the injected cheatsheet, the vetting allowlist, and the dropped-verb token set are now rendered from (or CI-asserted against) it, so the advertised and executable surfaces can no longer diverge. - Enumerates every entity on both the
/showand/listlines (previously/listnever listed its entities — the exact gap that made an agent unable to confirm/list approvalwas valid). - Prefixes the cheatsheet with an authoritative, current, supersedes-earlier header so the model treats the live list as truth over anything it said earlier in the thread.
- Web and multi-agent chat agents now receive the live capability context
(capability-awareness, slice 2). Previously only the Slack and Telegram
dispatchers injected
BuildChatContext; the web UI, JSON API, and multi-agent fan-out called the model with just the agent's frozensystem_prompt— so on those surfaces an agent's only capability knowledge was hand-written persona prose, and a newly added verb or MCP tool was invisible until someone edited the prompt.engine.ChatStreamgained the sameWithChatContextoption asChatComplete(via one shared splice), and every web / JSON-API / multi-agent send — plus the Slack/Telegram re-prompt second turn — now injects the live digest. The web/JSON/multi-agent digests are built withactor=nil(company- safe content only) since those chats are company-shared — a member's private notes must not enter a transcript other members can read. - The Anthropic chat provider no longer drops an agent's persona when extra
system context is present. The Messages API takes a single
systemfield; the provider kept only the last system message, so once the capability digest (and project-knowledge) were spliced as additional system messages, the agent's ownsystem_promptwas silently discarded. Both the completion and streaming paths now concatenate all system messages (matching the Bedrock provider), preserving persona + context + knowledge together. This also fixes a latent case on the Slack/Telegram surfaces, which already injected context.
Added¶
- Chat ↔ Issues parity v2 — the epic's remaining slices (#378, #379, #380). Builds on the v1 reads + issue-conversation writes:
- Approvals + routines writes (#378) — a new
approvalread entity (/show approval,/list approval),/resolve-approval(approve/reject a pending approval, running the same downstream effects as the web — comments linked issues, unblocks + wakes the assigned agent on approval), and/set-routine-status active|paused(enable/disable a routine). Completes the curated write set (issues + approvals + routines). These write verbs mirror the web role — member+ only (a vetted viewer is refused) — and resolve an ambiguousapproval:/routine:reference to nothing rather than guessing, since the side effects are irreversible. - Proactive delivery-back (#379) — when a user drives an agent on an issue
from chat (a
/commentresume, or a chat-created issue), the agent's reply is delivered back into the originating chat once it finishes, without polling. Single-shot (a heartbeat agent's later runs don't re-push) via a new nullableissues.chat_reply_tocolumn (migration 171), and DM-only — an agent's reply is never broadcast into a shared channel/group (the #374 audience principle). /capabilitiesdiscovery verb (#380) — lists what the requester can read/do, filtered to their role, vetting, and surface, so the injected vocab stays compact as the registry grows.
v4.16.3 — 2026-07-18¶
Fixed¶
- Chat MCP tool calls now answer in a single turn (no more "ask twice").
When a chat agent (e.g. the Slack Concierge) called a remote MCP tool, it
emitted the
/mcp:verb and a "standing by" reply, and the actual result only arrived after the user sent a second message. Root cause: MCP results were delivered only as a durable system marker, not via thereadsslice that drives the Slack/Telegram re-prompt (the mechanism that lets/get-issueanswer in one turn). An MCP-only turn had emptyreads, so no re-prompt fired. Themcp_calldispatch now surfaces the result viareadsand the durable marker: re-prompt surfaces fold it into the same turn (the agent answers with the tool result directly), while reads-discarding surfaces (web UI, agent fan-out) keep the durable copy — the #362 "never silently dropped" guarantee holds, with no double delivery.
v4.16.2 — 2026-07-18¶
Fixed¶
- Chat
/list-issuesnow shows each issue's opened date and reads newest-first. The list rendered ref/status/title but no timestamps, so the Concierge couldn't answer "which issues were opened in the last two days?" (it correctly refused rather than guessing).renderIssuesListnow appends each issue's created ("opened") date;query.ListIssuesalready orderscreated_at DESC, so the list is newest-first (now stated in the header). No query change — the timestamps were already loaded, just not surfaced.
v4.16.1 — 2026-07-18¶
Fixed¶
/get-issueand/get-notenow accept the reference on a continuation line, not just the verb line. The Concierge frequently emits the reference on atitle:continuation line (the structured format it learned from/create-issue) — e.g./get-issuethentitle: Look up MSFT closing price…. The parser only read the ref from the verb line, so it dropped the verb ("/get-issuedidn't parse"), and #374's full-issue lookup never ran — the Concierge could never actually retrieve an issue's status or answer from chat. Both verbs now read the ref from the verb line or a continuation hint (title:/issue:/ref:/id:for issues;id:/note:/title:/ref:for notes; the verb-line ref wins). A bare verb with no ref anywhere still drops loudly (#371). Parser-leniency fix; no behavior change to the verb-line form.
v4.16.0 — 2026-07-18¶
Added¶
- Chat ↔ Issues parity, v1 — broad tiered reads + issue conversation from
chat (design:
docs/superpowers/specs/2026-07-18-chat-issues-parity-design.md). A paired + vetted user can now drive much more of Staple from Slack/Telegram/ web chat, with authority that never exceeds the web (each action mirrors the role its web route requires) and audience-tiered sensitivity (never-in-chat / DM-only / channel-safe): - Generic reads via an entity registry —
/show <entity> <ref>and/list <entity>coveragent,project,goal,routine, andbudgetthrough one dispatch + authorization choke point (so a new entity can't bypass the gate). Channel-safe metadata renders anywhere; config fields (agent model/adapter/system prompt, project working-dir/repo) and the wholebudgetentity (admin-only) are DM/web-only — redacted in a shared channel. - Issue conversation writes —
/commentposts your reply on an issue (authored by you) and resumes the assigned agent by default (resume: falseto opt out);/edit-issuechanges title/body;/cancel-issuecancels. Drive-class behind the #265 vetting gate, audited, with loud markers on missing input. -
Follow-ons tracked separately: approvals/routines writes, proactive delivery-back of agent replies to the originating chat, and a
/capabilitiesdiscovery verb. -
Concierge reads full issue content — the resolution/answer, work product, and comment history — for authorized users (#374).
/get-issuepreviously returned only issue metadata (title/status/priority/description), so when a user asked "what was the answer from that issue?" the Concierge could confirm the issue existed but never surface the outcome — even for a completed issue whose answer was sitting in its comments (the engine posts an agent run's whole output as an issue comment). Now, for a paired, authenticated, vetted user on a 1:1 surface (a DM, or the already role-gated web chat),/get-issueincludes the issue's discussion: comments, agent work product, and the resolution/answer, each attributed to its author with a timestamp. The discussion is rendered newest-first (the answer leads) so it survives the chat re-prompt's head-truncation, kept under a size budget, with any trimming disclosed. In a shared channel or group (a Slack channel, or a Telegram group/supergroup) — where the reply reaches every member, including unvetted bystanders and external guests —/get-issuestays metadata-only even for a vetted mentioner (the Concierge offers to continue in a DM); unvetted / unpaired users likewise get metadata only. No comment content is exposed to a reader who could not already see it, matching the #265 vetting gate that governs the drive verbs. First slice toward full chat↔Issues parity.
v4.15.1 — 2026-07-17¶
Fixed¶
- Slack/Telegram: chat issue-creation silently dropped; list-issues empty on done-heavy companies; failures now surfaced (#371). Three fixes, none an access change (the reporting user was already vetted):
/create-issuenow accepts the title on atitle:continuation line (plusdescription:/priority:hints), matching the structured format chat agents naturally emit. Previously an empty verb-line title made the parser silently drop the verb — the issue never persisted while the agent reported confident success (data-loss + false-success). Applies to the shared action parser (heartbeat + chat)./list-issuessupportsstatus: alland falls back to every status when the active-set default is empty, so a company whose issues are alldoneno longer lists as empty.- When a reply contains a known
/verbline that parses to no action, a visible "action not applied — nothing was saved" note is routed back to the agent (and the user), so dropped verbs fail loudly instead of silently.
v4.15.0 — 2026-07-17¶
Fixed¶
- The company-template catalog is now reachable when creating a company
(#368). The
/get-startedtemplate gallery shipped in v4.13.0, but its only automatic entry was a first-run redirect that exempts admins — so instance admins (the people who actually create companies) had no way to reach it, and the "+ New Company" button only offered a blank name/slug form. The Companies page now has a Create from template button, and the New Company dialog links to the catalog, so an admin can stand up a pre-wired industry org (retail, healthcare, automotive, manufacturing, chemical, …) from the create flow. Reuses the existing gallery — no behavior change to blank-company creation.
v4.14.1 — 2026-07-17¶
Fixed¶
- Agent-written files in subdirectories are no longer stranded (#365).
A
/write-file agents/foo.mdon a project-less issue lands under the company file store (<CompanyHome>/files/agents/foo.md), but the company/agent Files browser was non-recursive and its download route was single-segment (rejecting/), so the artifact was invisible AND undownloadable. The browser now lists files recursively (by relative path, bounded at 5000, skipping VCS/dep dirs) and the download routes are chi/*wildcards resolved through the symlink-safehome.SafeJoinWithinDir(the #162 containment guard, shared with the project-file browser — a planted leaf symlink can't escape the files dir). A successful/write-filenow also posts a confirmation comment naming the saved path and where to find it (e.g. "Company → Files"), instead of leaving the location to the agent's prose.
v4.14.0 — 2026-07-17¶
Added¶
- Agents can now see and call registered remote MCP tools (#362). The
v2.40.0.3 registry bridge stopped at the external
/mcp/{companyId}server endpoint — Staple's own agents had no path to remote tools. Now a new/mcp:<server>:<tool> {json-args}action verb dispatches through the typed-tools registry, and agent prompts render a bounded Remote Tools (MCP) inventory so agents know the tools exist. Inventory coverage: issue-run prompts on the in-process and subprocess adapters (LLM, claude/codex local + sandbox, provider) and the Slack/Telegram chat digests; the reduced-context container adapters (docker/lambda) and the web-UI chat surface don't render the inventory yet, though the verb still dispatches there. Results return asynchronously per Staple's single-shot run model: issue runs get the result as a timeline comment, chats as a durable system message the agent reads next turn — both capped at 8 calls per run/reply. In chat the verb is drive-class behind the #265 vetting gate. Themcp_tool_callsaudit row now records the calling agent_id (NULL remains the marker for external-client calls). The remote-worker operating prompt intentionally does not advertise the tools yet (versioned contract — follow-on).
v4.13.1 — 2026-07-17¶
Changed¶
- Release signing now ships a sigstore bundle (#360). cosign v4 (CI
dependency bump) requires the bundle flow for keyless signing, so release
assets now include
checksums.txt.sigstore.jsoninstead of the detachedchecksums.txt.sig+checksums.txt.pem. Verify withcosign verify-blob --bundle checksums.txt.sigstore.json ….
Fixed¶
- MCP client now speaks spec-compliant Streamable HTTP (#347). The
outbound JSON-RPC client's
Acceptheader lists bothapplication/jsonandtext/event-stream(spec-compliant servers rejectedinitializewith 400 without it), SSE-framed POST responses are parsed (the JSON-RPC response is extracted fromdata:events by request id, skipping unrelated notifications), a server-issuedMcp-Session-Idis echoed on subsequent requests,MCP-Protocol-Versionis sent, andnotifications/initializedaccepts the spec-required202 Accepted. - Flaky test:
TestBearerAuth_*LastUsedAtUpdated(#351). Both middleware tests asserted the asynclast_used_atwrite after a fixed 200ms sleep, which races the fire-and-forget goroutine under CI load. Replaced with a shared bounded poll (5s deadline, 50ms interval). Test-only change.
v4.13.0 — 2026-07-17¶
Added¶
-
New company template: Chemical → Specialty chemicals (#346). A specialty chemicals org — CEO, CFO, R&D Director, Plant Operations (with EHS and Supply Chain under it), Regulatory Affairs, and Sales & Technical Service — with 2 starter projects, 3 routines with prefilled schedules (shipped disabled), 2 goals, and 6 labels. One JSON file, zero Go changes. Completes the template-catalog program (#341-#346).
-
New company template: Manufacturing → Contract manufacturing (#345). A contract manufacturer org — CEO, CFO, VP of Operations with Quality, Supply Chain, Manufacturing Engineering, and HR & Safety under it, plus Sales & Programs — with 2 starter projects, 3 routines with prefilled schedules (shipped disabled), 2 goals, and 6 labels. One JSON file, zero Go changes.
-
New company template: Automotive → Dealership (#344). A car dealership org — General Manager, Controller, Sales, Service, Parts, F&I, and Marketing, with the reporting hierarchy wired — plus 2 starter projects, 3 routines with prefilled schedules (shipped disabled), 2 goals, and 6 labels. First template added purely as content through the #341 catalog: one JSON file, zero Go changes.
-
Healthcare (Hospital) template enriched to the turnkey standard (#343). Ships 3 routines with prefilled schedules, all disabled until the user enables them (weekly quality & patient-safety review, monthly compliance & privacy audit, weekly staffing & capacity report), plus 2 starter goals and 6 industry labels.
-
Retail (Apparel) template enriched to the turnkey standard (#342). The template now ships 3 routines with prefilled weekly/monthly schedules — all disabled until the user enables them (weekly merchandising report, monthly finance review, weekly e-commerce funnel review) — plus 2 starter goals and 6 industry labels. It is the reference exemplar for the catalog's authoring standard (
internal/handler/onboarding/README.md). -
Template catalog v2 (#341). The
/get-startedgallery is now a manifest-driven catalog: each onboarding template carries its own"template"metadata block (name, description,starter/industrycategory, industry + subtype taxonomy, sort weight) inside its bundle JSON, and the registry is discovered from the embedded files at startup — adding a template is a pure content operation with zero Go changes. The gallery groups cards into Starters and Industries (per-industry headings, subtype badges: Retail → Apparel, Healthcare → Hospital). A CI catalog harness validates every embedded template (taxonomy shape, role validity, unique names, single acyclic org root, dormant heartbeats, schedule triggers shipped disabled, in-bundle name resolution) and runs a realImportCompanyapply for each; negative fixtures prove each guardrail trips. New authoring guide:internal/handler/onboarding/README.md. -
Register a remote worker from the admin UI (#335).
/admin/workershas a Register worker form: it pre-provisions the worker server-side, shows the one-time API key, and generates a ready-to-pastestaple-workerenv file with the instance URL and freshly mintedSTAPLE_WORKER_KEYfilled in, plus setup instructions. Admin-created workers are born approved even underSTAPLE_REQUIRE_WORKER_APPROVAL=true(the admin's action is the admission decision) and count againstSTAPLE_MAX_REMOTE_WORKERS. The shipped binary's boot-timeregistercall resumes the pre-provisioned worker via the existing idempotent-resume path — no duplicate row, no new key. - Edit a remote worker's tags from the admin UI (#336). Each worker row on
/admin/workershas an inline edit control for its capability tags. Tags are now server-authoritative: the stored set is the ceiling for task matching, so an admin edit takes effect on the worker's next claim poll with no restart. Operating-prompt contract bumped to 1.4.0.
Changed¶
- Remote-worker claim tags are server-authoritative (#336). A claim-body
tagsset can now only narrow a worker to a subset of its stored tags (intersection) — it can no longer broaden a worker past the tags it registered with, closing a gap where an authenticated worker could claim any task regardless of its enrolment tags. The shippedstaple-workerbinary (which sends no claim-body tags) is unaffected. An idempotent register-resume continues to ignore the resume body's tags (it never adopted them) and now echoes the authoritativetagsin its response — and logs any drift — so a worker with a staleSTAPLE_WORKER_TAGSenv value can reconcile.
v4.12.0 — 2026-07-12¶
Kubernetes/operations hardening: three self-contained follow-ups that make split-mode deployments and license recovery production-ready — in-cluster MinIO by default (#236), cross-tier live events (#237), and in-app license install/replace without a restart (#270).
Added¶
- In-cluster MinIO default for object-storage project files (#236).
helm install ... --set projectFiles.backend=object_storagenow works out of the box: the chart provisions a single-replica MinIOStatefulSet+ a bucket-createJoband wires the app to it automatically. An externalprojectFiles.s3.endpoint(orminio.enabled=false) opts out to external S3. This closes the one deferred item from #134's chart. Chart-only — the app already spoke the fullSTAPLE_PROJECT_S3_*+ AWS credential-chain contract. - Split-mode cross-tier live (SSE) events (#237). In
STAPLE_ROLE=splitthe scheduler generates live events but the browser SSE hubs live on the web pods, so browsers previously missed scheduler-generated events. The scheduler nowNOTIFYs each event on a Postgres channel and every web podLISTENs and re-broadcasts it to its browsers — no new infrastructure, no config.role=allis single-process and unaffected. Largerun.logframes are shipped as a pointer and re-hydrated from the database, so nothing is dropped for being larger than the Postgres NOTIFY limit. - In-app license install/replace without a restart (#270). An instance admin
can paste a license at Admin → Operations → License to install, replace, or
remove it — verified before it's saved, persisted, and applied immediately. The
page is reachable while the instance is read-only (degraded), so a locked-down
instance can be restored without shell access or a restart (the self-heal path
deferred in #136). An uploaded license takes precedence over
STAPLE_LICENSEand survives restarts; minting stays CLI-only.
Notes¶
- No new migrations. #270 reuses the
instance_settingsKV table; #237 uses ad-hoc Postgres LISTEN/NOTIFY (no DDL); #236 is chart-only. - New Helm values block
minio.*(image tags pinned; changeminio.rootUser/minio.rootPasswordin production).
v4.11.0 — 2026-07-11¶
Compiled Knowledge (#220): a compile-time complement to the retrieval knowledge base. An LLM distils each company/project/agent's sources into typed, cross-linked wiki articles ahead of time, and a compact index of them is injected into every heartbeat prompt — the agent always sees the shape of what's known without a retrieval call. Opt-in and default-off. Also lands the Slack ingestion follow-ups (#312–#314) and webhook-trigger hardening (#226).
Added¶
- Compiled Knowledge (#220). With
STAPLE_COMPILED_KNOWLEDGE_ENABLED=trueon the worker + engine tier, a background worker distils a company's knowledge into typed articles — concept, decision, connection, qa — that cross-link with[[type/slug]]wikilinks, and the engine injects a compact index of them into every agent prompt under a Compiled Knowledge section. Compilation runs at three tiers that mirror Staple's tenancy — company (company-wide knowledge docs), project (a project's own docs), and agent (the agent's context-compression summary) — each hash-gated independently so an unchanged source is skipped. A single provider call per changed scope (the company must have an LLM provider). It complements the RAG knowledge base rather than replacing it; both run when enabled. - Compiled Knowledge management + promotion (#220). A new instance-admin
page at
/companies/{id}/compiled-knowledgelists a company's compiled articles across every tier and lets an admin promote an agent- or project-tier article up to the company tier — a copy is created (stamped withpromoted_fromprovenance) and thereafter injected for every agent in the company. Promoted articles are pinned: a later company recompile won't archive them. Seeconcepts/compiled-knowledge.md. - Slack ingestion: user-name resolution (#312). Ingested Slack threads now render author display names instead of raw user ids, resolved via the Slack API and cached per sync.
- Slack ingestion: channel picker (#313). The Slack Knowledge Ingestion →
Channels page now lists the workspace's channels (via
conversations.list) to pick from, instead of requiring hand-pasted channel ids. - Webhook fire-URL rotation (#226). A trigger's inbound fire URL can now be regenerated from the routine's triggers panel (rotating the public id), and the panel carries a persistent warning when a trigger accepts unsigned webhook payloads. Sign-by-default remains a deferred breaking change (#226 stays open for that).
Fixed¶
- Slack ingestion: reconcile deleted threads within the re-scan window (#314). A thread deleted in Slack but still inside the ~90-day re-scan window now archives its Knowledge Base document on the next sync; threads that have merely aged out of the window are left in place.
Notes¶
- Migrations 169 (
compiled_knowledge_articles) and 170 (compiled_knowledge_sources), both additive with tenant RLS. - New background worker
compiled_knowledge_compile(gated onSTAPLE_COMPILED_KNOWLEDGE_ENABLED, worker tier only). STAPLE_COMPILED_KNOWLEDGE_ENABLEDis runtime-editable and default-off; nothing compiles and nothing is injected until it is set.
v4.10.0 — 2026-07-11¶
Slack ingestion as a knowledge source (#266): learn from a Slack org's conversations by ingesting admin-selected channels into the company Knowledge Base. Opt-in and default-off.
Added¶
- Slack channel ingestion into the company KB (#266). An admin allowlists
specific Slack channels (never the whole workspace) on a new Slack Knowledge
Ingestion → Channels page under the company's Integrations; a background
worker then backfills roughly the last 90 days of each channel and keeps it
current, turning each thread into one Knowledge Base document. The docs
are embedded and retrieved like any company knowledge — surfaced to agents,
per-project/expert agents (#135), and chat. Reuses the company's existing
Slack bot token; requires adding read scopes (
channels:history,channels:read, andgroups:*for private channels) and inviting the bot to each channel. Gated onSTAPLE_SLACK_INGEST_ENABLEDon the worker tier. Ingestion is Web-API only (no Socket Mode) and the bot never posts to an ingested channel. v1 renders raw Slack user ids as authors and reconciles within the ~90-day window; name resolution and a channel picker are follow-ups.
Fixed¶
- Archiving a project now purges its knowledge embeddings (#135 follow-up). Archiving a project previously only disabled retrieval; its ingested-file docs and their embeddings physically remained. Archive now soft-archives the project's owned docs so the existing cleanup worker deletes their embeddings, fully meeting the "archive removes the project's retrieval + embeddings" requirement. Also fixes a case where a knowledge-indexed project with no expert agent skipped archive cleanup entirely.
Notes¶
- Migration 168 (
knowledge_slack_channels, additive). - New background worker
slack_knowledge_sync(gated, worker tier only).
v4.9.0 — 2026-07-11¶
Project-scoped knowledge (#135) and persona operational reporting (#267): two opt-in features that make a company's own data first-class context for its agents. Both are additive and default-off — existing companies, projects, and agents behave exactly as before until opted in.
Added¶
- Project-scoped knowledge + per-project expert agent (#135). A project can now be opted into its own knowledge store (a per-project settings toggle). When on:
- the project's files are ingested and embedded into a project-scoped store — a periodic reconcile worker walks each opted-in project's files, extracts text (skipping binaries), and keeps the store in sync as files are added, changed, or removed (catching both UI uploads and remote-worker agent edits);
- a curated subset of company-KB docs can be linked to the project from a picker on the project page (company-wide docs only; never another project's files);
- any agent working one of the project's issues automatically gets the project's knowledge injected into its prompt, alongside company knowledge;
- an optional per-project "expert" agent can be created — a dormant agent
you chat with to ask about the project, answering from the project's
knowledge store. Archiving the project archives its expert and stops
project-knowledge retrieval.
Reuses the existing embedding/RAG pipeline: a project's knowledge is
company-knowledge scoped by a new
project_id, so retrieval, budgeting, and injection all carry over. Retrieval remains gated onSTAPLE_EMBEDDING_ENABLED. - Persona operational reporting (#267). A routine can be marked a
persona-report routine by giving it a metrics window (24h / 7d / 30d /
90d). On fire, the worker pre-computes a curated operational metrics snapshot
(cost + issue throughput + run health) and injects it into the spawned issue,
so a C-suite-role assignee grounds its report on real numbers in a single
turn. Agents publish the result with a new
/reportverb as a work-product of typereport(rendered as markdown in the work-products panel). A/metricstyped tool exposes the same snapshot to the MCP surface.
Notes¶
- Migrations 164–167 (all additive:
routines.metrics_window;company_knowledge.project_id+project_knowledge_links;projects.knowledge_indexed;projects.expert_agent_id). - New background worker
project_knowledge_sync(gated onSTAPLE_EMBEDDING_ENABLED, runs only on the worker tier).
v4.8.0 — 2026-07-10¶
Company templates (#262): stand up a realistic agent org in one click at company creation.
Added¶
- Industry company templates (#262). Two new options in the
get-started gallery — Retail (apparel) and Healthcare (hospital) —
each instantiating a ~7-agent org with the
reports_tohierarchy wired (CEO at the root, function leads reporting up). Agents come up dormant (heartbeat_enabled=false) with the company-default adapter, so nothing runs or costs anything until you configure a provider and switch them on. Adding more industries later is just another template file. - Worker target in templates + export (#262/#261). Company- and
project-level
default_worker_tagsnow round-trip through the portability bundle, so a template can pre-set a worker target and company export no longer drops it.
v4.7.0 — 2026-07-10¶
Multi-agent chat threads (#265 slices 3–4) — the completion of the richer-chat epic. Several agents can now converse in one thread, and agents finally know about the chat drive surface v4.6.0 gave vetted users.
Added¶
- Multi-agent chat threads (#265 slice 3). Address any chat-capable
agent with
@Agent-Name(hyphens match spaces) in web chat, Slack, or Telegram. A mention routes that turn only — the chat's current agent stays the default responder. Mention several agents and they reply sequentially in mention order, each seeing the previous replies, so agents genuinely react to each other. Every reply is labeled with its author (web bubbles;*Name*:prefixes on channels). - Identity-safe multi-party transcripts. Each responding agent sees its
own prior replies as its own speech and other agents' replies as
[Name]:-attributed turns — no more identity bleed in shared threads. Single-agent chats produce byte-identical transcripts to before. - Agents now know the chat drive surface (#265 slice 4). The chat
vocabulary cheatsheet teaches the v4.6.0 verbs (
/status,/priority,/assign,/blocks,/blocked-by,/relates,/fire-routinewith theissue: <ref>hint) plus multi-agent etiquette, so agents actually use them. New "Chat verbs" section in the action-verbs docs.
Notes¶
- The JSON chat API keeps its backward-compatible single-reply contract: a mention routes the reply, but fan-out is a web/channel feature.
- No migrations. The #265 epic (vetting gate → action parity → multi-agent threads → capabilities) is complete; the issue is closed.
v4.6.0 — 2026-07-10¶
Chat becomes a real control surface (#265 slices 1–2): an admin-vetted, paired user can now drive Staple from Slack/Telegram chat — set status/priority, assign, link issues, and fire routines. Plus company/project-level worker targeting (#261).
Added¶
- Chat vetting gate (#265 slice 1). Pairing authenticates; vetting
authorizes. An admin-managed per-user gate now controls who may drive
Staple via chat verbs; read verbs (get/list issues + notes) stay available
to everyone the chat already served. Admins vet/revoke from the pairings
audit page (audited as
vet/unvet). Existing pairings are grandfathered as vetted — nothing breaks on upgrade; new pairings start unvetted (fail-closed). Vetting survives a same-account re-pair and resets when the external account changes. - Chat action parity (#265 slice 2). New chat verbs for vetted users:
/status,/priority,/assign,/blocks,/blocked-by,/relates, and/fire-routine. Issue-scoped verbs target an explicit issue via anissue: <ref>hint line (identifier, id prefix, or title); semantics mirror the heartbeat verbs, and/fire-routinehonors the routine's concurrency policy (run source recorded aschat). Heavy verbs (/write-file,/install-*) and/checkout//releaseremain heartbeat-only. - Company/project default worker target (#261). Remote-adapter agents with no worker tags of their own now inherit a default target at dispatch: agent explicit tags > project default > company default (first non-empty tier wins). Configure under company settings → Hired Agent Defaults and on the project page. Empty everywhere = any worker, exactly as before.
Security¶
- Chat drive-verb hardening (#265). The vetting gate also closes a
pre-existing gap where unpaired channel users could drive the actor-less
write verbs (create issue, request approval, spawn subagents) in any
strict_actions=falsechat. Unpaired and unvetted users are now limited to read verbs, with an explanatory marker on blocked actions. On Telegram (which has no allowlist) vetting is the only per-user drive control.
Notes¶
- Migrations 162–163. Agent "blank worker tags" now means inherit the project/company default rather than any worker; installs with no defaults configured behave exactly as before.
v4.5.0 — 2026-07-09¶
Deterministic Council orchestration (#263) is the headline: an operator can convene a bounded, tiered Council (Head → 5 Leads → 15 researchers) on an issue and get syntheses threaded back up and published — a 21-run deterministic cascade that replaces the earlier emergent path. Plus C-suite roles, live-transcript accessibility, and the usual hardening.
Added¶
- Council orchestrator (#263). An explicit operator convene — the issue-page
button or
POST /api/issues/{id}/convene-council— runs a bounded 21-run cascade (Head → 5 Leads → 15 researchers), merges each tier's syntheses upward, and publishes the result. Backed by a durable step-function (council_sessions/council_tier_runs, migrations 159–161), acouncil_runtask kind, and a context-bound run-dispatch primitive that pins each run to its originating issue + brief (absorbs #260). At most one active session per issue. - C-suite roles (#259). CRO, CLO, and CIO added to the built-in role set.
Changed¶
- Retired the emergent Council path (#263). Council is now
explicit-convene-only; the
name→council_rolecapability was renamed (backfill migration 161) and the Council prompts (states 0–5) trimmed.
Fixed¶
- Live-transcript accessibility (#282). The streaming run transcript now
carries
role="log"/aria-live="polite"so screen readers announce entries as they arrive, and the live-activity pulse animations honorprefers-reduced-motion. - Deterministic test isolation (#247).
TestUIAgentProposalsPendingno longer counts agent-header rows globally, so it is stable on a shared DB.
Security¶
- Unsigned-webhook trust model surfaced in the UI (#226). Creating or
revealing a webhook with
signing_mode=nonenow warns that the fire URL is a bearer capability. The default remainsnone; sign-by-default is tracked separately on #226.
Docs¶
- Licensing issuer/admin guide (#269). New consolidated reference — payload
schema,
staple-licensemint/verify, and the multi-kid key-rotation procedure. - Remote-worker deployment topologies (#264). Documented the supported topologies and the trust boundary.
Build / CI¶
- Go 1.26.5 in CI, clearing govulncheck advisory GO-2026-5856 (crypto/tls
ECH privacy leak). GitHub Actions bumped:
upload-artifactv7,setup-pythonv6,deploy-pagesv5,docker/login-actionv4.4.0,docker/setup-buildx-actionv4.2.0. Helm chartappVersion→ 4.5.0.
v4.4.2 — 2026-07-02¶
License anti-tamper hardening (#240) — the air-gap-safe, honest-customer subset. Backward compatible: existing high-water-mark / trial rows are accepted once and re-sealed on upgrade; no migration or action required.
Added / Changed (security)¶
- Build-date floor. License expiry is now evaluated against at least the binary's build date, so a clock back-dated below it is caught even on a fresh, air-gapped install.
- Tamper-evident time state. The persisted clock-rollback high-water mark and the trial first-boot time are HMAC-sealed, so editing them directly in the database is detected on the next boot. The response is always soft — the value is distrusted and floored, never a lockout — and the trial can no longer be extended or renewed by tampering. The high-water mark is also bounded so a glitched or hostile far-future time source can't pin an instance into read-only.
This is deliberately not DRM (a determined adversary with the binary is still out of scope, per #240) — it raises the bar against casual clock/database tampering without breaking air-gapped installs.
v4.4.1 — 2026-07-02¶
Follow-ups to the v4.4.0 licensing release (#136).
Added¶
- License status card on
/admin/operations. Surfaces the installed license at a glance — status (with read-only / clock-rollback flags), tier (or trial), customer, expiry date + days remaining, issued date, and any degraded reason — with a health-banded left border (green → amber ≤30d → orange ≤14d → red when degraded). Instance-admin only, so it may show the customer + tier that the all-users banner deliberately omits. Fills the details gap left by the v4.4.0 countdown banner. make build-staple-licensetarget for the offline license minting tool (cmd/staple-license), matching thebuild-sandbox-fixtures/build-slack-probeconvention — an internal tool, not in the defaultbuildand not shipped by GoReleaser.
v4.4.0 — 2026-07-02¶
Adds an offline, Ed25519-signed licensing system (#136).
Added¶
- Licensing (#136). An instance is gated by an offline, Ed25519-signed,
time-based license supplied via
STAPLE_LICENSE(inline) orSTAPLE_LICENSE_FILE(path). Verification is fully offline (no network, air-gap-safe). Clock-rollback is detected via a persisted monotonic high-water mark (strategy A), optionally advanced by trusted third-partyDateheaders (strategy B, never required). The signed payload carriestier/features/limits(seeded intoinstance_flags; only time is enforced in v1) and is minted offline with the internalcmd/staple-licensetool — the private key is never shipped. Escalating pre-expiry banners and a read-only banner surface in the UI. See Licensing. Adversarial-DRM hardening is deliberately out of scope (#240).
Changed — action may be required on upgrade¶
- Instances now require a license. After upgrading, an instance with no
STAPLE_LICENSEruns a built-in 30-day trial (from first boot) and then enters read-only mode: data stays fully viewable, but new writes and new agent runs are blocked. There is a 7-day grace window after a license'sexpiresdate. To keep an instance fully operational, install a valid license (STAPLE_LICENSE) before the trial/grace elapses. Login and all reads remain available while degraded, so you are never locked out of your data and can install a license and restart to recover.
v4.3.0 — 2026-07-01¶
Kubernetes deployment support: a process-role split, published container images,
and a Helm chart. role=all (the default) is byte-for-behavior identical to the
existing single-process binary.
Added¶
- Kubernetes deployment (#134). One binary now runs in one of three roles via
STAPLE_ROLE:all(default — HTTP + all background singletons, identical to the single-process binary),web(HTTP only, scale N replicas), orscheduler(the singletons only). A Helm chart (deploy/helm/staple) deploys an all-role or split web/scheduler topology + optional remote worker (HPA), with in-cluster pgvector Postgres by default (toggle to external/managed) and object-storage project files. Multi-arch (amd64 + arm64)staple-server/staple-worker/staple-seedimages are published to GHCR on release. Boot-time migrations are now serialized by a Postgres advisory lock so N replicas can migrate safely. See Kubernetes (Helm). Follow-ups: in-cluster MinIO default (#236), split-mode cross-tier SSE (#237).
v4.2.1 — 2026-07-01¶
Two runtime fixes surfaced on a live instance.
Fixed¶
- Project files couldn't be downloaded/uploaded/deleted from the UI (#228):
a logged-in user clicking Download on a project workspace file got
missing authorization header. The project-file routes (/api/projects/{projectId}/files/*) live in the Bearer-only route group, so a browser (session cookie, noAuthorizationheader) was rejected byBearerAuth. Added session-authed UI routes —GET /projects/{projectId}/ui/files/download/*,POST .../ui/files/upload,DELETE .../ui/files/*— reusing the existing handlers (which already authorize the actor against the project's company), and repointed the project page's controls. The Bearer-only API routes stay for agent (API-key) access.
Security¶
- Enforced human approval for agent-hired agents (#229): the
hire-agentskill lets an agent create agents viaPOST /api/companies/{id}/agents, and the handler auto-issues the new agent's API key — but the new agent was created active with no approval step, despite the skill documenting that it "installs paused and requires human approval." An agent could silently spin up active agents, each able to hire more. Now, when an agent creates an agent it lands inpending_approval: it is not scheduled, and its API key is inert until approved (closing the bypass where the auto-issued key is used to hire further agents). Activation is human-only — resume/pause/update paths refuse to move a pending agent to active, and a company admin/member approves or rejects it on the agent's page. Human-created agents are unaffected. No migration (mirrors the #210 worker-admission pattern).
v4.2.0 — 2026-07-01¶
A large security-hardening + tenancy-completion release: the full #145–#185
multi-agent security review, opt-in worker admission control (#210),
dial-time SSRF guards for all provider/MCP calls (#167/#169), and completion
of cross-company isolation (#142). New opt-in config; one behavior change —
GET /api/workers/operating-prompt now requires a worker key (#148), which the
bundled staple-worker handles transparently.
Security¶
A broad security-hardening pass from a multi-agent review (#145–#185), landing as branch-per-theme PRs #186–#209. Highlights below; each item links its issue.
Operator-facing / new configuration
- New env vars: STAPLE_MAX_REMOTE_WORKERS (default 10000; cap on worker
registrations, #153), STAPLE_MAX_SSE_CONNS_PER_USER (default 20; per-actor
live-stream cap, #183), STAPLE_MOUNT_ALLOWLIST_ROOT (optional agent-sandbox
bind-mount allowlist, #158), and STAPLE_METRICS_TOKEN (optional bearer gate
on /metrics, off by default, #147). See environment.md.
- Worker admission control (STAPLE_REQUIRE_WORKER_APPROVAL, default off, #210):
opt-in gate so a newly-registered remote worker sits pending — inert key,
no operating prompt, 403 on heartbeat/claim — until an instance-admin
approves it under Pending approval on /admin/workers (approve / reject,
with the worker's source IP + reported build shown for review). The bundled
staple-worker waits gracefully (backoff re-register) until admitted. Off by
default = today's auto-onboard; existing workers backfill to approved. Also the
agreed resolution for #148 and a hardening for #153/#144.
- Operating-prompt endpoint moved behind worker auth (#148):
GET /api/workers/operating-prompt now requires a valid worker key — the full
worker contract is no longer disclosable to anonymous callers. Bootstrap is
unchanged (the prompt ships in the register response); the standalone endpoint
is only for an approved worker re-fetching after an upgrade.
- Dial-time SSRF guard for all provider + MCP calls (#167/#169):
mcp.NewClient (#169) and every outbound call to a company-configured provider
base_url — the registry-provider heartbeat path (PR #217) plus the
chat/completion + inline adapter_config path and the embedding/completion
free functions — now dial through an SSRF-guarded client, so a host that
DNS-rebinds to a private/metadata IP after registration-time validation is
blocked at connect time.
- STAPLE_SESSION_SECRET removed (#155): it was never used — sessions are
DB-backed and survive restarts; the old "sessions die on restart" warning was
incorrect and is gone.
- Session cookie hardened (#154): over HTTPS the session cookie uses the
__Host- prefix (Secure + Path=/ + no Domain); reads tolerate the
legacy name so no forced re-login.
- Webhook hmac_ts signing mode (#178): opt-in per-trigger replay-resistant
mode — HMAC over "<unix-ts>.<body>" with a ±300 s freshness window, so a
captured request can't be replayed once it ages out. See
webhooks-in.md.
Access control, tenancy & credential exposure
- Cross-company write/read/enumeration guards, worker-task ownership checks, and
routine-trigger authorization (#142/#144/#179, PR #186).
- Cross-company isolation completed (#142): a systematic sweep of every
handler GetXByID call site closed the remaining IDOR tail — the PinComment
pin-write, an 11-handler cluster (foreign-agent adapter_config/secret writes,
adapter test-probes, session clears; issue read/archive state; feedback votes;
asset deletes) and cross-company label-delete + note→issue link injection
(PRs #223/#224/#225). Handlers loading an object by id now fail-closed with an
existence-hiding 404 unless the actor's company owns it.
- Stopped returning decrypted provider API keys to non-admins and inline
adapter_config secrets to all members; redaction now catches Anthropic/OpenAI
key formats in logs/SSE (#171/#172/#173, PR #189).
- Reject unverified emails for OAuth/OIDC account linking (#163/#164/#174, PR #190).
SSRF & injection - SSRF validation on provider/A2A/MCP/worker base URLs and knowledge-git URLs (#167/#168/#169/#170/#175, PR #187). - Stored-XSS escape in the adapter test-environment partial (#176, PR #192); CSV formula-injection neutralized in the audit export (#177, PR #197); LIKE-metacharacter escaping in user-facing search (#159, PR #204).
Sandbox, adapters & DoS
- Agent sandbox rejects host-sensitive bind mounts (Docker socket, /, /etc,
…) with a blocklist + optional allowlist root (#158, PR #200); extra_args
permission-bypass flags stripped, binary-path allowlists for codex/plugins
(#157/#166/#184, PRs #186/#196).
- Worker-registration + SSE-subscriber caps (#153/#183, PR #201); UI route-group
rate limiting (#181, PR #193); legacy heartbeat-run-events pagination cap
(#182, PR #194).
Crypto & data-at-rest
- Backup stream binds its fmt marker into the wrapped-file-key AAD, blocking a
silent partial-restore downgrade (#165, PR #199).
- A2A push-notification bearer token encrypted at rest (migration 156);
now write-only in the API (#180, PR #207).
Auth surface & infra
- Login timing equalized to prevent account enumeration (#149, PR #198);
login-form synchronizer-token CSRF (#156, PR #208).
- Project working_dir path confinement (#160, PR #191); asset/attachment
delete path-containment + workspace symlink resolution (#161/#162, PR #202).
- HTTP security headers + no static dir listing (#145/#152, PR #188); release
workflow Actions pinned to commit SHAs (#185, PR #195).
Decisions
- /healthz + /readyz (#146) kept public by design — orchestrator probes
carry no credentials; they expose only version/commit-class data. Isolate by
network policy.
- #148 was re-scoped to worker admission control (#210) — both shipped this
release (see Operator-facing above); #144 verified fully remediated by #210
+ #186 and closed.
- #150, #151 confirmed already-mitigated (regression tests added).
Fixed¶
- Agent provider can now be cleared to "None" (#143): the set-provider form
dropped an empty
provider_id(nilIfEmptyStrsentnil= "leave unchanged"), so the stale provider persisted; it now clearsprovider_idto NULL. The heartbeat-config form is deliberately unchanged (it carries noprovider_id).
v4.1.1 — 2026-06-29¶
Security¶
- Cross-company isolation hardening (#142 — deep remediation). An adversarial completeness audit of the #142 epic found the original pass closed the resource-read IDORs but had missed a broad class of cross-company leaks: most of the by-id write/mutation layer, several enumeration surfaces, and a few infrastructure paths (multiple CRITICAL/HIGH were live in v4.1.0). This release closes ~50 handlers so one company cannot read, mutate, delete, enumerate, or subscribe to another company's data by supplying its UUID. All cross-tenant attempts now return 404 (existence-hiding), matching the JSON twins; legitimate same-company access is unchanged. Highlights:
- Writes/deletes now company-guarded: issue update/status/comment/delete/trigger-agent/checkout/release/vote; agent update/config/pause/resume/delete/heartbeat-config/proposal accept-reject/prompt promote-restore/vote/home; work-product + issue-relation create/update/delete; routine + webhook-trigger create/update/delete/run; skill delete; agent-permission grants; read-state/inbox-archive toggles; chat-message + issue-comment feedback upsert and delete.
- CRITICAL — live events: SSE
/api/live/eventsnow enforces a connect-time company-membership check (any authenticated actor could previously subscribe to another tenant's live + replayed stream). - CRITICAL — agent files/skills: the agent file and filesystem-skill APIs authorize the agent's company before resolving/listing/mutating a directory (was: enumerate/modify another company's agent files & skills by UUID).
- Enumeration: company-scoped UI list/detail routes are company-gated at the middleware layer; the company user-management page no longer exposes the instance-wide user directory (all tenants' names/emails) to a single-company admin; command-search, the cost dashboard, A2A peer list, and the agent org-subtree no longer cross tenants; A2A agent-cards omit a non-discoverable company's name.
- Cross-company link writes: issue/routine/project/agent create+update now validate that every client-supplied linked id (
parent_issue_id/project_id/goal_id/assignee_agent_id/reports_to) belongs to the same company. - Operator action: none beyond upgrading — the fixes are app-layer guards behind existing auth. A one-time data reconciliation of any pre-existing cross-company link rows, and continued adversarial auditing of the remaining low-severity tail, are tracked on #142 (which stays open).
v4.1.0 — 2026-06-28¶
Added¶
- macOS
.pkginstaller for localhost demos (#141). A double-click, universal (amd64+arm64).pkgstands Staple up on a Mac in ~2 minutes for localhost demos / evaluation. It wraps the existinginstall.sh(generates secrets, brings up the Dockerized Postgres, starts the launchdstaple-server, runs migrations + seeds the admin, waits for health) and drops a Desktop note (URL + admin login) plus a double-click Uninstall Staple.command. Build withmake macos-pkg(orscripts/release/macos-pkg/build-pkg.sh); Docker Desktop is a prerequisite (the installer checks it's running). Unsigned in v1 — right-click → Open to get past Gatekeeper. Explicitly a temporary, single-user demo path, not a production deployment. Docs: installing.md → macOS installer andscripts/release/macos-pkg/README.md.
Security¶
- Admin alert DMs now pick recipients from the env allowlist, not the DB column (#142 follow-up). The GEPA auto-promote, cost-budget, backup-health, and chat-escalation alerts previously DM'd whoever had
users.instance_role = 'admin'. They now resolve instance-admin recipients fromSTAPLE_INSTANCE_ADMINS(by email) — consistent with the rest of v4.0.0's env-authoritative instance-admin model. This removes the v4.0.0 upgrade caveat: you no longer need to also set the DB column to keep receiving operator alerts; the env allowlist is the single source.
v4.0.0 — 2026-06-28¶
The #142 cross-company isolation epic: a top-to-bottom pass ensuring one company cannot see or touch another's data — not its resources, not its uploaded files, not even its name. App-layer ownership guards on every resource handler, an env-authoritative instance-admin model, company-scoping at the middleware layer, a private-by-default company directory, and Row-Level Security across every tenant table.
This is a major release (per SemVer): it includes breaking operator- and client-facing changes — see Breaking changes below.
Breaking changes¶
STAPLE_INSTANCE_ADMINSis now required to have any instance admins. Theusers.instance_roledatabase column no longer grants instance-admin. On upgrade, set the env allowlist (comma-separated emails) to your cross-company admins, or they lose elevated access. (Admin alert DMs still read the DB column until a tracked follow-up — set both for now if you rely on them.)- Companies are private by default. A company no longer appears in any other user's company directory or join flow until an admin marks it discoverable (new "Directory Visibility" toggle on company settings). Existing companies become invisible to non-members until opted in; join requests to a non-discoverable company are refused.
- Cross-company access now returns HTTP 404, not 403. When an actor touches a company it isn't a member of, the API returns
404 "not found"(existence hidden) instead of403. Clients that branched on 403 vs 404 for another company's resource must treat 404 as "no access". (Genuine permission errors — a member with the wrong role — still return 403.)
Security¶
- Instance admins are now env-authoritative (#142, slice 1). Who counts as an instance admin (the role that can see across all companies) is defined by a new
STAPLE_INSTANCE_ADMINSenv allowlist (comma-separated emails;STAPLE_ADMIN_EMAILfolded in) — not by theusers.instance_roledatabase column, which no longer grants instance-admin. This means instance-admin can't be escalated by tampering with the DB or via a stray UI toggle; only the operator's environment (plus a restart) can mint a cross-tenant admin. The decision is made at session-auth time from the user's email; API-key and board actors are never instance admins. Upgrade note: add any existinginstance_role='admin'users' emails toSTAPLE_INSTANCE_ADMINSor they lose elevated access (intended). Until a follow-up migrates it, admin alert DMs (GEPA, backup-health, cost-budget, chat-escalation) still pick recipients by the DBinstance_role='admin'column — so to keep receiving those, also set the column for your env admins (or wait for the follow-up that routes them off the env allowlist)./api/menow reports the effective (env-derived)instance_role. First step of the broader cross-company-isolation hardening. - Comprehensive cross-company IDOR remediation (#142). Audited every HTTP handler and closed a broad class of cross-tenant access where a logged-in user — or an agent/board API key — could read, mutate, or execute another company's data by guessing a UUID. Every resource handler now verifies the caller's company owns the resource before acting, and returns 404 (hiding existence) on a cross-tenant attempt: agents (incl. runtime state, permissions, configuration, task sessions), issues, goals, projects, providers, plugins (incl. the lifecycle/start/stop/restart/delete actions), secrets, approvals, assets, skills, labels, work products, issue relations, execution decisions, issue documents, file attachments (a key for one company could previously download or delete another company's uploaded files), routines (incl. manual run, which executed another company's routine), feedback exports, project↔goal links, and heartbeat run events. Two systemic root causes are fixed: a guard idiom silently skipped for human (multi-company) users, and handlers that had no ownership check at all. A per-endpoint cross-tenant denial regression suite guards against regressions.
- Agent/board API keys are company-scoped at the middleware layer (#142).
RequireRolepreviously let non-user actors through company-scoped routes without checking the URL's company, so an API key for one company could act on another via a route guarded by role alone. It now enforces the{companyId}match for agent/board actors — defense-in-depth alongside the per-handler guards. - Cross-company denials are hidden as 404, not 403 (#142). When an actor touches a company it isn't a member of, the auth middleware and handlers now return
404 "not found"rather than403 "company access denied", so a probe can't even confirm the company exists. Genuine permission errors (a member with the wrong role) still return 403. - Companies are private by default, with an opt-in directory (#142). A company no longer appears in any other user's company list or join flow unless an admin marks it discoverable (a new "Directory Visibility" toggle on company settings); join requests to a non-discoverable company are refused as 404. This also closes a leak where the sidebar company switcher listed every company in the instance on every page — it's now scoped to the viewer's own companies (instance admins still see all). Migration 154 (
companies.discoverable). - Row-Level Security now covers every tenant table (#142). Added RLS policies to the 22 remaining company-scoped tables — attachments, documents, work products, execution decisions, read states, inbox archives, memberships, skills, logos, sidebar prefs, agent/board API keys, invites, join requests, feedback votes/exports, finance events, evolution runs, agent feedback, wakeup requests, and remote tasks — completing the DB-level defense-in-depth begun in earlier slices. Migration 155. The policies target the tenant-scoped transaction role and stay dormant (bypassed) for the default DB pool, so they're a backstop behind the app-layer guards with no behavior change for existing queries.
v3.30.0 — 2026-06-27¶
Added¶
- Per-project external-module credential, encrypted at rest (#110 follow-up — final slice). A project on the
external_modulebackend can now carry its own module credential (an admin-only, write-only "Module Credential" field on the project page), stored encrypted with the instance key via the same envelope crypto secrets use. When set, the project's module token is signed with this per-project key instead of the sharedSTAPLE_MODULE_KEY— so a project pointed at its own module endpoint can authenticate to a module that does not share the instance key. The credential is decrypted server-side only at token-mint time (both the in-process file handlers and the remote-worker dispatch); the plaintext never reaches a worker — only the short-lived minted token does — and the ciphertext is never echoed back through the API (json:"-"). Empty clears it (→ inherit the instance key); a decrypt failure falls back to the instance key rather than breaking file access. Migration 153. Completes the #110 pluggable-ProjectFileStoreepic.
v3.29.0 — 2026-06-27¶
Added¶
- Per-project external-module endpoint (#110 follow-up). A project on the
external_modulefile backend can now point at its own module base URL (an admin-only "Module Endpoint" field on the project page) instead of the instance-wideSTAPLE_PROJECT_MODULE_ENDPOINT— so one Staple instance can front several module services (e.g. regional deployments) that share the module key. Empty inherits the instance default (status quo). Both the in-process file handlers and the remote-worker task payload resolve the project endpoint when set. Migration 152. Setting it is admin-only (it directs trusted server-side requests, so it sits above the member-settablerepo_url/working_dir). Per-project module credentials (a distinct key per project) remain a later slice.
Fixed¶
- Server-side module requests now carry a scoped token, not the raw instance key (#110). The in-process project-file handlers previously sent the raw
STAPLE_MODULE_KEYas the bearer credential to the module service; they now mint the same short-lived, company+project-scoped token the remote-worker path already uses (verified byVerifyModuleToken). The master secret no longer leaves the server — which also makes the new per-project endpoint safe, since a project's files only ever travel with a token scoped to that one project.
v3.28.0 — 2026-06-27¶
Added¶
- Set a company's default file backend from the UI, with automatic project migration (#110 follow-up). The company settings page now has a Default File Backend control (
local_fs/object_storage/external_module). Changing it migrates the files of every project that inherits the default to the new backend (the same copy-then-remove flow as a per-project switch), so inheriting projects are never orphaned; projects with an explicit per-project backend are untouched. The change is refused (and nothing altered) if migrating any inheriting project would be unsafe — e.g. switching toobject_storagewith no S3 bucket configured — so an operator configures the backend first, then retries.
Fixed¶
- A company default of
object_storageis now inherited by its projects (#110).effectiveFileBackendonly honored a company default ofexternal_module; anobject_storagecompany default was silently ignored (projects fell back tolocal_fs). It now inherits any non-local company default, matching howexternal_modulealready worked.
v3.27.0 — 2026-06-27¶
Changed¶
- Switching a project's file backend now migrates the files automatically (#110 follow-up). Changing a project's
file_backend(e.g.local_fs→object_storageorexternal_module) previously failed with a 409 whenever files still existed on the current backend (the SP2.1 "guard-only, no silent orphaning" policy). It now copies the files to the new backend and removes them from the old one as part of the switch, so the migration is seamless and leaves no orphans. The copy streams file-by-file through the sharedprojectfs.Storeinterface (a newprojectfs.CopyAll), so it works for any backend pair (local-FS, S3 object storage, external module) in any direction. The copy only ever writes to the destination, so a failure leaves the source fully intact and the project readable on its current backend; the old-backend cleanup deletes exactly the files that were copied (so a concurrent write is never removed without being migrated). The switch is refused (and the files left untouched) when the target backend isn't actually configured — e.g. switching toobject_storagewith no S3 bucket orexternal_modulewith no module endpoint, which would otherwise resolve back to local storage and risk a destructive self-copy — so an operator configures the backend first, then retries. Empty projects switch freely (no data to migrate). No migration.
v3.26.0 — 2026-06-27¶
Added¶
- Opt-in full-context parity for remote LLM agents (#108 SP-5). A new per-agent toggle, Full context on remote workers (agent edit page;
agents.remote_full_context, default off), brings a remote-LLM-worker agent to full behavioral parity with the in-process LLM path: when enabled, the worker renders the same rich heartbeat prompt —## You+ manager/escalation, team roster,## Capabilities Available, pinned context, the full merged## Conversation History,## Child Issues, the attachments block, the structured## Trigger, and all 25 action verbs — instead of the thin remote prompt, with a server-rendered (token-substituted) system message. The server already executes remote agents' action verbs, so this grants full orchestration ability (triage/lead roles become viable on remote workers). Because it injects much more context into every heartbeat against the worker's LLM, it costs materially more per run — hence opt-in per agent. The server loads + serializes the rich context (capabilities, comments, activities, children, wake, attachments) into the task payload only for opted-in agents, so other agents' payloads are unchanged. The prompt-contract version bumps to 1.3.0 (additive + opt-in) so the claim-time gate (#108 SP-3) rolls the enriched contract out only to workers that understand it; older workers soft-warn and keep rendering the thin prompt. Migration 151. The final slice of the #108 unified-execution epic. - Remote workers can read/write object-storage-backed project files (#110 follow-up). When a project (or its company default) uses the
object_storagefile backend (#86), a remote worker now materializes its files into the agent workspace before the run and syncs edits back afterward — the same snapshot-diff, last-write-wins flow the external-module backend already had (SP2.2), now generalized over anyprojectfs.Storeso it serves both backends on both theclaude_localanddockerworker paths. The worker reaches the bucket through its ownSTAPLE_PROJECT_S3_*configuration (AWS default credential chain), so no S3 endpoint or credentials ever travel in the task payload — the server only flags the backend and the company/project IDs that form the object key. A worker that isn't configured for S3 logs a clear error and falls back to a local workspace rather than failing the run. This closes the remaining gap from #86 (object storage was server-side only); the worker stays database-free. No migration.
Added¶
- Remote workers stream their run transcript back to the server (#108 SP-6). A new
POST /api/workers/tasks/{taskId}/eventsseam lets a remote worker forward the engine events itsclaude_localsubprocess produces — assistant text, thinking, tool calls/results, and the final result — so they land in the sameheartbeat_run_eventstranscript andrun.logSSE channel as an in-process run, instead of a remote run being an opaque black box until it completes. Events flow through the existingEventSink.Emit, so they inherit the identical redaction (redactPayload) and monotonic per-run sequencing as in-process events; the worker forwards typed payloads (a small wire codec reconstructs the concrete payload type on the server precisely so redaction's type switch still fires — a generic map would silently skip redaction). Forwarding is best-effort and batched: a background flusher drains the buffer in emit order, and a failed POST is dropped, never blocking stream parsing or failing the run. Only theclaude_localworker path produces a meaningful token stream (thellmpath is a single non-streaming call). This lands the transport; the user-facing surfacing of these events (run-status summaries, blocked-reason rendering) is owned by #83. Reuses the SP-2 worker bearer key; no migration. Seventh slice of the #108 unified-execution epic.
Changed¶
- Cross-mode prompt-render parity is now guarded by a hard test (#108 SP-7). A new matrix test renders one shared fixture through all three
promptkitrender modes (Full/ClaudeLocal/Worker) and asserts each mode's section contract — which sections it must emit, which it must NOT (so no mode leaks another's sections), and whether it splits a system message — plus the cross-mode invariants that must always hold (the shared## Available Skillssection is byte-identical wherever it appears, company knowledge reaches every mode, the assignment identity lines are shared). This locks the renderer contract established by SP-4 so a future change can't silently drop a section or let the modes drift. Test-only; no behavior change. Sixth slice of the #108 unified-execution epic.
v3.24.1 — 2026-06-26¶
Fixed¶
- Agent prompt proposals no longer silently vanish from the audit feed (#133). The two cross-tenant proposal readers (
ListAllRecentAgentPromptProposalsand its filtered variant)SELECTed 13 columns while the sharedscanProposalscanned 16 — it also reads thetokens_in/tokens_out/cost_usdtelemetry added in v2.46.0.16, but those three columns were never added to these two SELECTs. Every call failed at scan time withnumber of field descriptions must equal number of destinations, got 13 and 16, so the GEPA proposals panel of the audit feed (/admin/audit?source=agent_prompt_proposals) returned an error and dropped all proposal rows. The bug was masked by the empty-DB smoke test (the mismatch fires only when a proposal row exists). Both SELECTs now include the three columns; a regression test seeds a proposal with token/cost telemetry and asserts both readers return it with the values populated (distinct values guard column order too). Pre-existing since v2.46.0.16; no migration.
v3.24.0 — 2026-06-26¶
Fixed¶
- Callback skills now work on remote workers (#108 SP-2). The per-run ephemeral Staple API key — already minted and revoked server-side for the in-process path — is now threaded into the remote task payload and injected into the agent subprocess env (
STAPLE_API_KEY/STAPLE_BASE_URL) on both the worker'sclaude_localanddockerpaths. Callback skills (list-team,hire-agent,request-approval) that previously failed silently with a missing-authorization error on a remote worker now reach the Staple API, matching in-process behavior. The key is scoped to the run and revoked when it ends; the new payload fields areomitempty(old workers ignore them; thellmpath needs no subprocess injection). Second slice of the #108 unified-execution epic.
Changed¶
- The remote worker is now database-free — no pgx in the worker binary (#108 SP-4b). The worker pulled the entire
jackc/pgxstack (11 packages) for one reason: it importedinternal/engine/adapters, whoseclaude_localadapter importsinternal/db/query+pgxpoolfor an in-processExecutepath the worker never calls. The pgx-free pieces the worker actually uses — the execution-contract value types (ExecutionParams/Result,AgentInfo,CompanyInfo,IssueContext,RosterEntry,TimelineEntry,KnowledgeChunk), the remote task wire payload (RemoteTaskPayload/Result), theclaude_localCLI plumbing (ParseConfig/BuildArgs/ParseStreamEvents/InjectClaudeExtraPath+ config/stream types +DangerousPermissionsAllowed), theRenderPromptshim, and the worker-executable adapter set — moved into a new pgx-free leaf,internal/engine/execkit. Theadapterspackage re-exports every one via type aliases + thin forwarding wrappers, so the ~13 in-process engine/handler callers are untouched;cmd/workernow importsexeckit/promptkitand no longer referencesadaptersat all. A new gate test (TestWorkerStaysDBFree) assertsgo list -deps ./cmd/workercontains nopgx/db/query/adaptersso the worker can't silently re-acquire the DB layer. Fifth slice of the #108 unified-execution epic. No behavior change (verbatim relocation; wire types/tags unchanged); no migration. - Heartbeat prompt rendering unified behind one pgx-free renderer (#108 SP-4). The three historical prompt renderers — the in-process LLM path (
engine.BuildHeartbeatPrompt), theclaude_localadapter (adapters.RenderPrompt), and the remote worker (buildPromptText) — are collapsed behind a single entrypoint,promptkit.Render(req), in a new pgx-free leaf package (internal/engine/promptkit). Each caller is now a thin shim that adapts its inputs into apromptkit.Request(with aModeofFull/ClaudeLocal/Worker); the renderer reproduces each mode's historical output byte-for-byte (the three modes legitimately differ — heading levels, action-verb coverage, wake-reason placement — and stay distinct), guarded by parity tests that diff each shim against an independent oracle across every section and conditional branch. The shared skill renderer (GroupSkillFiles/RenderAvailableSkills, #109) moved into the leaf too, withadapterskeeping type aliases + thin wrappers so existing callers are untouched. This removes the renderer from the pgx-taintedadapterspackage, setting up the worker to shed its database-layer dependency in the follow-up (SP-4b). Fourth slice of the #108 unified-execution epic. No behavior change; no migration. - Remote-worker prompt-contract version is now gated at claim time (#108 SP-3). When a worker claims a task, the server compares the worker's reported prompt-contract version (
remoteprompt.PromptVersion) against its own: a worker a MAJOR version behind is hard-refused with HTTP 426 (a major bump signals a breaking contract change the stale worker can't honor), while minor/patch drift soft-warns and proceeds (the/admin/workers"behind" badge still surfaces it). Unparseable or missing versions never gate — drift is asserted only when both sides parse cleanly. Third slice of the #108 unified-execution epic. - Unsupported remote-worker adapter types fail fast at dispatch (#108 SP-1). When an agent runs remotely, the server now validates its resolved
local_adapter_typeagainst the set a worker can actually run (llm/claude_local/docker) before creating theremote_tasksrow — so a misconfiguredcodex_local/lambda/*_sandboxtype surfaces a clear, immediate error instead of a buried "unsupported adapter type on worker" runtime failure after the task was already dispatched. The server and the worker now gate on one sharedadapters.IsWorkerExecutableAdapterset, so the two can never silently diverge. First slice of the #108 unified-execution epic. No migration.
v3.23.0 — 2026-06-26¶
Added¶
- Object-storage backend for project files (#86). A third
file_backendvalue,object_storage, stores a project's files in an S3-compatible bucket (AWS S3 / MinIO / Cloudflare R2 / Backblaze B2 / GCS-S3) instead of the server's local disk — fixing agent-written files (viawrite_file, the file browser, or the HTTP API) being stranded on a possibly-multi-instance server with no retrieval path. The server brokers all reads/writes through the sameprojectfs.Storeinterface, so every server-side call site works unchanged; objects are keyed undercompanies/<companyId>/projects/<projectId>/files/. Configure withSTAPLE_PROJECT_S3_BUCKET(+ optionalSTAPLE_PROJECT_S3_PREFIX/_REGION/_ENDPOINT/_PATH_STYLE); credentials come from the AWS default chain. Opt in per project or company viafile_backend=object_storage; an unconfigured or failed backend degrades to local-FS with a warning (never blocks boot). Server-side only for now (worker-side object storage is a follow-up, like the module's worker path); reusesinternal/backup's aws-sdk-v2 client patterns. No migration.
v3.22.0 — 2026-06-26¶
Security¶
- Cross-tenant IDOR on the goal show page + hardened weak company checks. The goal show page (
GET /goals/{id},UIGoalShow) loaded any goal by UUID with no company check — an authenticated caller (including an agent key) could view another company's goal and its issues; it now runsactorMayAccessCompanyand 404s foreign callers. Separately, six handlers (UIProjectEdit,UIGoalUpdate×2, and the routine get/update/delete handlers) guarded company ownership with a weakactor.CompanyID != ""check that silently no-ops for session-user actors; all now useactorMayAccessCompany(correct for agents, session members viacompany_memberships, and instance admins). Pre-existing. - Cross-tenant IDOR on project files + project entity closed. The project-file endpoints (
GET/PUT/POST/DELETE /api/projects/{id}/files[/*]) and the project show page (/projects/{id}) authenticated the caller but never checked that the project belonged to the caller's company — so any authenticated user could list, read, write, or delete another company's project files, and (viaPOST /projects/{id}/ui/update) repoint another tenant'sworking_dir/repo_url. Sibling handlers (UpdateProject/ArchiveProject) already enforced this; the file handlers,GetProject,UIProjectShow,UIProjectUpdate, andUIProjectArchivedid not. All now runauthorizeProjectAccess(→actorMayAccessCompany) and return 404 (no existence oracle) to callers outside the owning company. Pre-existing (predates the #110 SP1 abstraction). No migration.
Added¶
- Project-module hardening for untrusted networks (#110, SP2.4). The
staple-project-moduleservice can now serve HTTPS directly (setSTAPLE_PROJECT_MODULE_TLS_CERT/_TLS_KEY, or front it with a TLS proxy) — the real protection for a module on an untrusted network, since the bearer key and scoped tokens travel in theAuthorizationheader. It also audit-logs every authorized request (method, company, project, path, auth kind; never the bearer). Two originally-planned items are documented as not applicable to the architecture: a single-use nonce cache (one scoped token is reused across a run's many requests, so the bound is the TTL + TLS) and per-company egress-allowlist wiring (that gates the agent subprocess; the worker performs module I/O, so reachability is a deployment concern). - Remote workers can read/write module-backed project files, securely (#110, SP2.2). When a project's
file_backendisexternal_module, the server now mints a short-lived, project-scoped HMAC token into the worker task payload; the worker materializes the project's files into the agent workspace before the run (claude_localCWD /docker/project-filesmount) and syncs edits back afterward (snapshot-diff, last-write-wins), talking to the module with that token. The module verifies the token's signature, expiry, and that its claims match the requested company/project — so a worker can only reach the one project it was authorized for — while still accepting the co-located server's raw key. Workers never hold the masterSTAPLE_MODULE_KEY(now also the token-signing secret); tokens expire perSTAPLE_PROJECT_MODULE_TOKEN_TTL(default 1h). Old workers ignore the new omitempty payload fields. No migration. - External project-module file backend (#110, SP2.1). A new standalone
staple-project-moduleservice can host a project's files off the main server; the server reaches it through theprojectfs.Storeinterface over HTTP (streaming reads/writes, status→sentinel error mapping). Opt in per project or per company via a newfile_backendcolumn (local_fsdefault |external_module; a project's value overrides its company default), with the module endpoint/key fromSTAPLE_PROJECT_MODULE_ENDPOINT/STAPLE_MODULE_KEY. Reads still stream-proxy through the server unchanged; switching a backend while files still exist on the current one is blocked with 409 (no silent orphaning); a misconfigured/unknown backend degrades to local-FS with a warning. Server-side only — remote workers, per-request signed-token tenancy, and egress wiring land in follow-ups, so until then the shared-key/server-trusted model is for a co-located, single-tenant module (seedocs/user/operators/project-module.md). Migration 150; a 5th release binary (staple-project-module). ProjectFileStoreabstraction — project files behind a pluggable interface (#110, SP1). Introducesinternal/projectfs(a streamingStoreinterface —Read/Write/List/Delete/Stat— plus a local-FS backend and a per-projectResolver) and routes every server-side project-file call site through it: theGET/PUT/POST/DELETE /api/projects/{id}/files[/*]handlers, the project show page's workspace listing, and the enginewrite_fileproject tier. No behavior change except project-file GET no longer honors HTTPRange(the read now streams through the store so it is backend-agnostic). This is the foundational sub-project of the #110 epic; selectable off-server backends (an external project-module, object storage subsuming #86, worker-resident) and worker file-artifact sync-back are deferred to follow-up sub-projects. No migration; no config change; the worker is untouched.- Skills delivery parity — every execution path sees the same instance+company+agent skill set (#109). Previously an agent's available skills depended on which path ran it: the in-process
llmadapter's prompt showed only company-tier skills (instance and agent skills were missing), and the remote worker'sllmanddockerbranches ignoredpayload.Skillsentirely (zero skills), while onlyclaude_localgot the full merged tree via--add-dir. Now all four paths derive from one artifact — the per-run merged skill tree (fsskills.BuildMergedDir,agent > company > instanceprecedence). The in-processllmprompt is sourced from that merged tree (so instance + agent skills surface alongside company skills), the workerllmbranch renders an## Available Skillssection frompayload.Skillsvia a shared renderer (adapters.GroupSkillFiles+RenderAvailableSkills, byte-identical to the in-process section), and the workerdockerbranch rehydratespayload.Skillsand delivers them as a read-only/skillsbind mount +STAPLE_SKILLS_DIRenv var (the docker analog of--add-dir). The v2.14.6 company-skill approval gate now applies to every path: a company skill disabled on the Capabilities page is relocated from the prompt's DB query into the merge (hiddenCompanySlugs, symmetric with the #87 instance opt-out), so a disabled skill is withheld from the in-process prompt,claude_local, workerllm, and workerdockeralike — closing a gap where "disabled" skills were still shipped to remote workers/containers. Evolution-trace attribution (skills_in_context/skills_used) keeps pointing atcompany_skills.id. No migration (the gate reads existing capabilities). Instance/agent tiers have no enable flag, so the gate governs the company tier; the instance tier remains governed by the #87 per-company opt-out. - Real run cancellation — interrupt a stuck/runaway agent run, not just a DB status flip (#111). Cancelling a heartbeat run now actually stops it. Previously "cancel" only flipped
heartbeat_runs.statustocancelledwhile the goroutine kept running, the Claude/Docker subprocess kept burning, a remote worker never heard about it, and the success path could even overwritecancelledback tocompleted. Now: terminal status writes are status-guarded so a cancelled run is never resurrected; the engine keeps a per-runcontext.CancelFuncregistry so an operator cancel hard-interrupts the in-process goroutine and its subprocess; a sharedCancelRunorchestration also releases the checked-out issue, revokes the per-run ephemeral API key, and emitsrun.cancelledSSE + an activity-log row; the stale-run watcher now performs a real interrupt instead of a bare flip; and a remote-worker cancel channel (acancel-requestedtask state + aGET /api/workers/tasks/{id}/controlpoll the worker honors mid-execution) kills the subprocess on a worker node. Cancel is instance-admin scoped for v1 and now covers root-agent runs too (newPOST /runs/{runId}/cancel+ a Cancel button on the per-company run page), not just subagents. Idempotent on already-terminal runs;pendingruns cancel without spinning up execution. Migration 149 adds aremote_tasks(heartbeat_run_id)index for the cancel-by-run lookup.
v3.21.0 — 2026-06-25¶
Added¶
adapter_type: "default"sentinel — agents and templates follow the company's default adapter (#107). A newdefaultvalue for an agent'sadapter_typeis resolved at dispatch to the company'sagent_default_adapter_type, adoptingagent_default_adapter_configonly when the agent's own config is empty (merge-if-empty — a non-empty agent config is never clobbered). Resolution happens server-side at the top ofExecuteHeartbeatRun, before provider/factory dispatch and any remote-worker payload, so the concrete adapter is used everywhere and"default"never reaches a worker (the remote adapter also hard-fails defensively if it ever sees the unresolved sentinel). This makes agents — and especially exported template/portability bundles — portable across local↔hosted instances: a bundle authored forremoteimports cleanly into a local instance and follows its posture instead of hardcoding one adapter. A new agent-sideadapter_typeallowlist now rejects typos (which previously fell through silently to the barellmadapter), the legacyprocessalias is normalized tollmon write, and a recursion guard rejects a company default of"default". Thedefaultoption is offered in the agent create/edit dropdowns, and the built-in onboarding templates (Solo / Engineering / Research) now ship their agents asdefault. No migration.
Fixed¶
claude_localskills fail under launchd/systemd because the service inherits a minimal PATH (#117, #119). Whenstaple-server(or a worker) runs as a managed service, the supervisor starts it with a barePATH(/usr/bin:/bin:/usr/sbin:/sbin), so the spawned Claude CLI — and the Bash tool it runs skill scripts with — can't find tools in~/.local/bin,~/.cargo/bin,/opt/homebrew/bin, or anapiKeyHelperlikeforge(the latter surfacing asapiKeyHelper failed→ 401). NewSTAPLE_CLAUDE_EXTRA_PATH(colon-separated dirs, with leading~/and$HOMEexpanded against the service'sHOME) is prepended to the spawned CLI's PATH by both the in-process adapter (BuildClaudeEnv) and the remote worker; dirs already on PATH are skipped. The user-mode installer now auto-captures your PATH into it, so a fresh--userinstall Just Works; system-mode installs get a commented example. Documented inenvironment.md,staple.env.example, and the LaunchAgent plist.
v3.20.0 — 2026-06-25¶
Added¶
- Getting-started checklist on the dashboard (#104; also covers #105). After a
company is created (from a template or otherwise), the dashboard shows a
dismissible Getting started card tracking the first setup steps — connect an
LLM provider, review your agents, create your first issue, and invite a
teammate — each rendered done/todo from live state and deep-linked to the page
where you complete it. Those deep links are the lightweight post-creation
customization guidance #105 called for (no separate wizard engine): the
checklist walks the operator through refining the new company. The card
auto-hides once every step is done, and an admin/member can dismiss it at any
time (per-company,
companies.onboarding_dismissed_at, migration 148). - Guided first-time experience — template gallery + instant company creation (#100: #101, #102, #103).
A new user previously logged in to an empty list and had to figure out what to
do. Now a signed-in user who belongs to no company (and has no pending join
request) is redirected from the companies list to
/get-started(#101), a template gallery (#102) offering four starting points — Solo operator, Engineering team, Research team, and Blank. Picking one and naming the company creates it instantly and drops the user on its dashboard (#103). The template engine reuses the existing portabilityImportCompany: each template is a pre-baked company bundle embedded in the binary (no new tables), so e.g. the Engineering template lands a Lead with a Code Reviewer and Docs Writer reporting to it, plus starter projects — withreports_towired by name. The creator is made an admin of the new company (which the raw company-create path did not do). Instance admins, who manage every company, are exempt from the redirect. The getting-started checklist (#104) and customization wizard (#105) build on this in a follow-up. - Model lifecycle management — detect retired models, flag affected agents, bulk-migrate (#99).
When an LLM provider retires a model, every agent configured to use it fails on
its next run, and the only fix was editing each agent by hand. This adds
detection + bulk migration (Layers 1 & 2 of the issue; the optional per-agent
fallback chain, Layer 3, is deferred). An opt-in worker
(
STAPLE_MODEL_LIFECYCLE_ENABLED=true, intervalSTAPLE_MODEL_CHECK_INTERVAL_SEC, default 6h) walks the distinct(provider, model)pairs in use by active agents, fetches each provider's live model catalog via the existingModelCatalog, and records each model as seen or missed. A model is flagged unavailable only after two consecutive misses (available → deprecated → unavailable), and a total catalog-fetch failure skips the provider for that tick — so a transient provider-API blip never false-positives. When a model goes unavailable, an alert opens for every affected agent (with the provider's default model recorded as the suggested replacement); when a model reappears, its alerts auto-resolve. A new company-admin Model Lifecycle page (/companies/{companyId}/models, sidebar link) lists the affected agents grouped by model and offers a one-click bulk migration that updates every matching agent — both theagents.modelcolumn and theadapter_config.modelkey — to a replacement and resolves the alerts atomically. New tablesmodel_availability+agent_model_alerts(migration 147, both RLS-armed). - User-scoped BYOK LLM keys for interactive chat (#81). An individual user can
now bring their own LLM API key, which takes precedence over the company's
configured provider — for their interactive chats only (scheduled
agent/heartbeat runs always use the company provider, since they carry no user
actor). New
user_llm_keystable (migration 146, RLS company-scoped; theapi_keyis encrypted at rest withcrypto.Encrypt), keyed by(user_id, company_id, adapter_type). Resolution happens at the single chat choke pointEngine.resolveChatProvider: when the acting user (from the request context) has an enabled key matching the chat's adapter type, the call uses the user's key instead of the company provider's — verified end-to-end by a test that asserts the user's key (not the company's) reaches the wire, and that the no-user-actor and disabled-key paths fall back to the company key. Self-service My API Keys page (/companies/{companyId}/my-llm-keys, member-accessible, sidebar link); every handler scopes strictly to the sessionactor.UserID(never a client-supplied id), so a member can only ever touch their own keys, and per-user isolation is a query-layer invariant (the tenant scope sets onlyapp.current_company_id). RequiresSTAPLE_ENCRYPTION_KEY(keys are credentials). Adapter types: anthropic, openai_compatible, perplexity, bedrock. Also hardens a pre-existing gap surfaced while reviewing this change: a chat'scurrent_agent_idis now validated to belong to the chat's own company both when set (CreateChat/UpdateChat reject a cross-company agent) and at the point of use (every chat send path refuses to run the LLM call if the resolved agent's company doesn't match the chat's) — otherwise the company provider and the user's BYOK key would resolve in the wrong tenant. - Per-company chat integrations — run one Telegram/Slack bot per company (#73).
Telegram/Slack were locked to a single company: the bot token, company id, and
default agent came from one set of env vars (
STAPLE_TELEGRAM_*,STAPLE_SLACK_*), so a hosted instance could connect exactly one company's bot and a company admin couldn't configure their own without instance-level env access. This adds acompany_integrationstable (migration 145, RLS-armed;bot_token/app_tokenencrypted at rest withcrypto.Encrypt, mirroringcompany_providers) plus a channel supervisor that starts one bot per enabled row at boot, registering each under a per-company/readyzname (telegram:<companyID>/slack:<companyID>) so the fleet stays observable. The engine notifier now resolves the sending bot per company (notify.NewMultiCompany), so each company's outbound DMs go through that company's own bot — a regression test proves company A's notification never leaks to company B's bot. A company admin self-services from a new Chat Integrations page (/companies/{companyId}/integrations, admin-gated, tenant-scoped on the path like providers). Backward-compatible: the single-company env vars are seeded once into acompany_integrationsrow at boot (whenSTAPLE_ENCRYPTION_KEYis set), and instances without an encryption key keep the exact legacy single-company env path (channel tokens are credentials, so the multi-company table requires encryption). A bot that fails to start (bad token, Slack connect failure) is logged and skipped — it never blocks startup or other companies. Lifecycle is restart-only for v1: integration edits take effect on the next server restart. - Network egress routing for remote-worker agent execution (#77 sibling, #79).
The server's per-company egress allowlist + DNS resolver previously stopped at
the server — remote workers ran agents with the host's full network stack and
no policy. Now the remote task payload carries the company's egress allowlist
snapshot plus the operator's egress proxy URL (
STAPLE_WORKER_EGRESS_PROXY) and DNS resolver address, and the worker applies them: theclaude_local/llmpaths run the agent withHTTPS_PROXY/HTTP_PROXY/NO_PROXYset (outbound traffic routes through the operator's egress proxy, which enforces the allowlist + logs); thedockerpath additionally gets-eproxy vars and--dns <ip>for a:53resolver.RemoteTaskResultgains an optionaloutbound_connectionsaudit field. All fields areomitempty, so unconfigured instances and pre-#79 workers are unaffected. Explicitly documented as best-effort routing for cooperative agents, not a hard sandbox — a--dangerously-skip-permissionsagent can unset the proxy env; hard containment requires running workers in a network-restricted environment (firewall / netns / locked-down docker network), which is a deployment concern left out of scope. - Worker fleet health monitoring, queue stats, and graceful drain (#77). The
remote-worker fleet was opaque: heartbeats carried no metrics, there was no
backlog visibility, no way to retire a worker gracefully, and the stale-offline
sweep ran nowhere. This adds (migration 144, nullable columns on
remote_workers): (1) self-reported metrics — the worker heartbeat now accepts an optional{cpu_pct, mem_pct, in_flight}body (older binaries that send nothing are unaffected), surfaced on the/admin/workersfleet view; (2) graceful drain — an admin can drain a worker so it finishes its current task but claims no new ones (enforced atomically inClaimRemoteTask: disabled or draining → claims nothing), with Drain/Resume buttons on the fleet page; (3) fleet queue stats —GET /api/admin/workers/queue-stats(instance admin) reports pending/claimed counts, oldest + average pending age, and pending-by-tag, for scaling decisions; and (4) a stale-sweep worker that flips silent workers (no heartbeat for 3m) toofflineon a 60s cadence, so the fleet view reflects reality. Policy pieces the issue also lists — version pinning enforcement, an autoscaler webhook, and geo/affinity routing — are deliberately left for follow-ups (they need an external owner / routing-policy design); the worker-side half of drain (a heartbeat-response drain flag) is a separate worker-binary change. - Generic OIDC single sign-on (#82). Adds OpenID Connect SSO alongside the
existing Google/GitHub OAuth and local email+password, so organizations can
sign in through any standards-compliant IdP (Okta, Azure AD, Google Workspace,
Keycloak, etc.). Configure
STAPLE_OIDC_ISSUER+STAPLE_OIDC_CLIENT_ID+STAPLE_OIDC_CLIENT_SECRET(+ optionalSTAPLE_OIDC_DISPLAY_NAME); the server performs issuer discovery at startup and, when configured, shows a "Sign in with …" button on the login page. The callback verifies the IdP's ID token against its JWKS (viacoreos/go-oidc, not hand-rolled JWT validation), reads thesub/email/nameclaims, and reuses the existing OAuth user-matching + session machinery (provider="oidc") — no schema change. Discovery failure is non-fatal (logged; SSO disabled, other auth unaffected). Auto-provisions an unknown verified email as a default-role user, matching the existing OAuth behavior. SAML and SCIM remain out of scope. - User-facing agent run observability on the issue page (#83). Hosted users
(who lack operator access) now get self-service feedback on the issue detail
page: (1) a user-safe failure explanation when a run fails — raw run errors
are translated through an allowlist into messages like "The run stopped because
a budget limit was reached" or "No worker was available", never passing the raw
Go error through (no leaked file paths / stack traces / internal hostnames);
(2) a "Queued for N minutes" indicator while a pending run waits for a
worker; and (3) the issue's attributed LLM cost (a badge in the header,
shown only when non-zero), summed from
cost_eventsjoined to the issue's heartbeat runs. No schema change — the run detail page and live status were already viewer-accessible. Failure notifications (email/webhook) remain a separate follow-up pending a channel/opt-in decision. - Per-company opt-out for instance-level skills (#87). Instance skills are
inherited by every company's agent runs (agent > company > instance
precedence) with no way to hide an irrelevant one. Companies can now hide
a specific inherited instance skill from their own agent runs without deleting
it globally — a per-company, slug-keyed opt-out (new
instance_skill_visibilitytable, migration 143, born with RLS). The filter is applied at the one place every execution path converges —fsskills.BuildMergedDir— so it covers subprocess adapters (claude_local etc.) and remote workers (which walk the same merged dir, #70), plus the codex sync fallback. The company Skills page shows a Hide/Show toggle per inherited skill (member+) and marks hidden ones; the agent Skills page reflects only the visible set. Default is unchanged (everything visible), so it's fully backward-compatible. (The "seed protection" the issue also asks for — preserving operator-added instance skills across re-seed — already ships via theinstance_skills.builtincolumn.)
Fixed¶
- Company admins can self-service their LLM providers; cross-company provider
IDOR closed (#89). The per-provider UI write/test/models actions (update,
enable/disable, delete, test-connection, list-models) were mounted at
/providers/{providerId}/ui/...with no{companyId}in the path. That had two consequences: (1)RequireRole(admin)couldn't resolve a company role without a company in the path, so company admins got 403 and couldn't manage their own providers (only instance admins could) — the exact self-service gap #89 describes; and (2) the per-handler tenant check (actor.CompanyID) was a no-op on cookie sessions (whereactor.CompanyIDis empty), so the handlers never verified the provider belonged to the caller's company. These routes are now mounted under/companies/{companyId}/providers/{providerId}/ui/..., and every per-provider handler enforcesprovider.CompanyID == {companyId}(returning a uniform 404 that never confirms a cross-company provider's existence). The JSON API (/api/providers/{providerId}) was already correctly scoped for token actors and is unreachable by cookie sessions. Adversarially security-reviewed.
Changed¶
- Actionable "no chat provider" guidance for hosted instances (#72). When an
agent can't serve a synchronous chat reply (no resolvable LLM provider — common
on a freshly-hosted instance), the chat UI now shows role-aware guidance:
company admins (and instance admins) get an actionable "Configure a provider
→" deep link to that company's providers page; non-admins are told to ask
their admin. The chat send/stream API errors now return a clearer message plus
a
provider_config_urlinstead of a flat "agent has no LLM provider configured". No schema change — company-admin self-service provider config and theSTAPLE_DEFAULT_LLM_*instance baseline already existed; this closes the dead-end UX gap. (User-scoped BYOK keys remain tracked in #81.)
Added¶
- Company Knowledge Base — foundation (#92, slice 1 of epic #91). A new
company_knowledgetable (migration 139, born with RLS) plus a CRUD JSON API (/api/companies/{id}/knowledge) and a "Knowledge" UI page in the company sidebar for managing knowledge documents (title, markdown body, category, tags, and injection modealways/relevant/tagged). Reads are viewer+; create/update/archive are member+ and human-only (agents are read-only — they consume knowledge via prompt injection in #93). Cross-company access returns 404 (never leaks existence). DELETE soft-archives; updates bump a version counter. Portability export/import now includes knowledge documents (bundle version → 2.3; older bundles still import). The embedding/RAG retrieval that surfaces this into agent prompts is the next slice (#93). - Company Knowledge Base — embedding + RAG retrieval (#93, slice 2 of epic
#91). Knowledge documents are now chunked, embedded, and semantically
retrieved into agent heartbeat prompts. A new
knowledge_embeddingstable (migration 140, born with RLS) stores one pgvector row per chunk with an HNSW cosine index. AKnowledgeEmbeddingworker mirrors the note-embedding worker (60s tick, gated by the sameSTAPLE_EMBEDDING_ENABLEDflag, shell-mode by default): it chunks each active doc (markdown-header-aware, ~2k-char chunks with overlap, dependency-free), embeds every chunk via the company's provider, atomically replaces the doc's chunk set, re-embeds on edit (staleness viaupdated_atvs newest chunk), and prunes embeddings of archived docs. At heartbeat time both execution paths (in-processllmand subprocessclaude_local) retrievealways-inject chunks plus the top-K chunks most similar to the issue (title+body) and render them under a new "## Company Knowledge" prompt section, deduped and budget-capped. Best-effort throughout — flag off / no provider / no match degrades to no augmentation, never an error. Remote-worker payload delivery is deferred to #97. - Company Knowledge Base — injection modes (#94, slice 3 of epic #91).
Knowledge retrieval now honors each document's
inject_modevia a budget-priority chain, per agent: always docs (every activeinject_mode='always'doc, unconditionally, up to a reserved budget, freshest first) → tagged docs (only when the document'sinject_tagsoverlap the agent's tags — currently its role, matched case-insensitively) → relevant docs (semantic top-K). The chain fills up to a configurable budget (query.KnowledgeBudget:MaxChunks/MaxAlwaysChunks/MinRelevance, defaults 8/3/0.2).taggeddocs no longer leak to agents whose role doesn't match, andalwaysdocs are no longer double-counted against the relevance pool. Retrieval is now agent-aware (RetrieveKnowledgeForAgent); a futureagents.tagscolumn plugs intoagentInjectTagswith no other changes. The Knowledge list page warns when always-inject docs exceed the reserved budget (with estimated chunk/token counts and a "convert to relevant" hint). The mode-setting UI (select + tags input + badges) already shipped with #92. (Note: #94's suggestedMinRelevancedefault of 0.7 is too strict for text-embedding-3-small in practice — we default to 0.2 and leave it tunable.) - Company Knowledge Base — knowledge reaches remote workers (#97, slice 4 of
epic #91). Remote-executed agents now receive the same RAG-retrieved
knowledge as in-process ones. The server retrieves chunks (via the #94
inject-mode budget chain) when building the remote task payload and serializes
them into a new
RemoteTaskPayload.Knowledgefield (omitempty, so pre-#97 workers and empty knowledge bases see nothing — no migration, JSON payload). The worker renders them identically to local: theclaude_localpath feedspayload.KnowledgeintoExecutionParams.KnowledgeContextso the sharedRenderPromptemits the same## Company Knowledgesection, and thellmpath renders the matching block inbuildPromptText. Mirrors the #70 skills delivery pattern. (The docker worker path runs a container without a server-assembled prompt, so it's unaffected.) - Company Knowledge Base — versioning + edit history (#96, slice 5 of epic
#91). Every edit to a knowledge document now snapshots the pre-edit state
into a new
knowledge_versionstable (migration 141, born with RLS), tagged with who made the edit and how (edit_source: manual / git_sync / agent_authored / rollback) — written atomically with the update so no version is lost on concurrent edits. New JSON API:GET …/knowledge/{id}/versions,GET …/versions/{n}(viewer+), andPOST …/rollback/{n}(member+, human-only). Rollback is non-destructive — it re-applies the chosen version's content as a fresh edit (snapshotting the current state first, taggedrollback). New UI: a "History" page (linked from the document) showing the version timeline (version, who/when/how superseded) with a dependency-free line diff of each version against the current content and a member-only "Restore" button. Version history is keyed per company (RLS + explicit company filter) and is not included in portability bundles (instance-local audit data). - Company Knowledge Base — git sync source (#95, slice 6 of epic #91,
completing the epic). Companies can now auto-import knowledge documents from
a git repository. A new
knowledge_git_sourcestable (migration 142, born with RLS) holds the per-company config (repo URL, branch, optional HTTPS token encrypted at rest, enabled flag, last-sync status). The importer (internal/knowledgegit) shallow-clones the repo and maps each*.mdfile — with optional YAML frontmatter (title/category/inject_mode/inject_tags) and a heading/filename title fallback — to acompany_knowledgedoc, keyed by repo path (source_ref). Git is the source of truth: changed files update their doc (taggededit_source='git_sync', captured in #96 version history), and files removed from the repo archive their doc; unchanged files don't bump versions. A new opt-in worker (STAPLE_KNOWLEDGE_GIT_SYNC_ENABLED, 30m cadence) syncs all enabled sources, and a "Sync now" button + config form on the Knowledge page drive it manually. The auth token is injected into the clone URL for HTTPS, redacted from errors, and never serialized; the repo URL is validated against dangerous git transports (reusing #71's guard).
Fixed¶
- Test no longer mutates committed skill fixtures.
testEnginebuilt itsConfigwith an emptyStapleHome, soexecuteWithAdapter'sfilepath.Join("", "skills")collapsed to the relative path"skills"— which, becausego testruns with CWD = the package dir, pointed atinternal/engine/skills/. The heartbeat dispatch tests materialized DB-seeded skills there, dirtying the working tree on everygo test ./....testEnginenow usesStapleHome: t.TempDir().
Removed¶
internal/engine/skills/deleted (again). It was a stale manual mirror of the embedded skill sourceinternal/skills/data/(the only//go:embed'd copy) — already removed in a prior release, but silently re-created and re-committed by thetestEnginebug above. No code reads or embeds it; docs cross-links already point atinternal/skills/data/. With the test fixed it will not reappear.
v3.19.0 — 2026-06-23¶
Hosted-deployment readiness: the first two waves of the "local-first → centrally-hosted" roadmap (epic #90) — production-posture enforcement, secure transport, corporate-proxy support, and full local↔remote execution parity.
Added¶
STAPLE_MODE=hosteddeployment posture (#88). A single switch that enforces production-grade invariants at startup with an aggregated, actionable error: encryption key required,STAPLE_PUBLIC_URLmust be HTTPS, secure cookies forced on. Defaultlocalpreserves all existing behavior.- Built-in TLS (#76).
STAPLE_TLS_CERT+STAPLE_TLS_KEYswitch the server to HTTPS; new operator guideoperators/tls-and-deployment.md(reverse-proxy and built-in TLS topologies). Warns onSecurecookies over plain HTTP. - Outbound HTTP proxy support (#98). New
internal/httpclientroutes every outbound client through a proxy-aware constructor honoringHTTP_PROXY/HTTPS_PROXY/NO_PROXYplusSTAPLE_*overrides andSTAPLE_PROXY_AUTH_*. SSRF-guarded clients now trust the operator-configured proxy host while still blocking direct dials to private IPs. - Skills reach remote workers (#70). The merged instance→company→agent skill
tree is serialized into the remote task payload and rehydrated on the worker
(
--add-dir), with path-traversal protection and a payload size cap. - Project
repo_url(#71). Projects gain a git remote (the portable counterpart ofworking_dir) that remote workers clone/pull on demand for project source. Exposed in the project UI and the portability bundle. STAPLE_PUBLIC_URLvalidation + opt-in self-probe (#84). URL-shape validation at startup;STAPLE_SELFTEST_PUBLIC_URL=truebackground-probes/healthzfor reachability.
Security¶
- Per-company dangerous-permissions policy (#74).
companies.allow_dangerous_permissionsreplaces the all-or-nothing global kill-switch; the server resolves the policy (agent → company → instance env) and the worker enforces the decided policy from the payload instead of re-deriving it. The encryption env var remains a hard ceiling. - Encryption enforcement in hosted mode (#75). A missing
STAPLE_ENCRYPTION_KEYis now a fatal startup error underSTAPLE_MODE=hosted(was a warning). - Dependency bump for GO-2026-5026.
golang.org/x/net→ v0.56.0, fixing a vulnerability inidna.ToASCIInewly reachable via the proxy support'shttpproxy.Config.ProxyFunc(#98).govulncheckis clean.
Changed¶
- Portable backups strip machine-specific local paths (#78, #80). Export and
remote-payload build now remove absolute
cwdvalues and operator-home--add-dir/path-valuedextra_argsfromadapter_config, so bundles restore cleanly on another host and stale paths never reach a worker. Bundle version → 2.2 (older bundles still import). - Remote
adapter_config.cwdis validated (#85). A set-but-missing working directory now triggers a deliberate, logged fallback instead of an opaqueexecchdir failure.
Migrations¶
137_company_allow_dangerous_permissions,138_project_repo_url.
v3.18.0 — 2026-06-22¶
Added¶
- Reload-free agent configuration. The agent Configuration tab now saves Properties, the home-directory override, and all four toggles (A2A exposure, auto-extract findings, GEPA auto-promote, GEPA provider override) in a single atomic "Save changes" with an inline confirmation instead of a full-page reload. The Provider and per-adapter "Save Configuration" forms confirm in place too. The active tab is remembered across reloads (URL hash), and a dirty-guard warns before navigating away from unsaved edits.
- Live views update in place. The dashboard "Live activity" widget and the
issue agent-transcript now refresh themselves over SSE (a dedicated
?partial=live_agentsendpoint plus persistent slots) instead oflive-transcript.jsreloading the whole page on every heartbeat — so reading the dashboard or an issue is no longer interrupted. - Encryption-rotation guidance. The AI Providers and Secrets pages show a
styled, theme-aware tooltip on the "↻ Rotate to v2" badge explaining that
the value is encrypted under the older
enc:v1:envelope and how to upgrade it (staple-cli rewrap-all, or re-saving / rotating the entry).
Fixed¶
- Chat/Concierge failed on Anthropic. Newer Anthropic models reject
temperature("deprecated for this model"), but the engine forces a0.7default that both Anthropic request builders sent, so every chat turn 400'd. The Anthropic provider no longer sendstemperature(it uses Anthropic's own default); OpenAI and Bedrock are unchanged. - Triage role could not be set on an existing agent. The agent Configuration Role dropdown omitted "Triage" (the routing role that picks up company-wide unassigned issues and routes them to the best teammate), even though the create form offered it. Worse, saving a triage agent's config silently reverted its role to "general". The dropdown now offers Triage.
- AI Providers / Secrets mislabeled encrypted rows as "Unencrypted".
enc:v1:values are encrypted; because the server runs a v2 key vault they report as needing rotation, which the UI rendered as a red "⚠ Unencrypted" badge. They now render an accurate "↻ Rotate to v2" badge, and the secrets check no longer flagsenc:v2:rows (the newest format) at all. - Activity page rendered recursively. The activity feed's SSE refresh
fetched the full page instead of the feed fragment, nesting the entire app
shell inside
#activity-feedon every event. It now returns just the fragment for?partial=feed. - Agent Configuration tab fixes. Nested toggle
<form>s plus an SSE reload loop made the tab unusable when changing adapter settings; the unsaved-changes guard also fired spuriously after a successful save. Both are resolved.
v3.17.1 — 2026-06-10¶
Fixed¶
- Remote worker actions never applied. The remote adapter returned the
worker's summary text as raw output without calling
ParseAgentActions, so action verbs like/status donewere posted as comment text but never executed by the engine. This caused issues worked by remote workers to loop indefinitely — the issue stayed in its original status and was re-dispatched on every heartbeat. The fix applies the same action-parsing step that every other adapter already performs.
v3.17.0 — 2026-06-09¶
Added¶
- Goal deletion (#65). Goals can now be deleted — the last missing CRUD
operation. New
DELETE /api/goals/{goalId}endpoint (204on success,404when missing or out of company scope, same tenant-isolation posture as PATCH) and a Delete button on the goal page (/goals/{id}) with a themed confirm dialog that redirects back to the company goals list. Linked issues, projects, routines, and child goals survive the delete with their goal reference cleared (ON DELETE SET NULL); project–goal links are removed (ON DELETE CASCADE).
Fixed¶
query.ListIssuesnow returns an empty slice instead ofnilwhen a company has no matching issues, matchingListGoalsand the other list queries (the HTTP handler already guarded, but direct callers andTestListIssuesDB— which asserts non-nil — were exposed to thenil).
v3.16.0 — 2026-06-08¶
Added¶
- Per-company batch "Optimize all agents." Instance-admins can trigger GEPA
prompt optimization for every eligible agent in a company at once from a new
"Prompt optimization (batch)" section on
/admin/operations(pick a company, click "Optimize all agents"). The sweep runs sequentially via the existing per-agent optimizer, automatically skipping agents with no system prompt or too few settled traces, and produces pendingagent_prompt_proposalsfor review — approval-only, nothing goes live automatically. It honorsSTAPLE_EVOLUTION_ENABLEDand is bounded by a new per-sweep spend capSTAPLE_EVOLUTION_PER_SWEEP_MAX_USD(default100), in addition to the existing per-run cap. Results appear under Pending GEPA as they are produced; the batch tally is logged. A direct stepping-stone to the deferred Phase 2 scheduler, which can reuse the sameRunForCompanysweep.
v3.15.1 — 2026-06-08¶
Fixed¶
- Discoverability of the prompt-evolution page. The per-agent evolution page
(run history + "Run optimization now" trigger) shipped in v3.15.0 had no link
from anywhere in the UI — it was reachable only by typing
/agents/<id>/evolution. The agent detail page now shows a "Prompt evolution → Optimize prompt" link in the GEPA controls section (instance-admin only), next to the auto-promote and provider-override controls.
v3.15.0 — 2026-06-08¶
Added¶
- Continuous self-evolution — Phase 0+1 (system-prompt optimizer, approval-only).
A GEPA-style multi-objective (Pareto) optimizer that improves an agent's
system_promptfrom the gradedoutcome_qualityalready computed per trace — not just hard failures. Because the system prompt is read on every surface, improvements reach Chat/Slack/Telegram as well as the heartbeat path. Pieces: - New
evolution_runs+evolution_variantstables (migration 136) and aListSettledTracesForAgentquery for offline replay. - A native Go GEPA engine (
internal/evolution+internal/evolution/gepa): Pareto dominance/frontier, an embedding-based identity guard, a reflective-mutation prompt builder, and a replay + independent-LLM-judge + identity evaluator across three axes (outcome quality, cost efficiency, identity fidelity). providerrouter.ProvidergainsCompleteMessages(role-structured message arrays) so candidate prompts can be replayed against real traces.- An
EvolutionOptimizerworker that runs the engine, persists runs/variants, records cost (billing_type='evolution'), and creates a pendingagent_prompt_proposalsrow — never auto-promotes (Phase 1 is approval-only). - An instance-admin review UI (run list + run detail with per-variant Pareto scores) and a manual trigger; promotion reuses the existing proposal-accept flow.
- Gated behind
STAPLE_EVOLUTION_ENABLED(default off). Autonomy (scheduler/reactive/auto-promote/auto-rollback) and skills evolution are deferred to later phases. Seedocs/superpowers/specs/2026-06-07-continuous-self-evolution-design.md.
v3.14.2 — 2026-06-07¶
Changed¶
- Org chart now fills the viewport at a legible zoom. The aspect-ratio canvas
from v3.14.1 (#64) still fit the entire tree via
preserveAspectRatio=meet, so a wide-short org (e.g. 32 leaves ≈ 6700×430, ~15:1) collapsed into a thin, unreadable strip — and the page was capped at the 1280px content width, so the chart never grew with the monitor. The org-chart page now spans the full content width (a scoped.content-body--wideopt-out of the cap), and the client opens at a readable scale (root-centered, all levels visible) with drag-to-pan / scroll-to-zoom for the rest. The canvas is a generousclamp(480px, 78vh, 900px)box that re-fits on viewport resize until you interact; Reset returns to that default view. On a representative 42-agent tree this raised the default render from ~0.18× to ~1.4× (boxes ~33px → ~252px).
v3.14.1 — 2026-06-05¶
Fixed¶
- Flaky
TestReleaseIssueHandler. It relied on seededcompanies[0]/agents[0]that a concurrently-running package test could mutate (the suite shares one test DB); it now creates its own isolated tenant fixtures, so it can't be destabilized (#60). - Brittle not-found detection. Replaced
err.Error() == "…no rows in result set"string matches witherrors.Is(err, pgx.ErrNoRows)across the query and handler layers (9 sites) — robust to pgx message changes and%wwrapping (#61). - Data race in
internal/version. The build-metadata accessors (Set/Version/Commit/Date/AssetVersion) shared package vars with no synchronization; they're now guarded by anRWMutex(#62). - Placeholder worker build metadata in the UI. A worker built without ldflags
reports
commit=none/build_date=unknown; those sentinels are now omitted from the/admin/workersVersion tooltip instead of rendering "commit none · built unknown" (#63).
Changed¶
- Org chart canvas sizing. The chart now sizes its canvas to the chart's own
aspect ratio (bounded by
min-heightandmax-height: 75vh) instead of a fixed75vh, so a wide-short tree fills the space and the chart scales with the viewport instead of leaving a large empty band (#64).
v3.14.0 — 2026-06-05¶
Added¶
- Remote worker Version & Contract visibility. Remote workers now
self-report their build metadata (
version,commit,build_date) and the operating-contract revision they were compiled against (prompt_version) in thePOST /api/workers/registerbody — all optional and backward-compatible. The server records them on the worker row (new nullable columns, migration135) on both the new-registration and idempotent-resume paths, and the Remote Workers admin table (/admin/workers) now shows Version and Contract columns with abehindbadge when a worker is older than the server's own build/contract, degrading to—when a worker doesn't report a value. The contract-version constant moved to a leaf package (internal/engine/remoteprompt) so the DB-freestaple-workerbinary no longer transitively imports the heavyinternal/enginepackage.
v3.13.0 — 2026-06-05¶
Added¶
- One-command installer (
curl | sh).scripts/get-staple.shbootstraps a full install in one line: it detects your OS/arch, downloads the matching release archive, verifies its SHA-256 against the publishedchecksums.txt, extracts it, and runs the bundledinstall.sh— with all installer flags passing through (sh -s -- --system …) and an optionalSTAPLE_VERSIONpin. It installs anonymously once a release is public and falls back toGITHUB_TOKEN/ theghCLI while the repo is private. The README and install guide now lead with it.
Fixed¶
- Release-mode native install no longer ships a non-pgvector Postgres.
scripts/release/docker-compose.ymlpinned stockpostgres:16, but migration001runsCREATE EXTENSION vector— so a fresh release install via that compose would have failed on the very first migration (the dev compose and CI had already moved topgvector/pgvector:pg16; only the release path drifted). The image is now a single env-defaulted definition, so it cannot diverge again.
Changed¶
- Releases are gated on the full test suite, single-sourced with CI. The
test job moved into a reusable workflow (
.github/workflows/test.yml,workflow_call); bothci.yml(push/PR) andrelease.yamlinvoke it, and the release jobneeds:it — so a tag can't publish a release unless the exact same suite that CI runs passes, and the two gate definitions can't drift apart. - One canonical
docker-compose.ymlfor both dev and release. The separate dev and release Postgres compose files are collapsed into a single env-parameterized file (${STAPLE_PG_*}with dev-friendly defaults).scripts/release/install.shno longer{{}}-renders a private copy — it ships the canonical file as-is and supplies production values through a generatedcompose.env(docker compose --env-file). The Postgres password moves to a Composesecrets:file mount for both modes (dev gets a gitignored./.pgpasswordviamake devel-up), keeping the secret out ofdocker inspect. Newmake smoke-composeboots the compose in both modes and assertsCREATE EXTENSION vectorsucceeds, so the image can never silently regress to a non-pgvector base.
Documentation¶
- README front-door now leads with the operator install. The
getting-started section previously showed a build-from-source/dev setup;
it now leads with the recommended one-command
./install.sh(matching Staple's self-hosted-control-plane positioning), plus a short "Run / Develop / Configure" routing table. Developer setup is a single link toCONTRIBUTING.md(no duplicated steps), and the exact install command stays single-sourced indocs/user/operators/installing.md.
Security¶
- Release artifacts are now signed (cosign keyless). Every release signs
checksums.txtwith cosign in keyless mode (GitHub OIDC — no stored keys), publishingchecksums.txt.sig+checksums.txt.pem. Because each artifact's SHA-256 is listed in that file, the signature transitively authenticates every archive — closing the gap where the one-command installer verified an unauthenticated checksums file.get-staple.shverifies the signature whencosignis installed (soft-skips otherwise); verify by hand withcosign verify-blobagainst the release-workflow OIDC identity. - Encrypted backups now authenticate stream termination (defeats silent
truncation). The chunked AES-GCM backup format bound each chunk's index
into its nonce (so reorder/drop/dup already failed to decrypt), but the
stream's end was an unauthenticated zero-length marker — an attacker with
write access to the backup object could truncate after any chunk and re-cap
it with a forged marker, yielding a silent partial restore. The writer now
emits an authenticated trailer (sealed under the one-shot file_key,
committing the data-chunk count) marked by a new
fmt=2header field, and the reader requires it: a clean cut OR a forged zero-length marker now surfaces asErrBackupTruncated. Backward-compatible — pre-fmt=2backups still restore via the legacy terminator (data chunks are byte-identical), and the magic line staysSTAPLE_BACKUP_v1so every encrypted-vs-plaintext sniff site is unchanged. New tests cover forged-EOF rejection, deeper mid-stream truncation, legacy read, and legacy-truncation detection. - Plugin capability/approval enforcement is now a hard startup invariant.
The per-plugin host-capability gate and the StartPlugin approval gate
fail open when their DB pool is nil (a deliberate test affordance). Production
always wires them (
cmd/server/main.go), but a future refactor that dropped the wiring would silently disable enforcement with only a log line. The server now asserts bothCapabilityEnforcementArmed()andApprovalEnforcementArmed()after wiring and refuses to start (os.Exit(1)) if either is unset — the fail-open path is unreachable in a real server. home_diroverrides are confined toSTAPLE_HOMEby default.ValidateHomeDirpreviously accepted any absolute path whenSTAPLE_HOMES_ROOTwas unset, so an admin (or anything with admin-level config access) could point a company/agent home at/etc,~/.ssh,/proc, etc. WhenSTAPLE_HOMES_ROOTis unset it now confines overrides toSTAPLE_HOME(the same containment modelinternal/home/trash.goalready uses); operators who need out-of-tree homes setSTAPLE_HOMES_ROOTexplicitly. Validation runs only on write, so existing stored homes and running engines are unaffected.- Opt-in: enforce the Slack user allowlist on channel @-mentions. By design,
the
STAPLE_SLACK_USER_ALLOWLISTgate applied to DMs only — channel mentions are gated by Slack-native channel membership (an operator invited the bot). Operators who want a single allowlist policy across both surfaces can now setSTAPLE_SLACK_ENFORCE_ALLOWLIST_ON_MENTIONS=true; non-allowlisted mentions are then dropped silently (no in-channel rejection notice). Default off preserves the prior behavior exactly. - Pairing auto-revoke now writes its audit trail atomically with the
revoke.
AutoRevokeUserPairingsandAutoRevokeInactivePairingsdeleted theuser_channel_identitiesrows in one transaction, then wrote thepairing_audit_eventsrows in separate per-row transactions afterward — so a crash or DB-connection drop between the two left identities silently revoked with no audit record, defeating the audit log whose row "IS the security guarantee." The per-row audit inserts now run inside the sameWithBypasstransaction as theDELETE(via a newInsertPairingAuditEventTxthat writes on a caller-ownedpgx.Tx); any audit-insert failure rolls theDELETEback, so an identity is never revoked without its audit row.InsertPairingAuditEventkeeps its pool-based signature for all existing callers; both paths share one validation + INSERT helper so the SQL is never duplicated. NewTestAutoRevokeAtomicityasserts identity-gone-iff-audit-row-present for both revoke paths and that a validation failure leaves the identity intact with no audit row written. - Armed row-level security on the remaining tenant-scoped tables. Ten
company-scoped tables created before the RLS rollout never got a policy:
cost_events,evolution_traces(full LLM prompts),company_notes+note_versions,secret_audit_events,budget_policies/budget_incidents/company_cost_budgets,principal_permission_grants, andcompany_federations. Migration 133 enables RLS + atenant_isolationpolicy (company_id = current_setting('app.current_company_id');note_versionsjoins throughcompany_notes)FOR ALL TO staple_app, matching the rest of the schema. This is the database-layer backstop behind the application-layer company checks — additive (the plain-pool /WithBypassoperator reads bypass RLS as owner, so nothing breaks) and it scopes any present/futureWithTenantScopeaccess to these tables. The full suite passes with RLS armed. - A2A inbound
message/sendis now replay-protected. The inbound JSON-RPC surface authenticates with an API key only, so a capturedmessage/sendreplayed within a short window (by a network observer or a malicious peer) would spawn a duplicate issue. The handler now dedups by(agent, params hash)against a newa2a_inbound_deduptable (migration 134) and rejects a repeat within 5 minutes (matching the routine-trigger webhook window). The table is opportunistically pruned. Regression test asserts an identical replayed send is rejected. - A2A task streams stop when the task is reassigned. The A2A streaming
handler verified the caller agent owned the task once at stream open, then
re-read the issue inside the live-update loop WITHOUT re-checking ownership —
so a peer kept receiving a task's state updates after it was reassigned to a
different agent (cross-agent info disclosure on the open stream). The loop now
re-verifies
AssigneeAgentID == agent.IDon every re-read and closes the stream on mismatch. Regression test opens a stream, reassigns the task, and asserts the stream terminates. - Closed SSRF on operator/agent-supplied URLs the server fetches. A2A peer
URLs (
CreateA2APeer/UpdateA2APeer), MCP server URLs (UIMCPServerCreate), and A2A push-notification callback URLs (validatePushNotificationConfig) were stored verbatim with no private-range block and later fetched by the server — so an operator (or, for push callbacks, a peer) could point them athttp://169.254.169.254/(cloud IMDS) or an internal address and have the server issue authenticated requests there. Newinternal/ssrfpackage:ValidateURL(scheme + resolved-IP private/metadata block) is enforced at all four registration sites (400 on block), and every outbound A2A client (sync fetcher, JSON-RPC client, push notifier) now uses aGuardedHTTPClientwhosenet.Dialer.Controlrejects private/reserved IPs at dial time and whoseCheckRedirectre-validates each hop — closing the redirect + DNS-rebinding TOCTOU that a URL-string check alone can't. Tests cover the validator, the dial guard, and assert the guard is wired into the production push client. - Container env vars (incl. the ephemeral
STAPLE_API_KEYand provider keys) no longer appear inps aux. The docker and sandbox adapters passed every container env var as inlinedocker run -e KEY=VALUEarguments — visible on the command line to any local user/monitoring agent on the engine host (STAPLE_API_KEY,ANTHROPIC_API_KEY/OPENAI_API_KEYfrom adapter config, and the fullSTAPLE_ISSUE_JSON). They're now written to a per-run mode-0600 temp file passed via--env-file(values containing newlines, unsupported by--env-file, fall back to inline-e); the file is removed after the container is created. Regression tests assert no secret appears in the docker argv and the env-file is 0600. staple-cli backup restore --validatewrites the decrypted dump with 0600 regardless of umask.decryptToTempFilerelied onos.CreateTemp's default mode; under a permissive umask (e.g. 0 in some container/CI environments) the temp file — which holds the fully-decrypted pg_dump, i.e. every tenant's plaintext rows — would be world-readable to any local user for the duration of the restore. NowChmod(0600)is enforced immediately after creation.- Telegram bot token no longer leaks into logs on network errors. The
getUpdates/sendMessage request URLs embed the bot token in the
/bot<TOKEN>/path, and Go's HTTP client puts the full URL into the*url.Errorit returns on any transport failure (DNS hiccup, API downtime, cancellation). That error was wrapped verbatim and logged, exposing the credential to anything that collects application logs. Errors from the bot's HTTP calls are now passed throughredactTokenbefore wrapping. Regression tests assert the token is absent from the returned error. - Closed a CRITICAL cross-tenant company-admin IDOR.
PATCHandDELETE /api/companies/{companyId}were gated byRequireRole("admin")only — andRequireRolepasses non-user (agent/board) actors straight through — so any agent API key could rename, re-home, or permanently delete any company on the instance by supplying its UUID. AddedRequireCompanyAccessahead ofRequireRoleon that route group, which rejects agents whose key is for a different company and resolves the user's role for the target company (soadminnow means "admin of this company"). - Closed cross-tenant write IDORs on join-request, project, skill,
work-product, comment, and budget-incident by-id routes. Same root cause —
RequireRolepasses agent/board keys through and these routes carry nocompanyId— so an agent key could approve another company's join request (granting cross-tenant membership), edit/delete another company's projects/skills (incl. overwriting the on-disk skill body)/work-products, read or post comments on another company's issues, and resolve another company's budget incidents. AddedactorMayAccessCompanygates (load the resource → 404 if the actor is outside its company; for join requests the gate runs before the resolve side-effect so no cross-tenant membership is created). Regression tests per resource. - Closed cross-tenant IDORs on the a2a-peer, plugin-tool, plugin-webhook, and
secret by-id routes. None of these routes carry a
companyId(or, for the plugin ones, the handler never compared it), andRequireRolepasses agent keys through, so any authenticated caller could: read/update/delete/refresh another company's A2A peer — including rotating its stored API key (/api/a2a/peers/{peerId}*); invoke another company's plugin tool by qualified name (/plugins/tools/invoke, global registry); deliver to another company's plugin webhook by path (/plugins/webhooks/{path}, global lookup); and read-metadata/update/rotate/delete another company's secret — including rotating it to attacker-controlled ciphertext (/api/secrets/{id}*). AddedactorMayAccessCompany-based gates (returning 404, existence hidden) to every one of these handlers. Regression tests cover the foreign-company and owner paths. - Closed a cross-tenant note IDOR.
/api/notes/{id}and the/notes/{id}UI routes are not wrapped byRequireCompanyAccess, and agent-authored notes are company-wide, so any authenticated caller could read, edit, archive, promote, or delete another company's agent notes by guessing the UUID. Added anactorMayAccessCompanygate to every note-by-id handler (instance-admins → any company; agent/board API keys → their key's company; session users → verified againstcompany_memberships). Regression test added. - Closed a cross-tenant routine-trigger IDOR. The
/api/routine-triggers/{triggerId}routes (PATCHupdate,DELETE, andPOST .../rotate-secret) carry nocompanyIdand were not company-scoped, so any authenticated caller could edit, delete, or rotate the webhook signing secret of another company's trigger by guessing the UUID — silently breaking a victim's scheduled/webhook automations or hijacking their webhook. Added anauthorizeTriggerAccessgate (trigger → routine →actorMayAccessCompany) that returns 404 (existence hidden) before any mutation. Regression test covers all three handlers for both the foreign-company and owner paths. - Closed a cross-tenant plugin IDOR across the entire
/api/plugins/{pluginId}surface. These routes carry nocompanyId, andRequireRolelets non-user (agent/board) API keys pass straight through — so any agent key could read, start, stop, restart, update, delete, or invoke tools on another company's plugin by ID (starting/invoking runs that plugin's binary), and the unguarded GET routes leaked another company's plugin metadata to any authenticated caller. Added anauthorizePluginAccessgate (plugin →actorMayAccessCompany) to all twelve by-id handlers — GetPlugin, UpdatePlugin, DeletePlugin, StartPlugin, StopPlugin, RestartPlugin, PluginHealth, ListPluginTools/Events/ Jobs/Webhooks, and InvokePluginTool — returning 404 (existence hidden) before any action. The approval handlers (allow/ban/rescind) were already guarded bypluginApprovalGate. Regression test covers all twelve handlers for foreign agent keys, a foreign session user, and the legitimate owner. - Fixed a per-run data race that could leak one agent's ephemeral credentials
and home paths into another company's concurrent run. The heartbeat engine
stashed per-run state — the resolved Staple/company/agent home dirs, the
merged skills dir, and the freshly-minted ephemeral
STAPLE_API_KEY— in fields on the shared*Engine. A single Engine is reused across the worker pool, so two agents executing concurrently raced on those fields: one run could hand its adapter another run's home directory or API key, i.e. run an agent with a different company's callback credentials (cross-tenant privilege escalation) and filesystem paths. The per-run state now lives in a per-callrunScopecarried throughcontext.Context(runscope.go); the shared fields are gone, so the race is eliminated by construction. Regression test asserts per-goroutine scope isolation under-race. - Backups are now integrity-verified BEFORE being uploaded to S3.
backup.Runuploaded the encrypted dump to remote storage first and verified it second, so a corrupt artifact (truncated/partial write, GCM-tag mismatch) was pushed to S3 and left there — a poisoned "latest backup" that would be silently relied on during disaster recovery, exactly when it's least recoverable. Reordered so the decrypt-round-trip verify runs first; on failure Run deletes the local file and returns without ever uploading. Regression test forces a verify failure and asserts zero S3 PUTs. - Closed an SSRF bypass in the plugin
http_fetchhost service. The SSRF guard (validateURL) checked only the initial URL's resolved IP, but the host'shttp.Clientfollowed redirects with no re-validation and no dial-time check — so a plugin could fetch an allowed public URL that 30x-redirects tohttp://169.254.169.254/(cloud IMDS) or an RFC1918 address and read back IAM credentials / internal services. DNS rebinding (TOCTOU betweenvalidateURL's lookup and the real dial) was likewise unmitigated. Added anet.DialerControlhook that rejects private/reserved IPs at dial time — on the actually resolved IP, on every redirect hop — plus aCheckRedirectthat re-validates each hop, and extended the blocklist with0.0.0.0/8. Regression tests cover the dial guard (metadata, RFC1918, loopback, link-local, IPv4-mapped) and that the guard is wired into the host client. - Login brute-force lockout now fails closed when its own check errors. The
gate was
if locked, _, err := IsLockedOut(...); err == nil && locked, so if the lockout-status read errored (e.g. thelogin_attemptstable was briefly unreachable) the lockout was silently skipped and the request fell through to the password check — letting an attacker who could induce errors on that table bypass brute-force protection. The handler now denies (withRetry-After) on a lockout-check error rather than proceeding. Regression test forces the check to error and asserts the request is refused.
Fixed¶
- MCP handshake compliance. The MCP HTTP handler answered
notifications/initializedwith an empty200body (a JSON-RPC notification expects no response body); it now returns204 No Content. The MCP sync worker now sends the spec-requirednotifications/initializednotification afterinitializeand beforetools/list, so strict external MCP servers accept the handshake. - Telegram conversations no longer break (and spawn orphan chats) for
companies with >200 chats.
findChatByTelegramIDfetched only the 200 most-recently-active chats and linearly scanned them for a matchingmetadata.telegram.chat_id; a company with more than 200 chats whose target conversation wasn't in that window got a miss → a brand-new chat row was created on every message, permanently losing continuity. Replaced with a direct JSONB-keyed lookup (FindChatByTelegramID,LIMIT 1, company-scoped) that mirrors the existing Slack DM/thread lookups. Regression test seeds 201 chats with the target as the oldest and asserts it's still found. - Heartbeat timeout now actually escalates to SIGKILL (and stops leaking a
goroutine). The grace-period logic in the local claude/codex adapters used
select { case <-graceDone: kill; default: }— thedefaultarm fired immediately (the channel was freshly created), soKillwas never sent and the grace goroutine leaked forGraceSecseconds on every timed-out run. Theselectnow blocks with atime.After(GraceSec)timeout that callsKill, and the grace goroutine exits promptly. - Operator cost dashboard (
AggregateCosts) no longer inflates spend 2–3×. Like the budget fix above,AggregateCostssummedchat_messages.cost_usd+heartbeat_runs.cost_usd+evolution_traces.cost_usd— the same per-call dollars mirrored across multiple tables — so the/admin/costsbreakdown ran 2–3× actual. It now aggregatescost_events(the canonical ledger) only, grouped by provider / model / billing_code. Regression test seeds a call in bothcost_eventsand a substrate table and asserts it counts once. - Approval resolution is now idempotent (no duplicate side effects on a
concurrent/double resolve).
ResolveApproval'sUPDATEhad nostatus='pending'guard, so two concurrentPATCH /api/approvals/{id}(or a double-click in the UI) could both seepending, both succeed, and runapplyApprovalResolutiontwice — installing/enabling a capability twice and waking the blocked agent twice (duplicate heartbeat runs). TheUPDATEnow matches... AND status = 'pending'(atomic compare-and-set); the loser getsErrApprovalNotPending, which both handlers map to 409 and skip the side effects. Regression test asserts the second resolve is rejected. - Slack/Telegram reply truncation no longer corrupts multibyte text. Both
truncateForSlackandtruncateForTelegramcut the over-long reply at a raw byte offset (text[:n]), which could land in the middle of a multibyte UTF-8 rune (emoji = 4 bytes, CJK = 3) and emit invalid UTF-8 — Telegram rejects it with a 400 (and the plain-mode retry can't fix invalid UTF-8 either); Slack silently mangles it. Truncation now backs the byte budget off to the nearest rune boundary (runeSafeCut), so the prefix is always valid UTF-8 while still fitting the wire-size cap. Regression tests truncate emoji past the cap and assert the result is valid UTF-8. - CLI
company delete <id> --yesnow honors--yesafter the positional argument. Go'sflag.Parsestops at the first non-flag arg, so a trailing--yeswas silently dropped and the (operator-confirmed) deletion refused — even though the documented usage isdelete <id|name> --yes. Args are now partitioned before parsing so the flag is honored in any position. Regression test added. - Monthly budget spend is no longer double/triple-counted.
MonthToDateSpendUSD(which drives the cost-budget alerter, the dashboard budget bar, and the budget-status API) summedcost_eventsPLUSchat_messages.cost_usdPLUSheartbeat_runs.cost_usdPLUSevolution_traces.cost_usd. But every LLM call writes its cost intocost_events(the canonical ledger) AND mirrors the same dollars into one or more of those substrate tables — so each call was counted 2–3×. A company's reported month-to-date spend ran 2–3× actual, tripping warning/critical budget alerts (and, where enforced, blocking agent work) at roughly a third of the real cap. Now sumscost_eventsalone. Regression test seeds a single call across all four tables and asserts it counts once. (The operator cost breakdown,AggregateCosts, has a similar overlap and is tracked separately as a reporting-accuracy fix.)
v3.12.0 — 2026-06-04¶
Added¶
- Task-queue retention worker. The global
tasksjob queue had no retention — the enqueue→claim→complete lifecycle never deleted rows, so the table grew unboundedly on long-lived instances. A new worker (task_retention.go) prunes terminal (completed/failed) tasks older than a configurable window; pending/running rows are never touched. Defaults ON at 90 days, mirroring the heartbeat-run retention worker. Tunables:STAPLE_TASK_RETENTION(disable),STAPLE_TASK_RETENTION_DAYS(default 90),STAPLE_TASK_RETENTION_INTERVAL_HOURS(default 24).
Fixed¶
- A2A peer sync no longer wrongly prunes freshly-synced remote agents.
SyncPeercaptured the prune cutoff with the app-sidetime.Now()while the upsert stampslast_seen_at = now()(DB clock). When the DB clock trails the app clock (e.g. a containerised Postgres on Docker Desktop, where the VM clock lagged ~1.4 ms), a just-upserted agent sorted before the cutoff and was deleted. The cutoff is now sourced from the database (SELECT now()) so it shares a clock with the rows it compares against. No impact on single-host deployments; fixes deterministic test failures on dev machines with app/DB clock skew. - Operations panel no longer reports backups "enabled" when the cron is
actually dead. The Environment card derived "Backup cron ✓ enabled" from
STAPLE_BACKUP_CRONalone, so a node where the backup worker self-disabled at runtime (pg_dump not on PATH) still showed a green check — masking days of missed backups. The card now also probes forpg_dumpand renders a warning ("enabled, but pg_dump not on PATH — cron is NOT running") when the cron can't actually run.
v3.11.0 — 2026-06-04¶
Installation is simpler, more correct, and self-documenting.
Changed — installer/uninstaller¶
- One canonical installer.
scripts/install.shand the newscripts/uninstall.share now thin wrappers around the fullscripts/release/{install,uninstall}.sh. The installer works from an extracted release archive or a repo checkout — when the binaries aren't bundled alongside it, it downloads the matching release (gh / GITHUB_TOKEN) and verifies the checksum. - Mode is auto-detected. Run with
sudo→ system-wide; as a normal user → user-level.--systemis no longer required (it and--userremain as explicit overrides). The uninstaller auto-detects the same way. - Database is no longer hard-pinned to port 5432. New
--db-urlpoints at an existing/managed Postgres (implies--skip-docker);--db-portsets the bundled Postgres host port. The generatedDATABASE_URL, the docker-compose port mapping, and the readiness message all honor the chosen port instead of asserting 5432. --helpeverywhere, with examples; the installer drops the full annotatedstaple.env.examplenext to the live env as a reference.- Uninstall keeps its safe default (removes binaries + service + bundled
Postgres, but preserves config + state — including a hand-edited
staple.env— so a re-install is seamless);--purgestill wipes everything. Clearer--help.
(No code/binary changes — installer tooling only. The env example was audited and is current; the only undocumented vars are internal runtime flags.)
v3.10.0 — 2026-06-04¶
Goals become operational (part 3 of 3): goal status maintains itself.
Added¶
- Auto-achieve / auto-reopen goals. A new background worker
(
goal_status_reconciler, every 2 min by default) flips a goal to achieved once all its rolled-up issues are done, and reopens it to active when new incomplete work appears — so a goal stays reopenable as objectives are added over time rather than being locked once achieved.query.ReconcileGoalStatusesonly touchesactive/achievedgoals (deliberateplanned/cancelledstates and empty goals are never auto-changed) and uses the same hierarchy + project-link rollup as the progress view. Tunable viaSTAPLE_GOAL_STATUS_RECONCILER_INTERVAL_MINUTES(1–60) and disablable viaSTAPLE_GOAL_STATUS_RECONCILER=disable; the worker reports ticks on/admin/operationslike the other sweepers.
This completes the three-part Goals upgrade (v3.8.0 prompt awareness → v3.9.0 rollup → v3.10.0 self-maintaining status).
v3.9.0 — 2026-06-04¶
Goals become operational (part 2 of 3): progress reflects the whole subtree.
Changed¶
- Goal progress now rolls up the hierarchy and project links.
GetGoalProgress(dashboard + goal pages) counts the DISTINCT issues that are tagged to a goal or any descendant goal (parent_goal_idchain), plus issues in projects linked to the goal or its subtree — via bothprojects.goal_idand theproject_goalsmany-to-many. So a high-level (company/team) goal whose work lives in child goals or linked projects now shows real progress instead of 0%. The recursive walk usesUNION(notUNION ALL), so a malformed parent-goal cycle terminates instead of looping; each issue is counted once per goal. Goals with only directly-tagged issues are unaffected.
v3.8.0 — 2026-06-04¶
Goals become operational (part 1 of 3): agents now understand the objective behind their work.
Added¶
- Agents are goal-aware. When an issue is attached to a goal, the goal's
level, title, and intent (description) are injected into the agent's
heartbeat prompt — so the agent understands the "why," not just the task.
Surfaced across all execution paths: the in-process LLM/provider path
(
BuildHeartbeatPrompt), the local CLI adapters (claude_local / codex_local viaRenderPrompt), and remote workers (carried in the task payload). New query helperquery.GetGoalPromptContext; newgoal_*fields on the engine and adapterIssueContext(best-effort — a goal lookup failure simply omits the line rather than failing the heartbeat). - The local/remote CLI prompt now also renders the issue's project name (it was carried in the remote payload but never rendered — a latent gap fixed alongside the goal work).
v3.7.0 — 2026-06-04¶
Added¶
- Admin unpair on the DM Pairings Audit page. Instance admins can now
revoke any user's Slack/Telegram pairing directly from
/companies/{companyId}/pairings/auditvia an Unpair action on each active pairing — no CLI or asking the user to self-unpair. The removal writes apairing_audit_eventsrow recording the admin asactor_user_id(distinct from the subjectuser_id), so the audit trail shows who performed it. New routePOST /companies/{companyId}/pairings/ui/unpair(instance-admin only). There is no soft-revoke state in this model, so the unpair is a permanent removal (the user needs a fresh pairing code to re-link), matching the existing self-service Unpair button.
v3.6.0 — 2026-06-04¶
Closes the last tracked parity gap — self-service join requests — which required new API/routing work, plus fixes found along the way. Backward-compatible aside from the relocated join-request endpoint (which was previously unreachable by its intended users).
Added — self-service join requests (API-first, then UI)¶
- API:
POST /api/join-requests(body:company_id,message?). Relocated out of the company-scoped/api/companies/{companyId}group — which appliesRequireCompanyAccessand so 403'd the very non-members a join request comes from — into the authenticated API group. Guards: 404 unknown company, 409 already a member, and an existing pending request is returned idempotently (200) instead of duplicated. - UI: a "Request to join" action (with an optional message) on the
companies list for any company the signed-in user is not a member of; the
list now shows Dashboard for member companies and a Request pending
badge once a request exists. New query helpers
ListCompanyIDsForUser,ListJoinRequestsByUser, andGetPendingJoinRequest.
Changed (API)¶
- Join-request creation moved from
POST /api/companies/{companyId}/join-requeststoPOST /api/join-requests(company now in the body ascompany_id). The old path is removed. This is versioned as a MINOR rather than a breaking MAJOR because the old endpoint sat behindRequireCompanyAccess— it returned 403 to the non-members a join request is for, so it had no functional callers. Listing a company's join requests is unchanged (GET /api/companies/{companyId}/join-requests, members/admins).
Fixed¶
- Join-request / invite membership used the wrong identifier. Both
CreateJoinRequestandAcceptInviterecorded the membership/request underactor.KeyID, which is empty for user-session actors — so a session user's request/acceptance would have stored an empty user id. They now resolve the user id (UserID, falling back toKeyID). TestListCommentsAfterflaked under Docker: it compared the host clock (time.Now) against a DB-stampedcreated_at, which fails when the database container's clock skews from the host. The cutoff is now anchored to the comment's own DB timestamp.
v3.5.0 — 2026-06-04¶
Completes the API↔UI parity effort (batch 3 of 3, LOW priority) and closes the last API-completeness gap (budget-policy edit/delete). Backward-compatible; no breaking changes.
Added — API↔UI parity (LOW batch)¶
- Agent configuration revisions — a read-only "Configuration revisions"
card on the agent page, surfacing
GET /api/agents/{agentId}/configuration/revisions. - Chat actions — rename a chat, escalate it (with a reason; notifies
admins), and trigger context compression ("compress now") from the chat
page, mirroring the existing
PATCH /api/chats/{id},POST .../escalate, andPOST .../compressendpoints. - SSE metrics panel — a live connection-metrics card on
/admin/operations(publishes, sent, dropped, drop-rate, active subscribers), surfacingGET /api/admin/sse-metrics. - Plugin health probe — an on-demand "Check health" button on the plugin
page, surfacing
GET /api/plugins/{pluginId}/health.
(Four other candidates were investigated and found already present in the UI:
webhook-trigger edit, portability export-preview, and the instance-flags
current-state table on /admin/operations.)
Added — budget policies: edit & delete (API-first, then UI)¶
- New API:
PATCHandDELETE /api/companies/{companyId}/budget-policies/{policyId}, both company-scoped. Delete cascades the policy's incidents (ON DELETE CASCADE, migration 046). - UI: a per-policy inline Edit form (threshold / window / status) and a Delete button on the costs page. Previously policies could only be created, not modified or removed.
Known gaps (require new API work — tracked for a follow-up)¶
- Self-service join requests:
POST /api/companies/{id}/join-requestsexists but is registered behind the member-only middleware, so a non-member (the actor who would request to join) cannot reach it. Making this usable needs the route relocated plus a "request to join" affordance on the companies list — out of scope for pure UI parity.
v3.4.0 — 2026-06-03¶
API↔UI parity, batch 2 of 3 (MEDIUM priority). Continues surfacing API-only capabilities in the web UI. Backward-compatible; no breaking changes. (The LOW-priority batch follows in v3.5.0.)
Added¶
- Agent task sessions — a read-first "Task Sessions" panel on the agent
page listing each session (status, linked issue, run, timestamps, summary),
with "Mark completed" / "Abandon" actions backed by the same query path as
PATCH /api/task-sessions/{id}. - Issue inbox archive — archive / unarchive an issue from its detail page and the inbox feed (distinct from the existing per-user inbox dismiss).
- Execution-decisions panel — a read-only "Decisions" panel on the issue page (decision type, reason, operator-friendly actor, timestamp).
- MCP external-server edit — an inline edit form per server row (the update handler already existed; the form was missing).
- Provider models — a "Models" section on the provider page listing the provider's available models.
- Plugin config edit — an "Edit configuration" form on the plugin page
(name / description / version / binary path / config JSON / enabled),
mapping to
PUT /api/plugins/{id}. - Raise approval — a "New Approval" form on the approvals page.
- Project ↔ goal linking — link/unlink goals on the project page and a reciprocal "Projects" list on the goal page.
- Project metadata edit — full project edit (name / status / description / color / shortname), not just working directory.
- Notes semantic search — a search box on the notes page over
POST /api/companies/{id}/notes/search. - Budget policy create — an "Add policy" form on the costs page.
- Assets management — a new Assets page (sidebar nav entry) to list, upload, download, and delete company assets.
Fixed¶
- Plugin config edit: the "enabled" checkbox (hidden
false+ checkboxtrue) was read viar.FormValue, which returns the first value (alwaysfalse), so a checked box never enabled the plugin. Now the full form-value slice is inspected (order-independent).
v3.3.0 — 2026-06-03¶
API↔UI parity, batch 1 of 3 (HIGH priority). Following a full audit of the API surface vs. the web UI, this release surfaces the most important operator capabilities that were previously reachable only via the JSON API. Backward-compatible; no breaking changes. (MEDIUM and LOW batches follow in v3.4.0 / v3.5.0.)
Added¶
- Agent permissions — view and set an agent's capability grants from a Permissions card on the agent page.
- Agent API keys — mint (reveal-once), list, and revoke an agent's Staple
API key from the agent page. Also adds the missing JSON API
(
GET /api/agents/{agentId}/api-keys,DELETE /api/agent-api-keys/{keyId}) so the capability is API-first, not UI-only. - Issue labels — attach/detach labels on an issue and create/delete company labels from the UI (previously read-only).
- Issue read/unread — mark issues read or unread.
- Account self-service — change password and edit profile (email /
display name) on
/me/settings. - Access administration — create + list invites (with an accept page),
list + resolve join requests, change a user's instance role, and
update / deactivate / reset-password / delete users from
/users. - Secrets — rotate and update a secret from the secrets page.
- Plugin tools — invoke a plugin tool (JSON args, rendered result) from the plugin page.
- Webhook routine triggers — create webhook triggers (reveal-once secret + fire URL) and rotate a webhook secret.
- Approvals — add a comment and link an issue to an approval.
- Goals — edit a goal (title / description / level / status).
- Budgets — resolve a budget incident.
v3.2.0 — 2026-06-03¶
A backward-compatible release: it makes remote execution configurable from the UI and folds in the v3.1.0 doc corrections. No breaking changes.
Added¶
- Remote execution is now configurable from the web UI. The agent create
and edit forms — and the company-wide Agent defaults — now offer
Remote (worker) as an adapter, with fields for the required worker tags,
the local adapter the worker runs internally, and the timeout. Previously
adapter_type: "remote"could only be set through the JSON API, so the fully-working remote adapter was effectively hidden from operators. The engine and API were unchanged; this just closes the UI gap.
Documentation¶
REMOTE_WORKERS.md§3 now documents configuring a remote agent from the UI (Adapter → Remote) alongside the API method.- Corrected the remote-worker guides to match v3.1.0 behavior: operating-prompt
version
1.1.0→1.2.0, worker statusactive→online, removed the reference to a non-existentDELETE /api/workers/{id}endpoint (deletion is the/admin/workersUI button), and documented idempotent registration plus the disable/enable/delete actions. The smoke test now notes it requires server v3.1.0+. Touchesremote-workers.md,remote-worker-smoke-test.md,building-a-worker.md, andAPI.md.
v3.1.0 — 2026-06-03¶
Remote workers are now usable end-to-end and manageable from the admin UI. A backward-compatible release (new features + fixes; no breaking changes).
Fixed¶
- Remote-worker authentication returned 401 for every valid key. The
authenticated worker endpoints (
POST /api/workers/heartbeat,/api/workers/tasks/claim,/api/workers/tasks/{id}/complete,/api/workers/tasks/{id}/fail) were declared inside the agentBearerAuthgroup.BearerAuthresolvesagent_api_keys, notremote_worker_keys, so it rejected every worker key with401 {"error":"invalid api key"}beforeWorkerAuthran — remote workers could never authenticate. The four routes now live in a dedicatedWorkerAuth-only group (rate-limit + 1 MB body cap preserved). /admin/workersand the A2A peer pages could 500. The templates calledderef(a*stringhelper) on*time.Timefields (LastSeenAt,LastFetchedAt); the page errored the moment a worker authenticated or a peer was fetched. Corrected toderefTime.- Re-registration leaked an orphan worker row on every boot. See idempotent registration below.
Added¶
- Remote-worker management UI.
/admin/workersnow offers Delete (permanently removes a worker and its keys) and Disable / Enable (a reversible fence —WorkerAuthrejects a disabled worker's key with403while keeping its registration and history). Backed by a newdisabledcolumn (migration132). - Idempotent worker registration.
POST /api/workers/registernow resumes an existing worker when a recognised key is presented (sameworker_id, HTTP200, no duplicate row) instead of creating a fresh orphan; the shippedstaple-workerbinary adopts any newly issued key.
Changed¶
- Docs:
REMOTE_WORKERS.mddocuments idempotent registration and the delete/disable UI;scripts/service/staple.env.exampleis now a complete, sectioned reference for every environment variable.
Internal¶
- Made
TestInstanceFlagEvents_FilteredByDaterobust to host/DB clock skew (it had no product bug; the test was clock-coupled and failed under Docker Desktop on macOS).
v3.0.0 — 2026-05-31¶
Version re-baseline. Staple's earlier history accumulated many parallel, non-monotonic version tracks (v2.40.x … v2.51.x), all dated 2026-05-30, which made "the current version" ambiguous. v3.0.0 establishes a single clean, going-forward version of record. It is not a breaking-change release in the SemVer sense — it is a consolidation milestone that also ships the work below. The historical entries beneath this one are preserved as-is for provenance.
Documentation¶
- Comprehensive documentation rewrite. Every top-level reference
(
README.md,ARCHITECTURE.md,API.md,PLUGIN.md,REMOTE_WORKERS.md,CONTRIBUTING.md) and the full mkdocs user manual (docs/user/— board, agents, concepts, operators, integrators, getting-started) were rewritten against the current code and verified endpoint-by-endpoint / env-var-by- env-var.API.mdnow documents the complete authenticated surface (~270 endpoints);docs/user/concepts/action-verbs.mddocuments the full 35-verb agent interface;docs/user/operators/environment.mdis a complete env-var reference. - Corrected substantial drift in the old docs, including: a fabricated plugin
Go SDK (the real interface is the JSON-lines protocol), wrong remote-worker
env vars + a non-existent wake endpoint, the "secrets stored in plaintext"
myth (an unset
STAPLE_ENCRYPTION_KEYrefuses encrypted writes — it does not fall back to plaintext), and ~23 undocumented action verbs. - Added the 7 operator pages that were missing from the mkdocs navigation.
Testing & reliability¶
- Test-coverage campaign: every package with meaningfully testable logic is
now ≥80% (handler ~79.7% and skills ~75% are capped by genuinely unreachable
defensive code;
cmd/*main packages and pure-embed/test-helper packages are excluded by nature). The fullinternal/...suite passes under-p 1. - Fixed a pervasive test-infrastructure defect: tests hardcoded the live
staple_devDSN, so they polluted production data locally and silently skipped in CI. All DB-backed test helpers now honourDATABASE_URLviainternal/testsupport.
Bug fixes (shipped during this cycle)¶
- Egress allowlist no longer accepts duplicate hostname-only destinations (migration 131 — NULL-safe unique index).
- Admin audit dashboard filtered readers fixed (uuid/text join + ILIKE operator errors that silently returned empty panels).
- Remote-worker onboarding restored:
POST /api/workers/registerandGET /api/workers/operating-promptwere trapped behind bearer auth and returned 401; they are now public, as documented. - Supervisor notifications now fan out to a company's admin members with a channel pairing (previously resolved an agent id as a user id and never fired).
- Evolution heartbeat scorer now enforces the intended 24-hour operator- intervention window (it was computed but never applied).
Operations¶
- The systemd unit (
scripts/service/staple.service) now waits for the database to accept connections before starting, so a host reboot brings the service up cleanly in one attempt instead of crash-looping until Postgres is ready;StartLimit*moved to[Unit]with headroom.
v2.36.0.4 — 2026-05-30¶
feat(ui): company logo upload (replace letter avatar) — operators
can now upload a custom logo per company, rendered in the sidebar
rail and the Preferences switcher in place of the auto-generated
letter avatar. The schema + JSON write paths already existed (the
v2.36.0.0 family shipped the assets + company_logos tables in
migration 035 and the upload/delete handlers in
internal/handler/company_logos.go); v2.36.0.4 closes the loop by
making the feature visible and usable from the UI.
What's new in v2.36.0.4:
-
Image-serving endpoint —
GET /api/companies/{companyId}/logo/imagestreams the bytes of the configured logo withContent-Typeset from the stored asset andCache-Control: public, max-age=3600so browsers cache the avatar between page navigations. Returns 404 when no logo is configured, the asset row is missing, or the file is missing from disk. Auth is the standardRequireCompanyAccessmiddleware — any actor with read access to the company can fetch its logo bytes. -
Branding panel on the per-company Settings page — a new card at the top of
/companies/{id}/settingsshowing a 96×96 live preview of the current logo (or the letter-avatar fallback) and a multipart upload form with the file input, a Light/Dark theme dropdown, an Upload button, and a Remove button (only when a logo is currently configured). The formacceptsimage/png,image/jpeg,image/gif,image/webpto nudge the browser toward the supported formats before submission. -
Sidebar + preferences rendering —
layout.htmlandpreferences/index.htmlnow branch on a newCompany.HasLogoflag. When true, they render<img src="/api/companies/<id>/logo/image">; when false, the existing letter-avatar tile renders unchanged.Company.HasLogois populated by a LEFT JOIN againstcompany_logosinsideListCompaniesandGetCompanyByIDso the sidebar avoids an N+1 per-row query. The agent-avatar surfaces (agents/show.html,agents/index.html) are intentionally untouched —companyColor/initialthere are bound to agent names, not company names. -
Upload-path hardening —
UploadCompanyLogonow: -
Caps the request body at 1 MB via
http.MaxBytesReader; an oversize upload returns 413. - Validates the multipart part's Content-Type against a closed
allowlist (
image/png|jpeg|gif|webp); anything else returns 415. SVG is deliberately excluded because it can carry embedded<script>that would run in the rendered<img>context. - Verifies the file actually decodes as one of the declared
formats by running
image.DecodeConfigon the buffered bytes (header-only — cheap even for the 1 MB cap). A.pngextension on a non-PNG body now returns 415 instead of silently writing a broken file to disk. - Enforces a 2000 × 2000 max dimension to defend against decompression-bomb PNGs.
- Buffers the body in memory first, then writes atomically — a rejected size/dimensions check no longer leaves a half-written file on disk.
-
Prunes the previous logo's file and asset row as a best-effort post-step when an upload replaces an existing logo. Pre-fix the prior file leaked forever.
-
CSS — three new classes in
app.css: .rail-company.has-logo+.rail-company-img— 32 × 32 rail avatar variant that hosts an<img>instead of the letter tile, withobject-fit:coverso a non-square upload crops gracefully..company-logo-thumb— 24 × 24 sibling for the Preferences switcher.-
.company-logo-preview— 96 × 96 sibling for the Settings panel preview. -
Tests —
internal/handler/company_logos_test.go(new) covers: image-serve happy path + 404 (no logo) + 404 (after delete); 415 on unsupported Content-Type (SVG); 415 on bogus bytes declared as PNG; 413 on oversized body;Company.HasLogoflips true after upload via bothListCompaniesandGetCompanyByID; Branding section renders the form and accept attribute in the no-logo case; renders the preview<img>and Remove button in the with-logo case. ATestTinyPNG_IsValidsanity check guards the fixture so a future typo'd PNG fixture fails loudly instead of failing every upload test with a confusing 415. -
Docs —
docs/user/operators/branding.md(new) walks an operator through uploading and removing a logo, lists the limits and rejection codes, explains the storage layout, and documents the/api/companies/{id}/logo/imageendpoint. Linked from the operators table indocs/user/README.md.
Schema delta: 0 (the existing migration 035 already covers
assets + company_logos; the theme column on company_logos
remains reserved for a future theme-aware variant). Migration
counter stays at 130.
Files touched:
internal/handler/company_logos.go—GetCompanyLogoImage(new);UploadCompanyLogorewritten with size/type/dimension guards and previous-logo pruning;pruneCompanyLogoAsset(new, shared withDeleteCompanyLogo).internal/handler/company_logos_test.go(new).internal/handler/integration_handlers_test.go— existing upload subtests adjusted to send a real PNG + matching Content-Type now that the handler enforces the allowlist.internal/db/query/companies.go—Company.HasLogo(new,json:"-");companyColumnsWithLogo+scanCompanyWithLogo(new);ListCompanies+GetCompanyByIDrewritten to LEFT JOINcompany_logos. Write paths (CreateCompany,UpdateCompany) unchanged.cmd/server/routes.go—GET /logo/imageroute added to the viewer-tier group inside the per-company API tree.internal/web/templates/layout.html— sidebar rail branches onHasLogo.internal/web/templates/preferences/index.html— company switcher branches onHasLogo.internal/web/templates/company_settings/index.html— Branding panel added at the top of the page.internal/web/static/app.css—.rail-company.has-logo,.rail-company-img,.company-logo-thumb,.company-logo-preview.docs/user/operators/branding.md(new) +docs/user/README.md(linked).go.mod/go.sum—golang.org/x/imageadded forimage/webpDecodeConfig registration.
v2.51.0.26 — 2026-05-30¶
feat(backup): backup-restore-test (dry-run validation) — adds
staple-cli restore-validate (a new CLI verb) that walks an
encrypted or plaintext backup archive via pg_restore --list
without actually applying anything to a database. Validates that
the file IS a restorable Postgres archive — a strictly stronger
check than v2.51.0.5's backup-verify (which only confirms the
encryption layer is well-formed).
Pre-v2.51.0.26 an operator had three options for "would this backup actually restore?":
backup-verify: catches encryption-layer corruption (~4s / 100 MiB) but is blind to whether the underlyingpg_dumpoutput was truncated;restore --yes: catches everything but is destructive, slow, and needs a throwaway Postgres;- read the source and hope.
restore-validate fills the gap. It decrypts the file (if
encrypted) into a temp file under $TMPDIR, runs
pg_restore --list <tempfile> to enumerate the archive's TOC,
parses the result into per-kind object counts (tables / indexes /
sequences / views), and reports:
✓ Archive valid: 47 tables, 92 indexes, 35 sequences, 4 views — Restore would succeed.
source=./staple-backup-20260529T140112.dump.enc toc-entries=193 decrypted=132.40 MiB in 5.83s
A pass means the file's plaintext is a parseable pg_restore custom- format archive with at least one TOC entry. A fail (exit 1) means the file decrypted but pg_restore couldn't read the contents — the underlying pg_dump output was truncated, corrupted, or empty.
Implementation:
cmd/staple-cli/backup_restore_validate.go(new):runBackupRestoreValidate(ctx, args)is the dispatcher entry. Argument shape mirrorsbackup-verify: a single positional (path ors3://bucket/key) OR--file=path; same--s3-bucket=/--s3-region=/--s3-endpoint=/--s3-path-style=flags +--quietfor cron friendliness. The verb-prefixstaple-cli restore-validate ...is wired intomain.go's switch.restoreValidateorchestrates the three-stage pipeline: materialise the archive (decrypt to a temp file if needed), runpg_restore --list, parse the TOC, tear down the temp file. Pure-Go composition over the existingvault.NewBackupReader+openBackupSourcehelpers so the code shares the file-format / S3 contracts the rest of the backup CLI already pins.materializeArchivebranches on s3 prefix +looksEncryptedsniff. Plaintext local files are used in-place (no copy, cleanup is a no-op). Encrypted files + every s3 URI route throughdecryptToTempFile. The 17-byte STAPLE_BACKUP_v1 sniff is the same onerestore.go'slooksEncryptedperforms so the two surfaces agree on what counts as encrypted.decryptToTempFilestreams the source throughvault.NewBackupReaderinto a temp file inos.TempDir()with arestoreValidateMaxTempBytes(64 GiB) cap viaio.LimitReader. Hitting the cap exits 1 with an explicit "refusing to fill /tmp" error rather than truncating silently. Temp file isSync'd before close so pg_restore doesn't race against a stale page cache. The cleanup closure removes the path on every failure path.parsePgRestoreListis a pure function that walks the stdout ofpg_restore --list, skips header / comment / blank lines, splits on whitespace, and matches the 4th field (kind token) against the per-kind classification table.TABLE/INDEX/SEQUENCE/VIEWincrement their respective counter; unknown kinds (CONSTRAINT, SCHEMA, FUNCTION, etc.) still bumpTotalEntriesso an operator can spot an archive shape mismatch. A trailing;on the kind token is stripped so a future pg_restore version variant doesn't silently misclassify.truncatePgRestoreStderrcaps the stderr snippet embedded in error messages at 400 chars + a…(truncated)marker so a noisy pg_restore failure doesn't drown the operator's terminal while still keeping the underlying cause one-line-grep-able.runBackupRestoreValidateAlreadyPrintedmirrorsrunBackupVerifyAlreadyPrintedsomain.gosuppresses the dispatcher prefix when the operator already saw the recognisable✗ failed: ...line on stderr.cmd/staple-cli/main.go:- New
case "restore-validate":branch dispatches torunBackupRestoreValidate; same already-printed posture asbackup-verify. - Usage block under
staple-cli helpgains arestore-validateentry describing the flag set, the comparison tobackup-verify, the DATABASE_URL / STAPLE_ENCRYPTION_KEY gates (only when input is encrypted), and the pg_restore PATH requirement. cmd/staple-cli/backup_restore_validate_test.go(new):TestParsePgRestoreList_HappyPathwalks a 7-entry fixture mirroring real pg_restore --list shape — header comments followed by per-object rows — and pins the per-kind talliesTotalEntries.
TestParsePgRestoreList_EmptyTOCcovers the degenerate "no objects" case (header comments only).TestParsePgRestoreList_HeaderOnlycovers the defensive minimal-input case (only;lines, noSelected TOC Entriespreamble).TestParsePgRestoreList_UnknownKindensures CONSTRAINT / SCHEMA / FUNCTION rows bumpTotalEntrieswithout leaking into any per-kind counter.TestParsePgRestoreList_MalformedShortLineconfirms a short or non-TOC-shaped line is counted towardTotalEntriesbut classified into nothing.TestParsePgRestoreList_TrailingSemicoloncovers the defensiveTrimSuffix(fields[3], ";")branch.TestTruncatePgRestoreStderr_BelowCap/_AboveCap/_EmptyStaysEmptypin the 400-char ceiling, the…(truncated)suffix, and the whitespace-only collapse.TestRunBackupRestoreValidate_ArgShaperuns the dispatcher's argument-shape paths (no args,--quietonly,--file=empty) and pins each surfaces ausage: staple-cli restore-validate ...error rather than exiting 0.TestRunBackupRestoreValidateAlreadyPrintedpins the dispatcher contract for main.go's prefix-suppression check.TestRestoreValidateResult_HappyPathRenderingregression- pins the struct field shape so a future refactor that renames or drops a field surfaces in the test suite.docs/user/operators/backup-and-restore.md:- New "Validate a backup file end-to-end with
pg_restore --list" subsection under the existing "Verify a backup file" section. Includes the comparison table (backup-verifyvsrestore-validate), the cost / what-it-catches summary across the trio (backup-verify/restore-validate/restore --yes), the cron pattern with mail-on-failure, and the S3 URI form. - Operator command-reference table gets a
staple-cli restore-validate <path>row marked v2.51.0.26 so the table-of-commands stays the definitive operator lookup.
No migrations. No production handler / worker changes. Pure additive CLI work + operator docs.
v2.43.0.27 — 2026-05-30¶
feat(agents): subagent dashboard spawn source column — extends
the v2.43.0.x /admin/subagents observability dashboard with a
"Spawned by" column that surfaces each subagent dispatch's
spawn_reason text (from migration 105, agents.spawn_reason) so
operators can see who initiated each row without drilling into the
per-run detail page. Adds a matching ?spawn_source=<substring>
URL filter pushed down to SQL via ILIKE, and surfaces the same
field as a "Spawned by" tile on the per-run detail page (with a
click-through link back to the dashboard pre-filtered by the row's
reason).
Pre-v2.43.0.27 the dashboard answered "what's running where?" but
not "why is each of these things running?". An operator triaging a
runaway recursive dispatch had to click each r-id cell into the
detail page just to read the spawn_reason — slow and
context-shifting. v2.43.0.27 puts the reason text inline in the
feed so the operator can scan + filter without leaving the index.
Implementation:
internal/db/query/heartbeat_runs.go:SubagentRunRowgains aSpawnReason *stringfield. Pulled fromchild.spawn_reasonin bothListAllRecentSubagentRuns(the no-filter helper) andListAllRecentSubagentRunsFiltered(the pushdown variant) so the dashboard projection carries the value end-to-end. NULL rows (root agents — excluded by the existing WHERE — and legacy children seeded before migration 105) keep their nil pointer; the template renders an em-dash so the operator can tell "no reason recorded" apart from "(empty string)".SubagentRunFiltergainsSpawnReasonQuery string— the case-insensitive ILIKE substring the dashboard filter pushes down. NULL spawn_reason rows are explicitly excluded viaIS NOT NULLso the three-valued logic doesn't bite a future reader. The filter is recognised byactive()so the empty- filter fast-path still short-circuits to the unfiltered helper when nobody's typed anything.internal/db/query/heartbeat_run_detail.go:- The detail SELECT in
GetSubagentRunDetailnow projectschild.spawn_reasonso the per-run page can render the new "Spawned by" tile from.Run.SpawnReason(the field resolves through the embeddedSubagentRunRowsinceSubagentRunDetailRowdoesn't shadow it). internal/handler/ui_subagents.go:subagentFiltersgainsSpawnSource string;active()includes it;toQueryFilterforwards the trimmed value intoSpawnReasonQueryso the SQL layer applies the predicate before the 200-row cap. The handler readsq.Get("spawn_source")off the URL.applySubagentRowFilters(the defensive in-memory second pass) gains the matching predicate — NULL spawn_reason rows excluded from any non-empty filter, case-insensitive substring match otherwise. Mirrors the SQL exactly so the two layers can't drift.- Template data picks up
"SpawnSourceFilter": filters.SpawnSourceso the form input'svalue=attribute round-trips an operator-submitted query. internal/web/templates/subagents/index.html:- The filter form gains a "Spawn source" text input next to the v2.43.0.23 "Search agents" input — same placeholder style, same width.
- The active-filter pill row gains a "spawn_source=
" pill with the same ✕clear behaviour as the rest of the knobs; the existing pills now propagate the newspawn_sourceURL param when their own ✕ clears so the operator doesn't lose the spawn_source filter when clearing an unrelated knob. - The table gains a "Spawned by" column between Cost and
Company. The cell truncates at 48 chars (
…suffix) for column hygiene; the full string lives in atitle=attribute for hover. NULL spawn_reason renders an em-dash. The Go template uses the existingderefhelper to unwrap the*stringsolen/sliceoperate on the underlying value. internal/web/templates/subagents/detail.html:- The Status & timing card gains a "Spawned by" tile alongside
the existing Status / Enqueued / Started / Completed /
Duration / Run id / Company tiles. The tile renders the full
spawn_reason text and wraps it in a link back to
/admin/subagents?spawn_source=<reason>so the operator can pivot from "what spawned this one?" to "what else did this source spawn?" in one click. NULL spawn_reason renders an em- dash, same as the index cell. internal/handler/ui_subagents_test.go(extended):TestApplySubagentRowFilters_SpawnSourcewalks the in-memory predicate against a NULL + two non-NULL fixture set — pins that empty filter passes everything, non-empty filter excludes NULL rows, case-insensitive substring match in both directions, and combined-with-status narrowing.TestSubagentFilters_Active_IncludesSpawnSourcepins the.active()participation.TestToQueryFilter_ForwardsSpawnSourcepins the UI → DB translation (trimmed; empty stays empty so the pushdown short-circuits).TestUISubagentsIndex_SpawnSourceColumnRendersseeds a fresh subagent with a known spawn_reason, firesGET /admin/subagents, and pins that the column header (>Spawned by<), the per-row reason text, the filter input scaffold (name="spawn_source"), and the filter label (>Spawn source<) all land in the rendered HTML.TestUISubagentsIndex_SpawnSourceFilterRoundTripseeds two subagents with distinct reasons, drives?spawn_source=triage, and pins the URL value round-trip (value="triage"), the active filter pill (spawn_source=<code>triage</code>), the matching row's reason text visible, and the non-matching row's reason text excluded.- The two existing
TestUISubagentsIndex_DepthLimitBadge…tests now match the full badge HTML (🚫 depth limit) rather than the raw "depth limit" substring because the new "Spawned by" column may render fixtures whosespawn_reasontext contains the substring. The badge-vs-text distinction is the right thing to assert anyway.
No migrations. The agents.spawn_reason column has shipped since
v2.43.0.0 (migration 105); v2.43.0.27 just surfaces it on the
dashboard and per-run detail.
v2.42.0.26 — 2026-05-30¶
feat(cli): chat REPL /ping command — adds a zero-argument
/ping slash command to the staple-cli chat REPL that probes
the server's /healthz endpoint three times in sequence and reports
per-attempt + aggregate latency. Lets an operator confirm "is the
server still responsive, and how fast?" without leaving the chat
session, burning a model round-trip on a "are you there?" prompt,
or shelling out to curl. Doesn't consume a chat turn; failures
surface inline and the loop continues.
Pre-v2.42.0.26 a wedged stream or a "is the box back yet?" check forced the operator to /quit + curl + rerun. /ping collapses that to a single in-REPL command and prints a small block the operator can scan:
🏓 ping http://node4:3100
attempt 1: 45ms ✓ (v2.50.0.22 / f7aa9a0)
attempt 2: 42ms ✓
attempt 3: 48ms ✓
avg: 45ms min: 42ms max: 48ms
The (version / commit) suffix only appears on the first successful
attempt — repeating identical fields on every row would be visual
noise, and the first row is enough to confirm "yes I'm pointed at
the build I expect". Failed probes render as a red ✗ <reason>
line; the avg/min/max footer walks the surviving successful probes
only (a failed probe's 5s timeout would skew the average upward
misleadingly) and gets a (N failed) annotation. If every probe
fails the footer degrades to a single all 3 probes failed line.
Implementation:
cmd/staple-cli/chat_ping.go(new):chatPingAttempts(3),chatPingDelay(200ms), andchatPingTimeout(5s) are named constants so the tests pin the contract without hardcoding the numbers and so a future operator can tune any one of them without ambiguity.runPingis the dispatcher entry. CallspingProbeAll(which runs the sequential GETs with a cancel-aware 200ms gap so a Ctrl-C during the gap short-circuits cleanly) thenprintPingResultsfor the rendered block.pingProbeOncebuilds one GET against<baseURL>/healthzwith its own 5s timeout (the defaultchatHTTPClientis 60s, way too generous for a liveness probe) and records latency + decoded health envelope. A decode failure on a 2xx is non-fatal — the server is still responding, just with an unexpected shape — so the timing reports but the suffix degrades.chatPingHealthis the minimal projection of thehandler.HealthzHandlerwire shape (status / version / commit).chatPingAttemptcarries per-probe data so the formatter is pure-function over a fixture slice — easy to unit-test without spinning a real network round-trip.formatPingLatencyrenders durations as<N>ms; sub- millisecond probes degrade to<1msrather than0msso the operator can tell apart "stupendously fast" from "no signal".printPingResultswalks the slice, emits the header, the per-attempt lines (with the first-success-only version/commit suffix), and the avg/min/max footer (or the degraded "all N probes failed" line).-
computePingStatsrolls successes / failures / avg / min / max — failures contribute only to the failure counter, never to the timing aggregates. -
cmd/staple-cli/chat.go: - Dispatcher gains a zero-argument
case "/ping":branch that callsrunPing. Continues the loop after rendering. -
shouldRecordToHistoryadds/pingto the no-record list so the up-arrow recall buffer stays clean of debugging probes. -
cmd/staple-cli/chat_slash.go: -
chatHelpTextgains a new entry advertising the command and its one-line behaviour so/helpsurfaces it for discovery. -
cmd/staple-cli/chat_slash_test.go(extended): TestChatHelpText_ContainsPingpins the help-block entry.TestShouldRecordToHistory_ExcludesPingconfirms the no-record posture.TestFormatPingLatency_Tablewalks the sub-ms / 1ms / 45ms / 5s boundaries.TestComputePingStats_AllSuccess,_MixedFailure,_AllFailedpin the aggregate roll-up across the three failure modes — particularly that the failed-probe latency does NOT skew the success-only aggregates.TestPrintPingResults_HappyPathdrives the pure formatter against three successes and pins the header, each✓row, the version/commit suffix appearing exactly once (only on row 1), and the avg/min/max footer.TestPrintPingResults_MidStreamFailurecovers the brief's "mock one failure mid-stream → still prints stats" requirement: rows 1 and 3 succeed, row 2 fails, the footer still emits avg/min/max +(1 failed).TestPrintPingResults_AllFailedpins the degradedall N probes failedfooter.TestChatLoop_PingCommanddrives/pingthroughchatLoopWithConfigagainst a mock/healthzthat returns 3 successful responses; asserts exactlychatPingAttemptsGETs fire and the rendered output contains the header + ✓ markers + avg/min/max footer + the mock'sversion / commitsuffix.TestChatLoop_PingCommand_MidStreamFailuredrives the end-to- end mid-stream-failure path: the mock returns 503 on attempt 2, the dispatcher still issues all 3 GETs, and the rendered output contains the✗ HTTP 503line + the(1 failed)footer annotation.
No migrations. No production handler changes. Pure additive CLI work.
v2.50.0.22 — 2026-05-30¶
feat(ops): audit dashboard keyboard review shortcuts — extends the v2.50.0.14 keyboard shortcut handler and the v2.50.0.18 review status column with four single-key bindings that drive the focused row's review state without reaching for the mouse:
R— mark focused row "Reviewed"A— mark focused row "Action needed"I— mark focused row "Ignored"U— clear focused row's review status (Undo / Unreview)
Pre-v2.50.0.22 a triage workflow looked like: j j j (find the row), mouse over to Status column, click dropdown, click option, mouse back. Per row. v2.50.0.22 collapses that to: j j j R. During a page incident across two dozen rows that adds up.
Implementation:
internal/web/templates/audit/index.html:- The v2.50.0.14 keydown dispatcher gains four new
switchcases (R/A/I/U, case-insensitive so caps-lock operators still triage). Each callsmarkFocused(status)which writes the new value into the focused row's existing[data-staple-audit-review-select]and fires a syntheticchangeevent so the v2.50.0.20 history- tint + filter cascade runs unchanged. The "Undo" case writes
""(the explicit cleared transition), which the v2.50.0.20 machinery records as a fresh history entry rather than deleting the row — preserving the operator's audit trail.
- tint + filter cascade runs unchanged. The "Undo" case writes
- A new toast element (
#staple-audit-review-toast,role="status",aria-live="polite") fades in for ~1.5s after every R/A/I/U press with "Marked as". Rapid sequences reset the timer rather than queueing. - The keyboard help overlay (v2.50.0.14) gains a "Review status
(v2.50.0.22)" sub-header and four operator-facing rows
describing the new bindings; the
?key surfaces them alongside the existing j/k/Enter/// shortcuts. -
The footer in the modal now reads "v2.50.0.14, v2.50.0.22" so the provenance trail stays visible on hover.
-
internal/handler/ui_audit_test.go: TestUIAuditDashboard_ReviewShortcutScaffoldingpins the toast element (id,data-*,role,aria-live), the four operator-facing help-overlay rows, the inline-script identifiers (function markFocused,function showReviewToast), the four lower-casecasearms in the keydown switch, and the literal "Marked as " toast prefix.
No migrations. No new handlers. Pure template + client-side work.
v2.47.0.24 — 2026-05-30¶
feat(ops): Operations panel alert acknowledgement — extends the
v2.47.0.22 "Recent alerts" tile with per-row Acknowledge links so
operators can mark an alert as seen and stop noticing it on every
refresh. Acknowledgement state lives entirely in localStorage
(staple_ops_alerts_ack); the in-process ring loses history on
restart anyway, so durable server-side ack state would burn writes
for no return.
Pre-v2.47.0.24 every refresh re-surfaced the same backup_health / cost_budget / gepa critical alerts whether the operator had triaged them or not — the tile had no way to distinguish "new" from "already on it". v2.47.0.24 adds that distinction without a database round- trip.
Implementation:
internal/web/templates/operations/index.html:- Each
<tr>in the alerts table gainsdata-alert-ts,data-alert-source, anddata-alert-messageattributes so the inline JS can hash them into a stablealert_id. The same ring position keeps the same hash across page loads as long as the alert hasn't been evicted; eviction rotates the timestamp set which naturally rotates the hash so a stale ack doesn't leak onto an unrelated successor row. - A new "Ack" column gains an
Acknowledgeanchor + a hidden✓ <ack time>badge. The JS toggles between them when the operator clicks. - A header-row checkbox ("Show acknowledged") flips visibility of acknowledged rows; off by default, so the tile defaults to "what's still on your plate".
- The card itself carries
data-alerts-ack-storage-key="staple_ops_alerts_ack"so the JS reads the key name once rather than hardcoding — keeps the storage-key contract visible in the DOM for operators debugging via devtools. - A small inline
<script>block implements djb2 hashing,loadAcks/saveAcksover localStorage, and anapplyAckStatefunction that runs on load + every toggle / click. Robust to quota errors, malformed payloads, and private-browsing mode — every failure silently degrades to "not acknowledged". -
The script is only emitted when
!.Emptyso the empty-state banner stays free of dead JS. The testTestUIOperationsShow_AlertsTile_AckScaffoldHiddenWhenEmptypins this. -
internal/handler/ui_operations_test.go: TestUIOperationsShow_AlertsTile_AckScaffoldpins every part of the v2.47.0.24 contract: the storage key name, the card attribute, the three per-rowdata-alert-*attributes, theAcknowledgeanchor text, the hidden badge scaffold, theShow acknowledgedtoggle label, and the presence of the hashing / loadAcks / saveAcks / applyAckState helpers in the emitted script.TestUIOperationsShow_AlertsTile_AckScaffoldHiddenWhenEmptypins the empty-state path: the empty banner shows, no hash function is shipped, no row attributes leak.
No migrations. No new handlers. Pure template + client-side work plus extended test coverage.
v2.42.0.25 — 2026-05-30¶
feat(cli): chat REPL /info command — adds a one-shot
/info <type> <id> slash command to the staple-cli chat REPL so
operators can look up an arbitrary entity by ID without leaving the
session. Type ∈ {chat, agent, note, issue}; each branch hits the
already-existing detail endpoint, decodes the minimal projection,
and renders a metadata-only block. Doesn't consume a chat turn;
errors surface inline and the loop continues so the trailing /quit
still exits cleanly.
Pre-v2.42.0.25 the operator had to leave the chat — burning context — every time they wanted to confirm "what chat is this UUID? who's assigned to that issue?". /info answers those questions inline.
Implementation:
cmd/staple-cli/chat_info.go(new):parseInfoCommandis a pure function that splits the body into (type, id), normalises the type to lowercase, validates againstchatInfoValidTypes, and surfaces a single helpful usage line on every failure path. Empty args / missing id / extra tokens / bad type each produce a distinct error so the operator can fix the typo on the first try.- Per-type fetchers (
chatInfoFetchChat,chatInfoFetchNote,chatInfoFetchIssue) each do one GET against the existing routes (/api/chats/{id},/api/notes/{id},/api/issues/{id}) and decode a minimal projection of the server's wire shape. The note projection deliberately omitsbody_markdown— the body can be many kilobytes and /info is metadata-only. errInfoNotFoundis the 404 sentinel;runInfotranslates it into a brief "(not found)" line so the operator chasing a typo doesn't get the verbose status envelope.- The
agentbranch prints a documented friendly notice rather than 404ing — there's noGET /api/agents/{agentId}route yet, so the renderer points the operator at /agents (list) or the dashboard. -
Per-type printers render the brief's required columns:
- chat: agent_id, company, title, created_at, message count
(via
latest_seq, which equals the message count given the trigger-assigned seq + no-delete schema invariant) - note: title, labels, created_by (polymorphic author),
kind, age (via
formatNoteAgefrom chat_slash.go), archived_at - issue: title, status, priority, assignee, created_at
- chat: agent_id, company, title, created_at, message count
(via
-
cmd/staple-cli/chat.go: - Dispatcher gains a
/infoprefix branch that callsrunInfowith the trimmed args. Continues the loop on error. -
shouldRecordToHistoryadds both/info(bare) and the/infoprefix to the exclusion list — operators don't want arbitrary UUIDs sitting in their up-arrow recall buffer. -
cmd/staple-cli/chat_slash.go: -
chatHelpTextgains the new/info <type> <id>line so /help surfaces the command for discovery. -
cmd/staple-cli/chat_info_test.go(new): - Table test for
parseInfoCommand: empty / whitespace / missing id / trailing extra / unknown type / case normalisation / each happy path. - Per-type happy-path tests against an
httptest.Servermock for chat, note, issue. - Per-type 404 paths pin the "(not found)" line and verify the verbose status envelope does NOT leak into the operator's terminal.
- Note happy-path verifies the body field does NOT leak.
- Issue NULL-assignee path pins the "(unassigned)" fallback.
- Agent path verifies no HTTP request is issued and the friendly "no per-agent detail endpoint" notice is printed.
- Bad-type test pins the "unknown type" error + the supported types list.
- Empty-args test covers three input variants (
/info,/info,/info chat). - History-exclusion test pins both the bare and arg-bearing forms.
- Help-text test pins the new line and the type list.
No migrations. No production handler changes. Pure additive CLI work.
v2.49.0.7 — 2026-05-30¶
feat(ops): /readyz response time histogram — extends the
existing /readyz probe with a per-check response-time histogram
and a p99-based escalation so operators can spot slow probes
before they fail. Each check (db_ping, workers, channels, disk,
backup) is now wrapped at the call site with a Record(name,
duration) push into a fixed-size per-check ring buffer; the
/readyz response carries the observed duration_ms on every
check row plus a top-level recent_check_timings map keyed by
check name with p50/p95/p99 percentiles + sample count. The
same in-process histogram is exposed via Prometheus at
staple_readyz_check_duration_ms{check="<name>",pct="<50|95|99>"}
so dashboards consuming /readyz JSON or /metrics see the same
distribution.
Pre-v2.49.0.7 operators tracking probe regressions only saw the binary "ok=true/false" signal per check. By the time a check flipped to ok=false, the probe was already past the readyzCheckTimeout (2s) and the orchestrator had been waiting on a slow response. The new histogram catches the climbing-p99 trend BEFORE the next probe times out so the orchestrator can drain the node early.
Implementation:
internal/health/timing.go(new):- Per-check fixed-size ring buffer (
ringSize = 100). Push viaRecord(checkName, duration); pull viaSnapshot(). Mutex-protected because /readyz can land concurrently from multiple scrapers. PercentileSetcarries P50/P95/P99 (in ms) + a Count so consumers can distinguish "no signal yet" from "fast probe".- Percentile math uses the "nearest-rank" definition
(Prometheus's discrete-bucket convention) so the result
is a real sample value, not an interpolated point. Pure
function
computePercentilesis package-private and exhaustively unit-tested. -
ResetForTest()wipes the singleton between unit tests. Not exported under any name reachable by production code. -
internal/health/timing_test.go(new): - Empty / single-sample / even-N / odd-N / full-ring
percentile cases; sort-invariance pin; boundary clamp on
percentileIndexwalking N×P combinations. - Ring overflow: writes past ringSize evict the oldest sample; the snapshot still surfaces exactly ringSize entries with the new distribution.
- Half-overwrite: 50 fast + 50 slow samples land 50/50 in the histogram, catches an off-by-one in the snapshot walk.
-
Defensive empty-check-name short-circuit; negative duration retained; snapshot independence (mutating the returned map doesn't leak into the store); concurrent Record + Snapshot under
go test -raceto catch mutex misuse; ResetForTest contract. -
internal/handler/health.go(modified): ReadinessCheckgainsDurationMs int64— observed wall-clock cost of running this specific check on this specific request.ReadinessResponsegainsRecentCheckTimings map[string]readinessCheckPercentile(omitempty so pre-first-probe responses stay backwards- compatible). NewreadinessCheckPercentilewire struct mirrorshealth.PercentileSetwith explicit JSON tags.- New
STAPLE_READYZ_CHECK_P99_MSenv knob (default 1000ms, range [1, 60000]).readyzCheckP99Threshold()follows the established fallback contract used by the other readyz env vars. - New
timeReadyzCheck(name, row, fn)helper wraps a check's execution with timing — calls fn(), records the elapsed duration into bothrow.DurationMsand the package-global histogram viahealthdisk.Record. - Every check site in
ReadyzHandlernow records into the histogram. db_ping + disk use thetimeReadyzCheckhelper directly. workers + channels + backup time the whole block manually because each can emit multiple rows — only the aggregate / first-emitted row carries DurationMs so the rollup stays meaningful without misleading per-row fairness signal. -
After every check has emitted its row, the handler snapshots the histogram once into
out.RecentCheckTimingsAND walks the check rows to apply the p99 escalation: when a check's recent p99 exceeds the threshold AND its histogram count is at least 10 (so a single slow boot probe can't trip the gate), the row'sokflips to false, the responsestatusflips to "not_ready", and the row'sdetailappendsrecent p99 Xms > threshold Yms (over last N probes). -
internal/handler/metrics.go(modified): - New
staple_readyz_check_duration_ms{check,pct}gauge family. One label is the check name (db_ping / workers / channels / disk / backup); the other is the percentile (50 / 95 / 99). Operators alert on e.g.staple_readyz_check_duration_ms{pct="99"} > 1000. -
Companion
staple_readyz_check_samples{check}gauge surfaces the histogram's sample count so alerts can require meaningful signal before firing — alerting on the duration gauge alone would treat a fresh boot's missing data as "all clear". -
docs/user/operators/monitoring.md(modified): - New "/readyz response-time histogram (v2.49.0.7)" section under the existing /readyz docs. Documents the new fields, the env knob, the Prometheus gauges, and the relationship between the histogram and the escalation.
Tests:
internal/handler/health_test.go(extended):TestReadyzCheckP99Threshold_DefaultAndOverride— table-driven env knob coverage (unset / explicit / garbage / out-of-range / negative) following the poolUtilizationThreshold pattern.TestReadyzHandler_DurationMsPopulated— after one probe the db_ping / workers / disk / backup rows must carry a non-negative DurationMs.TestReadyzHandler_RecentCheckTimingsPresent— the new RecentCheckTimings map surfaces every timed check with Count >= 1 and non-negative percentiles.TestReadyzHandler_P99EscalationFlipsStatus— seeding 20 × 5s entries into the db_ping histogram with the threshold set to 100ms flips the response Status to not_ready, the db_ping row's OK to false, and appends the p99 marker to the row's detail.TestReadyzHandler_P99BelowSampleMinNoEscalation— seeding only 5 entries (below the p99SampleMin = 10) leaves the row OK even when the seeded durations would otherwise trip the threshold.
Operator notes:
- The histogram is per-process, in-memory. A process
restart resets every ring, which surfaces as Count=0 on
the first scrape and silently grows as probes land.
Dashboards alerting on
staple_readyz_check_samples >= Ncan require meaningful signal before firing. - The wire shape is backwards-compatible: pre-v2.49.0.7
consumers reading the existing ReadinessCheck fields
(Name / OK / Detail / Ticks / Errors / ErrorRatePct / pool
stats / disk stats / backup stats) see no change.
DurationMs is a NEW field; older parsers ignore unknown
fields.
recent_check_timingsisomitemptyso the pre-first-probe response shape is unchanged.
No migrations. Locked-roadmap v2.49.0.7. Migration counter unchanged at 130.
v2.43.0.26 — 2026-05-30¶
feat(agents): subagent dashboard most-active children leaderboard
— extends the v2.43.0.25 per-parent rollup with a symmetric
"most-active CHILDREN" view at /admin/subagents/leaderboard. The
v2.43.0.25 page groups by PARENT agent so operators can answer
"which parent is generating most of this load?". The new leaderboard
groups by CHILD agent so the symmetric question — "which child
agents have been dispatched the most this week?" — has a one-click
answer.
Pre-v2.43.0.26 the only way to surface noisy children was to scroll
the chronological /admin/subagents feed and tally by eye, or to
deep-link into /admin/subagents/by-parent and inspect each parent's
children one at a time. Both are friction during cost-attribution
triage. v2.43.0.26 surfaces the answer directly.
Implementation:
internal/db/query/heartbeat_runs.go:- New
ChildAgentActivitystruct carrying AgentID, AgentName, CompanyID, ParentAgentID, ParentAgentName, SubagentDepth, RunCount, SuccessCount, FailedCount, TotalCostUSD, AvgDurationMs, LastRunAt — the per-row projection the leaderboard template renders. - New
GetTopActiveChildAgents(ctx, pool, windowDays, limit)helper. Single SQL: COUNT / FILTER / SUM / AVG / MAX over heartbeat_runs ⋈ agents (child) ⋈ agents (parent), GROUP BY child.id, ORDER BY run_count DESC then last_run_at DESC NULLS LAST, LIMIT $2. Same scope.WithBypass posture as the v2.43.0.25 by-parent helper — cross-tenant projection, instance-admin surface. defaultChildLeaderboardLimit = 20matches the v2.43.0.25 page size;maxChildLeaderboardLimit = 100caps the chip set.-
windowDays <= 0 rejected so a tampered URL can't surface every child that ever ran. limit <= 0 normalises to the default; limit > 100 clamps so the page can never blow out to thousands of rows.
-
internal/handler/ui_subagents_leaderboard.go(new): UISubagentsLeaderboard(pool)http.HandlerFunc gated onactor.IsInstanceAdmin() && actor.IsUser()— same actor posture as/admin/subagentsand/admin/subagents/by-parent. api_key callers bounce with 403 even when their InstanceRole is admin, because the underlying query is scope.WithBypass.- Window chips: 24h / 7d / 30d (default 7d). Same chip set as
/admin/subagents/by-parentso operators only learn one rule. - Row-cap chips: 10 / 20 / 50 / 100 (default 20). Anything off the chip set falls back to the default — a tampered URL can't surface a custom row count.
- Render-path failures surface as
slog.Warn+ the page renders with an empty list. The dashboard is informational; a transient DB blip shouldn't take it offline. -
Pre-computed view struct
childAgentActivityViewcarries the human-formatted spend (formatSpendUSD) + duration (humanShortDurationMs) so the template stays declarative. -
internal/web/templates/subagents/leaderboard.html(new): - Same column scheme as the v2.43.0.25 by-parent rollup with
additional Child agent / Parent / Depth columns up front. Each
child name links to
/admin/subagents?agent=<name>(drill into the chronological run timeline); each parent name links to/admin/subagents?parent=<uuid>so the operator can pivot between views without retyping a filter. - Single-decimal success-rate precision (
%.1f%%) matches the v2.43.0.25 by-parent rollup and the v2.43.0.22 stats tile — operators only learn one rule. -
Empty-state copy nudges operators to widen the window or check
/admin/subagentsfor the chronological row feed. -
internal/web/templates/subagents/index.html(modified): -
"Leaderboard →" button added next to the existing "Group by parent →" link in the
/admin/subagentspage header. -
internal/web/templates/subagents/by_parent.html(modified): -
Cross-link to
/admin/subagents/leaderboardadded in the header so the two pivot views reference each other. -
cmd/server/routes.go(modified): r.Get("/admin/subagents/leaderboard", handler.UISubagentsLeaderboard(pool))registered BEFORE the/admin/subagents/{run_id}wildcard (same constraint as/admin/subagents/by-parentand/admin/subagents/tree) so chi doesn't eat "leaderboard" as a run_id.
Tests:
internal/db/query/heartbeat_runs_test.go(extended):TestGetTopActiveChildAgents_OrderedByRunCount— seeds a fresh subagent with 5 known heartbeat_runs (3 completed / 1 failed / 1 cancelled), confirms the row surfaces in the leaderboard projection with RunCount=5 / SuccessCount=3 / FailedCount=1, and pins the ORDER BY invariant: every row above ours has run_count >= ours, every row below has run_count <= ours.TestGetTopActiveChildAgents_WindowZeroRejects— windowDays=0 and -1 both error.-
TestGetTopActiveChildAgents_LimitClamps— limit=0 caps at the default 20; limit=9999 caps at the clamp 100. -
internal/handler/ui_subagents_leaderboard_test.go(new): TestUISubagentsLeaderboard_NonAdminForbidden— nil actor / non-admin / admin api_key all 403.TestSubagentLeaderboardWindowSet_Resolution/TestSubagentLeaderboardWindowDays_Resolution/TestSubagentLeaderboardLimitSet_Resolution/TestSubagentLeaderboardLimitFrom_Resolution— pure-function tables for the chip resolvers.TestUISubagentsLeaderboard_HappyPath— full handler against the dev pool; asserts the page-level scaffolding markers (header, back-to-feed, group-by-parent cross-link, window chips, limit chips, table column headers OR empty-state copy).TestUISubagentsLeaderboard_WindowChipResolves—?window=24hsurfaces "last 24h" in the header.TestUISubagentsLeaderboard_LimitChipResolves—?limit=50swaps the default chip out of active badge state.TestUISubagentsLeaderboard_UnknownWindowFallsBack— garbage window+limit still renders 200 with default fallback.TestGetTopActiveChildAgents_ReachableFromHandler— smoke that the handler reaches the underlying query helper without panicking when the dev DB returns rows.
No migrations. Locked-roadmap v2.43.0.26. Migration counter unchanged at 130.
v2.50.0.21 — 2026-05-30¶
feat(ops): audit dashboard export reviewed-only CSV — extends the v2.50.0.16 bulk-export-by-tag flow with three per-status download buttons that ship every row tagged Reviewed / Action needed / Ignored as CSV. Operators end an incident shift wanting to hand off the worked-through subset to a spreadsheet or paste it into a post-incident doc, but the v2.50.0.16 bulk export only knew about the v2.50.0.15 manual tag column — not the v2.50.0.18 review-status buckets the operator was actually using during triage. v2.50.0.21 closes that gap.
Pre-v2.50.0.21 the Review status card (v2.50.0.18, extended in v2.50.0.19 / v2.50.0.20) surfaced live counts and a dropdown filter, but the only path off the dashboard was "screenshot the visible feed" or "manually tag everything and use the bulk-export-by-tag flow". Both are friction during the wind-down minutes of a page incident — the operator just spent the last hour clicking "Reviewed" on rows and wants a one-click CSV of exactly that bucket.
Implementation:
internal/web/templates/audit/index.html:- New "Export by status" card immediately under the count tile,
inside the v2.50.0.18 Review status section so it inherits the
same storage-probe-gated reveal (
data-staple-audit-review- filterparent is hidden until the inline IIFE confirms localStorage is accessible). - Three
<button>elements, one per bucket (data-staple-audit-review-export-go="reviewed"/"action_needed"/"ignored"). Each carries a live count badge<span data-staple-audit-review-export-count="<bucket>">that the existingrebuildCounts()flow now updates alongside the count tile, so the operator sees "Export reviewed (12)" reflect the exact ship count BEFORE clicking. - Per-button click handler shares the v2.50.0.16 URL composition
pattern verbatim: pull
window.location.search, drop any pre-existingrow_idsparam, setrow_ids=<id1>,<id2>...from the bucket's localStorage keys, navigate viawindow.location.assign("/admin/audit.csv?…"). The CSV endpoint already supports therow_idsparameter (added in v2.50.0.16) so NO server-side change is needed — the bucket export reuses the same "URL filter AND operator row_ids intersection" semantics. - Empty bucket short-circuits with a status hint
(
"No reviewed rows to export.") in the shareddata-staple-audit-review-export-statusspan instead of a silent click — same posture as the v2.50.0.16 bulk-export "No rows tagged" hint. -
rebuildCounts()now calls a newrebuildReviewExportCounts()that tallies the FULL persisted current-status map (not just visible rows) so the per-button counts reflect "everything you can export" rather than "what's currently in front of you" — matches the row_ids semantics, which ships every persisted ID regardless of current display. -
internal/handler/ui_audit_test.go(extended): TestUIAuditDashboard_ReviewExportScaffoldingpins the new section wrapper, the three per-status button attribute values, the per-status count placeholders, the status hint marker, the operator-facing labels, and the CSV endpoint + row_ids param the JS composes against. Same posture as the v2.50.0.16TestUIAuditDashboard_BulkExportScaffoldingtest.
Operator notes:
- Browser-local. The store is the same v2.50.0.20
staple_audit_review_historykey — operator-private, not synced across machines or shared with anyone else. - Counts refresh on every per-row dropdown change and every
bulk-bar Apply, because both flows already call
applyFilter()→rebuildCounts()→rebuildReviewExportCounts(). - localStorage unavailable (private mode, extension blocking) → the entire Review status card stays hidden, including the new export buttons. Same posture as v2.50.0.18 / v2.50.0.19.
No migrations. Locked-roadmap v2.50.0.21. Migration counter unchanged at 130.
v2.42.0.24 — 2026-05-30¶
feat(cli): chat REPL /replay command — adds a new local slash command that re-prints the last N messages with full markdown rendering, as if they had just arrived. Refreshes the operator's mental context in a long-running session without scrolling the terminal back.
Pre-v2.42.0.24 the only way to revisit recent activity was
/history N (v2.42.0.7), which prints compact "[N] role: X\n
/replay fills that gap.
CLI:
- New
/replay [N]slash command. N defaults to 5 (smaller than /history's 20 because the use case is different — operators re-read a few messages in full, not scan twenty headers), max 50 (caps how much terminal scrollback a single replay can push out). - Distinct from
/history(v2.42.0.7) by formatting:/historyprints[N] role: X\n<body>with compact spacing./replayprintsrole HH:MM:SS\n<body>with aReplaying last N message(s):banner and a blank line between entries.
- Header banner names the actual count returned, not the requested
limit — so a fresh chat with 2 messages and
/replay 5reads "Replaying last 2 messages:" instead of misleadingly "5". - Singular "message" form (no "s") when only one message is in
the chat. The
pluralShelper keeps the banner reading "Replaying last 1 message:" rather than "1 message(s):". - Empty chat falls through to the same "(no messages in this chat
yet)" line
/historyuses — operators don't have to learn two empty-state strings. - Added to
/help+ the chat usage block + theshouldRecordToHistoryexclusion set so/replay [N]never ends up in the persistent prompt history.
Implementation:
cmd/staple-cli/chat_slash.go:- New
chatReplayDefaultLimit(5) /chatReplayMaxLimit(50) constants — named so the help text and the parser share one source of truth. - New
parseReplayLimit(args)— mirrorsparseHistoryLimit's posture: empty args returns the default; bad args return an error; values above the cap clamp silently. - New
printReplay(ctx, out, baseURL, apiKey, chatID, limit)— reuseschatFetchMessages(already used by/historyand/stats), renders each message with full markdown viarenderChatPrompt. Timestamps are formatted asHH:MM:SSin the operator's local timezone viam.CreatedAt.Local(); a zero-valueCreatedAt(older fixtures) omits the column cleanly. - New
pluralS(n)helper for the banner's "message/messages" grammar. /helpblock extended with/replay [N]and a one-line explanation distinguishing it from/history.
- New
cmd/staple-cli/chat.go:- Dispatcher entry for
msg == "/replay" || HasPrefix(msg, "/replay ")immediately after the/historybranch — same pattern as every other arg-bearing slash command. shouldRecordToHistoryexclusion: exact-match/replayalready in the existing switch line; the prefix/replaybranch keeps/replay 12off the persistent history file.- Top-of-file usage block (the
staple-cli chatcommand's documentation) updated to include/replayin the "Local commands" list and a dedicated paragraph under "Local commands during the session" describing the distinction from/history.
- Dispatcher entry for
Tests:
cmd/staple-cli/chat_slash_test.go(extended):TestParseReplayLimit_Table— table-driven parser coverage: empty / explicit / clamped / whitespace / zero / negative / non-integer / space-prefix. MirrorsTestParseHistoryLimit_Tablerow-for-row.TestChatHelpText_ContainsReplayCommand— pins the help text contains/replay,default 5,max 50so a future change to the constants surfaces here too.TestShouldRecordToHistory_ExcludesReplay— pins the persistent-history filter for the bare/replayand arg-bearing/replay 1//replay 50//replay 999forms.TestChatLoop_ReplayCommand_DefaultLimit— httptest server, drives/replay\n/quit\nthroughchatLoopWithConfig, assertslimit=5propagates to the messages endpoint, the header banner names the actual returned count (3, not 5), and the[N] role:/history-style format is NOT present.TestChatLoop_ReplayCommand_ExplicitLimit— confirms/replay 2propagateslimit=2.TestChatLoop_ReplayCommand_EmptyChat— confirms the empty-chat line prints AND theReplaying last 0 messages:banner does NOT.TestChatLoop_ReplayCommand_BadArg— confirms bad args surface asError:lines and the server is never hit.TestChatLoop_ReplayCommand_SingularBanner— confirms theReplaying last 1 message:singular form (no "s").
No migrations.
v2.46.0.17 — 2026-05-30¶
feat(agents): GEPA proposal model breakdown — extends the per-
agent /agents/{id}/proposals page with a "By model" section between
the v2.46.0.14 acceptance-rate tile and the v2.46.0.15 weekly trend
chart. Operators tuning cost-vs-quality on the GEPA auto-evaluate
pipeline now have a one-glance answer to "which models produce the
most-accepted proposals?" without scanning the row table by hand.
Pre-v2.46.0.17 the cost tile told you the aggregate spend (v2.46.0.16) and the trend chart showed acceptance over time (v2.46.0.15), but neither answered "is anthropic:claude-3-5-sonnet worth its cost?" — the operator had to pull the proposal row list, group by model in their head, and divide the totals manually. The new breakdown surfaces that math as one table.
UI:
- New "By model (last 8 weeks)" card on
/agents/{id}/proposals, positioned between the v2.46.0.14 acceptance-rate tile and the v2.46.0.15 weekly trend chart. Columns:- Model (
<code>tag with the proposer model identifier; "(empty)" muted fallback for the unlikely blank-model row). - Total (raw count of proposals in the window).
- Accepted (with parenthesised acceptance pct color-banded green ≥70 / yellow 30–70 / red <30, matching the v2.46.0.14 and v2.46.0.15 thresholds so the operator learns ONE color rule across the page).
- Rejected (raw count).
- Total cost (
$X.XXXXaggregated across the model's LLM rows; "—" when ProposalsWithCost is zero so a heuristic- only model isn't mis-attributed as $0.00). - Avg cost (
$X.XXXXper LLM proposal; "—" same as total cost when there are no LLM rows).
- Model (
- Sorted by Total DESC at the query level so the heaviest contributor is first — cost-optimization decisions read top- down.
- Window matches the v2.46.0.15 trend chart (8 weeks) by construction. Operators reading the trend then scanning the model breakdown see the same scope; drift would surface as "wait, the trend says 14 but the model row says 23".
- Hidden when no proposals exist in the window or the query failed. Same best-effort fail-soft posture as the v2.46.0.14 / v2.46.0.15 / v2.46.0.16 widgets above.
Implementation:
internal/db/query/agent_prompt_proposals.go:- New
ModelProposalStatsstruct carrying the per-model counters and pre-computedAcceptancePct/AvgCostPerProposal. - New
GetAgentProposalsByModel(ctx, pool, agentID, weeks)query. Single SQL round-trip with conditional aggregates (COUNT FILTERper status;COUNT FILTER WHERE cost_usd IS NOT NULLfor ProposalsWithCost;COALESCE(SUM(cost_usd), 0)::float8for TotalCostUSD). - Window:
created_at >= now() - make_interval(days => $2)matching the existing interval shape ininternal/db/query/evolution_traces.go. The pattern stays consistent with other windowed projections. weeks <= 0falls back toproposalsByModelWindowWeeksDefault(8);weeks > 52is clamped — same posture asGetAgentAcceptanceTrend.AcceptancePctis computed in Go after the scan against (Accepted + Rejected) only — pending proposals are "undecided" and shouldn't drag the metric down. Same rule the v2.46.0.14 tile + v2.46.0.15 trend already use.AvgCostPerProposaldivides TotalCostUSD by ProposalsWithCost (NOT Total) so a model with 1 LLM row + 9 heuristic rows doesn't report ~1/10th the real per-call cost.- Scope:
scope.WithBypass— matches every other per-agent proposal aggregator (GetAgentProposalStats,GetAgentProposalCostSummary,GetAgentAcceptanceTrend). The caller is the auth boundary.
- New
internal/handler/ui_agent_proposals_list.go:- New
proposalsByModelWeeksconstant (8) intentionally matchesproposalsTrendWeeksso the breakdown reads against the same window as the trend chart above. UIAgentProposalsListcallsGetAgentProposalsByModel, stuffs the result intoModelBreakdown/ModelBreakdownWeekstemplate data keys. Best-effort failure suppresses the section rather than blocking the page render.
- New
internal/web/templates/agents/proposals_list.html: new "By model" card between the v2.46.0.14 tile and the v2.46.0.15 trend chart. Hidden whenModelBreakdownis nil / empty.
Tests:
internal/db/query/agent_prompt_proposals_test.go(extended):TestGetAgentProposalsByModel_AgentIDRequired— validation gate.TestGetAgentProposalsByModel_SeedsBreakdown— seeds 4 proposals across model A (2 accepted, 1 rejected, 1 pending) and 1 proposal across model B (1 accepted). Pins the per-row Total / Accepted / Rejected / Pending / AcceptancePct / TotalCostUSD / AvgCostPerProposal / ProposalsWithCost values, AND pins the Total DESC sort order so model A comes first.TestGetAgentProposalsByModel_EmptyAgent— non-nil empty slice on a brand-new agent with no proposals.TestGetAgentProposalsByModel_WindowClamping— passes weeks=0 / -1 / 999 and confirms no error (the helper clamps silently).
internal/handler/ui_agent_proposals_list_test.go(extended):TestUIAgentProposalsList_ModelBreakdown_v2_46_0_17— seeds two models with mixed decisions + costs; asserts the rendered HTML contains the "By model (last 8 weeks)" section header, both model identifiers in<code>tags, the column headers (Total / Accepted / Rejected / Total cost / Avg cost), the per-row costs ($0.0123 and $0.0234), and the 100% acceptance pct (model B is 1/1 = 100%).TestUIAgentProposalsList_ModelBreakdown_HiddenOnEmptyAgent— confirms the section is omitted when the agent has zero proposals.
No migrations.
v2.51.0.25 — 2026-05-30¶
feat(cli): staple-cli backup-import — adds a new one-shot
operator subcommand that walks the local backup directory, picks
files matching the retention worker's extension list, and inserts
one backup_runs row per file with kind='reconciled'. After
running, legacy dump files (written before v2.51.0.8 deployed, or
produced by out-of-band tooling) surface on /admin/audit and are
tracked by retention as normal.
Pre-v2.51.0.25 the only way to register a backlog of legacy files
was to wait for the v2.51.0.12 backup_reconcile worker tick, or
to hand-craft INSERT statements. Both options were operator-
unfriendly: the worker runs on a 6-hour cadence, and writing SQL
by hand against the audit table without dropping a typo into
created_at or local_size_bytes is error-prone. The new
backup-import command is the operator-facing equivalent of the
worker pass: same kind='reconciled' value, same header sniff,
same idempotency guard via local_path.
CLI:
- New
staple-cli backup-importsubcommand. Flags:--dir=<path>overrides$STAPLE_BACKUP_DIR; default/var/backups/staple(the retention worker's sweep target).--dry-run(default true) shows what would be imported without writing.--yesis required to actually writebackup_runsrows. Passing--yesimplicitly flips--dry-runoff unless the operator explicitly set both (in which case the command errors with "mutually exclusive" rather than silently preferring one).--quietsuppresses the per-file lines and emits the canonicalstaple-backup-import ok found=N imported=M tracked=K unreadable=Usummary. Composes with$STAPLE_QUIET=1as a fleet-wide default.
- Sub-directories under
--dirare skipped silently (top-level only, matching the retention worker). The extension list mirrorslistedExtensionsfrombackup-list(.dump.enc,.dump,.sql.gz,.sql). - Per-file decisions surface in the dry-run output:
will-insert: file is on disk, no matchingbackup_runsrow, a live run will INSERT.skip-tracked: an existingbackup_runsrow already references thislocal_path. Re-runs of the command land every file here.skip-unreadable: the file couldn't be opened for the header sniff (perm denied, racing delete). Reported and counted but not a fatal error — a single broken file doesn't block import of the other 49.
Implementation:
cmd/staple-cli/backup_import.go(new):runBackupImport— flag parsing, env/default resolution, DB pool open, dispatch to scan + classify + tally + execute. The--yes+ default--dry-run=truecombo is resolved with the same "explicit beats default" rule the rest of the backup family uses (flagWasExplicitlySet), so the operator who passes--yesalone gets the live path without an extra--dry-run=false.scanImportCandidates—os.ReadDirwalk, extension filter via the existinghasListedExtension, encryption sniff viasniffImportEncryption. Sub-directories and non-regular files dropped silently. Returns[]importCandidatewith blankDecision— classification happens in a second pass so the DB lookup can batch across the whole set.sniffImportEncryption— reads the first 17 bytes and compares againstbackup.BackupMagicV1Bytes. Distinguishes "couldn't open" (return"") from "read short" (return"plaintext"), unlike thebackup-listsniff which always coerces to"?". Empty return drives theimportSkipUnreadabledecision downstream.classifyImportCandidates— batches all candidate paths into a single SELECT againstbackup_runs.local_path = ANY($1). Avoids an N-query waterfall on a 50-file pile. The result map is used to flip each candidate'sDecision.executeImportInserts— reusesquery.RecordReconciledBackupRow(v2.51.0.12) for everywill-insertcandidate. Stops on first failure and returns the partial count so the caller can surface progress. Keeping the insert path convergent with the reconcile worker prevents drift in the audit dashboard's rendering of imported rows.tallyImportCandidates— pure function over the candidate slice, returning(willInsert, alreadyTracked, unreadable). Used by both the live and dry-run summary lines.selectTrackedLocalPathscarries the// rls:allow instance-wide audit readannotation matching the otherbackup_runsaccessors.backup_runshas no RLS — backups are instance-wide.
cmd/staple-cli/main.go: dispatcher entry + help block.docs/user/operators/backup-and-restore.md: new "Retroactively register legacy backups" section between drift detection and aggregate stats.
Tests:
cmd/staple-cli/backup_import_test.go(new):TestScanImportCandidates_PicksListedExtensions— seeded tmp dir with one plaintext dump, one encrypted dump, one non-matching.txt, one sub-directory. Asserts only the two matching files are picked up and theirEncryptionclass is correctly sniffed.TestScanImportCandidates_MissingDirectory/TestScanImportCandidates_PathIsNotDirectory/TestScanImportCandidates_EmptyDirectory— error and no-op edge cases.TestSniffImportEncryption_{Encrypted, Plaintext, ShortFile, Unreadable}— the four sniff outcomes. The Unreadable case is skipped on Windows and when running as root (chmod 000 doesn't actually block open in either environment).TestTallyImportCandidates— table-driven across empty, all-insert, mixed, all-unreadable, and the blank-Decision mid-classify state. Pure function; no DB / fs / process.
The classification and insert layers are exercised indirectly
through the existing TestRecordReconciledBackupRow in
internal/db/query/backup_runs_test.go — that suite already pins
the SQL shape; re-asserting it here would just duplicate coverage.
No migrations.
v2.43.0.25 — 2026-05-30¶
feat(agents): subagent dashboard parent-agent rollup — adds a
new instance-admin view at /admin/subagents/by-parent that
aggregates the same heartbeat_runs ⋈ agents data the
/admin/subagents index reads, GROUP BY child.parent_agent_id,
sorted by TotalRuns DESC. Operators triaging a dispatch-fan-out
incident can now answer "which parent agent is generating most of
this load?" without scanning the row feed by hand.
Pre-v2.43.0.25 the only way to see "parent X dispatched 42
children" was to filter the row feed by parent=<uuid> and count
the result manually. The new view surfaces the rollup in one
table: per-parent totals, success/failed/cancelled splits, success
rate %, average duration, total cost, and last-run timestamp.
UI:
- New GET
/admin/subagents/by-parentpage (instance-admin only). Renders a table with one row per parent agent active in the selected window:- Parent agent name (linked to
/admin/subagents?parent=<uuid>for the chronological drill-down — same parent= filter the index has supported since v2.43.0.6). - Truncated 8-char company id (matches the rhythm the row feed uses).
- Total / success / failed / cancelled counts.
- Success rate % (one decimal).
- Avg duration (human format —
humanShortDurationMsfrom the v2.43.0.22 stats tile). - Total cost (
formatSpendUSD— same$X.XXXXprecision the cost dashboard uses). - Last run timestamp (relative via the existing
relativeTimetemplate helper; raw UTC in thetitle=for hover).
- Parent agent name (linked to
- Window selector chips at the top of the page: 24h / 7d / 30d. Default 7d matches the audit-insights default + the GEPA acceptance-trend chart so operators only learn one "default window" rule across surfaces.
- "Group by parent →" link in the
/admin/subagentsheader so operators can jump from the chronological feed to the rollup without manually navigating to the new URL. - Empty-state copy points at the row feed when zero parents have dispatched anything inside the selected window. Widening the window is one click via the chips.
Implementation:
internal/db/query/heartbeat_runs.go:- New
ParentSubagentStatsstruct with the per-parent fields the dashboard renders. - New
AggregateSubagentRunsByParent(ctx, pool, windowDays)helper. Single SQL statement overheartbeat_runs ⋈ agents (child) ⋈ agents (parent), GROUP BYchild.parent_agent_id, ORDER BYtotal DESC, last_run_at DESC NULLS LAST. Window expressed in days because the chip set maps cleanly to day counts and the SQL stays clean. Zero / negativewindowDaysrejected with an error — silently treating those as "all time" would surface every parent that ever spawned a subagent on a long-lived instance. - Company id read from
parent.company_id(one parent → one company by definition) rather than aggregating across the child runs. Postgres has noMAX(uuid)aggregate; the parent's column is also semantically the right answer. scope.WithBypassposture matches the existing cross-tenant projections on the same page.
- New
internal/handler/ui_subagents_by_parent.go(new):UISubagentsByParent(pool)— instance-admin gate, window chip resolution, projection ofParentSubagentStatsinto a template-ready view shape (pre-computedAvgDurationHuman,TotalCostHuman,SuccessRatePct).subagentsByParentWindowSet/subagentsByParentWindowDaysare pure functions so the test pins every chip resolution branch without driving the handler.- Render-path failures (DB error) log Warn and proceed with an empty list — same fail-soft posture the v2.43.0.22 stats tile uses.
internal/web/templates/subagents/by_parent.html(new): table- chip selector + empty-state copy.
internal/web/templates/subagents/index.html: header link "Group by parent →".cmd/server/routes.go:GET /admin/subagents/by-parentregistered BEFORE the/admin/subagents/{run_id}wildcard so chi doesn't eat the literal as a run id (same ordering the v2.43.0.16/admin/subagents/treeroute requires).
Tests:
internal/db/query/heartbeat_runs_test.go(extended):TestAggregateSubagentRunsByParent_MatchesSeededRowsseeds 4 runs across known statuses (pending / completed / failed / cancelled) and asserts the parent's rollup row reflects the seeded deltas. Uses the dev DB +SpawnSubagentso the FK chain is real.TestAggregateSubagentRunsByParent_WindowZeroRejectspins thewindowDays > 0guard.internal/handler/ui_subagents_by_parent_test.go(new):TestUISubagentsByParent_NonAdminForbiddenmirrors the gating matrix the other instance-admin handlers use (nil actor / non-admin user / admin api_key all 403).TestSubagentsByParentWindowSet_Resolution/TestSubagentsByParentWindowDays_Resolutiontable-walk the chip resolver.TestUISubagentsByParent_HappyPath/TestUISubagentsByParent_WindowChipResolvesdrive the full handler through the dev pool and pin the scaffolding markers (window chips, table headers, back-to-feed link, empty-state copy when applicable).TestUISubagentsByParent_QueryFailureRendersEmptyTestAggregateSubagentRunsByParent_ReachableFromHandlercover the fail-soft branch and the query-call integration.
Operator notes:
- The view shares the row feed's posture: read-only, no actions. Operators who need to cancel a run still click into the row detail page via the chronological feed.
- The "Last run" column reads
MAX(COALESCE(started_at, created_at))so a parent whose children are all still pending shows the enqueue timestamp rather than zero — same fallback the row feed'srowTimeKeyhelper applies. MAX(uuid)is not a Postgres aggregate, so the rollup pulls company_id from the parent agent itself rather than aggregating across children. This is also semantically right — agents inherit company_id from their parent at spawn, so every child of a given parent shares the parent's company.
Locked-roadmap v2.43.0.25. Migration counter unchanged at 130.
No new RLS bypasses or scope changes — the new query helper uses
the existing scope.WithBypass already established for the
cross-tenant subagent projections.
v2.50.0.20 — 2026-05-30¶
feat(ops): audit dashboard row review history — extends the v2.50.0.18 / v2.50.0.19 browser-local review-status work with a per-row transition log. Operators want to see "this was first reviewed by me on X, then marked action-needed on Y, then reviewed again on Z" without losing earlier transitions when the status changes. v2.50.0.20 records every transition under a new localStorage key and exposes them via an inline history list per row.
Pre-v2.50.0.20 the staple_audit_review_status map stored only
the CURRENT status per row — every change replaced the prior
value. There was no way to answer "did I already review this last
week?" or "when did I flip this from reviewed to action_needed?"
because the prior states were lost on every write. v2.50.0.20
upgrades the store to a history-array shape, runs a one-shot
migration so in-flight operator state survives, and surfaces the
history in-line on each row.
UI:
- Storage schema upgraded. The new
localStorage["staple_audit_review_history"]is keyed by the same"<source>:<id>"row key the v2.50.0.18 store used; the value is an array of{status, at}records, one per transition. Status values are the same three buckets the dropdown uses (reviewed,action_needed,ignored), plus the empty string for the explicit "Clear status" transition. - One-shot migration. On first v2.50.0.20 load, if
staple_audit_review_historyis empty AND the legacystaple_audit_review_statushas entries, each(rowID → status)becomes a single-element history[{status, at: now}]. The legacy key is then deleted so the two stores can't drift. Re- running the migration on subsequent loads is a no-op (the legacy key is gone), so a paranoid operator can refresh without side effects. - Per-row inline "ⓘ history" toggle. The Status cell now carries
a small button (inline-style
display:noneinitially) the inline script reveals when the row has 2+ persisted transitions. Clicking expands an inline list below the dropdown showing each prior transition: status label (or "(cleared)" for the empty-string entry) and the operator's locale-formatted timestamp. The button togglesaria-expandedso a screen reader announces the state change. - Bulk action keeps its existing UX — the action bar still fires one click per batch — but the storage write changes from "replace each row's status" to "append a new history entry for each row". A single bulk-apply shares one ISO timestamp across every affected row so the history list shows the same wall-clock instant for the whole batch.
- Filter, count tile, and row tint are unchanged. They derive
from the LAST entry's status via a new
currentStatusFromHistoryaccessor, so the existing "unreviewed / reviewed / action_needed / ignored" bucket filter and the "X reviewed / X action needed / ..." count tile work without rewrites. - Per-row history capped at 32 entries (tail-kept). A long-running incident shouldn't grow unbounded JSON; an operator who legitimately needs more transitions can hand-edit the file.
Implementation:
internal/web/templates/audit/index.html:- New
HISTORY_KEY+HISTORY_MAX_ENTRIES_PER_ROWconstants at the top of the review-status IIFE. readHistories/writeHistoriesreplacereadStatuses/writeStatusesas the canonical persistence pair;sanitizeHistoriesandmigrateLegacyStatuseskeep the shape pure.readStatusesis preserved as a derived accessor so the existing bulk-apply + per-row hydration loops keep working without a rewrite — every consumer that reads the "current status map" now derives from the history tail.appendHistoryEntry(rowID, status, at)is the single mutating write path: per-row change handler and bulk-apply both go through it.refreshRowHistoryUI(row, rowID)renders the inline list + controls visibility of the toggle.wireHistoryTogglesbinds the open/close handler.- The Status cell template gains the toggle button and the
collapsed-by-default list
<div>so the inline script has stable DOM hooks. Both elements ship withstyle="display:none"so non-JS clients see nothing. - The script's storage probe stays unchanged — when localStorage is unavailable, the new surfaces stay hidden alongside the existing filter card.
- New
Tests:
internal/handler/ui_audit_test.go:TestUIAuditDashboard_ReviewStatusScaffoldingupdated to acknowledge the legacy key still appears in the rendered body (the migration block reads it once before deleting).- New
TestUIAuditDashboard_ReviewHistoryScaffoldingpins the v2.50.0.20 markers: the new localStorage key name, continued presence of the legacy key string for migration, and the per-row toggle + list scaffolding when rows are rendered.
Operator notes:
- The migration is one-way. If the operator downgrades back to a
pre-v2.50.0.20 build, the dashboard will re-create the legacy
staple_audit_review_statusmap on the next interaction (the old IIFE writes there); the history under the new key remains but is invisible to the older code. Subsequent re-upgrade to v2.50.0.20+ will see the legacy key, migrate any newly-recorded rows in, then delete the legacy key again. - Empty-status entries (the "Clear status" transition) are recorded, not deleted. This matters because the history view should be able to render "reviewed → cleared → re-reviewed" without losing the earlier transitions. The count tile and filter still treat a row whose tail is empty-status as "untouched", matching the v2.50.0.18 semantics.
- The 32-entry cap is tail-kept: if a row exceeds the limit, the oldest entries are dropped first. A real incident-triage flow is unlikely to hit this cap; the limit exists primarily to bound the JSON blob size against accidental script-driven abuse.
Locked-roadmap v2.50.0.20. Migration counter unchanged at 130. No new RLS bypasses or scope changes.
v2.42.0.23 — 2026-05-30¶
feat(cli): chat REPL bookmark commands — operators running multi-day debug sessions have repeatedly asked for a way to come back to "that chat from last Tuesday" without bouncing off the audit dashboard to copy a chat ID. v2.42.0.23 brings that into the REPL via three new slash commands.
Pre-v2.42.0.23 the only way to "save" a chat for later was to keep the REPL open or copy the chat ID into a scratch note. Reconnecting required pasting the ID into a curl, then re-opening the REPL pointed at the agent — which would itself spawn a fresh chat instead of resuming the saved one. The new commands close that gap end-to-end as pure local file I/O; the server never sees a bookmark write.
CLI:
- New
/bookmark <name> [--force]slash command. Saves the current chat session under the operator-supplied name. The bookmark storeschat_id,agent_id, the chat target's friendlyagent_name, the bearer'scompany_id, the serverbase_url, pluscreated_at/last_used_attimestamps. Re-saving the same name without--forceis refused so the operator does not silently lose the older save. A forced overwrite preserves the originalcreated_at(so the operator can still see "first saved on X") and refreshes everything else. - New
/bookmarksslash command. Lists every saved bookmark in a fixed-column block sorted bylast_used_atDESC (newest first). The currently-active chat row is marked with a▸glyph on interactive TTYs and a*on dumb terminals — same marker rhythm/agentsuses. Empty bookmarks file prints a hint pointing at/bookmark <name>so first-time operators see what to do next. - New
/resume <name>slash command. Re-points the REPL's in-memorychatID+targetAgentID+sessionStartedAtto the saved values; bumps the bookmark'slast_used_atso the next/bookmarkslisting shows it at the top. The next user turn POSTs to the restored chat, so a deleted-row failure surfaces as a normal send error. /helpupdated.shouldRecordToHistoryextended to exclude the three new exact-match forms and their argument-bearing prefixes so up-arrow recall stays clean of bookmark management.
Bookmark file resolution (mirrors STAPLE_CHAT_HISTORY):
STAPLE_CHAT_BOOKMARKS_FILEif set (tests use this).- else
$XDG_STATE_HOME/staple/chat_bookmarks.jsonif XDG is set. - else
$HOME/.staple/chat_bookmarks.json. - If none of the above resolve, all three commands print an actionable error rather than silently black-holing saves.
File shape: JSON array of records, parent directory at 0o700,
file at 0o600 (chat IDs aren't strictly secret but the names
operators pick can be — incident-<customer> style). Atomic
write via temp + rename so a crash mid-save doesn't leave a
truncated file. The same atomicWriteFile + ensureHistoryDir
helpers the chat history file uses provide the durability story.
Implementation:
- New
cmd/staple-cli/chat_bookmarks.go:chatBookmarkstruct with snake_case JSON keys so a hand-edit by the operator sees familiar field names.chatBookmarksPathresolver, separable from the chat history resolver (different env var, different filename) so operators can relocate one without touching the other.loadChatBookmarks/saveChatBookmarksround-trip helpers. Missing file is the first-session case (returns empty slice). Corrupt file surfaces an error rather than silently truncating — operator decides whether to repair.saveBookmarkAt(path, name, ..., force, now)with injectablenowso tests can pin timestamps; preservescreated_atacross--forceoverwrites.resumeBookmarkAt(path, name, now)bumpslast_used_atand returns the saved row.parseBookmarkSaveArgsargv parser handles<name>,<name> --force,--force <name>, rejects bare--force/ two-name / empty forms with usage errors.printBookmarkSave/printBookmarkList/handleResumeCommandare the dispatcher entry points.renderBookmarkListis the pure formatter so the listing column layout is unit-tested against a fixed-time fixture.
cmd/staple-cli/chat.go:chatLoopConfig.bookmarksPathadded;runChatresolves it viachatBookmarksPathat session start.- Dispatcher gains the three new branches:
/bookmarksin the exact-match switch,/bookmark//resumeas prefix matches alongside/switch//find. Successful/resumerefreshessessionStartedAtso/whoamireflects the restored session. - The dispatcher uses
chatResolveTargetWithNameto look up the chat target's agent name for the bookmark listing label, falling back to the bearer name when the target IS the bearer (the common case afterchat me). - In-REPL
/helpblock extended with the three commands and the bookmark-file resolution policy.
cmd/staple-cli/chat_slash.go:chatHelpTextextended.
Tests:
cmd/staple-cli/chat_bookmarks_test.go(new):TestChatBookmarksPath_*(env / XDG / HOME / disabled);TestParseBookmarkSaveArgs_Tabletable-driven argv cases;TestChatBookmarks_RoundTripasserts JSON keys + mode bits + field round-trip;TestChatBookmarks_SaveConflictexercises the refusal +--forcepath and confirmsCreatedAtis preserved;TestChatBookmarks_ResumeMissing/TestChatBookmarks_ResumeBumpsLastUsedcover the two resume paths;TestChatBookmarks_MissingFileLoadsEmpty/TestChatBookmarks_CorruptFileSurfaces/TestChatBookmarks_DisabledPathErrorscover the file-I/O edge cases;TestListBookmarksSorted_OrderedByLastUsedpins the sort policy;TestRenderBookmarkList_MarksActivepins the column layout + active-marker behaviour;TestChatLoop_BookmarkCommand_SaveAndList/TestChatLoop_ResumeCommand_SwapsState/TestChatLoop_ResumeCommand_MissingBookmarkdrive the full dispatcher throughchatLoopWithConfigwithstrings.NewReaderfixtures, asserting the persisted file shape + the swapped REPL state + the inline-error path.TestChatHelpText_ContainsBookmarkCommands+TestShouldRecordToHistory_ExcludesBookmarkCommandspin the /help + history-exclusion policy.
Operator notes:
/resumedoes NOT touch the server. If the saved chat row has been deleted server-side, the failure surfaces on the next user turn as a normal send error — same posture as any stale chat ID. Re-bookmarking a fresh chat is the recovery path.- The bookmark file is per-host. Sharing a bookmark across
laptops means copying the JSON file by hand. A future remote
sync surface could store these in
agent_notesbut is out of scope for v2.42.0.23. - The active-chat marker on
/bookmarksmatches bychat_id, so right after a/reset(which opens a fresh chat) no row is marked even if the previous session'schat_idis saved. This is intentional — the marker answers "where am I right now?", and after/resetthe answer is "in a new chat".
Locked-roadmap v2.42.0.23. Migration counter unchanged at 130. No new RLS bypasses or scope changes.
v2.46.0.16 — 2026-05-30¶
feat(agents): GEPA proposal generation cost column — every
GEPA proposal the LLM-driven proposer produces costs real tokens
(prompt + completion against the company's configured provider).
Pre-v2.46.0.16 that spend was invisible — operators reading the
per-agent proposal list at /agents/{id}/proposals could see "12
proposals last week" but had no way to answer "...and how much did
that cost me?". v2.46.0.16 surfaces per-row cost + a running total
tile.
UI:
- New Cost column in the proposal-history table at
/agents/{agentId}/proposals. Pre-render: each LLM-generated row shows$0.0123with atitle=tooltip carrying the underlyingtokens_in + tokens_outestimate; heuristic rows (no LLM call) and historical pre-migration rows show a muted "—" so operators can distinguish "we know it was free" from "we don't know what it cost". - New "Proposal generation cost:" summary tile above the
table:
Total: $X.XXacross N LLM proposals.- "(K heuristic, no cost recorded)" annotation when the agent has heuristic-only rows.
- Cumulative
prompt + completion tokensfooter line. - Distinct empty states:
- No proposals yet → "No proposals to price yet."
- Heuristic-only → "No LLM-generated proposals — all N rows came from the heuristic fallback (no cost recorded)." (rather than a misleading $0.00).
Migration:
- 130 (
130_agent_prompt_proposals_cost.sql) adds three nullable columns toagent_prompt_proposals:tokens_in INTEGERtokens_out INTEGERcost_usd NUMERIC(12, 6)All three nullable so existing rows degrade cleanly. Column comments record the v2.46.0.16 contract. No new indices (the existing pending-row index covers all read paths).
Implementation:
internal/db/query/agent_prompt_proposals.go:- Extended
AgentPromptProposalstruct +InsertAgentPromptProposalParamswith the three optional pointer fields. - Every SELECT (
ListPendingProposals,GetProposalByID,AcceptProposalload,ListAllProposalsForAgent,ListAllPendingAgentPromptProposals) now returns the new columns; INSERT writes them;scanProposalreads them. - New
GetAgentProposalCostSummary(ctx, pool, agentID)aggregator: single SQL round-trip withCOUNT(*) FILTER (WHERE cost_usd IS NOT NULL)to separate ProposalsWithCost from TotalProposals +COALESCE(SUM(...), 0)for the totals. Runs underscope.WithBypass, matchingGetAgentProposalStats's posture (the caller is the instance-admin-gated handler).
- Extended
internal/worker/gepa_evaluator.go:- New
pricing engine.PricingTablefield onGEPAEvaluator, loaded once at construction viaengine.LoadPricing()so the cost recorded on each proposal row reflects the pricing table at that moment — not at every dashboard render. proposeWithLLMnow returns*llmProposalResultcarrying Body + Model + best-effortTokensIn/TokensOut/CostUSD. Tokens estimated viaengine.EstimateTokens(samelen/4heuristic the context-compression worker uses); cost viaengine.EstimateCost(falls back to a flat per-1K rate when the model isn't in the pricing table, so a brand-new model still gets a non-NULL cost).- Heuristic-fallback path leaves all three nil so the dashboard's "—" branch fires.
- New
internal/handler/ui_agent_proposals_list.go:- New
CostSummarydata field on the rendered view, populated fromquery.GetAgentProposalCostSummary. Best-effort: a query failure leaves CostSummary nil and the template hides the tile.
- New
internal/web/templates/agents/proposals_list.html:- New cost summary tile (between the trend chart and the row table) with three branches: empty / heuristic-only / with-cost.
- New "Cost" column header + per-row cell with
derefFloat/derefInttemplate helpers to dereference the pointer fields insideprintf.
Tests:
internal/db/query/agent_prompt_proposals_test.go:TestInsertProposal_WithCost_RoundTrip— insert + list with cost populated, assert values survive the SELECT/SCAN.TestInsertProposal_NoCost_NullsRoundTrip— heuristic-only insert path leaves NULL columns; pointers come back nil.TestGetAgentProposalCostSummary_MixedRows— 2 LLM + 1 heuristic seed, assert TotalProposals=3, ProposalsWithCost=2, and the SUMs match.TestGetAgentProposalCostSummary_NoRows— empty agent returns zero-valued summary cleanly.TestGetAgentProposalCostSummary_AgentIDRequired— input validation.
internal/handler/ui_agent_proposals_list_test.go:TestUIAgentProposalsList_CostColumn_v2_46_0_16— seed mixed rows, render, assert the tile + the per-row cost values + the column header + the heuristic-row "—" empty marker.TestUIAgentProposalsList_CostTile_HeuristicOnly— confirms the "No LLM-generated proposals" copy fires rather than a misleading$0.00.
No new pricing-table changes — uses the existing
internal/engine/pricing.go::LoadPricing + EstimateCost
already in use by the chat path.
v2.47.0.23 — 2026-05-30¶
feat(ops): Operations panel worker tick trigger — the
v2.47.0.19 per-worker drill-down at
/admin/operations/workers/{name} surfaced tick counters, last
action, recent errors, and "next expected at". But operators
verifying a config change (e.g. flipping an instance flag, deploying
a worker patch, debugging a stale read) had no way to ask "run a
sweep RIGHT NOW so I can see the result" — they had to wait out
the interval, which is hours for the retention / verify workers.
v2.47.0.23 adds a Tick now button to each worker's drill-down
that signals the worker to run an immediate sweep.
UI:
- New "Tick now" form button in the Summary card of
/admin/operations/workers/{name}. POSTs to/admin/operations/workers/{name}/tick. data-staple-confirmmodal warns "Force{name}to tick now? Useful for verifying recent config changes." — same dialog pattern as the v2.51.0.15 Run-backup-now button so an accidental click can't trigger an expensive sweep.- New Force ticks counter row in the Summary card showing cumulative button clicks (coalesced or not). Multiple admins see each other's activity at a glance.
- After the POST, the drill-down renders a transient flash banner: green "signalled" when the channel slot was empty, neutral "coalesced" when a prior request was still pending. The handler whitelists the flash value to two known strings so a forged query string can never smuggle attacker-controlled text into the page.
Implementation:
- New
internal/worker/metrics.go::TickStats.ForceTick()method bumps a cumulativeForceTickRequestedatomic counter and performs a non-blocking send on a buffered-1forceTickChchannel. Two near-simultaneous clicks coalesce — the worker still runs exactly one bonus sweep — but the counter records both. - New
TickStats.ForceTickC()returns the receive-only channel workerStartloops select on alongsideticker.C. - New
safeForceTickC(s *TickStats) <-chan struct{}helper returns nil whens == nil, exploiting Go's "select on nil channel permanently blocks" semantic so workers with optional telemetry don't need explicit nil checks in their loops. - Every one of the 13 ticker-based workers (
backup_retention,backup_verify_cron,backup_reconcile,backup_cron,backup_health,cost_budget_alerter,heartbeat_run_retention,context_compression,note_embedding,mcp_sync,pairing_cleanup,notes_retention,gepa_evaluator) grew acase <-safeForceTickC(w.stats):branch that calls the samerunOnce(ctx)the ticker branch does. Behaviour is identical to a natural tick — same telemetry path, same audit rows, same error handling. - New
internal/handler/ui_worker_force_tick.go::UIOperationsWorkerForceTickhandler at POST/admin/operations/workers/{name}/tick. Instance-admin gated; 404 on unknown worker (matchesUIWorkerDetailfor symmetry); 303 redirect back to/admin/operations/workers/{name}?tick_flash=...so the drill-down can render the confirmation banner. Each click is logged atslog.Infowith the worker name, actor user ID, and whether the signal landed or coalesced. cmd/server/routes.gowires the new POST route inside the same instance-admin gate block as the existing GET drill-down route.internal/handler/ui_worker_detail.goextendsworkerDetailViewwithForceTickRequestedandTickFlashKindfields and reads the latter from?tick_flash=with a strict whitelist.
Tests:
internal/worker/metrics_test.go(extended):TestTickStats_ForceTick_SignalsAndCoalescespins the land / coalesce / drain / land sequence on the channel.TestTickStats_ForceTickC_ReceiveOrderconfirms select semantics on the returned channel.TestSafeForceTickC_NilSafecovers the nil-safe helper.TestTickStats_ForceTick_NoRaceWithRecordTickruns the new atomic + channel writes concurrently with the existing counter paths under-race.
internal/handler/ui_worker_detail_test.go(extended):- Three force-tick handler tests (403 / 404 / 400) mirror the existing GET-detail gate tests.
TestUIOperationsWorkerForceTick_HappyPathregisters aTickStats, fires the handler, assertsForceTickRequestedbumped + the channel slot has a pending signal + the redirect target is the drill-down page withtick_flash=signalled.TestUIOperationsWorkerForceTick_Coalescedprimes the channel slot, fires the handler, asserts the redirect carriestick_flash=coalesced.TestUIWorkerDetail_RendersTickNowFormasserts the v2.47.0.19 drill-down template now renders the Tick-now form, the cumulative counter row, and the data-staple-confirm attribute.TestUIWorkerDetail_FlashBannerSignalledandTestUIWorkerDetail_FlashBannerWhitelistexercise the flash path and confirm a forged value (e.g. an XSS payload) renders no banner at all.
Docs:
docs/user/operators/monitoring.mdgets a "Force tick now (v2.47.0.23)" subsection under the existing v2.47.0.19 drill-down section.
No DB migrations. No schema churn. Pure in-process channel signal + new POST route.
v2.51.0.24 — 2026-05-30¶
feat(cli): backup CLI date range filters — staple-cli
backup-list (v2.51.0.7) and staple-cli backup-stats (v2.51.0.19)
both grew flag dialects for narrowing output, but neither could
scope to a specific time window. Operators investigating "what
backups happened during the May 28 incident?" had to eyeball the
AGE column or pipe through jq '.[] | select(.mtime > ...)'.
v2.51.0.24 adds --since=DATE and --until=DATE to both
subcommands.
CLI:
- Both flags accept
YYYY-MM-DD(treated as UTC midnight on the--sinceside, end-of-day inclusive on--untilso an operator passing--until=2026-05-28gets every row from May 28 itself) and RFC3339 (2026-05-28T14:00:00Z) for sub-day windows. backup-listapplies the filter to local mtime AND S3 LastModified; runs after the directory walker and S3 listing, before the retention annotation pass. Composes with--s3,--json, and--retention-days.backup-statsapplies the filter tobackup_runs.created_at. When set, the explicit range overrides--days— operators who passed a range want exactly that, not the intersection of trailing-N and the range. The range is validated before the DB pool opens so a typo fails fast.- Malformed dates surface a clear error including the operator's
literal input + the flag name (
--since="yesterday": expected YYYY-MM-DD ...). Inverted bounds (--untilbefore--since) reject at parse time.
Implementation:
- New
cmd/staple-cli/backup_date_filter.gohouses the sharedparseBackupDateRange+backupDateRange.Containshelpers so both subcommands speak the same flag dialect. backup_list.goruns the newfilterEntriesByDatepass against local and S3 row slices.backup_stats.gofactors the aggregate intoaggregateBackupStatsWindow(runs, window, dateRange, now); the existingaggregateBackupStats(runs, window, now)becomes a trivial wrapper so test fixtures and the existing dispatch path stay byte-identical.WindowDaysis derived from the explicit range when both bounds are set; half-bounded ranges keep the trailing-window day count for the summary header (no honest day count for "from May 28 to now").
Tests:
cmd/staple-cli/backup_date_filter_test.go(new) covers the pure-function helpers: every accepted format, end-of-day inclusivity for--until, inverted-range rejection, HasFilter / Contains semantics for half-bounded ranges, slice filter on a four-day fixture.cmd/staple-cli/backup_list_test.go(extended) seeds a dated backup directory (May 26/27/28/30) and asserts--since,--until, the combined window, malformed dates, inverted ranges, and JSON output all respect the filter.cmd/staple-cli/backup_stats_test.go(extended) covers the newaggregateBackupStatsWindowbranch: tight window overrides trailing window, empty range falls back to trailing behavior, half-bounded since-only keeps the trailing-window day count. Dispatcher tests confirm malformed dates and inverted bounds reject before the DB pool opens.
Docs:
docs/user/operators/backup-and-restore.mdadds a "Date range filter (v2.51.0.24)" subsection to thebackup-listsection and an absolute-window paragraph to thebackup-statssection.
No DB migrations. No schema churn. Pure-function date math + flag wiring; the underlying queries are unchanged.
v2.50.0.19 — 2026-05-30¶
feat(ops): audit dashboard bulk mark-reviewed — v2.50.0.18
shipped a per-row Review-status dropdown so operators could
pace through an audit feed marking each row reviewed /
action needed / ignored. The per-row UX is fine for surgical
triage but slow when an operator wants to flip an entire visible
block at once — for instance after filtering the per-actor
timeline down to "every event from this compromised API key in
the last 4 hours" they shouldn't have to drag the dropdown down
each of fifty rows.
v2.50.0.19 layers a bulk action bar over the v2.50.0.18 status column.
UI:
- New leading checkbox column at the left of every audit row. Per-row boxes feed into the action bar; a header checkbox toggles every currently-visible row in one click. "Visible" honours both the v2.50.0.15 tag filter and the v2.50.0.18 review-status bucket filter — bulk-action only ever spans what the operator can actually see.
- New action bar above the table with:
Mark reviewed/Mark action needed/Mark ignored/— Clear statusdropdown.Applybutton — flips every checked row's status in one localStorage write.X selectedcounter that updates as boxes are ticked and after select-all toggles.
- After Apply, the per-row dropdowns and row tints update
in-place; the count tile and filter rebuild so the page
stays internally consistent. Per-row boxes stay ticked so
the operator can chain a second action (e.g. mark a block
as
action needed, then tag them all withincident-123via the v2.50.0.15 tag editor). The header checkbox clears after each Apply.
Implementation:
- Pure template / JS change in
internal/web/templates/audit/index.html. The script lives inside the existing v2.50.0.18 review-status IIFE so it shares thereadStatuses/writeStatuses/applyTints/applyFilter/rebuildCountshelpers — the single-row path and the bulk path drive off identical primitives so they can't diverge. - Bulk action bar carries
style="display:none"on the server-rendered HTML; the inline script reveals it only after the localStorage probe succeeds. Same gating posture as the v2.50.0.18 filter card — bulk actions that don't persist would mislead the operator. - Action bar + per-row checkboxes only render when the feed
has items (
{{if .Items}}gate). An empty feed shows neither.
Tests:
internal/handler/ui_audit_test.go:TestUIAuditDashboard_BulkReviewActionBar(new) asserts the rendered HTML contains every bulk-scaffolding marker when the feed has rows: action bar + dropdown + Apply + per-row checkbox cell + select-all + count span. Empty-feed branch logs rather than fails so the test stays decoupled from the dev DB seed state.TestUIAuditDashboard_ReviewStatusScaffolding(existing, v2.50.0.18) continues to pass — the bulk additions don't disturb the single-row UI.
Docs:
docs/user/operators/audit-dashboard.mdgets a "Bulk mark-reviewed (v2.50.0.19)" section with the new workflow.
No DB migrations. No schema churn. Browser-local only.
v2.47.0.22 — 2026-05-30¶
feat(ops): Operations panel alert history widget — many
background workers emit DM alerts (cost budget threshold crossings,
backup health status transitions, GEPA auto-promote notifications,
channel staleness). Until now those alerts only lived in the Slack /
Telegram DM channels they fanned out to, plus the structured slog
lines. Operators wanting to spot patterns ("did we have a cluster
of backup_health flips this week?", "which company crossed budget
warning most often?") had to grep individual worker journals.
v2.47.0.22 surfaces a unified in-process "Recent alerts" tile at
the top of /admin/operations so the last 10 alerts emitted by
any worker land in one scannable card.
UI:
- New
Recent alertscard on/admin/operations, rendered above theBackground workerstable. Empty ring renders an affirmative banner:No alerts in the last 24h ✓. A non-empty ring renders a four-column table: relative-age, severity badge, source code (<code>cost_budget_alerter</code>), and truncated message body with the full body accessible via the row's HTML title attribute. - Severity coloring matches the rest of the panel's badge
vocabulary so the operator's visual model stays consistent:
critical → red (
badge-failed), warning → yellow (badge-pending), info → blue/green (badge-active). - Footer copy reminds the operator that the ring is in-process —
restart loses history. For durable records the link points at
/admin/auditand worker journals.
Implementation:
- New
internal/alertspackage with a mutex-protected ring buffer of fixed capacity (100 entries — ~25 KiB resident footprint). Exposes three calls:Record(Alert)for emitters,Recent(n)for readers (returns a defensive copy in newest-first order), andResetForTest()for test isolation. Empty ring +Recent(N)returns nil so the template renders the empty state cleanly. - Three wired emission sites in
internal/worker:cost_budget_alerter.dispatch()records on every threshold crossing (warning / critical). The ring write fires BEFORE DM attempts because the tile reflects "what the worker decided to alert about", not "what was successfully DM'd".backup_health.dispatchTransition()records on every composite-status transition (OK→WARN, WARN→CRIT, CRIT→OK, etc.). Same posture — Record fires regardless of DM channel configuration.gepa_notifier.NotifyAutoPromote()records the auto-promotion event as informational. Severity is Info because auto-promote is success-signal, not failure.
- The
channels/healthregistry has no DM-emission point (it's a pure registry of channel adapters consulted by/readyz), so no integration there. - Severity → badge-class mapping lives in
handler.alertSeverityBadgeClass— the template stays stupid; the resolver picks the class.
Tests:
internal/alerts/ring_test.go(new): round-trip with default stamping, value preservation, newest-first ordering, empty ring → nil, n=0 → nil, n clamps, capacity overflow eviction, parallel writer race safety (go test -race).internal/handler/ui_operations_test.go(extended): TestAlertSeverityBadgeClass (table-driven mapping), TestResolveAlertsTile_EmptyRing (empty banner), TestResolveAlertsTile_RingPopulated (newest-first ordering, resolved severity class, age label populated), TestUIOperationsShow_AlertsTile_EmptyRing (rendered HTML contains the banner, NOT the table headers), TestUIOperationsShow_AlertsTile_Populated (rendered HTML shows source code, message text, and the severity badge class; empty-state banner does NOT leak).
Operator notes:
- The ring is process-wide singleton state. A staple-cli restart
(or service restart) zeroes it. This is intentional — the tile
is a "what's happening lately?" surface; durable history lives
at
/admin/auditand in worker structured logs. - 100-entry capacity is plenty for a typical week's worth of alerts on an active multi-tenant instance. Operators with legitimate higher volume should tune the cost_budget_alerter / backup_health intervals (the offenders) rather than bump the ring size.
v2.42.0.22 — 2026-05-30¶
feat(cli): /diff chat REPL command — operators frequently want
to compare two short text inputs from inside the REPL without
bouncing to diff -u in another terminal: "is the prompt the agent
is actually using the same as the one I edited?", "did this note
rev change anything semantic or just whitespace?", "what changed
between two versions of a snippet I'm pasting around?" v2.42.0.22
brings that workflow into the REPL via a /diff slash command. No
chat turn consumed; reuses the Myers diff algorithm that landed in
v2.46.0.5 for the GEPA proposal view.
CLI:
- New
/diffslash command with two input modes:- File mode:
/diff <file1> <file2>reads both inputs from disk and prints the unified diff. Missing files surface an inline error and the REPL loop continues so /quit exits normally. Per-file reads are capped at 1 MiB — anything bigger is "go usediff -u" territory. - Interactive mode:
/diff(no args) prompts the operator for two text blocks, each terminated by a literal.on its own line (SMTP / ed convention so muscle memory carries over). Blank lines inside the block are preserved verbatim. The reader bypasses the REPL's persistent history so the scratch text doesn't pollute up-arrow recall.
- File mode:
- Unified-diff output: bare leading space for unchanged lines,
+for additions,-for removals. ANSI color (green / red) when stdout is an interactive TTY and NO_COLOR / STAPLE_CHAT_NO_COLOR aren't set — matching the v2.42.0.6 shouldColorize policy already used for markdown rendering. - No-edits inputs render
(no differences)so the operator sees affirmative feedback rather than blank screen confusion.
Implementation:
- New
cmd/staple-cli/diff.gowith a self-contained Myers/LCS implementation. Intentionally NOT importinginternal/handler.computePromptDiff— pulling the handler package in for a 50-LOC helper would drag the entire pgxpool / jet / scope dependency graph into the CLI binary. The two surfaces are kept in lockstep by both producing a Myers edit script over LCS backtracking; the rendering differs (REPL uses a simpler per-line projection rather than@@hunk headers because operators are visually scanning, not saving a.patchfile). - New
diffLineReaderinterface (PromptNoHistory(p string) (string, error)) — satisfied by both bufioPromptReader and linerPromptReader. The interactive mode uses the no-history variant so multi-line block input doesn't get appended to the persistent ~/.config/staple/chat_history file. - Help text + shouldRecordToHistory exclusion updated so /diff
(and
/diff <args>) stay out of recall.
Tests:
cmd/staple-cli/diff_test.go(new): table-driven Myers diff coverage (identity, all-add, all-remove, interior edit, moved line, appended lines), parseDiffArgs argv coverage, renderDiff no-edits + plain + ANSI-colored branches, readDiffBlock roundtrip + EOF-before-terminator paths.cmd/staple-cli/chat_slash_test.go(extended): TestChatHelpText_ContainsDiff, TestShouldRecordToHistory_ExcludesDiff, TestChatLoop_DiffCommand_FileMode (tmp-file roundtrip through the dispatcher), TestChatLoop_DiffCommand_FileMode_MissingFile (inline error), TestChatLoop_DiffCommand_InteractiveMode (scripted strings.NewReader feeds two.-terminated blocks), TestChatLoop_DiffCommand_BadArgs (one-arg usage error).
Operator notes:
- The interactive prompts (
First text (end with.on a blank line):/Second text (end with.on a blank line):) print BEFORE the reader starts collecting lines, so a paste- in-the-blind workflow still works. - Trailing whitespace IS considered significant in the CLI
variant. The handler-side proposal diff TrimRights for
cosmetic reasons; the REPL operator who's asking "what
changed?" generally DOES want trailing-space differences
visible. If you don't, pipe both inputs through
sed -E 's/[[:space:]]+$//'first.
v2.46.0.15 — 2026-05-30¶
feat(agents): GEPA per-agent acceptance trend chart — v2.46.0.14
landed the per-agent acceptance-rate stats tile on
/agents/{id}/proposals so operators get a one-glance "is this
agent's proposals being trusted?" answer before scrolling the
list. v2.46.0.15 adds a small text-based weekly trend chart
underneath so the operator can see "is auto-promote getting
smarter?" at a glance — a trajectory rather than a single
snapshot.
UI:
- New
Weekly trend (last 8 weeks)section under the stats tile, rendered with the same Unicode block char and the same scale- relative-to-max rule as the v2.50.0.13 audit-insights daily breakdown so the two charts feel consistent. - One row per week. Columns: date range (
YYYY-MM-DD – MM-DD), decided count, ASCII bar, and the rounded acceptance percentage. - Color: green ≥70% per week, yellow 30–70%, red <30%. Matches the v2.46.0.14 stats-tile band so an operator learns one rule for both surfaces.
- Empty weeks (
Total==0) render with an em-dash in the count cell and no bar — the trend doesn't lie about an "0%" rate that's really "no decisions yet". - Hidden entirely when every bucket is empty so a brand-new agent doesn't see an empty grid.
- Bar widths scale to the busiest week so a quiet week with one decided proposal still gets at least 1 cell — same "always-visible" rule the audit-insights per-day chart uses.
Implementation:
- New
query.WeeklyAcceptanceBucketstruct (WeekStart,Accepted,Rejected,Total,AcceptancePct). - New
query.GetAgentAcceptanceTrend(ctx, pool, agentID, weeks)helper:- Single SQL query against
agent_prompt_proposals WHERE status IN ('accepted', 'rejected')withdate_trunc('week', decided_at)as the aggregation key. - Pre-allocates an exact-
weeks-length bucket spine in Go so empty weeks still appear — the trend doesn't silently shrink to weeks that have data. weeks <= 0falls back to the default (8 weeks);weeks > 52clamps down (maxAcceptanceTrendWeeks).- Runs under
scope.WithBypass— same posture asGetAgentProposalStats. The caller is responsible for the instance-admin gate (theUIAgentProposalsListhandler).
- Single SQL query against
- New
query.isoWeekStartin-Go helper that mirrors Postgresdate_trunc('week', …)so the bucket spine and the SQL aggregation key agree on the ISO-Monday boundary. - New
handler.AgentProposalTrendRowtemplate projection (DateRange,Bar,Band). - New
handler.buildAgentProposalTrendRowspure projector that scales bar widths against the busiest week and classifies the percentage intogood/warn/badbands (matching the v2.46.0.14 stats-tile thresholds). UIAgentProposalsListnow fetches the trend best-effort: a query failure suppresses the section rather than blocking the render, same posture as the stats tile.
Tests:
query.TestIsoWeekStart_Boundaries— 5-case in-Go boundary helper matrix (Monday, mid-week, Sunday wraps to previous Monday, midnight no-op, non-UTC normalisation).query.TestGetAgentAcceptanceTrend_RequiresAgentID— input validation.query.TestGetAgentAcceptanceTrend_DefaultWeeksAndClamping— 5-caseweeksnormalisation matrix on a fresh empty agent.query.TestGetAgentAcceptanceTrend_SeedsBuckets— seeds 2 accepted + 1 rejected + 1 pending and asserts the current week's bucket carries the right counts and percentage (with the pending row correctly excluded from the totals).handler.TestBuildAgentProposalTrendRows— 5-case pure projection matrix (empty input, all-empty buckets, high pct + max-scaled bars, mid + low pct boundaries, quiet week still gets at least 1 bar cell).handler.TestUIAgentProposalsList_TrendChartRendersOnSeededAgent— seeds 2 accepted + 1 rejected and asserts the trend section markers ("Weekly trend (last 8 weeks)", Activity / Acceptance column headers, Unicode█) land in the rendered HTML.handler.TestUIAgentProposalsList_TrendChartHiddenOnEmptyAgent— confirms a fresh agent with zero decided proposals does NOT render the trend section (empty-state branch).
Locked-roadmap v2.46.0.15. Migration counter unchanged at 129. Cross-tenant; instance-admin gated.
v2.43.0.24 — 2026-05-30¶
feat(agents): subagent dashboard filter by cost band — v2.43.0.20
shipped per-row CostUSD bands ("ok" / "warning" / "critical") on
/admin/subagents so an operator skimming the page catches an
expensive run by its background tint. v2.43.0.24 layers a filter
on top: a cost_band URL parameter and a matching dropdown so an
operator who spotted a critical row can ask "show me everything
that hit the critical threshold" with one click.
URL knob:
?cost_band=ok|warning|critical— exact match on the per-run computed band. Unknown / empty / fuzzer-style values degrade to "no band filter" so a malformed URL still renders the full feed (same posture as?status=...and?depth=...).- Composes with every other filter knob.
?cost_band=critical&status=failed&from=2026-05-29for example narrows to "critical-cost failed runs since yesterday".
UI:
- New "Cost band" dropdown in the filter row, next to the
v2.43.0.23 agent-name search. Options are
(any),ok (under warning),warning,critical. - New active-filter pill
cost_band=warning(etc.) with an✕clear link. The pill mirrors the shape of the other knobs so the visual triage flow stays consistent. - The pre-existing v2.43.0.20 expensive-runs summary card ("X of Y runs above warning, totalling $Z") is computed against the pre-band-filter row set so the rollup continues to answer "what does this feed look like overall?" even when the table is narrowed to one band.
Implementation:
- New string field on
subagentFilters(CostBand) that participates in.active()so the "Filtered" pill row and "Clear filters" link fire on a band-only filter. - New
parseSubagentCostBandhelper that case-folds + trims the URL input, returns one of the canonicalcostBandOK,costBandWarning,costBandCriticalconstants, or the empty string on unknown input. - New
filterSubagentViewsByCostBandhelper that narrows the classified[]SubagentRunRowViewpost-classification. The filter runs afterclassifySubagentRowsForBandso the classification still feeds the expensive-runs rollup against the pre-band-filter set; only the rendered table shrinks. Empty / unknown band returns the input unchanged — a defective parser can never silently truncate the feed. - New dropdown helper
subagentCostBandDropdownreturns the ordered[]subagentCostBandDropdownOptionthe template renders, mirroring the v2.43.0.13subagentStatusDropdownshape. - Template clear-link chains on every other filter pill pick
up the new
cost_band=suffix so dropping any one filter preserves the band selection.
Tests:
handler.TestParseSubagentCostBand_Table— 12-case URL parser matrix (empty, whitespace, canonical values, mixed case, trimmed, unknown, adjacent typo, numeric).handler.TestFilterSubagentViewsByCostBand— 5-case classified-row narrowing matrix (identity on empty + unknown; per-band exact match).handler.TestSubagentFilters_ActiveIncludesCostBand— pins the.active()contract so the "Clear filters" link fires on a band-only filter.handler.TestUISubagentsIndex_CostBandFilterDropdownRenders— render-side smoke that the dropdown markers (name="cost_band",>Cost band<,value="ok|warning|critical") land in the HTML.handler.TestUISubagentsIndex_CostBandFilterActiveRendersPill— drives?cost_band=warningand asserts the active pill + clear link land.handler.TestUISubagentsIndex_CostBandUnknownDegradesGracefully— drives?cost_band=drop-tableand asserts the page renders the full feed without leaking the fuzzed value as an active pill.
Locked-roadmap v2.43.0.24. Migration counter unchanged at 129. Cross-tenant; instance-admin gated.
v2.50.0.18 — 2026-05-30¶
feat(ops): audit dashboard row review status — v2.50.0.15 added operator-private manual tags on the audit feed; v2.50.0.16 layered bulk export of tag-selected rows. Both are free-text and good for fuzzy triage notes. v2.50.0.18 adds a more structured signal — three-state review status — for the "where am I in this list?" problem operators hit during long incident sweeps.
UI:
- New
Statuscolumn on every row with a dropdown: blank (default),✓ Reviewed,⚠ Action needed, or🚫 Ignored. - Row background tints subtly to match: green for reviewed
(
rgba(34,197,94,0.10)), yellow for action needed (rgba(234,179,8,0.14)), gray for ignored (rgba(107,114,128,0.14)). Untouched rows render with no tint. - New top-of-page
Review statuscard adds aShow:dropdown (all/unreviewed/reviewed/action needed/ignored) that narrows the rendered feed to one bucket. Composes with the v2.50.0.15 tag filter — both can be active at the same time. - Count tile underneath: "X reviewed · Y action needed · Z ignored · W untouched (N total visible)". Counts reflect currently-visible rows so the operator can confirm "I have processed every visible item" before clearing the filter.
Persistence:
localStorage["staple_audit_review_status"]is a JSON object mapping"<source>:<id>"→ one of"reviewed","action_needed","ignored". Setting a row back to blank deletes the key (keeps the JSON blob small after long triage sessions).localStorage["staple_audit_review_filter"]holds the currently active bucket so a refresh keeps the operator's scoped view.
Implementation:
- All-in-template: column header, per-row select, filter card,
count tile, and inline IIFE script live in
internal/web/templates/audit/index.html. No new handler routes, no schema, no Go code paths beyond the rendered HTML. - The IIFE feature-tests
localStoragewith the same probe-then-readback pattern used by saved-views, tags, and the timestamp toggle. On failure the filter card stays hidden, but per-row selects still render so the operator can mark rows for the current session. - Untaggable rows (no stable id, same edge case the tag column
hides) collapse the select with
display:none. Tagging or status-marking such a row would silently lose state on the next feed refresh, so the affordance disappears rather than misleads. - Row tinting is applied via inline style so it composes with the existing tints from sort-column highlights without a CSS- variable round-trip.
Tests:
handler.TestUIAuditDashboard_ReviewStatusScaffolding— asserts the rendered HTML carries the column header (data-staple-audit-review-col,>Status<), filter card hooks (data-staple-audit-review-filter,>Review status<), dropdown values (unreviewed,reviewed,action_needed,ignored), count tile slots, and both localStorage key strings. Row-level markers (data-staple-audit-review-cell,data-staple-audit-review-select, the select class) are asserted conditionally on the dev DB carrying rows.
Documentation: docs/user/operators/audit-dashboard.md gains a
new "Review status column (v2.50.0.18)" section between the
tags section and the keyboard-shortcuts table.
Locked-roadmap v2.50.0.18. Migration counter unchanged at 129. Cross-tenant; instance-admin gated.
v2.47.0.21 — 2026-05-30¶
feat(ops): auto-refresh toggle on the Operations panel —
/admin/operations is server-rendered HTML and its cards (deploy
info, env summary, disk, spend, retention preview, backup workers,
etc.) don't tick forward without a reload. v2.47.0.21 adds a
header-bar Auto-refresh toggle + interval selector so operators
running long migrations or watching a stuck worker can leave the
page open and see fresh numbers without hammering reload.
UI:
- New control cluster in the page header: a checkbox + a select
with
every 30s/every 60s/every 5minoptions. A small pulsing green dot appears next to the controls when the timer is running, hides when the toggle is off. - Preference persists to
localStorage["staple_ops_autorefresh"]as{"enabled": true, "intervalSec": 60}. The script rehydrates on page load. - Refresh uses
window.location.reload()so every card sees the same atomic server-render — no XHR fanout, no partial state. - New "Last loaded: YYYY-MM-DD HH:MM:SS" footer at the bottom of the page so the operator can sanity-check when on-screen data was actually fetched.
- Graceful degradation: when
localStorageis unavailable (private mode, file:// scheme), the toggle still works in-page but doesn't survive a reload.
Implementation:
- All-in-template: header markup +
<style>keyframes for the pulsing dot + footer + inline script live ininternal/web/templates/operations/index.html. No new handler routes, no schema, no Go code paths beyond the rendered HTML. - The script is a single self-invoking function so it doesn't leak globals. It safe-wraps the localStorage reads/writes so a storage exception in private mode degrades to in-memory.
Tests:
handler.TestUIOperationsShow_AdminRendersextended with 10 rendered-HTML assertions for the toggle scaffold: element IDs, the literal localStorage key name, the three interval-select options, and the "Last loaded:" footer.
Docs: docs/user/operators/monitoring.md — added an
"Auto-refresh on the Operations page" subsection before the
per-worker drill-down docs.
Locked-roadmap v2.47.0.21. Migration counter unchanged at 129.
Cross-tenant; instance-admin gated (matches the existing
/admin/operations posture).
v2.42.0.21 — 2026-05-30¶
feat(cli): /budget chat REPL command + budget status endpoint —
v2.48.0.3 added per-company LLM cost budgets plus a dashboard tile
showing month-to-date spend vs the configured monthly ceiling. The
chat REPL had no way to see those numbers without leaving the
terminal — /budget closes that gap.
New chat REPL command (staple-cli chat):
/budget— pretty-prints the company's monthly LLM cost budget vs month-to-date spend. Output includes monthly ceiling, spent amount + percent, warning + critical thresholds (with the implied USD amounts), and a colored OK / WARNING / CRITICAL status label on TTY (green / yellow / red).- "No budget configured" path prints a one-line hint pointing the
operator at
/admin/companies/<id>/budgeton the web UI instead of zero-padded numbers. - Excluded from persistent liner history (same posture as
/cost,/stats, and the other meta commands). - Doesn't consume a chat turn; failures surface inline.
New API endpoint:
GET /api/companies/{companyId}/budget/status— viewer+ gated, read-only. Returns{"configured": bool, "monthly_usd": ..., "spent_usd": ..., "spent_pct": ..., "warning_pct": ..., "critical_pct": ..., "status": "ok"|"warning"|"critical"}. When nocompany_cost_budgetsrow exists, returns{"configured": false}.- Wired under the existing
/api/companies/{companyId}chi group so it inherits the per-company access check.
Implementation:
- New handler
APICompanyBudgetStatusininternal/handler/api_company_budget_status.go. Composesquery.GetCompanyCostBudget(migration 127) withquery.MonthToDateSpendUSD(v2.48.0.3 helper that aggregatescost_events,chat_messages,heartbeat_runs, andevolution_tracesfor the current calendar month). - Pure-function
budgetStatusFromPct(spentPct, warningPct, criticalPct)derives the band label so the test pins boundary transitions without seeding a row. - CLI side: new
cmd/staple-cli/chat_budget.gocarries the REPL fetch + render.formatBudgetStatusengages ANSI coloring only when stdout is a TTY (sameshouldColorizerule the markdown renderer uses);bytes.Buffertest sinks see raw labels.
Tests:
handler.TestBudgetStatusFromPct_Boundaries— 10-case table for ok / warning / critical transitions including the zero-threshold disabling cases.handler.TestAPICompanyBudgetStatus_MissingCompanyID— empty path value returns 400 without touching the pool.handler.TestAPICompanyBudgetStatus_NotConfigured/_Configured— DB-touching round-trips; skip when the dev pool isn't reachable.main.TestFormatBudgetStatus_Table— 9 cases covering raw, colored, and unknown-status fallback labels.main.TestBudgetMonthLabel_Format— pins "Month Year" rendering.main.TestChatLoop_BudgetCommand_HappyPath/_NotConfigured/_HTTPError/_MissingCompanyID/_RoutePins— end-to-end REPL dispatcher coverage through anhttptestmock.main.TestChatHelpText_ContainsBudgetCommandandmain.TestShouldRecordToHistory_BudgetExcluded— pins the help block and persistent-history exclusion.
Docs: docs/user/operators/costs.md — added a "Checking the budget
from the chat REPL" subsection under the budget alerter docs.
Ops: also patches /mnt/Media/staple/reset.sh on node4 to point
REPO_DIR at the actual /mnt/Media/staple/staple git checkout
instead of the stale rsync target /mnt/Media/staple. The previous
layout silently shipped stale code unless the operator remembered
to rsync after git pull; we hit this in v2.40.0.5. Backup at
/mnt/Media/staple/reset.sh.bak-pre-repo-dir-fix. No repo-side
commit for the script itself (operator-owned outside the tree).
Locked-roadmap v2.42.0.21. Migration counter unchanged at 129. Per-tenant; bearer-agent + viewer+ session access.
v2.50.0.17 — 2026-05-30¶
feat(audit): row-level inline expand on the audit dashboard — v2.50.0.0 surfaced the per-row "view →" link that opens each audit row's source-specific detail page in a new screen. v2.50.0.17 layers an inline expand-in-place toggle on top so the operator can peek at the full raw row payload without leaving the dashboard.
UI:
- New "▸" toggle column at the start of every row. Click expands
a sub-row beneath the source row that fetches the full per-
source payload as JSON and renders it in a pretty-printed
<pre>block. Re-clicking collapses. - Quick-summary line above the JSON shows the well-known fields
(
created_at,called_at,action,outcome,company_id) when present, so the operator sees the headline state without scanning the JSON tree. - localStorage cache (key
staple_audit_row_cache_v1, 5-minute TTL matching the server's Cache-Control) avoids re-fetching the same row across navigation / sort / filter cycles. - The toggle is independent of the existing v2.50.0.15 tag chips and the "view →" detail-page link — operators can mix all three interactions per row without state leakage.
New JSON endpoint: GET /admin/audit/row/{source}/{id}.
- 403 for non-instance-admin actors (matches the dashboard posture).
- 400 when
sourceisn't in the surfaced-tables allow list. - 404 when the row id is missing, malformed (non-UUID), or refers to a row that doesn't exist. Malformed UUID errors are intentionally translated to 404 rather than 400 so the dashboard's "expand failed" path covers both cases without a separate branch.
- 200 +
{"source":...,"id":...,"row":{...}}envelope on success. Server-sideCache-Control: private, max-age=300matches the client cache TTL.
Implementation:
- New query helper
FetchAuditRowJSON(ctx, pool, source, id)ininternal/db/query/audit_row_lookup.go. Usesscope.WithBypass(cross-tenant; instance-admin gated upstream) and a guardedSELECT row_to_json(t) FROM <table> t WHERE t.id = $1::uuid. The table name is interpolated only after the allow-list check —surfacedAuditTablesAllowed()fromaudit_aggregate.gois the single source of truth for the 9 surfaced sources. - Sentinel errors
ErrAuditRowUnknownSourceandErrAuditRowNotFoundso the handler maps them directly to HTTP codes without re-stringing. - The handler also short-circuits on missing path values before hitting the DB.
Tests (DB-touching tests skip cleanly when the dev pool is unavailable):
handler.TestUIAuditRowJSON_NonAdminForbidden— 3-case gate (nil / non-admin / api-key).handler.TestUIAuditRowJSON_MissingPathValues— empty source / empty id / both empty → 400.handler.TestUIAuditRowJSON_UnknownSource— unknown source with a valid-shaped id → 400.handler.TestUIAuditRowJSON_DB_HappyPath_SecretAudit/_BackupRuns/_MCPToolCalls— three different source tables exercise the generic SELECT path. Each seeds a fresh row, fetches it through the endpoint, asserts the envelope shape and a per-source field round-trips.handler.TestUIAuditRowJSON_DB_NotFound— valid UUID with no row → 404.handler.TestUIAuditRowJSON_DB_MalformedID— non-UUID input → 404 (validates the SQL-error-to-NotFound translation).
Locked-roadmap v2.50.0.17. Migration counter unchanged at 129. Cross-tenant; instance-admin gated.
v2.45.0.4 — 2026-05-30¶
feat(user-models): cross-tenant per-company engagement rollup —
v2.45.0.1 added the per-company drill-down at
/companies/<id>/user-models. v2.45.0.4 layers a cross-tenant
rollup on top at /admin/user-models so an instance admin can
answer "which companies are actively using the optional
user-modeling feature?" without clicking through every company.
New page:
- One row per company including companies with zero adoption (so the page surfaces "feature not adopted yet" rather than hiding the company entirely).
- Columns: company name + short id, total users (membership
count), users with a populated + enabled
user_modelsrow, engagement %, average profile-update age, last activity timestamp. - Engagement % colour-tier per row: green ≥50%, yellow ≥20%, red <20%. Different thresholds than the v2.40.0.5 server-stats page — 50% adoption is a strong signal for an opt-in profile feature; the success-rate scale doesn't apply.
- Sorted by engagement % DESC with a deterministic name-ASC tiebreaker.
- Per-row
drill in →link pivots to the per-tenant page. - Header chip strip totals: company count, adopted-company count, total engaged users / total users, and an across-the-board engagement %.
New query helper: query.AggregateUserModelsByCompany reads
through scope.WithBypass. A single LEFT-JOIN-aware query joins
companies × company_memberships × user_models with the
"engaged user" gate of (enabled = TRUE AND
length(profile_markdown) > 0). Two CTEs precompute the per-
company numerator and denominator separately so the inline join
can't double-count.
New handler: handler.UIUserModelsByCompany at
GET /admin/user-models, instance-admin gated.
Sidebar nav entry "User models" lights up on the new page; positioned next to the v2.43.0.5 Subagents entry as another cross-tenant operator observation surface.
Tests (DB-touching tests skip cleanly when the dev pool is unavailable):
query.TestAggregateUserModelsByCompany_HappyPath— seeds a dedicated company with 4 members, populates 2 user_models rows, asserts the 50% engagement, the member count, the populated-row count, and theLastUpdatedAtnon-nil contract.query.TestAggregateUserModelsByCompany_EmptyProfileNotCounted— seeds 2 members bothenabled=TRUEbut with emptyprofile_markdown, asserts they count towardTotalUsersbut NOT towardUsersWithModel— pins the empty-profile-is-inert gate.query.TestCompanyUserModelStatsLess/TestSortCompanyUserModelStats— pin the engagement-DESC / name-ASC ordering rule.handler.TestUIUserModelsByCompany_NonAdminForbidden— gate on nil actor, non-admin user, api-key admin.handler.TestCompanyUserModelEngagementTier— 50% / 20% thresholds + zero-users short-circuit.handler.TestFormatAvgUpdateAge— label rendering across the<1d/ day / month breakpoints.handler.TestBuildCompanyUserModelStatsViews{,_Empty}— per-row projection + nil-in → empty-slice guarantee.handler.TestItoaSimple— the tiny inline int formatter.
Locked-roadmap §4.8 v2.45.0.4. Migration counter unchanged at 129. Cross-tenant; instance-admin gated.
v2.40.0.5 — 2026-05-30¶
feat(audit): MCP tool-call per-server stats page —
v2.40.0.4 surfaced the raw per-call audit feed at
/admin/mcp/calls. v2.40.0.5 layers a per-server rollup on top
at /admin/mcp/servers/stats so an operator can answer
"which external MCP servers are agents leaning on the hardest,
and is any of it failing?" in one click without scrolling the
chronological feed.
New page:
- Window selector: trailing 24h / 7d / 30d (default 7d).
- Per-server table sorted by total-calls DESC: total calls, success %, average duration, max duration, last-call timestamp.
- Success % colour-coded per row: green ≥95%, yellow ≥80%, red <80%. The same tier strip appears in the header chip for the across-the-board success rate over the window.
- Each row collapses into a per-tool call breakdown via a plain
<details>element — no JS required. Each breakdown also carries aView per-call rows for this server →link that pivots back to/admin/mcp/calls?server=<name>for the chronological drill-down. - Servers that were deregistered (FK
ON DELETE SET NULL) still appear under their preservedserver_namewith a(deregistered)marker — historic activity stays continuous.
New query helper: query.AggregateMCPCallsByServer(ctx, pool,
window) reads through scope.WithBypass (instance-admin gated
upstream, mirroring the v2.40.0.4 cross-tenant posture). Two
queries: per-server rollup by server_name, then per-tool
breakdown grouped by (server_name, tool_name); the Go side
stitches the tool counts onto the matching server row.
New handler: handler.UIMCPServerStats at
GET /admin/mcp/servers/stats. Instance-admin gated. Maps
?window=24h|7d|30d onto the helper's canonical durations
(unknown values fall through to 7d).
Templates:
internal/web/templates/mcp_tool_calls/server_stats.html— the new page, matching the v2.40.0.4 audit-feed style.internal/web/templates/mcp_tool_calls/index.html— adds aServer stats →header link to the new page.
Tests (DB-touching tests skip cleanly when the dev pool is unavailable):
query.TestAggregateMCPCallsByServer_HappyPath— seeds 6 rows across 2 servers with deterministic outcome / duration / tool shapes, asserts per-server totals, success %, avg / max duration, tool breakdown, slice ordering, and the window cutoff.query.TestAggregateMCPCallsByServer_DefaultsWindow— pins the zero / negative window fall-through.handler.TestUIMCPServerStats_NonAdminForbidden— gate on nil actor, non-admin user, and api-key admin.handler.TestParseMCPServerStatsWindow— pins the case-insensitive window mapping + default fall-through.handler.TestMCPServerStatsSuccessTier— colour-tier thresholds (95% / 80%).handler.TestBuildMCPServerStatsViews{,_Empty}— per-row projection: tier, formatted durations, last-call label, nil-in → empty slice.
Operator docs: docs/user/operators/mcp.md adds the new page
to the audit-browsing section alongside /admin/mcp/calls and
/admin/audit.
Locked-roadmap §4.3 G v2.40.0.5. Migration counter unchanged at 129. Cross-tenant; instance-admin gated like the parent audit surface.
v2.42.0.20 — 2026-05-30¶
feat(cli): chat REPL /compress command + API endpoint —
v2.44.0.0 added the context-compression worker that compresses an
agent's transcript when it crosses the threshold token budget.
v2.42.0.20 lets an operator force a clean state mid-debug without
waiting for the next tick. Useful when debugging an agent stuck
in a long failure loop where the runtime context is dominated by
archaeology.
New endpoint: POST /api/chats/{chatId}/compress. Pre-flight
checks mirror the other chat-scoped APIs:
- nil actor → 401
- chat not found → 404
- actor not allowed on the chat (the
chatActorAllowedgate shared with/escalate,/messages, etc.) → 403 - chat has no
current_agent_id→ 400
Happy path calls worker.CompressAgentNow(ctx, pool,
encryptionKey, companyID, agentID) — a new public function
extracted from the background-sweep path. Unlike the sweep, it:
- Bypasses
STAPLE_CONTEXT_COMPRESSION_ENABLED(the operator explicitly asked). - Bypasses the threshold gate (the operator wants a clean state even if the agent's only sitting at 8k tokens).
- Still resolves the per-company provider via
providerrouter.For, so a tenant misconfiguration produces the same "no provider configured for company" error a sweep would.
Returns 200 + JSON envelope: compressed_runs, tokens_before,
tokens_after, tokens_saved, model, compression_id,
provider. The wire shape is mirrored on the CLI side as
chatCompressResponse.
CLI:
- New
/compressslash command in the chat REPL. POSTs an empty body to the endpoint and prints a one-line summary:✓ compressed: N run(s) → M summary tokens (was X, saved Y, provider=Z)plus a follow-up line with the truncated compression id and model for cross-referencing. /helpupdated.- Excluded from persistent liner history (matches the rest of the no-record list).
Tests:
cmd/staple-cli/chat_slash_test.go(extended):TestRunCompress_HappyPathmocks the success envelope and pins every field in the rendered line.TestRunCompress_HTTPErrorconfirms the structured error envelope'serrorfield is preferred over the raw body.TestRunCompress_NoActiveChatexercises the early-return with no chat ID.internal/handler/api_chat_compress_test.go(new): auth gate (nil actor → 401), 404 for missing chat, 400 for unbound chat, and a no-provider failure path that exercises the full call chain through the worker package's exported function.
Implementation note: the new worker.CompressAgentNow was
extracted with deliberate symmetry to the background sweep's
compressAgent method — they share the transcript build,
cutoff anchor, model label, and insert helper. The differences
are exactly the threshold + enabled-flag bypass, plus the
return-shape that the API echoes to the CLI.
v2.44.0.2 — 2026-05-30¶
feat(chats): context compression badge on chat detail page —
v2.44.0.0 added the context-compression worker so long-running
agents' transcripts get rewritten when they cross
STAPLE_CONTEXT_COMPRESSION_THRESHOLD_TOKENS. The rewrite is
operator-invisible: rows land in agent_context_compressions but
nothing in the UI surfaces "this just happened" or "the agent's
seen N compressions." Operators investigating "why is this agent
suddenly forgetting earlier context?" had no UI affordance.
v2.44.0.2 fills the gap with a compression badge and expandable
table on the chat detail page (GET /chats/{chatId}). The badge
renders only when the chat's current_agent_id resolves to an
agent with at least one compression row:
🗜 compressed N time(s)
Clicking the badge expands a per-row table showing when each
compression event landed (UTC date + time), the token counts
before / after, the savings, the model that produced the
summary, and a <details>-collapsed snippet of the first 320
chars of the summary markdown. Each row carries a 8-char short
ID for cross-referencing against the underlying table.
Implementation:
query.ListAgentContextCompressions(ctx, pool, companyID, agentID, limit)— new list helper alongside the existingGetLatestAgentContextCompressionsingle-row reader. Limit clamps at 100; default 20. Runs throughscope.WithTenantScopeso the cross-tenant boundary stays sealed.handler.UIChatShowloads the current agent's compression history best-effort and threadsCompressions,CompressionViews, andCompressionCountinto the template data. A query failure logs at Warn and the page renders without the badge — the chat surface must stay reachable when one auxiliary load is slow / broken.handler.buildChatCompressionViewspre-renders the per-row display fields (short ID,2006-01-02 15:04Z"when" label, string-formatted token columns, 320-char summary snippet with…overflow, defensivemax(0, before-after)saved-tokens count).chats/show.htmladds the badge button (gated ongt .CompressionCount 0) inside the<h2>Agent</h2>header and an expandable panel below the header that toggles via a small inline<script>block — no external JS dependency.
Tests:
internal/db/query/agent_context_compressions_test.go(new): list-helper validation (missing companyID / agentID), DB-backed round-trip against the v2.44.0.0 insert helper, and a limit-clamp pure-function probe.internal/handler/ui_chats_test.go(extended):TestBuildChatCompressionViews_Empty+_Renderingpin the per-row projection (short ID, summary truncation, defensive saved-tokens clamp).TestUIChatShow_CompressionBadge_Absent WhenNoneand_PresentWhenHistoryround-trip the badge gate through the rendered HTML.
Docs: docs/user/operators/audit-dashboard.md already mentions
the ninth source from v2.40.0.4. No new operator doc — the
chat detail page's badge is self-explanatory.
v2.40.0.4 — 2026-05-30¶
feat(audit): MCP tool-call audit log + admin dashboard — v2.40.0.3 wired the external MCP tools bridge so agents could invoke remote tools via the typed-tools registry, but the invocation itself was invisible: operators investigating "did the agent really call out to this server, and what came back?" had to chase tells in adapter logs without a forensic trail. v2.40.0.4 adds the forensic surface.
Migration 129 adds mcp_tool_calls — one row per outbound
tools/call carrying called_at, duration_ms, server_id
(nullable, ON DELETE SET NULL), server_name (denormalised),
tool_name, agent_id, company_id, arguments_json (JSONB),
truncated result_summary (4096 chars), outcome (CHECK enum
of success / error / timeout), and error_message. RLS is
armed in the same migration with the standard tenant_isolation
policy.
The recorder lives inside the typed-tools layer:
internal/db/query/mcp_tool_calls.go exposes
RecordMCPToolCall(ctx, pool, params) for the call site,
ListAllRecentMCPToolCalls{,Filtered} for cross-tenant reads,
and GetMCPToolCall for the per-call detail page.
internal/engine/typedtools/remote_mcp.go gets a new
NewRemoteMCPToolWithAudit constructor that threads
(pool, companyID, serverID) into the wrapper; the existing
NewRemoteMCPTool constructor stays backward-compatible for
the test suite. The Invoke path times the call, classifies
the failure (timeout vs error via errors.Is(err,
context.DeadlineExceeded)), and writes the audit row
best-effort — a recorder failure logs at WARN and the agent's
result is returned untouched. The engine bootstrap path
(engine.registerRemoteMCPTools) now uses the audit-aware
constructor.
Two new instance-admin handlers under /admin:
GET /admin/mcp/calls— filterable table (substring on server name and tool name, outcome dropdown, date range). Outcome counters in the header (success / error / timeout) reflect the filtered set. 200-row page cap with a "showing N of M" indicator; deeper exploration pivots to the consolidated dashboard.GET /admin/mcp/calls/{id}— per-call detail page with the fullarguments_json(pretty-printed in a scrollable<pre>) and the truncatedresult_summary. Error rows surface theerror_messagein a danger-coloured block at the top.
mcp_tool_calls becomes the ninth source on the consolidated
/admin/audit dashboard:
query.AuditFeedTablesForFilterandsurfacedAuditTablesAllowedexpand from eight to nine.query.AuditFeedCollectorgrows aMCPToolCallsslice;CollectAuditFeedcallsListAllRecentMCPToolCallsFilteredwhen the source isn't filtered out.- The filter pushdown short-circuits
Actorfilters (MCP calls have no human actor — every row is system-attributed) and maps the dashboard'saction=filter onto theoutcomecolumn. auditFailureActions["mcp_tool_calls"]classifieserrorandtimeoutas failures so the v2.50.0.7 insights page counts them on the failure tile.- The friendly label is "MCP tool call".
handler.collectAuditFeedprojects rows intoauditFeedItemwith a summary line of the formmcp tools/call <server>:<tool> (<outcome>, duration=<N>ms), appending: <error>on the failure path. DetailURL points at/admin/mcp/calls/<id>.
Tests pin the round-trip:
internal/db/query/mcp_tool_calls_test.go— recorder validation (CompanyID/ToolNamerequired, outcome enum), DB-backed round-trip across all three outcomes, cross-tenantListAllRecentMCPToolCalls, and the filter short-circuits (Actor→ empty;Action=successnarrows). ATestMCPToolCallsAuditFeed_NinthSourceblock confirms the surfaced-tables expansion locked in lockstep with the failure-actions table.internal/handler/ui_mcp_tool_calls_test.go— instance- admin gate on both new routes, filter parser shape across empty / full / malformed inputs, in-memory filter narrowing per dimension, duration formatter boundaries (<1sms /<60s1-decimal /≥60sminutes-and-seconds), per-row view projection, and the filter query-string round-trip.- Existing
TestSurfacedAuditTables_StableOrder,TestAuditFeedTablesForFilter, andTestSurfacedAuditTablesAllowed_PinsKnownSetupdated to expect nine sources.
Docs: docs/user/operators/mcp.md (new) documents the audit
policy, the two browsing surfaces, what's not captured
(full results), and the indices. audit-dashboard.md updated
to reflect the ninth source.
v2.46.0.14 — 2026-05-30¶
feat(agents): GEPA per-agent acceptance-rate trend tile —
v2.46.0.10 added the per-agent gepa_auto_promote toggle so an
operator can opt an agent into auto-accepting GEPA proposals
without manual review. The toggle ships disabled by default and
asks the operator to make a call: "should I trust this agent's
auto-evaluator?" There's been no signal in the UI to inform that
call — operators have to either click through to /admin/audit or
open every proposal one by one to count the accepts vs rejects.
v2.46.0.14 fills the gap with query.GetAgentProposalStats(ctx,
pool, agentID). Single SQL round-trip via Postgres conditional
aggregates returns:
- Total / Accepted / Rejected / Pending counts
- AcceptanceRate = Accepted / (Accepted + Rejected) — pending proposals are deliberately excluded so they don't drag the metric down
- First/LastProposalAt for "since YYYY-MM-DD" framing
Two surfaces consume the rollup:
-
/agents/{id}/proposals(the v2.46.0.11 list page) — a card above the table. Renders the four counts plus a color-coded percentage: green ≥70%, yellow 30-70%, red <30%. When (accepted + rejected) is zero the percent shows "—" instead of a misleading "0.0%". -
/agents/{id}(the agent show page) — a compact subline inside the auto-promote toggle row so the operator sees "Acceptance trend: 8 of 12 decided (66.7% accepted) · 2 pending · View all →" right next to the Enable/Disable button. The same green/yellow/red band fires.
Implementation:
internal/db/query/agent_prompt_proposals.go:- New
AgentProposalStatsvalue type. - New
GetAgentProposalStats(ctx, pool, agentID)query. Runs underscope.WithBypassbecause the callers (proposal list page + agent show page on instance-admin-gated routes) need cross-tenant reads and the auth gate stays in the handler. UsesCOUNT(*) FILTER (WHERE status = …)so a single scan yields every counter. MIN/MAX over created_at give the first/last timestamps; both NULL for an agent with no proposals, which scans cleanly into*time.Timenil. -
Empty-agent posture: a zero-row result returns a zero-valued struct (not nil) so callers don't need a nil-guard.
-
internal/handler/ui_agent_proposals_list.go: -
Hands
Statsto the template. Best-effort: a query failure suppresses the tile rather than blocking the page render. -
internal/handler/ui_agents.go: -
Hands
ProposalAcceptanceStatsto the agent show template, same best-effort posture (slog.Warn + nil-out). -
internal/handler/render.go: -
New
mulFloattemplate helper because the existingmulis int-only and would silently truncate the 0.0–1.0 rate when scaling to percent. -
internal/web/templates/agents/proposals_list.html: -
New tile above the table. Green/yellow/red threshold bands. Empty state (Total=0) renders "No proposals yet" copy instead of zeroes, so an operator looking at a fresh agent sees an actionable message.
-
internal/web/templates/agents/show.html: - Compact trend subline inside the Auto-promote GEPA proposals detail row. Hidden when Stats is nil OR Total=0 so a fresh agent doesn't add visual noise to the toggle.
Tests:
internal/db/query/agent_prompt_proposals_test.go:TestGetAgentProposalStats_Rollupsseeds an empty agent then four proposals (2 accept, 1 reject, 1 pending), accepts + rejects accordingly, then asserts every field of the rollup: Total=4, Accepted=2, Rejected=1, Pending=1, AcceptanceRate ≈ 0.667 (2/3), First/Last non-nil with First ≤ Last. A second agent with a single pending proposal pins the "pending shouldn't pollute AcceptanceRate" guard.-
TestGetAgentProposalStats_RequiresAgentIDpins the input validation. -
internal/handler/ui_agent_proposals_list_test.go: TestUIAgentProposalsList_HappyPath_SeededProposalsextended to assert the trend tile renders the seeded counts (Total=3, Accepted=0, Rejected=1, Pending=2) and the "0.0%" band lights up.
No migration; query-layer + UI only.
v2.43.0.23 — 2026-05-30¶
feat(agents): subagent dashboard agent-name substring search — the /admin/subagents filter row already has company id, status, depth, and parent-UUID. All four match an operator's "what UUID is this?" mindset; none of them help when the operator is thinking "show me the doc-builder's recent runs" without remembering the UUID. v2.43.0.23 adds a free-form text-input "Search agents" filter that does a case-insensitive ILIKE substring match on the child agent's name.
Implementation:
internal/db/query/heartbeat_runs.go:SubagentRunFiltergains anAgentNameQuery stringfield — pointer-style nullability ("empty = no filter") matches the siblingParentfield's posture.(SubagentRunFilter).active()includes the new field so theFilteredrow-count banner fires when only the agent search is active.-
ListAllRecentSubagentRunsFilteredpushes the predicate down aschild.name ILIKE '%' || $n || '%'so the 200-row cap is applied AFTER the WHERE — same posture as Company/Parent. ILIKE chosen over LOWER(name) LIKE so a futureCREATE INDEX … gin (name gin_trgm_ops)lights up automatically. -
internal/handler/ui_subagents.go: subagentFiltersgains anAgent stringfield;active()andapplySubagentRowFilters(defensive in-memory pass) both learn the new predicate.toQueryFiltertrims and forwards Agent → AgentNameQuery.- Handler reads
?agent=<substring>alongside the existing?parent=<uuid>so operators can use either mental model. - New
distinctSubagentNames(rows)helper computes the deduped- sorted set of child agent names present in the current (post-filter) result set — feeds the template datalist.
-
Template data gains
AgentFilter(round-trip) andAgentNameSuggestions(datalist source). -
internal/web/templates/subagents/index.html: - New "Search agents" text input with placeholder
e.g. doc-builderand alist="staple-subagent-names"datalist. - Inline
<datalist>rendered fromAgentNameSuggestions. - New "agent=…" filter badge with a clear-link that drops only
the agent param. Every other filter badge's clear-link gains
{{if .AgentFilter}}agent={{.AgentFilter}}{{end}}preservation so the operator can dismiss one filter without losing the agent search.
Tests:
internal/db/query/heartbeat_runs_test.go:-
TestListAllRecentSubagentRunsFiltered_AgentNameQueryPushdownseeds a uniquely-named subagent via SpawnSubagent + creates a heartbeat run, then asserts the filter returns the row for exact-case + mixed-case substrings and excludes it for a bogus token. -
internal/handler/ui_subagents_test.go: TestApplySubagentRowFilters_HappyPathsextended with five Agent-filter rows (exact, mixed case, partial substring, no-match, combined with status).TestToQueryFilterextended with two Agent cases (trim, whitespace-only → empty).TestDistinctSubagentNamespins de-dup + sort + blank-drop.TestUISubagentsIndex_AgentURLRoundTripdrives the handler with?agent=<probe>and asserts the operator-typed value round-trips into the rendered form input.
No migration; UI / query-layer only.
v2.42.0.19 — 2026-05-30¶
feat(cli): chat REPL /switch — re-target the session mid-stream —
the existing /reset (v2.42.0.7) starts a fresh chat against the
SAME agent the REPL was launched with; useful for dropping context
drift, useless when the operator realises mid-debug that they wanted
to be talking to a different agent. Today the workaround is /quit
+ re-invoke staple-cli chat <other-agent> — losing the in-process
liner history and the terminal scroll position.
v2.42.0.19 adds the missing /switch <agent-name-or-uuid> slash
command. Behavior mirrors /reset (opens a new session, refreshes
sessionStartedAt) but also re-resolves the target agent via the
v2.42.0.1 chatResolveTarget machinery:
> /switch ops-bot
✓ switched to ops-bot (chat=fresh-ch)
> /switch 11111111-2222-3333-4444-555555555555
✓ switched to ops-bot (chat=abcdef12)
> /switch me
✓ switched to bearer-name (chat=99887766)
Three resolution shapes are supported, same as the v2.42.0.1 launcher:
me— bearer's own ID (offline; no HTTP probe).- A literal UUID — passed through; the name is discovered via the
cheap
GET /api/agentslist endpoint so the success line names the target instead of just printing a UUID prefix. - A friendly agent name —
GET /api/agents?name=…with the existing case-insensitive exact-match semantics.
Implementation:
cmd/staple-cli/chat_slash.go(new helpers):chatResolveTargetWithName— wrapper around the v2.42.0.1 resolver that returns both the UUID and a human-readable display name. UUID path falls back to a short-id display when the agents list lookup misses, so the operator never gets stranded with a blank success line.-
handleSwitchCommand— validates the args, resolves the target, opens a new session viachatOpenSession, and returns achatSwitchResult{NewChatID, NewTargetAgentID, NewAgentName}so the REPL caller can swap state atomically. -
cmd/staple-cli/chat.go(dispatcher): - Adds a
/switch//switch <arg>prefix-match branch right after the/escalateblock. On success, rotatescfg.chatID,cfg.targetAgentID, andcfg.sessionStartedAt, then prints the spec line✓ switched to <agent-name> (chat=<short-id>). Failures (empty arg, unknown agent, multi-match) surface inline and leave the original session untouched. - Help text gains the
/switch <agent>entry. shouldRecordToHistoryexclusion list gains/switch(bare and prefix forms) — same posture as/reset. Re-running a/switch ops-botvia up-arrow is not a workflow operators actually want; the agent-name argument also has the privacy smell that/find <query>and/escalate <reason>already treat as recall-unsafe.
Tests (cmd/staple-cli/chat_slash_test.go extends):
TestChatHelpText_ContainsSwitch— pins the/switchline in the canonical help block.TestShouldRecordToHistory_ExcludesSwitch— both bare and arg-bearing/switchshapes stay out of liner history; the near-miss/switcherIS recorded (it's a real prompt).TestChatLoop_SwitchCommand_ByName— drives the friendly-name resolution path through a mockedGET /api/agents?name=…and asserts the POST opens a fresh chat plus the success line names the agent (not just a UUID prefix).TestChatLoop_SwitchCommand_ByMe— themeshortcut short- circuits resolution; the server must not be hit for a name lookup.TestChatLoop_SwitchCommand_UnknownName— empty match set surfaces "no agent named …" inline and the POST does NOT fire, so the operator's current session keeps working.TestChatLoop_SwitchCommand_EmptyArg— bare/switch(and whitespace-only variants) print the usage hint without any HTTP round-trip.TestChatLoop_SwitchCommand_RotatesChatID— two/switchcommands in sequence; the second POST opens against the SECOND target, provingcfg.chatID+cfg.targetAgentIDactually rotated.
No migration; CLI-only.
v2.42.0.18 — 2026-05-30¶
feat(cli): chat REPL /search-notes — semantic search via pgvector —
the v2.42.0.11 /notes [N] command lists the most recent notes in
the bearer's company. That's fine for "what was added lately" but
useless when the operator is hunting for a note by CONTENT ("where
did I save the migration playbook?"). The v2.41.0.0 pgvector
foundation + v2.41.0.1 retrieval helper already make the data
available — v2.42.0.18 wires it into the REPL.
> /search-notes migration playbook
Search results for "migration playbook" (top 5):
[1] #abc12345 agent_authored Migration playbook
Step 1: backup the DB. Step 2: roll the deploy.
[runbook]
[2] #def56789 user_authored Old migration notes
Historical playbook from v1.
Server side (new endpoint, viewer+ gated):
Implementation:
internal/handler/notes_search.go(new):SearchNotesHandler(pool, encryptionKey)decodes the JSON body, looks up the company's first enabled openai-family provider viaproviderrouter.For, computes the query embedding through the provider'sEmbed, and runsquery.FindSimilarNotesfor the top-K nearest notes.- Status codes:
- 200 with
{"notes": [...]}envelope on success. - 400 on missing companyId, malformed JSON, or empty query.
- 502 on transient provider failure.
- 503 when no embedding-capable provider is configured (Anthropic-only / no provider at all) so the CLI can render a clear "provider doesn't support embeddings" line.
- 200 with
defaultNotesSearchLimit=5,maxNotesSearchLimit=100— matches the v2.41.0.1 retrieval helper default + theFindSimilarNotesinternal cap.cmd/server/routes.go— registers the route inside the per-company viewer+ group so RLS scoping pairs with the middleware-level access check.cmd/staple-cli/chat_search_notes.go(new):chatSearchNotesPostdoes one POST to the new endpoint and decodes the envelope.printSearchNotesrenders the matches as a fixed-column block (short id, kind, title, snippet, optional labels).buildSearchNotesSnippetcollapses whitespace and truncates to 80 chars with an ellipsis suffix.cmd/staple-cli/chat.go:- New
/search-notesprefix branch in the dispatcher (sits next to/notes). shouldRecordToHistoryexcludes both the exact/search-notesand/search-notes <query>so repeated semantic searches don't pollute the persistent recall buffer.cmd/staple-cli/chat_slash.go— help block adds the/search-notes <q>line.
Tests:
internal/handler/notes_search_test.go(new):TestSearchNotesHandler_MissingCompanyID— 400 on empty path variable.TestSearchNotesHandler_EmptyQuery— 400 on missing / whitespace / emptyqueryfield (3-case table).TestSearchNotesHandler_MalformedJSON— 400 on broken body.TestSearchNotesHandler_DefaultLimitClamppins the published constants (5 default, 100 cap).cmd/staple-cli/chat_search_notes_test.go(new):TestChatHelpText_ContainsSearchNotesCommandpins the help- text addition.TestShouldRecordToHistory_SearchNotesExcludedpins the exclusion (exact + prefix).TestBuildSearchNotesSnippet_Boundaries— whitespace collapse, exact-cap, over-cap+ellipsis, short pass-through.TestChatLoop_SearchNotesCommand_HappyPathdrives the dispatcher through a mockhttptest.Serverand asserts the request shape (POST + per-company path + query + limit) and rendered output (short ids, titles, body snippet).TestChatLoop_SearchNotesCommand_EmptyQueryUsageconfirms/search-noteswith no arg prints the usage hint and does NOT hit the server.TestChatLoop_SearchNotesCommand_HTTPErrorsurfaces the server's 503 friendly message inline without short-circuiting the REPL.TestChatLoop_SearchNotesCommand_NoMatchesrenders the(no notes match …)hint when the server returns an empty array.
Migration count¶
Unchanged from v2.51.0.22: 128.
v2.50.0.16 — 2026-05-30¶
feat(ops): audit dashboard bulk export of tag-selected rows —
v2.50.0.15 shipped per-row manual tagging stored in the operator's
browser localStorage. Operators triaging an incident often tag rows
"follow-up" / "false positive" / similar labels — but the only way
to share or escalate that set was to take a screenshot. The full
/admin/audit.csv download covers EVERY filter-matching row, which
defeats the purpose of having tagged the subset.
v2.50.0.16 adds a second control inside the existing "Filter by tag"
card: "Export tagged: __". Typing a tag and clicking the download
button composes a /admin/audit.csv URL that inherits the active
filter query string AND adds ?row_ids=<csv-of-source:id-pairs>
listing only the rows whose localStorage tag set matches the
operator's input. The server returns a CSV restricted to that
intersection, with the same staple-audit-<timestamp>.csv filename
pattern as the standard export.
Implementation:
internal/handler/ui_audit.go:parseAuditCSVRowIDs(raw)(new) — splits the?row_ids=query parameter into amap[string]struct{}set of canonical<source>:<id>keys. Blanks dropped silently; no-colon entries dropped defensively; duplicates deduped; set capped atauditCSVRowIDsMax(5000) to bound pathological inputs.filterAuditFeedBySelection(items, selected)(new) — applies the parsed set to the loaded feed using the same<source_table>:<ID>composite key the v2.50.0.15 template emits asdata-staple-audit-row-idon each<tr>. Empty set is the identity (defensive — an upstream parser bug must never silently truncate the whole CSV).UIAuditDashboardCSVplumbs the parse + filter into the existing load → filter → sort pipeline. Order:loadAndFilterAuditFeed→ server-side filter → row_ids narrowing → CSV write.internal/web/templates/audit/index.html:- New card row inside
[data-staple-audit-tag-filter]holding the "Export tagged" input + Download button + status span. The datalist autocompletes from the same per-tag list the in-place filter uses. - JS handler (
runBulkExport) collects every localStorage row key whose tag list contains the operator-supplied substring (case-insensitive, mirroring the in-place filter rule), appends them towindow.location.searchasrow_ids, and triggers the download. Empty input → "Enter a tag." status; zero matches → "No rows tagged \"X\"." status. Hidden by default; revealed only after the localStorage probe succeeds.
Tests:
internal/handler/ui_audit_test.go(extended):TestParseAuditCSVRowIDs_Table— empty / whitespace / single / two distinct / duplicates / blanks / non-colon entries / all-malformed cases.TestFilterAuditFeedBySelection_NarrowsRows— selection of two distinct rows yields a 2-row output.TestFilterAuditFeedBySelection_EmptySelectionPassesThrough— defensive identity guard.TestUIAuditDashboardCSV_RowIDsFilterNarrows— drives the HTTP handler with a fabricated?row_ids=...; asserts the response is header-only when no row matches AND the fabricated key does not leak back.TestUIAuditDashboard_BulkExportScaffolding— pins the rendered HTML markers the dashboard JS hooks into.
Migration count¶
Unchanged from v2.51.0.22: 128.
v2.41.0.2 — 2026-05-30¶
feat(cli): staple-cli reembed-notes — bulk re-embedding when the
embedding model changes — v2.41.0.0 / v2.41.0.1 shipped the
pgvector embedding foundation: the worker
(internal/worker/note_embedding.go) embeds notes whose body or
title changes, but it skips notes whose body is unchanged even when
the operator has swapped the embedding model. After a model swap (or
a post-restore catch-up that lost the note_embeddings table)
operators need an explicit CLI sweep to converge the corpus. The
worker on its own would never re-embed those rows.
reembed-notes closes that gap:
staple-cli reembed-notes [--company=<id>]
[--model=<name>]
[--batch=N] [--max-rows=N]
[--dry-run] [--yes] [--quiet]
Default model is text-embedding-3-small (matches the v1 schema dim
1536). The scan picks up rows whose stored embedding model doesn't
match — i.e. notes the worker would otherwise leave alone. Without
--model it falls back to "no embedding yet" rows only (the worker's
classic view). --dry-run prints the candidate count and stops;
--yes is required for the live run.
Per-batch progress lines surface the running count and estimated input tokens; the final summary tallies embedded / skipped (no-provider, no-embed-support) / failed plus the cumulative token estimate.
Failure posture: a single note's failure NEVER aborts the loop —
provider transient errors, upsert errors, and unsupported providers
(Anthropic) all surface as Warn logs and tally into the skip /
fail buckets. The loop terminates when a batch returns zero rows,
when --max-rows is hit, or when the operator Ctrl-Cs the process.
Implementation:
cmd/staple-cli/reembed_notes.go(new) — flag parsing, provider lookup cache (one DB read per company per run), batched scan / compute / upsert loop, per-batch + final summary.query.ListNotesForReembedding/query.CountNotesForReembedding(new,internal/db/query/note_embeddings.go) — bypass-scoped scan helpers. The filter (CompanyID / Model / Limit) composes the WHERE clause via positional args so the SQL injection surface is zero. Same per-batch ceiling as the worker (50 default, 500 max).cmd/staple-cli/main.go— dispatch the newreembed-notessubcommand + help-text entry.
Tests:
cmd/staple-cli/reembed_notes_test.go(new):TestEstimateInputTokens_Boundariespins the whitespace-split rule (empty, single, pair, tabs, runs of whitespace).TestDisplayCompany_AllVsScopedpins the(all)fallback.TestRunReembedNotes_TrailingArgsRejectedconfirms a stray positional arg lands on the usage hint.TestRunReembedNotes_RequiresYesForLiveRunpins the--yesguard for destructive runs.TestRunReembedNotes_DryRunRequiresDatabaseconfirms--dry-runstill needs$DATABASE_URL.internal/db/query/note_embeddings_test.go(extended):TestCountNotesForReembeddingDBexercises the three filter shapes (instance-wide / stale-model superset / scoped subset).TestListNotesForReembeddingDBconfirms the bounded scan returns rows with non-empty CompanyID.TestListNotesForReembedding_LimitClamppins the 500-row cap.
Migration count¶
Unchanged from v2.51.0.22: 128.
v2.43.0.22 — 2026-05-30¶
feat(ops): subagent dashboard aggregate metrics tile —
operators triaging a fan-out incident open /admin/subagents
and want the "headline numbers" before drilling into the row
table. v2.43.0.22 adds a "Last 24h" stat strip above the table:
The tile's left border + badge color flips on the trailing-24h failure rate:
- Green ("healthy") — failure rate < 5%
- Yellow ("elevated") — failure rate ≥ 5% AND < 20%
- Red ("high failure rate") — failure rate ≥ 20%
An empty window (no dispatches yet) renders green / "no runs" so the operator sees "all quiet" rather than a confusing "0% / red" misread.
Implementation:
query.AggregateSubagentRunStats(ctx, pool, window)(new,internal/db/query/heartbeat_runs.go) issues one SQL statement:COUNT(*)+COUNT(* FILTER WHERE status='X')for the three terminal statuses +SUM(cost_usd)+AVG / MAXoverEXTRACT(EPOCH FROM finished_at - started_at) * 1000, filtered bychild.parent_agent_id IS NOT NULLandcreated_at > now() - $1::interval.scope.WithBypass— the page is instance-admin cross-tenant. Operates on the RAWheartbeat_runsset, so the figures include runs that have aged out of the 200-row table cap on a busy instance.resolveSubagentStatsTile(new,internal/handler/ui_subagents_stats.go) projects the aggregate into the template-readySubagentStatsTileDatawith pre-formatted strings (TotalCostHuman =$X.XXXX, Avg/MaxDurationHuman =Xms/X.Xs/Xm Ys/Xh Ym). A DB error rendersAvailable=falseinstead of failing the page — same fail-soft posture as the rest of the operator tiles.classifySubagentStatsBand— pure function; band rule pinned by a table-test. Boundary policy: ties land in the more alarming band (5.0% → yellow, 20.0% → red).- Template:
internal/web/templates/subagents/index.htmlgrows a{{with .StatsTile}}block right after the page header, before the "Saved views" section. Each stat column has a faded uppercase label above the number.
Tests:
internal/db/query/heartbeat_runs_test.go(extended):TestAggregateSubagentRunStats_CountsMatchSeededRowsseeds 5 runs (1 pending, 1 running, 2 completed, 1 failed) on a fresh subagent, takes a baseline + post-seed snapshot, and asserts the deltas match the seeded set. Relative deltas keep the test deterministic on the shared dev DB.TestAggregateSubagentRunStats_WindowZeroRejectspins thewindow > 0guard.internal/handler/ui_subagents_test.go(extended):TestUISubagentsIndex_RendersStatsTilesmokes the rendered HTML for the tile's headers ("Last 24h", "Dispatches", "Total cost", "Failed", "Avg duration", "Max duration").TestComputeFailureRatePctpins the zero-division guard.TestClassifySubagentStatsBandpins every band boundary including the empty-window short-circuit.TestHumanShortDurationMscovers every band (0ms,<X>ms,<X.X>s,Xm,Xm Ys,Xh,Xh Ym).TestHumanShortWindowpins the "24h" / "d" label rule.
v2.51.0.22 — 2026-05-30¶
feat(ops): backup-health worker + history table —
v2.51.0.21 shipped staple-cli backup-health as an on-demand
composite check. v2.51.0.22 adds a ticker-based worker that runs
the same composite projection every
STAPLE_BACKUP_HEALTH_INTERVAL_HOURS (default 1, min 1, max 168)
and writes one row to a new backup_health_checks table per tick.
Opt out with STAPLE_BACKUP_HEALTH_CHECK=disable.
Migration 128 adds the table:
CREATE TABLE backup_health_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL CHECK (status IN ('ok','warn','crit')),
backup_status TEXT NOT NULL CHECK (...),
verify_status TEXT NOT NULL CHECK (...),
retention_status TEXT NOT NULL CHECK (...),
disk_status TEXT NOT NULL CHECK (...),
details JSONB
);
CREATE INDEX idx_backup_health_checks_at
ON backup_health_checks (checked_at DESC);
Storage is lower-case (CHECK-friendly); the CLI / worker emit
upper-case OK/WARN/CRIT for grep-ability and the lowercasing
happens at the write boundary in query.RecordBackupHealthCheck.
The JSONB details column carries the full
backuphealth.Result envelope so the per-check Detail string +
Value (age in hours, percent used) survives the round-trip.
Shared classification logic — the on-demand CLI and the new
worker both call backuphealth.Aggregate in
internal/backup/health.go. Any change to the classification
rule lands in one file and both substrates pick it up on
rebuild. The CLI's backup_health.go becomes a thin shim over
the shared package via type aliases + one-line wrapper functions
so existing test references continue to compile.
Import-cycle workaround — internal/health already imports
internal/worker for ResolveBackupDirAndRetention, so the
worker can't import internal/health directly. The disk-pressure
source is injected via a DiskPressureSource callback that
cmd/server/main.go constructs by closing over
health.CollectDiskSummary + DiskStatus*Pct. The shared
package also re-types the disk shape as
backuphealth.DiskPressure (a small projection of
health.DiskSummary) so it doesn't import
internal/health either.
Change notification — when a tick's composite status differs from the most recent prior tick (transition: OK→WARN, WARN→CRIT, CRIT→OK, etc.), the worker dispatches one Slack + Telegram DM per instance admin with a configured pairing. The DM body is single-line ("Backup health OK → WARN — backup=OK verify=WARN retention=OK disk=OK. Review: /admin/operations"). Same-status ticks emit zero DMs — a sticky CRIT doesn't pager-bomb the admin roster on every hourly tick.
The transition detector uses an in-process prior-status cache,
not the DB. A fresh deploy starts with priorStatus == "" and
never DMs on the first tick — even if the first tick lands a
CRIT. This avoids "deploy → boot → first tick → admin DM blast"
on a known-sticky-broken instance.
Operations panel — /admin/operations grows a new
"Backup health" tile (above the background workers section) that
reads the latest backup_health_checks row instead of recomputing
on every page load. Composite envelope at the top, four
per-signal rows below with the Detail string from the worker's
JSONB. A DB error renders the tile as "(no data yet)" without
500ing the page.
Query helpers (internal/db/query/backup_health_checks.go):
RecordBackupHealthCheck— write one row; lowercases status at the write boundary; takes optional JSONB details.GetLatestBackupHealthCheck— read the most recent row; nil on empty table; indexed lookup.ListAllRecentBackupHealthChecks— paginated DESC bychecked_at; limit clamped to 1000.
Tests:
internal/worker/backup_health_test.go(new): construction parses env; disable switch; interval clamp + parse-failure; per-signal status extraction; DM body shape (single-line, carries the per-check summary, prefixes publicBase); the transition-gate state machine (first-tick silent, same-status silent, transition fires, escalate / recover fire); the no-channels short-circuit doesn't 500.internal/db/query/backup_health_checks_test.go(new): round-trip + lowercasing; empty table returns nil; latest ordering; pagination DESC; CHECK constraint refuses unknown status tokens.
main.go wiring — cmd/server/main.go constructs the worker
right after the cost-budget alerter, sharing the same
slackSender + telegramSender + cfg.PublicBaseURL. The disk
closure is constructed inline so the worker package stays
decoupled from internal/health.
v2.47.0.20 — 2026-05-30¶
feat(ops): operations panel LLM provider connectivity test —
operators on /admin/operations want to verify each configured
LLM provider is reachable + the API key still works. v2.47.0.20
adds a Test all providers button + result tile.
The handler UIOperationsProvidersTest (POST
/admin/operations/providers/test, instance-admin only) probes
three providers concurrently:
- OpenAI —
GET /v1/modelsagainstOPENAI_BASE_URL(defaulthttps://api.openai.com) with the configuredOPENAI_API_KEY. Auth-check only, $0 cost. - Anthropic —
POST /v1/messageswithmax_tokens=1and a single 1-token user message, usingANTHROPIC_API_KEY. The smallest valid completion Anthropic accepts. ≈$0.0001 per call. - Bedrock —
sts:GetCallerIdentityinAWS_REGION/AWS_DEFAULT_REGIONusing the SDK default credentials chain (env vars / IAM role / IRSA / shared config). $0 cost. Picked over a Bedrock-native control-plane call because (a) thestsSDK package is already a transitive dependency via S3 backup wiring, and (b) STS is the canonical "are these credentials usable?" probe in AWS.
Per-call timeout 5s; total batch ceiling 7s. Each probe runs on
its own goroutine writing a deterministic slice slot — no shared
mutex. The handler never 500s; a failed probe surfaces as
Reachable=false + truncated error message.
Content negotiation:
- Default (HTMX,
Accept: text/html) returns theoperations/_providers_test_result.htmlpartial swap-in. Accept: application/jsonor?format=jsonreturns{results: [{name, configured, reachable, latency_ms, error_message, probed_at}, ...], tested_at: ...}for scripted callers.
"Configured?" gating:
- OpenAI:
OPENAI_API_KEYset. - Anthropic:
ANTHROPIC_API_KEYset. - Bedrock:
AWS_REGION(orAWS_DEFAULT_REGION) set.
Unconfigured providers surface as grey "(not configured)" badges with no probe issued — the operator sees "this isn't wired" rather than a confusing 401.
UI: a new "LLM provider connectivity" card on /admin/operations
(below the spend tile, above the backups widget) hosts the
button + HTMX target div. Each click swaps the result table
inline. The button tooltip surfaces the cost (≈$0.0001/click via
Anthropic) so admins aren't surprised at the trailing-day usage
report.
Tests: internal/handler/ui_operations_providers_test.go (new)
covers the auth gate, happy-path concurrent probe, unconfigured-
skip, error-path truncation, JSON envelope, HTML partial,
fake-server OpenAI/Anthropic happy + 401 paths, empty-key /
empty-region short-circuits, and the wantsJSON content-
negotiation rules.
Docs: docs/user/operators/providers.md gains an
"Instance-wide provider connectivity test" section with the
per-provider cost table.
v2.51.0.21 — 2026-05-30¶
feat(cli): staple-cli backup-health composite check —
operators want one CLI command that returns OK / WARN /
CRITICAL aggregating the various backup health signals.
Useful for cron, alerting integration, "is my backup story
working?". The existing backup-stats answers "how have
my backups been doing?" over a window; this command
answers the simpler operational question of "should I
be paging right now?".
v2.51.0.21 adds staple-cli backup-health. It aggregates
four signals — all DB or filesystem backed, no in-process
worker registry, so it runs from a recovery shell too:
- Backup freshness — most recent successful
backup_runsrow withkind IN ('create','cron'). - OK ≤ threshold
- WARN > threshold and ≤ 2× threshold
- CRIT > 2× threshold (or no row ever recorded)
- Default threshold 36h via
--max-age-hours=N(range 1..720). - Verify freshness — most recent successful
cron_verifyORverify_outcome='verified'. - OK ≤ 7d
- WARN > 7d (or never recorded — staleness is "we haven't checked", not "we're broken")
- Retention worker health — most recent
pruned_atonbackup_runsas a proxy for "the worker is taking ticks". - OK ≤ 2× the worker's interval (120m)
- WARN > 120m
- "no prune ever" treats as OK (idle / disabled)
- Disk pressure —
health.CollectDiskSummary. - OK < 85%, WARN ≥ 85%, CRIT ≥ 95%; mirrors the
/readyz+ Operations panel thresholds.
Composite status: WORST of all four. CRIT > WARN > OK.
Output:
- Default table:
📋 Backup health checkbanner +Status: <STATUS>+ four indented per-check rows with status + parenthesised detail. --json:{status, generated_at, checks: [{name, status, detail, value}, ...]}envelope.--quiet: canonical one-linestaple-backup-health <STATUS> backup=<s> verify=<s> retention=<s> disk=<s>summary for cron / alerting grep. Composes with$STAPLE_QUIET=1for fleet-wide defaults.
Exit codes:
0= OK1= WARN2= CRIT- (Plus
1for argument-shape errors, which is the existing CLI convention for "command errored".)
The dispatcher pattern uses sentinel errors
(errBackupHealthWarn / errBackupHealthCrit, both with
empty messages) so the exit code can promote past
1/2 without prefixing the canonical output line with
an error string.
Implementation:
cmd/staple-cli/backup_health.gois the new command.runBackupHealth(ctx, args) erroris the entry point. The composite is split into pure functions —classifyBackupFreshnessForHealth,classifyVerifyFreshnessForHealth,classifyRetentionForHealth,classifyDiskPressureForHealth,worstHealthStatus,humanHoursShort— so every band is testable in isolation.cmd/staple-cli/main.gowires thecase "backup-health":arm into the dispatcher withrunBackupHealthExitCode(err)driving the exit. The usage text gains abackup-healthblock.
Tests: cmd/staple-cli/backup_health_test.go pins
- TestResolveBackupHealthThreshold (input validation
/ clamping).
- TestHumanHoursShort (every band of the duration
formatter).
- TestWorstHealthStatus_Composite (CRIT > WARN > OK
collapse).
- TestClassifyBackupFreshness_Bands (10 cases:
empty / failure / verify-only excluded / boundary /
multi-row most-recent picking).
- TestClassifyVerifyFreshness_Bands (no-CRIT band,
verify_outcome handling, most-recent picking).
- TestClassifyRetention_Bands (no-prune = OK,
boundary, multi-row picking).
- TestClassifyDiskPressure_Bands (every
health.DiskSummary status including statfs-failed).
- TestAggregateBackupHealth_OrderAndComposite
(end-to-end fixture: 1 WARN signal collapses
envelope to WARN, checks emitted in fixed order).
- TestEmitBackupHealthTable (operator-facing layout
markers).
- TestEmitBackupHealthJSON (round-trip via
json.Unmarshal).
- TestPrintBackupHealthQuiet (exact one-line shape).
- TestRunBackupHealthExitCode (every sentinel + a
non-sentinel error).
Docs: docs/user/operators/backup-and-restore.md gains
a "Composite health check" subsection covering the
signals, output modes, exit codes, and a representative
cron + journalctl alerting setup.
Migration delta: 0.
v2.50.0.15 — 2026-05-30¶
feat(ops): audit dashboard manual tag column —
operators triaging an incident often want to bookmark
certain audit rows as "needs follow-up", "false positive",
or any free-text label. Until now the only triage state on
/admin/audit was the row's own audit data — nothing the
operator could overlay.
v2.50.0.15 adds a per-row Tags column with inline
editing, plus a top-of-page "Filter by tag" section to scope
the visible feed. Everything is localStorage-backed
(operator-private, browser-local) — same trust model as the
v2.50.0.5 saved views. No DB, no sharing flow, no
permissions story.
UI:
- New Tags column between Summary and the "view →" link.
Each cell holds a row of chips plus a
+edit button. - Clicking
+opens a small inline input. Comma-separated values are parsed, normalised to lowercase + trimmed, and saved onEnteror blur.Esccancels. - Each chip carries a
×that removes that tag. - A new top-of-page Filter by tag section appears once
localStorageis confirmed accessible. Typing filters the visible rows to those carrying a matching tag; the filter persists across page loads. The<datalist>autocomplete is populated with every known tag on the page.
Persistence:
localStorage["staple_audit_tags"]— JSON object mapping"<source>:<id>"→string[]of tags. The composite<source>:<id>is stable across feed refreshes / sorts / filters so a tagged row stays tagged.localStorage["staple_audit_tag_filter"]— the currently active tag filter string. Empty = no filter.
Graceful degradation:
- If
localStorageis unavailable (private mode, extension blocking, sandboxed iframe), every tag- related affordance stays hidden so operators don't see half-working controls. - Untaggable rows (no stable id, hypothetical future
source) hide their
+button — tagging would silently lose the label after the next feed refresh.
Template scaffolding (HTML hooks the JS depends on):
<th data-staple-audit-tags-col>Tags</th><tr data-staple-audit-row-id="<source>:<id>" …><td data-staple-audit-tags-cell><span data-staple-audit-tags-chips>(chip container)<button data-staple-audit-tags-edit>+</button>- Top-of-page:
[data-staple-audit-tag-filter]section #staple-audit-tag-filter-input+#staple-audit-tag-filter-known<datalist>+[data-staple-audit-tag-filter-clear]
Tests: internal/handler/ui_audit_test.go adds
TestUIAuditDashboard_TagsScaffolding pinning the static
markers (column header, filter section, key strings) and
asserting the per-row hooks land when rows exist. Catches
attribute renames that would silently orphan every
operator's stored tags.
Docs: docs/user/operators/audit-dashboard.md gains a
"Manual tag column (v2.50.0.15)" section covering the
UI, persistence keys, and the devtools snippet for
clearing every saved tag.
Migration delta: 0.
v2.47.0.19 — 2026-05-30¶
feat(ops): per-worker drill-down page — /admin/operations
shows every background worker in a single big table
(v2.47.0.0) and the v2.47.0.12 "Backup workers" tile
summarises the backup_* family. When operators spot a
worker in the table that's misbehaving (ticks not
advancing, error count climbing, "stale" badge), the only
place they can click through to is the raw Prometheus
/metrics scrape — fine for Grafana, lousy for "what is
THIS worker actually doing right now?". /metrics has every
worker interleaved with no recent-error context.
v2.47.0.19 adds a dedicated per-worker drill-down at
GET /admin/operations/workers/{name}. The page shows:
- Tick count, error count, computed error rate (%)
- Interval, last tick at (UTC + relative), next-expected-at (last + interval)
- Mode + last action from the most recent tick (same vocabulary as the index page)
- Recent errors ring buffer — up to 10 captured single- line error messages with timestamps, newest first
The recent-error ring is a new
TickStats.RecordErrorWith(msg) surface alongside the
existing counter-only RecordError(). Workers that adopt
the new method capture a one-line cause into the ring; ring
entries are bounded to 512 runes per message and 10
messages per worker (~5 KB telemetry budget per worker,
well under noise). Older entries fall off as newer ones
arrive. Workers still on RecordError() bump the counter
only and show "No recent errors captured" on the drill-
down. Adopt RecordErrorWith in new workers and migrate
old ones during routine touch-ups.
Implementation:
internal/worker/metrics.go:- New
ErrorRecord{At, Message}struct. TickStatsgainsrecentErrors []ErrorRecord,recentErrorsHead,recentErrorsCount,recentErrorsMu sync.Mutex. The ring uses ahead + countmodel —countgrows to capacity, thenheadadvances and new writes overwrite the oldest entry. The mutex is uncontended in the success path (onlyRecordErrorWithandRecentErrorstouch the ring).- New
RecordErrorWith(msg string)method bumps the error counter atomically AND appends to the ring. Empty / whitespace-only messages fall back to(no detail)so the drill-down never renders a blank row. Messages longer than 512 runes are truncated with a trailing ellipsis. - New
RecentErrors() []ErrorRecordreturns a defensive copy, oldest-first. - New
LookupStats(name string) *TickStatsis the drill-down handler's lookup-by-name surface. internal/handler/ui_worker_detail.gois the new handler. Instance-admin gated. 404 when the name isn't registered, 400 when the path-value is empty.computeWorkerErrorRate(ticks, errors)is a pure function so the rounding policy is testable in isolation; clamps at 100% defensively.internal/web/templates/operations/worker_detail.htmlrenders the page. Health badge mirrors the index page's vocabulary (ok/boot/warn/stale). Recent-error table renders newest-first to match thejournalctlreverse convention.cmd/server/routes.goregistersGET /admin/operations/workers/{name}.internal/web/templates/operations/index.html:- The backup-workers tile (
v2.47.0.12) now wraps each worker name in an anchor to the drill-down. - The main "Background workers" table does the same.
Tests:
internal/worker/metrics_test.go(extended):TestLookupStats_MissingAndPresent— registry lookup contract.TestRecentErrors_EmptyAndPopulated— empty state + basic append.TestRecentErrors_RingOverwritesOldest— overflow path; messages are zero-padded so lex == numeric ordering for the monotonic-increasing assertion.TestRecentErrors_EmptyMessageFallback—(no detail)guard for empty / whitespace input.TestRecentErrors_LongMessageTruncated— pathological-stack-trace cap.TestRecentErrors_ConcurrentSafe— race-detector coverage for parallel writers + readers.internal/handler/ui_worker_detail_test.go:TestUIWorkerDetail_NonAdminForbidden(auth-gate table).TestUIWorkerDetail_404OnUnknown.TestUIWorkerDetail_400OnEmptyName.TestUIWorkerDetail_HappyPathRendersAllFieldspins every operator-facing string.TestUIWorkerDetail_NoErrorsShowsEmptyStatecovers the no-errors branch.TestComputeWorkerErrorRate— table-driven rounding policy.
Docs: docs/user/operators/monitoring.md gains a
"Per-worker drill-down (v2.47.0.19)" section.
Migration delta: 0.
v2.49.0.6 — 2026-05-30¶
feat(ops): /readyz includes backup freshness check —
operators using /readyz as a "should I keep this node in
rotation?" signal can already see DB pressure
(v2.49.0.3), worker staleness (v2.49.0.1/2), channel
adapter health (v2.49.0.4), and disk pressure
(v2.49.0.5). The one dimension missing is the daily-backup
cron: a Staple instance can serve every request perfectly
while its last successful backup happened a week ago, and
the operator only finds out when they need to restore.
v2.49.0.6 wires the existing
query.GetLastSuccessfulBackupRun (introduced in v2.51.0.8
for the Prometheus metric) into a new backup row on
/readyz:
{
"name": "backup", "ok": true,
"detail": "healthy (last success 2.1h ago; threshold 36h)",
"last_success_at": "2026-05-30T08:42:14Z",
"since_hours": 2.1,
"threshold_hours": 36.0
}
Escalation:
- DB / query error →
ok=false("backup probe failed: ..."). Missing signal is treated as a problem — we don't quietly flip the row to OK because we couldn't look up the truth. - no successful backup ever recorded →
ok=false. since_hours>=threshold_hours→ok=false.- otherwise →
ok=true.
A failing backup row flips the response status to
not_ready and returns 503 just like every other escalation.
Operator opt-out: STAPLE_READYZ_BACKUP_CHECK=disable
(case-insensitive, whitespace-trimmed) skips the check
entirely for operators using external backup tooling
(Litestream, pg_dump on the host, etc.) where the
staple-cli backup row would always look stale. The row is
still emitted with ok=true + a "disabled" detail so the
operator can see the check exists but isn't gating their
probe.
Implementation:
ReadinessCheckstruct gainsLastSuccessAt(RFC3339 string, pointer for JSON omitempty),SinceHours(*float64),ThresholdHours(*float64). All optional via JSONomitemptyso every existing dashboard consumer keeps working.envReadyzBackupMaxAgeHours/defaultReadyzBackupMaxAgeHours(default 36, range [1, 720]). Same fallback-on-malformed contract as the other env-driven knobs: empty / non-numeric / out-of-range collapse to default with aWarnlog.envReadyzBackupCheckis the opt-out spelling ("disable"); narrow accepted value to avoid accidental disables from "off" / "0".backupProbe(ctx, pool)is the swap-for-test indirection aroundquery.GetLastSuccessfulBackupRun. Tests override it with a closure returning a syntheticbackupProbeResultso every age / no-backup / error branch is exercised without seedingbackup_runsrows.appendBackupCheck()evaluates the check and appends one row to the response.backupCheckReasonForText()projects the same logic to a one-line reason for the text/plain/healthz/readyhandler. Both consume the same probe + threshold.- The existing Prometheus
staple_backup_last_success_timestamp_secondsgauge (v2.51.0.8) is unaffected — both probes consume the same query, so a tightly-tuned Prometheus alert and a permissive/readyzthreshold can coexist (or vice versa) on the same instance.
Tests:
internal/handler/health_test.go: 4 env-resolver tests (TestReadyzBackupMaxAgeHours_{Defaults, FromEnv, OutOfRangeFallsBack, GarbledFallsBack}) plus aTestReadyzBackupCheckDisabledtable pinning the case-insensitive whitespace-trimmed opt-out spellings; 7 handler tests (BackupHealthy,BackupStaleFlipsNotReady,BackupNeverRecorded,BackupProbeErrorFlipsNotReady,BackupCheckDisabledSkips,BackupEnvThresholdHonored,HealthzReady_BackupStaleAppendsReason).withBackupProbewithDefaultDiskProbehelpers mirror the existingwithDiskProbeso the new tests don't double-fail on the disk row when the workstation FS happens to be busy.
Operator docs (docs/user/operators/monitoring.md) gain a
"/readyz backup-freshness row (v2.49.0.6)" section with
shape, escalation rules, env var matrix, and the opt-out
rationale.
Migration: 0.
Tag: v2.49.0.6.
v2.43.0.21 — 2026-05-30¶
feat(agents): subagent dashboard cumulative subtree cost —
v2.43.0.19/20 added a per-run Cost (USD) column with color
bands to the flat /admin/subagents index. For multi-level
dispatches operators want the next question answered: "what did
this whole subtree cost?" Scrolling the flat dashboard and
mentally summing descendants is the wrong UX; the tree page —
already the canonical "see the shape of this dispatch" surface —
should carry the figure inline.
v2.43.0.21 extends query.GetSubagentTreeFromRoot and the
/admin/subagents/tree template to roll up cost across the
trailing window.
Implementation:
- Query layer:
SubagentTreeNodegainsSelfCostUSD/SelfRunCount(computed in SQL via a second LATERAL join aggregatingheartbeat_runs.cost_usdwithin the suppliedcostWindow) andCumulativeCostUSD(populated handler- side).GetSubagentTreeFromRootnow takescostWindow time.Duration;costWindow == 0drops the time filter. A new exported helperRollupSubagentTreeCumulativeCost(nodes)walks the BFS-ordered slice in post-order, populates every node'sCumulativeCostUSD, and returns the forest-wide(totalUSD, totalRuns)pair for the top-of-page summary. - Handler:
resolveSubagentTreeCostWindow(getenv)reads the newSTAPLE_SUBAGENT_TREE_COST_WINDOW_DAYSenv var (default 7, range [1, 720], same fallback-on-malformed contract as the other env-driven knobs). The handler invokes the rollup, color-bands every node's Self/Cumulative cost cells via the existingSTAPLE_SUBAGENT_COST_WARN_USD/_CRITICAL_USDthresholds (shared with the flat dashboard), and pushesTotalSubtreeCostUSD,TotalSubtreeRuns,CostWindowDays,CostBandWarnUSD,CostBandCriticalUSDinto the template payload. - Template: a new
data-staple-tree-cumulative-summarycard sits below the page header reading "Total subtree cost (last Nd): $X.XXXX across M runs"; the card itself is color-banded so a runaway dispatch jumps out even when the operator hasn't expanded the tree. Per-row, every node now renders "self: $X / N runs" (always) and "subtree: $Y" (when the node has children OR the subtree figure exceeds its self figure). Both cells pick up the warn/critical color band individually. - Render-tree shape:
subagentTreeRenderNodegainsSelfCostBand/CumulativeCostBandstrings;buildSubagentTreeRender(nodes, thresholds)populates them at construction time so the template stays branch-light.
Tests:
internal/db/query/subagent_tree_test.go(new): pure-fnTestRollupSubagentTreeCumulativeCost_FlatChain(3-level linear chain),TestRollupSubagentTreeCumulativeCost_BranchingTree(2x2 fan with grandchildren under one branch),TestRollupSubagentTreeCumulativeCost_EmptySlice,TestRollupSubagentTreeCumulativeCost_OrphanedSubtreeplus a DB-backed integration test (TestGetSubagentTreeFromRoot_CumulativeCost) that seeds a 3-level tree, writescost_usdto a handful ofheartbeat_runs, and confirms the BFS projection + cumulative roll-up agree on >= 1.70.internal/handler/ui_subagent_tree_test.go: extended happy-path assertions for the "Total subtree cost (last Nd)" summary line, the per-row "self: $X" cells, and the cumulative-summary marker div; new resolver coverage (TestResolveSubagentTreeCostWindow_{Defaults, FromEnv, OutOfRangeFallsBack, GarbledFallsBack}), boundary coverage forsubagentTreeCostBand, and a render-builder cost-band pin (TestBuildSubagentTreeRender_PopulatesCostBands).
Migration: 0.
Tag: v2.43.0.21.
v2.42.0.17 — 2026-05-30¶
feat(cli): chat REPL /escalate command — when an operator notices a chat going sideways (agent stuck in a tool loop, hallucinated state, unexpected output), they want to flag the conversation for instance-admin review WITHOUT leaving the REPL and WITHOUT consuming a chat turn that would bait the agent into more bad output. v2.42.0.17 closes that gap.
/escalate <reason> POSTs the operator-supplied reason to a
new server endpoint which:
- writes a
system-rolechat_messagesrow whose content reads "⚠ Escalated by: " and whose metadata carries {"escalation":true, "reason":"...", "actor":"..."}so the audit dashboard + chat transcript both surface the flag without re-parsing the body; - fires a best-effort DM to every instance admin with a
configured Slack or Telegram pairing via the same
SlackDMSender/TelegramDMSenderfunction-pointer pair the GEPA notifier (v2.46.0.8) uses; - returns
200 {"escalated":true, "marker_id":"…"}. The CLI prints✓ escalation sent (marker=<truncated-id>).
Implementation:
- CLI side:
cmd/staple-cli/chat_escalate.goownsrunEscalate - the
postChatEscalationHTTP round-trip + thechatEscalateUsageconst. The REPL dispatcher inchat.gohandles both bare/escalate(prints the usage hint) and/escalate <reason>(callsrunEscalate)./escalateand/escalate <reason>join the no-record list inshouldRecordToHistory. - Server side:
internal/handler/api_chat_escalate.goexposesAPIChatEscalate(pool, slack, telegram, publicBase)returning anhttp.HandlerFunc. Auth gating reuseschatActorAllowed: nil actor → 401, cross-company non-admin → 403, instance admins always pass. Empty reason → 400. The marker insert usesquery.AppendChatMessagewith role="system" and the escalation metadata. DM dispatch runs in a goroutine viacontext.WithoutCancel(r.Context())so the HTTP response returns immediately. Per-admin failure logs at Warn and is swallowed. - DM body shape:
chatEscalationDMBodyclamps the reason at 500 chars and the assembled body at 1200 chars so a runaway reason can't blow up Slack/Telegram payloads. Permalink is relative whenpublicBaseis empty. - Wiring:
RegisterRoutesgrew two new params — the slack + telegram senders — forwarded fromcmd/server/main.go. The route lands on the existing/api/chats/{chatId}group asPOST /escalate. - Help text: both the canonical
chatHelpTextslash-command list and the long-formstaple-cli chat -husage block carry an/escalate <reason>entry. The CLI no-history- exclusion comment notes the v2.42.0.17 addition.
Tests:
cmd/staple-cli/chat_slash_test.goextended withTestChatHelpText_ContainsEscalate,TestShouldRecordToHistory_ExcludesEscalate,TestRunEscalate_HappyPath(mock POST, asserts the reason is forwarded verbatim and the truncated marker ID prints),TestRunEscalate_EmptyReason(no server hit, usage hint printed), andTestRunEscalate_HTTPError(500 path surfaces inline without crashing the REPL).internal/handler/api_chat_escalate_test.go(new) covers the pure-function builders (chatEscalationActorLabel,chatEscalationMetadata,chatEscalationDMBodyincluding the clamp + relative-link branches) plus the DB-backed handler matrix: happy-path member actor, no-actor → 401, foreign-company actor → 403, empty reason → 400, missing chat → 404, instance-admin cross-company bypass.
Operator docs and the chat help list both surface the new command. Migration: 0.
Tag: v2.42.0.17.
v2.43.0.20 — 2026-05-30¶
feat(agents): subagent dashboard cost threshold warnings —
v2.43.0.19 added a per-run Cost (USD) column to the
/admin/subagents index plus a "Total cost: $X.XXXX across N
runs" header. The column made expensive runs visible, but the
operator still had to scan the cost column row-by-row to spot a
runaway dispatch — a $5 outlier in a sea of $0.0024 rows didn't
jump out on a busy page.
v2.43.0.20 adds visual cost bands. Two env-derived thresholds
divide every row into one of three bands:
ok—CostUSD < STAPLE_SUBAGENT_COST_WARN_USD(default $1.00), or the row has no recorded cost.warning—WARN_USD <= CostUSD < STAPLE_SUBAGENT_COST_ CRITICAL_USD(default $5.00). Yellow row tint + bold yellow cost value.critical—CostUSD >= CRITICAL_USD. Red row tint + bold red cost value.
Implementation:
- Two new constants in
internal/handler/ui_subagents.go:envSubagentCostWarnUSD,envSubagentCostCriticalUSD, plus the defaults.resolveSubagentCostThresholds(getenv)reads the pair at handler-construction time with the same defaults-on-malformed contract asresolveSubagentMaxDepth. Inverted pairs (warn >= critical) fall back to defaults with a Warn log rather than publishing a nonsensical band ordering. costBandForUSD(cost, thresholds)classifies a single cost into one of"ok"/"warning"/"critical". Pure function so the test pins every boundary; nil →"ok"by design so legacy / currently-running rows stay neutral.SubagentRunRowViewis a new template projection that embedsSubagentRunRowand adds aCostBandstring. The embed keeps existing{{.CostUSD}}references resolving; new band check is{{.CostBand}}.classifySubagentRowsForBand(rows, thresholds)walks the row list and produces the views plus the rollup count + USD of rows in the warning or critical bands. The index handler wires those into the page data for the "expensive runs" summary card.- Index template: the row's
<tr>picks up an inlinebackground:rgba(...)based on the band; the cost cell itself picks upcolor:+font-weight:600for visibility in addition to the row tint. A summary card lands above the table wheneverExpensiveRunCount > 0showing the count + total USD across the warning/critical bands plus the effective thresholds + env-var names. - Detail template: the LLM telemetry card's
Cost (USD)row picks up the same color + a sub-row marker reading "exceeds warning threshold (>$X)" or "exceeds critical threshold (>$X)" inline. Same threshold values as the index page; the detail handler sharesresolveSubagentCostThresholds.
Tests:
internal/handler/ui_subagents_test.goextended withTestResolveSubagentCostThresholds_{Defaults,FromEnv,Inverted FallsBack,GarbledFallsBack},TestCostBandForUSD_Boundaries(covers nil, below-warn, exact-warn, between, just-under-crit, exact-crit, above-crit, zero), andTestClassifySubagentRowsForBand{,_Empty}. All tests pure-fn — no DB or HTTP recorder needed.
Migration: 0.
Tag: v2.43.0.20.
v2.48.0.3 — 2026-05-30¶
feat(ops): per-company cost budget + alerts —
v2.48.0.1 shipped the cross-tenant /admin/costs dashboard
and v2.47.0.17 shipped the operations-panel spend tile.
Together they answer "how much has the instance spent in the
last N days?", but nothing pushes when a single company crosses
a per-tenant budget — the operator has to look at the dashboard
on their own cadence. A canary deployment can burn through its
month's allocation by mid-week and nobody notices until the
next manual sweep.
v2.48.0.3 adds:
-
A new table
company_cost_budgets(migration 127) — one row per company carryingmonthly_usdandwarning_pct/critical_pctthresholds, pluslast_warned_at/last_critical_atre-fire stamps.CHECKconstraints pin the percent value space (1..100) and the cross-field ordering (warning_pct <= critical_pct), plus a non-negativemonthly_usdso a typo can't write a negative budget. -
A new worker
cost_budget_alerter— hourly tick (configurable viaSTAPLE_COST_BUDGET_ALERTER_INTERVAL_HOURS, 1..24 hours). Readscompany_cost_budgets, aggregates month-to-date spend per company acrosscost_events,chat_messages,heartbeat_runs, andevolution_traces, and dispatches a Slack + Telegram DM to every instance admin with a configured pairing when spend crosses either threshold. Re-fire is gated to once per 30 days per (company, level) pair so an over-budget company gets one DM per month, not one per tick. Critical wins over warning when both are eligible — a single over-budget company gets one DM at the critical level, not two. Opt out viaSTAPLE_COST_BUDGET_ALERTER=disable. Best-effort: DM send failures log at Warn and the stamp still writes so a partial delivery doesn't cause re-fire on the next tick. Reuses the sameSlackDMSender/TelegramDMSendershape wired up forgepa_notifier(v2.46.0.8). -
A new admin handler
UICompanyBudgetSetatPOST /admin/companies/{companyId}/budget— acceptsmonthly_usd(required, >= 0),warning_pct(default 80),critical_pct(default 100). Instance-admin gated. Validates at the form layer (400) before the SQL CHECK constraints engage. 303s back to the company dashboard so the operator sees the updated tile. -
A new tile on the per-company dashboard. When a budget exists, shows the month-to-date spend, the percent of budget, the band (ok / warning / critical), and the bar. When no budget exists and the viewer is an instance admin, shows the inline set form. Per-company admins without instance-admin see nothing — the budget is an instance-level decision.
Query helpers in internal/db/query/cost_budgets.go:
UpsertCompanyCostBudget(ctx, pool, *CompanyCostBudget) errorGetCompanyCostBudget(ctx, pool, companyID) (*CompanyCostBudget, error)— returns(nil, nil)on missListAllCompanyCostBudgets(ctx, pool) ([]CompanyCostBudget, error)— cross-tenant viaWithBypassMarkBudgetWarned(ctx, pool, companyID, level)— stampslast_warned_atorlast_critical_atMonthToDateSpendUSD(ctx, pool, companyID, now) (float64, error)— single-company rollup across the four substrate tables
Worker design notes (internal/worker/cost_budget_alerter.go):
decide()is a pure function overCompanyCostBudget+nowso the test pins the priority + gate logic without DB.crossesLevel()is a pure function over the budget + spend + level so the threshold arithmetic test stays branch-light. Zero-budget case explicitly handled:monthly_usd=0means "alert on any non-zero spend" rather than a divide-by-zero.eligible()covers the 30-day re-fire gate with the same signature regardless of level — the gate is uniform.
Tests:
internal/db/query/cost_budgets_test.go(new) — round-trip insert/update, nil-on-miss, MarkBudgetWarned column targeting, cross-field CHECK enforcement, MonthToDateSpendUSD on a seeded cost_events row.internal/worker/cost_budget_alerter_test.go(new) — pure-fn unit tests for decide, crossesLevel, eligible, budgetAlertMessage, plus env-parsing for newCostBudgetAlerterFromEnv.internal/handler/ui_company_budget_test.go(new) — auth gate, happy path with DB readback, four 400 validation paths, 404 for unknown companyId.
Docs: docs/user/operators/costs.md extended with a
"Per-company monthly budgets and alerts" section covering the
form, the worker behaviour, the env knobs, and what the alerter
explicitly does NOT do (refuse cost events; surface a
dashboard banner; write to budget_incidents).
Migration: +1 (127).
Tag: v2.48.0.3.
v2.47.0.18 — 2026-05-30¶
feat(ops): reset.sh rebuilds staple-cli too —
/mnt/Media/staple/reset.sh (the operator-owned refresh script
that runs on node4 on every deploy) has rebuilt
/usr/local/bin/staple-server from cmd/server/ since
v2.18.4, but it has never touched /usr/local/bin/staple-cli.
The deploy-host CLI binary therefore predated v2.45.0.2 and
was missing every subcommand shipped since:
staple-cli user-model show / set / clear / bulk-clear(v2.45.0.2,v2.45.0.3)staple-cli cost ...(v2.48.0.2)staple-cli flag ...staple-cli backup-list / backup-verify / backup-stats(including the--quietmode shipped inv2.51.0.18andv2.51.0.19)
Operators running these on node4 hit "unknown subcommand"
errors against the stale binary. The source tree was always
current — git reset --hard origin/main runs at the top of
reset.sh — but the build step only emitted the server. The
fix is one extra go build line right after the existing
server build, reusing the same -ldflags -X so the cli stamps
the same version, commit, and date metadata. The rm -f step
near the top of the script also now removes the cli so each
deploy starts clean.
Implementation:
- Patched
/mnt/Media/staple/reset.shon node4 in place. The previous version is preserved alongside asreset.sh.bak-v2.47.0.18(the same convention used by the earlier hardening patchesv2.47.0.5,v2.47.0.11,v2.47.0.13). - Three line-level edits: (a) extend the
rm -fblock to cover/usr/local/bin/staple-cli; (b) after the existingsudo go build ... ./cmd/serverline, addsudo go build -buildvcs=false -ldflags "${LDFLAGS}" -o /usr/local/bin/staple-cli ./cmd/staple-cli; (c)sudo chmod 755 /usr/local/bin/staple-cli. The header comment block was extended with av2.47.0.18paragraph describing the rationale. docs/user/operators/upgrades.mdextended: a newv2.47.0.18 hardeningcallout documents the change, the numbered "what reset.sh does" list now mentions both binaries in steps 4 and 6.- The deploy of this version IS the verification — after this
release
staple-cli helpon node4 surfacesuser-model,cost,flag,backup-list,backup-verify,backup-stats, etc., all matching the source tree.
Migration: 0. No source-code changes in the repo; the patch is to the operator-owned script outside the repo plus the documentation.
Tag: v2.47.0.18.
v2.45.0.3 — 2026-05-30¶
feat(cli): staple-cli user-model bulk-clear —
v2.45.0.2 ships staple-cli user-model show / set / clear for
per-user profile inspection and management. Operators on a busy
instance with churning rosters wanted a fleet-wide path for
"drop every model that hasn't been touched in N days" — clicking
through the per-company UI list one stale row at a time across
hundreds of rows isn't workable.
v2.45.0.3 adds a bulk-clear subcommand:
Defaults:
--days=90— a quarter of inactivity matches the "this user has moved on" cliff most rosters hit. Range[1, 3650]; values below 1 risk wiping freshly-set profiles, above 3650 invite arithmetic overflow on typos.- DRY-RUN. Without
--yesthe command prints what WOULD be cleared and exits 0 without writing.--yesflips to real deletion. The two-step gate is deliberate — bulk-clear deletes rows, not soft-disables, so a typo without --yes is the safety net.
Output (dry-run):
Stale user_models — older than 90 days (3 rows)
alice@example.com user=11111111 company=aaaaaaaa updated=2025-12-01T...
bob@example.com user=22222222 company=aaaaaaaa updated=2025-11-15T...
(user deleted) user=33333333 company=bbbbbbbb updated=2025-10-02T...
DRY RUN — 3 rows would be cleared. Pass --yes to delete.
Orphaned models (the linked users row has been deleted but the
user_models row remained — schema permits this) render as
(user deleted) so the operator knows the row is doubly stale.
--quiet collapses the summary to a cron-friendly single line:
- dry-run:
staple-user-model-bulk-clear ok dry-run days=90 would_clear=N - actual:
staple-user-model-bulk-clear ok days=90 cleared=N
Composes with STAPLE_QUIET=1 as a fleet-wide default — same
pattern the backup-* family and cost use.
Implementation:
- New file
cmd/staple-cli/user_model_bulk_clear.gocarriesrunUserModelBulkClear, the read helperlistStaleUserModels(cross-tenant viascope.WithBypass), the write helperdeleteUserModelsByIDs(single transaction for all-or-nothing semantics), and the two renderers (renderBulkClearDryRun,renderBulkClearDeletion). runUserModelincmd/staple-cli/user_model.gois restructured to route thebulk-clearsubcommand BEFORE the email-style argument validation — bulk-clear has its own flag-set + no email arg.- The schema query is
updated_at < now() - (N || ' days')::interval. Note: the brief refers to "last_updated_at" but the column onuser_modelsisupdated_at(per migration 108). It's bumped on everyUpsertUserModelwrite, so "no row touched in N days" maps 1:1.
Tests:
- Extended
TestRunUserModel_ArgShapeto assert the new help envelope surfacesbulk-clear. - New
TestRunUserModelBulkClear_ArgShapecovers positional rejection, --days bounds (below min / negative / above max), and the precedence chain (args validated before DATABASE_URL check). TestValidateBulkClearDayspins the [1, 3650] policy.TestPlural/TestShortIDpin the render helpers.TestRenderBulkClearDryRun_EmptyAndPopulatedandTestRenderBulkClearDeletionpin the dry-run and post-delete table shapes including the(user deleted)marker and the singular / plural row count.
Migration: 0. Pure CLI addition; no schema change. Tag: v2.45.0.3.
v2.48.0.2 — 2026-05-30¶
feat(cli): staple-cli cost command —
/admin/costs (v2.48.0.1) and the operations panel spend tile
(v2.47.0.17) answer the "what did we spend on LLMs?" question for
operators who can land on a browser. For cron-style burn checks,
SSH-only shells, and the long tail of "I need this in jq", we add
a cost subcommand to staple-cli that shares the same
aggregator as the dashboard.
DATABASE_URL=... staple-cli cost [--window=24h|7d|30d]
[--by=provider|model|source]
[--json] [--quiet]
Defaults — window 7d, by provider — mirror the dashboard so
operators don't have to relearn the layout. Window labels match
the selector strip on the page; an unknown value rejects loudly
(the CLI is for scripted invocation, where silent default-
fallback would hide a typo in a cron job).
Output shape (default table):
💰 LLM spend — last 7 days
Total: $1.2345 (42 calls, 16,824 in / 8,420 out)
By provider:
openai $0.8123 (28 calls)
anthropic $0.4222 (14 calls)
--json emits the full query.CostAggregate shape (Total +
ByProvider + ByModel + BySource), suitable for jq piping.
--quiet collapses to the canonical one-line summary suitable
for cron health checks:
Composes with STAPLE_QUIET=1 as a fleet-wide default — same
pattern backup, backup-stats, backup-list, and
backup-verify already use.
Implementation:
- New file
cmd/staple-cli/cost.gocarriesrunCost+ the pure helpers (resolveCostWindow,resolveCostBy,formatCostUSD,formatTokenCount,pickBreakdown,costWindowLabel) + the two emitters (emitCostTable,emitCostJSON). main.godispatcher gains thecostcase + a help entry next tobackup-stats. Help-text examples cover the default, the model breakdown, jq piping, and the cron-friendly invocation.- Reads
$DATABASE_URL, opens a pool, callsquery.AggregateCosts(same helper the dashboard uses), and renders. No new DB code — the aggregator is the contract.
Tests:
- Argument-shape table covering positional rejection, invalid --window, invalid --by, and the precedence chain (args validated before DATABASE_URL check).
- Pure-function table tests for
resolveCostWindow,resolveCostBy,formatCostUSD,formatTokenCount,pickBreakdown,costWindowLabel. - Renderer tests for
emitCostTable(happy path + empty window) andemitCostJSON(round-trip throughCostAggregateto lock the JSON tag schema).
Operator docs (docs/user/operators/costs.md) gain a
"staple-cli cost" section.
Migration: 0. Pure shell around an existing query helper. Tag: v2.48.0.2.
v2.47.0.17 — 2026-05-30¶
feat(ops): operations panel LLM spend tile —
/admin/operations already surfaces deploy / env / disk / backups
/ workers / flags at-a-glance. Operators wanted one more glance —
"are we spending money right now?" — without having to drill into
/admin/costs. v2.47.0.17 adds a compact "LLM spend (24h)" card
alongside the Deploy / Environment / Disk tiles at the top of the
page.
The tile carries:
- Total — trailing 24h USD spend rendered as
$X.XXXX(4 decimal places — same as the full dashboard). - Calls — billed call count contributing to the total.
- Top provider — highest-spend provider key in the window, or
—when nothing contributed. - View detailed breakdown → — link to
/admin/costs?window=24hso the drill-down lands on the same window the tile summarised.
A left-border color band signals the spend level:
- green when total
< $10(defaultSTAPLE_OPS_SPEND_WARN_USD) - yellow when
$10 <= total < $100 - red when total
>= $100(defaultSTAPLE_OPS_SPEND_CRITICAL_USD)
Both thresholds are env-tunable. Operators on a busier instance
dial them up; the values are captured at server boot to match the
rest of the panel's boot-once contract. Malformed / negative
overrides fall back to the defaults so the tile always renders.
A misordered pair (critical < warn) collapses to
critical = warn so banding never inverts.
Boundary policy: ties land in the more alarming band. A spend of
exactly $warn renders yellow; a spend of exactly $critical
renders red. Matches the disk tile's tie-break posture.
Implementation:
- New file
internal/handler/ui_operations_spend.gocarries the pure-function classifier (classifyOpsSpendStatus), the env threshold reader (resolveSpendThresholds+ the env-key constants), the formatter (formatSpendUSD), and the tile-payload assembler (resolveSpendTile). UIOperationsShowresolves the thresholds at construction time (boot-once), then callsresolveSpendTileper request. A DB error renders the tile asStatus="unavailable"with—placeholders instead of failing the page.- The tile shares
query.AggregateCosts(v2.48.0.1) with/admin/costs, so the two surfaces always agree to the visible decimal. - Template
operations/index.htmlgains the tile block alongside the Disk card. Border color and badge variant mirror the disk tile's vocabulary so the operator's visual model stays consistent.
Tests:
- Pure-function unit tests for
classifyOpsSpendStatus,resolveSpendThresholds(defaults / overrides / malformed / misordered pair),formatSpendUSD, andtopProviderFrom. - DB-backed render test seeds an empty cost window, asserts the
page returns 200, the tile header lands, total renders
$0.0000, the healthy badge appears, the drill-down href is correct, and the threshold legend (with the two env knob names) is visible.
Operator docs (docs/user/operators/costs.md) gain a new
"Spend tile on /admin/operations" section that describes the
banding policy and the env knobs.
Migration: 0. Pure render layer. Tag: v2.47.0.17.
v2.43.0.19 — 2026-05-30¶
feat(agents): subagent dashboard cost column —
/admin/subagents already showed duration, status, depth, and
parent → child wiring for every recent subagent heartbeat run.
Operators triaging a "runaway dispatch" incident wanted to spot
expensive runs at a glance without opening each row.
v2.43.0.19 adds the Cost column. Each row's cost_usd value
(from heartbeat_runs.cost_usd, populated by the LLM SDK on
heartbeat completion) renders right-aligned, 4 decimal places.
NULL values — legacy rows and currently-running rows — render
as an em-dash so the operator can tell "no cost recorded yet"
apart from "$0.0000 recorded".
A header summary line lands above the table:
The summary is only rendered when at least one row contributed a cost. When some rows have a recorded cost and others don't, the line surfaces both counts so the totals stay honest:
Implementation:
query.SubagentRunRowgrew aCostUSD *float64field. Pointer type so an absent value is distinct from a recorded zero.query.ListAllRecentSubagentRunsandquery.ListAllRecentSubagentRunsFilteredboth projecthr.cost_usdand scan into the new field.- The detail row (
SubagentRunDetailRow) already had its ownCostUSDfield bound from a separate detail-page query; field-shadowing leaves the detail-page render unchanged. The detail template atsubagents/detail.htmlalready surfaces cost from its own field, satisfying the brief's "same column on detail" requirement without a separate change. - New handler helper
sumSubagentRunsCostrolls the (post-filter) Items slice into the header summary numbers. Pure function so the unit test pins the rule without standing up the full handler.
Sort capability: the table doesn't pick up a new sort axis with this change. Operators sort by cost via the same filter form they already use (depth, status, parent) — adding a click-to- sort header on the cost column lands as a follow-up if the use case demands it.
Tests:
- Pure-function unit tests for
sumSubagentRunsCostcovering the mixed (nil + non-nil) case, the all-empty / all-nil guards, and the $0-counts-as-contributing-row rule. - A DB-backed render test seeds one subagent run with a known
cost_usd = 0.1234, hits the index handler, and asserts both the per-row "$0.1234" cell and the "Total cost:" header line appear. - A second DB-backed test seeds a run with NULL
cost_usdand asserts the rendered cell carries an em-dash empty marker. - The existing render-side smoke test (
RendersSavedViewsKey) conditionally checks the Cost header appears in the table whenever the dev DB has rows to surface.
Migration: 0. Schema unchanged — heartbeat_runs.cost_usd has
been there since migration 025_heartbeat_execution. Tag:
v2.43.0.19.
v2.42.0.16 — 2026-05-30¶
feat(cli): chat REPL /cost command — staple-cli chat
already had /stats (turn count, chars, age, agent) for the
"how big is this chat?" question. Operators wanted the next
question — "what did THIS chat cost me?" — without leaving the
REPL. The new /admin/costs dashboard (v2.48.0.1) answers the
instance-wide variant; this command answers the in-session
variant.
/cost pulls a 200-message window via the same GET
/api/chats/{id}/messages?limit=10000 (server-clamped to 500;
client-clamped to 200) endpoint /stats uses, sums the
non-NULL cost_usd / tokens_in / tokens_out fields, and
rolls the totals up per model. Top 5 most-expensive models
print; surplus models surface as "...and N more model(s) not
shown" so the picture stays bounded.
Output shape:
Chat cost so far:
Total: $0.0432 (12 messages)
Tokens in: 8124
Tokens out: 3290
By model:
claude-3-5-sonnet $0.0312 (5 messages, 6240 in, 2150 out)
gpt-4o-mini $0.0120 (7 messages, 1884 in, 1140 out)
"Messages" means messages that contributed a non-NULL cost or token figure — typically agent reply rows. User and system rows are skipped (they don't represent a billed LLM call).
Behaviour:
- No billing data → "No tokens billed in this chat yet." Distinguishes a fresh chat from a network failure that silently returned zero rows.
- NULL or empty model → folds under "unknown" (matches the
/admin/costsposture so the operator's mental model transfers between surfaces). - Tie-break sort: cost descending, model name ascending. Deterministic output so the test pins the ordering.
- HTTP / decode errors print inline; the REPL loop continues so the operator's current session keeps working.
The chat metadata projection (chatMessageEntry) gained five
nullable fields (tokens_in, tokens_out, cost_usd,
model, provider) — all are pointer types so existing fixture
slices in other tests stay valid without re-initializing.
Excluded from persistent liner history (same posture as
/stats, /last, /agents, etc.) — operators don't recall
/cost via up-arrow; they scroll their terminal back or run
the command again.
Help text on /help and the top-of-file slash-command list both
mention the new command.
Migration: 0. Server-side schema unchanged. Tag: v2.42.0.16.
v2.48.0.1 — 2026-05-30¶
feat(ops): instance-wide LLM cost dashboard — operators
triaging an instance-wide billing spike used to have nowhere to
land. The only cost surface was per-company
/companies/<id>/costs, which reads the dedicated cost_events
ledger. You had to pick a company, look at its page, then repeat
the exercise for every tenant.
v2.48.0.1 lands a new instance-admin dashboard at
/admin/costs?window=24h|7d|30d that fans out across three
substrate tables — chat_messages, heartbeat_runs, and
evolution_traces — and renders four surfaces in one page:
- Total spend — header card with total USD, total tokens in / out, and total billed call count for the trailing window.
- By provider —
openai,anthropic,bedrock, etc., one row per distinct provider across all three sources. NULL providers fold underunknownso they don't disappear. - By model — same shape pivoted by model name.
- By source —
chat_messages→heartbeat_runs→evolution_tracesin canonical order. Per-tile token columns are normalized soheartbeat_runs.prompt_tokensfeeds the "tokens in" column alongsidechat_messages.tokens_inandevolution_traces.tokens_in.
Default window is 7d (rolling burn view). Unknown ?window
values collapse to the default; the page always renders.
All amounts in USD to 4 decimal places — a $0.0001 figure is visible; a $0 cell means "no billed activity in this window for this row".
Why not just read cost_events? That's the budget pipeline's
ledger and any label-mapping bug in its ingest path would
silently hide spend on a dashboard built on top of it. The new
page reads the raw figures every LLM call wrote at the moment
it ran, so a gap there is operator-visible immediately.
The query layer (internal/db/query/costs.go::AggregateCosts)
runs in a single scope.WithBypass transaction — instance
admins legitimately read across tenants here and we want the
three sub-queries to see a consistent snapshot. Errors abort
with HTTP 500 so the operator sees the problem rather than
getting a silent zero-spend render.
New cost-bearing substrate tables land on the dashboard
automatically — add the source to costSourceTables() in
internal/db/query/costs.go and both the per-source tile and
the by-provider / by-model rows fold the new data in.
Sidebar nav: "Costs" entry under Admin, next to "Operations" and "Audit". Activates on both with-company and no-company sidebar variants.
Operator documentation at docs/user/operators/costs.md.
Migration: 0. Schema unchanged — all three sources already had
the cost columns from 066_chats_substrate,
025_heartbeat_execution, and 067_evolution_traces_and_feedback.
Tag: v2.48.0.1.
v2.42.0.15 — 2026-05-30¶
feat(cli): chat REPL /last command — staple-cli chat already
has /history [N] (chronological dump) and /find <query>
(substring search) for scrolling back through a chat, but neither
answers the day-to-day operator question "what did the SYSTEM just
say?" without trawling the whole dialogue. System markers —
failure messages, dispatched-subagent confirmations, retry counters,
operator banners pushed by the server — get buried under the
conversational turns that surrounded them.
/last [N] closes that gap. It pulls a 200-message window from
GET /api/chats/{id}/messages, filters to role="system", sorts
newest-first, and prints up to N (default 5, max 50).
Behaviour:
/last — newest 5 system messages
/last 12 — newest 12
/last 99 — silently clamped to 50 (matches /history)
/last abc — "/last N: …" parse error inline
/last 0 / <0 — "must be at least 1" inline error
no system messages → "No system messages in this chat yet."
Each entry prints with a [N] index marker, an absolute UTC
timestamp, the system role label, and the rendered markdown
body (ANSI-rendered when stdout is a TTY, raw when piped). Same
renderChatPrompt helper /system and /history use so the operator's
terminal experience stays consistent across the meta commands.
/last is excluded from the persistent liner history file
(STAPLE_CHAT_HISTORY / ~/.staple/chat_history) — same posture as
the other read-only meta commands. Operators don't recall
/last 12 via up-arrow; they scroll their terminal back or just
run /last again.
Migration: 0. Server-side schema unchanged; the command uses the existing /api/chats/{id}/messages endpoint. Tag: v2.42.0.15.
v2.50.0.14 — 2026-05-30¶
feat(ops): audit dashboard keyboard shortcuts — operators triaging an audit incident scan row after row, jump between source pages, and re-filter by actor. Doing all that with the mouse during a page incident is friction; v2.50.0.14 wires a vim-style triage layout every operator already knows from Gmail/GitHub-style queues:
j / ↓ — focus next row
k / ↑ — focus previous row
Enter — open detail link of focused row
/ — focus the actor filter input
Esc — blur active input
n — refresh page
? — toggle a keyboard-shortcuts help overlay
Bindings stand down whenever an <input> / <textarea> /
<select> / contenteditable surface is focused so they don't
fight browser text editing. Esc is the one exception: it
ALWAYS blurs the active input AND dismisses the help overlay
so an operator who lands in a text field can escape without
reaching for the mouse.
Same shortcuts ship on /admin/audit (the chronological feed),
/admin/audit/actor/{email} (the per-actor timeline), and a
smaller subset (Esc/n/?) on /admin/audit/insights (which
aggregates rather than listing).
The implementation hangs off four data-staple-audit-*
attribute hooks:
data-staple-audit-rows on the
data-staple-audit-row on eachThe help overlay is hard-rendered inert (display:none) and
toggled by attribute so no-JS clients see no flash of the
modal. Markdown render also pins the overlay scaffolding's
ARIA semantics (role="dialog", aria-modal,
aria-labelledby) so screen-reader operators get a real
dialog instead of "an unlabelled overlay just appeared".
Migration: 0. Schema unchanged. Tests cover the marker presence on all three pages so a future template refactor that silently drops a hook fails CI rather than the dashboard.
v2.49.0.5 — 2026-05-30¶
feat(ops): /readyz disk-usage check — the v2.47.0.16 disk tile
on /admin/operations surfaces root-filesystem usage, the systemd
journal size, and the backup-dir size so an operator can see "is
the host running out of room?" at a glance. Readiness, however,
was blind to disk pressure: the orchestrator would keep routing
new traffic at a host that was minutes from filling up /, with
backups, the worker queue, and systemd all about to start failing
hard. v2.49.0.5 closes the loop.
The disk-summary probe is now factored into a shared package
(internal/health) so /admin/operations, /readyz, and
/metrics read off one source of truth. The /readyz JSON
response gains a disk check row:
{
"name": "disk", "ok": true,
"detail": "root usage 47.0% < threshold 90.0% (free=18.2 GiB of 29.0 GiB; journal=1.4 GiB; backups=480.1 MiB)",
"pct_used": 47.0,
"total_bytes": 31135514624,
"free_bytes": 19575808000,
"journal_bytes": 1503238553,
"backup_dir_bytes": 503316480
}
Escalation:
- root Statfs failed →
ok=false("signal missing beats all-clear with zero") pct_used>=STAPLE_READYZ_DISK_PCT(default 90) →ok=false- otherwise →
ok=true
A failing disk row flips the response status to not_ready and
returns 503, exactly like the existing db_ping / worker / channel
escalations.
Override the threshold per-process:
Out-of-range / unparseable values silently fall back to 90. Read once at handler construction so probes stay fast.
The text/plain /healthz/ready alias mirrors the escalation —
over-threshold usage contributes disk <X.X>% to the
not ready: ... body so operators tailing LB logs see the
signal without parsing JSON.
/metrics adds three Prometheus gauges:
staple_disk_used_pct gauge root filesystem usage percent
staple_disk_free_bytes gauge root filesystem free bytes
staple_journal_bytes gauge sum of regular-file sizes under
/var/log/journal
The walk is cached server-side for 30 seconds so a scraper hitting
/metrics every 15s doesn't pay for a fresh du-style walk on
every probe. Operators alerting on disk pressure should prefer the
gauges to scraping the /readyz JSON — the gauges are stable
across scrape windows and easier to plug into existing Prometheus
alert rules.
Why 90% and not the v2.47.0.16 95% critical band: readiness lives one level higher than the tile. Its job is to drain a node BEFORE backups, the worker queue, and systemd start failing — 90% gives the orchestrator headroom to take the host out of rotation while there's still room for in-flight requests to complete.
Migration: 0. Schema unchanged. Tag: v2.49.0.5.
v2.46.0.13 — 2026-05-30¶
feat(agents): GEPA pending page agent-level auto-promote toggle —
on /admin/gepa/pending (the cross-tenant v2.46.0.12 batch-review
queue), each proposal row showed an agent's name and a "View →"
link to the per-proposal detail page. To set
agents.gepa_auto_promote an operator had to click into the agent,
flip the v2.46.0.2 toggle, and come back. That round-trip was the
common case for a noisy agent: see three pending proposals → decide
"this is fine, let it run unattended" → flip auto-promote → accept
the backlog. v2.46.0.13 collapses that into one click.
v2.46.0.13 reshapes the pending dashboard to group proposals by agent. Each agent gets a header row carrying:
- The agent name (linked to
/agents/{id}). - The pending count (
<N> pending). - An inline auto-promote checkbox tied to the v2.46.0.2 endpoint
POST /agents/{id}/ui/set-gepa-auto-promote. - An on/off badge reflecting the current state.
Proposal rows under each header are indented and labelled "proposal
The toggle's tooltip reads
"Auto-promote future proposals for this agent without manual
review." Operators clicking the checkbox trigger an async POST via
the existing endpoint; on 204 success the page reloads so the new
state lands in the rendered HTML. The endpoint's HX-Redirect target
is intentionally bypassed by the inline JS — the operator stays on
/admin/gepa/pending. Non-JS clients still get a working form (it
falls through to the endpoint's HX-Redirect URL, which is the agent
detail page — graceful degradation).
Implementation:
-
internal/handler/ui_agent_proposals_pending.go::pendingProposalViewgains aGepaAutoPromote boolfield populated from the resolved*query.Agent. Cached agent lookups stay one-per-unique-id. -
pendingProposalAgentGroup— new shape. AgentID + AgentName + GepaAutoPromote + CompanyID + Proposals slice. Stays in arrival order so the most-recently-active agent lands at the top of the rendered list. -
groupPendingByAgent— pure-function grouper. Iterates the view slice once, indexes by AgentID, preserves both inter- group (first-seen-first) and intra-group (input-order) ordering. Nil input → nil output, matching the "no work" contract. -
UIAgentProposalsPending— wires the newAgentGroups/AgentCounttemplate keys alongside the existingPending/PendingCountfor backward template compatibility. -
internal/web/templates/gepa/pending.html— rewritten table body to render onegepa-agent-headerrow per agent followed by indented proposal rows. Inline JS handler intercepts thechangeevent on everygepa-auto-promote-checkbox, rewrites the hiddenvaluefield with the operator's intent, fires afetchPOST to the existing endpoint, then reloads on 204 success. -
The checkbox is
disabled-d for the duration of the in-flight POST so a rapid double-click can't queue two writes. On HTTP error the checkbox flips back and an alert surfaces the status.
Tests (internal/handler/ui_agent_proposals_pending_test.go
extended):
-
TestGroupPendingByAgent_GroupsByAgentID— interleaves 5 proposals across 2 agents, asserts the result has 2 groups in first-seen order, per-group proposal counts match, the GepaAutoPromote flag propagates, and the intra-group order is preserved. -
TestGroupPendingByAgent_EmptyInput— nil in → nil out. -
TestUIAgentProposalsPending_RendersAutoPromoteToggle— seeds two agents with different auto-promote starting states + two proposals each, renders the page, asserts:- Exactly two agent-header rows render (
class="gepa-agent- header"count = 2, not 4 — pinning the per-agent invariant). - Each header carries a form posting to the existing v2.46.0.2 endpoint.
- The hidden
valuefield is"true"when the agent is currently off (so clicking flips on) and"false"when on. - Checkbox
checkedreflects DB state for each agent. - On / off badges render.
- Exactly two agent-header rows render (
-
containsInWindowtest helper added so per-agent assertions aren't satisfied by a different agent's form matching the same substring.
Migration delta: 0 (read-side handler + template, reuses the existing v2.46.0.2 endpoint).
locked-roadmap v2.46.0.13.
v2.43.0.18 — 2026-05-30¶
feat(agents): subagent tree page collapse/expand toggle —
/admin/subagents/tree/{run_id} (added in v2.43.0.16) was rendering
a deep dispatch as a flat indented list. For a 3+ level subtree that
turned into a wall of text — operators wanted to focus on one branch
without losing the parent context.
v2.43.0.18 wraps each branch node's children in a native HTML
<details>/<summary> disclosure widget. Three controls at the top
of the page drive the whole tree at once:
- Expand all — opens every
<details>. - Collapse all — closes every
<details>. - Persist state — saves the open/closed state per agent id in
localStorageso a page refresh restores the operator's reading position.
Default open-state: depth 0–1 open, depth 2+ closed. That lands the operator on a useful shape — root + immediate children visible, grandchildren one click away.
Branches whose subtree contains at least one pending or in-flight descendant run render with a yellow left-border accent + an "active subtree" badge so collapsed branches with running work still draw the eye.
Implementation:
-
internal/handler/ui_subagent_tree.go::buildSubagentTreeRender— flat → nested. Indexes everySubagentTreeNodeby agent id in one pass, then attaches each non-root row to its parent'sChildrenslice. Orphaned subtrees (parent not in the flat slice — should be impossible but defended against) become secondary render roots so operators debugging a partial walk still see them. -
markActiveDescendants— post-order DFS that propagates a pending/running latest-run status from any descendant up to every ancestor, populating each row'sHasActiveDescendantfield. The template renders the accent + badge against that field. -
isActiveStatus— small predicate exposed for table-driven tests. Onlypendingandrunningcount; terminal statuses (completed/failed/cancelled) andnilare quiet. -
flattenForLegacy+subtreeSize— keep the v2.43.0.16 flat.Nodesslice +NodeCountkeys populated so existing test probes (data-depth="2", "no children" inline notes) keep working without a parallel rewrite. -
internal/web/templates/subagents/tree.html— rewritten to render via a recursivesubagentTreeNodedefined-template pair: branches render inside<details>with an<open>attribute computed fromdefaultOpenMaxDepth; leaves render as a plain row with the v2.43.0.16 "no children" note. The inline<script>block wires the three controls + the delegatedtogglelistener that saves the open/closed state on every disclosure change when persistence is enabled. -
localStorage contract:
staple-subagent-tree-open:<agent_id>("1"/"0") per branch +staple-subagent-tree-persist("1"/"0") for the global on/off. Both keys are namespaced so they don't collide with other UI state.
Tests (internal/handler/ui_subagent_tree_test.go extended):
-
TestUISubagentTree_DisclosureControls— seeds a 3-level tree, renders the page, asserts at least one<details data-staple-tree-branch>wrapper is present, the three control button ids are wired, the localStorage key strings appear in the inline script, and the "depth 0–1 open" default-state hint is rendered. Skips when the dev DB isn't reachable (same pattern as the existing happy-path test). -
TestBuildSubagentTreeRender_HierarchyShape— pure-function test on a synthetic 5-node tree. Asserts the render forest has one root with 2 children, c1 has 2 grandchildren, c2 has 0;subtreeSizecounts every node;HasActiveDescendantpropagates from arunninggrandchild up to the root and along c1 but NOT along c2. -
TestIsActiveStatus_NilAndKnownStatuses— table test pinning the active-status predicate. -
TestBuildSubagentTreeRender_OrphanedSubtreeBecomesRoot— parent_agent_id pointing at a node not in the slice yields a secondary render root rather than a dropped node. -
TestBuildSubagentTreeRender_Empty— empty input yields(nil, nil)without panicking.
Operator docs
(docs/user/operators/audit-dashboard.md, subagents section) gain a
collapse/expand callout under the existing v2.43.0.16 tree-view
paragraph.
Migration delta: 0 (read-side template + handler change).
locked-roadmap v2.43.0.18.
v2.47.0.16 — 2026-05-30¶
feat(ops): operations panel disk + journal usage tile — the
staple host's 29G root partition has hit 100% multiple times during
active development (Go build cache + systemd journal + accumulated
backup dumps). Operators landing on /admin/operations want a quick
"is the host running out of room?" signal before backups, workers,
or systemd start failing.
v2.47.0.16 adds a "Disk" card next to the Deploy + Environment cards that surfaces three numbers:
- Root filesystem usage —
syscall.Statfs("/")total / free / used percentage with an inline progress bar. - Systemd journal size —
du-style walk of/var/log/journal. - Backup dir size —
du-style walk of theworker.ResolveBackupDirAndRetention()path (matches the Retention preview tile).
Status banding follows the rest of the panel's color vocabulary:
- critical at >= 95% root usage (red border).
- warning at >= 85% root usage (yellow border).
- ok below 85% (green border).
- unknown when the root
Statfserrors (neutral).
Warning + critical states render an inline "Cleanup hints" block
with the three commands an operator would actually reach for first
(sudo journalctl --vacuum-size=300M, lowering
STAPLE_BACKUP_RETENTION_DAYS, go clean -cache).
Implementation:
-
internal/handler/ui_operations_disk.go::collectDiskSummary/collectDiskSummaryWith— handler-side. Pure function shape: adiskRootStatProbe(defaultsyscall.Statfs) plus adiskDirSizeProbe(defaultfilepath.WalkDirsumming regular- file sizes). The probe-injection split keeps the test surface decoupled from the real filesystem. -
defaultDiskRootStat— castsBsizeand the block counts toint64so Darwin'sint32Bsizedoesn't truncate; usesBavail(notBfree) to mirrordf's reservation-aware free number. -
defaultDiskDirSize— silently skips permission-denied subtrees viafilepath.SkipDir, filters non-regular files so symlinks and sockets don't get double-counted, returns(0, nil)for missing paths (the "host has no/var/log/journal" case). -
classifyDiskStatus— pure threshold mapper; boundary cases (>=, not>) tip into the higher-severity band. -
internal/handler/ui_operations.go::UIOperationsShow— gains aDiskSummarytemplate key. The card render lives in the same flexbox row as the Deploy + Environment cards. -
internal/web/templates/operations/index.html— new "Disk" card with status badge, inline usage bar, journal + backup-dir rows, and the conditional Cleanup hints block.
Tests (internal/handler/ui_operations_disk_test.go):
classifyDiskStatustable test pinning the 85% / 95% boundaries + theRootStatfsFailedshort-circuit.collectDiskSummaryWithhappy-path / warning / critical band tests with synthetic probes.- Root-stat failure →
RootStatfsFailed=true,Status="unknown", root fields zeroed. - Dir-probe error → that field zeroes, the card still renders (the journal-permission-denied case on locked-down hosts).
- Nil probes don't panic; empty paths skip the walk entirely.
defaultDiskDirSizeagainst at.TempDir— sums two regular files (350 B), excludes a symlink, returns(0, nil)for a missing path and the empty-string input.
Operator docs (docs/user/operators/backup-and-restore.md) gain a
Disk card entry under the Operations panel monitoring section.
Migration delta: 0 (read-side handler + template).
locked-roadmap v2.47.0.16.
v2.42.0.14 — 2026-05-30¶
feat(cli): chat REPL /find <query> command — case-insensitive
substring search of the current chat's messages, with optional ANSI
highlighting on a TTY and a "... and N more" footer when the result
set exceeds the per-page cap.
Background: the REPL already had /history [N] to dump the last N
messages chronologically. /history is fine when an operator wants
to scroll back, but painful when they're hunting for a specific
phrase ("where did I last mention the deploy script?") that may
live in any of the last 500 messages.
/find <query> closes that gap:
> /find deploy
[1] 05-30 13:42 user Where is the deploy script?
[2] 05-30 13:42 agent The deploy script lives in scripts/deploy.sh.
[3] 05-30 14:01 agent Ran the deploy. Logs are clean.
On a TTY each match of deploy renders with an ANSI yellow
background (\033[30;43m) so the eye lands on the term inside the
surrounding snippet. The renderer auto-disables when stdout isn't
a TTY (test capture, pipe to grep, NO_COLOR set) — same gate as
the v2.42.0.6 markdown renderer (shouldColorize).
Implementation:
-
cmd/staple-cli/chat_find.go::findMatchesInMessages— pure function. Walks the message slice (oldest-first; the API returns them chronologically), lowers both sides for the substring compare, returns(matches[], totalBeforeCap). Cap defaults to 10 (chatFindMaxResults); thetotallets the caller print "... and N more" when the search was over-broad. -
buildFindSnippet— when the matched body fits insidechatFindSnippetLimit(160) the full body is used; otherwise a centered window around the FIRST match position is extracted with…/…markers. The matched substring (and every other occurrence in the window) is wrapped in ANSI escapes when highlighting is enabled. -
highlightAll— wraps every case-insensitive match in the snippet, not just the first, so multi-occurrence messages show every hit. Index math is on the lowered string; the rendered output is reconstructed from the original-cased body so capitalisation survives the highlight. -
collapseWhitespace— replaces runs of whitespace (including newlines) with a single space so each search-result row is one line. The detail view (/history) is still the place for multi-line markdown. -
printFind— REPL-side glue. Trims the query (empty argument printsUsage: /find <query>inline without an HTTP request), fetches up to 500 messages from/api/chats/{id}/messages?limit=500, runs the pure search, prints the matches + optional footer. -
cmd/staple-cli/chat.go::chatLoopWithConfig— new prefix-match dispatch branch for/findand/find <query>. Same shape as the/historyand/notesbranches. -
cmd/staple-cli/chat.go::shouldRecordToHistory— both/find(bare) and/find <query>skip the persistent liner history file. Re-running the same search via up-arrow isn't a typical workflow; operators recall via their terminal scrollback. -
cmd/staple-cli/chat_slash.go::chatHelpText— the/helpblock lists the new command on its own row between/historyand/stats.
Tests (cmd/staple-cli/chat_find_test.go):
- Pure aggregator: happy path, empty query, no matches, cap at
10 with
total=15, single highlight, multi-occurrence highlight, long-body snippet truncation, whitespace collapse - REPL wire: happy path through
httptest.NewServer, empty query short-circuits the server, no-match copy, many-match footer fires - Plumbing: help text contains
/find,shouldRecordToHistoryexcludes/find+/find <query>
Migration delta: 0 (read-side CLI feature).
locked-roadmap v2.42.0.14.
v2.51.0.19 — 2026-05-30¶
feat(cli): staple-cli backup-stats command — aggregate the
backup_runs audit table over a trailing window so an operator
can answer "how have my backups been doing?" without opening a
browser or writing SQL.
Background: operators have a chronological feed of every backup
run via the audit dashboard (/admin/audit?source=backup_runs),
and per-row drill-downs via backup-list --orphans. What was
missing was the aggregate read — "44 of 48 succeeded, failure
rate 8.3%, latest success this morning, biggest file 18 MiB" — in
a form that fits a cron health-check log line.
v2.51.0.19 fills that gap with a single new CLI command:
$ staple-cli backup-stats
Backup statistics — last 30 days
Total runs: 48
Success: 44 (91.7%)
Failure: 4 (8.3%)
By kind:
create: 12
cron: 18
cron_verify: 16
manual: 2
Latest success: 2026-05-30T02:00:00Z (12.0 MiB)
Largest: 18.0 MiB (cron, 2026-05-25T02:00:00Z)
Avg duration: 4.2s
Implementation:
-
cmd/staple-cli/backup_stats.go::runBackupStats— flag-parses--days=N(default 30, max 365),--json,--quiet; opens a pool against$DATABASE_URL; pulls up to 2000 recentbackup_runsrows via the existingquery.ListAllRecentBackupRuns; callsaggregateBackupStats; renders. -
aggregateBackupStats(runs, window, now)— pure aggregator with an explicitnowso tests can pin a deterministic window. Walks the row slice once; excludes rows older thannow-window; counts success / failure / by-kind; picks the latest sized success asLatestSuccess; picks the row with the biggest file size asLargestBackup; averages duration oversuccess && duration_ms > 0samples so verify-tick rows (always success but ~50ms) don't drown out real backups. -
sizeForStats(run)— prefers S3 size when present (the durable copy), falls back to local size. Same convention as the audit-dashboard row formatter so the CLI summary and the web dashboard agree. -
emitBackupStatsTable/emitBackupStatsJSON— split renderers so--jsonconsumers get a stable shape while the interactive operator sees the formatted block. JSON keys are snake_case, RFC3339 UTC timestamps, ints for counts, floats for the rate. -
--quietshort-circuits both renderers and emits the canonical one-liner:Same shape as the existing
backup-list --quiet/backup-verify --quiet/backup --quietfamily; composes withSTAPLE_QUIET=1as a fleet-wide default. -
formatStatsDuration— short operator-readable duration ladder (<1ms,Nms,N.Ns,Nm Ns,Nh Nm). Distinct fromformatAgebecause durations are typically sub-minute and the operator wants "4.2s" not "0s". -
roundOneDecimal— clamps the failure-rate percent to one decimal place via integer round-half-away-from-zero so the operator-visible value is stable across floating-point representations. -
cmd/staple-cli/main.go— newcase "backup-stats"in the dispatcher + updated usage block describing flags + env requirements. -
docs/user/operators/backup-and-restore.md— new "Aggregate stats" subsection under "Operator actions" with example output, cron pattern, and a pointer to the audit dashboard as the recovery-shell alternative when$DATABASE_URLisn't available.
Tests (cmd/staple-cli/backup_stats_test.go):
- aggregator: empty window, all-success, all-failure, mixed outcomes with verify-tick rows and an out-of-window row that must be excluded, S3-size-preferred case
- renderers: table copy assertions, empty-window message, JSON-shape key assertions
- argument shape:
resolveStatsWindowzero/negative reject + clamp + boundary,runBackupStatsmissing-$DATABASE_URLerror,--days=0reject, extra-positional usage error - small helpers:
formatStatsDurationladder,roundOneDecimal,kindLabel
Migration delta: 0 (read-side feature).
locked-roadmap v2.51.0.19.
v2.46.0.12 — 2026-05-30¶
feat(agents): cross-tenant GEPA pending-proposal batch review.
Operators can now triage every pending GEPA proposal across all
companies from a single page at /admin/gepa/pending, with row-
level checkboxes and two bulk-action buttons (Accept selected /
Reject selected). Sidebar carries a live "GEPA pending" entry with
a badge count so the queue depth is visible without opening the
page.
Background: GEPA proposals are written by the auto-evaluate worker
and decided one-at-a-time on the per-proposal detail page
(/agents/{id}/proposals/{pid}). As the worker matures the queue
accumulates — a backlog of dozens makes single-click triage
painful. v2.46.0.12 adds a cross-tenant queue surface that
collapses bulk triage into two clicks (select + submit) while
preserving the per-proposal detail-page drill-down for cases that
need closer review.
Implementation:
-
internal/db/query/agent_prompt_proposals.go::ListAllPendingAgentPromptProposalspulls everystatus='pending'row cross-tenant underscope.WithBypass. Caller (the new dashboard) is responsible for the instance-admin gate; the helper bounds the LIMIT (50 default, 200 cap) so a runaway worker can't blow the page out.CountAllPendingAgentPromptProposalssibling powers the sidebar badge. -
internal/handler/ui_agent_proposals_pending.go— three instance-admin-gated handlers:-
UIAgentProposalsPending(GET) renders the table with checkboxes, per-row agent + company labels (resolved viaGetAgentByID+GetCompanyByIDwith one cache lookup per unique ID), a Show-times-relative timestamp, and a truncated rationale column. -
UIAgentProposalsBulkAccept/UIAgentProposalsBulkReject(POST) iterate the selectedproposal_ids[]form values and call the existingquery.AcceptProposal/RejectProposalhelpers per row. Already-decided rows are classified as skipped via a substring match against the existing "proposal X already Y" guard error (the helpers don't expose a typed sentinel yet); transient lookup failures count as failed. Each path 303-redirects to/admin/gepa/pending?accepted=N&skipped=M&failed=Kso the flash banner surfaces the per-batch tally. -
lookupPendingProposalCompanyresolves a proposal'scompany_idby re-scanning the cross-tenant pending list —GetProposalByIDrequires a company up front (it's tenant-scoped), so we re-use the same WithBypass helper. O(N) in pending count per call, which is fine for the 150-row page cap. -
dedupeAndTrimIDs,truncatePendingRationale,parsePositiveQueryInt,isAlreadyDecidedError— small pure-function helpers with table-driven tests.
-
-
internal/handler/render.go::buildLayoutContext— instance- admin branch now callsCountAllPendingAgentPromptProposalsand threadsGepaPendingCountinto the layout map. Failed lookups log and fall through with the zero value so a transient DB blip doesn't disable the sidebar. -
internal/handler/render.go::activePageFromTemplate—gepa/prefix maps to the newgepa-pendingactive-page key so the sidebar link highlights when the dashboard is rendered. -
internal/web/templates/gepa/pending.html— table with a select-all checkbox header, per-row checkboxes, a single shared reason field for bulk-reject, and an inline JS snippet that confirms a no-selection submit and nudges the operator to supply a rejection reason before posting bulk-reject. Empty-state copy points at the audit-feed source filter for the historical view. -
internal/web/templates/layout.html— new "GEPA pending" sidebar entry placed next to the audit dashboard (both are instance-admin operator queues). Renders a badge with the cross-tenant count whenGepaPendingCount > 0. -
cmd/server/routes.go— three new routes inside theRequireInstanceAdminblock:GET /admin/gepa/pending,POST /admin/gepa/bulk-accept,POST /admin/gepa/bulk-reject. -
internal/handler/ui_agent_proposals_pending_test.go— auth-gate table (GET + both POST handlers; nil actor / non- admin / api_key), happy-path tests for the GET dashboard (three seeded pending rows render with checkboxes and detail links), bulk-accept happy path (two seeded rows both flip to accepted, 303 withaccepted=2), bulk-reject happy path (the shared reason flows through torejected_reason), mixed-already-decided test (one pending + one pre-rejected, asserts the bulk handler classifies the pre-rejected as skipped rather than failed), missing-reason and no-selection 400 cases, and table-driven pure-function tests for the four helper utilities.
Migration delta: 0 (read-side feature; no schema change). Counter remains at 126.
locked-roadmap §4.9 v2.46.0.12.
v2.43.0.17 — 2026-05-30¶
feat(agents): subagent dashboard surfaces a "depth limit" badge on
runs whose agent sits at or beyond STAPLE_MAX_SUBAGENT_DEPTH
(default 3) — they cannot spawn further children. Index, detail, and
tree views all get the chip; the index page adds a header summary
"X of Y runs at the spawn depth limit" so operators can scan totals
without walking every row.
Background: the runtime spawn rule is "parent.subagent_depth + 1 >
MaxDepth → refuse" (see query.SpawnSubagent). Until v2.43.0.17 the
dashboard surfaced the bare depth number with no indication that a
particular branch was at the spawn ceiling. Operators debugging a
3-level dispatch had no in-page signal that the deepest agents were
locked out — they had to keep STAPLE_MAX_SUBAGENT_DEPTH in their
head and compare manually. The new badge flips this into a glance.
Implementation:
-
internal/handler/ui_subagents.go::defaultSubagentMaxDepth+resolveSubagentMaxDepth(getenv)— env-var resolution mirroringquery.envIntPositive. Missing / empty / non-numeric / non- positive inputs collapse to the default 3 so a typo never silently disables the badge. -
internal/handler/ui_subagents.go::countSubagentRunsAtDepthLimitwalks the filtered row slice and counts rows whoseSubagentDepth >= maxDepth. Rows whose depth EXCEEDS maxDepth (rare but possible if the operator dropped the env var after a deep dispatch landed) still count — they're just as locked-out as a depth-3 one.maxDepth <= 0returns 0 to avoid alarm spam from an instance whose env var was set to a typo. -
internal/handler/ui_subagents.go::UISubagentsIndexresolvesMaxSubagentDepthfrom the env at request time, computesDepthLimitCountover the filtered rows, and threads both into the template data map. Resolved per-request (not cached process-wide) so the env-flag UI can flip the override without a restart, matching the existing posture inDefaultSubagentLimits. -
internal/handler/ui_subagent_detail.go::UISubagentRunDetailthreadsMaxSubagentDepthso the detail-page header badge fires on the same threshold. -
internal/handler/ui_subagent_tree.go::UISubagentTreethreadsMaxSubagentDepthso the per-row chip in the tree view fires on the same threshold. -
internal/web/templates/subagents/index.html— new card above the table rendering "🚫 X of Y runs at the spawn depth limit" whenDepthLimitCount > 0, with a hint linking to/admin/operationsfor the env override. The per-row depth badge gains a sibling "🚫 depth limit" pill (badge-failed class) whenSubagentDepth >= MaxSubagentDepth. Both elements stay invisible on a quiet instance. -
internal/web/templates/subagents/detail.html— header gains the same "🚫 depth limit" pill next to the existing depth number. -
internal/web/templates/subagents/tree.html— each agent row gains the depth-limit pill so operators scanning a deep tree spot the leaves that can't spawn further.
Tests:
-
internal/handler/ui_subagents_test.go: (1) TestResolveSubagentMaxDepth_Table walks every env-var branch (missing / empty / zero / negative / non-numeric / valid) pinning the 3-default fallback. (2) TestCountSubagentRunsAtDepthLimit_Table walks a fixed row slice across maxDepth=0/1/3/4/5 and negative inputs. (3) TestCountSubagentRunsAtDepthLimit_EmptyAndNil pins the zero-row edge case so a brand-new instance doesn't fire the summary row. (4) TestUISubagentsIndex_DepthLimitBadgeRendersOnSeededRuns seeds a 4-level dispatch (root depth 0 → child 1 → grandchild 2 → ggrandchild 3) withSTAPLE_MAX_SUBAGENT_DEPTH=3, creates a heartbeat run per node, and asserts the badge fires only on the depth-3 row when filtering by parent=gchild. The same handler invocation with parent=child confirms the depth-2 view does NOT render the badge. (5) TestUISubagentsIndex_DepthLimitBadgeRespectsEnvOverride re-runs the seed withSTAPLE_MAX_SUBAGENT_DEPTH=2and confirms the depth-2 row now DOES get the badge. -
All five new tests pass with
-race -count=1on node4 Go toolchain.
Code: ~70 lines handler plumbing (resolver + counter + 3 handler data-map plumb points); +14 lines template (index summary + 3 per-row badges); +280 lines test.
Migrations: none (zero schema delta; migration counter still 126).
v2.50.0.13 — 2026-05-30¶
feat(ops): /admin/audit/insights grows a "Daily breakdown" tile
between Top actors and Source activity. Renders a per-day total
event chart across the active window — 24h → 1 row (today UTC), 7d
→ 7 rows, 30d → 30 rows — with bars scaling to the busiest day so
quiet days remain visible without being dwarfed by a 10× spike.
Background: until v2.50.0.13 the insights page surfaced aggregates over the whole window (top failures, top actors, source activity) but nothing showed the temporal distribution of events. Operators triaging "did the failure rate spike on Tuesday or was it distributed?" had to either scroll the chronological feed or query the SQL directly. The new tile renders a daily breakdown in the same page so the question is one glance, not one trip to psql.
Implementation:
-
internal/db/query/audit_aggregate.go::AuditInsightsResultgrowsPerDay []DayBucket. Existing fields are unchanged so older callers reading TopFailures / TopActors / SourceActivity continue to work unmodified. -
internal/db/query/audit_aggregate.go::DayBucket— new type carrying Date (UTC midnight at start of day), Counts (source → count map), and Total. Counts is always non-nil even on quiet days so the renderer doesn't have to nil-guard. -
internal/db/query/audit_aggregate.go::auditInsightsBucketCountpins the window → bucket-count mapping: 24h → 1, 7d → 7, 30d → 30. Other durations round-floor to the nearest day with a minimum of 1 so the renderer never receives an empty slice. -
internal/db/query/audit_aggregate.go::buildAuditPerDayBucketsbuilds the canonical bucket slice: oldest-first, last bucket = today (UTC). Pure function — boundaries are pinned without DB. -
internal/db/query/audit_aggregate.go::dayBucketIndexmaps an event timestamp into its bucket index, returning -1 for timestamps outside the rendered window (cusp case when the cutoff and the bucket boundary disagree by < 1 day). -
internal/db/query/audit_aggregate.go::collectAuditPerDayfans out oneGROUP BY DATE_TRUNC('day', …)query per surfaced audit source over the cutoff..now range, merges every (day, source) → count tuple into the bucket slice. Per-source errors log via slog but don't take the chart down — a partial daily breakdown is still useful. Usesscope.WithBypass(instance-admin only, cross-tenant rollup). -
internal/db/query/audit_aggregate.go::AuditInsightscalls collectAuditPerDay after the existing TopFailures / TopActors / SourceActivity projections. Per-day fan-out runs after the main SELECT so a slow per-day query doesn't block the rest of the page. -
internal/handler/ui_audit_insights.go::auditInsightsPerDayRowis the renderable row shape.buildInsightsPerDayRowsprojects DayBuckets into the row shape with pre-rendered Unicode bars at 40-char scale. Scaling matches the SourceBars rhythm so the two charts line up visually when both render on the same page. -
internal/web/templates/audit/insights.html— new "Daily breakdown"<div class="detail-section">between Top actors and Source activity. Renders a 3-column table (Date / Total / Activity-bar) using the samedata-table/ monospaced-bar styling as the existing Source activity tile. -
docs/user/operators/audit-dashboard.md— new "Insights page" section documenting the four tiles, including the new Daily breakdown.
Tests:
-
internal/db/query/audit_aggregate_test.go— four new tests: (1) auditInsightsBucketCount table walking 24h / 7d / 30d / edge cases, (2) buildAuditPerDayBuckets sub-tests for each canonical window pinning len + first/last Date + ascending order + empty Counts maps, (3) dayBucketIndex table walking today / yesterday / 6-days-ago / outside-window / future, (4) AuditInsights_PerDayCoversWindow against the dev DB for each of 24h / 7d / 30d asserting PerDay length, that a seeded backup_runs row rolls up into the right bucket, and that per-day Total reconciles against the sum of Counts. -
internal/handler/ui_audit_insights_test.go— three new tests: (1) TestUIAuditInsights_DailyBreakdownRowCount drives the handler with window=24h / 7d / 30d, locates the Daily breakdown section's tbody, and asserts the row count matches the window, (2) TestBuildInsightsPerDayRows_- CanonicalScaling pins the bar scaling against fixed inputs (max → 40 chars, silent → empty, tiny → at least 1 cell, Date formats YYYY-MM-DD), (3) TestBuildInsightsPerDayRows_- AllZero pins the no-div-by-zero path. The existing TestUIAuditInsights_RendersDefaultWindow also gains a "Daily breakdown" want string.
Code: ~150 lines query layer (DayBucket + helpers + collectAuditPerDay); ~70 lines handler renderer; +35 lines template; +260 lines tests; +25 lines docs.
Migrations: none (zero schema delta; migration counter still 126).
v2.42.0.13 — 2026-05-30¶
feat(cli): staple-cli chat REPL grows a /whoami slash command
that prints the bearer agent identity (name, role, truncated UUID),
the active company (name when known, truncated UUID), the chat ID
with a relative-age "started
Background: operators in a multi-tenant deployment routinely
context-switch between bearer keys (admin-on-staple-prod vs
ops-on-customer-A vs ops-on-customer-B) and the REPL gave them no
in-session way to confirm "yes, this is the bearer I think it is."
The existing /agents and /system commands hit the server; when
the network is flaky, neither helps. v2.42.0.13 adds a one-shot
identity block that reads exclusively from the chatLoopConfig
state populated at session bootstrap.
Implementation:
-
cmd/staple-cli/chat_slash.go::printWhoami— new render function. Emits a four-line block: "Agent:( ) — uuid ", "Company: — uuid ", "Chat: (started )", "Server: ". When companyName is empty (the server doesn't expose a company-name JSON endpoint reachable by an agent bearer; the field is plumbed best-effort), degrades to "(name unavailable)". When sessionStartedAt is zero (older chatLoopConfig call sites), degrades to "(unknown)" rather than a 50-year-old "started 50y ago" stamp from the zero-value time.Time. -
cmd/staple-cli/chat_slash.go::shortenUUID+ thechatWhoamiUUIDShort = 8constant — shared truncation helper. 8 chars matches the dashboard short-id rhythm used in /agents /issues /notes /secrets so the operator can correlate UUIDs across surfaces. -
cmd/staple-cli/chat.go::chatLoopConfig— three new optional fields:bearerIdentity *chatAgentIdentity,companyName string,sessionStartedAt time.Time. Older chatLoopConfig call sites (every TestChatLoop_ and TestChatCommand_* in the existing suite) leave them zero/nil; the renderer degrades gracefully so the back-compat surface stays clean. -
cmd/staple-cli/chat.go::runChat— populates the three new fields after chatProbeIdentity returns. sessionStartedAt is taken at chat-open time (just before chatLoopWithConfig fires) so the relative-age stamp is wall-clock honest. -
cmd/staple-cli/chat.go::chatLoopWithConfig— adds thecase "/whoami":branch to the local-command switch. /reset now refreshes sessionStartedAt so /whoami's "started" reflects the new chat session, not the original. -
cmd/staple-cli/chat.go::shouldRecordToHistory— adds /whoami to the persistent-history exclusion list. Same posture as /stats, /agents, /secrets — a one-shot debug-glance command, not a prompt the operator wants to re-summon via up-arrow. -
chatHelpTextand the runChat usage block both gain the /whoami line so /help andstaple-cli chat --helpstay in lockstep.
Tests:
-
cmd/staple-cli/chat_slash_test.go(new): seven tests covering (1) /whoami help-text entry pinned, (2) shortenUUID table walking empty / short / exact-cap / over-cap / full-UUID, (3) chatLoopWithConfig integration with full bearer identity + chat ID + session start, asserting all rendered substrings (name, role, truncated bearer UUID, company name, truncated company UUID, chat ID prefix, server URL, "started" marker), (4) the safety net when bearerIdentity is nil (renders "not authenticated", REPL loop continues), (5) printWhoami with empty companyName degrading to "(name unavailable)", (6) printWhoami with zero sessionStartedAt degrading to "(unknown)", and (7) /whoami in the shouldRecordToHistory exclusion list. -
The /whoami integration test additionally pins the offline contract: the test httptest.Server fails the test if any HTTP request lands during /whoami execution. /whoami MUST stay network-free.
Code: +96 lines printWhoami / shortenUUID / chatLoopConfig plumbing; +176 lines test.
Migrations: none (zero schema delta; migration counter still 126).
v2.51.0.18 — 2026-05-30¶
feat(cli): staple-cli backup (and restore / backup-verify /
backup-list) learn a uniform --quiet flag, plus the
STAPLE_QUIET=1 env-var fallback for fleet-wide cron deployments.
The multi-line decorative output stays on for interactive operators;
cron callers get the single
staple-<verb> ok <details> line per invocation so log digests
stay readable.
Background: every backup-family subcommand printed a multi-paragraph
summary on success — useful for an operator at a terminal, awful for
a cron line that runs staple-cli backup every two hours into a
journalctl tail. Operators were piping output to /dev/null and
losing the failure messages along with the noise. v2.51.0.18 splits
the two concerns: success output stays multi-line for interactive
sessions; the cron-friendly --quiet mode keeps exactly one line on
success (and full errors on failure to stderr).
Implementation:
-
New
cmd/staple-cli/backup_quiet.gocarries the shared plumbing:resolveQuietFlag(three-way precedence: explicit flag wins,STAPLE_QUIETenv fills in, default off);stapleQuietEnvSet(the strconv.ParseBool gate matching the existingSTAPLE_BACKUP_NO_VERIFYposture);quietWriter(an io.Writer that drops writes when quiet);printSummaryLine(the canonical "staple-ok " emitter); andflagWasExplicitlySet(so resolveQuietFlag can distinguish "operator didn't pass --quiet" from "operator passed --quiet=false"). -
backup.go::runBackupaccepts--quiet. The "Writing backup to ..." preamble, the multi-line "Backup complete:" block, and the row-count tally are all routed through a quietWriter so they vanish in cron mode. The canonicalstaple-backup ok <path> <size> <duration>line emits explicitly via printSummaryLine at the end.printRowCountSummarysignature grew an io.Writer parameter — every caller now passes qout so the per-table tally is silenceable in quiet mode. -
backup_verify.go::runBackupVerifyalready had a--quietflag (pre-v2.51.0.18 it was "fully silent on success"). The new shape is "fully silent except the cron-grep line":staple-verify ok <source> dek=<id> chunks=<n> in <duration>. The existing failure-still-emits-on-stderr behaviour is kept — operators relying on the✗ failed:marker still see it. Also composes withSTAPLE_QUIETnow. -
backup_list.go::runBackupListaccepts--quiet. Suppresses both the table and the--jsonarray — the spec is explicit that quiet means a one-line summary, and a JSON array of backup entries isn't a one-line summary. Emitsstaple-backup-list ok N entries. -
restore.go::runRestoreaccepts--quiet. Suppresses the "Restoring ..." preamble, the "(this will DROP existing data)" line, the encrypted/plaintext branch marker, the "Restore complete." banner, and the row-count tally. The S3 path'srunS3Restoresignature grew a writer parameter so the S3 restore goes through the same quiet gate as the local-file path. Emitsstaple-restore ok <source> <duration>. -
docs/user/operators/backup-and-restore.md— v2.51.0.18 "Cron usage" subsection added under "Manual backup (CLI)" in the Operator Actions section. Documents the per-subcommand quiet semantics, theSTAPLE_QUIET=1standing env, and the--quiet=falseescape hatch for a one-time visible invocation against a quiet-by-env instance.
Tests:
cmd/staple-cli/backup_quiet_test.go(new) — 12 tests covering every helper plus four integration tests (flag parses cleanly on backup/restore/verify/list; quiet emits the summary, no-quiet emits the table; env-default works; --quiet=false overrides env; quiet stdout is exactly one line).- Existing
TestBackupSummaryTables_Curated,TestRunBackup_*,TestRunRestore_*,TestRunBackupList_*, andTestRunBackupVerify_*continue to pass without modification.
Code: +145 lines plumbing; +346 lines test; +60 lines docs.
Migrations: none (zero schema delta; migration counter still 126).
v2.43.0.16 — 2026-05-30¶
feat(ops): subagent depth-tree view at
/admin/subagents/tree/{run_id}. The flat table at /admin/subagents
shows recent runs scattered across every tenant in chronological
order; operators debugging a 3-level dispatch (root → 2 children →
4 grandchildren) had to scan and parent-id-correlate. The new tree
page renders the whole subtree as an indented list with the most-
recent run pill inline per agent, so the shape of a dispatch is
legible at a glance.
URL forms:
GET /admin/subagents/tree/{run_id}— the linked-from-detail- page form. The handler resolves run_id → agent_id → walks the parent chain to the root → BFS-walks down. Linked from the per-run detail page via a "view tree from root →" button next to the existing "back to subagents" link.GET /admin/subagents/tree?agent_id=<root>— the saved-URL form. Treats the agent_id directly as the root (no parent- chain walk). Useful when an operator wants to bookmark "the engineering-team-bot root tree" rather than a specific run.
Both forms route through the same UISubagentTree handler, gated
on instance-admin like the rest of the /admin/subagents/* family.
404 on missing / bogus IDs at every step (no run, no agent, root
walk hits the 16-step safety bound).
Implementation:
-
internal/db/query/subagent_tree.go— three new helpers:FindAgentForRunID(run → agent),FindRootAgentForAgentID(walk up to root, bounded at 16 steps to defend against a malformed graph), andGetSubagentTreeFromRoot(the BFS projection — recursive CTE down from the root agent, LATERAL- joined to the most-recent heartbeat_runs row per agent so the template gets the run-status pill inline without an N+1). All three run under scope.WithBypass — the surface is instance-admin cross-tenant by design. -
internal/handler/ui_subagent_tree.go— the handler. Resolves run_id → root or accepts agent_id directly; the handler intentionally accepts both because the detail page links via run_id (operator's natural pivot) but a saved URL reads better when it names the root agent. Maps the per-row heartbeat status to the existing CSS badge classes so the pills match the detail page's pills. -
internal/web/templates/subagents/tree.html— the template. Renders one row per agent indented by 16 × subagent_depth pixels. Per row: name + depth badge + role + status pill + short run id (linking to/admin/subagents/{run_id}) + spawn reason (italicized when present). Leaf agents get an inline "no children" note (derived from the absence of any other node naming this one as parent). Header carries the root name so operators know whose tree they're looking at. -
internal/web/templates/subagents/detail.html— adds the "view tree from root →" link next to the existing "back to subagents" link in the page header. -
cmd/server/routes.go— wires the two routes. CRITICAL: both are registered BEFORE the existing/admin/subagents/{run_id}wildcard or chi would eat the literal "tree" segment as a run_id and 404 on the detail handler. -
docs/user/operators/audit-dashboard.md— adds the v2.43.0.16 paragraph under the existing/admin/subagentssection.
Tests cover four paths:
- Permission gate matrix — nil actor, non-admin user, agent actor → 403 on every combination.
- 404 matrix — no run_id and no agent_id, bogus run_id, bogus agent_id query — all three return 404.
- Happy-path against a seeded 3-level subtree (root → 2 → 4 =
7 nodes). Asserts every agent name appears in the rendered
HTML AND that
data-depth="2"(the grandchildren tier) is present in the markup so the depth indent wired to the template. - Same happy-path against the path-param URL form (run_id of a grandchild's heartbeat run). Confirms the walk-up-to-root dispatch hits the full 7-node tree, not just the grandchild's empty subtree.
Code: +204 lines query + handler; +88 lines test; +60 lines template; +13 lines route + detail-page link + docs paragraph.
Migrations: none (zero schema delta; migration counter still 126).
v2.42.0.12 — 2026-05-29¶
feat(cli): chat REPL gains /secrets — a one-shot listing of the
top 10 company secret NAMES (NEVER values). Continues the v2.42.0.7+
list-commands pattern (/agents, /issues, /notes); lets an operator
confirm "what's actually configured?" without leaving the REPL.
Background: the v2.42.0.9 → v2.42.0.11 arc added /agents, /issues,
and /notes — each a one-shot GET against an existing per-company
endpoint, formatted as a terminal-clean table, excluded from
persistent up-arrow history because they're debugging glances not
prompts. /secrets is the next entry in that arc, scratching the
same "I'm in the chat REPL and want to confirm reality without
re-deriving it from docker exec env" itch but for the secrets
inventory.
Implementation:
-
One-shot
GET /api/companies/{companyId}/secrets?limit=10. The handler (handler.ListSecrets) returns a bare JSON array of query.CompanySecret rows — metadata only; novaluefield exists in the response shape at any layer (confirmed by reading internal/db/query/secrets.go's CompanySecret struct, which has no value column, and internal/handler/secrets.go which projects only the metadata fields). The REPL still uses a deliberately- narrow chatSecretEntry projection (id, name, provider, created_at, updated_at) so a future API regression that accidentally returns avaluefield cannot leak through — json.Unmarshal silently drops unrecognised fields. -
Sort by UpdatedAt DESC client-side. The server orders by name ASC (fine for the UI list) but a glance-command is more useful when answering "what was recently touched?". Stable sort preserves the server's name order on ties.
-
Output is name + "last rotated
", aligned columns, nothing else. The internal RotateSecret bumps company_secrets.updated_at after every version insert (see secrets.go:254), so updated_at is a faithful proxy for the last-rotation moment even without a dedicated version_number column on the list payload. -
403 from the server → "Listing secrets requires instance-admin permission" — the wording is identical regardless of how many secrets exist. The spec is explicit that the non-admin path must NOT leak existence. The 403 branch is implemented via an errSecretsForbidden sentinel so the dispatcher can pattern- match without coupling on the HTTP status, and the test fixture includes a poisoned
count: 42body field that the test asserts does NOT appear in output. -
Excluded from persistent history (joins the existing shouldRecordToHistory deny-list for /quit, /clear, /reset, /system, /history, /stats, /agents, /issues, /notes).
-
/help updated;
/secrets list the top 10 secret NAMES ONLY (never values)line added. The "NAMES ONLY" phrasing is pinned by a test so a future copy edit can't quietly weaken the guarantee.
Tests cover six paths:
- Help text contains /secrets AND contains the "NAMES ONLY" qualifier.
- Happy-path with three rows + POISON description / external_ref fields the printer must not surface.
- Dedicated no-value-leaks regression that injects bogus
valueandencrypted_valuefields into the wire payload and asserts they don't reach stdout. - Empty-company degrades to the friendly "(no secrets configured…)" line, not a stray header.
- 403 → "Listing secrets requires instance-admin permission"
with assertion that the 403 body's
count: 42poison does NOT leak. - 5xx → "Error fetching secrets" with the status surfaced for log correlation.
Plus the shouldRecordToHistory exclusion test and the missing-companyID guard.
Code: +318 lines of tests; +167 lines of impl across chat_slash.go; +28 lines of REPL dispatcher + help-text edits in chat.go.
Migrations: none (zero schema delta; migration counter still 126).
v2.51.0.17 — 2026-05-29¶
docs(backup): backup-and-restore.md consolidation. The v2.51.0.x
arc accumulated fifteen "section N: feature M" appendices on top of
the original setup-and-restore narrative. This release reorganises
the entire doc into a coherent eight-section runbook without
changing any operator-visible behaviour.
Background: every v2.51.0.x release (S3 sink, retention worker, verify-on-write, audit table, backup-list, orphan detection, auto-reconcile, cron worker, manual UI trigger, env summary tile) landed its own section in the doc. The narrative had grown to 1549 lines across 15 numbered sections — uneven, with overlap between "Operations panel widget", "Deploy card", "Backup workers tile", and "Environment summary card" appendices. Operators who wanted "how do I restore from S3?" had to scroll past the entire backup-CLI procedure to get to it.
The new structure:
- Overview — what the system does, what it doesn't (no continuous replication, no multi-version restore, no per-tenant restore via SQL), what you must back up, and the encryption-key contract (the single-point-of-failure warning consolidated from four places into one).
- Architecture — CLI + 4 workers (
backup_cron,backup_verify,backup_retention,backup_reconcile) +backup_runsaudit table, with a compact table giving each worker's cadence env, default, and purpose. - First-time setup — env vars, S3 config (all three provider
variants kept inline: AWS S3, minio, Cloudflare R2), encryption
key provisioning,
pg_dumpinstallation, filesystem-tree tarballs, optional one-shot backup script. - Daily operations — what runs automatically, what an operator needs to glance at periodically (just the operations panel + the audit dashboard).
- Operator actions — the action surface: manual CLI backup,
manual UI backup, restore from local + S3, verify, list (incl.
--orphansdrift detection), partial restore, full test-restore procedure. Every CLI command lives under one parent heading instead of being scattered across "Section 8", "Section 9", "Section 11", etc. - Monitoring — operations panel (Deploy / Environment / Backups / Retention preview / Backup workers tiles in one place), audit dashboard, Prometheus metrics, and the four suggested alert recipes (stale, never-recorded, verify-failing, reconcile-failing).
- Troubleshooting — eight common failure modes with concrete remediation steps, including the three new ones the v2.51.0.x arc introduced ("cron isn't running", "retention sweep isn't pruning", "orphan-in-db got auto-cleaned").
- Reference — three tables: every env var the system reads
(with default and consumer), every CLI command with one-line
purpose, and the full
backup_runscolumn-by-column schema. Operators wanting the spec without the narrative read this section.
A four-line table of contents links to every top-level section
plus every operator-action sub-section. Cross-links to
/admin/operations and /admin/audit?source=backup_runs are
added next to the matching prose so an operator on a live system
can pivot from the doc to the dashboard in one click.
Fact preservation: every operational fact present in the prior
1549-line version is preserved in the new 1413-line version. The
delta is reorganisation, deduplication of three separate "the
encryption key is the SPOF" warnings, deduplication of the same
S3 credential paragraph appearing in upload + restore + verify
sections, and merging of the four separate /admin/operations
tile descriptions into one Monitoring → Operations panel
subsection.
Code: zero. This is a docs-only release tagged as
docs(backup): so operators tracking the changelog still see
the consolidation as a real event rather than a silent rewrite.
Migrations: none (no schema changes; migration counter still 126).
v2.47.0.15 — 2026-05-29¶
Operations panel (/admin/operations) learns an "Environment"
card next to the existing v2.47.0.14 Deploy card. Surfaces presence
checks for the operationally-important env vars (encryption, Slack,
Telegram, S3 backups, OTLP, DATABASE_URL) plus the safe-to-display
tuning knobs (backup dir, cron, retention days, subagent depth +
rate caps) — all without ever printing a credential value.
Operators have been asking for a way to confirm "is Slack actually
wired on this instance?" / "what's the subagent depth limit on the
prod box right now?" without docker exec and reading env. The
v2.47.0.14 Deploy card answered "which build are you looking at?";
v2.47.0.15 answers "what's configured?" The two cards render
side-by-side via flex so the operator's at-a-glance overview lives
in one row.
Implementation:
internal/handler/ui_operations_env.gois new. Contains:EnvSummary— the struct rendered by the template. Eleven fields: six bools (Encryption / Slack / Telegram / S3 backup / OTLP / DATABASE_URL presence), one string (BackupDir), one bool (BackupCronEnabled), and three ints (BackupRetentionDays — with-1as the "env unset" sentinel — plus MaxSubagentDepth and MaxSubagentRunsPerMin).collectEnvSummary()— the single source of truth for "what does an operator see for $X?". Booleans areos.Getenv(name) != ""after astrings.TrimSpaceso a stray whitespace value doesn't false-flip. Numerics parse viastrconv.Atoiand degrade to0on parse failure. The two PATH-style values (BackupDir) are surfaced verbatim — they're a filesystem path, not a credential.- SECRET CONTRACT:
collectEnvSummaryMUST NOT return any field whose value is derived from a credential env var. TheEnvSummarystruct has no*Token,*Key,*Suffix, or similar fields by design. The contract is enforced byTestCollectEnvSummary_SecretContract: it takes two snapshots under differentSLACK_BOT_TOKENvalues and asserts every field is byte-identical. A future patch that leaks even a suffix of a secret breaks this test. internal/handler/ui_operations.go::UIOperationsShowcallscollectEnvSummary()and passes it to the template asEnvSummary.internal/web/templates/operations/index.htmlwraps the existing Deploy card and the new Environment card in a flex container so they sit side-by-side on wide viewports and stack on narrow ones. Each row uses ✓ / ✗ glyphs for terminal-clean parity with the CLI surface; configured rows are green-tinted, unconfigured rows are muted. The numeric / path rows render the value (or(default)/disabled (keep all)as appropriate) instead of a marker.internal/handler/ui_operations_env_test.gois new and adds nine tests:TestCollectEnvSummary_AllUnset— every field falls through to its zero-shape default.TestCollectEnvSummary_AllSet— every presence flag flips to true; numeric values parse correctly.TestCollectEnvSummary_SecretContract— the regression guard against secret leakage described above.TestCollectEnvSummary_BackupCronDisableSentinel— the literaldisablestring flipsBackupCronEnabledoff; any other value (including empty) leaves it on.TestCollectEnvSummary_BackupRetentionZero— explicit0is kept as0(the "keep everything" semantic), not the-1unset sentinel.TestCollectEnvSummary_SlackPrefixFallback— theSTAPLE_SLACK_BOT_TOKENfallback is honored when the unprefixedSLACK_BOT_TOKENis missing.TestCollectEnvSummary_WhitespaceCountsAsUnset— whitespace- only env values don't flip a presence flag.TestCollectEnvSummary_NumericParseFailureDegrades— a non- integer numeric var degrades to0.TestUIOperationsShow_EnvSummaryCard_Rendered— template smoke test that confirms the card header, all eleven row labels, both check / cross glyphs, and the numeric values land on the page — and that the fake secret values do NOT (the regression guard at the integration layer).docs/user/operators/backup-and-restore.mddocuments the new card and the secret contract.
Operator notes:
- Card placement: next to the Deploy card, above the Backups
widget. Both cards stack on narrow viewports via the existing
flex-wraprules. - The secret contract is the load-bearing invariant. If you're
adding a new env var to the card, the rule is: if the value is
ever sensitive, the only allowed field shape is
bool. Numerics / paths are allowed verbatim. - Default sentinels (
(default)for unset numerics,disabled (keep all)for retention=0) match the worker package's actual defaulting behavior — the operator sees the same fallback the runtime applies.
Migrations: none (no schema changes; migration counter still 126).
v2.42.0.11 — 2026-05-29¶
staple-cli chat REPL gains a /notes command. Operators in a
live session can list the most recent notes visible to them in the
bearer agent's company without quitting the REPL — same one-shot
posture as the existing /agents and /issues commands.
The chat REPL has been growing a small toolkit of read-only debug
commands (/system, /history, /stats, /agents, /issues).
Notes are the next obvious add: the v2.38.0.x note-taking system is
core to how operators and agents keep durable shared context, but
there was no in-session way to spot-check what's there without
opening the dashboard in a browser. /notes closes that gap.
Implementation:
cmd/staple-cli/chat_slash.go:chatHelpTextand the cmd-flag help block both list/notes.chatNoteEntry/chatNotesResponseare minimal projections of the{notes:[…]}envelope returned by GET/api/companies/{companyId}/notes(seeinternal/handler/notes.go::ListNotesHandler). We pullid,title,labels,kind, andcreated_at— enough to render a scannable table without dragging in body/markdown.parseNotesLimitmirrorsparseHistoryLimit: empty defaults to 10, the cap is 50, zero/negative/non-integer values error out inline. Over-cap silently clamps (the ceiling is documented in/help).chatFetchNotescalls the per-company list endpoint with no query parameters; the handler has nolimit=plumbing yet so we slice client-side after the response decodes. The 4 MiB body cap protects against runaway companies with thousands of notes — the REPL is for spot-checking, not exhaustive paging.formatNoteAgerenders a CreatedAt timestamp as a short relative-age string (just now,5m ago,3d ago,1y ago). Pure function so the boundary transitions stay testable without time-dependent fixtures. Future-skewed inputs degrade tonowso the column never prints a negative duration.printNotessorts the response newest-first (the list handler doesn't promise an order), slices to the requested limit, and prints fixed columns:#<short-id> kind=<k> <title> [<labels>] <age>. Empty-label rows degrade to[-]; long titles truncate at 60 chars with an ellipsis. Failure posture matches/agentsand/issues: HTTP and decode errors print inline, the REPL keeps running.cmd/staple-cli/chat.go:- REPL dispatcher matches
/notesand/notes <N>via the same prefix-handling pattern as/history, parses the limit, and callsprintNotes. Doesn't consume a chat turn. shouldRecordToHistoryexcludes the exact/notestoken and every/notes <arg>prefix variant from the persistentchat_historyrecall file. Keeps the operator's recall list clean of debug commands.cmd/staple-cli/chat_slash_test.goextends with seven new tests:TestChatHelpText_ContainsNotesCommandpins the help-block addition.TestParseNotesLimit_Tablewalks the parser (default, explicit, clamp, zero, negative, non-integer).TestFormatNoteAge_Boundariespins the relative-age renderer.TestChatLoop_NotesCommand_HappyPathdrives the full path against a mock list endpoint, asserts every short id / kind / title / labels surface, and verifies the newest-first client-side sort.TestChatLoop_NotesCommand_LimitArgconfirms/notes 2slices the rendered table.TestChatLoop_NotesCommand_EmptyCompanypins the empty-state line.TestChatLoop_NotesCommand_HTTPErrorverifies inline error reporting on 5xx.TestChatLoop_NotesCommand_MissingCompanyIDconfirms the safety net: no HTTP request fires when the REPL bootstrap leftcompanyIDempty.TestChatLoop_NotesCommand_BadArgconfirms parse failures surface inline without firing an HTTP request.TestShouldRecordToHistory_NotesExcludedpins the recall-list exclusion for both/notesand/notes 5.
Operator notes:
/notesis a one-shot debug command — same posture as/agentsand/issues. It hits the list endpoint once and prints the response. There's no streaming, no pagination, and no in-session edit path.- The visibility rule mirrors the dashboard: you see your own private notes plus all agent-authored notes in your company. Other operators' private notes are filtered out server-side.
Migrations: none (no schema changes; migration counter still 126).
v2.50.0.12 — 2026-05-29¶
Audit dashboard learns a browser-side alert-rule prototype at
/admin/audit/alerts. Operators can save lightweight rules
("egress denied > 10 in the last hour", "secret-reveal failures > 5
in 15 min") and the page polls /admin/audit?format=json every 30
seconds, counts matching rows, and flips a red banner + a 🔔 title
prefix when any rule breaches its threshold. Pairs with a new JSON
content-negotiation path on the main dashboard so the same load +
filter + sort pipeline serves both the HTML feed and the alert
poller's JSON consumer.
Background: operators have asked for a notification path ("something pinged me about egress spikes overnight") since the v2.50.0.0 consolidated feed first landed. A full DB-backed alert system would need a rules table, a watcher worker, a fan-out channel, retention rules, audit-of-the-audit, and a permissions story for who can edit whose rules. That is real work. v2.50.0.12 instead ships the smallest thing that validates the UX: a localStorage-only rule list polling the existing dashboard. Operators can play with the rule shape, the threshold semantics, and the banner ergonomics before we commit to a heavyweight implementation.
Implementation:
internal/handler/ui_audit.go:UIAuditDashboardlearns a content-negotiation branch: when the request carriesAccept: application/jsonOR?format=json, the sameloadAndFilterAuditFeedpipeline feedsemitAuditFeedJSONinstead of the HTML template.wantsAuditJSONconsolidates the two signals so the rest of the handler stays clean. The HTML default is unchanged — a browser hit on/admin/auditstill gets the dashboard.auditFeedJSONItemmirrorsauditFeedItemwith explicit JSON tags + a pre-renderedsource_label(the v2.50.0.11 friendly label) so the JS poller doesn't have to know about the friendlyAuditSourceLabels map.auditFeedJSONResponsewraps the items in{items, count, pre_filter_count, filtered, filter, sort, generated_at}— thefilterandsortechoes let an operator confirm which dimensions were applied (handy when debugging "why isn't my rule firing").UIAuditAlertshandlesGET /admin/audit/alerts— instance- admin gated, renders the newaudit/alerts.htmltemplate withSurfacedTablesso the rule-form dropdown stays in sync with whatever set of audit tables the dashboard currently surfaces.cmd/server/routes.gowires the new GET route under the same instance-admin middleware chain as the rest of the/admin/auditfamily.internal/web/templates/audit/alerts.htmlis a single self- contained page: a rule list, a banner, an add-rule form, and an inline<script>that drives everything fromlocalStorage. Storage key isstaple_audit_alerts(a JSON array of rule objects with{id, name, source, action, threshold, window, last_count, last_checked, status}). The script:- Polls every 30s via
setInterval, plus an immediate poll on page load and on each new-rule submit. Per-rule fetch shape isGET /admin/audit?format=json&source=<s>&action=<a>& from=<UTC-midnight-day-floor>. The day-floor matches the server's?from=YYYY-MM-DDparse path; the JS re-filters by the precise window cutoff (created_at >= now - windowSeconds) so "last 15 min" stays honest even though the server bound floors to midnight. - Flips per-rule status to
ok/breach/unknownbased on count vs threshold. Breach state surfaces in three places: the rule row turns red, a banner appears summarising every breached rule, anddocument.titleis prefixed with🔔(preserved across re-renders by capturingORIGINAL_TITLEonce at script init). - Skips the poll when
document.visibilityState !== 'visible'so backgrounded tabs don't hammer the server. A cosmetic countdown ticks the "next check in Ns" line; it has no effect on the poll cadence. - Add-rule submit validates
name + threshold, generates a crude rule id, pushes the rule onto the array, persists, re-renders, and triggers an immediate poll so the operator sees status within seconds. - Delete is a per-row button; the click filters the rule out by id, persists, and re-renders.
internal/web/templates/audit/index.htmladds an "Alerts →" button alongside the existing By-actor / Insights / Download CSV header bar so operators can find the new page from the consolidated feed.
Tests:
internal/handler/ui_audit_test.goextends with:TestWantsAuditJSON_Detects— table-driven matrix across HTML default,Accept: application/json(with and withoutq=),text/html(no),?format=json(yes, case- insensitive),?format=csv(no), and theformat=json + filterscompose case.TestUIAuditDashboard_JSONContentType— end-to-end against the realtestPool: instance-admin GET withAcceptheader returnsContent-Type: application/jsonand a decodable envelope carrying every expected top-level key (items, count, pre_filter_count, filtered, filter, sort, generated_at); same with?format=json; a plain GET stays HTML so a browser hit is unchanged.TestUIAuditAlerts_NonAdminForbidden— gates the new/admin/audit/alertspage on instance-admin with the same matrix used elsewhere in this file.TestUIAuditAlerts_RendersScaffold— instance-admin GET returns 200 and the body carries the localStorage key (staple_audit_alerts), every scaffold id the JS wires up (banner, tbody, form, countdown, every input), and the surfaced-tables dropdown sample (secret_audit_events) so a refactor that drops the{{range .SurfacedTables}}block surfaces.docs/user/operators/audit-dashboard.mdgains an "Alert rules (v2.50.0.12)" section documenting the storage key, the polling cadence, the threshold semantics, the visibility-aware poll gate, and the explicit "this is a prototype, not a 24/7 watchdog" caveats (no escalation, no cross-machine sync, dies with localStorage). A note on the JSON API surface points curl users at the same endpoint.
No migrations. No new DB tables. No new dependencies. The HTML +
CSV variants of /admin/audit are unchanged on the wire — the
JSON path is purely additive.
v2.42.0.10 — 2026-05-29¶
staple-cli chat learns /issues — the operator inside the REPL
can glance at the top 10 open issues in their company without
quitting and restarting the CLI. Closes the "I need to peek at the
queue while I'm mid-debug" gap that /agents left open in
v2.42.0.9.
Background: v2.42.0.7–.9 closed three of the four common "I have
to quit + retarget" loops (drop context drift via /reset, glance
at the system prompt via /system, browse other agents via
/agents). The remaining one was "what's even open right now?" —
operators debugging a stale routine or chasing a flaky integration
naturally want to scan the issue board mid-session. v2.42.0.10
adds that as a one-shot listing that doesn't consume a chat turn.
Implementation:
cmd/staple-cli/chat_slash.goadds:chatIssueEntry— minimal projection of one row from the[]query.Issuearray the server'sGET /api/companies/{companyId}/issuesendpoint returns. The REPL only decodes the five fields it prints / branches on (id,title,status,priority,assignee_agent_id); the rest of the wire shape is silently discarded by the decoder.chatFetchOpenIssues(ctx, baseURL, apiKey, companyID, limit)— one-shotGET /api/companies/{companyID}/issues?status=open& limit=N. Refuses (/issues: companyID missing …) when called with empty companyID so a misconfigured chatLoopConfig surfaces inline rather than producing a malformed URL. The server returns a bare JSON array (no envelope) — confirmed byhandler.ListIssueswhich writesWriteJSONdirectly on a[]query.Issuevalue.printIssues(ctx, out, baseURL, apiKey, companyID)— renders a column-aligned table:The short id (8 chars) matches theOpen issues (top 3): #a1b2c3d4 high open assigned=agent-uu Investigate slack timeouts #b2c3d4e5 medium open unassigned Refactor concierge prompts #c3d4e5f6 low open unassigned Wire alert rule prototype/reset"(chat=%s)" convention + the/admin/subagentsshort-id column. Priority- status widths come from a single pre-pass so columns line up
regardless of which subset of
low/medium/high/criticallands in the page. Long titles get an ellipsis suffix at 64 chars so a 200-char title doesn't break the table on a standard terminal.
- status widths come from a single pre-pass so columns line up
regardless of which subset of
cmd/staple-cli/chat.go:chatHelpTextlearns a/issuesline.- The
chatLoopWithConfigswitch gains acase "/issues":branch that callsprintIssueswithcfg.companyID— the field is already populated at REPL bootstrap from/api/agents/me(the same source/resetuses forchatOpenSession). shouldRecordToHistoryadds/issuesto the exclusion list — debugging commands don't belong in the persistent recall file.- Top-of-file
staple-cli chat --helpblock adds the new entry in both the slash-command table and the exclusion-list line.
Tests:
cmd/staple-cli/chat_slash_test.go:TestChatHelpText_ContainsIssuesCommand— assertion-of-record that the canonicalchatHelpTextconst carries the new line.TestChatLoop_IssuesCommand_HappyPath— mocksGET /api/companies/{companyID}/issuesreturning three issues (one assigned, two unassigned); asserts the URL shape (status=open+limit=10), the header text including the actual count, every short id + priority + title, the shortened assignee id on the assigned row, and thatunassignedappears exactly twice (one per NULL row).TestChatLoop_IssuesCommand_EmptyCompany— empty array triggers the(no open issues in your company)notice and suppresses the header.TestChatLoop_IssuesCommand_HTTPError— 500 on the endpoint surfaces "Error fetching issues" inline and the REPL keeps running so the trailing/quitexits cleanly.TestChatLoop_IssuesCommand_MissingCompanyID— emptycompanyIDshort-circuits to the misconfiguration error without hitting the mock HTTP server (the t.Errorf inside the handler fires if it does).TestShouldRecordToHistory_IssuesExcluded— pins the no-persist policy.
No server-side surface change. The
GET /api/companies/{id}/issues?status=open endpoint has been
serving the same envelope-free shape since the API was first
exposed; the REPL is just a new consumer.
v2.43.0.15 — 2026-05-29¶
/admin/subagents/{run_id} gains a "Re-dispatch" button on
terminal rows (completed / failed / cancelled). Operators retrying a
transiently-failed subagent run no longer have to navigate back to
the parent agent and re-issue the spawn — one click enqueues a fresh
heartbeat_run for the same agent and lands the operator on the new
row's detail page so they can watch the retry progress in real time.
Pending / running rows hide the button to prevent double-dispatch on
a still-in-flight worker.
Background: the v2.43.0.9 cancel button closed the "stop this run" half of the operator's loop; the natural complement — "retry this run after a transient blip cleared" — was still a multi-step navigation through the parent agent's page. Provider timeouts, the mid-deploy cancel sweep, and the OOM-killed worker reaper all produce terminal rows that the operator legitimately wants to re-queue without re-typing the spawn parameters. v2.43.0.15 collapses that into a single button on the detail page they were already looking at when they noticed the failed run.
Implementation:
internal/handler/ui_subagent_detail.goaddsUISubagentRunRedispatch— instance-admin gated, mirrors the existingUISubagentRunCancelposture. The handler loads the prior run viaquery.GetHeartbeatRunByID, refuses with 409 when the row is still inpendingorrunning(defence against a hand-crafted POST or a stale tab clicking through), and otherwise callsquery.CreateHeartbeatRun(prior.CompanyID, prior.AgentID)followed byquery.EnqueueTask("heartbeat_run", …)with the same payload shape every other re-dispatch site in the codebase uses ({run_id, agent_id}). On success the response is a 303 redirect to/admin/subagents/{newRunID}so the operator's tab swivels to the new row's detail page. Missing prior run → 404; missing agent (defensive — the CASCADE FK should make this unreachable) → 404 with an explicit body; DB blip → 500.cmd/server/routes.gowires the newPOST /admin/subagents/{run_id}/redispatchnext to the existing per-run cancel route. Same routing group, same instance-admin middleware chain.internal/web/templates/subagents/detail.htmladds a "Re-dispatch run" card that surfaces only when.Run.Status∈ {completed, failed, cancelled}. The card explains the action in operator language ("creates a new heartbeat_runs row in pending state and queues the corresponding task"), calls out that the original run is preserved untouched (audit-trail invariant), and posts to the new endpoint. The confirm modal is wired viadata-staple-confirm— the same themed-confirm path that the rest of the dashboard uses — so the operator sees the same modal style as elsewhere instead of the rawwindow.confirmshell the cancel button still uses.internal/handler/ui_subagent_detail_test.goextends the existing detail-test file with five new cases:TestUISubagentRunRedispatch_NonAdminForbidden(auth gate matrix matches the cancel-endpoint test);TestUISubagentRunRedispatch_HappyPath(seeds a completed run, POSTs, asserts the 303 carries a NEW run id distinct from the prior, that the new row ispendingwith matching agent/company ids, and that the prior row's status is unchanged);TestUISubagentRunRedispatch_ConflictOnRunningRow(pending prior → 409, with a body assertion + a count check that proves no duplicate heartbeat_runs row was created);TestUISubagentRunRedispatch_404OnMissingRun(bogus run_id); andTestUISubagentRunDetail_RedispatchButtonOnlyOnTerminal(mirrors the cancel-button visibility test but in reverse — pending hides, completed shows).docs/user/operators/audit-dashboard.mddocuments the new button in the/admin/subagentssection alongside the v2.43.0.9 cancel + v2.43.0.10 bulk-cancel + v2.43.0.14 retention workers.
No new migrations. The button rides on existing
CreateHeartbeatRun + EnqueueTask helpers without touching the
schema. Re-dispatch produces a fresh row visible in the audit
feed (heartbeat_run.created) so the operator action is auditable
through the existing v2.50.0.x pipeline.
v2.47.0.14 — 2026-05-29¶
/admin/operations gains a "Deploy" card pinned at the top of
the page. Operators landing on the page see version + commit +
build date + hostname + uptime before scrolling — no more
"is this the deploy I think it is?" guessing. Replaces the prior
<h2>Server</h2> block that lived below the workers grid.
Background: the operations panel previously surfaced version / commit / build date but only in a "Server" section that sat between the workers grid and the flags table. Operators had to scroll past every worker row to confirm they were looking at the right deploy. The deploy card moves the same info to the top, adds hostname (immediately useful when triaging across a multi- node cluster) and uptime (catches the "I thought I restarted at 3pm but it's been up for 8 days" surprise).
Implementation:
internal/handler/ui_audit.goaddsformatUptime— sibling offormatRelativefrom v2.50.0.10. Same vocabulary ("3d 12h 4m" / "47m 12s") but forward direction, no "ago" suffix. Sub-second and negative inputs clip to "0s" so a clock-skew bug never leaks "-3m" into the card.internal/handler/ui_operations.go::UIOperationsShowaddsServerHostname(viaos.Hostname(), with(unknown)as the swallowed-error fallback) andServerUptime(viaformatUptime(now - version.ProcessStart())) to the template payload.internal/web/templates/operations/index.htmlslots a new "Deploy" card immediately before the backup widget — same card / padding / typography styling as the surrounding cards for visual consistency. The old<h2>Server</h2>section is removed (subsumed by the new card).- A "Refresh page" link sits at the right of the card and
hard-reloads
/admin/operationsso the uptime visibly ticks forward without any JS plumbing. internal/handler/ui_operations_test.goextendsTestUIOperationsShow_AdminRendersto assert the rendered HTML carries the Deploy label, the local hostname, the Uptime row label, and the Refresh-page link. AddsTestFormatUptimecovering zero / negative / sub-second / seconds / minutes / hours / days transitions.docs/user/operators/backup-and-restore.mddocuments the new card alongside the existing operations-panel widgets.
v2.49.0.4 — 2026-05-29¶
/readyz gains per-channel reachability rows. When the operator
has wired Slack (Socket Mode) and/or Telegram (long-poll) bots,
the readiness probe now emits one channel:<name> row per
configured channel and flips overall status to not_ready when a
channel hasn't seen an upstream poke past a configurable staleness
threshold (default 60s). Unconfigured channels are silently
omitted — no stale channel:slack row for operators who never
wired Slack.
Background: pre-v2.49.0.4 the readiness probe covered DB and worker liveness but had no visibility into the chat bridge. A Slack token rotation or Telegram NAT change would leave the bot retrying silently inside its goroutine while every probe still returned 200. Chat-only customers cannot reach their agents when the channel is down, so the failure mode is operator-relevant and belongs on the readiness gate.
Implementation:
internal/channels/health/registry.goadds a tiny process-level registry. TheChannelHealthinterface is three methods (Name,Configured,LastSuccessfulPoll) — the bots themselves implement it, no wrapper structs. The registry mirrors theinternal/worker.SnapshotAllStatspattern: global state with aResetForTestescape hatch.internal/channels/slack/bot.goandinternal/channels/telegram/bot.gotrack alastPollAttimestamp under async.RWMutex. Slack updates it on every Socket Mode event (lifecycle frames included) and seeds it onConnectso a freshly-booted Staple does not flap not_ready during the first staleness window. Telegram updates it on every successfulgetUpdates(empty result still counts). Both exposeLastSuccessfulPoll()and satisfy theChannelHealthinterface directly.cmd/server/main.gocallshealth.Registerfor each bot the operator wired — afterslackBot.Connectsucceeds, aftertelegram.NewBot. Skipped when env-vars are absent, which cascades to "unconfigured = silently omitted" on /readyz.internal/handler/health.goreads the registry at every probe and emits one row per configured channel. The newSTAPLE_READYZ_CHANNEL_STALENESS_SECenv var tunes the threshold; default 60s, with the same "out-of-range falls back to default" pattern as the other threshold helpers.internal/handler/health_test.goadds four scenarios: healthy (last poll within threshold), stale (flips not_ready), unconfigured (row omitted), never polled (treated as not_ready). The newwithChannelstest helper swaps the registry indirection so tests don't pollute the package global.docs/user/operators/monitoring.mddocuments the new rows, the wire shape, the threshold env var, and the "block readiness, don't warn" disposition.
v2.51.0.16 — 2026-05-29¶
Test-hygiene release. Stabilises four pre-existing flaky tests
that previous subagent runs have repeatedly flagged but never
fixed. No production code changes other than a tiny additive
PortabilityCompany.IssuePrefix JSON field that unblocks one of
the test fixes; the field is omitempty so existing bundles
round-trip byte-for-byte.
internal/worker/retention_test.go—TestRetentionLoop_DeletesOldSucceededEventsreplaces a fragiletime.Sleep(50ms)race with a 5-second deterministic poll. The retention worker runs on a 10ms ticker so the deletion typically lands in well under 100ms; the poll just guarantees the assertion never fires before the side-effect has actually happened. At.Cleanupwas also added so the three seeded heartbeat runs are deleted at end-of-test, preventing cross-test accumulation when the suite runs-count=N.internal/db/query/heartbeat_run_events_test.go—TestDeleteOldRunEventsOnlySucceededRunsstops asserting on the global rows-deleted count returned byDeleteOldRunEvents. That count is non-deterministic when the shared test database has events backdated by other tests. We now only check the two rows this test seeded — the succeeded one must be deleted, the failed one must be retained — which is what the test is actually documenting.internal/db/query/portability_test.go—TestImportCompany_BundleProvidersandTestImportCompany_RejectsDuplicateProviderLabelnow pass a uniqueIssuePrefixthrough the bundle so the company INSERT no longer collides with the existingissue_prefix='ISSUE'row (every Staple database has one — the seedednegroncompany in this case). The bundle struct gains an optionalIssuePrefixfield for this purpose, whichImportCompanyhonours when set and falls back to the column default otherwise.
Verified locally with -count=3 on node4 for all four targets:
internal/worker and internal/db/query packages both go from
intermittent flake to clean pass.
v2.50.0.11 — 2026-05-29¶
/admin/audit swaps raw audit-source table names for operator-
readable labels. The raw name is preserved as a title attribute on
the cell so hovering still shows the underlying source; filtering by
source continues to use the raw name verbatim.
Background: the consolidated audit dashboard (v2.50.0.0+) renders
each row's source-of-truth as the raw audit table name —
secret_audit_events, plugin_approval_events,
egress_anomaly_events, etc. The labels are mechanically faithful
but operator-hostile: a fresh user landing on /admin/audit shouldn't
have to know which Postgres table backs each kind of event to read
the page. v2.50.0.11 is a presentation-layer polish — no schema or
filter behaviour changes.
Implementation:
internal/handler/ui_audit.goadds:friendlyAuditSourceLabels— amap[string]stringmapping every currently-surfaced audit table to its friendly label. The table mirrors the v2.51.0.8surfacedAuditTablesset 1:1.friendlyAuditSourceLabel(raw string) string— returns the label for known sources and echoes the raw value back for unknowns. A future audit table that lands before its label mapping renders with its raw name rather than a misleading placeholder — defensive default.internal/handler/render.goregistersfriendlyAuditSourcein the global template func map so every page can call{{friendlyAuditSource .SourceTable}}.- Templates updated:
internal/web/templates/audit/index.html— the per-row source cell now renders the friendly label withtitle="{{.SourceTable}}".internal/web/templates/audit/insights.html— both the "Top failures"Source × Actioncell and the "Source activity" rolled-up table apply the same treatment, preserving the raw name in the title attribute.internal/web/templates/audit/actor.html— the per-actor timeline applies the same swap; the existing<a href="/admin/audit?source={{.SourceTable}}">link target still uses the RAW name so the round-trip filter keeps working.
Behaviour preserved:
?source=URL parameter, CSV exportsourcecolumn, and the JSON API still use the raw table name. The friendly label is presentation-only.- Filtering by source from the actor timeline (clicking the
source-name link) still lands on
/admin/audit?source=<raw>and continues to match.
Tests:
internal/handler/ui_audit_test.go::TestFriendlyAuditSourceLabel— table-driven test covering every known source mapping plus the unknown-and-empty fallback paths.TestFriendlyAuditSourceLabel_CoversEverySurfacedTable— pins the cross-check between the two sources of truth: every entry insurfacedAuditTables()MUST appear infriendlyAuditSourceLabels, so adding a new audit table to the dashboard without giving it a friendly label fails fast.TestUIAuditDashboard_RendersFriendlyAuditSource— end-to-end render assertion: seeds onebackup_runsrow, drives the handler, asserts the rendered HTML contains both "Backup run" (friendly label) andtitle="backup_runs"(raw name preserved).
Operator docs:
docs/user/operators/audit-dashboard.mdadds a "Friendly source labels" section with the full mapping table and the contract that filtering still uses raw names verbatim.
Migration delta: 0.
v2.42.0.9 — 2026-05-29¶
staple-cli chat learns /agents — the operator inside the REPL
can list every other agent in their company without quitting and
restarting the CLI.
Background: v2.42.0.7 / .8 added /reset / /system / /history /
/stats so common debugging asks no longer required exiting the
REPL. The one remaining "I have to quit + retarget" moment was "who
else lives in this company?" — useful when the operator is
mid-debug, realises they want to ping a different agent, but
doesn't have the agent list cached.
Implementation:
cmd/staple-cli/chat_slash.goadds:chatAgentEntry/chatAgentListResponse— minimal projection of the server's agents list (id,name,role) so the REPL decodes only what it renders.chatFetchAgents(ctx, baseURL, apiKey)— one-shotGET /api/agents(no query string). Re-uses the existingchatHTTPClient+truncateSnippeterror-snippet helper for consistent error surface.printAgents(ctx, out, baseURL, apiKey, targetAgentID)— renders a column-aligned table:The marker isAgents in your company: ▸ Empirical Lead role=concierge d2c5a8f1 (current) Go Reviewer role=code-reviewer 8a7b9d12 Historical-Codex role=research c3e4f5a6▸for the current chat target and a space for the rest.shouldColorize(out)returns false for piped / non-TTY destinations (and for the test*bytes.Buffer); in that case the marker degrades to a plain*so the output stays terminal-clean. Column widths come from a single pre-pass over the names + roles so the IDs line up regardless of name length. Short IDs are truncated to 8 chars to match the/reset"(chat=%s)" output and the/admin/subagentsdashboard column.cmd/staple-cli/chat.go:chatHelpTextlearns a/agentsline.- The chatLoopWithConfig switch gains a
case "/agents":branch that callsprintAgentswithcfg.targetAgentID. shouldRecordToHistoryadds/agentsto the exclusion list — debugging commands don't belong in the persistent recall file.- Top-of-file
staple-cli chat --helpblock adds the new entry in both the slash-command table and the exclusion-list line.
Server-side surface change (additive):
handler.ListAgents(already servingGET /api/agents?name=) now treats a missing or emptyname=query parameter as "return every agent in the bearer's company" — the v2.42.0.9 REPL's/agentscall depends on this. The existingname=<exact>contract is preserved verbatim. The scope is always the bearer's company via the route group'sBearerAuth, so a cross-tenant leak is impossible regardless of input.
Tests:
cmd/staple-cli/chat_slash_test.go:TestChatLoop_AgentsCommand_HappyPath— mocksGET /api/agentsreturning three agents (one matching the REPL'stargetAgentID); asserts every name + role + short ID surfaces, the(current)marker appears exactly once on the target row, and the ASCII fallback marker (*not▸) lands on the target name. Also asserts the call did NOT include aname=query parameter (the v2.42.0.9 no-name contract).TestChatLoop_AgentsCommand_EmptyCompany— empty agents array triggers the(no agents in your company)notice and suppresses the header.TestChatLoop_AgentsCommand_HTTPError— 500 on/api/agentssurfaces "Error fetching agents" inline and the REPL keeps running so the trailing/quitexits cleanly.TestShouldRecordToHistory_AgentsExcluded— pins the new exclusion-list entry.internal/handler/api_handlers_test.go::TestListAgentsHandler:- Old
missing-name → 400subtest is replaced withmissing-name-lists-all → 200(full company list). - New
empty-name-lists-all → 200subtest exercises the?name=(present but empty) variant on the same code path. - The existing
known-name/unknown-namecases stay unchanged.
Migration delta: 0.
v2.43.0.14 — 2026-05-29¶
Heartbeat run retention worker — terminal heartbeat_runs rows
older than STAPLE_HEARTBEAT_RUN_RETENTION_DAYS (default 90) are now
hard-deleted once per
STAPLE_HEARTBEAT_RUN_RETENTION_INTERVAL_HOURS (default 24). Mirrors
the v2.51.0.3 backup retention + v2 RetentionJob (run-events)
posture.
Background: every agent dispatch lands a heartbeat_runs row. The
v2 RetentionJob prunes the per-run heartbeat_run_events rows for
succeeded runs, but nothing ever pruned the parent heartbeat_runs
row itself. On a busy instance the table grows unboundedly, and the
/admin/subagents dashboard's 200-row projection starts losing its
meaning when the underlying table is dominated by ancient terminal
rows the operator no longer cares about.
Implementation:
internal/worker/heartbeat_run_retention.go— newHeartbeatRunRetentionworker. Same shape as the v2.51.0.3BackupRetentionworker:EnvHeartbeatRunRetentionDays(default 90, hard limits 1–3650).EnvHeartbeatRunRetentionIntervalHours(default 24, hard limits 1–168).EnvHeartbeatRunRetention=disable(case-insensitive) — opt-out switch for operators who run their own pruning cron.runOncecomputes a cutoff time and calls the helper below. Out-of-range / unparseable env values fall back to defaults with aslog.Warn— a bad env must not block server start.- First tick fires immediately so a fresh deploy with old terminal runs doesn't wait a full interval for the first reap.
- Registers a
TickStatshandle namedheartbeat_run_retentionso the worker shows up on/admin/operationsand on the/metricsendpoint alongside the other retention workers. query.DeleteOldTerminalHeartbeatRuns(ctx, pool, cutoff time.Time) (int64, error)— instance-wide DELETE wrapped inscope.WithBypass. Predicate is: TheCOALESCElets rows that completed but never stampedfinished_at(a legacy bug surface) still age out on theircreated_attimestamp. Pending / running rows are NEVER touched regardless of age — stale in-flight rows are the stale-run watcher's problem, not retention's.cmd/server/main.gowires the worker next to theRetentionJobforheartbeat_run_events(the parallel concern). Deletion of the parent row hard-cascades toheartbeat_run_eventsvia the existingON DELETE CASCADEon that FK (migration 025), so the two workers compose cleanly: events get pruned at 30 days for succeeded runs; the parent row gets pruned at 90 days for any terminal status, taking residual events with it.
Operator docs:
docs/user/operators/environment.mdadds three new entries under the retention section:STAPLE_HEARTBEAT_RUN_RETENTION_DAYS,STAPLE_HEARTBEAT_RUN_RETENTION_INTERVAL_HOURS, andSTAPLE_HEARTBEAT_RUN_RETENTION.docs/user/operators/audit-dashboard.mdmentions the worker in the/admin/subagentssection so an operator reading the dashboard docs knows why the projection stays bounded.
Test isolation fix (rolled into the same release):
TestUISubagentRunCancel_*/TestUISubagentRunDetail_*/TestUISubagentRunsBulkCancel_*/TestListAllRecentSubagentRuns*/TestGetSubagentRunDetail_*all spawn a subagent underagents[0]withMaxChildrenPerParent: 50and never clean up. After ~50 cumulative repeats the parent hits the cap and the next spawn fails with "max 50 direct children". Every one of those tests now carries at.CleanupthatDELETE FROM agents WHERE id = $childonce the test exits; theON DELETE CASCADEonheartbeat_runs.agent_idreaps the seeded run rows with it. Confirmed by running the affected packages twice in a row on node4 without resetting state.
Tests:
internal/db/query/heartbeat_runs_test.go:: TestDeleteOldTerminalHeartbeatRuns_PrunesTerminalKeepsActive— seeds 5 runs (3 old terminals, 1 recent terminal, 1 old pending) and asserts the SQL helper deletes the old terminals and leaves the recent terminal + the pending one in place. Tolerates extras from a busy dev DB by assertingdeleted >= 3rather than== 3.internal/worker/heartbeat_run_retention_test.go:TestNewHeartbeatRunRetention_Defaults— default 90 days, 24h interval, enabled, stats registered.TestNewHeartbeatRunRetention_Custom— both env vars honoured.TestNewHeartbeatRunRetention_Disabled—STAPLE_HEARTBEAT_RUN_RETENTION=disable(case-insensitive) skips ticker + stats registration.TestNewHeartbeatRunRetention_ClampsDays— 0 → min, 999999 → max.TestNewHeartbeatRunRetention_ClampsInterval— same for interval-hours.TestNewHeartbeatRunRetention_InvalidFallsBackToDefault— "not-a-number" gets the default with a Warn.TestRunOnce_PrunesOldTerminalsKeepsRest— end-to-end DB parallel of the query helper test: seeds 5 runs, callsrunOnce, asserts the expected delete pattern. Cleanup viat.Cleanupso the test stays repeatable.
Migration delta: 0.
v2.46.0.11 — 2026-05-29¶
Per-agent proposal history page — operators can list every prompt
revision the GEPA auto-evaluate worker has ever proposed for one
agent (pending / accepted / rejected) at /agents/{id}/proposals.
Background: the v2.46.0.x GEPA arc gave operators a per-agent
"Pending proposals" panel on the agent show page (/agents/{id})
plus a per-proposal side-by-side diff (/agents/{id}/proposals/{pid},
v2.46.0.4). What was missing was an INDEX of every proposal the
agent had accumulated. Operators wanting "show me every revision
this agent has ever been offered" had to scrape /admin/audit?source=
agent_prompt_proposals and filter by agent_id manually, or guess
proposal IDs from log lines.
v2.46.0.11 adds /agents/{id}/proposals.
Implementation:
query.ListAllProposalsForAgent(ctx, pool, companyID, agentID, limit int)— newest-first, capped at the caller's limit (with a sane default of 200 and a hard ceiling of 1000 so a forgotten limit can't accidentally page-DDoS the dashboard). Returns an empty slice (never nil) so the template's{{if .Proposals}}… {{else}}…{{end}}branch is unambiguous.query.CountAllProposalsForAgent(ctx, pool, companyID, agentID)— used by both the list page (to render "Showing N of M total" when the limit clips) and the agent show page (to render "View all proposals (M total) →" next to the section header).handler.UIAgentProposalsList(pool)at GET/agents/{agentId}/proposals:- Instance-admin only (same posture as
UIAgentProposalShow). - 404 on unknown agent.
- Loads up to
proposalsListLimit=200rows + the total count. - Renders
agents/proposals_list.html. - Template
internal/web/templates/agents/proposals_list.html— five-column table: status pill, proposed-at (timestamp + relative), model, truncated rationale (first 80 chars with a hover-revealing full text via thetitleattribute), and "View →" link to the per-proposal detail page. Rejected rows surface the rejection reason as a sub-line. Empty state points the operator atSTAPLE_GEPA_EVAL_ENABLEDso they know how to start the worker. agents/show.htmllearns a "View all proposals (N total) →" link next to the "Proposed prompt revisions" section header when at least one historical proposal exists. The link survives the no-pending case via an{{else if gt .ProposalsTotalCount 0}}branch that surfaces a one-line "No pending proposals. View all proposals (N total) →" stub.- Route wired in
cmd/server/routes.goright next to the existing/agents/{agentId}/proposals/{proposalId}detail route.
Tests:
internal/db/query/agent_prompt_proposals_test.go:TestListAllProposalsForAgent_ReturnsEveryStatusseeds three proposals (pending / accepted / rejected), callsListAllProposalsForAgent, asserts:- All three rows come back.
- Newest-first ordering (accepted, rejected, pending in this seed sequence).
- Status distribution matches the seeded states 1/1/1.
CountAllProposalsForAgentagrees onn=3.limit=1clips to just the newest row.
TestListAllProposalsForAgent_EmptyAgent— fresh agent returns[]AgentPromptProposal{}(not nil) and count0.internal/handler/ui_agent_proposals_list_test.go:TestUIAgentProposalsList_NonAdminForbidden— table of four actor types (nil / non-admin user / board_user / api_key admin) all return 403 before any pool access.TestUIAgentProposalsList_HappyPath_SeededProposals— seeds three proposals (one rejected), asserts the response contains:- Each proposal's detail-page link.
- The "pending" + "rejected" status-pill copy.
- The truncate-ellipsis on the long rationale.
- The "View →" detail-link copy.
- "Back to
" footer.
TestUIAgentProposalsList_EmptyState— fresh agent renders "No proposals yet" copy + the env-flag hint.TestUIAgentProposalsList_AgentNotFound— unknown agent → 404.
No DB schema changes (migration counter stays at 126). Pure query + handler + template change.
v2.51.0.15 — 2026-05-29¶
Manual backup trigger on /admin/operations — instance admins click
"Run backup now" and get a fresh dump file + backup_runs audit
row without shelling into the host for staple-cli backup.
Background: the v2.51.0.x backup arc gave operators retention sweeps, inline verify-on-write, audit rows, pruning audit-marks, a continuous-verification cron, an orphan reconciler, and (v2.51.0.14) a server-side cron worker that produces backups on a schedule. The one remaining gap was the emergency case: an operator about to perform a risky operation (DDL change, restore drill, dependency update) wanted to bank a fresh dump RIGHT NOW. Their options were:
- Wait for the cron tick (could be hours).
- SSH to the host, find the binary, ensure DATABASE_URL +
STAPLE_ENCRYPTION_KEY were in scope, run
staple-cli backup. - Read the runner code and curl-hit the cron worker tick endpoint (which doesn't exist).
v2.51.0.15 adds a fourth option: a "Run backup now" button on /admin/operations.
Implementation:
- New handler
UIOperationsTriggerBackupininternal/handler/ui_operations_backup.go: - POST
/admin/operations/backup/run. - Instance-admin gated (matches the existing
/admin/operationsauth posture — backups span every tenant, not just one). - Resolves the backup dir from
STAPLE_BACKUP_DIR(default/var/backups/staple, mirroring the cron worker). - Resolves S3 from env via
backup.LoadS3ConfigFromEnv. - Refuses to run when the vault is nil — silently producing a
plaintext backup from a UI button would surprise operators.
The flash points them at
staple-cli backup --no-encryptif plaintext is intentional. - Calls
backup.Run(ctx, BackupRunOptions{..., Source: "manual", ActorUserID: <admin's user id>})synchronously. - 303-redirects back to
/admin/operations?backup_flash=ok&msg=...on success or?backup_flash=err&msg=...on failure. The template renders a green or red banner from the query string. - Wraps the runner in a 5-minute
context.WithTimeout. A timeout maps to a dedicated flash that recommends the CLI (where the operator's terminal owns the run lifetime past the HTTP server's write timeout). A non-timeout failure carries the error string and a pointer to/admin/audit?source=backup_runs. - Test seam: package-level
manualBackupRunner(function var defaulted tobackup.Run) +manualBackupVault(function var defaulted tocrypto.Default). Tests swap both viawithManualBackupRunner/withManualBackupVaultso the unit tests don't need pg_dump in PATH, a working DB, or a bootstrapped vault. UIOperationsShowlearns two new template fields,BackupFlashKindandBackupFlashMessage, parsed from the query string. The template renders a colored banner at the top of the page when both are non-empty; otherwise the block is suppressed.internal/web/templates/operations/index.html:- New "Run backup now" form in the backup-widget link row, with
a
data-staple-confirmconfirmation modal warning about the synchronous 5-minute wait. - New flash banner block above the page body.
- Route wired in
cmd/server/routes.gonext to the existing/admin/operations/flags/...POST.
Migration 126 (126_backup_runs_manual_kind.sql):
ALTER TABLE backup_runs DROP CONSTRAINT backup_runs_kind_check;
ALTER TABLE backup_runs ADD CONSTRAINT backup_runs_kind_check
CHECK (kind IN ('create', 'cron_verify', 'reconciled',
'cron', 'manual'));
The CHECK constraint pins the value space so a typo in future
code cannot silently pollute the table; this migration just
admits a new value alongside the v2.51.0.14 cron discriminator.
Migration counter is now 126.
Tests (internal/handler/ui_operations_backup_test.go):
TestUIOperationsTriggerBackup_RequiresInstanceAdmin— no actor → 403.TestUIOperationsTriggerBackup_NonAdminUserForbidden— authenticated user withoutinstance_role=admin→ 403.TestUIOperationsTriggerBackup_BoardUserForbidden— even a board_user withcompany_role=adminis forbidden; backups span the instance.TestUIOperationsTriggerBackup_VaultMissingErrorFlash— nil vault short-circuits to an error flash, runner NOT invoked.TestUIOperationsTriggerBackup_HappyPath_RedirectsToOkFlash— asserts the 303 +backup_flash=ok+ the capturedBackupRunOptionscarriesSource="manual",ActorUserID=<admin>,Encrypt=true,Verify=true.TestUIOperationsTriggerBackup_TimeoutErrorFlashRecommendsCLI—context.DeadlineExceededfrom the runner produces an err flash whose message containsstaple-cli backup.TestUIOperationsTriggerBackup_RunErrorFlash— a non-timeout failure produces an err flash carrying the error text and a pointer to/admin/audit.TestFormatBackupDuration— table of seven cases across the sub-second / seconds / minutes / hours branches of the duration formatter.
Limitations (documented in the flash + the doc string):
- Synchronous. A multi-GB dump that exceeds the 5-minute timeout
surfaces as a timeout flash. The recommended fallback for that
case is still
staple-cli backupfrom the host, where the operator's terminal owns the run lifetime. - No streaming progress yet. A future v2.51.0.x release could promote this to a background goroutine + SSE-driven progress pill, but for the dev-cluster dump sizes Staple actually carries the synchronous path is fine.
v2.47.0.13 — 2026-05-29¶
Ops hygiene release: reset.sh now works under plain sudo bash,
and TestUIAgentShowValid stops dereferencing a nil *time.Time.
Two small fixes that have been forcing operator workarounds since v2.47.0.11 and v2.51.0.13 respectively.
A — reset.sh sudo invocation¶
Background: reset.sh is operator-owned and lives outside the repo
at /mnt/Media/staple/reset.sh on the deploy host. The script's
git refresh step (git fetch --tags --prune origin →
git reset --hard origin/main → git clean -fd) needs GitHub
credentials; those are configured via gh auth git-credential in
the ubuntu user's environment, not root's.
Symptom (since v2.47.0.11 made sudo bash reset.sh the recommended
invocation, because the build step needs root):
$ sudo bash /mnt/Media/staple/reset.sh
Refreshing Staple
...
fatal: could not read Username for 'https://github.com':
terminal prompts disabled
Workaround: invoke as sudo -u ubuntu /mnt/Media/staple/reset.sh,
which keeps the git step in the ubuntu environment but then can't
write /usr/local/bin/staple-server without re-asking for sudo
inside the script.
Fix (v2.47.0.13): wrap the git refresh block in
sudo -u ubuntu -H bash -c '...' inside the script, so the operator
can use the natural sudo bash reset.sh reflex and the script
itself drops privileges for the three lines that need ubuntu's
credentials. The build / chmod / systemctl / docker compose lines
stay at root. Build metadata extraction (git describe,
git rev-parse) also runs through sudo -u ubuntu git -C so root
never tries to read the ubuntu-owned .git tree. (Originally tried
sh -c here; dash on Ubuntu doesn't grok set -o pipefail, so the
inner shell is bash.)
Backup of the pre-v2.47.0.13 script lives alongside as
reset.sh.bak-v2.47.0.13. The script is still not tracked in
the repo (it's deploy-host-specific); the canonical reference for
its contents is in docs/user/operators/upgrades.md.
B — TestUIAgentShowValid nil-pointer¶
Background: v2.51.0.13 widened AgentRun.StartedAt to *time.Time
because heartbeat_runs.started_at is nullable in the schema
(migration 011) and a pending run with NULL there was blowing up
pgx scans (cannot scan NULL into *time.Time). The pg-side fix
went in cleanly, but two existing templates in
internal/web/templates/agents/show.html were still calling
{{timeAgo .StartedAt}} (the non-pointer variant). timeAgo
takes time.Time by value, so html/template tries to dereference
the *time.Time for the call site — and on a NULL row that's a
nil-pointer dereference at template-execution time:
ERROR content render error template=agents/show.html
error="template: agents/show.html:89:67: executing
\"agents/show.html\" at <.StartedAt>: dereference of nil
pointer of type time.Time"
TestUIAgentShowValid saw 500 instead of 200 against any seeded
fixture that included a pending run.
Fix:
internal/web/templates/agents/show.html:89and:983switch{{timeAgo .StartedAt}}→{{timeAgoPtr .StartedAt}}. ThetimeAgoPtrhelper already exists ininternal/handler/render.goand returns"never"on nil. Renders identically for non-nil rows.internal/handler/render.go::durationFuncwidens its first argument fromtime.Timetointerface{}so{{duration .StartedAt .FinishedAt}}at line 984 keeps working whenStartedAtis*time.Time. The function already acceptedinterface{}for the end argument; the start argument was the last strict-typed gate. A nil pointer in the start position renders"—"(nothing to measure from); a nil pointer in the end position keeps its v2.43.0.x behaviour of"running".internal/handler/render_test.go::TestDurationFuncgains five new subtests covering the four pointer-start / pointer-end permutations and a non-time-type guard.
No migration, no version table touch (migration counter stays at
125). Pure script + template + template-helper change. Backup of
the operator-owned reset.sh lives at reset.sh.bak-v2.47.0.13.
v2.43.0.13 — 2026-05-29¶
Subagent dashboard terminal-status filter — ?status=terminal
returns every "done" row (completed / failed / cancelled);
?status=active returns every in-progress row (pending / running) —
without the operator having to repeat the filter for each enum
value.
The /admin/subagents ?status= filter had been a single-enum
equality match since v2.43.0.5. Operators triaging "show me
everything that's done" had been issuing three sequential queries
(status=completed, status=failed, status=cancelled) and eyeballing
the union. The symmetric "what's currently working?" case needed
two queries (pending + running). v2.43.0.13 collapses both into
single-click shortcuts.
Implementation:
query.ListAllRecentSubagentRunsFiltered(SQL pushdown layer) switches onfilter.Status:"terminal"→AND hr.status IN ('completed','failed','cancelled')"active"→AND hr.status IN ('pending','running')- any other value → the historical equality
hr.status = $Npath so existingstatus=completed,status=failed, etc. calls keep working unchanged. handler.applySubagentRowFilters(in-memory defensive pass) gets symmetric treatment via two helpers:terminalSubagentStatuses()andactiveSubagentStatuses(). When the resolved alias is"terminal"or"active"the loop matches against the set membership; otherwise it stays on the existing equality branch.handler.statusFilterAliases()learns three new entries:"terminal" → "terminal","active" → "active","cancelled" → "cancelled"(the third was implicitly accepted by the SQL layer but had not been on the dropdown — surfacing it lets operators filter on cancelled-only without typing the URL by hand).handler.subagentStatusDropdown()is a new helper that returns an ordered list of{Value, Label}options. The two shortcut pseudo-statuses lead ("Terminal (any done)","Active (pending or running)"); the per-enum rows follow. The template's status<select>ranges over this richer struct so the shortcut sits where the operator's eye lands first.
Tests:
internal/db/query/heartbeat_runs_test.go:TestListAllRecentSubagentRunsFiltered_TerminalAndActivePushdownseeds five runs across all five statuses for a fresh subagent, assertsterminalreturns exactly {completed, failed, cancelled} andactivereturns exactly {pending, running} from the seeded set, and that the intersection is empty.internal/handler/ui_subagents_test.go:TestApplySubagentRowFilters_TerminalAndActiveSets— pure- function pass over a five-row table; the single-status branch stays exercised in the same case.TestSubagentStatusDropdown_TerminalAndActiveAtTop— pins the leading two entries so a future template edit can't silently bury the shortcuts under the per-enum rows.TestUISubagentsIndex_StatusTerminalRoundTrip/TestUISubagentsIndex_StatusActiveRoundTrip—?status=terminaland?status=activeURL parse through, the dashboard renders 200, and the dropdown surfaces the option marked selected.TestStatusFilterAliases— extended to cover the three new entries.
No DB schema changes (migration counter stays at 125). Pure query / handler / template change.
v2.50.0.10 — 2026-05-29¶
Audit dashboard absolute/relative timestamp toggle — operators triaging incidents can flip the consolidated audit feed (and the per-actor timeline + the subagents dashboard) from ISO timestamps to "5 minutes ago" with one click, persisted per-browser.
The audit dashboard had been emitting 2026-05-29 14:32:18 for every
row since v2.50.0.0. That's the right format for forensics — sortable,
unambiguous timezone, copy-pasteable into a runbook. But operators
triaging a live incident often want the "how long ago" answer, and
recomputing it by eye for every row is slow. v2.50.0.10 adds a
two-pill toggle to the dashboard chrome that swaps every per-row
timestamp between absolute (default) and relative.
Implementation:
- New
formatRelative(t, now)helper ininternal/handler/ui_audit.go— parameterized onnowso the test stays deterministic. Uses the same vocabulary as the existing template-leveltimeAgohelper but extends past "Xd ago" into "Xw ago" so a 30-day audit row doesn't render as the indistinguishable "30d ago"; older values are still readable at a glance. Future timestamps (clock skew) coalesce to "just now" so a triaging operator never sees "-5m ago". formatRelativeNow(t)is a thin wrapper that callsformatRelative(t, time.Now()), wired into the global template func map asrelativeTimeso templates can emit the rendering inline without plumbing a precomputed string per row.- Every timestamp the affected templates render is wrapped as:
The
<time class="audit-ts" datetime="2026-05-29T14:32:18Z" data-relative-text="5m ago"> <span class="ts-absolute">2026-05-29 14:32:18</span> <span class="ts-relative" style="display:none">5m ago</span> </time>datetimeattribute lets a future user-agent recompute the relative on focus / clock change; thedata-relative-textmirrors the server-side computation so screen-reader / JSON-scraping users see the same value the visible span shows. - A "Show times: absolute | relative" pair of pill buttons sits at
the top of each affected page (under the existing page header).
An inline
<script>swaps thedisplay:between the two spans on click and persists the choice inlocalStorage["staple_audit_time_display"]. Default = absolute. The script gracefully degrades when localStorage is unavailable (private mode, extension blocking) — the toggle still flips during the page load, it just doesn't persist.
Applied to:
/admin/audit(consolidated feed, v2.50.0.0+) — per-row timestamps + scaffolding./admin/audit/actor/{email}(per-actor timeline, v2.50.0.6) — the Stats panel's "First seen" / "Last seen" + per-row timestamps + scaffolding./admin/subagents(subagent runs, v2.43.0.x) — the When column (enqueued vs. started) + scaffolding.
Deliberately skipped:
/admin/audit/insights— no per-row timestamps render on this page (Top failures + Top actors aggregate counts, not events), so the toggle would be a no-op. Adding the chrome alone would be dead UI./admin/audit/actors— no per-row timestamps either (event counts only).
Tests (internal/handler/ui_audit_test.go):
TestFormatRelative— thirteen-case table covering future, sub-minute, minutes, hours, days, weeks; verifies clock-skew coalescing.TestUIAuditDashboard_TimestampToggleScaffolding— pins the page-chrome markers:data-staple-ts-toggle, the twodata-staple-ts-modebuttons, and thestaple_audit_time_displaylocalStorage key.TestUIAuditDashboard_TimestampToggleRowMarkup— seeds a backup_runs row, asserts the rendered per-row markup carries<time class="audit-ts">,<span class="ts-absolute">,<span class="ts-relative", and thedata-relative-textattribute; confirms "just now" wins for a freshly-seeded row.
No DB schema changes (migration counter stays at 125). No new worker; this is a pure template + handler-helper change.
v2.47.0.12 — 2026-05-29¶
Operations panel backup workers status tile — a single glance at all four backup background workers (backup_cron, backup_verify, backup_retention, backup_reconcile) without scrolling the full worker table.
The backup arc has grown to four workers as of v2.51.0.14:
backup_cronruns the actual backup on a cadence (v2.51.0.14).backup_verifychecks dump integrity (v2.51.0.10).backup_retentionprunes local files past the retention cutoff (v2.51.0.3).backup_reconcileadopts orphans and tombstones ghosts (v2.51.0.12).
Operators triaging "is anything wrong with backups?" had been
scrolling the full "Background workers" table on /admin/operations
and picking out the four backup_* rows by eye. v2.47.0.12 surfaces
the answer as a compact tile sitting next to the existing v2.47.0.8
"Backups" lifecycle widget and v2.47.0.10 "Retention preview" card.
Implementation:
handler.resolveBackupWorkersTile(snaps, now, boot)filters all registered TickStats snapshots tobackup_*names, computes the per-row staleness class via the existingclassifyWorkerStalenesspolicy (same vocabulary as the big table — ok / warn / stale / boot), and aggregates into a tile-level OverallClass:- green when every row is "ok"
- yellow when any row is overdue (>1× interval) or in boot grace
- red when any row is stale (>2× interval) OR when any expected backup_* worker is missing from the registry OR when no backup workers are registered at all
expectedBackupWorkerNames()is the source of truth for the "of registered" affordance the template renders top-right. Set to {backup_cron, backup_reconcile, backup_retention, backup_verify}. humanRelativeFromNow(t, now)parameterizes the relative-time formatter on an explicit now so the tile stays deterministic in tests.aggregateBackupWorkersHealthis a pure function with a table-driven test pinning all the green / yellow / red branches.- The template adds a third card to the existing flex row alongside
the Backups widget and the Retention preview tile. Each row links
to
/metricsvia the bottom-right "All worker metrics →" affordance (per-worker drill-down is out of scope here — operators who want the per-worker view can scroll to the "Background workers" section below the tile).
Tests (internal/handler/ui_operations_test.go):
TestAggregateBackupWorkersHealth— eleven-case table covering green / yellow / red transitions, missing rows, mixed warn+boot.TestResolveBackupWorkersTile_FilterAndSort— seeds fourbackup_*snapshots + two non-backup workers, asserts only the backup family lands in the tile, sorted, with the stale row promoting the tile to red.TestResolveBackupWorkersTile_NeverTicked— verifies the "never" label surfaces when a backup_* worker registers but hasn't ticked yet.TestHumanRelativeFromNow— seven cases pinning the formatter shape ("just now" / "1m ago" / "1h ago" / "3d ago").TestUIOperationsShow_BackupWorkersTile_Rendered— end-to-end render test: seeds fourbackup_*TickStats and asserts the rendered admin page contains the tile header, all four worker names, and the "registered" affordance.
Docs: docs/user/operators/backup-and-restore.md gets a new
"Backup workers tile (v2.47.0.12)" subsection under the existing
Operations panel widget block.
No DB schema changes (migration counter stays at 125). No new worker; this is a pure UI / handler change reading already-registered TickStats.
v2.43.0.12 — 2026-05-29¶
Fix the flaky TestSpawnSubagent_DB_DepthLimit test by reconciling
its inverted depth=0 expectation with the helper's documented
"<=0 → use default" safety net.
The test had been latently contradictory since v2.43.0.0: it passed
MaxDepth: 0 and expected SpawnSubagent to return an error,
while the helper's first action with a non-positive MaxDepth is to
re-default to STAPLE_MAX_SUBAGENT_DEPTH (default 3) via the same
envIntPositive semantics the env-config side uses. The original
test's own inline comment admitted "MaxDepth=0 in the limits gets
re-defaulted to 3 by the helper" and that "we can't easily build a
depth-3 parent without recursion, so just verify the cross-tenant
/ other guards run cleanly above the depth check" — a tacit
acknowledgement that the assertion was wrong.
Decision: the code is right. Treating MaxDepth<=0 as "use default"
matches the env-side semantics (envIntPositive treats
zero/negative/non-numeric as fallback) and protects against the
operator-typo footgun where setting STAPLE_MAX_SUBAGENT_DEPTH=0
would silently disable the depth bound entirely. v2.43.0.12 fixes
the test, not the helper.
The new test seeds a parent with subagent_depth = 1 directly via
SQL (no recursive spawn needed), then exercises three guards:
MaxDepth=1with parent at depth 1 → rejects (nextDepth=2 > 1). This is the real production guard path.MaxDepth=0→ re-defaults to env / 3 → nextDepth=2 succeeds. Pins the operator-safety re-default.MaxDepth<0→ same re-default treatment.
No production code changes — this is a pure test fix.
v2.50.0.9 — 2026-05-29¶
Audit dashboard action filter pushdown — operators can now narrow the consolidated /admin/audit feed to a single per-row action (e.g. "failed", "denied", "rejected") across every surfaced source table.
v2.50.0.8 closed the source × actor × date drill-down loop from the insights page, but the action dimension was still client-side post-filter: an operator clicking "denied" from the Top failures table got the full source feed and had to eyeball the action chips. v2.50.0.9 makes action a first-class WHERE-clause dimension across all eight audit sources.
Implementation:
query.AuditFeedFiltergrows anActionfield. Each per-source filtered helper maps the dashboard's action dimension onto its underlying column:secret_audit_events.actionplugin_approval_events.actionegress_anomaly_events— no action column (denials are always 'denied'); a non-empty filter ≠ 'denied' short-circuits to []a2a_dispatch_events.outcomepairing_audit_events.actionagent_prompt_proposals.statusinstance_flag_events.actionbackup_runs.outcomehandler.auditFeedFiltermirrors the field, parses?action=, passes it throughloadAndFilterAuditFeed, and includes it in the in-memory defensive second pass + the CSV / sort-link query-string round-trip.audit/index.htmlgets a new Action text input in the filter row. The pill summary line below the filter form echoes the active value.audit/insights.html's Top failures table now emits?source=...&action=...&from=...so the drill-down lands on /admin/audit pre-filtered to the precise (source × action) pair the operator clicked.
Tests:
query.audit_aggregate_test.go—auditFilterActionhelper shape: empty no-op, equality render, column-rename mapping matrix.handler.ui_audit_test.go— action-only filter, action + source composition, action round-trip throughfilterQueryString.handler.ui_audit_insights_test.go— Top failures source anchors all carry&action=; locks in the drill-down so a future template edit can't silently regress it.
v2.51.0.14 — 2026-05-29¶
Server-side backup cron worker — the BACKUP itself now auto-runs on the same operator-invisible cadence that retention, verification, and reconciliation already ran on.
The v2.51.0.0..0.13 backup arc closed every loop around the backup
file EXCEPT the production of the file itself: retention swept,
verify cron re-decrypted, reconcile healed audit drift — but
somebody still had to ssh in and run staple-cli backup to produce
the dump in the first place. v2.51.0.14 closes that loop.
Implementation:
internal/backup/runner.go::Run(ctx, BackupRunOptions)is the new pure-function core. Takes the pool, the vault, the dir, the encrypt / verify / upload toggles, and the audit-row kind ('create' for CLI, 'cron' for worker). Returns a structured*BackupRunResultplus the insertedbackup_runs.id.cmd/staple-cli/backup.go::runBackupnow delegates to the runner. The CLI keeps its operator UI layer (thefmt.Printflines, the row-count summary, the post-success summary) and is responsible for KEK loading. The audit-row write moves into the runner so both callers share one source of truth.internal/worker/backup_cron.go::BackupCronis the new server- side worker. HonoursSTAPLE_BACKUP_CRON,STAPLE_BACKUP_CRON_INTERVAL_HOURS(default 24h, clamped [1, 720]), andSTAPLE_BACKUP_DIR. Auto-disables when the vault is unavailable (STAPLE_ENCRYPTION_KEYunset) orpg_dumpis not in$PATH.cmd/server/main.gowires the worker beside the retention + verify + reconcile trio.- Migration 125 extends
backup_runs.kind's CHECK constraint to admit the new'cron'value beside('create', 'cron_verify', 'reconciled').
Tests:
internal/backup/runner_test.go— pure-function input validation (missing DSN, missing vault, missing dir) plus an integration smoke that spins a real Vault against the dev pool, runs the pipeline, and asserts the kind='cron' row landed.internal/worker/backup_cron_test.go— constructor coverage across env-parse / clamp / disable paths, plus a round-trip runOnce test that verifies the file lands and the audit row is tagged.
Operator note: on hosts where pg_dump lives only in the postgres
container (not the staple-server systemd unit's PATH), the worker
auto-disables with a clear journal line. Install postgresql-client
in the staple deployment or fix the unit's PATH.
v2.42.0.8 — 2026-05-29¶
Add the /stats slash command to the staple-cli chat REPL —
one-shot summary of the current chat's turn count, total characters
sent and received, age, and bearer agent name. Lightweight operator
UX win for "how much context have I burned in this session".
The v2.42.0.7 slash-command set (/reset, /system, /history)
covered three common debug actions but missed the most basic one
operators kept asking for: "is this chat 5 turns deep or 50?" Without
/stats the operator had to /history 200, eyeball the dump, and
count — overkill for a single-line aggregate. v2.42.0.8 closes that
gap:
> /stats
Chat statistics
- Turns: 12 (you 6, agent 6)
- Sent: 1240 chars / Received: 3480 chars
- Age: 47m 12s
- Agent: ops-bot
The command issues three one-shot HTTP calls — none of which consume a chat turn:
-
GET /api/chats/{id}/messages?limit=10000— pulled into the newcomputeChatStatshelper which buckets messages by role (user / agent / system) and rolls up turn counts and content-markdown character totals. The client-side cap matches whatchatFetchMessagesalready advertises in the help text — 200 — but the call requests 10000 because the server clamps at 500 and we want the most accurate counts available. The handler doesn't render the body, so the response is small in practice (role + content_markdown per row). -
GET /api/chats/{id}— minimal projection (chatStatsMetaResponse) readschat.created_atfor the age calculation. When the metadata call fails OR the server returns a zero-value created_at, the code falls back to the earliest message'screated_at(rows arrive in seq ASC order, so index 0 is the earliest); when both are unavailable the age line renders as "(unknown — chat metadata unavailable)" so the rest of the block still surfaces. -
GET /api/agents/me— re-uses the v2.42.0.7chatFetchSystemPromptplumbing for the bearer agent's name, matching the posture/systemalready takes. Fetch failure → agent name renders as(unknown)rather than failing the whole command.
The age formatter (formatChatAge) rounds to whole seconds and uses
escalating units — Ns under a minute, Mm Ss under an hour, Hh Mm Ss
under a day, and Dd Hh Mm Ss past 24h. Sub-second durations show
"0s" instead of "0.0s" so the terminal output stays clean.
System-role messages (engine-injected personality / fork markers) get their own counter — they don't count toward the user/agent turn split because they're not operator-driven. When at least one system marker is present a sub-line surfaces under the Turns line so the operator can tell the headline from the engine bookkeeping.
/stats is added to:
chatHelpTextincmd/staple-cli/chat_slash.goso/helpprints it alongside the v2.42.0.7 set.- The
chat.goflagset usage block (the--help/-houtput) sostaple-cli chat -hsurfaces the new command. shouldRecordToHistoryincmd/staple-cli/chat.goso the operator's persistent liner history stays clean of debugging commands (same posture as/quit,/reset,/system,/history).
Tests in cmd/staple-cli/chat_slash_test.go:
-
TestComputeChatStats_Buckets— 2 user + 2 agent fixture asserts correct counts and character sums. -
TestComputeChatStats_EmptyAndSystem— nil input and system-only fixture confirm the empty path and the system-row bookkeeping (system messages count towardTurnsandSystemTurnsbut notUserTurns/AgentTurns/chars). -
TestFormatChatAge_Boundaries— pins the eight boundary cases (sub-second, 1s, 47s, 1m, 1m15s, 1h, 1h30m12s, 1d 1h). -
TestShouldRecordToHistory_ExcludesStats—/statsstays out of persistent history; a normal prompt containing the word "stats" still records. -
TestChatLoop_StatsCommand_HappyPath— full REPL drive throughchatLoopWithConfigwith a 3-endpoint mock server (messages, chat metadata, agents/me). Asserts the rendered output carries the expected counts, age prefix, and agent name. -
TestChatLoop_StatsCommand_EmptyChat— zero-message + zero- value created_at confirms the empty path renders cleanly with the "(unknown)" age fallback. -
TestChatLoop_StatsCommand_MessagesServerError— 500 on the messages endpoint surfaces inline ("Error fetching messages: ...") and the REPL keeps running through the trailing/quit. -
TestChatLoop_HelpV2_42_0_7(extended) — now asserts/statsappears in/helpoutput alongside the v2.42.0.7 commands.
No schema change. No new migration. No new server-side route. Pure client-side glue against existing server endpoints.
v2.50.0.8 — 2026-05-29¶
Wire the v2.50.0.7 audit-insights page tiles into the chronological
feed: every linked source / actor cell now carries the active
window's start as a ?from=... param, so clicking a row on
/admin/audit/insights?window=7d lands the operator on
/admin/audit (or /admin/audit/actor/{email}) pre-filtered to the
same time range.
The insights page added three aggregation tables in v2.50.0.7 (Top
failures / Top actors / Source activity). Every row had counts but no
durable drill-down — the existing anchors went to /admin/audit?source=…
or /admin/audit/actor/… with no from= carry-over, so a 7d-window
operator who clicked into "12 failures on egress" got the dashboard's
default 50-row chronological window instead of "the 7d slice the cell
came from". That made the drill-down lose its context: operators
spent the second click re-applying the date filter from the date
inputs on the feed page.
v2.50.0.8 closes the gap:
-
Handler (
internal/handler/ui_audit_insights.go): computewindowStart = now.UTC().Add(-windowDur).Format(time.RFC3339)and pass it to the template asWindowStart. The format matches whatparseAuditDateBoundalready accepts on the feed side (/admin/auditand/admin/audit/actor/{email}), so the round-trip works without any feed-side change. -
Template (
internal/web/templates/audit/insights.html): every drill-down anchor now appends&from={{$.WindowStart | urlquery}}(or?from=...when the actor email is in the path):-
Top failures: source cell + "view rows →" both link to
/admin/audit?source=<src>&from=<windowStart>. -
Top actors: actor email links to
/admin/audit/actor/<email>?from=<windowStart>, "view rows →" links to/admin/audit?actor=<email>&from=<windowStart>. -
Source activity: source cell links to
/admin/audit?source=<src>&from=<windowStart>when the row had any activity in the window (Count > 0). Zero-count rows stay rendered as plain text — same v2.50.0.7 behaviour.
All anchor params are
| urlquery-escaped so embedded:in the RFC3339 timestamp becomes%3Acorrectly. -
-
Tests (
internal/handler/ui_audit_insights_test.go): two new tests scan the rendered HTML for every/admin/audit?source=,/admin/audit/actor/, and/admin/audit?actor=anchor and assert each one carries thefrom=param. A missing param on any one anchor regresses the drill-down, so the tests fail on the first miss (per-anchort.Errorfkeeps the failure local to the surface that drifted).
Action filter was scoped out: the v2.50.0.7 insights cells include
the Action per failure row, but auditFeedFilter on the feed side
has no Action field — adding it would require touching the SQL
pushdown (query.AuditFeedFilter, the per-source WHERE builders,
and the query-string round-trip helpers). Well over the 30-LOC bonus
threshold from the brief, so deferred to a future v2.50.0.x.
The per-actor timeline page (UIAuditActorTimeline) currently
ignores the ?from= query param it now receives — the page renders
the full unfiltered timeline regardless. The link still works (lands
on the right actor); narrowing the actor timeline by window is a
follow-up. The dashboard /admin/audit?actor=…&from=… link IS fully
honored because the v2.50.0.4 feed handler already parses from=.
No schema change. No new migration. No new route. Pure template + handler.
v2.47.0.11 — 2026-05-29¶
Permanent fix for the sudo go build deploy bug introduced when
node4 upgraded to Go 1.24: reset.sh now passes -buildvcs=false
to the build step so the toolchain doesn't try to read .git as
root, silently abort, and leave the deploy without a binary.
reset.sh is operator-owned and lives at /mnt/Media/staple/reset.sh
on node4 — it isn't tracked in this repo because it's deploy-host
specific. Several recent release subagents (v2.43.0.11, v2.51.0.13,
v2.46.0.10) ran into the same trap: under Go 1.24, go build defaults
to -buildvcs=true, which stamps the binary with the commit SHA and
dirty-tree state by reading .git. The checkout under
/mnt/Media/staple/staple is owned by the unprivileged operator
account, so when reset.sh runs the build as root, the .git
directory is unreadable, go build silently fails, no binary is
written to /usr/local/bin/staple-server, and the subsequent
sudo chmod 755 /usr/local/bin/staple-server blows up with "No
such file or directory". Workaround had been to prepend a manual
sudo go build -buildvcs=false ... invocation to every deploy.
The fix is one flag on the build line in reset.sh:
The -ldflags block on the lines above still injects version,
commit, and build-date via -X main.version=… so the running
binary continues to report the right tag through
internal/version.Version(). -buildvcs=false only suppresses
the Go-toolchain VCS stamping that is redundant given the
explicit -X overrides.
Done in this commit:
- Patched
/mnt/Media/staple/reset.shon node4 with the-buildvcs=falseflag and saved areset.sh.bak-v2.47.0.11backup alongside it for rollback parity with prior hardening revisions (reset.sh.bak-v2.47.0.5, etc.). - Documented the trap in
docs/user/operators/upgrades.mdunder the existingreset.shhardening note, so the next subagent (or human) who has to spelunk a half-finished deploy has a written explanation of why the flag is there. - CHANGELOG entry (this entry) so the v2.47.0.11 tag has a repo-visible record of an otherwise external script change.
No source code changes. No new migrations. No new routes. No schema
change. The v2.47.0.11 deploy itself exercises the fix: if the
patched script writes the binary cleanly and systemctl start staple
brings up /healthz reporting v2.47.0.11, the fix is verified.
v2.46.0.10 — 2026-05-29¶
Finish the GEPA agent-level proposal opt-in toggle UI: add the
"ticks every 6h" interval hint next to the toggle so operators
understand the lag between flipping the switch and the worker
acting on it, add structured-log audit attribution on every flip
(so journalctl scans answer "who turned auto-promote on for agent
X?"), and backfill round-trip + bad-input tests for the
UIAgentSetGepaAutoPromote handler.
The toggle, route, and handler shipped in v2.46.0.2 (locked-roadmap
§4.9). The agent show page already rendered an Enable/Disable
button posting to POST /agents/{agentId}/ui/set-gepa-auto-promote.
v2.46.0.10 fills the remaining product polish that was deferred at
the time:
-
Interval hint (
internal/web/templates/agents/show.html): add a second<small>line under the toggle reading "GEPA evaluation ticks every 6h (thegepa_evaluatorworker). Toggling here takes effect on the next sweep." Pre-fix operators flipped the switch and asked why "nothing happened" — answer: the worker tick rate. The interval matches thegepaEvaluatorIntervalconstant ininternal/worker/gepa_evaluator.go:61. -
Audit attribution log (
UIAgentSetGepaAutoPromoteininternal/handler/ui_agents.go): emit aslog.Infoline on every successful flip carryingagent_id, the new booleanvalue, and the actor'sactor_id+actor_type. Deferred: a dedicatedagent_audit_eventstable — for now structured logs are the durable audit trail. (Pattern matches what v2.50.0.x did for the audit-dashboard event sources before they got their own table.) -
Tests (
internal/handler/ui_agents_gepa_toggle_test.go): two new tests cover (1) round-trip flip-on then flip-off actually mutatingagents.gepa_auto_promotevia re-read, and (2) bad input ("value=maybe") returning 400 with the column untouched. The handler had no test coverage at v2.46.0.2 ship; v2.46.0.10 fills that gap.
No new migrations. No new routes. No schema change. The handler behavior is unchanged from v2.46.0.2 modulo the audit log line — strictly additive, no operator-visible regression.
v2.51.0.13 — 2026-05-29¶
Fix three pre-existing test bugs that were red on node4 but had nothing to do with the suites under active development. The production code wasn't affected by any of them; this is purely cleanup so the test suite runs green and future regressions stand out instead of getting lost in the existing-fail noise.
-
Egress UUID (
TestUICompanyEgressCreate_HappyPath,TestUICompanyEgressCreate_DuplicateReturns409): the tests handed the handler anauth.ActorwithUserID = "user-x", butcompany_egress_allowlist.created_by_user_idisUUID REFERENCES users(id) ON DELETE SET NULL(migration 089). Postgres rejected the insert with22P02 invalid input syntax for type uuid. Fixed by seeding a realusersrow per test and using the seeded UUID as the actor'sUserID. The happy-path assertion now compares the persisted column to that UUID instead of the literal "user-x". The duplicate test additionally needed non-NULL port + protocol on both rows — Postgres treats NULL as distinct in UNIQUE constraints, so the previous NULL-vs-NULL seeding never tripped the duplicate-detection path. (Pre-fix it only ever reached the 409 check by accident, because the first insert failed on UUID validation; once the first row landed cleanly we needed the explicit non-NULL tuple to actually exercise ErrEgressAllowlistEntryExists.) -
MCP runs scan NULL
started_at(TestMCPHandlerPromptsGet_ValidArgs):AgentRun.StartedAtwas a non-pointertime.Time, but the underlying columnheartbeat_runs.started_atis nullable (migration 011) — pending runs that haven't started yet legitimately scan as NULL. pgx blew up withcan't scan into dest[2] (col: started_at): cannot scan NULL into *time.Time. Switched the field to*time.Time, updated the two real-code consumers (internal/handler/mcp.go::buildPromptRecentRunsandinternal/worker/context_compression.go) to nil-guard the format call, and tightened the compression cutoff loop to walk back to the oldest non-nil StartedAt when picking the temporal anchor. UpdatedTestAgentRunFieldsto use a&nowliteral. -
PATH-dependent test (
TestUITestAdapterFallbackCWD): the test asserts thecwd_validpass message, but the/ui/test-adapterendpoint runs acli_installedprobe viaexec.LookPath("claude")first. node4 (and many CI sandboxes) don't have claude in PATH, so the probe short-circuited with "Claude CLI not found in PATH" and the test failed long before cwd_valid ran. Addedt.Skip("claude CLI not in PATH ...")when LookPath returns error. Developer machines with a real claude binary still get the coverage.
No migration. No schema change. No production code path was silently failing; these were latent test gaps.
v2.43.0.11 — 2026-05-29¶
Fix /await-subagents-recursive production bug: the recursive CTE
in query.ListDescendantAgents was emitting a CTE column literally
named coalesce because COALESCE(adapter_config, '{}'::jsonb)
was unaliased. The outer SELECT %s FROM descendants (where %s is
the shared agentColumns projection) then tried to re-read
adapter_config from descendants, and Postgres rejected the query
with column adapter_config does not exist. Every recursive dispatch
through the v2.43.0.4 verb silently failed with that error. High
severity — the verb was effectively dead in production.
Root cause: when v2.43.0.4 introduced descendantsRecursiveColumns()
to mirror agentColumns with an a. alias for the recursive arm of
the CTE, both column-list builders kept the original unaliased
COALESCE(...) projection. Postgres derives the CTE column name
from the projection — without AS adapter_config the column is
named coalesce (its function), and the outer SELECT ... FROM
descendants blew up reading the third-layer projection.
Fix: alias COALESCE(adapter_config, '{}'::jsonb) AS adapter_config
in both agentColumns (around internal/db/query/agents.go:100)
and descendantsRecursiveColumns() (around line 848). Positional
scanAgent is unaffected by column names, so all other callers
(GetAgentByID, ListAgentsByCompany, …) keep working without code
changes.
Verification: TestProcessChatAgentReply_AwaitSubagentsRecursive_*
(3 subtests in internal/handler/chat_actions_test.go) — the
DispatchesSubtree case actually exercises the recursive CTE against
a 3-level descendant graph and now passes. The other two subtests
(_NoAgent, _NoDescendants) bail out before the CTE fires and
were already passing; the regression bar is the DispatchesSubtree
case.
No migration. No schema change. Pure SQL projection-naming fix in
internal/db/query/agents.go.
v2.49.0.3 — 2026-05-29¶
Extend /readyz with pgxpool connection-pool stats. The db_ping
row now carries pool_acquired / pool_total / pool_max_open /
pool_idle / pool_utilization_pct fields and escalates to
not_ready when utilization crosses an operator-configurable
threshold (default 90%). Operators can spot connection-pool
exhaustion — a classic Postgres-side bottleneck — directly off
/readyz JSON instead of correlating with /metrics.
/readyz already pinged the database and reported the result on
the db_ping check row. v2.49.0.3 piggybacks on that ping to also
read pool.Stat() and surface the snapshot inline:
{
"name": "db_ping",
"ok": true,
"pool_acquired": 3,
"pool_total": 8,
"pool_max_open": 16,
"pool_idle": 5,
"pool_utilization_pct": 18.8
}
Fields are emitted via *int32 / *float64 pointers with JSON
omitempty so:
-
Every other check row (
workers,worker:<name>, …) omits the pool fields entirely — the existing wire shape stays backward compatible for dashboards that only consumename/ok/detail. -
The
db_pingrow itself omits the fields whenpool == nil(a defensive guard for production routes.go bugs) so the "no pool" failure mode remains explicit.
pool_utilization_pct = AcquiredConns() / MaxConns() × 100,
rounded to 1 decimal place to match the v2.49.0.2 error_rate_pct
wire shape. MaxConns is the honest denominator: TotalConns
floats as pgx scales connections up and down, so it would yield a
misleading 100% whenever the pool happens to be fully grown but
only half-used.
Escalation: when pool_utilization_pct > threshold the db_ping
row flips to ok=false and the overall response status becomes
not_ready (with HTTP 503). The threshold is configurable via
STAPLE_READYZ_POOL_UTILIZATION_PCT and defaults to 90%. Out-of-
range or unparseable values silently fall back to the default. The
env var is read once at handler construction — runtime probes don't
pay the env-lookup cost. Same pattern as v2.49.0.2's
STAPLE_READYZ_WORKER_ERROR_RATE_PCT.
The plain-text /healthz/ready alias (v2.47.0.3) mirrors the
escalation — over-threshold utilization contributes
db pool utilization <X.X>% to the not ready: ... body so
operators tailing LB logs see the signal without parsing JSON.
Tests added:
TestPoolUtilizationThreshold_DefaultAndOverride— pins the env reader's branches (unset → default, parsed → value, bad → default, out-of-range → default).TestDerefInt32— pins the nil-safe pointer deref used by the detail-string formatting.TestReadyzHandler_PoolStatsPopulatedOnHappyPath— runs the handler against a realtestPool(t)and asserts every pool stat field is populated, the row stays OK, and utilization sits in the 0..100 range.TestReadyzHandler_PoolUtilizationOverThresholdFlipsNotReady— sets the threshold to 0.5%, acquires a real connection to drive utilization above the threshold, and asserts the row flips tonot_readywith the canonical detail string mentioning both utilization and threshold.TestHealthzReady_PoolUtilizationBodyShape— pins the LB-friendly text/plain body for the escalation: must containdb pool utilizationand end with a single newline.TestHealthzReady_PoolStatsBelowThresholdReady— confirms the happy path of the text endpoint stays atready\nwhen a quiet test pool sits below the default 90% threshold.
Operator docs (docs/user/operators/monitoring.md) updated with the
new field table, the threshold env var, and the rationale for using
MaxConns over TotalConns as the denominator.
No DB schema change; no migration. The pool snapshot is a process-local read.
v2.43.0.10 — 2026-05-29¶
Bulk cancel for subagent runs. v2.43.0.9 added a per-row Cancel button on the subagent detail page — useful for one stuck run, slow for a runaway dispatch with ten or fifty rows to kill. v2.43.0.10 closes the loop on the index page itself: a checkbox per cancellable row, a select-all checkbox in the header, and a "Bulk action / Apply" bar above the table. Two clicks instead of N.
The page mechanics:
-
Cancellable rows (status ∈ {
pending,running}) carry a checkbox withname="run_ids". Terminal rows (completed,failed,cancelled) render a—placeholder instead so the operator can't accidentally select them. The formaction=cancelis the only supported bulk action today; the dropdown is shaped for future expansion (retry / re-enqueue / archive would slot in here). -
The "Apply" button is disabled until at least one row is ticked; a small "N selected" counter keeps the operator oriented. The select-all checkbox in the header ticks every cancellable row at once and supports the indeterminate state when the selection is partial.
-
The submit handler shows a
confirm()dialog with the count and a one-sentence reminder that cancellation is an audit-row status flip — the worker may still emit one final heartbeat. A misclick on Apply doesn't nuke a dispatch by accident. -
On submit the form posts to
POST /admin/subagents/bulk-cancel. The handler:- Gates on instance-admin (same posture as the per-row Cancel).
- Parses the multi-value
run_idsslice and passes it to the newquery.BulkCancelHeartbeatRunshelper which iterates per row through the v2.43.0.9CancelHeartbeatRunByOperatorwrapper. The tally distinguishescanceled(row was cancellable, now flipped) fromskipped(row missing or already terminal). - On success returns 303 to
/admin/subagentswith a fragment-encoded flash:#cancelled=<canceled>-of-<total>-<skipped>-skipped. An inline JS snippet on the index page reads the fragment on load, renders a short notice ("Cancelled 7 of 9 runs (2 were already terminal)."), then strips the fragment viahistory.replaceStateso a refresh doesn't replay it. - On a partial DB error returns 500 with a plain-text body that surfaces the tally up to the point of failure — every successfully cancelled run already has its audit row written, so the operator's signal is "the remainder didn't fire."
-
Empty
run_ids(the operator submitted with nothing ticked, or a hand-crafted POST without a body) is a no-op: the handler redirects with#cancelled=0-of-0-0-skippedand the JS notice reads "No runs were selected." The button on the page is disabled in this state but the defensive branch keeps the handler honest.
Query layer — new query.BulkCancelHeartbeatRuns(ctx, pool, runIDs,
actorUserID) returns (canceled, skipped int, err error). The loop
trims each id (blank entries are silently dropped — they map to the
empty form fields a browser sometimes submits) and stops on the
first DB error with a partial tally so the handler can surface where
the iteration stopped. The wrapper itself does no UPDATE — every
cancellation routes through the existing v2.43.0.9
CancelHeartbeatRunByOperator so audit attribution stays
consistent.
Tests added:
-
DB-level (
TestBulkCancelHeartbeatRuns_HappyPath,TestBulkCancelHeartbeatRuns_EmptyInputNoop,TestBulkCancelHeartbeatRuns_BlankEntriesSkipped,TestBulkCancelHeartbeatRuns_MissingRowSkipped) seed three runs (pending / running / completed) and verify the tally is 2 canceled, 1 skipped, with the cancellable rows flipped and the terminal row untouched. Edge cases (nil / empty / whitespace-only slice; non-existent runID) get their own table-row coverage. -
Handler-level (
TestUISubagentRunsBulkCancel_NonAdminForbidden,TestUISubagentRunsBulkCancel_EmptyRunIDs,TestUISubagentRunsBulkCancel_HappyPath,TestUISubagentRunsBulkCancel_NoRunsTickedRedirectsCleanly) cover the auth gate, the empty-form noop, the canonical fragment-encoded redirect target, and a seeded three-row run POST that asserts both the redirect tally and the DB-level status flips.
Operator docs (docs/user/operators/audit-dashboard.md) updated to
describe the bulk widget and the fragment-encoded flash semantic
next to the v2.43.0.9 per-row Cancel description.
No DB schema change; no migration. The bulk helper composes existing v2.43.0.9 plumbing — no new state machine.
v2.50.0.7 — 2026-05-29¶
Audit dashboard gains a pre-aggregated insights page at
/admin/audit/insights. Operators triaging an active incident now
have a one-click view of "what's been failing the most" instead of
scrolling the chronological feed at /admin/audit. The page pivots
the trailing-window audit activity into three tiles — top failures
(source × action), top actors (success / failure mix), and per-
source activity totals — with a 24h / 7d / 30d window selector.
Until v2.50.0.7 the consolidated audit dashboard was the only cross-tenant aggregate view: a chronological merge across every per-company audit table the platform writes. Forensics-friendly, aggregation-hostile — an oncall asking "which source has been failing loudest in the last 24h?" had to read 200 rows by hand. v2.50.0.7 adds a pre-aggregated companion page that pivots on (source × action) and (actor × success / failure) so the same answer is one click away.
The page renders three tiles:
-
Top failures — top 10 (source, action) rows where the action classifies as a failure per the new
query.IsAuditFailureActiontable. Sorted by count desc, ties broken by (source asc, action asc) so the same input always produces the same row order. Each row links into the main dashboard at/admin/audit?source=<table>so the operator can drill into the underlying rows in two clicks. -
Top actors — top 10 actors by total event count over the window, with success / failure counts broken out using the same allow-list. Sorted by total desc; the
systemactor (egress denials, a2a outcomes, system-attributed rows) is a real row so operators can see how loud the system has been over the window. Per-actor cells link into/admin/audit/actor/<email>(the v2.50.0.6 drill-down) when the row is notsystem/(deleted). -
Source activity — per-source event count, in the canonical eight-source order. Rendered as a horizontal bar chart with a 40-char scale; quiet sources still appear at width 0 so the operator sees what was checked.
The window selector lives in the page header (24h / 7d / 30d).
Default is 24h. Unknown / typo'd ?window= values collapse to the
default — the page always renders. Zero and negative durations
collapse to 24h at the query helper level so an upstream caller
can't accidentally trigger an unbounded scan.
DB-level work — new query.AuditInsights(ctx, pool, window) helper
issues one GROUP BY query per source under scope.WithBypass (eight
total). Each query joins through the source's actor column to
resolve emails inline so the aggregator doesn't need a second
round-trip. The action projection is per-source — the brief
prescribed action/outcome but the real schemas vary:
secret_audit_events.action(denied is the failure)plugin_approval_events.action(denied / banned / rescinded / auto_reverted are failures; allowed is success)egress_anomaly_eventsprojects the literal'denied'because the table has no action column and every row IS a denial; the same shape the chronological feed usesa2a_dispatch_events.outcome(the failure set is rpc_error / transport_error / rate_limited / cascade_exhausted / no_candidates — the brief's literal {failed, timeout} doesn't appear in cascade.go's outcome taxonomy so we ranged against what the codebase actually writes)pairing_audit_events.action(only the auto_revoke_ paths classify as failures; manual unpair_ paths are normal lifecycle transitions)agent_prompt_proposals.status(rejected is the failure; accepted / pending are not)instance_flag_eventshas no failure mode — toggles are deliberate operator actions; the allow-list entry is empty so the helper counts no failures for this sourcebackup_runsuses COALESCE so cron_verify rows surfaceverify_outcome(verification_failed) while create / reconciled rows surface the raw outcome (failure)
The failure-action allow-list lives in a single helper
(auditFailureActions()) so a schema change in any source surfaces
as a one-line edit + a test edit, not a hunt across SQL strings.
The exported IsAuditFailureAction predicate is what the page
handler and the tests both consult.
The sort posture is stable: ties on count fall back to (source asc, action asc) for failures and (actor asc) for actors. Re-rendering the same input yields the same row order — handy for snapshot tests and operator memory.
Errors per-source are logged but do NOT bubble up — the page must stay reachable when one slow / broken table would otherwise take the whole insights view down. A source that errors contributes zero counts and the rest of the page renders normally.
Sidebar / nav — a new Insights sub-link sits below the existing
Audit entry in both sidebar variants (with / without an active
company) so operators have one click to either surface. The main
/admin/audit page also gains an Insights → button next to the
existing Download CSV / By actor → controls.
Tests added:
-
Query-level pure-function tests (
TestAuditFailureActions_PinsKnownMapping,TestAuditFailureActions_CoversEverySurfacedSource,TestInsightsActionExpr,TestInsightsActorJoin,TestInsightsCreatedAtExpr) pin the load-bearing per-source projection so a schema-drift or a typo in the failure-action allow-list fails CI before it ships. -
Query-level DB-integration tests (
TestAuditInsights_DBIntegration_SeededFailure,TestAuditInsights_TopActorsSurfacesSystem,TestAuditInsights_DefaultWindow) seed real backup_runs failure rows and assert they surface in bothTopFailuresandSourceActivity, that thesystemactor's success / failure counts add up to the total, and that a zero / negative window collapses to the 24h default. -
Handler-level tests (
TestUIAuditInsights_NonAdminForbidden,TestParseAuditInsightsWindow,TestAuditInsightsWindowOptions,TestBuildInsightsSourceBars_CanonicalScaling,TestBuildInsightsSourceBars_AllZero,TestAuditInsightsTotalEvents,TestUIAuditInsights_RendersDefaultWindow,TestUIAuditInsights_RendersAlternateWindow,TestUIAuditInsights_UnknownWindowCollapsesToDefault) cover the auth gate, the window-selector parsing, the bar-projection helper, and the end-to-end render path.
No DB schema change; no migration. The backup_runs.verify_outcome
overlay introduced in v2.51.0.10 is consulted via COALESCE only at
query time.
v2.43.0.9 — 2026-05-29¶
Operator Cancel button on the subagent detail page. v2.43.0.8 made
the per-run subagent detail at /admin/subagents/{run_id} a read-
only debug view, with a note that cancel/retry actions still lived
on the per-company runs view. v2.43.0.9 brings the cancel half of
that to the instance-admin page directly — operators don't have to
hop to the per-company UI just to kill a stuck or runaway run.
The page header on /admin/subagents/{run_id} (v2.43.0.8) already
read "Read-only. Use the per-company runs view for cancel / retry
actions." That guidance was friction: an operator who landed on the
detail page from /admin/subagents (which surfaces runs across every
company) had to look up the company, navigate to its runs view, find
the same row, and then click cancel. v2.43.0.9 closes the loop
without changing the per-company posture.
The Cancel button renders only when the run is still cancellable
(status ∈ {pending, running}). Terminal rows (completed,
failed, cancelled) hide the form entirely so the operator isn't
tempted to click on an action that the handler would 409. A click
posts to POST /admin/subagents/{run_id}/cancel; the handler:
- Gates on instance-admin (same posture as the detail page).
- Calls a new
query.CancelHeartbeatRunByOperatorwrapper that routes through the existing v3.2CancelHeartbeatRun(used by the stale-run watcher), synthesising a reason string from the operator's user id + a UTC timestamp so the audit row carries the attribution. - Returns 303 on success (post-form-redirect → refresh-safe) →
/admin/subagents/{run_id}. The operator sees the row flip tocancelledwith the timing line populated. - Returns 409 when the row is no longer cancellable (either the row is gone or it terminated between page load and click) with a short plain-text body explaining the state. Avoids the "did it work? page still shows running" confusion a silent redirect would create on a stale page.
No DB schema change. The heartbeat_runs.status column is plain
TEXT with no CHECK constraint (verified via \d heartbeat_runs),
so the new cancelled value lands at the application layer. The
detail-page status badge gains a cancelled branch alongside the
existing pending / running / completed / failed cases.
Cancellation is an audit-row status flip — the underlying subagent worker may continue running until its next heartbeat. The confirm() dialog calls this out explicitly so an operator with a runaway run can plan accordingly (the v2.43.0.3 fan-out rate limit is the in-flight kill switch; cancellation is the audit record of the operator's decision).
Tests added:
- Five DB-level cases pinning the wrapper behaviour (TestCancelHeartbeatRunByOperator_*) — cancels pending, cancels running, refuses completed, refuses missing, errors on empty runID.
- Two handler-level cases — happy path (303 + status flip) and terminal-row conflict (409 + body marker).
- One template-visibility case — TestUISubagentRunDetail_
CancelButtonOnlyWhenCancellable confirms the form action
appears for
pendingrows and is absent after a status flip tocompleted.
Operator docs (audit-dashboard.md §/admin/subagents) note the
button + the audit-row-flip semantic.
v2.46.0.9 — 2026-05-29¶
Proposer rationale block on the GEPA proposal detail page renders
as markdown via the existing goldmark helper, wrapped in a
<details open> element so operators can collapse it after
reading. Empty rationale (legacy rows that pre-date the v2.46.0.4
guard) fall back to "No rationale provided".
The proposal show page already loaded proposal.Rationale (the
field populated by the auto-evaluate worker per migration 112) and
the template had a "Rationale" header — but the body rendered as
plain text. A rationale that included bullet lists or fenced code
(common shapes — the worker often surfaces failure rates as a
short bullet list and includes a representative transcript) lost
all structure on the page.
v2.46.0.9 routes the field through the {{markdown ...}} template
helper, the same goldmark-driven CommonMark+GFM renderer the chat
page uses. Lists, fenced code, links, and strikethrough all render
correctly without html.WithUnsafe() exposure (raw HTML and
javascript: URLs are stripped by goldmark's default safe path).
The header text changes from "Rationale" to "Proposer rationale"
to match the v2.46.0.5+ language ("the proposer says X"), and the
wrapper becomes a <details open> element so operators with long
rationale blocks can collapse the section after reading without
losing the page state on reload. No JavaScript required — native
HTML5 disclosure element.
Empty rationale renders as <em>No rationale provided</em> rather
than a blank card. The v2.46.0.4 InsertAgentPromptProposal guard
rejects empty rationale at write time, so this branch only fires
for legacy rows that landed before migration 112 added the
NOT-NULL constraint enforcement — but the fallback is the right
posture for any row that ever ends up with an empty value.
Tests added: a render-happy-path test (markdown rationale → <ul>
+ <code> in body), a legacy-row test (empty rationale → "No
rationale provided" fallback), and the existing forbidden-actor
gating tests still cover the auth posture unchanged.
v2.51.0.12 — 2026-05-29¶
Backup reconcile worker. v2.51.0.11 surfaced drift between the
on-disk backup directory and the backup_runs audit table as a
CLI report (staple-cli backup-list --orphans). v2.51.0.12 adds
the worker that auto-heals that drift on a periodic cadence so
operators don't have to run a CLI just to keep audit reality and
filesystem reality aligned.
The worker scans STAPLE_BACKUP_DIR every six hours by default
and reconciles in two directions:
-
Direction 1 — on-disk file with no matching audit row. Common cause: a backup written before v2.51.0.8 (when
backup_runsrows started landing) survived to today, or an operator copied a dump in from a sibling host. The worker inserts abackup_runsrow withkind='reconciled',outcome='success',encryptionsniffed from the file header (STAPLE_BACKUP_v1magic →enc:v2; anything else →plaintext), size fromos.Stat,error_message="(reconciled from on-disk orphan)",actor_user_id=NULL. The audit dashboard at/admin/audit?source=backup_runsrenders the new rows with areconciled (size=<bytes>)summary line so an operator can scan them at a glance. -
Direction 2 — audit row pointing at a missing file. Common cause: an operator's manual
rmin the backup directory, a failed-mid-write that left the row but not the file, or a sibling host's retention sweep run against a shared NFS mount. The worker stampspruned_at = now()and appends a reconciliation marker toerror_message((auto-reconciled: file missing at <timestamp>)). The dashboard renders the row with the samePRUNED <when>lifecycle marker the v2.51.0.9 retention worker uses — operators see one consistent badge regardless of why the file is gone.
Idempotency holds in both directions. A re-tick over the same
file is a no-op via a local_path lookup; a re-tick over the
same already-stamped row is a no-op via the pruned_at IS NULL
guard on the UPDATE.
Behaviour switches:
STAPLE_BACKUP_DIR(default/var/backups/staple) — shared with the retention + verify workers; one canonical location.STAPLE_BACKUP_RECONCILE_INTERVAL_HOURS(default6, clamped to[1, 168]) — below 1h the worker thrashes on hot filesystems; above a week the reconciliation stops being useful for routine drift.STAPLE_BACKUP_RECONCILE=disable— operator opt-out for instances that prefer manual control. Start logs a single Warn line and returns without registering a ticker.
Safety:
- Refuses to act when the directory is missing — mirrors the retention + verify workers' fresh-deploy guard.
- Top-level only; never recurses; only
.dump/.dump.encregular files are considered orphan candidates..sqland.sql.gzfiles (external tooling) are left alone. - Per-file errors are logged at Warn and the pass continues — a single permission-denied entry doesn't stall the worker.
- All writes go through
query.WithBypass(backup_runs is instance-wide; no RLS posture applies).
Migration 124 extends the backup_runs.kind CHECK constraint to
admit 'reconciled' alongside the existing 'create' and
'cron_verify' values. The constraint is dropped and re-added
in place under the same name so subsequent migrations can target
it by the established convention.
Worker registers as backup_reconcile in the worker tick table
so /metrics exposes
staple_worker_ticks_total{name="backup_reconcile"} and
staple_worker_errors_total{name="backup_reconcile"} for
alerting. A Prometheus rule of the shape
increase(staple_worker_errors_total{name="backup_reconcile"}[24h]) > 0
fires when reconciliation hit a real I/O or DB error in the last
day.
v2.42.0.7 — 2026-05-29¶
Three new local slash commands for the staple-cli chat REPL:
/reset, /system, and /history. Each is a small operator-UX
win — drop context drift without restarting, peek at the bearer
agent's prompt, scroll the recent transcript — without leaving
the chat session.
The v2.42.0.0+ chat REPL already supported four local commands
(/quit, /exit, /help, /clear), none of which hit the
server. v2.42.0.7 adds three more that DO hit the server but each
as a single one-shot request that doesn't consume a chat turn:
/reset— Opens a fresh chat session against the same target agent. Lets the operator drop context drift mid-debug without restarting the CLI or losing their liner prompt history. ReuseschatOpenSession. Prints✓ new session started (chat=<short-id>)after rotation./system— Prints the bearer agent'ssystem_promptvia a one-shotGET /api/agents/me. Useful for "why is this agent answering like that?" debugging mid-session. Truncates at 4000 characters with a(truncated, GET <url> for full)footer. NULL prompts render as(no system_prompt set)rather than a confused blank line. Markdown is rendered through the same v2.42.0.6 terminal renderer when stdout is a TTY./history [N]— Prints the last N messages from the current chat. Default N=20, override with/history 50, hard-capped at 200. Each entry renders as[<i>] role: <role>header plus markdown-rendered body. Bad arguments (non-integer, zero, negative) surface as inlineError:lines without crashing the loop.
All three commands are excluded from the persistent liner history
(same posture as /quit and /clear) so the operator's recall
list stays clean of debugging commands. /history N with any
argument is also excluded via prefix-match.
The chatLoopConfig grows two fields (companyID,
targetAgentID) needed by /reset's chatOpenSession call. The
older chatLoop(...) positional wrapper is unchanged — existing
unit tests that build chatLoopConfig without the new fields
continue to pass; /reset in those configs surfaces a friendly
Error: /reset: companyID or targetAgentID missing (REPL not
configured for v2.42.0.7 commands) rather than panicking.
The /help block is rebuilt as a multi-line chatHelpText
constant — the single source of truth shared between the
/help dispatcher and the staple-cli chat --help flag
documentation. The pre-existing TestChatLoop_LocalCommands
assertion was updated to look for the header + each command
name rather than the old single-line Commands: /quit, /exit...
form.
Migration delta: 0. No new server routes — /system reuses the
existing GET /api/agents/me endpoint, /history reuses
GET /api/chats/{id}/messages?limit=N (clamped server-side to
500, additionally clamped client-side to 200).
v2.46.0.8 — 2026-05-29¶
GEPA auto-promote now DMs every instance admin with a Slack or Telegram pairing. Operators who opted into auto-promote get a per- event nudge instead of having to tail journalctl or scrape the audit dashboard at their own cadence.
v2.46.0.2 added the gepa_auto_promote per-agent opt-in: when a
proposal is generated AND the agent has the flag set, the GEPA
evaluator accepts the proposal immediately with NULL actor (system
attribution). That closed the latency gap between "the LLM judges
the proposal strictly better" and "the prompt is live" — but it
closed the operator's observability loop too. The only signal that
auto-promote fired was a structured slog line on the server; the
operator who said "yes I trust GEPA to evolve this agent" never got
a per-event nudge.
v2.46.0.8 fixes that with a new GEPA notifier wired into the existing v2.37.0.0 pairing system:
- On every successful auto-promote, a single-line DM lands in
every instance admin's configured channel(s). Format:
GEPA auto-promoted prompt for agent <name> (proposal=<short-id>, model=<model>, version=<n>). Review: <link> - Recipients are users with
instance_role='admin'ANDis_active=true. Admins without a Slack or Telegram pairing are silently skipped (the zero-pairing state is normal on a fresh instance). - Errors per-recipient log at Warn and are swallowed. A DM
failure must NEVER undo an already-completed promotion — same
best-effort posture as
internal/channels/notify. - The opt-out env var
STAPLE_GEPA_NOTIFY_AUTO_PROMOTE=falseshort-circuits the dispatcher. Useful for an operator running GEPA on a quiet canary who doesn't want every micro-event in their DMs. Default is enabled (matches the v2.46.0.8 intent: if you turned on auto-promote, you want to know when it fires). Any non-bool value logs Warn and stays enabled.
The link in the DM body uses cfg.PublicBaseURL when set, falling
back to a relative /agents/<id>/proposals/<pid> path otherwise.
Operators with STAPLE_PUBLIC_URL configured get a one-click
review path; operators without it get a paste-able relative URL.
Implementation:
internal/worker/gepa_notifier.go:GEPANotifierstruct holding the optional Slack/Telegram senders and the public base URL. nil-safe ((*GEPANotifier)(nil).NotifyAutoPromote(...)is a no-op) so the GEPAEvaluator can hold a nil pointer when no channel is wired. Reuses thenotify.SlackDMSender/notify.TelegramDMSenderfunction- pointer types frominternal/channels/notify— one wiring path incmd/servercovers both notifiers.internal/worker/gepa_evaluator.go: GEPAEvaluator grows an optional*GEPANotifierfield plus aWithNotifier(...)builder method. The auto-promote code path callsw.notifier.NotifyAutoPromote(...)after the success log line — nil-safe so a worker constructed withoutWithNotifierkeeps the pre-v2.46.0.8 behaviour.cmd/server/main.go: constructs the notifier with the sameslackSender/telegramSenderalready used by the engine notifier, pluscfg.PublicBaseURL. Adds anauto_promote_ notifier=true/falsefield to the existing "gepa evaluator worker started" log line so operators can confirm the wiring at boot.
Migration delta: 0. RLS: none — users already lacks RLS (read-
only enumeration by an instance-wide worker is the same posture as
the engine notifier in internal/channels/notify).
v2.51.0.11 — 2026-05-29¶
staple-cli backup-list --orphans reconciles on-disk and S3
inventory against the backup_runs audit table. Operators get a
one-shot answer to "do my files and my audit rows agree?" without
hand-diffing ls output against a psql query.
v2.51.0.7 introduced backup-list — a pure filesystem and S3 walker
that surfaces what's in the backup pile. v2.51.0.8 added the
backup_runs audit table — one row per staple-cli backup invocation,
persisted in Postgres. The two surfaces are independent, and drift is
the operationally interesting case:
- A file on disk with no matching
backup_runsrow. Pre-v2.51.0.8 vintage, or an operator-managed file the CLI never wrote (manualpg_dump, a debugging artifact, an out-of-band cron). - A
backup_runsrow claiming a file or S3 object that's no longer there. Hand-deletion, disk swap, S3 lifecycle rule the retention worker doesn't know about.
--orphans flags both. The default behaviour (no flag) is byte-for-
byte unchanged. With the flag, the table grows a STATUS column with
three values:
tracked— file exists ANDbackup_runsrow points at it.orphan-on-disk— file exists with no matching audit row.orphan-in-db—backup_runsrow claims a path that's not there. Synthesized into the rendered table from the audit row'screated_at(as the AGE column) and recorded size / encryption so the row still has a sensible shape.
When --orphans is set AND any orphan is detected, the CLI exits 1
so cron + alert pipelines can pick it up:
0 8 * * * staple-cli backup-list --orphans >/dev/null || \
send-alert 'staple backup orphans detected'
When no orphans are detected, exit is 0 and the summary line reads
no orphans found. Combining --orphans --s3 also reconciles the
S3 surface; without --s3 the backup_runs.s3_* columns are skipped
(we can't tell "S3 object missing" apart from "we just didn't look at
S3").
The reconciliation considers the most recent 500 non-pruned
backup_runs rows by created_at DESC. Pruned rows (the retention
worker NULLs local_path when it deletes the file) are excluded so
they never produce false-positive orphan-in-db flags. Failure rows
are excluded because they never produced a file in the first place.
--orphans requires $DATABASE_URL (the audit table lives in
Postgres); without it the command fails fast with a clear error so a
recovery shell intentionally lacking DB access doesn't silently
report "no orphans".
The matching logic is a pure function over three input slices (on-disk, S3, audit rows) producing a tagged output slice — keeping it pure means the unit tests cover the full tracked / orphan-on-disk / orphan-in-db matrix without a DB, an S3 server, or a real filesystem walker beyond the temp-dir fixture.
v2.47.0.10 — 2026-05-29¶
Backup retention prune preview on /admin/operations. Operators
see at a glance "what does the next retention sweep delete?" without
waiting for the worker to fire or grepping the logs after the fact.
The v2.51.0.3 retention worker walks STAPLE_BACKUP_DIR once an hour
and removes encrypted dumps older than STAPLE_BACKUP_RETENTION_DAYS.
That policy works fine in steady state but obscures the lead-up: an
operator who just shipped a backup script change has no visibility
into whether the next sweep is about to nuke yesterday's dump or
skip the whole directory because the path doesn't exist.
v2.47.0.10 fixes that with a small preview tile rendered next to the
v2.47.0.8 backups widget. The tile reads the worker's configured
dir + retention via the new worker.ResolveBackupDirAndRetention
helper, then runs the new worker.PreviewSweep against them. The
preview is pure: walks the dir top-level only, applies the same
extension filter the worker uses (.dump, .dump.enc, .sql,
.sql.gz), and returns the count + cumulative bytes + oldest /
newest eligible ages — no deletes, no DB writes, no worker side
effects.
Tile variants:
<N> eligiblewithOldest/Newestages when files are past the cutoff. "Newest" is the next-to-survive — useful for confirming the retention window's actual boundary before tightening it.none eligible(green) when the dir exists and nothing matches. The next sweep is a no-op.disabled(gray) whenSTAPLE_BACKUP_RETENTION_DAYS=0. The worker is opted out and an operator cron is canonical.dir missing(red) when the configured dir doesn't exist on disk. Surfaces the silent misconfig that would otherwise let disk usage balloon while the worker no-ops every tick.
worker.PreviewSweep returns (PreviewResult{}, nil) for the
retention=0 + missing-dir cases so the UI can render the right
badge without conflating them with a real I/O error. Other errors
(permission denied at top level, ctx cancelled) bubble up; the
handler logs Warn + falls back to the "no preview" state so the
operations page never 500s on a transient FS hiccup.
worker.ResolveBackupDirAndRetention factors the env-var parsing
out of NewBackupRetention so the UI helper sees the same values
the worker constructor does — same defaults, same clamps. Bad
values fall back to the default silently on the UI path (the
worker constructor still logs Warn on startup) so a permanently
misconfigured value doesn't flood logs on every page hit.
Operations template (operations/index.html). The widget row
is split into a flexbox; the backups card keeps flex:2 and the
retention preview card takes flex:1. Border colors mirror the
backups widget's freshness scheme: green for "none eligible",
yellow for "files past cutoff", red for "dir missing" / "disabled".
Tests. internal/worker/backup_retention_test.go extends with
five PreviewSweep cases (empty dir, happy path with seeded mtimes,
missing dir → nil, retention=0 → nil, non-matching extension
filter) + three resolveBackupDirAndRetentionFromEnv cases
(defaults, disabled via env, custom values).
internal/handler/ui_operations_test.go extends with three render
paths (eligible files surface count + Oldest/Newest, disabled
renders env-var name, dir missing renders path) plus
TestHumanDuration_Coarse for the age formatter
(<1m, 45m, 3h 12m, 32d 4h).
Docs. docs/user/operators/backup-and-restore.md § 7 adds a
"Retention preview tile" subsection covering each badge variant
and pinning the "matches what the next tick will actually do"
contract — the preview uses the same extension filter as the
worker so the eligibility count is load-bearing.
v2.43.0.8 — 2026-05-29¶
Subagent per-run detail drill-down at /admin/subagents/{run_id}.
Operators click any run_id on the v2.43.0.5 dashboard and get the
full debug picture for that one heartbeat run on a single read-only
page — no more SSH-ing into the DB to inspect a stuck dispatch.
The dashboard's list projection (SubagentRunRow) is intentionally
thin: ten columns wide, 200 rows in a page, no heavy JSON. That's
the right shape for the index but useless when an operator wants to
know what a specific in-flight subagent is actually doing.
v2.43.0.8 adds a fat-row projection (SubagentRunDetail /
SubagentRunDetailRow) plus a handler and template that surface
the columns the list page skips: context_snapshot, result_json,
usage_json, summary, log_excerpt, error, model/provider/
token/cost figures, and the chat messages the child agent emitted
while the run was alive.
query.GetSubagentRunDetail loads the projection in three
round-trips inside one scope.WithBypass transaction: the fat
heartbeat_runs ⋈ agents (child) ⋈ agents (parent) row, the full
child Agent (for system_prompt and spawn_reason), the
optional parent Agent, and a windowed chat_messages query
scoped to [started_at, COALESCE(completed_at, now())+1s] filtered
by author_agent_id = child.id. The window is capped at 500
messages (detailMessagesCap) so a runaway subagent that emitted
thousands of status pings doesn't OOM the template. Missing
run_id returns (nil, nil) so the handler can map cleanly to
404 without conflating it with a query failure.
handler.UISubagentRunDetail is the GET handler at
/admin/subagents/{run_id}. Same gate as the index: instance-admin
only (actor.IsUser() && actor.IsInstanceAdmin()). The handler
pre-renders the three JSON columns through prettyJSONOrEmpty
(two-space indent) so the template can dump them into <pre>
blocks without re-running marshal per section. It also preserves
the operator's filter context across the drill-down: if Referer
points at /admin/subagents (any query string) the
"back to subagents" link returns to that filtered view rather
than the unfiltered top-of-feed.
Template (internal/web/templates/subagents/detail.html) is
split into six sections that the projection's structure mirrors:
status + timing, LLM telemetry (gated on at least one field set),
dispatched context snapshot, result digest, child + parent agent
prompts, and messages emitted during the run. Every section has
an explicit "(not yet populated)" / "(no parent)" / "(no
context_snapshot captured)" branch so blank states are
unambiguous. The messages table links per-row back to
/companies/{company_id}/chats/{chat_id} so operators can dig
further into a specific exchange.
Index page (templates/subagents/index.html) now wraps each
run_id cell in an anchor to the new detail page so the drill
path is discoverable from the dashboard without forcing operators
to remember the URL shape.
Tests (heartbeat_run_detail_test.go +
ui_subagent_detail_test.go). Query: missing-id (empty +
bogus UUID) → nil; happy path seeds company → parent → child →
heartbeat run → chat with a message inside the window, promotes
the run to "running" + populates the heavy columns via
UpdateHeartbeatRunResult, then asserts every projection field
including ChildAgent.SystemPrompt + the windowed message ends
up in MessagesDuringRun. Handler: permission gate matrix (nil,
non-admin user, agent actor → 403), 404 for missing path param
and bogus UUID, 200 happy path with the run_id + child agent
name rendered into the page, and Referer preservation
(?status=failed&depth=2 round-trips into the back-to-index
link).
v2.42.0.6 — 2026-05-29¶
staple-cli chat REPL now renders agent replies with ANSI markdown
on an interactive TTY. Operators stop seeing literal **bold**
asterisks, ### Header hashes, and triple-backtick fences in the
prose; the formatting auto-disables for pipes, redirects, scripts,
and any caller that sets NO_COLOR or passes --no-color.
The chat substrate stores agent replies as markdown and prints them verbatim. v2.42.0.5 wrapped that in a history-aware liner REPL but left the rendering raw. v2.42.0.6 adds a small, hand-rolled terminal markdown renderer that handles the constructs an LLM actually emits:
**bold**/__bold__→ ANSI bold (\033[1m).*italic*/_italic_→ ANSI italic (\033[3m).`inline code`→ ANSI dim (\033[90m), preformatted contents.- Fenced
```code blocks → dim horizontal rule above + below, content emitted verbatim (no inline parsing inside the fence). ### Headerlines → cyan "▸ " prefix + bold heading text.- list item/* list item→ "• item" with leading indentation preserved for nested lists.[link text](url)→ underlined link text + " (url)" trailer.- Paragraphs and plain text pass through unchanged.
We chose a hand-rolled converter over goldmark (already a
dependency for the web UI) because goldmark's renderer is HTML-
shaped and would require a custom ANSI-emitting node walker.
~400 lines of focused Go covers the constructs we care about and
ships with table-driven tests for each.
Streaming stays raw. SSE chunks arrive piecemeal and an
in-flight **bold** token could split across two chunks; mid-
stream colorization would leave dangling ANSI escapes. The
sync-fallback path (older deploys without
/api/chats/{chatId}/messages/stream) renders the full reply
after decode, so every operator sees consistent output for their
deploy era.
The renderer is policy-free. Rendering decisions live in
shouldColorize(io.Writer), which returns true only when:
- the writer is the real
os.Stdout; - that stdout is an interactive TTY (
term.IsTerminal); - neither
NO_COLORnorSTAPLE_CHAT_NO_COLORis set.
Test writers (bytes.Buffer, io.Discard) never satisfy that
predicate, so the existing cmd/staple-cli/chat_test.go cases that
assert on raw reply bytes continue to pass without modification.
New CLI flag --no-color. Forces raw markdown for the whole
session. Composes with the env variables: any of --no-color,
NO_COLOR, or STAPLE_CHAT_NO_COLOR disables rendering. We
implement the flag by setting STAPLE_CHAT_NO_COLOR=1 at session
start so the renderer machinery doesn't need to know about CLI
state.
Tests (cmd/staple-cli/chat_render_test.go). Table-driven
coverage for each markdown construct (17 cases including empty
input, malformed unclosed bold, snake_case false-positive guard,
trailing-newline preservation), plus
TestRenderMarkdownForTerminal_RespectsNoColorEnv (the renderer
is policy-free; env reading lives in shouldColorize) and
TestShouldColorize_NoStdout / TestShouldColorize_NoColorEnvOverrides
for the gate itself. All v2.42.0.5 chat tests continue to pass
unchanged via the non-TTY bufio fallback.
v2.51.0.10 — 2026-05-29¶
Backup verify cron worker: the newest encrypted backup is re-verified daily. Bit rot, retired DEKs, and silent corruption surface in the audit feed instead of waiting for the next restore drill.
v2.51.0.6 already verifies each backup end-to-end at write time;
that catches drift between pg_dump and the moment the file
lands on disk. The harder failure mode is rot in cold
storage: a disk that returns valid-looking bytes for a while,
then flips bits in an archive that hasn't been opened in months.
Or a KEK / DEK rotation that retired the file's wrapping key.
The inline verify can't see either.
v2.51.0.10 closes that gap with a background worker. Once per
day (configurable) it walks the configured backup directory
(top-level only), picks the newest .dump.enc file by mtime,
and streams it through Vault.NewBackupReader into io.Discard
— the same path staple-cli backup-verify uses. The outcome
lands in backup_runs as a new row with kind='cron_verify',
so the audit dashboard at /admin/audit?source=backup_runs
shows the continuous-verification stream alongside the
write-time create stream.
Migration 123_backup_runs_kind.sql. Adds a kind
discriminator with allowed values 'create' (existing v2.51.0.0
write-path rows) and 'cron_verify' (new worker rows). Existing
rows default to 'create' which matches their semantics before
this migration; the CHECK constraint pins the value space so a
typo in future code can't pollute the table. The (kind,
created_at DESC) index covers the dashboard's likely filter
pattern.
Query layer (internal/db/query/backup_runs.go).
- BackupRun.Kind string — new field. Existing call sites that
leave it empty get 'create' by default in RecordBackupRun.
- RecordBackupRun accepts an explicit b.Kind, validates it
against {create, cron_verify} client-side for a clearer
error than the DB CHECK constraint emits, and inserts.
- scanBackupRun + backupRunsColumns + the
ListAllRecentBackupRunsFiltered projection all pick up the
new column.
Worker (internal/worker/backup_verify_cron.go).
- BackupVerifyCron struct with the same pool / vault /
dir / interval / stats shape as the v2.51.0.3
BackupRetention worker.
- NewBackupVerifyCron(pool, vault) reads STAPLE_BACKUP_DIR
(default /var/backups/staple) and
STAPLE_BACKUP_VERIFY_INTERVAL_HOURS (default 24, clamped
[1, 720]). Disabled when vault == nil — server logged
that case at the v2.51.0.6 vault-init site already.
- Start(ctx) runs an immediate first tick (so a fresh deploy
doesn't wait a day for the first verification), then ticks
on the configured interval until ctx is cancelled.
- runOnce(ctx) returns a verifyResult for tests; the loop
routes telemetry through stats + the audit row.
- pickNewestEncryptedBackup filters to .dump.enc, skips
symlinks / subdirectories / unmatched extensions, picks by
mtime DESC. Mirrors the retention worker's safety posture.
- verifyFile streams through Vault.NewBackupReader into
io.Discard. Header-parse failures land as
verify_outcome='verification_failed'; mid-stream GCM auth
failures land the same way; clean EOF lands as
verify_outcome='verified'.
- copyWithCtx is io.Copy with ctx.Done() polling between
64 KiB reads so graceful shutdown mid-decrypt cuts within a
chunk instead of waiting for the whole file.
- recordVerifyRun writes one backup_runs row per tick.
Failures to record the audit row are logged at Warn but do
not re-thrash the worker — the on-disk file hasn't changed;
we'll get another shot tomorrow.
Server wiring (cmd/server/main.go). New
backupVerifyCron := worker.NewBackupVerifyCron(pool,
crypto.Default()) slot alongside backupRetention. The vault
is resolved from crypto.Default() so when
STAPLE_ENCRYPTION_KEY is unset the worker arrives nil-vaulted
and Start logs once before returning.
Audit dashboard (internal/handler/ui_audit.go). The
backup_runs row formatter now:
- Prefixes summaries with verify (cron_verify rows) vs
backup (create rows) so operators can tell the streams
apart at a glance.
- Surfaces the verify_outcome as the row's Action value
(verified / verification_failed) for cron_verify rows.
Create-row Actions stay on the raw outcome
(success / failure) per the existing v2.51.0.8 contract.
- The audit template (audit/index.html +
audit/actor.html) picks up new badge-active / badge-failed
cases for the two verify states.
Operator docs (docs/user/operators/backup-and-restore.md).
New section 13 "Continuous verification" — explains the
worker's posture, the two env vars, the disabled-on-no-key
branch, an example Prometheus alert rule on
staple_worker_errors_total{name="backup_verify"}, and the SQL
recipe for filtering backup_runs to the cron_verify stream.
Tests.
- internal/worker/backup_verify_cron_test.go — 14 new tests:
constructor defaults / env-driven overrides / clamping below
min / above max / invalid-falls-back-to-default /
nil-vault-disables; Start returns when disabled; runOnce
skips missing dir / no candidates / picks newest /
ignores non-encrypted extensions / records success row on
happy path / records failure row on corrupted-file path;
copyWithCtx respects pre-cancelled ctx + propagates
upstream read errors; strPtrIfNonEmpty boundary cases.
DB-backed tests use a snapshot-restore pattern (mirrors
crypto.freshVault) to keep the existing DEK rows intact
across runs.
- internal/db/query/backup_runs_test.go — 4 new tests:
RecordBackupRun defaults Kind to create; honours an
explicit cron_verify; rejects an unknown Kind with a clear
error; ListAllRecentBackupRuns surfaces both Kinds in one
read.
Migration counter: 122 → 123.
v2.47.0.9 — 2026-05-29¶
staple-cli flag — operator CLI for the v2.47.0.2 instance_flags
runtime overrides. Runbooks + CI scripts now flip flags from a
shell without session-cookie tooling.
The v2.47.0.7 JSON API at /api/admin/flags{,/{name}} lets
HTTP-cookie-bearing operators script flag toggles, but a release
runbook that already has DATABASE_URL in scope (for vault-status,
rotate-dek, backup) shouldn't have to acquire a session cookie
just to flip one flag. v2.47.0.9 adds a DB-direct CLI alternative
that uses the same posture as vault-status, unlock-user, and
secret — connects on the operator's DATABASE_URL, no HTTP round-
trip required.
Subcommands.
- staple-cli flag list — aligned table (tabwriter) of every
override row: NAME / VALUE / DESCRIPTION / UPDATED_AT /
UPDATED_BY. Empty DB renders the friendly
"(no overrides set — every flag falling back to env / default)"
line so a fresh install doesn't look broken.
- staple-cli flag get <name> [--strict] — single-row print with
description / updated_at / updated_by. On miss, exits 0 by
default with "no override set" copy; --strict exits 3 instead
so a shell script can branch on presence without parsing the
human-readable summary.
- staple-cli flag set [--description=...] <name> <value> — upsert
the override row. Empty value is REJECTED (the UI handler uses
empty-as-clear; the CLI requires the explicit clear verb so a
typo can't accidentally delete a setting). Prints the new value
+ prior value for confirmation.
- staple-cli flag clear <name> — delete the override row.
Idempotent: clearing an already-absent flag is a no-op that
still records the operator's intent in the audit trail. Prints
"cleared
Audit attribution. Every set / clear writes a row to
instance_flag_events with actor_user_id = NULL via the same
query.RecordInstanceFlagEvent the v2.47.0.4 UI handler uses. NULL
maps to the literal "system" actor in query.ResolveActorEmail and
the consolidated /admin/audit dashboard renders it that way —
matching the SecretReveal / SecretRotate / pair-code convention for
CLI-originated audit rows. Old → new values track correctly across
set → set (update) → clear sequences. A future v2.47.0.10+ could
read $USER and resolve it to a users.id for richer attribution;
that's a separate change and out of scope here.
Implementation (cmd/staple-cli/flag.go).
- runFlag(ctx, args) is the dispatcher entry. Argument-shape
errors return wrapped errors so the main.go dispatcher prints
flag: <message> to stderr and exits 1.
- Per-verb handlers (runFlagList, runFlagGet, runFlagSet,
runFlagClear) own their own positional-count checks so the
operator gets verb-specific usage hints. Go's flag.Parse stops
at the first non-flag arg, so --description=... must precede
the positional <name> <value> pair on set — documented in
the usage block and the operator docs.
- flagPoolOpen shares the DATABASE_URL-required check with a
friendly error envelope across every verb.
- truncateForTable shortens long descriptions for the list
table layout; stringPtrOr dereferences nullable columns
(UpdatedByUserID) with a (none) fallback.
Wiring (cmd/staple-cli/main.go). New case "flag" slot in the
dispatcher alongside the other v2.36/v2.42/v2.51 subcommands;
multi-line usage entry under Subcommands: documenting the four
verbs + the strict-exit-code semantic; three Examples: lines.
Operator docs (docs/user/operators/audit-dashboard.md). The
Related dashboards block now mentions the v2.47.0.9 CLI alongside
the v2.47.0.7 JSON API entry. A new "Flag CLI" section walks
through every verb, documents the audit attribution, and shows the
canonical release-window wrap pattern:
DATABASE_URL=$DATABASE_URL staple-cli flag set \
--description="release v2.X" DEBUG_VERBOSE_LOGGING true
deploy.sh "$@"
DATABASE_URL=$DATABASE_URL staple-cli flag clear DEBUG_VERBOSE_LOGGING
Tests (cmd/staple-cli/flag_test.go). Sixteen new test cases
across four functions:
- TestRunFlag_ArgShape — 14 sub-cases pinning the dispatcher's
verb routing, the positional-count gates per verb (empty,
excess), the empty-name / empty-value rules, the set empty-
value rejection ("use flag clear"), and the precedence
between arg validation and the DATABASE_URL check.
- TestRunFlag_HelpDoesNotError — help / -h / --help all
return nil so the main.go dispatcher prints help and exits 0.
- TestRunFlag_Helpers — truncateForTable and stringPtrOr
boundary cases (empty, exact-length, longer-than-max, max=1,
nil pointer).
- TestRunFlag_RoundTrip — DB-integration. list → set → get →
set (update) → clear → get-after-clear. Skips when the dev DB
isn't reachable. Asserts the underlying instance_flags row
state after each step.
- TestRunFlag_AuditFanOutMatchesHandler — DB-integration. A
set → set (update) → clear sequence writes exactly the three
instance_flag_events rows the v2.47.0.4 UI handler produces:
action='set'/'clear', actor_user_id=NULL, old_value
tracks the prior state correctly across all three steps.
No migration delta. The CLI inherits the existing scope.WithBypass
posture from the underlying query.UpsertInstanceFlag /
query.DeleteInstanceFlag / query.RecordInstanceFlagEvent
helpers.
v2.50.0.6 — 2026-05-29¶
Per-actor audit drill-down: clicking any actor email on
/admin/audit lands on a focused per-actor footprint page —
stats + per-source breakdown + chronological timeline.
Operators investigating "what is alice@example.com doing across
every tenant on this instance?" used to set ?actor=... on the
main dashboard, which narrowed the timeline but dropped the
source-mix summary. v2.50.0.6 adds a dedicated drill-down at
/admin/audit/actor/{email} that materialises both views on one
page: total events / first-seen / last-seen / most-active source
in a stats header, an inline ASCII bar chart across all eight
surfaced source tables, and a timeline table identical in shape
to the main dashboard's body but with the source column linking
back to /admin/audit?source=X for focused per-source pivoting.
Endpoints.
- GET /admin/audit/actor/{email} — instance-admin-only per-actor
timeline + stats. Unknown / typo'd emails render 200 with the
empty-state body so an operator pasting from a chat link
doesn't hit a wall.
- GET /admin/audit/actors — instance-admin-only directory
listing the top-20 most-active actors over the trailing 30
days. Click-through to the timeline drill-down per row.
Query helpers (internal/db/query/audit_aggregate.go).
- GetActorAuditStats(ctx, pool, email) (ActorAuditStats, error)
fans out 8 parallel COUNT/MIN/MAX queries — one per surfaced
source table — and aggregates the result. Errors per-source
log via slog but do NOT bubble up; a slow / broken table
contributes a zero entry to BySource so the bar chart still
shows every source. UUID/TEXT mismatch on the legacy
secret_audit_events.user_id / plugin_approval_events.user_id
columns is bridged with an explicit ::uuid cast on the
audit-side join — Postgres 12+ no longer auto-bridges
text↔uuid in = comparisons.
- ListTopActiveActorsRecent(ctx, pool, window, limit) — UNION
ALL across every per-actor surface grouped by lowered email,
ranked by total events DESC. Window clamps to [1, 365] days;
limit clamps to [1, 100]. System-attributed tables (egress,
a2a, NULL-actor backup_runs) contribute zero rows by
construction.
Handler (internal/handler/ui_audit_actor.go).
- UIAuditActorTimeline — instance-admin gate fires before any
DB / chi access; trims the percent-decoded {email} path
parameter; loads the timeline via the same
collectAuditFeed helper the main dashboard uses
(filter.Actor pushes down into per-table SQL via
v2.50.0.3); fetches stats via the new
GetActorAuditStats; pre-renders the per-source bar chart
in Go so the template stays branch-light.
- UIAuditActorsIndex — instance-admin gate; calls the new
ListTopActiveActorsRecent with auditActorWindowDays = 30
and auditActorTopN = 20.
- buildSourceBars rounds up so a single event renders at least
one cell, scales the widest count to 40 chars, and returns
every surfaced source in canonical display order so silent
sources still get an explicit (zero-width) row.
- pickMostActiveSource resolves ties using canonical order so
re-renders of the same stats always pick the same answer.
Templates.
- internal/web/templates/audit/actor.html — header + stats
block + ASCII bar chart + timeline table. Source cells link
back to /admin/audit?source=X; the "most-active source"
stat links to the dashboard pre-filtered for both actor +
source.
- internal/web/templates/audit/actors.html — top-N table
with "timeline" + "on dashboard" pivot links per row, and a
friendly empty state explaining the 30-day window when the
instance is freshly stood up.
- internal/web/templates/audit/index.html — actor cells in
the main dashboard's timeline now link to the drill-down
(skipped for system and (deleted) actors). A new "By
actor →" button in the page header next to Download CSV.
Tests (internal/handler/ui_audit_actor_test.go). Nine new
test functions:
- permissions: nil / non-admin / api-key actor → 403 for both
handlers (auth fires before any DB access; nil pool safe).
- buildSourceBars canonical order + scale + width clamps,
rounds up small counts, and never divides by zero on
all-zero stats.
- pickMostActiveSource empty / populated / tie cases.
- TestUIAuditActorTimeline_EmptyState_UnknownEmail confirms a
never-seen email renders 200 with the empty-state body
instead of 404 — the "paste from chat link" workflow stays
unblocked.
- TestUIAuditActorsIndex_RendersEmptyOrPopulated confirms
the index page renders 200 against the dev DB regardless of
seed-data shape.
- TestUIAuditActorTimeline_URLEncodedEmail_RoundTrips pins
that %2B (+ in a localpart) arrives intact at the
handler and renders verbatim in the body. html/template
escapes + to + in HTML text context; the test
asserts on the rendered form so a double-encode regression
still fails.
- TestAuditActorWindowConstants pins the 30-day window /
top-20 cap so a drift in the operator-visible digest
semantics fails at build time.
No migration delta (R1). Cross-tenant reads stay on
scope.WithBypass per the existing audit-aggregate posture.
v2.46.0.7 — 2026-05-29¶
GEPA proposal diffs are downloadable as standard unified-diff
.patch files — offline review and sharing without copy-paste.
Operators reviewing a GEPA proposal often need to share the diff with
a colleague who isn't logged into Staple, or save it for an offline
code review during a flight. Until v2.46.0.7 they had to either
screenshot the on-page view or copy-paste the proposed template into
a side-by-side text editor. v2.46.0.7 adds a "Download .patch" link
next to the existing view-mode toggle that emits a real unified-diff
file — text/x-patch with a Content-Disposition: attachment
header, ready to feed to git apply --check, drop into a code
review tool, or just attach to an email.
Endpoint. GET /agents/{agentId}/proposals/{proposalId}/diff.patch
mirrors the UIAgentProposalShow auth + 404 posture exactly:
instance-admin-only; agent / proposal not found → 404; cross-tenant
or cross-agent proposal hidden behind 404 (don't leak existence).
Returns text/x-patch; charset=utf-8 with
X-Content-Type-Options: nosniff so a browser sniffer can't reclassify
the body. Filename: staple-proposal-<short>.patch where <short> is
the first 8 chars of the proposal id — readable in a Downloads
folder full of patches without exposing the full UUID.
Renderer (internal/handler/diff.go). New helper:
renderUnifiedPatch(current, proposed, currentLabel, proposedLabel string) string
The function walks the Myers []DiffLine output of computePromptDiff
(the same algorithm the on-page renderer consumes — no algorithmic
divergence between the page and the file) and groups adjacent
non-Keep operations into hunks with up to 3 lines of context above
and below — matching the GNU diff -u default. Adjacent hunks
separated by ≤ 2×context Keep lines are merged so the patch behaves
exactly like diff -u against the same files. The hunk-header math
(@@ -<oldStart>,<oldCount> +<newStart>,<newCount> @@) is 1-indexed
per the unified-diff spec so POSIX patch tools accept the output
verbatim.
Pure-add and pure-remove hunks anchor the zero-count side at
<start>-1,0 per the spec. The empty-diff edge (identical inputs
after trailing-whitespace normalisation) returns an empty string;
the handler renders just the two header lines so the downloaded
file matches what diff -u identical identical produces.
Handler (internal/handler/ui_agent_proposal_show.go). New
UIAgentProposalDiffPatch registered at the new route. Helpers:
shortProposalIDForFilename(id string) string— first 8 chars or the whole id; used in both theContent-Dispositionfilename and the proposal-side label inside the file headers.agentVersionLabel(a *Agent) string— current prompt version identifier embedded in the patch header. Today the agent model doesn't carry a numeric prompt_version column, so the helper falls back to the agent'supdated_attimestamp (UTC, RFC3339). A future schema bump that adds a realprompt_versioncolumn has one place to update.
Template (internal/web/templates/agents/proposal_show.html). The
"Download .patch" link sits in the same paragraph as the existing
"View: unified | side-by-side" toggle, with a 12-char description
line so an operator discovers the affordance without reading docs.
The download attribute is set on the <a> so well-behaved browsers
save the file straight to disk rather than attempting in-tab render
(belt-and-suspenders alongside the Content-Disposition header on
the response).
Tests (internal/handler/diff_test.go). Seven new test functions:
TestRenderUnifiedPatch_NoChanges— identical inputs → empty string (the handler handles header injection).TestRenderUnifiedPatch_PureAdd— header lines + hunk with leading context ++-prefixed insertion lines.TestRenderUnifiedPatch_PureRemove— symmetric;--prefixed removal lines with surrounding context.TestRenderUnifiedPatch_Mixed— two edits at far-apart positions produce two distinct hunks, not one fused hunk.TestRenderUnifiedPatch_MergedHunks— two edits within 2×context of each other merge into one hunk with the intervening keeps preserved as interior context.TestRenderUnifiedPatch_Headers— exact---and+++formatting with the supplied label text.TestRenderUnifiedPatch_HunkLineCounts— the@@ -O,N +O,N @@counts match the body of the hunk; this is the load-bearing correctness check becausepatchtools parse it to apply edits.
No migration. Endpoint is read-only and operates entirely on existing data. Migration counter remains at 122.
Route (cmd/server/routes.go). New line registers the patch
endpoint inside the same auth block as UIAgentProposalShow (the
existing instance-admin gate continues to apply through middleware).
v2.43.0.7 — 2026-05-29¶
Subagent dashboard filters push down to Postgres — "yesterday's failed dispatches" no longer get silently truncated by busy traffic.
v2.43.0.6 added date-range, depth, and parent filters to the
/admin/subagents page, but the implementation ran every predicate
in-memory against a slice already capped at 200 rows. On a busy
instance — thousands of subagent runs in a day — the filter the
operator wanted ("show me yesterday's failed dispatches") would miss
data because more recent activity pushed the matching rows past the
cap before the WHERE clause ever ran. The dashboard was telling the
operator "no matches" when there were actually matches the LIMIT had
already discarded.
v2.43.0.7 fixes the ordering by pushing every filter knob into the
SQL layer via ListAllRecentSubagentRunsFiltered. Postgres applies
the predicates BEFORE the LIMIT, so the cap means "the most recent
200 rows that match" instead of "the most recent 200 rows, of which
some might match".
Query layer (internal/db/query/heartbeat_runs.go). Introduces
SubagentRunFilter mirroring the v2.50.0.3 AuditFeedFilter shape:
Company string— substring match onheartbeat_runs.company_id. UsesPOSITION($n IN hr.company_id) > 0so operator input never needs%/_escaping (a_in a company_id used to be a silent wildcard in the in-memory pass — fixed here by design).Status string— exact match onhr.status. URL aliases ("in-flight" → "running") are resolved by the handler intoQueryFilterBEFORE the SQL helper sees them; the query takes the raw enum.Depth *int— exact match onagents.subagent_depth(the child side of the self-join). nil = no filter.Parent string— exact match onagents.parent_agent_id. Empty = no filter.From, To *time.Time— half-open[From, To)range onCOALESCE(hr.started_at, hr.created_at). The COALESCE matchesrowTimeKeyin the handler so pending-but-not-yet-started rows still fall inside a "today" window.
ListAllRecentSubagentRunsFiltered builds the WHERE incrementally
the same way audit_aggregate.go does (one fragment per active
field, args slice grown alongside), then runs the same projection
the un-filtered helper does — same self-join, same ORDER BY, same
EXTRACT(EPOCH FROM ...) duration shape. An empty SubagentRunFilter
short-circuits straight to ListAllRecentSubagentRuns so the no-
filter dashboard access pattern stays on the existing planner cache.
Handler layer (internal/handler/ui_subagents.go). toQueryFilter
translates the UI-facing subagentFilters to the DB-facing
query.SubagentRunFilter — applies the status alias map and the
historical strings.TrimSpace on company/parent strings.
UISubagentsIndex swaps the call site from ListAllRecentSubagentRuns
to ListAllRecentSubagentRunsFiltered with the translated filter.
The existing in-memory applySubagentRowFilters stays as a
defensive second pass: when the SQL is correct it returns the input
unchanged, but if the two ever drift the dashboard still respects the
operator's filter request.
Dashboard semantics. The "filtered: X of Y" indicator carries
the same shape — Y is what Postgres returned (already filtered),
X is what the defensive pass produced (typically equal to Y).
The .Filtered flag continues to drive display via
filters.active(), so an operator who set filters always sees the
indicator even when the result is empty (which is the case the bug
fixed: empty result means "no matches in the whole window", not
"no matches in the recent 200").
Tests. Four new query-level tests + two new handler-level tests:
query/heartbeat_runs_test.go—TestListAllRecentSubagentRunsFiltered_EmptyFilterDelegatespins the short-circuit;_StatusPushdownconfirms a seeded pending row appears instatus=pendingand not instatus=completed;_DepthAndParentPushdownconfirms the depth + parent UUID filters are applied at the DB;_DateRangePushdownconfirms the[From, To)half-open window excludes future-from and past-to while including a "now-1h to tomorrow" window.handler/ui_subagents_test.go—TestToQueryFiltertable-drives the eight translation cases (empty, company trim, in-flight alias, raw running, unknown→empty, depth pass-through, parent trim, from/to pass-through).TestUISubagentsIndex_FilteredPushdownend-to-end-smokes the handler with?status=failed&from=2099-01-01and asserts a 200 response with the expected layout.
No migration. Reuses the existing heartbeat_runs and agents
tables and their indexes unchanged. The (hr.started_at DESC NULLS
LAST, hr.created_at DESC) ORDER BY still serves the LIMIT
efficiently; adding equality predicates on hr.status, hr.company_id,
child.subagent_depth, and child.parent_agent_id falls back to
heap scans bounded by the LIMIT after the index walks — the
operator-perceived latency on the dashboard is unchanged.
Migration counter remains at 122.
Pre-existing test note. TestSpawnSubagent_DB_DepthLimit was
failing before this change and remains out of scope for v2.43.0.7;
filed but not blocking this release.
v2.47.0.8 — 2026-05-29¶
Backup lifecycle widget on the Operations panel — operators land on
/admin/operations already knowing whether backups are healthy.
v2.51.0.8 added the backup_runs audit table; v2.51.0.9 wired the
retention worker into it so pruned files stay visible in the audit
dashboard. The two changes together give the instance a durable,
queryable record of every backup, but until now there was no glanceable
"is the instance healthy?" surface — an operator had to navigate to
/admin/audit?source=backup_runs, scan rows, and reason about what
they meant. v2.47.0.8 closes that gap by rendering a small "Backups"
card at the top of the Operations panel.
Widget contract. The card has a single header line ("Backups") with
a freshness badge — fresh (last successful backup within 24h),
aging (within 7 days), stale (older than 7 days), or never run
(no successful backup row exists for this instance). The left border
of the card is color-tagged against the same freshness class — green /
yellow / red / red respectively — so the operator gets a peripheral-
vision signal without reading the badge text.
Below the header sits a two-column body:
- Last success —
<relative-time> • <enc-flag> • <size> • <duration>. Empty state (no success ever recorded) renders an em-dash so the pane never crashes on a nil pointer. Size pulls from S3 when an upload happened, else from the local file size — mirrors the audit-dashboard policy. - Last failure (conditional) —
<relative-time> • <truncated-error>. The pane renders ONLY when the most recent failure post-dates the most recent success. A failure from before a successful run is stale (the success recovered from it) and rendering it would mislead the operator into thinking the instance is currently broken. The error message is truncated to 80 runes; the operator can click through to the audit dashboard for the full string.
A trailing action row carries two deep-links: "View all backup runs →"
to /admin/audit?source=backup_runs (the existing backup_runs-
filtered audit view) and "Metrics →" to /metrics (where the
v2.51.0.8 staple_backup_last_success_timestamp_seconds +
staple_backup_last_size_bytes gauges live).
Query helper (internal/db/query/backup_runs.go). Adds
GetLastFailedBackupRun(ctx, pool) (*BackupRun, error) — the natural
mirror of GetLastSuccessfulBackupRun. Backed by the same (outcome,
created_at DESC) index so the lookup stays O(log n) even on instances
with millions of rows. Returns (nil, nil) when no failure has ever
been recorded; that's the operationally important degenerate case
(healthy instance) and the handler treats it as "hide the pane".
Handler logic (internal/handler/ui_operations.go). Three pure
functions land in this file alongside the existing flag-resolution
helpers:
classifyBackupFreshness(last *BackupRun, now time.Time) string— folds the four freshness classes ("fresh" / "warn" / "stale" / "missing") out of the time-since-last-success. Pure; tested table-driven.truncateForWidget(s string, n int) string— rune-safe truncation with a trailing "…" when the cut happens. Multibyte-safe so error messages with non-ASCII text don't get cleaved mid-codepoint.pickBackupDisplaySize(b *BackupRun) int64— prefer S3 size when an upload happened, else local. Mirrors the audit dashboard's inline summary formatter so the two surfaces stay visually consistent.
These compose into resolveBackupWidget(ctx, pool, now) which the
UIOperationsShow handler calls once per render. DB errors are logged
and treated as "no data" — the widget is informational, not load-
bearing for the rest of the page.
Template (internal/web/templates/operations/index.html). The
widget sits between the page header and the "Server" section.
Conditional rendering uses {{with .BackupWidget}} so the markup elides
cleanly if the data load itself failed catastrophically. Border-color
is driven by a {{$borderColor :=}} chain off .StaleClass, falling
back to var(--text-muted) when an unknown class slips through.
Tests. Six new test functions across two files:
query/backup_runs_test.go—TestGetLastFailedBackupRun_IgnoresSuccessesasserts the WHERE clause skips past success rows the same wayGetLastSuccessfulBackupRundoes, in reverse.handler/ui_operations_test.go—TestClassifyBackupFreshnesstable-drives the four-class boundary policy (nil, 1h/23h/25h/6d/8d/ 30d ago).TestTruncateForWidgetpins rune-safe truncation. Three end-to-end handler tests hit the live DB withtestPool:_BackupWidget_EmptyDB(graceful empty state),_BackupWidget_SuccessRendered(size + duration + encryption + freshness badge all appear),_BackupWidget_RecentFailureRendered(both panes render),_BackupWidget_StaleFailureHidden(failure-before-success suppresses the failure pane and its error message).
No migration. Reuses the v2.51.0.8 backup_runs table + indexes
unchanged. Migration counter remains at 122.
Operator note. The widget is read-only — there's no "trigger a
backup" button. Backups remain a CLI/cron-driven concern (staple-cli
backup); the panel is the place to see what's happening, not to
make it happen. v2.47.0.8 keeps that separation clean so the panel
stays an observability surface and the CLI stays the operator's
source of truth.
v2.51.0.9 — 2026-05-29¶
Backup retention worker integrates with the backup_runs audit
table — pruned backups stay visible in the dashboard.
v2.51.0.3 shipped the retention worker that sweeps aged-out local
backup files; v2.51.0.8 added the backup_runs audit table. The two
have been mutually unaware until now — after a prune, the audit
dashboard at /admin/audit?source=backup_runs would still show the
row pointing at a local_path that no longer existed, and an
operator had no way to tell at a glance whether a backup was on disk
or had been pruned.
v2.51.0.9 closes the loop. When the retention worker deletes a file
it now calls query.MarkBackupRunPruned with the absolute path,
which stamps pruned_at = now() and NULLs local_path on the
matching backup_runs row. The dashboard renders pruned rows with a
— PRUNED <when> marker so the lifecycle is visible at a glance
even after the file is gone.
Migration 122. ALTER TABLE backup_runs ADD COLUMN pruned_at
TIMESTAMPTZ. Partial index idx_backup_runs_pruned_at on
pruned_at filtered by pruned_at IS NOT NULL so it only carries
the rows the worker has actually touched. No data backfill — every
existing row gets pruned_at = NULL which correctly represents "on
disk (or never tracked by this instance)".
Query helpers (internal/db/query/backup_runs.go). New helper:
MarkBackupRunPruned(ctx, pool, path) (markedCount int, err)— UPDATE backup_runs SET pruned_at = now(), local_path = NULL WHERE local_path = $1 AND pruned_at IS NULL.WithBypass(instance-wide table, no RLS). Idempotent: thepruned_at IS NULLguard collapses repeated calls to 0-affected, no error. Refuses a blank path with an explicit error so a bug at the call site can't accidentally nuke pruned_at on every row.
The existing BackupRun struct gains PrunedAt *time.Time; the
canonical backupRunsColumns projection appends pruned_at at the
end so positional args in other helpers don't have to renumber.
Worker hook (internal/worker/backup_retention.go). The sweep
loop calls query.MarkBackupRunPruned immediately after a
successful os.Remove. Failure-mode policy:
- 0 rows affected → no audit row exists for that file (likely a
pre-v2.51.0.8 backup). Logged at Debug; counted toward the
sweep's
audit_markedtally as 0. - DB error → logged at Warn but does NOT abort the sweep. The file is already gone; refusing to continue would leave other aged-out files unswept.
The per-tick TickStats label and the "sweep complete" log line both
carry the new audit_marked=N count so an operator can see at a
glance how much of the prune activity corresponds to known audit
rows.
Audit dashboard rendering (internal/handler/ui_audit.go). The
backup_runs row formatter appends — PRUNED <YYYY-MM-DD HH:MM:SSZ>
to the summary line when PrunedAt != nil. The filter helper
doesn't change — pruned_at is purely additive metadata; the
existing actor / date / company filters compose unchanged.
Files touched.
- internal/db/migrations/122_backup_runs_pruned_at.sql (new)
- internal/db/query/backup_runs.go (struct field, projection,
new helper)
- internal/db/query/backup_runs_test.go (4 new test fns:
happy path, idempotency, unknown-path, blank-path guard)
- internal/worker/backup_retention.go (audit-mark call,
enriched stats line, doc-comment expansion)
- internal/worker/backup_retention_test.go (2 new test fns:
audit-mark happy path, orphan-path graceful continue)
- internal/handler/ui_audit.go (summary-line marker)
- docs/user/operators/backup-and-restore.md (audit-integration
subsection under the existing retention section)
No new env vars. No CLI changes — the retention work is entirely server-side. The migration counter is now 122.
v2.43.0.6 — 2026-05-29¶
Subagent dashboard filter refinements — date range, depth, parent-agent quick-filter, and saved views.
v2.43.0.5 shipped /admin/subagents with company-id substring and
status filters. Operators triaging deep recursive subtrees have
since asked for the rest of what the audit dashboard offers:
- A date range. "Show me what ran yesterday" is faster than scrolling 200 rows looking for the boundary.
- A depth filter. After v2.43.0.4 deep dispatch landed, a single root can spawn a tree across multiple depths; isolating "everything at depth 2" makes pattern-spotting tractable.
- A parent-agent quick-filter. When investigating a specific run's fan-out, "every subagent of agent X" is the first question.
- Saved views — same UX as the v2.50.0.5 audit dashboard, just keyed off a different localStorage namespace so the two pages don't collide.
Query-string surface.
?company=<substring> existing (v2.43.0.5)
?status=<alias> existing (v2.43.0.5)
?from=<YYYY-MM-DD> new (v2.43.0.6)
?to=<YYYY-MM-DD> new (v2.43.0.6)
?depth=<int> new (v2.43.0.6)
?parent=<uuid> new (v2.43.0.6)
All knobs compose with AND. Empty or malformed values degrade to "no
filter" rather than 400 — same posture as
parseAuditDateBound in ui_audit.go. Date-range filtering uses
started_at when set and falls back to enqueued_at for pending
runs so a "today" filter catches freshly-enqueued-but-not-yet-started
work too. Half-open interval: rows AT exactly to=<X> are excluded.
Click-to-drill on parent column. Each parent-agent name in the
table is now a link to /admin/subagents?parent=<uuid>, so an
operator investigating one branch can jump to the rest of that
branch's subtree without copy-pasting a UUID.
Active-filter pills + clear-this-filter buttons. When any filter is set, the table renders a row of pills with the active value and a × link that clears only that filter, preserving the others. The existing "Clear filters" link still wipes everything at once.
Saved views (localStorage). Operator-private bookmark layer
under staple_subagent_views. Save the current filter combo by name,
load it from a dropdown to navigate straight back to that view.
Same template, same JS, just a different namespace from the audit
dashboard (so views don't collide). Browsers without localStorage
(private mode, extension blocking) get the section hidden so the
control surface doesn't lie about working.
Where the filters run. Still in memory after the DB pull —
subagentDashboardLimit is a hard 200 and DB pushdown would add SQL
complexity for no measurable win at this scale. If the cap grows
past a thousand later (v2.43.0.7+), revisit and move the WHERE
clauses into ListAllRecentSubagentRunsFiltered, mirroring the
v2.50.0.3 shape on the audit feed.
Files touched. internal/handler/ui_subagents.go (extended
filter struct + handler wiring), internal/handler/ui_subagents_test.go
(extended matrix), internal/web/templates/subagents/index.html
(new inputs, active-filter pills, saved-views block),
docs/user/operators/audit-dashboard.md (related-dashboards
pointer).
No migration. No new env vars.
v2.47.0.7 — 2026-05-29¶
Instance flag JSON API for scripted operators.
The HTMX-driven Operations panel at /admin/operations has been the
only way to flip instance flags since v2.47.0.2. Operators driving
smoke tests, CI scripts, or staple-cli automations have had to
round-trip a session cookie + form encoding to toggle a flag, which
is workable but awkward — particularly for pipelines that want to
flip STAPLE_GEPA_EVAL_ENABLED for a single test run and then revert.
v2.47.0.7 adds a JSON API mirror at /api/admin/flags{,/{name}}:
GET /api/admin/flags (instance admin)
GET /api/admin/flags/{name} (instance admin)
PUT /api/admin/flags/{name} (instance admin)
DELETE /api/admin/flags/{name} (instance admin)
Side effects are identical to the UI path. Both routes call
query.UpsertInstanceFlag / query.DeleteInstanceFlag, and both
emit the same query.RecordInstanceFlagEvent rows that the v2.47.0.4
audit fan-out introduced. A flag flipped via curl is
indistinguishable from one toggled via the panel when viewed in
/admin/audit?source=instance_flag_events.
Auth. Every endpoint sits inside a RequireInstanceAdmin group
in cmd/server/routes.go. The middleware returns the standard
JSON-shape {"error":"..."} payload with 401 (not authenticated) or
403 (authenticated but non-admin). The handlers themselves don't
re-check — the middleware is canonical, and a defensive duplicate
check would just be dead weight that drifts.
Allowlist. Same set as the UI path: toggleableFlagNames(),
which is the subset of operationalFlagDefs() flagged as runtime-
mutable. Read-only flags (boot-only env vars like
STAPLE_EGRESS_DNS_ADDR) return 400 — writing rows the runtime
won't consult would create silent dead weight.
Empty vs absent. PUT with value="" returns 400 to keep the
JSON API verbs distinct ("clear" is DELETE, not PUT with empty
value). DELETE of a flag with no override returns 404, mirroring
how rm -f reports missing files but doesn't fail.
Response shape. GET / PUT return apiInstanceFlagRow — name,
value, updated_at (RFC3339 with sub-second), updated_by_user_id
(null when the user account was later deleted), description. List
wraps the rows in {"flags":[...]} and renders an empty list as
[], not null, so jq / JS consumers can iterate without a guard.
Files touched. internal/handler/api_instance_flags.go (new),
internal/handler/api_instance_flags_test.go (new),
cmd/server/routes.go (+11 LOC for the new admin group),
API.md (new "Instance flag API" section under Instance admin).
No migration. No new env vars. No worker churn — the workers were
already reading instance_flags once per tick since v2.47.0.2; the
new API writes the same rows.
v2.51.0.8 — 2026-05-29¶
backup_runs audit table + consolidated dashboard integration + Prometheus freshness gauges.
staple-cli backup has shipped progressively richer functionality
since v2.51.0.0 — encryption (v2.51.0.1), S3 upload (v2.51.0.2),
retention (v2.51.0.3), straight-from-S3 restore (v2.51.0.4),
verify (v2.51.0.5), auto-verify (v2.51.0.6), backup-list (v2.51.0.7).
What's been missing is a durable, queryable record of every
invocation. Operators reading stdout know what happened this
invocation; the audit dashboard had no view into backup activity.
v2.51.0.8 adds a backup_runs table (migration 121) that the CLI
writes to on every invocation regardless of outcome. The audit
dashboard surfaces it as the eighth audit source. /metrics adds
two new gauges so a Prometheus alert can trip on "no backup in 24
hours" without any per-instance config.
Migration 121. backup_runs columns: outcome (success/failure
CHECK enum), encryption (enc:v2/plaintext CHECK enum), dek_id,
local_path/size, s3_bucket/key/size, duration_ms, verify_outcome
(verified/verification_failed/skipped/NULL), error_message,
actor_user_id (FK to users with ON DELETE SET NULL — cron-driven
backups carry NULL here), hostname, staple_version. Two indexes:
(created_at DESC) for the dashboard's recent-first ORDER BY and
(outcome, created_at DESC) so the latest-success lookup skips
past failure rows directly.
No company_id column — backups are per-instance, not per-tenant. The dashboard's company filter short-circuits to zero rows on a non-empty value (same posture as instance_flag_events from v2.47.0.4). No RLS armed; the only writer is the CLI, which is already a privileged operator tool, and the only readers (/admin/audit and /metrics) run inside scope.WithBypass.
CLI integration. runBackup is now a thin wrapper around the
existing logic (renamed to runBackupCore) that captures a
backupRunStats struct as the run progresses and flushes it to
backup_runs via query.RecordBackupRun on either return path
(success OR failure). The audit write is best-effort: a DB hiccup
logs at Warn and is otherwise swallowed. Operator's primary signal
is "did the file land?"; the audit row is secondary. Error messages
are truncated to 4000 chars and have embedded newlines stripped so
a pathological pg_dump stderr can't bloat the audit table or wreck
the dashboard's summary column.
Query helpers (internal/db/query/backup_runs.go).
RecordBackupRun(ctx, pool, *BackupRun)— single INSERT, validates outcome + encryption against the schema's CHECK constraints.GetLastSuccessfulBackupRun(ctx, pool)— returns the most recent success row or nil. Read by the /metrics handler.ListAllRecentBackupRuns(ctx, pool, limit)— created_at DESC cap, used by the dashboard.ListAllRecentBackupRunsFiltered(ctx, pool, limit, filter)— matches the *Filtered shape of the seven existing audit helpers. Company filter short-circuits to zero rows immediately (no DB round-trip). Actor filter does an INNER JOIN on users. Date range viaauditFilterDateRange.
Audit dashboard. surfacedAuditTables 7→8.
AuditFeedTablesForFilter canonical list 7→8.
surfacedAuditTablesAllowed 7→8. AuditFeedCollector gains a
Backups field. CollectAuditFeed calls the new *Filtered
helper. The handler's collectAuditFeed projects rows into the
audit feed with summary line
backup <outcome> (encryption=<enc>, size=<human>, duration=<dur>),
appending the error message on failure. DetailURL points at
/admin/operations until a dedicated /admin/backups page lands.
The audit/index.html template's source dropdown gains a
backup_runs option so operators can narrow the feed to just
backups.
Three pin-the-set tests updated — TestSurfacedAuditTables_StableOrder,
TestSurfacedAuditTablesAllowed_PinsKnownSet, and
TestAuditFeedTablesForFilter — to require eight tables, in
canonical order, with backup_runs as the eighth entry.
Prometheus metrics (v2.51.0.8). Two new gauges:
staple_backup_last_success_timestamp_seconds— Unix epoch seconds of the most recent successful backup_runs row. 0 when no success has ever landed. Operators alert ontime() - <gauge> > 86400for staleness and on== 0for cold-start "you never set up cron".staple_backup_last_size_bytes— size of the latest successful backup. Prefers the S3 size when uploaded (= remote durable size); falls back to local. Useful for capacity alerts.
Both are cached for 5 minutes (backupMetricCacheTTL) so a busy
Prometheus scraper hitting /metrics every 15 seconds doesn't
turn into ~20 DB round-trips per minute. Cache misses on the next
scrape after TTL expiry; DB error keeps the prior cached value so
the gauge doesn't blank mid-incident.
Tests. New internal/db/query/backup_runs_test.go covers
RecordBackupRun round-trip + the two CHECK-constraint guards,
GetLastSuccessfulBackupRun skipping failure rows, the company-
filter short-circuit on the *Filtered helper, and date-range
filter composition. New
TestMetricsHandler_BackupGaugesEmitZeroWithoutDB in
internal/handler/metrics_test.go pins the cold-start wire format:
both gauges emit 0 when no backup history exists.
Operator docs (docs/user/operators/backup-and-restore.md).
New section 12 "Audit + monitoring" — the schema, the dashboard
behaviour, the two new Prometheus gauges, and a sample alert
recipe for StapleBackupStale and StapleBackupNeverRecorded.
Migration counter advances 120 → 121.
v2.43.0.5 — 2026-05-29¶
New /admin/subagents dashboard: cross-tenant operator visibility into recent subagent activity.
Since v2.43.0.4 shipped /await-subagents-recursive, dispatching
a deep subtree from a chat is one verb. The corollary problem is
operator-side: once a subtree is in flight, the only way to see
what's running was to drill per-company into the runs page. That
breaks down the moment two operators kick off recursive dispatches
in parallel — the per-company view doesn't reveal who else is
running, depth, or how long things have been in flight.
v2.43.0.5 adds a focused dashboard at /admin/subagents. The page
is instance-admin-only and cross-tenant (scope.WithBypass). It
renders up to 200 of the most recent heartbeat runs whose agent
has parent_agent_id IS NOT NULL — i.e. subagents only. Root-
agent runs are explicitly excluded; they're already on the per-
company view and would dominate the feed.
Projection. New query.SubagentRunRow carries:
- run_id, agent_id, agent_name
- parent_agent_id, parent_agent_name (LEFT JOIN — deleted parents render as "(deleted parent)" rather than dropping the row)
- subagent_depth
- company_id
- status (pending | running | completed | failed)
- enqueued_at (heartbeat_runs.created_at — there's no explicit enqueued_at column, but created_at is set on row insert before the worker picks the run up, so it serves the same purpose)
- started_at (nullable while pending)
- completed_at (nullable while in-flight)
- duration (computed
finished_at - started_atfor completed rows; nil while pending or in-flight so the template renders "running" instead of misleading partial timing)
Ordering. ORDER BY started_at DESC NULLS LAST, created_at
DESC — freshly enqueued rows that haven't started yet still
surface at the top of the feed alongside the most recently
started in-flight ones. Once a run finishes the order tracks
when it began, not when it completed.
Filters. Optional URL query string:
?company=<substring>— substring match on company_id (UUID prefix is the common operator workflow)?status=<pending|in-flight|completed|failed>— exact match.in-flightis the operator-friendly alias for the schema'srunningenum; the matcher accepts either name.
Filtering happens in memory after the DB query. The row cap is
already 200, so pushing predicates into SQL is no measurable win.
Unknown statuses fall through to the full feed (same posture as
the audit dashboard's source filter) so a fuzzer poke at
?status=DROP_TABLE doesn't silently empty the page.
Sidebar. "Subagents" entry sits next to Audit on the
instance-admin sidebar in both layout contexts (company-scoped
and instance-scoped). Active-page key is subagents.
Empty state. When no subagent activity has been recorded the
page renders an inline hint pointing operators at
/spawn-subagent + /await-subagents for the first dispatch,
and at the v2.43.0.4 /await-subagents-recursive verb for
recursive dispatch. The page header carries the same hint as a
small banner so even a non-empty feed gets the discoverability.
Tests. Three new query tests in
internal/db/query/heartbeat_runs_test.go cover the happy path
(freshly minted subagent run lands in the projection with parent
+ depth populated), the root-agent exclusion guard (root-agent
runs do not leak), and the limit-clamp contract
(normalizeSubagentRunsLimit boundaries). Three new handler tests
in internal/handler/ui_subagents_test.go cover the
instance-admin gate (nil actor / non-admin user / API-key admin
all 403), the in-memory filter predicate matrix (company,
status, combinations, unknown-status fallback), and the URL
alias map.
Scope discipline. No DB schema change, no migration. The heartbeat_runs / agents tables already carry everything the dashboard needs.
v2.49.0.2 — 2026-05-29¶
/readyz JSON now surfaces per-worker error rate, with an operator-configurable degradation gate.
/readyz has been the orchestrator-facing source of truth for
per-worker liveness since v2.49.0.0. It catches "stuck on boot"
and "stalled mid-flight" but had a blind spot: a worker that's
ticking on schedule and failing every single tick still reports
OK: true. The error counter exists on worker.TickStats
(v2.47.0.0) and /metrics already exports it; /readyz just
wasn't reading it.
v2.49.0.2 closes that. Each per-worker check row now carries
optional ticks, errors, and error_rate_pct fields. They're
omitted on the aggregate workers row and on db_ping (JSON
omitempty on pointer fields) so the existing wire shape stays
backward compatible for the operator dashboards already consuming
name/ok/detail. Operators reading /readyz JSON directly
now see the error signal inline — no correlation with /metrics
needed.
Escalation gate. If a worker has more than 10 ticks AND its
error rate is above the configured threshold (default 50%, set
via STAPLE_READYZ_WORKER_ERROR_RATE_PCT), the row flips to
OK: false with a detail string of
error rate <X.X>% over <N> ticks; threshold <T.T>%, the
aggregate workers row inherits the failure, and overall status
goes not_ready. The orchestrator sees 503 and drains traffic.
Sample-size discipline. The escalation only fires once
Ticks > 10. Below that, a single early failure could trip a 50%
rate inside the first tick or two — the signal isn't trustworthy
yet. Below the sample gate, rows stay OK regardless of rate,
but the metric fields are still emitted so dashboards can read
the partial signal.
Configurable threshold, parsed once at construction. Read
STAPLE_READYZ_WORKER_ERROR_RATE_PCT from the environment when
the handler is built. Out-of-range values (negative, >100) or
unparseable strings fall through to the 50% default — a
misconfigured env var should never make the probe wrongly OK or
wrongly down. The parse stays out of the per-probe hot path.
Text/plain alias kept in sync. v2.47.0.3's /healthz/ready
LB-friendly alias also surfaces the new gate: degraded workers
contribute worker:<name> error rate <X.X>% to the
not ready: ... body. A single-line body still wins for HAProxy
+ ALB log fields; operators reading the LB log get the same
signal without parsing JSON.
Healthy-path rows now visible. Pre-v2.49.0.2, the per-worker
loop was exception-only — only emitted rows for boot grace, stuck
on boot, or stalled. Healthy workers stayed silent. With the new
metric fields it's cheaper for operators to see all worker
healthy rows carrying their tick counts than to maintain an
exception-only view; the extra rows are bounded by the worker
count (single-digit in practice) and the existing aggregate
workers row stays untouched for legacy consumers.
Tests. Six new unit tests in internal/handler/health_test.go
pin the wire-format contract end to end: zero-error happy path
emits error_rate_pct: 0.0; 100/60 degrades; below the minimum
sample stays OK; the env override flips behaviour; and the
db_ping row's JSON snippet asserts that ticks/errors/
error_rate_pct keys are physically absent (regression guard
against accidentally leaking them onto non-worker rows).
Operator docs. New section in
docs/user/operators/monitoring.md covering the new fields, the
default threshold, and the env override.
v2.50.0.5 — 2026-05-29¶
Audit dashboard: operator-private saved views via localStorage + a defensive guard against an empty-CompanyID slice panic.
Operators have been pasting /admin/audit?… URLs out of their
browser history daily. A handful of filter combos recur — "denied
egress last week", "GEPA accepts this month", "instance flag flips" —
and re-typing or re-pasting them is the kind of friction that
silently makes the dashboard get used less than it should.
v2.50.0.5 adds saved views. A new "Saved views" section above the
filter row lets the operator name and bookmark the current filter +
sort combination. Each view is stored locally in the browser's
localStorage under the key staple_audit_views as a JSON array of
{name, query} pairs. The dropdown lists every saved view; Load
navigates to /admin/audit?<query>; × deletes the selected view
after a confirm. Saves de-duplicate by name — re-saving an existing
view overwrites it rather than spawning a duplicate.
Scope discipline. This is the minimum-viable shape: operator-
private, browser-local, no DB changes, no sharing flow, no server-
side endpoints. Sharing a view today still means pasting the URL.
Server-backed shared views — a saved_audit_views table, an
"export to team" button, a per-user vs. per-instance scope — lands
later if there's real demand. Shipping the localStorage version
today proves the workflow without paying the schema-migration cost.
Graceful degradation. The inline script feature-tests
localStorage with a write-then-read probe before exposing the
controls. Browsers in private mode, with extensions that block
storage, or with sandboxed-to-zero quotas all hit the same
defensive branch: the entire saved-views section hides itself.
Operators on those browsers see no "save view" affordance rather
than a button that throws when clicked.
Inline JS, no build step. The script lives at the bottom of
internal/web/templates/audit/index.html, follows the same
zero-bundler pattern as the rest of the audit template. ~110 lines
of plain DOM + JSON, no dependencies. The contract — views is a
JSON array of {name, query} under the localStorage key
staple_audit_views — is documented in a leading comment so a
future maintainer reading the template understands the data shape
without grepping.
Bonus fix — {{slice .CompanyID 0 8}} panic on instance-wide rows.
Writing the admin-renders test for v2.50.0.5 surfaced a pre-existing
latent bug. instance_flag_events rows (v2.47.0.4) carry
CompanyID="" because the underlying flag is instance-wide. The
audit-table template renders the company column as
{{slice .CompanyID 0 8}}, which panics with index out of range:
8 when given an empty string. The page 500'd for any admin actor
on an instance that had recorded a flag toggle.
The slice is now guarded by {{if ge (len .CompanyID) 8}} with two
fall-through arms: a short non-empty id renders verbatim, and an
empty id renders (instance) in muted text. The pre-existing
behaviour for the common case (full UUID) is byte-identical.
Was this in scope? Technically the bug predates this release — it
landed alongside v2.47.0.4. But the saved-views feature required an
admin-renders test that wasn't there before, the test fired the bug
on day one, and shipping saved views without the guard would have
left a 500 in production from the moment of deploy. Bundling the
one-template-line fix is cheaper than a separate hotfix and keeps
the test passing for everyone who runs go test afterwards.
Operator docs. New docs/user/operators/audit-dashboard.md
collects the dashboard's behaviours into one operator-facing
reference: filters, CSV export, the v2.50.0.5 saved-views section,
auth posture. Replaces the per-feature scattering across the
release notes for operators who arrive looking for "how do I use
this page".
Tests. internal/handler/ui_audit_test.go gets
TestUIAuditDashboard_RendersSavedViewsScaffolding — an admin-
renders test that asserts the rendered HTML carries the "Saved
views" header, the [data-staple-saved-views] hook the inline
script attaches to, and the four button ids it wires up. If any of
those strings drifts in the template, the localStorage feature
silently breaks; pinning the contract in Go catches that at build
time.
Migration delta: 0. No tables, no endpoints, no Go side changes beyond one test addition + a one-line template guard.
v2.47.0.6 — 2026-05-29¶
Per-flag history drill-down at /admin/operations/flags/{name}/history.
v2.47.0.4 wrote instance flag toggles to a dedicated
instance_flag_events audit table. v2.50.0.0 + v2.50.0.1 surfaced
those rows on the consolidated /admin/audit dashboard alongside
the other six audit sources, with broad filters and a sort header.
That covers "what changed today?" workflows well. It's awkward for
the other half of the operator's questions: "show me the lifetime
of flag X — every flip, with old → new on each row."
The dashboard caps the cross-source feed at 500 most-recent rows and the filters compose multiplicatively across the seven sources; a flag that's been steady for weeks shows zero rows even when its history is the whole reason the operator opened the page.
v2.47.0.6 adds the drill-down: a per-flag page showing every recorded event for one flag, oldest-relevant first, with the flag's current effective override at the top for context. Capped at 500 rows — instance flags rarely flip more than a few dozen times across a deployment's life, but capping at all prevents a pathological data set from rendering an unbounded table.
URL: GET /admin/operations/flags/{name}/history.
Instance-admin-gated. Matches the gate on /admin/operations.
Per-row format. Each row carries timestamp / actor / action badge / change description. The change column is pre-computed handler-side:
setrows renderset: <old> → <new>(a first-ever set shows(unset) → <new>).clearrows rendercleared (was <old>).
Both forms route nullable *string values through the same
nullableStringForDisplay helper that the consolidated audit
dashboard uses, so an (unset) cell here means the same thing it
means there.
404 vs 200-empty. The handler distinguishes two empty-history cases:
- No audit rows AND no current override → 404. The flag is unknown to the operations subsystem.
- No audit rows but a current override row exists → 200 with an empty-state banner explaining the gap. This is the pre-v2.47.0.4 data shape: an override was set before the audit table existed, so the durable history is genuinely empty. The page still renders 200 because operators want to see the current value either way.
Entry points wired.
/admin/operations: every flag name in the flag-list table is now a clickable link to the drill-down. The visual stays the same (<code>-styled name) so the existing layout doesn't shift./admin/audit: theDetailURLforinstance_flag_eventsrows was previously/admin/operations(the flag-list index). It now links straight to/admin/operations/flags/<flag_name>/history. One click from an audit row to the flag's full timeline — which is almost always the operator's next question.
Query layer.
query.ListInstanceFlagEventsByName(ctx, pool, name, limit)
replays the existing instance_flag_events schema with a
WHERE flag_name = $1 predicate. Exact-equality match (no LIKE)
keeps a query for STAPLE_FOO from leaking into
STAPLE_FOO_DEPRECATED. The helper short-circuits to an empty
slice on a blank flag name so a misrouted request can't
accidentally return every row in the table.
Tests.
internal/db/query/instance_flag_events_test.goaddsTestListInstanceFlagEventsByNamecovering happy path (DESC-ordered rows for one flag), empty result, blank-name short-circuit, and the exact-match boundary against a superset-named flag.internal/handler/ui_instance_flag_history_test.go(new) covers the auth gate, the happy path with three events of mixed actions, the 404 path for a truly-unknown flag, and the 200-with-banner path for a pre-v2.47.0.4 override-without-audit flag. Plus a table-driven pin for the purecomputeFlagChangeDescriptionhelper.
Migration delta: 0. The instance_flag_events schema lands
in this release unchanged — this is a read-side feature.
v2.47.0.5 — 2026-05-29¶
Deploy hygiene: flaky-test fix for agent name lookups + reset.sh hardening against stale working trees.
This is a small operational release combining two unrelated quality wins that block clean deploys.
A. TestListAgentsHandler/known-name panic on real fixtures.
The handler test added in v2.42.0.1 to exercise the
?name=<friendly> lookup path constructed its request URL with a
raw string concat:
rawQuery was built as "name=" + known, where known is the
actual agent display name pulled from the live database. Most
fixtures (empirical-lead, triage, …) are hyphen-cased and
URL-safe, so the test happened to pass. But any tenant whose seeded
agents use the display form ("Empirical Lead") produced a
request line GET /api/agents?name=Empirical Lead HTTP/1.1, which
net/http's parser then read as a malformed HTTP version token and
panicked.
The fix is one import and two call sites: percent-escape the value
the way every real client (browser, curl, staple-cli) does. The
unknown-name literal (does-not-exist-xyz-987) is already
URL-safe but is escaped for the same reason — defending against
future changes that swap in a less-safe sentinel.
Verified on node4 with go test ./internal/handler/ -run
TestListAgentsHandler -count=3 — three back-to-back runs, all
three subtests green each time.
B. Operator-owned reset.sh patched against rsync'd cruft.
The deploy script at /mnt/Media/staple/reset.sh on node4 (which
lives outside the repo, hence no in-repo file in this commit) used
git pull to refresh the source tree before building. That works
when the local working tree is clean and tracks origin/main
cleanly. When operators rsync artefacts into the checkout — backup
dumps under ./backups/, ad-hoc test output, an experimental
branch left checked out — git pull refuses to fast-forward, the
build runs against the wrong commit, and /healthz reports a
stale version after the service restarts. We've eaten that surprise
at least twice this milestone.
The patched flow forces the working tree to match origin/main before building:
This is destructive against local modifications by design — the
deploy host is a deploy target, not a dev box. Human edits belong
on a feature branch or a separate machine. The previous git
checkout main line is kept (idempotent if already on main; cheap
otherwise). The redundant git pull is removed.
The change was applied directly on node4 via ssh and verified with
bash -n /mnt/Media/staple/reset.sh. This release is the first
deploy to exercise the new flow; the next deploy (v2.47.0.6) will
confirm it cleanly fast-forwards across the v2.47.0.5 → v2.47.0.6
tag bump.
Operator docs: docs/user/operators/upgrades.md gains a
"v2.47.0.5 hardening" note under the ./reset.sh section
documenting the new behaviour and explicitly calling out the
no-local-edits expectation.
Scope discipline. Neither change introduces a migration, a new endpoint, or a behaviour change visible to agents or tenant operators. Both are quality wins for the deploy loop itself.
v2.46.0.6 — 2026-05-29¶
GEPA proposal diff: opt-in side-by-side mode with line numbers + change-pair collapsing.
v2.46.0.5 shipped a real Myers diff for the proposal review page, replacing the naive line-set comparison from v2.46.0.4. The default view renders two columns (current | proposed) with red/green highlighting on remove/add rows. That works well for short prompts but reads densely for large prompt evolutions where the operator wants to scan across hundreds of lines fast — there are no line numbers to anchor against, and a single-line replacement renders as two adjacent rows (one remove, one add) which doubles the table height of the most common edit.
v2.46.0.6 adds an opt-in ?mode=side-by-side view that addresses
both. The new mode adds per-side line numbers and collapses
consecutive (remove, add) Myers script pairs into a single
"change" row with both columns populated and an amber background
distinct from the pure-red remove / pure-green add highlights. The
default URL (no ?mode= parameter) keeps the v2.46.0.5 visual
exactly so existing bookmarks don't shift.
A small toggle line above the diff lets operators flip between the two modes:
View: unified | side-by-side — side-by-side adds line numbers
+ collapses consecutive remove/add
pairs (v2.46.0.6).
Aliases ?mode=sxs and ?mode=sbs are accepted for brevity in
hand-typed URLs. Unknown or malformed values fall through to the
unified default rather than 500ing the page.
The new projection computePromptDiffSideBySide lives alongside
the existing computePromptDiff in internal/handler/diff.go and
re-uses the same underlying myersDiff LCS backtracker. The
change-pair collapse is intentionally limited to the immediate
(Remove, Add) sequence — three consecutive Removes followed by
three Adds stay as six rows rather than three arbitrarily-paired
change rows, because pairing them would lie about which Remove
"became" which Add. The collapse also requires both sides to have
non-empty text, so a synthetic empty-line remove (from
strings.Split("", "\n")) doesn't fuse with the first real Add and
produce a misleading "was '', now 'real content'" row.
Tests:
TestComputePromptDiffSideBySide_AllKeep— identical inputs → N keep rows, both columns populated, line numbers advance in step.TestComputePromptDiffSideBySide_PureAdd— empty current → every proposed line lands as anaddrow with the left line number frozen.TestComputePromptDiffSideBySide_Mixed— keep / remove / keep with the expected line-number drift across the unbalanced middle row.TestComputePromptDiffSideBySide_CollapsesChangePairs— the cosmetic collapse: a single-line replacement renders as ONE "change" row rather than two separate remove/add rows.TestNormalizeDiffMode_Table— alias + unknown-value handling.
Operator docs: the in-page toggle is the discovery surface; no separate doc revision was made because the diff is reachable only from the agent proposal-detail page which already explains itself.
v2.51.0.7 — 2026-05-29¶
staple-cli backup-list — one-shot operator view of the backup pile.
The v2.51.0.3 retention worker prunes expired backups in the
background. The v2.51.0.2 / .4 S3 path lets operators ship a copy
off-host. The v2.51.0.5 / .6 verify path makes sure each file is
actually restorable. What was missing: the operator's "what's
currently in my pile, and what's about to go next?" answer.
Operators were reconstructing it from ls -lt /var/backups/staple
+ aws s3 ls $bucket/prefix + mental math against
STAPLE_BACKUP_RETENTION_DAYS.
v2.51.0.7 collapses that into one subcommand. Walks the local directory (top-level, matching the retention worker's sweep) and optionally lists the S3 mirror; emits a sorted-newest-first fixed-width table with size + age + encryption status + expires-in:
$ staple-cli backup-list --s3
LOCATION PATH/KEY SIZE AGE ENCRYPTION EXPIRES_IN
local /var/backups/staple/staple-20260529T140112.dump.enc 132.40 MiB 2h enc:v2 30d
local /var/backups/staple/staple-20260528T140057.dump.enc 132.10 MiB 26h enc:v2 29d
s3 s3://ops-staple-backups/prod/staple-20260528T140057.dump.enc 126.30 MiB 26h enc:v2
local /var/backups/staple/staple-20260101T140042.dump.enc 122.00 MiB 148d enc:v2 (expired)
EXPIRES_IN renders as:
<n>dwhen the file's age is within the retention window and pruning is N days away.(expired)when the file's age already exceeds retention. The next retention-worker tick will prune it (or the operator's ownfind -mtime +N -deletecron, whichever runs first).- blank for S3 rows. The retention worker is local-only; S3 lifecycle is the bucket admin's concern.
Encryption status is sniffed from the first 17 bytes of each file
(matching STAPLE_BACKUP_v1\n). For local files this is a single
4-KiB-cap os.Open + io.ReadFull. For S3 entries it's a
Range-GET (Range: bytes=0-16) — about 17 bytes per object on the
wire, fine even for 100-object listings.
--json emits a stable array of structured records for downstream
scripts:
$ staple-cli backup-list --dir=/var/backups/staple --json | jq
[
{
"location": "local",
"path": "/var/backups/staple/staple-20260529T140112.dump.enc",
"size": 138807424,
"mtime": "2026-05-29T14:01:12Z",
"encryption": "enc:v2",
"expires_in_seconds": 2589120
},
...
]
Flags:
--dir=<path>overrides$STAPLE_BACKUP_DIR(default/var/backups/staple, same as the retention worker).--s3enables the S3 listing leg.--s3-bucket / --s3-prefix / --s3-region / --s3-endpoint / --s3-path-styleoverlay the env-var-derivedS3Configper-field, same precedence shape the v2.51.0.2 upload + v2.51.0.4 / .5 download/verify commands use.--retention-days=<n>overrides$STAPLE_BACKUP_RETENTION_DAYSfor the EXPIRES_IN column.0hides the column entirely — mirrors the worker's "0 = opt out" semantic.--jsonswaps the table for JSON.
The command opens NO database connection. It's a pure filesystem +
S3-API walker so it runs from a recovery shell with no
DATABASE_URL set — useful during disaster recovery when the
operator wants to see what they actually have available before
deciding which restore invocation to issue.
Implementation notes:
- The S3 primitives live in
internal/backup/s3list.go(ListEncryptedBackups+SniffEncryption), alongside the v2.51.0.2 upload sink and v2.51.0.4 download source. Same S3Config struct, same AWS SDK default-credential chain, same endpoint-override handling. - The byte-prefix classifier (
classifyMagic/BackupMagicV1Bytes) is duplicated as a 17-byte constant ininternal/backuprather than importinginternal/crypto.BackupMagic— the backup package shouldn't grow a crypto-package dependency for a 16-byte sentinel. A drift between the two would mis-classify freshly-written files; a compile-time test guards the length. - The CLI's
internal/workerimport pulls inEnvBackupDir/DefaultBackupDir/EnvBackupRetentionDays/DefaultBackupRetentionDaysso the operator-visible defaults match the worker's runtime defaults exactly.
Operator docs: docs/user/operators/backup-and-restore.md section
11 covers the table and JSON examples.
v2.51.0.6 — 2026-05-29¶
Auto-verify after backup — defense-in-depth.
v2.51.0.5 gave operators staple-cli backup-verify as a standalone
sanity check. v2.51.0.6 wires that same check into the tail of every
staple-cli backup run by default. The reasoning is small but
load-bearing: a backup that can't be decrypted is worse than no
backup. With no backup, the operator knows where they stand; with a
backup that turns out to be corrupt at restore time (months later,
during a disaster, in a hurry) they're worse off than if they'd
realized at write time and re-run the dump. Auto-verify makes
"successful exit" mean "the bytes on disk actually decrypt cleanly
end-to-end", not just "pg_dump wrote a file".
The verify step happens after the local file write AND after the S3 upload (if any). It reuses the same Vault the encrypt step already loaded — no second pool open, no second DEK fetch. The success line gains one row:
Backup complete:
file: ./staple-backup-20260529T160001.dump.enc
size: 132,418,560 bytes
encryption: enc:v2 (dek=01HM...)
uploaded: s3://ops-staple-backups/prod/.../staple-backup-...dump.enc (126.30 MiB)
Verifying backup ...
verified: dek=01HM... chunks=0 decrypted=126.30 MiB in 4.21s
On verify failure the local file is DELETED. The file is corrupt by definition; leaving it on disk would just confuse the next retention sweep into thinking a recent backup exists. The exit code is 1. If an S3 upload happened we print the URI but refuse to delete the remote object — operators may have S3 versioning or lifecycle rules on the bucket that we'd interfere with. The remote-cleanup decision is theirs:
$ staple-cli backup
Writing backup to ./staple-backup-20260529T160001.dump.enc ...
Backup complete:
file: ./staple-backup-20260529T160001.dump.enc
...
uploaded: s3://ops-staple-backups/.../staple-backup-...dump.enc (126.30 MiB)
Verifying backup ...
verify failed; corrupt local file ./staple-backup-...dump.enc removed: ...
WARNING: S3 copy at s3://ops-staple-backups/.../staple-backup-...dump.enc was NOT deleted; clean up manually.
backup: auto-verify after write: ...
exit status 1
Two escape hatches for operators who already validate elsewhere (restore-into-staging pipeline, third-party Postgres recovery tool):
--no-verifyper-invocation flag.STAPLE_BACKUP_NO_VERIFY=1env var for a fleet-wide standing policy. Truthy values perstrconv.ParseBool(1/true/yes).
--no-encrypt backups (the EMERGENCY-USE-ONLY plaintext path) skip
auto-verify silently — there's nothing to decrypt-verify, and validating
a custom-format dump structurally would mean running pg_restore --list
which is heavier than what fits here. Deferred to a future revision.
The verify pipeline factor-out lives in verifyEncryptedBackupWithVault
in cmd/staple-cli/backup_verify.go — same plumbing the standalone
backup-verify subcommand uses, just with the Vault passed in rather
than opened on the spot. The standalone command stays the canonical
"validate an arbitrary file on disk" entry point; the v2.51.0.6 inline
call is the "validate the file we just wrote" specialisation.
Operator docs: docs/user/operators/backup-and-restore.md section 10
covers the auto-verify default and the override flags.
Retroactive entry for v2.51.0.5¶
The post-v2.51.0.5 commit 1c5b088 fix(cli): backup-verify silently
exited on argument-shape errors was a polish follow-up that
prevented staple-cli backup-verify (no args) from returning 0 and
appeared in the staple-cli dispatcher (runBackupVerifyAlreadyPrinted
helper in cmd/staple-cli/main.go). It landed without its own
CHANGELOG section; this entry closes that drift. The fix makes the
no-positional / no---file case emit the usage error to stderr and
exit 1 like every other subcommand.
v2.47.0.4 — 2026-05-29¶
Instance flag audit trail surfaces on the consolidated dashboard.
v2.47.0.2 introduced instance_flags — the operator-driven runtime
override table the Operations Control Panel writes to when an admin
flips a behavior toggle without restarting staple-server. The
runtime effect is the entire point of that table: every toggle
changes how the platform behaves for every tenant for the duration
of the override. Until v2.47.0.4 those changes left no durable audit
trail beyond the structured slog "operations: instance flag
updated" line in journalctl, which is fine for live debugging but
not queryable, not retained, and not surfaced anywhere an instance
admin would think to look.
The consolidated /admin/audit dashboard (v2.50.0.0+) already
surfaces six audit tables across the platform — secret reveals,
plugin approvals, egress denials, A2A dispatch attempts, DM-channel
pairing transitions, GEPA proposal lifecycle. v2.47.0.4 adds
instance_flag_events as the seventh source so a single "what
changed and who changed it?" view covers runtime-config drift the
same way it covers everything else.
Migration 120 creates the table:
CREATE TABLE instance_flag_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
flag_name TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('set','clear')),
old_value TEXT, -- nullable; first-ever 'set' has no prior
new_value TEXT, -- nullable for 'clear' events
description TEXT, -- copy-on-write at audit time
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL
);
Two indexes: idx_instance_flag_events_created_at_desc for the
dashboard's ORDER BY created_at DESC LIMIT 50 per-source query,
and idx_instance_flag_events_flag_name for the future "history of
this one flag" view.
No RLS. instance_flags is instance-wide (the entire point — the "this applies to every tenant" runtime config layer); the audit rows inherit the same property. Reads go through scope.WithBypass, same posture as the other instance-level audit-aggregate helpers in audit_aggregate.go.
Write path (handler/ui_operations.go::UIOperationsFlagSet):
- Snapshot the existing flag value via GetInstanceFlag BEFORE the upsert/delete. A nil pointer means "no row existed" (first 'set') and renders in the dashboard as "(unset)". A failed GetInstanceFlag at this step gracefully degrades to nil rather than blocking the toggle.
- Perform the UpsertInstanceFlag (action="set") or DeleteInstanceFlag (action="clear") as before.
- Write the audit row with the captured old → new transition. The description is the flag's prose description as known at write time (copy-on-write), so the dashboard renders intelligibly even if the flag is later renamed.
- Errors writing the audit row are logged at Warn but do NOT block the user-visible flag change. The flag has already been toggled at this point; failing the response would confuse the operator into thinking the toggle didn't take effect.
The dashboard projection (handler/ui_audit.go) renders one summary
line per row: instance flag set: STAPLE_EMBEDDING_ENABLED
(old=(unset), new=true). CompanyID is intentionally blank because
the rows are instance-wide; the Company filter on a populated value
short-circuits to zero rows (same posture as the egress / a2a
system-attributed short-circuits). DetailURL links back to
/admin/operations so the operator can flip the flag back from the
same row.
Source-pushdown is wired through end-to-end:
AuditFeedTablesForFilterreturns 7 tables (was 6) for the unfiltered case;surfacedAuditTablesAllowedaccepts "instance_flag_events" as a valid Source value;CollectAuditFeedqueriesListAllRecentInstanceFlagEventsFilteredwhen the source is in scope;ListAllRecentInstanceFlagEventsFilteredshort-circuits on non-empty Company filter (instance-wide rows have no company_id), folds the Actor filter into a users INNER JOIN, and carries the v2.50.0.4 date-range pushdown.
Coverage:
internal/db/query/instance_flag_events_test.go— Record + List round-trip, action CHECK + empty-name validation, Company short-circuit, date-range pushdown.internal/db/query/audit_aggregate_test.go— bothTestAuditFeedTablesForFilterandTestSurfacedAuditTablesAllowed_PinsKnownSetwere expanded from six to seven sources. The "known-source-queries-only-that" subtest now exercises instance_flag_events.internal/handler/ui_audit_test.go—TestSurfacedAuditTables_StableOrderpinned to seven sources.internal/handler/ui_operations_test.go—TestUIOperationsFlagSet_WritesAuditRowsverifies a set+clear cycle produces a "set" row (old=nil, new=true) and a "clear" row (old=true, new=nil).internal/web/templates/audit/index.html— source-filter dropdown gains the instance_flag_events option.
v2.51.0.5 — 2026-05-29¶
staple-cli backup-verify — operator-facing readability check.
Until v2.51.0.4 the only way an operator could answer "did last
night's encrypted backup actually decrypt?" was to stand up a
throwaway Postgres, set up DATABASE_URL + STAPLE_ENCRYPTION_KEY, and
run staple-cli restore against it. The restore is destructive,
slow, and easy to point at the wrong database if the operator is
juggling environments. None of that ceremony is necessary if all you
want is "header parses + every chunk decrypts cleanly + the trailing
EOF marker is intact".
v2.51.0.5 adds staple-cli backup-verify. The command streams the
encrypted file end-to-end through the same vault.NewBackupReader
the restore path uses, discards the plaintext via io.Copy →
io.Discard, and reports:
DATABASE_URL=postgres://... STAPLE_ENCRYPTION_KEY=... \
staple-cli backup-verify ./staple-backup-20260529T140112.dump.enc
✓ verified: ./staple-backup-...dump.enc dek=01HM... chunks=0 decrypted=132.40 MiB in 4.21s
The exit code is the load-bearing signal: 0 on success, 1 on any
failure (bad magic, malformed header, GCM auth tag mismatch,
truncation, unknown dek_id, network failure on s3:// inputs). On
failure the first stderr line is ✗ failed: <reason> — even with
--quiet set, so cron-driven monitoring can grep on it.
What backup-verify verifies, in order:
- The header starts with the
STAPLE_BACKUP_v1magic. - The header's three mandatory key-value lines (
dek_id,wrapped_fk,nonce_prefix) are all present and parseable. - The named DEK exists in the Vault (i.e. the KEK in the current environment is the same one the file was written under).
- Every length-prefixed GCM-sealed chunk decrypts cleanly. The AES-GCM auth tag check trips on any single bit-flip in either the ciphertext body OR the chunk's nonce (which is derived from the per-file nonce_prefix + chunk index).
- The trailing zero-length EOF marker is present (no truncation).
What backup-verify does NOT verify: the plaintext is a valid
Postgres custom-format dump. That requires actually running
pg_restore and inspecting row counts. The
backup-and-restore.md
quarterly-restore procedure stays the canonical "is this backup
restorable end-to-end" check; backup-verify is the daily-cron
"is the file structurally intact" check, which catches the failure
modes 95% of operators actually hit (file copied off-host with a
truncating rsync, S3 PUT mid-aborted, disk-level bit-rot, KEK
rotation that didn't re-wrap correctly, etc.).
Cron-friendly recipe:
#!/usr/bin/env bash
set -euo pipefail
source /etc/staple/staple.env
LATEST=$(ls -t /var/backups/staple/*.dump.enc | head -1)
if ! /usr/local/bin/staple-cli backup-verify "$LATEST" --quiet \
2> /tmp/staple-verify.err; then
cat /tmp/staple-verify.err | mail -s "STAPLE BACKUP VERIFY FAILED" ops@example.com
exit 1
fi
S3 inputs work the same way: pass s3://bucket/key instead of a
local path. The download streams through an io.Pipe (same
plumbing as v2.51.0.4's restore path) so the encrypted bytes never
land on disk. One cosmetic difference: the success line drops the
dek=... label for s3:// inputs because peeking at the header
would mean a second GetObject. The dek id is recoverable from
staple-cli vault-status if the operator needs it.
Implementation notes for the next maintainer:
-
cmd/staple-cli/backup_verify.gohosts the subcommand. The body isverifyEncryptedBackupwhich opens the source (local file or s3:// URI), opens a Vault reader, andio.Copys through anio.TeeReaderintoio.Discard— the tee writes into a counting sink so the summary line can report the plaintext byte count. -
The chunk count is reported as 0 because the v2.51.0.1
backupReaderkeeps itschunkIndexunexported. Exposing it via(*backupReader).ChunkCount()is a future refinement; for now the dek_id + decrypted byte count are the load-bearing summary signals.probeChunkCountis a no-op placeholder kept named so a future change can wire it without touching every call site. -
extractDEKIDis a small local-file-only peek that re-opens the source and reads the first 4 KiB to lift thedek_id=...value out of the header. It's deliberately not threaded through the main verify path (which would need a buffered prefix reader) — the cost is a second open of a small file region, which is cheap enough that pulling the helper into the hot path isn't worth the complexity. For s3:// the helper returns "" rather than issue a secondGetObject. -
The CLI's
--quietflag controls the success line ONLY. Failure always emits✗ failed: <reason>on stderr; operators relying on cron-driven monitoring need a one-line reason even when their default is silent. -
Crypto-level coverage is in
internal/crypto/backup_stream_verify_test.go— happy round-trip, tampered body, truncated stream, bad magic. CLI-level coverage incmd/staple-cli/backup_verify_test.gopins the argument shape + env-gate ordering +--quiet-still-emits-failures behavior + theextractDEKIDhelper.
Symmetric counterpart in the operator docs:
docs/user/operators/backup-and-restore.md section 9 covers the
local + s3:// examples and the cron-friendly pattern.
v2.51.0.4 — 2026-05-29¶
Restore from S3.
v2.51.0.2 gave operators a way to ship the encrypted backup file off
the host immediately after writing it. The matching restore story was
still operator-driven: pull the object down with aws s3 cp (or mc
cp / rclone copy) and feed the local file to staple-cli restore.
Two steps, two sets of credentials to align, and a stray plaintext-
encrypted copy of a sensitive dump to clean up.
v2.51.0.4 closes the loop. The operator gives the CLI the same
s3://bucket/key URI the v2.51.0.2 upload printed and the dump flows
straight from S3 through an io.Pipe into vault.NewBackupReader,
then into pg_restore's stdin — never touching disk.
DATABASE_URL=postgres://... STAPLE_ENCRYPTION_KEY=... \
staple-cli restore \
s3://ops-backups/staple-backups/staple-backup-20260529T140112.dump.enc \
--yes
The s3:// prefix is strict: an unsupported scheme (gs://,
https://, file://), a missing key (s3://bucket/), or a malformed
URI like s3:/bucket/key (single slash) all fall through to the
local-file path — which then surfaces as a "backup file: no such
file or directory" error from the existing v2.51.0.0 code. There is
no fuzzy parsing; if the URI is mistyped, the operator notices
immediately rather than getting a confusing SDK 404 later.
The S3 object is required to be encrypted (i.e. start with the
STAPLE_BACKUP_v1 magic). Plaintext custom-format dumps from
--no-encrypt are not supported on the s3:// path because there is
no operational reason to push plaintext dumps to S3 — they leak every
operator secret in cleartext. If you need to restore a plaintext
dump, pull it with aws s3 cp first and feed the local file to
staple-cli restore.
Configuration mirrors the v2.51.0.2 upload command:
STAPLE_BACKUP_S3_REGION/STAPLE_BACKUP_S3_ENDPOINT/STAPLE_BACKUP_S3_PATH_STYLEenv vars,--s3-region,--s3-endpoint,--s3-path-styleflag overrides,- credentials from the AWS SDK default chain
(
AWS_ACCESS_KEY_ID, IRSA,~/.aws/credentials, EC2 instance profile).
There is no --s3-prefix flag for the restore path — the operator
supplies the full key in the URI, so a prefix would be ambiguous. A
--s3-bucket flag exists for symmetry with the backup command (and
to support the rare "I want to use a different bucket than the one I
pasted" case) but in practice the bucket is taken from the URI.
Implementation notes for the next maintainer:
internal/backup/s3source.gohosts the helpers:ParseS3URI(s)— stricts3://bucket/keyparser. Wide table- driven coverage ins3source_test.go(9 rejection cases plus two happy paths).-
DownloadEncryptedBackup(ctx, cfg, key, w)— single-shotGetObject+io.Copyintow. Plain GetObject (not the managerDownloader) because the manager requires aWriterAtsink (concurrent range-GETs into different offsets), which would force us to buffer the whole file in memory or land it on disk before feedingpg_restore. The streaming variant is slower for multi-GiB dumps but preserves the "never touches disk" goal. -
cmd/staple-cli/restore.go::runRestorebranches onbackup.ParseS3URIto pick the s3 vs. local-file path. The pre-existing local-file flow is byte-for-byte unchanged: same magic sniff, same plaintext fallback for v2.51.0.0 dumps. -
runS3Restoreplumbs the SDK body through anio.Pipe. The read end is consumed by a tight magic sniff (17 bytes) and then prepended back viaio.MultiReaderbefore handing off tovault.NewBackupReader. This keeps the byte sequence identical to what the Vault reader sees from a local file, so the existing GCM round-trip tests (internal/crypto/backup_stream_test.go) exercise the same code path on the restored bytes. -
Error policy: any failure before we hand off to
pg_restoreexits 1 with a clear error line. We refuse to startpg_restoreon a stream that can't possibly succeed (e.g. wrong magic, missing key, auth failure) becausepg_restore --cleanwould otherwise drop the destination schema before failing on the missing input.
Symmetric counterpart in the operator docs:
docs/user/operators/backup-and-restore.md section 8 now covers
the AWS + minio + R2 examples.
v2.42.0.5 — 2026-05-29¶
Persistent chat REPL history across sessions.
staple-cli chat gained arrow-key history recall in v2.42.0.4 via
peterh/liner. The recall was
in-memory and session-scoped: closing the REPL threw away every
prompt the operator typed. The conscious-design rationale at the
time was privacy — chat prompts can carry half-typed credentials,
sensitive context, debugging snippets, etc. — but operators have
been asking for cross-session recall consistently enough that
"opt-in persistence with sensible defaults" is the better default.
v2.42.0.5 writes the history to disk between sessions, with explicit opt-out for environments that prefer the old behaviour.
Resolution order (first hit wins):
STAPLE_NO_HISTORY=1(or any truthy form) → disable persistence entirely; in-memory-only, identical to v2.42.0.4.STAPLE_CHAT_HISTORY=<path>→ use it verbatim.XDG_STATE_HOME=<dir>→<dir>/staple/chat_history.- Otherwise →
$HOME/.staple/chat_history.
The --no-history CLI flag composes on top of the env vars: pass
it to suppress persistence for a single invocation regardless of
how the environment is configured. Either signal is sufficient.
Privacy guarantees:
- The parent directory is created with mode
0o700. Chat prompts can be sensitive; treat the directory like~/.ssh. - The history file itself is written atomically (write to a
sibling
.tmp-*, fsync-less close, rename into place) with mode0o600. A crash mid-write does not leave a truncated file. - On load, the file's mode bits are inspected. Anything looser
than
0o600(i.e. group or world access) logs a stderr warning so the operator can re-tighten the perms rather than silently re-write at the looser mode. - Local
/-commands (/quit,/exit,/help,/clear) are NEVER recorded — same as v2.42.0.4. They aren't things the operator wants to recall, and persisting them would clutter the recall history with noise.
History is capped at the most recent 1000 entries. liner has no built-in capacity setter, so the cap is enforced at flush time: we write the in-memory history to a buffer, drop the head when it exceeds the cap, and persist the tail. This keeps the file bounded across long-running sessions without any extra plumbing.
Implementation notes for the next maintainer:
- The path resolver
chatHistoryPath(getenv, homeFn)is factored out as a free function so the unit tests pin the policy without spinning up a real liner. Five table-driven cases cover the resolution order, the disable path, and the no-home fallback. chatLoopis preserved as a thin wrapper around the newchatLoopWithConfig. The existing unit tests that drive the REPL viastrings.NewReadercontinue to work unchanged — they hit the bufio fallback which doesn't touch the history file regardless of env state.- The persistence step lives in
linerPromptReader.Close()so a graceful exit,/quit,/exit, ctx cancellation, or stdin EOF all flush identically.
v2.51.0.3 — 2026-05-29¶
Background sweep for local backup retention.
staple-cli backup preserves the local encrypted file even after a
successful S3 upload (v2.51.0.2) so the operator has a hot copy on
the same host for fast restores. The downside is unbounded disk
growth: a daily 200 MiB dump piles up to roughly 6 GiB per month,
and the operator has historically been on the hook for a separate
find -mtime +N -delete cron to keep the directory in line.
v2.51.0.3 brings that sweep into the platform. A new background
worker scans STAPLE_BACKUP_DIR (default /var/backups/staple)
once an hour and removes regular files older than
STAPLE_BACKUP_RETENTION_DAYS (default 30, clamped to
[1, 3650]) whose name ends in .dump, .dump.enc, .sql, or
.sql.gz.
Safety properties:
- Non-recursive. Only the top-level directory is scanned. Drop
a
checksums/subfolder if you want guaranteed survival of sidecar files. - Symlinks are skipped. A symlink named
*.dump.encis deliberately left alone; we never follow links inside the sweep. - Non-matching extensions are skipped. README,
.key,.sha256, anything else stays put regardless of age. - Missing directory is a no-op. Logged as Warn, sweep
skipped. Avoids the "fresh deploy, backups not configured yet,
worker mass-deletes the empty
/varfolder it ended up pointing at" footgun. - Per-file errors do not abort the sweep. A single permission-denied entry logs Warn and the next entry is tried.
- Operator escape hatch. Set
STAPLE_BACKUP_RETENTION_DAYS=0in the staple-server environment to disable the worker entirely. Logs "backup retention disabled" once at startup and exits the loop.
The worker reports last-tick activity via the existing TickStats
registry — visible at /metrics as
staple_worker_ticks_total{worker="backup_retention"} and on the
admin Operations page.
The shape follows the pairing_cleanup template (worker struct +
constructor + Start(ctx) + runOnce(ctx)). Hour-rate ticker
with an immediate first run on startup so post-deploy backlog gets
swept without waiting a full hour.
The operator runbook (docs/user/operators/backup-and-restore.md)
gains a new "Local backup retention" section covering the env
vars, the protected-file matrix, and the disable path.
v2.51.0.2 — 2026-05-29¶
Optional S3 upload for encrypted backups.
staple-cli backup already produces an encrypted custom-format
pg_dump on the local filesystem (v2.51.0.1). Shipping that file
off-host has always been the operator's problem: a cron wrapper
that aws s3 cp-s the artifact somewhere, or a hand-rolled rsync
loop, or a separate backup-orchestrator entirely. For a
single-operator instance that mostly works, but it does mean
"backup" stops at the disk boundary and any small mistake in the
shipping script leaves the dump only on the local host.
v2.51.0.2 closes the loop: after the local .dump.enc file is
written successfully, staple-cli backup can ship it directly to
any S3-compatible object store. The local file is kept — the
upload is an additional copy, not a move — so a transient S3
failure (network, credentials, bucket policy) never costs the
operator the dump itself.
Set STAPLE_BACKUP_S3_BUCKET (and friends) and the next backup
uploads automatically. The full env-var matrix:
| Variable | Default | Purpose |
|---|---|---|
STAPLE_BACKUP_S3_BUCKET |
— | Required. Enables the upload. |
STAPLE_BACKUP_S3_PREFIX |
staple-backups/ |
Key prefix for uploaded objects. |
STAPLE_BACKUP_S3_REGION |
us-east-1 |
AWS region. |
STAPLE_BACKUP_S3_ENDPOINT |
(empty) | Custom endpoint URL — empty for AWS S3, set for minio / R2 / Wasabi / B2. |
STAPLE_BACKUP_S3_PATH_STYLE |
false |
true forces path-style addressing (required for minio + most self-hosted gateways). |
Each variable has a matching --s3-* CLI override on the backup
subcommand. Pass --no-s3 to suppress the upload for a single
invocation even when the env vars are set.
Credentials come from the AWS SDK default chain — same as the
Bedrock client. Set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
in the environment, attach an instance role, use IRSA, or drop a
profile in ~/.aws/credentials. Staple does not introduce a
separate credential channel for backups.
The upload uses the AWS SDK's multipart-aware Uploader; small
dumps go up in a single PUT, anything over 5 MiB switches to
multipart automatically. A successful upload prints the resulting
s3://bucket/key URI plus the multipart ETag so the operator can
verify against aws s3 ls or mc stat without grepping the
upload trace.
S3 sink lives in the new internal/backup package — separated
from cmd/staple-cli so the v2.51.0.3 retention worker and any
future S3-aware restore path can reuse the env-var loader and
uploader without copying constants. The operator-facing backup
runbook (docs/user/operators/backup-and-restore.md) gains a new
"S3 backup uploads" section with end-to-end examples for AWS,
minio, and Cloudflare R2.
v2.47.0.3 — 2026-05-29¶
Load-balancer-friendly text/plain health variants.
The existing /healthz and /readyz return JSON with structured
per-check detail — useful for operator dashboards and the admin
operations page. Load balancers, on the other hand, typically
inspect only the status code + the first few bytes of the body
and frequently log the body verbatim into their access log. For a
busy ALB or HAProxy probing every couple seconds, that's a
measurable amount of noise on a yes/no signal.
v2.47.0.3 adds two text/plain aliases mirroring the existing endpoints' semantics:
-
GET /healthz/live— always returns200 OKwith bodyOK\n. Cheaper than/healthz: no version lookup, no JSON marshaling, no map allocation. Use for LB target health checks where any 2xx + minimal body is sufficient. -
GET /healthz/ready— runs the same db_ping + worker- staleness logic as/readyzbut emits a one-line text response:200 OK+ bodyready\n503+ bodynot ready: <reason>\n
The reason string is a single-line summary across all failing
dependency checks (e.g. not ready: db_ping (timeout), worker:foo
(stalled)). Multiple reasons are joined with , because that
separator is safe inside HAProxy / ALB log fields without quoting
and reads cleanly in tail-the-log debugging. No embedded newlines
mid-reason — LB access logs don't deal gracefully with multi-line
fields.
The new endpoints are aliases sitting alongside the JSON ones —
they don't replace them. Operator dashboards and /admin/operations
still consume the structured shape; the text aliases are purely
for the LB / probe surface where the structured detail is wasted
bytes.
Implementation:
handler.HealthzLive() http.HandlerFunc— no pool argument. Cheap enough that it makes sense to install it on the "always" path even when readiness is also wired.handler.HealthzReady(pool *pgxpool.Pool) http.HandlerFunc— reusesreadyzCheckTimeout(2s) and the same SnapshotAllStats / boot-grace logic as/readyz, so the readiness verdict is consistent across both endpoints.- Routes registered in
cmd/server/routes.goimmediately below the existing/healthzand/readyzlines.
Tests (internal/handler/health_test.go):
TestHealthzLive_Always200PlainTextpins the tight contract: 200,text/plain; charset=utf-8, body exactlyOK\n.TestHealthzReady_NilPoolFlipsNotReadyconfirms 503 + body shape on the db_ping failure branch + the single-newline body invariant (no multi-line reasons).TestHealthzReady_NoWorkersBodyShapepins the reason concatenation under,separator.TestHealthzReady_StaleTickBodyShapeconfirms a stalled worker is named in the body so an operator reading the LB log can identify the failing dependency without falling back to/readyzJSON.
Operator runbook updated (docs/user/operators/monitoring.md)
with the four-endpoint table and the format / purpose split.
v2.50.0.4 — 2026-05-29¶
Audit dashboard date-range filter.
v2.50.0.1 added company / actor / source filters; v2.50.0.3 pushed
those filters into the per-table Postgres queries. v2.50.0.4 adds
the fifth and sixth dimensions: ?from=<bound> and ?to=<bound>
on /admin/audit (and /admin/audit.csv and the row-sort links),
also pushed into Postgres next to the existing predicates.
Half-open interval — from is inclusive, to is exclusive. This
matches how operators naturally think about ranges: "events
through 2026-05-29" means to=2026-05-30 without an off-by-a-day
fudge. The exclusive upper bound also avoids the "do I include
23:59:59 or not" question entirely.
Date parsing accepts two shapes per the operator runbook expectations:
YYYY-MM-DD— treated as UTC midnight. Lines up with<input type="date">which is what the dashboard's filter form emits.RFC3339(2026-05-29T14:30:00Z) — full timestamp for sub-day precision when an operator is investigating an incident window.
Malformed input is silently ignored (no 400). Same posture as
v2.50.0.1's company / actor parser: the page must always render
even when an operator hand-edits the URL.
Query-layer wiring:
query.AuditFeedFiltergainsFrom *time.Time/To *time.Time.- New helper
auditFilterDateRange(createdAtExpr, filter, n)renders the half-open WHERE fragment with placeholder slots starting atn. Picks up the existingauditFilterSecretAndPluginflow for secret_audit_events / plugin_approval_events; each of the four other helpers (egress, a2a, pairings, proposals) gets a direct call to the same helper. - The proposals helper targets
COALESCE(decided_at, created_at)for the date filter so what the operator filters by matches what the dashboard displays.
Handler-layer wiring:
handler.auditFeedFiltergainsFrom,To, plusFromRaw/ToRawto round-trip the operator-supplied string back through the form's<input value="…">and the sort-link / CSV-link composers without normalizing2026-05-29→2026-05-29T00:00:00Z.parseAuditDateBoundis the date parser — public-ish enough to test directly. Empty / malformed → nil; valid →*time.Time.filterAuditFeed's in-memory defensive pass picks up the half-open semantic (the SQL pushdown is authoritative; the in-memory pass guards against future refactors dropping a dimension).filterQueryStringandbuildSortLinksre-emitfrom/toso sort + filter compose without losing operator state.
Template (internal/web/templates/audit/index.html):
- Two
<input type="date">widgets ("From (inclusive)", "To (exclusive)") inline with the existing company / actor / source inputs. - Active-filter pill row reads
from=…andto=…when set.
Tests:
parseAuditDateBoundcovers date-only, RFC3339, empty, whitespace, garbage, and "looks like date but isn't" (2026-13-99) — all malformed paths fall through to nil so the dashboard renders unchanged.filterAuditFeedhalf-open coverage: rows atfromsurvive, rows attoare excluded.- Nil-bound unbounded behaviour on each side independently.
filterQueryStringandbuildSortLinkspreserve from/to.auditFilterDateRangeSQL builder: empty no-op, from-only, to-only, both. Placeholder advancement checked.auditFilterSecretAndPluginplaceholder ordering pinned at$2,$3,$4,$5(company, actor, from, to) so a future refactor can't accidentally collide arg slots.- End-to-end through the auth gate with date-range URLs.
v2.51.0.1 — 2026-05-29¶
Backup file encryption at rest.
v2.51.0.0 shipped staple-cli backup / restore as thin
pg_dump / pg_restore wrappers. The dump file landed on disk as
plaintext custom-format — every users.email, agents.system_prompt,
notes.body row sitting in /var/backups in cleartext. An attacker
with read access to the backup directory bypassed the entire
Vault story.
v2.51.0.1 closes that gap by piping pg_dump's stdout through a streaming AES-256-GCM envelope before the bytes hit disk. The matching reader unwraps on restore.
File format ("STAPLE_BACKUP_v1") — ASCII header + length-prefixed chunked GCM body:
STAPLE_BACKUP_v1
dek_id=<active-dek-uuid>
wrapped_fk=<base64 of AES-256-GCM(DEK, file_key)>
nonce_prefix=<base64 of 8-byte random nonce_prefix>
[4-byte BE length][GCM(file_key, plaintext_chunk_0, nonce=prefix||0x00000000)]
[4-byte BE length][GCM(file_key, plaintext_chunk_1, nonce=prefix||0x00000001)]
…
[4 bytes of zero] <-- EOF marker
Per-backup file_key is generated fresh, wrapped by the active
DEK, and discarded after the writer closes. That gives us forward
secrecy if the DEK rotates after the dump was written: the
encrypting Vault's KEK + DEK are required to recover the
file_key. KEK rotation rewraps every DEK in place, so the dek_id
embedded in the header continues to resolve and restore keeps
working.
Chunked GCM keeps memory bounded for multi-GB dumps — 1 MiB plaintext chunks, ~1 MiB + 16-byte GCM tag overhead per chunk in flight on either side. The 4-byte chunk index in the nonce gives ~4 GiB of plaintext per backup before nonces collide; way past anything pg_dump would ever emit.
The reader rejects three failure modes loudly rather than silently returning partial plaintext to pg_restore:
- Bit-flip: GCM auth-tag failure on the affected chunk.
- Truncation: missing zero-length EOF marker →
ErrBackupTruncated. pg_restore on a partial dump would happily replay a partial CTE stream and leave the destination DB in an inconsistent state, so we refuse to proceed. - Unknown DEK: the dek_id named in the header isn't in the
running Vault's registry →
ErrDEKNotFound. Operator either has the wrong KEK or has dropped the DEK row from the database; both are configuration errors that need attention.
CLI surface changes:
-
staple-cli backupnow requiresSTAPLE_ENCRYPTION_KEYby default. Output extension changes from.dump→.dump.enc. The success summary gains anencryption: enc:v2 (dek=<id>)line so the operator can confirm at a glance that the file is protected. -
--no-encryptescape hatch: writes a v2.51.0.0-style plaintext custom-format dump and emits three loud stderr WARNING lines per invocation. Mostly for emergency operator recovery when the KEK is lost and the only path forward is a plaintext snapshot. -
staple-cli restoreauto-detects the file format by sniffing the first 17 bytes. Encrypted dumps stream throughVault.NewBackupReaderintopg_restore's stdin; plaintext v2.51.0.0 dumps continue to restore unchanged. Encrypted files requireSTAPLE_ENCRYPTION_KEYset to the same value the backup was written under.
New module:
internal/crypto/backup_stream.go—Vault.NewBackupWriter,Vault.NewBackupReader, the chunk-nonce builder, the header reader, and the publicBackupMagicconstant consumed bycmd/staple-cli/restore.gofor the sniff path.
Tests added (internal/crypto/backup_stream_test.go):
- Round-trip 5 MiB random buffer with awkward 7919-byte writes that bridge chunk boundaries
- Tamper detection on a single bit-flip in the body
- Truncation detection (missing EOF marker →
ErrBackupTruncated) - Mid-chunk truncation rejection
- Bad magic →
ErrBackupBadMagicfor the restore-falls-back-to-plaintext path - Unknown DEK ID →
ErrDEKNotFound - Header flood rejection (4 KiB cap on the header read)
- Empty plaintext still round-trips cleanly (header + EOF marker only)
The dump file's permission mask is 0o600 so an umask on the
operator's host can't accidentally widen access at create time.
v2.43.0.4 — 2026-05-29¶
/await-subagents-recursive verb: BFS-walk the descendant
subtree in one shot.
The v2.43.0.1 /await-subagents verb dispatches only the parent's
DIRECT children. When a depth-1 child has its own subagents
(depth-2 grandchildren), the operator had to wait for the depth-1
child to itself emit /await-subagents on its own children before
those grandchildren could tick. For a 3-deep subtree that's
operationally cumbersome: spawn → await → wait for children's turn
→ children emit /await-subagents → wait for grandchildren.
v2.43.0.4 ships a sibling verb that walks the parent's full descendant graph and dispatches every active descendant in one shot:
-
/await-subagents-recursive(kebab) //await_subagents_recursive(underscore alias) — no arguments, no body. Parses toAgentAction{Kind: "await_subagents_recursive"}. The parser keeps the existing/await-subagentsexact-match (no prefix match), so both verbs can appear in the same reply and each lands as its own action. -
query.ListDescendantAgents(ctx, pool, companyID, rootAgentID)— recursive CTE rooted atrootAgentID. The CTE traversesparent_agent_idjoins, restricted tostatus='active'at every level. Ordering:(subagent_depth ASC, name ASC)so the dispatch marker reads naturally — depth-1 children first, then their children, then theirs. Runs throughscope.WithTenantScopeso RLS is still in effect. The root agent itself is NOT included in the result; the parent is the chat'scurrent_agent, not its own descendant. -
handleChatAwaitSubagentsRecursiveininternal/handler/chat_actions.go— modeled on the v2.43.0.1 flat handler. Differences: - calls
ListDescendantAgentsinstead ofListChildAgents - the rate-limit check is
recent + len(descendants) > limit, sharing the existingSTAPLE_MAX_SUBAGENT_RUNS_PER_MINUTEbudget. A 50-node subtree counts as 50. - the marker copy says "subtree" / "descendants" rather than "subagents" / "children" so an operator reading the chat transcript can tell which verb the agent emitted.
- the wait phase, digest formatter, and rate-limit refusal marker reuse the existing helpers — they're agnostic to whether the dispatched runs came from children or descendants.
The depth bound at spawn time (STAPLE_MAX_SUBAGENT_DEPTH,
default 3) still caps how DEEP the chain can grow; the recursive
verb caps how WIDE that bounded subtree can fan out per minute.
Vocabulary block in internal/engine/chat_context.go updated to
document the new verb so the LLM knows it exists. The block stays
under the 10KB token-budget cap pinned by
TestRenderChatContext_TokenBudgetSanity.
Tests added:
- Parser (
internal/engine/actions_await_subagents_test.go): TestParseAgentActions_AwaitSubagentsRecursive_HappyPathconfirms the kebab form parses to one action withKind="await_subagents_recursive"and the verb is stripped from the clean body.TestParseAgentActions_AwaitSubagentsRecursive_UnderscoreVariantcovers the alias.-
TestParseAgentActions_AwaitSubagentsRecursive_DistinctFromFlatis the load-bearing guard: when both verbs appear in the same reply, the parser must emit one of each rather than collapsing them. The kebab form/await-subagentsis a prefix of/await-subagents-recursive, so a naive prefix-match would silently collapse both lines into the flat kind. Exact-match on the verb token keeps them distinct. -
Handler (
internal/handler/chat_actions_test.go): TestProcessChatAgentReply_AwaitSubagentsRecursive_NoAgentpins the nil-agent marker.TestProcessChatAgentReply_AwaitSubagentsRecursive_NoDescendantscovers the empty-tree branch.TestProcessChatAgentReply_AwaitSubagentsRecursive_DispatchesSubtreeis the happy-path runtime test: builds root → 2 children → 2 grandchildren under child-a (4 total descendants), emits/await-subagents-recursive, verifies the dispatch marker counts 4 subtree runs, the marker uses "subtree" copy (not "children"), and each descendant has exactly one heartbeat_run. Parent itself stays at zero runs.TestProcessChatAgentReply_AwaitSubagentsRecursive_RateLimitExceededpins the shared fan-out budget — 2 descendants + limit=1 triggers the refusal marker with "descendants" copy and theSTAPLE_MAX_SUBAGENT_RUNS_PER_MINUTEenv var name surfaced. Zero heartbeat_runs land.
No schema changes; no new migrations.
v2.50.0.3 — 2026-05-29¶
Audit dashboard: filter pushdown into Postgres.
The consolidated audit dashboard at /admin/audit (v2.50.0.0)
introduced an in-memory filter step (v2.50.0.1): it pulled 50 rows
from each of six audit tables, merged-sorted to 200, then applied
the operator's ?company, ?actor, ?source filters in Go before
rendering. The pulls happened unconditionally — filtering to
?source=plugin_approval_events still queried the other five
tables, and a tight ?company=acme still read every company's rows
from every table before discarding most of them.
v2.50.0.3 pushes the filter dimensions down into the per-table SQL:
-
AuditFeedFilterininternal/db/query/audit_aggregate.go— shared filter struct mirroring the handler'sauditFeedFilter. Company is a substring ILIKE anchored to the column start (planner-friendly on indexed UUID prefixes); Actor is an exact case-insensitive match against the actor user's email, joined throughusers.id; Source is one of the six known audit table names. -
Per-table
Filteredvariants — six new helpers (ListAllRecentSecretAuditEventsFiltered,ListAllRecentPluginApprovalEventsFiltered,ListAllRecentEgressAnomalyEventsFiltered,ListAllRecentA2ADispatchEventsFiltered,ListAllRecentPairingAuditEventsFiltered,ListAllRecentAgentPromptProposalsFiltered) accept the new filter and fold its dimensions into the WHERE clause. The unfiltered v2.50.0.0 helpers stay in place for back-compat (no call site outside the dashboard currently uses them, but removing exported helpers is a needless breaking change).
For the two system-attributed tables (egress_anomaly_events,
a2a_dispatch_events) a non-empty Actor filter short-circuits to
zero rows — system events can never match a user email, matching
the v2.50.0.1 in-memory behaviour. For pairing, the actor lives
on a nullable actor_user_id; the Actor branch joins INNER to
the users table to honour that semantic. For
agent_prompt_proposals, the only column carrying a human actor
is rejected_by_user_id, so Actor narrows the feed to rejected
rows whose decider's email matches.
-
CollectAuditFeed(ctx, pool, limit, filter)— top-level entry point that owns the source-table selection. Whenfilter.Sourcematches one of the six known table names, only that table is queried; the other five are skipped entirely. The decision rule is factored intoAuditFeedTablesForFilterso the v2.50.0.3TestAuditFeedTablesForFiltertest can verify the source-pushdown without standing up a database. An unknown Source (typo, fuzzer probe) falls through to "query every table" — a hand-edited URL can't silently drop every row from the feed. -
Handler delegation —
internal/handler/ui_audit.go'scollectAuditFeedis now a thin projection layer overquery.CollectAuditFeed. The in-memoryfilterAuditFeedstep stays as a defensive second pass — Postgres is now authoritative, but a future SQL refactor that accidentally drops a dimension won't leak rows into the UI. The default behaviour (no filter set) is byte-identical to v2.50.0.2.
preFilter (the "showing N of M" counter on the HTML page) now
reflects the post-cap row count that survived the Postgres-level
filter rather than the cap-without-filter total. That's a more
honest number — it tells the operator how many rows actually
matched, not how many we'd have shown without their filter.
No schema changes; no new migrations.
v2.42.0.4 — 2026-05-29¶
staple-cli chat REPL: up/down-arrow history navigation.
The interactive REPL at staple-cli chat <agent> used
bufio.NewReader.ReadString('\n') for input — which has zero
in-line editing. Pressing the up arrow inside the REPL produced raw
ANSI escape sequences that got POSTed to the agent as part of the
prompt, and there was no way to recall a previous message short of
re-typing it.
v2.42.0.4 wires github.com/peterh/liner behind a small
chatPromptReader abstraction:
- TTY path (
linerPromptReader). WhenchatLoopis invoked withos.StdinANDgolang.org/x/term.IsTerminal(...)returns true, the loop drives aliner.State. Up/Down arrows recall previous prompts in the session; Ctrl-A / Ctrl-E / left-right arrows move the cursor; Ctrl-C aborts the current line without killing the session. - Non-TTY path (
bufioPromptReader). When stdin is redirected from a file or a pipe — or when the unit tests pass astrings.NewReaderdirectly — the loop falls back to the v2.42.0.0bufio.NewReadershape. Existing tests stay green and scripted use ("echo 'hi' | staple-cli chat ...") still works.
History is in-memory only. Chat prompts can carry credentials,
half-typed commands, and private context; persisting a
$HOME/.staple_chat_history file would be a privacy surprise. The
session-scoped buffer also means the next staple-cli chat
invocation starts with a clean slate.
The history-policy split — empty input + the four local /-commands
(/quit, /exit, /help, /clear) never enter the history — is
factored into shouldRecordToHistory and pinned by a dedicated
table-driven test (TestShouldRecordToHistory) so a future verb
addition can't silently leak local commands into the recall buffer.
Updated staple-cli help shows the new behaviour under the chat
entry.
v2.51.0.0 — 2026-05-29¶
staple-cli backup + restore foundation.
The platform now persists 120+ tables of operationally-meaningful state: companies, agents, users, issues, heartbeat_runs, secrets, plugins, audit trails, GEPA proposals, MCP catalogues, A2A candidates, evolutionary checkpoints, and the rest of the moving parts that make a Staple instance an actual deployed system. The documented backup story until now was "run pg_dump yourself" — which assumed every operator already knew the right flags, and left no built-in sanity-check that the dump actually captured what they thought it did.
v2.51.0.0 ships the foundation for first-class backup tooling: a
thin, opinionated wrapper around pg_dump / pg_restore with a
matching post-action row-count summary on a curated short list of
tables.
staple-cli backup [--output=path]wrapspg_dump --format=custom --no-owner --no-aclinto a single command. Default output:./staple-backup-<YYYYMMDDTHHMMSS>.dump, so a scheduled cron job produces a clean, ordered file list without any clobbering. RequiresDATABASE_URL+pg_dumpinPATH; a missing client binary surfaces with a "install postgresql-client" hint rather than a generic exec error.
On success, prints the file path, byte size, and a curated row-count summary:
Backup complete:
file: ./staple-backup-20260529T144632.dump
size: 4892173 bytes
Row counts:
companies: 7 rows
agents: 23 rows
users: 41 rows
issues: 188 rows
heartbeat_runs: 5412 rows
Operators eyeball the counts to confirm the dump captured the
expected dataset shape. Same summary list is reused by
restore so the two operations stay comparable.
staple-cli restore <backup-file> --yeswrapspg_restore --clean --if-exists --no-owner --no-aclto replay a custom- format dump intoDATABASE_URL. DESTRUCTIVE —--cleandrops every existing object before replaying — so we require an explicit--yesflag. Runningstaple-cli restore <file>by itself refuses to proceed with "restore is destructive — pass --yes to proceed".
Operator runbook (also documented in cmd/staple-cli/restore.go
header comments):
- Stand up a fresh Postgres locally:
docker run -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16 - Restore into it:
DATABASE_URL=postgres://postgres:test@localhost:5432/postgres staple-cli restore ./staple-backup-...dump --yes - Compare the post-restore summary row counts against the backup file's summary. Mismatches mean either the dump truncated mid-stream or the source DB shifted between dump and restore — investigate before promoting.
- Once the staging cycle is clean, run against the real
target. Take the platform offline first —
pg_restore --cleantakes the database through a "no tables exist" window where the live service can't read anything.
Same post-action row-count summary on the curated
companies / agents / users / issues / heartbeat_runs list,
so a side-by-side compare against the backup output gives an
instant sanity check.
Implementation notes:
- New
cmd/staple-cli/backup.gohousesrunBackupplus the sharedprintRowCountSummaryhelper (and the curatedbackupSummaryTableslist). - New
cmd/staple-cli/restore.gohousesrunRestore. Re-usesprintRowCountSummaryso backup and restore can't drift. - Both subcommands wired into
cmd/staple-cli/main.gonext to the existing dispatcher arms; help-line + Examples block updated. - Tests cover the argument-shape paths only — exercising the
actual
pg_dump/pg_restoreround-trip needs the postgres- client binaries + a live Postgres, which isn't reliable in CI. The runbook covers end-to-end testing. What we DO pin:backup_test.go: usage error on extra positionals, DATABASE_URL gate, unknown-flag handling, the curatedbackupSummaryTableslist.restore_test.go: usage error on missing or surplus positionals, --yes safety gate (the destructive op MUST refuse without it), DATABASE_URL gate, nonexistent- backup-file behaviour, and the safety gate fires even when an actual file exists (the gate precedes any I/O on the dump).
This is a FOUNDATION — v2.51.0.1+ will add:
- Optional encryption at rest for the dump file (re-using the
existing STAPLE_ENCRYPTION_KEY infrastructure).
- S3 / B2 upload integration so backups land off-host
automatically.
- A scheduled-backup worker that runs the wrapper on the same
cadence as the existing background workers.
v2.50.0.2 — 2026-05-29¶
Audit dashboard CSV export + column-sort.
v2.50.0.0 / v2.50.0.1 shipped the consolidated audit dashboard and filtering. Operators feeding the data into their own tooling (spreadsheets, SIEMs, per-team review docs) still had to copy rows out of the HTML by hand — and the merged feed always landed in chronological order, with no way to slice by source table or actor for review.
v2.50.0.2 closes both gaps:
-
CSV export at
GET /admin/audit.csv. Inherits the page's filter query string so the export round-trips: click a filter, click "Download CSV", get exactly the same rows your screen shows. Header row:created_at, source_table, action, actor, company_id, summary, detail_url. Timestamps emit as RFC 3339 UTC. Filename includes a UTC timestamp (staple-audit-20260529T144632.csv) so multiple downloads inside one investigation don't overwrite each other in the operator's downloads folder. Instance-admin only, same gate as the HTML page. -
Column-sortable headers via
?sort=<col>&order=<asc|desc>. Allowed columns:created_at(default) |source|action|actor. Sort applies AFTER the filter step so "Alice's pairings, oldest first" composes naturally. Click semantics: clicking the current column toggles its order; clicking any other column resets todesc(most useful default for time- series feeds). Each header carries a↑/↓indicator showing the active state. Sort links inherit the active filter so toggling sort never drops a filter.
Implementation notes:
- New
loadAndFilterAuditFeedhelper ininternal/handler/ui_audit.gois the shared load → merge → cap → filter → sort pipeline. BothUIAuditDashboard(HTML) andUIAuditDashboardCSV(export) call into it so the two endpoints can't drift on filter semantics. - New
auditFeedSort+parseAuditFeedSort+applyAuditFeedSorttrio in the same file. Unknown columns / orders collapse to the defaults rather than 400 — the page must always render cleanly even when an operator hand-edits the URL. - New
filterQueryString+buildSortLinkshelpers prepare the per-header sort hrefs and the CSV-download href so the template stays declarative (no URL composition in templates). - Route
GET /admin/audit.csvwired up next to the HTML route incmd/server/routes.go. - Tests in
internal/handler/ui_audit_test.gocover the auth gate on the CSV endpoint, the four-column allow-list, the default-fallback parsing for unknown sort directives, an actor-asc sort against the fixture (expected sequence pinned exactly), a source-desc sort (top + bottom + monotonic check), the no-op default sort, filter-preservation on sort-link generation, and the active-column toggle behaviour. - Operators consuming the CSV: every column is plain UTF-8 text;
no encoding tricks. Pipe through
awk -F, '$2=="secret_audit_events"'for ad-hoc filtering, or load straight into Sheets / Excel.
v2.46.0.5 — 2026-05-29¶
Real Myers diff for the GEPA proposal view.
v2.46.0.4 shipped the per-proposal detail page at
/agents/{id}/proposals/{pid} with a deliberately naive line-set
comparison: each side flagged lines that didn't appear in the
OTHER side's content. That implementation had a real limitation —
a line that MOVED (present on both sides but at a different
position) appeared as unchanged on both sides because the set-
membership test only cared whether the text existed somewhere on
the other side. Operators reviewing a refactor proposal would
see no diff signal even though the structure had shifted.
v2.46.0.5 replaces the set comparison with a true Myers diff:
-
Moved lines now render as edits. A line lifted from the bottom of the prompt to the top correctly emits a
Removefrom the old position plus anAddat the new position, matching what the LLM proposer actually rewrote. The v2.46.0.4 visual ("the columns look identical, but the proposed template clearly isn't") is gone. -
Column alignment is preserved. The template still renders two columns (current on the left, proposed on the right). The Myers edit script is projected into both columns so every
Keeplands on both, everyRemovelands on the left + a blank placeholder on the right, everyAddlands on the right -
a blank placeholder on the left. The two columns therefore always have matching length, and the eye-line of an unchanged line in the middle of an edit run stays aligned across the gap.
-
Trailing whitespace is still ignored. The comparison key is
TrimRight(line, " \t")before the LCS table is built, so a proposer that added trailing spaces doesn't fire a "real" edit signal. The displayed text retains the proposer's exact bytes so operators see what was actually written.
Implementation notes:
- New
internal/handler/diff.goholds the algorithm:DiffOp,DiffLine, themyersDiff(a, b)core, and the publiccomputePromptDiff(current, proposed)wrapper. O(N×M) time and space — prompts are typically under 500 lines so the cost is irrelevant in practice (one call per page load). internal/handler/ui_agent_proposal_show.gokeepscomputeProposalDiffreturning the two-column shape the template expects; the function body becomes a thin projection over the Myers edit script.- New
internal/handler/diff_test.gocovers the algorithm-level cases: identical input, single add, single remove, the moved- line case (the bug v2.46.0.4 had), common prefix + suffix + middle edit, empty-on-either-side, both empty, and the trim-trailing-whitespace policy. internal/handler/ui_agent_proposal_show_test.goupdates the diff-projection tests to assert the new column-aligned shape withblankplaceholders.- Template
internal/web/templates/agents/proposal_show.htmlloops over the left column only (the two are guaranteed equal length) and rendersblankcells with the surface-2 background so the visual flow makes the edit pattern obvious.
v2.50.0.1 — 2026-05-28¶
Audit dashboard filtering by company / actor / source table.
v2.50.0.0 shipped a single chronological feed at /admin/audit
merging the six surfaced audit tables. The roadmap noted filtering
as the next slice — a busy operator investigating "what did Alice
do this afternoon?" still had to eyeball-scan a 200-row table.
v2.50.0.1 adds three optional query-string filters:
-
?company=<id-substring>— substring match againstCompanyID. UUID prefixes (e.g.?company=11111111) and full ids both work. Loose by design: pasting from the per-company page URL bar lands on a working filter. -
?actor=<email>— exact, case-insensitive match against the resolved actor. Use the literal stringsystemfor worker- attributed rows,(deleted)for rows whose FK target is gone. Substring matches are deliberately NOT supported here — "alice" alone wouldn't disambiguate alice@a vs alice@b, so we require the full address. -
?source=<table>— exact source table match. Dropdown in the filter form is bound tosurfacedAuditTables()so the list stays in sync if v2.50.0.2 adds a seventh table.
All three filters AND together. The filter form sits at the top of the page with a Filter button and a "Clear filters" link that appears once any filter is active. Above the events table the heading reads "Recent events (N of M)" when filtered so operators see at a glance how much was hidden.
Implementation notes:
- New
auditFeedFilterstruct +filterAuditFeedhelper apply the dimensions in memory after the merge-sort. At most 6 × 50 = 300 rows go through the filter — well below the threshold where pushing predicates intoListAllRecent*would pay back the complexity. v2.50.0.2+ may push them down onceauditAggregateLimitPerTablegrows. - Template gains the filter form, the "(N of M)" indicator, an active-filter summary line, and a filter-aware empty state ("No rows match the current filters" with a "Clear filters" link).
- Tests in
internal/handler/ui_audit_test.gocover the active() matrix, single-dimension filters (source / actor / company), the AND-combined case, the no-match shape, and the auth-gate posture for filtered URLs.
v2.45.0.2 — 2026-05-28¶
staple-cli user-model operator commands.
The v2.45.0.1 UI exposes the per-user profile editor at
/companies/{id}/user-models/{userId} for interactive inspection
and editing. For automation, scripting, and recovery, operators
needed CLI access — the only existing knob was raw psql against
the user_models table, which bypasses the same validation the
UI applies.
v2.45.0.2 ships staple-cli user-model with three subcommands:
-
staple-cli user-model show <email>prints the currentenabledflag, body length, last-updated timestamp, and the fullprofile_markdownbetween two---markers. Sparse rows (nouser_modelsrow exists for the user yet) reportno user_model row (default disabled)and exit 0. -
staple-cli user-model set <email> < input.mdreads stdin until EOF and replaces the profile body.enabledis NOT touched — matchesquery.UpsertUserModel's contract; operators who want to enable a freshly-set profile flip the bit via the UI or psql. -
staple-cli user-model clear <email>wipes the body and force-disables in two writes. Idempotent — re-running on an already-cleared profile succeeds.
Email-to-user resolution reuses lookupUserForPairing — same
alphabetically-first company-membership lookup the pair-code
command uses. Multi-company users land on their first company
deterministically; --company selection is deferred to a
follow-up.
Implementation notes:
- New
cmd/staple-cli/user_model.gohousesrunUserModelplus the three small per-subcommand helpers (userModelShow,userModelSet,userModelClear). Each helper takes the resolved(email, companyID, userID)triple so the dispatch shape stays parallel. - Argument validation (subcommand name, email presence) runs
BEFORE the DATABASE_URL check so shape errors surface without
any DB dependency. Tests in
cmd/staple-cli/user_model_test.goexercise the matrix. - main.go registers the dispatch case alongside the existing CLI surface, with usage + an example for each of show/set/clear.
v2.43.0.3 — 2026-05-28¶
Subagent fan-out rate limit for /await-subagents.
The existing depth bound (STAPLE_MAX_SUBAGENT_DEPTH, default 3)
caps how DEEP a parent → child chain can grow at spawn time, and
the per-parent bound (STAPLE_MAX_SUBAGENTS_PER_PARENT, default 5)
caps the direct fan-out from any one parent. Neither caps how WIDE
the recursive dispatch becomes over time when subagents fire
/await-subagents again and again across the chain.
v2.43.0.3 adds a trailing-60s, system-wide subagent run budget:
-
New env var
STAPLE_MAX_SUBAGENT_RUNS_PER_MINUTE(default 50). Countsheartbeat_runsrows joined toagentswhereparent_agent_id IS NOT NULLandcreated_at > now() - 60s. Cross-tenant by design — a runaway in company A shouldn't be able to drain the budget for the whole instance. -
Handler check — before
/await-subagentsdispatches, it consultsengine.CheckSubagentRunRate. If `recent + len(children)limit`, the handler refuses, emits a marker explaining the refusal (with the env-var name surfaced), and skips the dispatch loop. No heartbeat runs are created.
-
Soft-fail on DB error — a transient blip reading the count logs at warn level and falls through to dispatch. The rate limit is a safety net, not a correctness gate.
-
Operator-discoverable — the new env var appears in
/admin/operationsalongside the other subagent limits, with the env value, effective value, and "(default 50/min — env-only)" description.
Implementation notes:
- New
internal/engine/subagent_limits.gocarriesDefaultMaxSubagentRunsPerMinute = 50,MaxSubagentRunsPerMinute()(env reader with fallback on every invalid form), andCheckSubagentRunRate(ctx, pool). - The aggregate query joins
heartbeat_runstoagentsonagent_idand filters byparent_agent_id IS NOT NULL. The 60s window is fixed; operators tune the count, not the window. - Handler test in
internal/handler/chat_actions_test.goforces the limit to 1 viat.Setenv, spawns two children, and asserts the refusal marker fires without creating anyheartbeat_runs. - Engine test in
internal/engine/subagent_limits_test.gocovers the env-reader fallback matrix and a baseline real-DB call.
v2.50.0.0 — 2026-05-28¶
Consolidated audit dashboard at /admin/audit.
We've shipped six audit tables across this release cycle:
secret_audit_events, plugin_approval_events,
egress_anomaly_events, a2a_dispatch_events,
pairing_audit_events, agent_prompt_proposals. Each has its own
per-company page. Operators investigating an instance-wide issue
(was that secret reveal followed by an egress denial? did GEPA
propose anything around the same time?) had to know which page
covered which table and switch companies one at a time.
v2.50.0.0 adds a unified read-only feed:
-
GET
/admin/auditrenders a single chronological table merging the most recent 50 rows from each surfaced table. Each row carries the source table name, action, actor (resolved to the actor's email, or "system" for worker-issued rows, or "(deleted)" when the FK target is gone), short company id, one-line summary, and a back-link to the per-table detail page where the actions live. -
Instance-admin only — the cross-tenant read surface is intentional. The dashboard uses
scope.WithBypassfor the aggregate reads. -
The feed is read-only — Accept / Reject / Approve actions still happen on the per-table pages linked from each row. v2.50.0.1+ will add filtering by company / actor / source table.
Per-table errors are non-fatal: if one table read fails (slow DB, permissions, schema drift) the dashboard still renders the other five. The CHANGELOG explicitly lists which tables are surfaced so operators know what's covered.
Sidebar nav gains an Audit entry between Operations and Settings in both the with-active-company and global instance-admin layouts.
Implementation notes:
- New
internal/db/query/audit_aggregate.gohouses the six cross-tenantListAllRecent<Table>helpers plusResolveActorEmailandShortID. Each helper clamps the per-page limit to a sane range (default 50, max 200) so the merged read stays bounded. - New
internal/handler/ui_audit.goprojects each table's rows into a commonauditFeedItemshape, merge-sorts bycreated_at, and caps the rendered list at 200 entries.
v2.46.0.4 — 2026-05-28¶
GEPA proposal diff view: side-by-side at /agents/{id}/proposals/{id}.
Closes the v2.46.0.3 deferral. Until v2.46.0.4 the only operator-visible
surface for pending GEPA proposals was a one-line summary in
agents/show.html with inline Accept / Reject buttons. To actually
see what the proposal changes, operators had to expand a <details>
blob containing the full proposed template and mentally diff it
against the current prompt. That worked for trivial revisions but
broke down the moment the LLM proposer rewrote multiple paragraphs.
v2.46.0.4 adds a per-proposal detail page:
-
GET
/agents/{agentId}/proposals/{proposalId}renders a side-by-side line comparison of the current prompt vs. the proposed template. Lines on the left that aren't in the proposed set get a red highlight ("removed"); lines on the right that aren't in the current set get a green highlight ("added"). Instance-admin only. -
The agent show page's "Proposed prompt revisions" section gets a new "View side-by-side diff →" link per proposal, sitting next to the existing Accept / Reject controls.
-
The detail page surfaces the evidence-run rows the worker considered when drafting the proposal — each linkable to its run transcript so operators can verify the failure pattern the proposer claims to have observed.
-
Accept / Reject forms on the detail page hit the SAME existing POST endpoints as the show.html inline forms; no new write-paths.
Diff strategy: v2.46.0.4 ships a deliberately naive line-set diff (splits both sides into lines, marks each line "added"/"removed" when it doesn't appear in the other side's set). Moved lines appear unchanged on both sides. This is not a true Myers diff — a real sequence-aligned diff lands in v2.46.0.5+ — but for prompt-review purposes it catches the cases operators actually care about (a rewritten paragraph; a new instruction block) without depending on an external diff package.
Trailing whitespace per line is trimmed before comparison so a proposer that added/removed only whitespace doesn't produce a false signal.
v2.49.0.1 — 2026-05-28¶
True 2× staleness check in /readyz; TickStats carries its interval.
Closes the v2.49.0.0 deferral. Until v2.49.0.1 the readiness probe's worker check was deliberately lax: "any worker that has never ticked" was surfaced as a non-blocking signal so a fresh boot wouldn't cause orchestrators to refuse traffic. That left a real failure mode — "the worker did tick once but then got stuck three days ago" — invisible to the LB.
v2.49.0.1 adds the missing piece: the worker's configured tick cadence travels with the snapshot, so /readyz can compute "this worker is overdue by 2× its expected interval" without needing the handler to know each worker's intervals.
Changes:
-
worker.TickStatscarries aninterval time.Durationfield set at construction.NewTickStats(name, interval)is the new signature; every existing worker is migrated to pass its tick interval. -
TickStatsSnapshot.Intervalis the JSON-friendly view of that field, surfaced through/metricsas a newstaple_worker_interval_seconds{worker=…}gauge so Prometheus alerts can compute their own "overdue" expressions. -
/readyzdecomposes the worker check into one row per worker, namedworker:<name>. Per-worker policy:Ticks == 0 AND uptime < 2× interval→ OK (boot grace)Ticks == 0 AND uptime >= 2× interval→ not_ready ("stuck on boot")Ticks > 0 AND tick-age > 2× interval→ not_ready ("worker stalled mid-flight")- Otherwise → OK (no row added; operators care about exceptions)
The aggregate workers row stays for legacy dashboards; its OK flag
flips to false if any per-worker row is not_ready. The probe returns
503 when any worker is stale.
-
version.ProcessStart()is a new helper exposing the wall-clock time the process began. /readyz consults it to compute uptime for the boot-grace window. -
/admin/operationsworker stats table gets two new columns: Interval (the configured cadence) and Health (a green/yellow/red badge — ok / overdue / stale — using the same thresholds as /readyz).
Behavioral note: the staleness threshold uses a 1-hour fallback for any worker that registered without declaring an interval; this should be nobody now, but the fallback prevents a misconfigured worker from silently passing forever.
v2.49.0.0 — 2026-05-28¶
Health endpoints for orchestrator probes.
Operator visibility win. Until v2.49.0.0 the only health probe Staple
exposed was /api/health — a single endpoint that pinged the DB and
returned 200/503. That mixed concerns: an orchestrator deciding
whether to RESTART a wedged container wants a different signal from
a load balancer deciding whether to ROUTE NEW TRAFFIC to an instance.
v2.49.0.0 splits them into the conventional pair:
GET /healthz— liveness probe. Returns 200 unconditionally as long as the HTTP server is responding. No DB or worker checks. Use this for kubelet liveness probes, AWS ALB target health, systemd watchdog ping. A failure here means the process is wedged and should be killed.
Wire shape:
GET /readyz— readiness probe with dependency checks. Returns 200 when every blocking check passed, 503 + per-check details otherwise. Use this for kubelet readiness probes and LB upstream health checks. A failure here means "alive but not ready" — the instance should stay running but stop receiving NEW requests.
Checks (v2.49.0.0):
-
db_ping —
pool.Ping(ctx)succeeds within 2 seconds. Blocking: a failure flips overall Status to "not_ready" and returns 503. -
workers — at least one worker is registered. Workers that haven't ticked yet are SURFACED in the response but NOT blocking (a fresh boot can legitimately have workers that haven't run for the first time; flipping Status would thrash the orchestrator). v2.49.0.1 will tighten this to "every worker has ticked within 2× its interval" once
TickStats.Intervallands. An empty registry IS blocking (treated as a configuration bug).
Wire shape:
{
"status": "ready",
"version": "v2.49.0.0",
"commit": "abc1234",
"checks": [
{"name":"db_ping","ok":true},
{"name":"workers","ok":true,"detail":"6 worker(s) registered, all have ticked at least once"}
]
}
Auth¶
Both endpoints are UNAUTHENTICATED — orchestrator probes don't carry
credentials. The payloads expose only version/commit (also visible
in every server log and at /admin/operations). Rate-limited by the
shared public-group limiter so a misconfigured probe loop can't
hammer the process.
Backward compatibility¶
The legacy /api/health endpoint (DB ping only) is preserved.
Existing monitoring that points at it continues to work; new probes
should prefer /healthz and /readyz.
Migration count after deploy: 119 (no schema change)¶
v2.46.0.3 — 2026-05-28¶
GEPA proposer through providerrouter + per-agent provider override.
v2.46.0.2 introduced the LLM-driven GEPA proposer but routed every
company through whichever provider family providerrouter.For
happened to pick first (label order, all enabled rows). v2.48.0.0 made
the router Anthropic-capable; v2.46.0.3 makes the GEPA worker
expressively multi-family:
-
providerrouter.ForFamily(ctx, pool, key, companyID, family)— new entry point that returns the first enabled provider row whoseadapter_typematches the requested family (openai/openai_compatible/anthropic). ReturnsErrUnknownProviderFamilyfor typos andErrNoProviderConfiguredwhen the family is valid but no enabled row of that family exists for the company. -
agents.gepa_provider_overridecolumn (migration 119) — per- agent override. NULL → use the company-defaultFor. Non-NULL → useForFamily(*override). When override resolution fails (typo, no enabled row of that family), the worker logs a Warn and falls back to the company-default provider for that agent so a stale override can never stall the sweep. -
Observability — the per-tick "proposal created" log now emits a
provider=anthropic|openai|heuristicfield parsed from the proposal row'smodelcolumn (which v2.48.0.0 already stamped as<provider-family>:<model>for LLM proposals). Operators can grep the journal forprovider=anthropicto compare proposal quality across families. -
Per-agent settings UI —
/agents/{agentId}Properties card gains a "GEPA provider override" dropdown alongside the existing auto-promote toggle. Three options: None (company default), openai, anthropic. POSTs to a new/agents/{agentId}/ui/set-gepa-provider-overrideroute.
Cache shape¶
The worker's per-tick provider cache moved from map[companyID] to
map[(companyID, family)] so multiple agents in the same company
using different overrides each amortize their lookups while still
sharing a fresh family resolution.
Backward compatibility¶
NULL is the default — the v2.46.0.2 posture (router picks first enabled row in label order) stays the safe out-of-the-box behavior. Operators opt in per-agent via the UI dropdown.
Migration count after deploy: 119¶
v2.42.0.3 — 2026-05-28¶
True per-token chat SSE streaming.
v2.42.0.2 introduced POST /api/chats/{chatId}/messages/stream with
the SSE wire shape (event: chunk / event: done / event: error)
but, to keep the scope tight, buffered the whole reply via
engine.ChatComplete and emitted ONE chunk event with the full text.
The wire format was ready for per-token streaming; the engine wasn't.
v2.42.0.3 switches the handler to engine.ChatStream, which has
shipped a per-token streaming API since v2.16.3 (the browser chat UI
has used it via UIChatSendStream since then). The engine's
StreamingProvider interface is natively implemented for OpenAI
(SSE data: {choices: [{delta: ...}]} parsing) and Anthropic
(content_block_delta events). For provider families that don't
implement StreamingProvider, ChatStream transparently falls back
to a synthesized single-delta-then-done stream — identical wire
format, no CLI changes needed.
Wire format (unchanged)¶
Same three event types as v2.42.0.2:
event: chunk
data: {"text":"..."}
event: done
data: {"messageId":"...","totalTokens":N,"promptTokens":N,"completionTokens":N}
event: error
data: {"error":"..."}
The CLI consumer already loops over chunk events, so existing clients see no behavior change beyond "chunks now arrive progressively instead of all at once."
Behavior by provider¶
- OpenAI / OpenAI-compatible (Ollama / vLLM / Together) — true
per-token streaming. Each
data: {choices: [{delta: {content: "..."}}]}SSE frame from the provider becomes oneevent: chunkon Staple's outbound stream. - Anthropic — true per-token streaming. Each
content_block_deltaevent withdelta.type == "text_delta"becomes one chunk. - Other providers (Perplexity wraps OpenAI; Bedrock falls through
to non-streaming
ChatCompletion) — single chunk + done. Same wire shape, same CLI parsing.
Bookkeeping parity¶
The handler still persists the agent message, runs
recordChatCost + writeChatTrace after the stream drains. The
engine's ChatStream wraps the provider channel and fills
Provider / Model / CostUSD on the terminal chunk, so the
post-stream ChatCompleteResult reconstructed by the handler is
identical in shape to what the buffered path used to produce.
Migration count after deploy: 118 (no schema change)¶
v2.48.0.0 — 2026-05-28¶
Provider router abstraction (Anthropic completion support).
Workers previously called adapters.ComputeOpenAI* free functions
directly — that worked only for openai_compatible providers. A
company configured with anthropic was silently skipped by every
context-compression / GEPA / embedding worker. v2.48.0.0 introduces a
tiny providerrouter package that abstracts the per-family
completion + embedding shape, and migrates all three workers onto it.
Scope¶
- New
internal/providerrouterpackage with aProviderinterface exposingComplete(ctx, msg)+Embed(ctx, text)+Name(). providerrouter.For(ctx, pool, encryptionKey, companyID)is the entry point. Returns the right family-specific implementation based on the company_providers row'sadapter_type(first enabled row in label order).openaiProviderwrapsadapters.ComputeOpenAICompletion/ComputeOpenAIEmbedding— no behavior change for openai shops.anthropicProviderissues a direct POST to/v1/messagesand returns the concatenated text content. Anthropic doesn't expose an embedding endpoint, soEmbedreturnsproviderrouter.ErrEmbeddingUnsupported. The embedding worker recognizes this sentinel and logs "skipping: provider doesn't support embeddings" once per company per tick — no regression for non-Anthropic shops.
Workers migrated¶
note_embedding— usesprovider.Embed. Anthropic companies are cleanly skipped via the sentinel error.context_compression— usesprovider.Complete. Companies with an Anthropic provider now get compression summaries.gepa_evaluator(LLM proposer path) — usesprovider.Complete. Same per-family expansion.
Per-company family selection¶
Different companies on the same Staple instance can use different
provider families without env-flag churn. Switching a company's
provider family is just toggling rows in the per-company
/companies/{id}/providers UI — no new flags, no restart.
Not migrated¶
- The GEPA
resolveCompletionProviderhelper was removed; callers go through the router. - Heuristic-only GEPA proposals continue to work without a provider
(the LLM-proposer path is gated on
STAPLE_GEPA_EVAL_LLM_ENABLED).
Migration count after deploy: 118 (no schema change)¶
v2.47.0.2 — 2026-05-28¶
Runtime env-flag toggling UI + override table.
Operators previously flipped feature flags by editing the service unit
env file and running systemctl restart staple. v2.47.0.2 surfaces a
Set/Revert form on /admin/operations per toggleable flag, persists
overrides to instance_flags, and routes worker flag reads through a
3-stage resolver so flips take effect on the next tick — no restart.
Scope¶
- New
instance_flagstable (instance-wide, no RLS): operator-set, persistent. Schema is(name, value, updated_at, updated_by_user_id, description). Mirror of the env-flag list the Operations panel knows about. - New resolver helpers
worker.ResolveFlagBool/worker.ResolveFlagStringwith precedence:instance_flagsrow > env var > caller-supplied default. Workers call these on every tick instead ofos.Getenv. - Workers migrated to the resolver:
note_embedding,context_compression(enabled flag + threshold),gepa_evaluator(enabled flag + LLM proposer flag). Each carries the new behavior with no observable change when no instance_flags row exists. /admin/operationspage now shows EffectiveValue + an "overridden" badge + a Set/Revert form per toggleable flag. Read-only flags (egress DNS bind addr, etc.) render their env value without a form.POST /admin/operations/flags/{name}— instance-admin-only. Allowlist-gated against the toggleable subset so operators can't pollute the table with rows the runtime won't consult. Empty value deletes the row (revert to env). Non-empty value upserts.
Migration count after deploy: 118¶
118_instance_flags.sql— newinstance_flagstable.
v2.40.0.3 — 2026-05-28¶
Bridge external MCP tools into the typed-tools registry.
Closes the v2.40 arc. v2.40.0.2 sync worker persisted external MCP server reachability + tool count. v2.40.0.3 plumbs the full ToolDescriptor list through to the per-company typed-tools registry so agents can actually invoke remote tools using the engine's existing tool dispatch.
Scope¶
- New column
mcp_servers.last_tools_jsoncarries the JSON-encoded[]mcp.ToolDescriptorfrom the most recent successful sync. - Sync worker now writes the full descriptor list (not just the count)
via
query.SetMCPServerSyncResultWithTools. - New typed-tool implementation
typedtools.NewRemoteMCPToolwraps a remote tool. Naming convention is/mcp:<server-name>:<remote-name>so two external servers can advertise the same name without collision. engine.typedToolsFordecodes each enabled company's MCP server rows and registers oneRemoteMCPToolper descriptor. Per-server decode / register failures are logged + skipped — one bad server must not poison the whole registry.- New
engine.InvalidateTypedToolsCache(companyID)lets the sync worker drop the per-company cache after each successful tick so newly advertised remote tools surface within one sync interval instead of requiring a process restart.
Transitive MCP bridge¶
Because the v2.40.0.0 /mcp/{companyId} server endpoint enumerates
the same typed-tools registry, registering an external server's tools
also exposes them through Staple's own MCP server. External MCP
clients hitting /mcp/{companyId} see remote tools alongside
built-ins — a free transitive bridge. No new code; documented here so
operators know.
Migration count after deploy: 117¶
117_mcp_servers_last_tools_json.sql—ALTER TABLEaddslast_tools_json TEXT NOT NULL DEFAULT '[]'.
v2.40.0.2 — 2026-05-28¶
MCP client direction (foundation).
v2.40.0.0 + v2.40.0.1 shipped Staple as MCP server (external
clients hit /mcp/{companyId} to discover Staple's tools / resources /
prompts). v2.40.0.2 inverts the direction — Staple AS MCP client
of external servers.
Scope¶
- Register external MCP servers per company.
- Periodic sync worker runs
initialize+tools/listagainst each enabled server and records the count + error string in the registration row. - Admin UI for register / enable-toggle / edit / delete.
Deferred to v2.40.0.3+¶
- Exposing remote tools through the typed-tools registry so agents can invoke them. v2.40.0.2 ships the wire protocol + sync write path; the consumption path is the next slice.
Migration count after deploy: 116¶
115_mcp_servers.sql— newmcp_serverstable.116_rls_mcp_servers.sql— RLS policy mirroring the 091/094/096/100/104/107/109/111/113 pattern.
Schema (mcp_servers)¶
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | |
| company_id | uuid FK | Companies(id) ON DELETE CASCADE |
| name | text | Unique per company |
| url | text | External server's HTTP endpoint |
| auth_bearer | text NULL | Encrypted via crypto.Encrypt; NULL = no auth |
| enabled | bool | Default FALSE — operator opt-in |
| last_synced_at | timestamptz | NULL until the first successful sync |
| last_tool_count | int | Count returned by last tools/list |
| last_error | text NULL | Free-form error from most recent failed sync |
internal/db/query/mcp_servers.go (new)¶
MCPServerstruct +mcpServerColumns+scanMCPServerhelper.CreateMCPServer,GetMCPServer,ListMCPServersForCompany,UpdateMCPServer,DeleteMCPServer— tenant-scoped CRUD.SetMCPServerSyncResult(ctx, pool, id, syncedAt, toolCount, syncErr)— the sync worker's write path. Usesscope.WithBypassbecause the worker walksListEnabledMCPServersacross all companies; the per-row update is keyed by the server's own id (already company- scoped via FK).ListEnabledMCPServers(ctx, pool)—WithBypasssweep across all enabled rows.
internal/mcp/client.go (new)¶
Clientstruct withWithHTTPClientswap for tests.NewClient(url, authBearer)— sets up a 30s-timeout HTTP client.Initialize(ctx)— performs the MCP initialize handshake.ListTools(ctx)— callstools/list.CallTool(ctx, name, args)— callstools/call(scaffolded for v2.40.0.3+ consumption).request(ctx, method, params)— shared JSON-RPC transport with 10 MiB response cap + structured error wrapping. AtomicnextIDso concurrent goroutines never collide.
internal/worker/mcp_sync.go (new)¶
MCPSyncworker. Constructed incmd/server/main.gowith the instance encryption key; registers aTickStatsnamedmcp_syncso the v2.47.0.0 Operations page and the v2.47.0.1 Prometheus endpoint surface its activity.- 5-minute tick interval. Per tick:
ListEnabledMCPServers(instance-wide viaWithBypass).- For each: decrypt bearer → build
Client→Initialize→ListTools→ write count tolast_synced_at/last_tool_count. - On any failure: write the error string to
last_errorand continue.
internal/handler/ui_mcp_servers.go (new)¶
UIMCPServersList(pool)—GET /companies/{companyId}/mcp-servers.UIMCPServerCreate(pool, encryptionKey)—POST .../mcp-servers/ui/create.UIMCPServerUpdate(pool, encryptionKey)—POST .../mcp-servers/{id}/ui/update.UIMCPServerToggle(pool)—POST .../mcp-servers/{id}/ui/toggle.UIMCPServerDelete(pool)—POST .../mcp-servers/{id}/ui/delete.- Every endpoint gates on
actor.IsInstanceAdmin(). Bearer tokens are encrypted with the instance encryption key before write (SEC-010 refuse-on-missing-key contract).
cmd/server/main.go¶
- Wires
worker.NewMCPSync(pool, cfg.EncryptionKey)after the GEPA evaluator and calls itsStartgoroutine.
cmd/server/routes.go¶
- Five new routes under the instance-admin scope.
internal/web/templates/mcp_servers/index.html (new)¶
- Three sections: about / register form / registered servers list.
- Per-row enable toggle, delete (with confirm), sync status badges (last sync timestamp + tool count + error).
internal/web/templates/layout.html¶
- New "MCP servers" entry inserted after Providers in the with-active-company nav block (instance-admin only).
internal/handler/render.go¶
activePageFromTemplatemapsmcp_servers/*→"mcp_servers".
Tests¶
internal/db/query/mcp_servers_test.go:TestMCPServers_CRUD— full lifecycle includingSetMCPServerSyncResultsuccess + error paths (skips on no DB).TestMCPServers_ValidationErrors— table-driven cover of every "required" guard.internal/mcp/client_test.go:TestClient_Initialize_RoundTrip— fixture httptest server, asserts bearer header + decoded result.TestClient_NoBearer_NoAuthHeader— empty bearer → no header.TestClient_ListTools_Decodes— round-trips a 2-tool fixture with inputSchema intact.TestClient_ListTools_NilSlice— empty response decodes to a non-nil slice.TestClient_CallTool_RoundTrip— tools/call happy path.TestClient_RPCError_Surfaces— server returnsJSONRPCErrorenvelope → client surfaces as Go error with code + message.TestClient_HTTPError— non-2xx surfaces as error with status code.internal/handler/ui_mcp_servers_test.go:TestUIMCPServersList_NonAdminForbidden— 403 for nil actor, non-admin member, api-key actor.TestUIMCPServerToggle_NonAdminForbidden— representative mutation handler also gates correctly.
v2.47.0.1 — 2026-05-28¶
Prometheus /metrics endpoint.
Exposes the worker.TickStats registry plus pgxpool stats as
Prometheus text format at GET /metrics. No new dependencies — the
exposition format is hand-rolled so the metrics surface adds zero
import-graph weight.
internal/handler/metrics.go (new)¶
MetricsHandler(pool) http.HandlerFunc—GET /metrics. Method- restricted (405 on POST and friends). SetsContent-Type: text/plain; version=0.0.4.- Metric families:
staple_worker_ticks_total(counter, per-worker label) — total tick invocations.staple_worker_errors_total(counter, per-worker label) — total error-path invocations.staple_worker_last_tick_seconds(gauge, per-worker label) — unix epoch seconds of the most recent tick. Zero for workers that have never ticked.staple_info(gauge, constant 1, labels: version, commit) — standard Prometheus build-info pattern. Dashboardsjoinagainst this to annotate panels with the running version.staple_db_pool_total_connections(gauge) —pool.Stat().TotalConns().staple_db_pool_idle_connections(gauge) —pool.Stat().IdleConns().staple_db_pool_acquire_count_total(counter) —pool.Stat().AcquireCount().
Auth posture¶
No auth. Metric scraping in operator environments is gated by
network policy (firewall / private VPC). When that's not acceptable
the operator puts a reverse proxy with auth in front. This matches
the convention every Prometheus-compatible Go service ships with,
and matches the unauthenticated posture of /api/health already on
this group.
The endpoint is mounted on the existing public-routes group, so it
inherits the same rate limiter as /api/health — a misconfigured
scraper cannot hammer the process.
Sample scrape config¶
cmd/server/routes.go¶
- New route
GET /metricsunder the existing public-routes group (RateLimitmiddleware, no auth).
Tests¶
internal/handler/metrics_test.go:TestMetricsHandler_GETShape— happy path, asserts all six# HELP/# TYPElines + Content-Type + at least one worker tick sample + the staple_info build-info gauge.TestMetricsHandler_NonGETIs405— POST / PUT / PATCH / DELETE all 405.TestMetricsHandler_NilPoolIsSafe— nil-pool path skips the pgxpool stats section without panicking.
v2.47.0.0 — 2026-05-28¶
Operations control panel.
Instance-admin-only page at /admin/operations that surfaces every
background worker's runtime state and the operational env-flag
toggle status in a single view. Read-only in v2.47.0.0; runtime
flag editing is deferred to v2.47.0.2+.
This release is the foundation for a Prometheus /metrics endpoint
(v2.47.0.1) and for runtime worker controls (v2.47.0.2+). Both layer
on the worker.TickStats registry introduced here.
internal/worker/metrics.go (new)¶
TickStatsstruct — per-worker counters with atomic fields:ticks(counter) — total tick invocations.errors(counter) — total error-path invocations.lastTickAt(gauge) — wall-clock time of the most recent tick.lastTickMode(string) — short tag mirroring the env-flag state on the last tick:active,live,shell,disabled,error, oridle(initial).lastTickAction(string) — one-line description of what the tick did ("compressed 3 agents", "scanned 0 notes").NewTickStats(name string) *TickStats— constructor.RecordTick(mode, action string)— call at the end of each tick.RecordError()— call on every failure path; additive to the tick counter.Snapshot() TickStatsSnapshot— JSON-friendly point-in-time view.- Package-level
registry(sync.Mutex-guarded map) +RegisterStats/SnapshotAllStats— workers register at construction; the Ops page and the future Prometheus endpoint iterate snapshots sorted by name. ResetStatsRegistryForTest()— test-only helper for deterministic starts.
Worker instrumentation¶
All five v2.36+ background workers now register a TickStats on
construction and call RecordTick / RecordError at every tick:
pairing_cleanup(1h tick) — reportsremoved N codes, revoked M identities.notes_retention(6h tick) — reportsdeleted N auto_extracted, M agent_authored.note_embedding(60s tick) — modesshell/live; action reflects the per-tick scan + embed counts.context_compression(5m tick) — modesdisabled/active; action carries the candidate / compressed / skipped / failed split.gepa_evaluator(6h tick) — modesdisabled/heuristic/llm; action reports proposals + auto-promoted counts.
Existing slog.Info calls are unchanged; the TickStats updates are
additive observability.
internal/handler/ui_operations.go (new)¶
UIOperationsShow(pool)—GET /admin/operations. Instance-admin only (actor.IsUser() && actor.IsInstanceAdmin()). Renders the registered worker snapshots, the operational env-flag values, and a server-info banner (version / commit / build date).operationalEnvFlags()— canonical list of the env flags shown on the page, with descriptions. Values are re-read fromos.Getenvon every request. Surfaced flags:STAPLE_EMBEDDING_ENABLEDSTAPLE_CONTEXT_COMPRESSION_ENABLEDSTAPLE_CONTEXT_COMPRESSION_THRESHOLD_TOKENSSTAPLE_GEPA_EVAL_ENABLEDSTAPLE_GEPA_EVAL_LLM_ENABLEDSTAPLE_EGRESS_DNS_ADDRSTAPLE_EGRESS_DNS_DEFAULTSTAPLE_LOCAL_ADAPTERSSTAPLE_MAX_SUBAGENT_DEPTHSTAPLE_MAX_SUBAGENTS_PER_PARENTSTAPLE_STALE_RUN_THRESHOLD_SECSTAPLE_STALE_RUN_GRACE_SECSTAPLE_STALE_RUN_CANCEL_SEC
internal/web/templates/operations/index.html (new)¶
Three sections: server info, worker tick table, env-flag table.
Matches the visual language of company_egress/index.html (data-
table style, badge-keyed mode column).
cmd/server/routes.go¶
- New route
GET /admin/operationsunder the existingRequireInstanceAdmin()group.
internal/web/templates/layout.html¶
- New "Operations" entry in both instance-admin sidebar blocks (with-active-company and no-active-company), positioned after Remote Workers / Egress.
internal/handler/render.go¶
activePageFromTemplatemapsoperations/*→"operations"for the sidebar active-page highlight.
Tests¶
internal/worker/metrics_test.go:TestTickStats_RecordAndSnapshot— covers initial state + single-tick + error counter.TestTickStats_ConcurrentRace— 8 writers × 4 readers × 500 iters under-raceto verify lock-free atomics.TestRegistry_RegisterAndSnapshot— verifies sort order and inclusion of zero-tick entries.TestRegistry_NilRegisterIsSafe— defensive.internal/handler/ui_operations_test.go:TestUIOperationsShow_NonAdminForbidden— 403 for nil actor, non-admin user, and api-key actor (no DB required).TestUIOperationsShow_AdminRenders— happy path, asserts the test marker worker name + two env flag names appear in the rendered HTML (skips on no DB).
v2.42.0.2 — 2026-05-28¶
staple-cli chat SSE streaming.
Closes the v2.42.0.1 scope-down on the staple chat REPL. v2.42.0.0
shipped the foundation (UUID-only target + sync POST); v2.42.0.1 added
friendly-name resolution. v2.42.0.2 closes the remaining gap by adding
a bearer-friendly SSE streaming endpoint and a CLI consumer that
prints reply text as it arrives.
The UI-side streaming (UIChatSendStream) is cookie + CSRF + form-
shaped, which doesn't compose with a bearer-token CLI. This release
ships a new sibling endpoint at POST /api/chats/{chatId}/messages/
stream that reuses the engine's ChatComplete via the same shared
helpers as the sync POST, then emits the result as SSE events on the
wire.
SCOPE-DOWN (v2.42.0.2)¶
The engine's ChatComplete returns the whole reply in one shot — it
calls the provider's chat-completions endpoint and buffers the full
response. v2.42.0.2 doesn't change that. The streaming endpoint
emits ONE event: chunk with the buffered text, then event: done.
The CLI consumer prints the chunk text as it arrives; with a single-
chunk emission this is functionally equivalent to a buffered print,
but the wire format is consistent for v2.42.0.3+ to layer in true
token-by-token streaming once the engine exposes a streaming-friendly
entry point.
internal/handler/chats.go¶
SendChatMessageStream(pool, hub, eng) http.HandlerFunc— the new SSE endpoint. Same input validation, auth, and chat-actor checks asSendChatMessage. SetsContent-Type: text/event-stream+Cache-Control: no-cache+X-Accel-Buffering: no(disables Nginx's stream buffer), flushes the preamble, then emits:event: chunk\ndata: {"text":"..."}for each reply chunk (v2.42.0.2: always exactly one chunk).event: done\ndata: {"messageId":"...", "totalTokens":..., "promptTokens":..., "completionTokens":...}on success.event: error\ndata: {"error":"..."}for any failure surfaced AFTER the SSE preamble (provider unavailable, LLM call failed, agent missing). Pre-preamble failures (bad JSON, empty content, 404/403) still use the WriteJSON path so the HTTP status code carries the error.- The user message + agent message persistence +
recordChatCost+writeChatTracecalls remain identical toSendChatMessage— this is intentional so the chat audit trail looks the same regardless of which endpoint the operator hit.
cmd/server/routes.go¶
- New
POST /api/chats/{chatId}/messages/streamroute under the existing chat-scoped group. Auth + RLS posture identical to the sibling sync POST.
cmd/staple-cli/chat.go¶
chatSendMessage(ctx, baseURL, apiKey, chatID, message, out)now takes anio.Writerso the streamed text can land directly on stdout (or a test buffer). Returns the accumulated reply for callers that want to inspect it.chatSendMessageStreamis the new SSE consumer. Parses theevent:/data:line-pair shape, callsflusher.Flushvia the HTTP client's normal read loop, writeschunk.texttooutas it arrives, returns ondone, surfaceserrorevents as Go errors. SSE comments (:keepalive) are ignored.errStreamEndpointMissingsentinel — when the server returns 404 on the SSE POST, the CLI falls back to the v2.42.0.1 sync POST so older deploys still work. The fallback writes tooutitself so the caller's print path stays uniform.chatSendMessageSyncis the v2.42.0.1 sync POST path, preserved verbatim for the fallback branch.chatLoop(REPL body): no signature change. The chunk text arrives viaoutduring the call so the loop just prints trailing newlines afterchatSendMessagereturns.
Test coverage¶
internal/handler/chats_test.go:TestSendChatMessageStream_BadJSON/_EmptyContent— input validation still 400s before the SSE preamble.TestSendChatMessageStream_NoAgentEmitsErrorEvent— the no-agent-attached branch returns 200 with an SSE error event (since headers have already flushed by the time we know), rather than a JSON 4xx.cmd/staple-cli/chat_test.go:TestChatSendMessage_StreamingHappyPath— full SSE round-trip with one chunk + done; reply written toout.TestChatSendMessage_StreamingMultipleChunks— multiple chunk events concatenate correctly. Prepares for v2.42.0.3+'s per-token emission.TestChatSendMessage_StreamingErrorEvent— SSE error event surfaces as a Go error.- Existing tests updated to mock the
/stream404 path so they exercise the sync fallback explicitly.
Operator-facing knobs¶
None added. The streaming endpoint is on by default; CLI auto- detects via the 404 fallback so the operator never has to flip a flag.
Scope notes¶
- True token streaming requires the engine's
ChatCompleteto expose a streaming-friendly entry point that yields tokens as the provider produces them. The current implementation buffers the whole reply (so it can run cost/trace bookkeeping on the full text). v2.42.0.3+ either layers aChatStreamengine method, or wraps the existing call in a streaming HTTP read of the provider's own SSE response. - The CLI's sync-fallback ensures backward compatibility with v2.42.0.1 servers, but a v2.42.0.2 client against a v2.42.0.2 server always takes the streaming path.
Out-of-scope deferrals (v2.42.0.3+)¶
- Per-token (true) streaming. The wire format is ready; the engine entry point is the next layer.
--continue <chat-id>history navigation.- Multi-chat session list / picker.
v2.46.0.2 — 2026-05-28¶
LLM-driven GEPA proposer + per-agent auto-promote opt-in.
Closes the v2.46.0.1 scope-down on the GEPA auto-evaluate worker. v2.46.0.1 shipped the deterministic-heuristic proposer (counts failed runs, appends a hard-coded GEPA note) with the LLM flag reserved and the auto-promote path manual-gated. v2.46.0.2 wires both:
- The LLM-driven proposer asks the company's openai_compatible
provider to revise the agent's system prompt based on recent run
summaries. Engaged via
STAPLE_GEPA_EVAL_LLM_ENABLED=true; falls back to the heuristic on any LLM failure so a flaky provider can never stall the sweep. - The per-agent auto-promote opt-in lets an operator turn on
agents.gepa_auto_promotefor a single canary agent. When TRUE, the worker callsAcceptProposalwith a NULL actor (system attribution) immediately after the proposal lands, bypassing the manual review step. Default FALSE so v2.46.0.1 posture stays the safe out-of-the-box behavior.
internal/db/migrations/114_agents_gepa_auto_promote.sql¶
ALTER TABLE agents ADD COLUMN gepa_auto_promote BOOLEAN NOT NULL DEFAULT FALSE. Default FALSE so existing agents retain the v2.46.0.1 manual-gated behavior. Column comment documents the semantic + the instance-level dependency onSTAPLE_GEPA_EVAL_ENABLED.
internal/db/query/agents.go¶
Agent.GepaAutoPromoteadded;agentColumnsand the correspondingscanAgentrow scan grow by one trailing column.ListAgentsWithLastRun's hand-rolled column list + scan updated to includegepa_auto_promoteso the agent-list page sees the flag.UpdateAgentParams.GepaAutoPromote *bool+ the matching SET clause so the UI toggle persists through the standard PATCH path.
internal/worker/gepa_evaluator.go¶
GEPAEvaluator.encryptionKey []bytefield — needed to decrypt the per-companycompany_providers.api_keywhen the LLM proposer path is engaged. nil-safe: nil key just keeps the LLM path off regardless of the env flag.NewGEPAEvaluator(pool, encryptionKey)is the new signature.cmd/server/main.gopassescfg.EncryptionKey.runOncenow resolves a per-companyopenai_compatibleprovider (cached per tick likenote_embedding/context_compression) and passes it toevaluateAgent.evaluateAgent(ctx, agent, provider)selects between the LLM and heuristic paths:- When
isGEPALLMEnabled() && provider != nil, callsproposeWithLLMfirst. Uses the returned text as theproposed_templateand stampsmodelwith the provider's model name. - On any LLM failure (network, auth, empty body), logs a Warn and
falls back to the heuristic appendix — the proposal still
lands, stamped with the new
llm-fallback-to-heuristic-v1sentinel so operators can tell what happened. proposeWithLLMbuilds a single-turn user message containing the current prompt + recent non-empty run summaries and asks the provider for a clean revised prompt body. Callsadapters.ComputeOpenAICompletiondirectly (same as the context-compression worker).- Auto-promote integration in
runOnce: when a proposal is created AND the agent'sgepa_auto_promote=TRUE, the worker immediately callsAcceptProposal(ctx, pool, companyID, proposalID, ""). The empty userID lights up the existing "system actor" path inAcceptProposal(the version row'screated_by_user_idstays NULL). Failures log + continue — the proposal still exists for manual acceptance later. currentGEPAMode()renders"heuristic+llm-proposer"when both flags are on so an operator scanning journalctl sees the proposer is engaged rather than just observed.
internal/handler/ui_agents.go¶
UIAgentSetGepaAutoPromote(pool)is the per-agent toggle handler. MirrorsUIAgentSetAutoExtractFindings: parsesvalueas a boolean, callsUpdateAgent, redirects viaHX-Redirect.
cmd/server/routes.go¶
- New
POST /agents/{agentId}/ui/set-gepa-auto-promoteroute under the existing auth group, sibling of the auto-extract-findings toggle.
internal/web/templates/agents/show.html¶
- New Properties-card row "Auto-promote GEPA proposals" with the
enabled/disabled badge + Enable/Disable button + the operator
copy explaining the
STAPLE_GEPA_EVAL_ENABLEDdependency.
internal/db/query/agent_prompt_proposals_test.go¶
- The existing
TestProposal_DB_AcceptPathalready exercises the empty-userID path; v2.46.0.2 adds an explicit assertion thatv.CreatedByUserID == nilso the system-actor contract is guarded against regression.
internal/worker/gepa_evaluator_test.go¶
TestGEPAEvaluator_NewGEPAEvaluatorAcceptsEncryptionKey/_NilEncryptionKeycover the new constructor signature.TestGEPACurrentModeupdated for the new"heuristic+llm-proposer"mode string.
Operator-facing knobs¶
STAPLE_GEPA_EVAL_LLM_ENABLED— flip to engage the LLM-driven proposer. Default off so the v2.46.0.1 heuristic-only behavior stays the safe baseline.agents.gepa_auto_promote— per-agent flag (default FALSE) that lets the worker auto-accept proposals. Flip via the agent show page's Enable button.
Scope notes¶
- The LLM proposer reuses
adapters.ComputeOpenAICompletionfor the same reasons context_compression does: anthropic / perplexity support lands once the router abstraction is in place. Anthropic Claude is the most common Staple provider; this is the most impactful gap to close in a v2.46.0.3+ release. - Auto-promote runs at WORKER tick time, not on proposal-insert. So
flipping the column back to FALSE leaves already-promoted versions
in place — operators wanting a rollback can use
/restore-prompt-version. - The
proposeWithLLMprompt asks the model to return ONLY the revised body. A model that ignores the directive and wraps the output in markdown will still land as the new prompt; v2.46.0.3+ could add a strip pass for code-fence preambles if the operator signal warrants it.
Out-of-scope deferrals (v2.46.0.3+)¶
- Anthropic / Perplexity-backed LLM proposer (currently openai_compatible-only).
- Output-shape guards on the LLM response (code-fence stripping, preamble detection).
- Auto-promote rate limiting per company (today the worker promotes every proposal for every opted-in agent on every tick).
- A multi-proposal queue (v2.46.0.1's one-pending-per-agent rule still applies; auto-promote ships per-tick).
v2.43.0.2 — 2026-05-28¶
/await-subagents synchronous result digest.
Closes the v2.43.0.1 scope-down on the subagent runtime layer. v2.43.0.1
shipped the dispatch path: the parent emits /await-subagents, the
handler enumerates active direct children and enqueues one heartbeat
run per child. But the parent had no way to see the children's output
in the same chat turn — it would have to reference each child agent's
page on its next turn to find out what they did. v2.43.0.2 closes that
loop: after dispatching, the handler waits (bounded) for each child run
to land, then synthesizes a Markdown digest of every result and
appends it as a user-role chat message so the parent LLM picks it up
on the next turn without operator intervention.
internal/db/query/heartbeat.go¶
HeartbeatRunSummary— narrow projection holding the digest-relevant fields per run (id, agent_id, status, summary, finished_at, error).GetHeartbeatRunSummaryByID(ctx, pool, runID)— single-row SELECT scoped to the polling needs of the await path. MirrorsGetHeartbeatRunByID's nil-on-not-found, error-on-DB-failure contract so callers can poll cheaply during the wait phase.
internal/handler/chat_actions.go¶
awaitSubagentsTimeout()resolves the wall-clock budget fromSTAPLE_AWAIT_SUBAGENTS_TIMEOUT(seconds; default 60). Empty, non-numeric, zero, or negative values all fall through to the default — operators can never accidentally pin the budget to 0.awaitSubagentsPollIntervalis a constant 500ms. Heartbeat workers writeheartbeat_runs.statusdirectly; a 500ms poll is fast enough to surface state changes within a second of landing without burning the DB.awaitSubagentsSummaryCap(800 chars) caps each child's preview in the digest. Long summaries get truncated with a...suffix; the parent can pull the full text from the child's agent page.formatAwaitSubagentsDigest([]awaitSubagentResult)renders the per-child digest the parent's next turn ingests. Heading + one subsection per child with status, summary preview (or error message, or "(no summary recorded)").waitForSubagentRuns(ctx, pool, runs, budget)polls each dispatched run's status until every run reaches a terminal state ("completed" / "failed") or the budget expires. Returns whatever projections it gathered; callers map missing-or-still-in-flight runs to "timed_out" in the digest.toAwaitResults(runs, summaries)flattens the polled state into the digest-shaped slice. Runs the polling never saw (worker pool hadn't picked them up yet, transient DB error) get rendered as timed_out with an explanatory error message.handleChatAwaitSubagents(extended): after the existing dispatch loop appends its dispatch marker, the handler invokes the wait phase and — when the digest is non-empty — appends auser-role message carrying the digest. The dispatch marker now mentions the wait budget so operators reading the chat transcript see the expected ceiling.- Test coverage:
TestAwaitSubagentsTimeout_DefaultWhenEnvMissing/_RespectsEnv/_RejectsGarbage— env-parsing rules.TestFormatAwaitSubagentsDigest_EmptyReturnsEmpty/_RendersSections/_TruncatesLongSummary— formatter rules.TestToAwaitResults_TimesOutMissing/_MapsTerminalStatus/_FlattensInflightAsTimedOut— projection rules including the in-flight-at-deadline boundary.TestProcessChatAgentReply_AwaitSubagents_DispatchesChildrenupdated to assert the new two-message shape (dispatch marker + digest), with a 1s timeout viaSTAPLE_AWAIT_SUBAGENTS_TIMEOUTso the unit test doesn't block on the 60s default.
Operator-facing knobs¶
STAPLE_AWAIT_SUBAGENTS_TIMEOUT— seconds the/await-subagentshandler waits for child runs to land before timing out individual children. Default 60. Reset on every call so flips take effect without restart.
Scope notes¶
- The wait phase polls
heartbeat_runsdirectly rather than introducing an in-process notification channel. The heartbeat workers already update the same table; the polling stays cheap (one SELECT-by-PK per child per 500ms tick) and keeps the runtime surface area minimal. - Children stalled past the budget show up as
timed_outin the digest. The chat reply NEVER blocks past the budget — the formatter always renders an entry per dispatched child even when the projection map is empty. - The digest is emitted as a
userrole message rather than asystemmarker so it becomes the LLM's literal next input. The v2.43.0.1 dispatch marker stays assystemfor operator-readable bookkeeping.
Out-of-scope deferrals (v2.43.0.3+)¶
- Streaming child-output delivery. v2.43.0.2 waits for each child to finish before rendering. A streaming variant could append partial per-child digests as each child completes, but the current shape is one digest per await invocation and operators reading along get a clean atomic transcript.
- In-process notification fabric. The polling loop is cheap enough for the v2.43.x scope; switching to LISTEN/NOTIFY would be a separate refactor across every worker that writes heartbeat_runs.status.
v2.44.0.2 — 2026-05-28¶
Live LLM summarization in the context-compression worker.
Closes the v2.44.0.0 context-compression slice. v2.44.0.0 shipped the
schema (migrations 106 + 107), the query helpers, the chat-context
splice, and a worker shell that LOGGED candidates without compressing
them. v2.44.0.2 wires the actual provider-side summarization call —
when STAPLE_CONTEXT_COMPRESSION_ENABLED=true is set, candidate
agents now produce real summary rows.
Adapter¶
adapters.ComputeOpenAICompletion(ctx, baseURL, apiKey, model, userMessage)
is a new free function that POSTs a single user message to
{baseURL}/chat/completions and returns the assistant's response.
Mirrors ComputeOpenAIEmbedding in shape — separate http.Client with
a 120s timeout, 8MB response cap, status/body surfaced on non-2xx.
Default model is gpt-4o-mini; empty baseURL falls back to
https://api.openai.com/v1.
Worker¶
worker.NewContextCompression now takes a second argument
(encryptionKey []byte) so the worker can decrypt the per-company
provider api_key. cmd/server/main.go passes cfg.EncryptionKey.
When a candidate is identified, compressAgent:
- Re-fetches the recent runs from
ListRecentRunsForAgent. - Builds a Markdown transcript (newest first) from the run summaries.
- Wraps it in the structured summarization request (5-10 bullet
points; use
#decision / #finding / #attempted / #failed / #escalation / #working / #blockedtags — same vocabulary the note auto-extraction worker uses). - Calls
ComputeOpenAICompletionagainst the first enabledopenai_compatibleprovider for the agent's company. - Writes the resulting row via
InsertAgentContextCompressionwith the oldest scanned run asCutoffRunStartedAt.
Failure modes (provider missing, completion error, insert error) are
non-fatal — the sweep logs and continues. Per-tick metrics now
include compressed, skipped_no_provider, failed alongside
candidates.
Cost¶
Each crossed-threshold candidate issues one chat-completion call per tick. With a 5-minute tick and ~10 active agents over threshold, worst case is ~120 calls/hour — bounded by the agent count, not the run count.
Tests¶
internal/engine/adapters/embeddings_test.go— 6 new tests forComputeOpenAICompletion: happy path, default model, missing api_key, blank user message, non-2xx, empty choices.internal/worker/context_compression_test.go— addedBuildPromptShapetest pinning the tag vocabulary + transcript markers so the format stays stable across refactors.- The existing
DisabledByDefaulttest continues to pass with the new constructor signature.
v2.40.0.1 — 2026-05-28¶
MCP — resources/ + prompts/ methods.
Closes the v2.40.0.0 MCP-server slice. v2.40.0.0 shipped initialize + tools/list + tools/call. v2.40.0.1 adds the resources + prompts surfaces so MCP clients can introspect "what is this agent set up to do" and "what has the agent recorded," and can render a parameterized summarization prompt.
Methods¶
resources/list— surfaces the URIs an MCP client can read:staple://agent/{agentID}/system-prompt(one per non-deleted agent in the calling company)staple://company/{companyID}/notes(one company-wide digest URI)resources/read— parses the URI scheme and returns one Markdown content chunk:- Agent system prompts return the raw prompt body (or empty when unset).
- The company-notes URI returns the most recent 50 agent-authored / auto-extracted notes (no user-private notes) as a Markdown digest with title + tags + body per note.
- Unknown URIs return
InvalidParams (-32602). prompts/list— returns one prompt descriptor:summarize-recent-runswith two args:agent_id(required) andcount(optional, default 10, capped at 10).prompts/get— renders the prompt into a single user-role text Messages array. The body asks the model to summarize the agent's recent heartbeat runs into a status report. Missing/invalid args → InvalidParams; unknown prompt names → InvalidParams.
Capabilities¶
initialize now advertises resources: {} and prompts: {} alongside
the v2.40.0.0 tools: {} entry. MCP clients use these capability
keys to feature-detect server support.
Auth¶
Same per-company isolation as v2.40.0.0: the BearerAuth middleware
gates the path's {companyId}, and resources/read + prompts/get
additionally verify the resolved agent belongs to the calling
company before returning content.
Tests¶
8 new internal/handler/mcp_test.go cases:
resources/listreturns the company-notes URI even with a nil pool.resources/readreturns agent system prompt (skips on no DB).resources/readrejects unknown + empty URIs.prompts/listreturns the one prompt with the right argument descriptors.prompts/getrejects missing agent_id, unknown prompt name.prompts/gethappy-path returns a user-role text message containing the agent name.
MCPHandlerUnknownMethod test updated to exercise
sampling/createMessage (the still-unimplemented Staple-as-MCP-client
direction) rather than resources/list.
Out of scope (v2.40.0.2+)¶
resources/subscribe— static-shaped resources have no payload to push.sampling/createMessage— Staple as MCP CLIENT of external servers.
v2.45.0.1 — 2026-05-28¶
User-modeling UI — opt-in toggle + operator view/edit.
Closes the v2.45.0.0 user-modeling foundation. v2.45.0.0 shipped the
schema (migration 109), the /update-user-model verb, and the
chat-context splice. v2.45.0.1 adds the human surfaces on top: a
profile-page opt-in toggle for users, and a per-company instance-admin
view+edit page so operators can read, overwrite, or force-disable any
user's agent-built profile.
Profile → Settings (/me/settings)¶
- New "Agent profile" section with a single checkbox: "Allow agents to remember things about me." Unchecked by default (privacy default from the v2.45.0.0 schema).
- When enabled, the page shows the current profile body length so the user knows whether the agent has written anything.
- "Clear profile and disable" button wipes the body and flips
enabled = false. Confirms before destroying the body.
New routes:
POST /me/user-model/toggle—UIUserModelToggle. Flipsuser_models.enabledon the row for the user's primary company (alphabetically-first membership). Idempotent.POST /me/user-model/clear—UIUserModelClear. Upserts an empty profile body and disables in one transaction.
Operator UI¶
Instance-admin-only; mirrors the egress / pairings-audit handler shape:
GET /companies/{companyId}/user-models—UICompanyUserModelsList. Lists everyuser_modelsrow for the company joined to user email/display_name. Shows enabled badge, profile length, last updated, link to detail view.GET /companies/{companyId}/user-models/{userId}—UICompanyUserModelShow. Renders the profile body in a<pre>block with an inline edit textarea + "Save profile" button. Also shows a "Force-disable opt-in" button when the user has it enabled.POST /companies/{companyId}/user-models/{userId}/save—UICompanyUserModelSave. Overwritesprofile_markdownviaUpsertUserModel; does NOT touchenabled.POST /companies/{companyId}/user-models/{userId}/disable—UICompanyUserModelForceDisable. Soft revocation — setsenabled = falseeven when the user has it on. The user can re-enable from their settings page.
Sidebar nav entry "User profiles" sits between Pairings audit and Instance Settings in the instance-admin block.
Schema + queries¶
No new migrations. The v2.45.0.0 user_models table (migration 108)
+ RLS (109) already cover this slice. Added one helper:
query.ListUserModelsForCompany(ctx, pool, companyID, limit)returns[]CompanyUserModel(theUserModelrow joined tousers.email + display_name), ordered byupdated_at DESC. Default limit 500.
Tests¶
internal/db/query/user_models_test.go— addedListForCompanyhappy-path test (DB-required, skips on no DB).internal/handler/ui_user_model_test.go— 5 new handler tests covering toggle on/off + idempotence, anonymous → /login redirect, clear-then-disable, admin-vs-non-admin gate on the operator list, admin-can-edit, and force-disable.
v2.46.0.1 — 2026-05-28¶
GEPA auto-evaluate worker — proposes prompt revisions; operator-gated promotion.
Closes the v2.46.0.0 GEPA loop. v2.46.0.0 shipped versioned prompts + manual Promote / Restore. v2.46.0.1 adds the auto-evaluate worker that analyzes recent runs, identifies failure patterns, and proposes prompt revisions for the operator to review. Promotion stays operator-gated: proposals are NOT auto-applied — the operator clicks Accept (which calls the v2.46.0.0 CreateAgentPromptVersion path inside one transaction) or Reject (which records a rationale).
Two flags ship. STAPLE_GEPA_EVAL_ENABLED (default false) engages
the worker. STAPLE_GEPA_EVAL_LLM_ENABLED (default false) is reserved
for v2.46.0.2+ — v2.46.0.1 only ships the deterministic-heuristic
proposer; the LLM flag is logged + skipped today so an operator can
flip it forward when the next release lands.
Migration 112 — agent_prompt_proposals.sql¶
agent_prompt_proposals— one row per proposal. Columns:proposed_template TEXT NOT NULL— the new prompt the worker generated. Whole-body, not a diff (matches howagent_prompt_versions.prompt_templateis stored).rationale TEXT NOT NULL— explains WHY the worker proposed this. Shown prominently in the UI.evidence_run_ids UUID[] NOT NULL DEFAULT '{}'— the run UUIDs the worker considered. The UI surfaces these so the operator can spot-check the pattern.status TEXT NOT NULL DEFAULT 'pending'with CHECK constraint pinning to (pending | accepted | rejected).accepted_version_number INTEGER— set when status='accepted'; points at the agent_prompt_versions row this proposal generated.rejected_reason TEXT,rejected_by_user_id UUID REFERENCES users ON DELETE SET NULL.model TEXT NOT NULL— identifies the proposer ("deterministic-heuristic-v1" in v2.46.0.1; LLM model name in v2.46.0.2+).created_at,decided_at.- Index
agent_prompt_proposals_agent_pending_idx (agent_id) WHERE status = 'pending'— partial index so the dominant query (the agent show page listing pending proposals) hits the smallest possible index.
Migration 113 — rls_agent_prompt_proposals.sql¶
- ENABLE ROW LEVEL SECURITY + per-company
tenant_isolationpolicy. Mirrors 091 / 094 / 096 / 100 / 104 / 107 / 109 / 111.
internal/db/query/agent_prompt_proposals.go (new)¶
AgentPromptProposalstruct.InsertAgentPromptProposalParams+InsertAgentPromptProposal— the worker's write path. Required fields are CompanyID, AgentID, ProposedTemplate, Rationale, Model. Cross-tenant guard at the application layer in addition to RLS.ListPendingProposals(ctx, pool, companyID, agentID)— UI reads for the show page. Newest-first.GetProposalByID(ctx, pool, companyID, proposalID)— UI handlers use this to load a proposal before accept/reject. Returns (nil, nil) on miss for clean 404s.AcceptProposal(ctx, pool, companyID, proposalID, userID)— THE critical write. Inside one scope.WithTenantScope tx:- SELECT FOR UPDATE on the proposal (concurrent Accept clicks can't race through).
- Compute next version_number for the agent.
- INSERT into agent_prompt_versions with the proposed_template and a summary "Accepted GEPA proposal (model=…)".
- UPDATE agents.system_prompt and current_prompt_version_number.
- UPDATE the proposal to status='accepted', accepted_version_number=N, decided_at=now(). Returns the new AgentPromptVersion.
RejectProposal(ctx, pool, companyID, proposalID, userID, reason)— reason is REQUIRED (audit trail).
internal/worker/gepa_evaluator.go (new)¶
GEPAEvaluatorworker handle.- Tick rate: 6h (every 6h is sparse enough to keep proposals from piling up before anyone reviews them; matches the typical operator-review-once-or-twice-a-day cadence).
- Per-tick: STAPLE_GEPA_EVAL_ENABLED gate → list companies → list agents → per active agent, count failed runs in the trailing 20 heartbeats. When the count >= 5 (25% failure rate, well above baseline drift), generate a proposal:
proposed_template= current system_prompt + a hard-coded "v2.46 GEPA note" appendix that calls out the escalation surface, the blocked-status verb, and verb-failure recovery.rationale= "Detected N failed runs out of 20 most recent…"evidence_run_ids= the IDs of the failed runs.model= "deterministic-heuristic-v1".- Skip rules: agent.status != 'active'; agent has 0 system_prompt (no baseline to extend); agent already has a pending proposal (don't pile up — operator clears the existing one first).
- STAPLE_GEPA_EVAL_LLM_ENABLED is RESERVED. v2.46.0.1 logs an Info line acknowledging the flag and continues with the heuristic. v2.46.0.2+ swaps the proposal generation behind this flag.
countRecentFailuresuses a single direct pool.Query (no scope.WithTenantScope) because the per-company iteration is the outer loop; one SET LOCAL per agent would be wasted work.
cmd/server/main.go¶
- New worker boot:
worker.NewGEPAEvaluator(pool)+go gepaEvaluator.Start(ctx). Sits between the context-compression worker and the route registration block (same pattern as every other worker).
internal/handler/ui_agents.go¶
UIAgentShownow loadsPendingProposalsviaquery.ListPendingProposalsand exposes it on the template data. Errors are non-fatal — the proposal panel just renders empty.UIAgentAcceptProposal(pool)— POST handler at/agents/{agentId}/ui/accept-proposal. Readsproposal_idform field. Validates agent-match (the proposal's agent_id must equal the URL's agent), loads the actor's user_id, callsquery.AcceptProposal. Redirects back to the agent page.UIAgentRejectProposal(pool)— POST handler at/agents/{agentId}/ui/reject-proposal. Readsproposal_idandreasonform fields. Empty reasons are rejected at the handler (the helper also enforces this; double-defense).
cmd/server/routes.go¶
- Two new POST routes under the member-or-above middleware block:
/agents/{agentId}/ui/accept-proposaland/agents/{agentId}/ui/reject-proposal. Sit immediately below the v2.46.0.0 promote/restore routes.
internal/web/templates/agents/show.html¶
- New "Proposed prompt revisions" section above the existing
"Prompt history" section. Hidden when
.PendingProposalsis empty — the panel only appears when the worker has something for the operator to review. - Each proposal row shows: created_at + model, rationale, evidence
run IDs (first 8 chars of each UUID), a collapsible
<details>block with the full proposed_template, an "Accept this proposal" button (POST with proposal_id), and a Reject form with a requiredreasoninput.
Tests¶
internal/db/query/agent_prompt_proposals_test.go:TestInsertAgentPromptProposal_RequiredFields— five entry- point validation branches (no DB).TestProposal_DB_HappyPath— DB-dependent. Creates a real agent + system_prompt, inserts a proposal, accepts it, verifies the new version row landed at v1, the proposal flipped to accepted, pending list is empty, and re-accept fails.TestProposal_DB_RejectPath— DB-dependent. Insert → reject with reason → verify status='rejected' + reason + decided_at; empty-reason rejection rejected at the helper.internal/worker/gepa_evaluator_test.go:TestGEPAEvaluator_DisabledByDefault— runOnce with nil pool must not panic in the default-disabled state.TestGEPAEvaluator_DisabledExplicit/TestGEPAEvaluator_EnabledRecognized— env-flag parsing.TestGEPAEvaluator_LLMFlagRecognized— confirms the LLM flag parses but doesn't change v2.46.0.1 behavior.TestGEPAEvaluator_ConstantsAreSane— guard against accidental dial-up (interval < 1m would put the worker into a hot loop; failure_threshold > recent_run_window would be unreachable).TestGEPAProposalAppendix_FormatStable— pin the appendix shape so future edits don't silently change proposal copy.TestGEPACurrentMode— pin the startup-log mode string for journalctl fingerprint stability.
Out-of-scope deferrals (v2.46.0.2+)¶
- LLM-driven proposals. The
STAPLE_GEPA_EVAL_LLM_ENABLEDflag is wired but does not change behavior in v2.46.0.1. v2.46.0.2 layers the real LLM-call path behind it: the worker calls engine.ChatComplete on a meta-agent (or the agent itself) with the recent-runs digest as context, parses the LLM's proposed prompt, and writes that as the proposal instead of the static appendix. - Auto-promote opt-in. v2.46.0.1 keeps every proposal operator- gated; v2.46.0.2 adds a per-agent flag that lets the worker auto-accept high-confidence proposals (with a notification rather than a review queue).
- Multi-proposal queues. v2.46.0.1 enforces one pending proposal per agent (the worker skips agents that already have one) to avoid the operator drowning. v2.46.0.2+ could allow multiple if the UI grows a queue-management view.
Migration count after deploy: 113.¶
v2.43.0.1 — 2026-05-28¶
Subagent runtime dispatch via /await-subagents.
Closes the v2.43.0.0 scope-down on the subagent runtime layer. v2.43.0.0
shipped the data model + /spawn-subagent verb; children landed in the
DB with status='active' but heartbeat_enabled=false — inert until
this release. v2.43.0.1 adds a new parser-emitted verb the parent
agent fires in a follow-up turn to dispatch all active children at
once.
Runtime semantics — opt-in, not autonomous. Subagents still don't
auto-tick. The parent explicitly emits /await-subagents (no args, no
body); the chat handler enumerates the parent's direct active children
and enqueues a one-shot heartbeat_run task per child. Each child does
ONE heartbeat tick (the existing worker pool picks the tasks up) and
goes back to dormant. This keeps the engine deterministic — no
background drift, no surprise LLM spend — and matches the v2.43.0.0
substrate that ships subagents as dormant by default.
Migration¶
None. v2.43.0.1 is a pure runtime layer; the schema from v2.43.0.0 (migration 105) carries through unchanged.
internal/engine/actions.go¶
- New verb branch for
/await-subagents//await_subagents. EmitsAgentAction{Kind: "await_subagents", Value: ""}. No hints, no body, no allowlist surface — argument-less verbs follow the same shape as/checkoutand/release. Trailing text on the line is harmless; the parser strips the verb line and preserves the prose.
internal/engine/chat_context.go¶
- The chat vocabulary block grew a
/await-subagentsentry positioned immediately below/spawn-subagent. The block also updates the/spawn-subagentdescription: subagents are stillheartbeat_enabled=false, but the description now points the agent at/await-subagentsas the explicit "go work now" trigger instead of describing the v2.43.0.0 inert state.
internal/db/query/agents.go¶
ListChildAgents(ctx, pool, companyID, parentAgentID)— returns the parent's direct active children inside ascope.WithTenantScopetx. Filtersstatus = 'active'so deleted / archived children are skipped. Heartbeat-enabled state is NOT a filter — the whole point of/await-subagentsis to dispatch dormant children — so the helper returns them regardless of that flag. Empty parent → non-nil empty slice (consistent with the existing list helpers).
internal/handler/chat_actions.go¶
- New case
"await_subagents"inProcessChatAgentReply's action switch. handleChatAwaitSubagents(ctx, pool, chat, agent, action)— the runtime dispatcher:- Guards: no current agent → marker explaining the no-op; cross- tenant parent → marker explaining the no-op.
- Lists direct active children via
ListChildAgents. - 0 children → friendly marker pointing at
/spawn-subagentfirst. - For each child:
CreateHeartbeatRun+EnqueueTask "heartbeat_run". Per-child failures log and skip; one broken child doesn't strand the others. - Marker emits a count summary listing each child as a Markdown link to its agent page so the operator (and the parent in the next turn) can navigate to results.
- The v2.43.0.0 spawn marker now says "Subagents are dormant until the
parent emits
/await-subagents" instead of "v2.43.0.0 subagents are inert until the runtime path lands."
Depth semantics¶
STAPLE_MAX_SUBAGENT_DEPTH (default 3) is enforced at spawn time
inside SpawnSubagent. /await-subagents dispatches only the
parent's DIRECT children, so a depth-2 child calling
/await-subagents on its own children (depth-3 grandchildren) cannot
exceed the bound — the children wouldn't exist at depth-4+ because
the spawn would have been rejected. The depth guard is structurally
unreachable from this verb.
Tests¶
internal/engine/actions_await_subagents_test.go:TestParseAgentActions_AwaitSubagents_HappyPath— kebab-case form produces one action, verb stripped from clean output.TestParseAgentActions_AwaitSubagents_UnderscoreVariant— the/await_subagentsalias is accepted (matches every other verb).TestParseAgentActions_AwaitSubagents_TrailingText— prose after the verb on the same line is harmless.internal/db/query/agents_subagent_test.go::TestListChildAgents_DB— DB-dependent. Spawns two children, lists them, verifies the result contains both with parent_agent_id set. Also exercises the zero-children branch on a lonely parent. Skips when no DB.internal/handler/chat_actions_test.go:TestProcessChatAgentReply_AwaitSubagents_NoAgent— verb on a chat with no current_agent_id writes the "no current agent" marker.TestProcessChatAgentReply_AwaitSubagents_NoChildren— real parent with 0 children writes the "no active subagents" marker.TestProcessChatAgentReply_AwaitSubagents_DispatchesChildren— happy path; spawns two real subagents, verifies each ends up with exactly one heartbeat_run row and the marker reports "Dispatched 2 subagent..."
Out-of-scope deferrals (v2.43.0.2+)¶
- Synchronous result digest. The original spec sketched
/await-subagentsreturning a digest of child outputs in the parent's next user message. That requires either (a) blocking on the worker pool for each child's heartbeat_run to complete, which ties up the chat reply path for the duration of multiple LLM calls, or (b) a follow-up verb the parent emits later to collect results. v2.43.0.1 keeps the dispatch shape simple and counts on the parent to reference child agent pages (linked in the marker) for results on the next turn. v2.43.0.2 layer adds/get-subagent-resultsor inlines the digest into the chat-context capability block. - Auto-tick subagents. Still off by default. Parent-gated dispatch remains the model.
v2.42.0.1 — 2026-05-28¶
staple-cli chat name resolution layer.
Closes the v2.42.0.0 scope-down on agent name lookup. v2.42.0.0 shipped
UUID-only target resolution with a clear error message for name input;
v2.42.0.1 adds the friendly-name path so staple-cli chat <name>
works without the operator hunting down a UUID.
internal/db/query/agents.go¶
ListAgentsByCompanyName(ctx, pool, companyID, name)— case- insensitive exact match onagents.name, scoped throughscope.WithTenantScopelike the existingListAgentsByCompany. Returns an empty slice (not nil; not an error) on no-match so the HTTP layer can serialize a clean{"agents":[]}.
internal/handler/agents.go¶
ListAgents(pool)handlesGET /api/agents?name=<exact-name>. Requires authentication (BearerAuth on the route group); the search scope is the actor's own company — cross-tenant lookups are not possible regardless of input.nameis required (empty / missing returns 400 to avoid accidental "enumerate the whole company" calls via an empty query string). Empty result returns 200 with{"agents":[]}; clients distinguish "no match" from "server error" by status code without parsing an envelope.
cmd/server/routes.go¶
- New
GET /api/agentsroute under the BearerAuth group, sibling of/api/agents/me.
cmd/staple-cli/chat.go¶
chatResolveTargetnow accepts a context + base URL + API key alongside the existing arg + bearer identity. Three branches:- "me" alias → bearer's own agent ID (offline, no API call).
- UUID input → passthrough (offline, no API call).
- Anything else → GET /api/agents?name=
. Single match returns the UUID; zero matches raises a friendly "no agent named X" error; multi-match (rare; names aren't unique in the schema) prints the candidates and asks the user to disambiguate by UUID. chatLookupByName(ctx, baseURL, apiKey, name)— new helper that builds the GET request, decodes the{"agents":[...]}envelope, and surfaces 401 with the same "STAPLE_API_KEY is invalid or expired" message aschatProbeIdentity.chatAgentIdentitygrows aRolefield for the multi-match disambiguation listing. Existing JSON-decode sites keep working — the field isomitempty-equivalent (absent → empty string).- Usage block updated: argument is "agent" (uuid | name | "me"), not "agent-uuid".
Tests¶
cmd/staple-cli/chat_test.go::TestChatResolveTargetextended withname-single-match,name-no-match,name-multi-match. The "me" and UUID subtests now use anhttptest.Serverthat fails the test on any hit — proving the offline branches don't reach the network even when given a server URL.internal/handler/api_handlers_test.go::TestListAgentsHandlercovers the three handler shapes: missing-name → 400, known-name → 200 with one row, unknown-name → 200 with empty list. Skips cleanly when no DB / companies / agents are seeded.
Scope-down: streaming stays deferred to v2.42.0.2+¶
UIChatSendStream (the v2.16.3 token-by-token streamer) is mounted
behind RequireRole with form-field input and the UI's CSRF +
session-cookie pattern. Extracting a bearer-friendly variant cleanly
requires a parallel POST /api/chats/{chatId}/messages/stream handler
plus a CLI-side SSE consumer. That work is layered enough to warrant
its own release — v2.42.0.1 stays focused on name resolution, which
is the high-value gap. The sync POST remains the v2.42.0.1 transport
and the CLI prints the agent's full reply once it lands.
v2.46.0.0 — 2026-05-28¶
GEPA loop close foundation — versioned agent prompts + manual promote/restore.
Closes locked-roadmap §4.9 substrate. FOUNDATION. Every agent prompt
change now lands as an immutable row in agent_prompt_versions. The
operator UI grows a "Prompt history" panel with "Save and promote"
(creates a new version with a rationale) and "Restore" (appends a new
version whose body matches the chosen historical one — append-only,
versions are never destroyed).
v2.46.0.0 ships the MANUAL substrate. Operators promote and restore
versions through the UI; every action carries the actor's user_id and
a free-form summary. v2.46.0.1+ adds the auto-evaluate-and-promote
worker that closes the GEPA loop: Generate (LLM proposes a new prompt
from evaluation runs), Evaluate (run candidate prompt against held-out
issues), Promote (call CreateAgentPromptVersion with the eval summary).
The substrate that lands here is fully shaped for the v2.46.0.1+ worker
to drop in without touching the data layer.
Migration 110 — agent_prompt_versions.sql¶
agent_prompt_versions— one row per (agent × version). Columns:version_number INTEGER NOT NULL— monotonic per-agent; theUNIQUE (agent_id, version_number)constraint makes concurrent promotes safe (loser retries).prompt_template TEXT NOT NULL— full body. Whole-snapshot versioning, not diff-based; every row is restorable in isolation.summary TEXT NOT NULL DEFAULT ''— operator rationale in v2.46.0.0; auto-promote eval summary in v2.46.0.1+.created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL— actor attribution. NULL for worker-authored versions (v2.46.0.1+).- Index
agent_prompt_versions_agent_idx (agent_id, version_number DESC)serves the dominant "list history newest-first" query. agents.current_prompt_version_number INTEGER NOT NULL DEFAULT 0— tracks the active version so the UI labels v3 (current) without a JOIN. Backfilled to 0 for the pre-versioning era; the next promote bumps to 1.
Migration 111 — rls_agent_prompt_versions.sql¶
- ENABLE ROW LEVEL SECURITY + per-company
tenant_isolationpolicy on the new table. Mirrors 091 / 094 / 096 / 100 / 104 / 107 / 109.
internal/db/query/agents.go — Agent struct field¶
Agent.CurrentPromptVersionNumber intjoins ParentAgentID / SubagentDepth / SpawnReason on the trailing-fields list. The sharedagentColumnsconstant grewcurrent_prompt_version_number; the scanAgent + ListAgentsWithLastRun scanners now consume it.
internal/db/query/agent_prompt_versions.go (new)¶
AgentPromptVersionstruct.CreateAgentPromptVersionParams+CreateAgentPromptVersion— the promote operation. Inside onescope.WithTenantScopetransaction:- Loads the agent + cross-tenant check.
- Computes
MAX(version_number) + 1for the agent. - INSERTs the version row.
- UPDATEs
agents.system_promptandcurrent_prompt_version_number. Readers see (vN, prompt_vN) or (vN-1, prompt_vN-1); never a torn intermediate. ListAgentPromptVersions(ctx, pool, companyID, agentID)— newest first, full history.GetAgentPromptVersionByNumber(ctx, pool, companyID, agentID, version)— returns (nil, nil) on miss for clean 404 handling.
internal/handler/ui_agents.go¶
UIAgentShownow loadsPromptVersions+CurrentPromptVersion+AgentIDinto the template data.UIAgentPromotePromptVersion(pool)— POST handler at/agents/{agentId}/ui/promote-prompt-version. Readssystem_promptsummaryform fields. No-op when prompt unchanged (prevents table spam). Pulls the actor's user_id fromauth.GetActorforcreated_by_user_id. CallsCreateAgentPromptVersionand redirects back to the agent page.UIAgentRestorePromptVersion(pool)— POST handler at/agents/{agentId}/ui/restore-prompt-version. Readsversionform field. Loads the historical version, then callsCreateAgentPromptVersionwith that historical PromptTemplate and a "Restored from v\<N>" summary. Append-only restore: restoring v1 while v7 is current creates v8 whose body matches v1.
cmd/server/routes.go¶
- Two new POST routes under the member-or-above middleware:
/agents/{agentId}/ui/promote-prompt-versionand/agents/{agentId}/ui/restore-prompt-version.
internal/web/templates/agents/show.html¶
- New "Prompt history" detail section in the Config tab. Renders:
- A "Save and promote" form (prompt textarea + summary input).
- A history table (version, created, summary, restore button).
- Confirmation dialog on Restore explaining append-only semantics.
- The current version is annotated with "(current)" in the table.
Tests¶
internal/db/query/agent_prompt_versions_test.go:- Non-DB validation guards on all three helpers.
- DB-gated happy path: v1 + v2 promote, agent's system_prompt and current_prompt_version_number both bump, list returns newest first, get-by-number happy + not-found.
- DB-gated cross-tenant guard: company B trying to promote on agent A's row errors (RLS + application layer combined).
internal/handler/ui_agents_prompt_versions_test.go:- Promote handler: no-op on unchanged prompt (no row created).
- Promote handler: creates v1 + bumps agent on changed prompt.
- Restore handler: restoring v1 with v2 current creates v3 whose body matches v1; updated.SystemPrompt + CurrentPromptVersionNumber track the restored row.
- Restore handler: unknown version returns 404.
What's NOT in v2.46.0.0 (scoped down)¶
- The auto-Generate-Evaluate worker. v2.46.0.0 ships the manual-promote
substrate so v2.46.0.1+ can land the auto loop with zero data-layer
churn. The worker will call
CreateAgentPromptVersionwith a nilCreatedByUserID(NULL = automated origin) and a summary derived from the evaluation result. - Modifying the existing adapter-specific heartbeat-config save forms
to auto-create versions on every save. The current Save button in
the heartbeat-config panel continues to overwrite
system_promptin-place. Versioning is opt-in via the new "Save and promote" form — operators iterate freely on the prompt without spamming the history table, then promote a clean state when ready. - A diff view between two historical versions. v2.46.0.0 shows the summary only; the body is restorable in full. Diff lands in a follow-on slice when the demand surfaces.
Migration count¶
111.
Round 4 closed (substrate)¶
v2.46.0.0 is the last v2.4X.0.0 in Round 4. The locked roadmap's §3.6 Round 4 sequencing (C → E → G → C+ → CLI B → Subagents → Compression → C++ → B) ships its FOUNDATION layer here. Round 4 closed: v2.46.0.0 ships the GEPA substrate; the full auto-loop arrives in v2.46.0.1+.
v2.45.0.0 — 2026-05-28¶
C++ user modeling foundation — opt-in profile + /update-user-model verb.
Opens locked-roadmap §4.8. FOUNDATION. Per-user style/preference
profiles that agents read in chat context and write via the new
/update-user-model verb. One row per (user × company); the same
Staple user with multiple company memberships gets independent
profiles per company.
Privacy posture: opt-in. The user_models.enabled column defaults
FALSE. The agent's write verb checks Enabled and emits a no-op marker
for opted-out users — agents literally cannot populate a profile the
user hasn't agreed to. The opt-in UI toggle (which flips enabled)
lands in v2.45.0.1+; v2.45.0.0 ships the data + verb so the toggle
lights both ends instantly when it ships.
Migration 108 — user_models.sql¶
user_models— one row per (user × company). Columns:enabled BOOLEAN NOT NULL DEFAULT FALSE— the per-user opt-in switch.profile_markdown TEXT NOT NULL DEFAULT ''— agent-maintained Markdown profile. The agent reshapes wholesale (vs. append) so the profile distills rather than grows linearly.last_updated_by_run_id UUID REFERENCES heartbeat_runs(id) ON DELETE SET NULL— provenance. NULL for operator-driven writes (UI v2.45.0.1+) and chat-driven writes (the chat path has no run attribution).UNIQUE (user_id, company_id)— cross-tenant isolation at the schema level on top of RLS.- Partial index
user_models_company_enabled_idx (company_id) WHERE enabled = TRUEfor the future "all enabled users in this company" admin surface.
Migration 109 — rls_user_models.sql¶
- ENABLE ROW LEVEL SECURITY + per-company
tenant_isolationpolicy on the new table. Mirrors 091 / 094 / 096 / 100 / 104 / 107.
internal/db/query/user_models.go (new)¶
UserModelstruct +GetUserModel(ctx, pool, companyID, userID)returning (nil, nil) when no row exists. Used by both the chat- context builder and the verb handler.UpsertUserModelParams+UpsertUserModel— idempotent insert/update on (user_id, company_id). Does NOT touchenabled— the opt-in switch belongs to the user/operator UI, not the agent. Handlers must gate on Enabled before calling.SetUserModelEnabled(ctx, pool, companyID, userID, enabled)— flips the opt-in flag. Creates a row with empty profile if absent.
internal/engine/actions.go — /update-user-model verb¶
- New parser case mirrors
/spawn-subagent's shape. Accepts: user: <email-or-uuid>hint (REQUIRED — the parser drops the verb without it)- a triple-backtick fenced block carrying the new
profile_markdown(REQUIRED — the parser drops the verb without it) - no trailing-text payload on the verb line (intentional — the user hint is the identifier; future hints would attach here).
parseHintLinelearned theuserkey, gated byhintsForVerbso only the new verb consumes it.
internal/handler/chat_actions.go — update_user_model action¶
- New dispatch case calls
handleChatUpdateUserModel. - Handler:
- Resolves the user via
resolveUserRef(UUID first, then email fallback). Unknown refs get a friendly no-op marker. - Loads the user_models row scoped to the chat's company. If absent
OR
enabled=FALSE, returns a "no consent" marker — the agent cannot bypass the opt-in. - Calls
query.UpsertUserModelwith the new body and a nil run id (chat-driven writes don't have heartbeat attribution). - Appends a system-role chat marker confirming the update, naming the target user.
internal/engine/chat_context.go — user-model splice¶
chatContextDatagainsUserModelMarkdown. Populated when actor is non-nil AND the user has an Enabled row in the chat's company.renderChatContextemits a new## About this usersection at the very top of the digest (before compression summary, before notes/issues). Per-user style shapes everything that follows.vocabularyBlock()documents/update-user-modelso agents know the verb exists, what it consumes, and that opt-in is required.
Tests¶
internal/db/query/user_models_test.go:- Non-DB validation guards (
GetUserModelempty args,UpsertUserModelmissing UserID/CompanyID,SetUserModelEnabledmissing IDs). - DB-gated:
Getreturns nil for unknown user,Upsertcreates then updates and does NOT flip Enabled on update,SetUserModelEnabledflips the flag (and INSERTs a stub row when absent). internal/engine/actions_update_user_model_test.go:- Happy path + missing-user + missing-body + empty-body + underscore variant + hint allowlist sanity.
Privacy & cross-tenant guarantees¶
- The verb resolves the target user, then loads the row scoped by
chat.CompanyID. A user with memberships in companies A and B has independent rows; the verb in company A's chat can only ever touch company A's row. - Both layers of RLS (migration 109) and the UNIQUE constraint on (user_id, company_id) enforce per-tenant isolation. A bug in the application code can't produce cross-tenant contamination at the database layer.
- Read-side splice in
BuildChatContextreuses the same per-company scoping. An agent in company A cannot read company B's profile even if the agent somehow gets the right user_id.
What's NOT in v2.45.0.0 (scoped down)¶
- The user-facing opt-in UI toggle (the surface that calls
SetUserModelEnabled). v2.45.0.0 ships the substrate; operators who want to enable a user's modeling must flip the flag via psql or the staple-cli (post-v2.45.0.0). - An operator UI to view / edit a user's profile_markdown. The agent is the only writer in v2.45.0.0.
Migration count¶
109.
v2.44.0.0 — 2026-05-28¶
Context compression foundation — DATA MODEL + WORKER SHELL + chat-context splice.
Opens locked-roadmap §4.7. FOUNDATION ONLY. v2.44.0.0 lands the schema (compression metadata + RLS), the deterministic token-count estimator, the worker shell that observes thresholds and logs candidates, and the chat-context builder splice that surfaces the compression summary to the LLM when one exists.
Compression is OPT-IN. Set STAPLE_CONTEXT_COMPRESSION_ENABLED=true
to engage. Default off — the worker still ticks but exits early, and
the chat-context builder skips the compression read. When enabled, the
v2.44.0.0 worker LOGS candidates (agents whose accumulated transcript
exceeds the threshold) but does NOT yet write compression rows; the
LLM-summarization call site needs a clean engine hook that lands in
v2.44.0.2. The chat-context splice is wired up and works the moment a
row appears in agent_context_compressions (e.g., written manually
via query.InsertAgentContextCompression or by v2.44.0.2+ runtime).
Migration 106 — agent_context_compressions.sql¶
agent_context_compressions— one row per (agent × compression event). Columns:cutoff_run_id UUID REFERENCES heartbeat_runs(id) ON DELETE SET NULL— anchor for the chronological cut.cutoff_run_started_at TIMESTAMPTZ NOT NULL— durable timestamp that survives heartbeat_runs deletion.summary_markdown TEXT NOT NULL— the model's prose summary of everything older than the cutoff.token_count_before/token_count_after— observability for the operator runbook ("compression saved 47k tokens on agent X").model TEXT NOT NULL— which provider model produced the summary.- Index
agent_context_compressions_agent_idx (agent_id, created_at DESC)serves the dominant "latest compression for this agent" lookup the chat-context builder runs on every assembly.
Migration 107 — rls_agent_context_compressions.sql¶
- ENABLE ROW LEVEL SECURITY + per-company
tenant_isolationpolicy on the new table. Mirrors 091 / 094 / 096 / 100 / 104.
internal/db/query/agent_context_compressions.go (new)¶
AgentContextCompressionstruct.CutoffRunID *stringis nullable (ON DELETE SET NULL); all other fields are required.InsertAgentContextCompressionParams+InsertAgentContextCompression— writes a new row insidescope.WithTenantScope. Required-field guards on AgentID, CompanyID, SummaryMarkdown, CutoffRunStartedAt, Model.GetLatestAgentContextCompression(ctx, pool, companyID, agentID)— returns the most recent row or (nil, nil) when none exists. Called byengine.BuildChatContexton every chat-context build.
internal/engine/tokenize.go (new)¶
EstimateTokens(text)— deterministiclen(strings.TrimSpace(text)) / 4estimator with ceil division. Good enough for the "is this approximately 10k tokens or 100k tokens" threshold decision the compression worker makes; not appropriate for billing.
internal/worker/context_compression.go (new)¶
ContextCompressionworker. Tick interval 5 minutes.- Per tick: re-reads
STAPLE_CONTEXT_COMPRESSION_ENABLED. When disabled, exits at the top — no DB pressure. - When enabled: re-reads
STAPLE_CONTEXT_COMPRESSION_THRESHOLD_TOKENS(default 50000), iterates active agents across all companies, estimates each one's transcript token budget fromSummarystrings on recent runs. When an agent exceeds the threshold AND has ≥ 10 recent runs, logs the candidate at Info with full context (agent_id, agent_name, run_count, tokens_before, tokens_target, threshold). - v2.44.0.0 SHELL: candidates are LOGGED, not compressed. The LLM-summarization call site needs a clean engine hook that lands in v2.44.0.2+. The schema + worker shell + chat-context splice all ship here so that v2.44.0.2's runtime layer can land without re-touching any of this.
internal/engine/chat_context.go — compression splice¶
chatContextDatagainsCompressionSummary,CompressionModel,CompressionCutoff. Populated byBuildChatContextwhen the env flag is true AND the chat has aCurrentAgentIDAND that agent has a row inagent_context_compressions.renderChatContextemits a new## Prior-context summary (compressed)section at the TOP of the digest (after the heading), before the notes/issues blocks. The LLM sees compressed prior context before the live data. Compression read failures are swallowed silently — the digest still renders without compression, same as pre-v2.44.0.0.isContextCompressionEnabled()mirrors the worker-side flag check and is intentionally duplicated to avoid an engine -> worker import cycle. The two readers are identical; worker-side tests cover the parsing semantics.
cmd/server/main.go¶
- New worker boot:
worker.NewContextCompression(pool)+ goroutine Start, logging "context compression worker started". Same pattern as noteEmbedding / notesRetention / pairingCleanup.
Environment variables¶
STAPLE_CONTEXT_COMPRESSION_ENABLED(default unset / false). Truthy spellings:true/1/on/yes(case-insensitive). Anything else is falsy. Re-read on every worker tick AND every chat-context build so operator flips take effect without restart.STAPLE_CONTEXT_COMPRESSION_THRESHOLD_TOKENS(default 50000). Integer- Invalid / non-numeric / non-positive values fall back to the default — a typo never silently disables compression.
Tests¶
internal/engine/tokenize_test.go— table-drivenEstimateTokens: empty / whitespace / single char / multiples of 4 / round-up / 100-byte paragraph.internal/worker/context_compression_test.go— non-DB:runOnceon disabled flag does not touch the pool, env-var parsing for both ENABLED and THRESHOLD_TOKENS, constants-are-sane defense against accidental hot-loop tick rates.
What's NOT in v2.44.0.0 (scoped down)¶
- The actual provider-side summarization call. The worker shell logs
candidates instead. v2.44.0.2+ plumbs an engine reference into the
worker constructor and calls a yet-to-be-defined
engine.SummarizeForCompressionhelper that wraps the agent's adapter ChatCompletion with a system- level "summarize this transcript" prompt. - A UI surface for compression rows. v2.44.0.0 is observable only via
journalctl +
SELECT * FROM agent_context_compressionsinpsql. Operator UI lands in a follow-on slice when the runtime path is live.
Migration count¶
107.
v2.43.0.0 — 2026-05-28¶
Subagent spawning foundation — DATA MODEL + VERB + helper.
Opens locked-roadmap §4.6. FOUNDATION ONLY. v2.43.0.0 lands the
schema (parent/child columns + index), the SpawnSubagent query helper
with depth/children guards, the /spawn-subagent chat action verb,
and its chat-side handler.
Subagents spawned in v2.43.0.0 are INERT. They land in the DB as
status='active' but with heartbeat_enabled=false (column default).
They appear in the agents list and detail UI but do not run their own
heartbeats. v2.43.0.1+ adds the runtime cycle ("subagent runs one
heartbeat and reports back to its parent").
Migration 105 — agents_subagent.sql¶
agents.parent_agent_id UUID REFERENCES agents(id) ON DELETE SET NULL— NULL for root agents; set when this agent was spawned. ON DELETE SET NULL (not CASCADE) so subagents outlive their parents — historical heartbeat_runs would otherwise lose attribution.agents.subagent_depth INTEGER NOT NULL DEFAULT 0— root=0.agents.spawn_reason TEXT— free-form rationale persisted at spawn.agents_parent_idxpartial index onparent_agent_id IS NOT NULLfor the future "list my subagents" view.
internal/db/query/agents.go¶
- New struct fields:
ParentAgentID *string,SubagentDepth int,SpawnReason *string. All scanning paths (scanAgent, ListAgentsWithLastRun's local Scan) updated to include them. The sharedagentColumnsconstant grew the three trailing columns; any new scanner must include them in the same order. SpawnSubagentParams— input shape. CompanyID, ParentAgentID, Name, SystemPrompt, and SpawnReason are all REQUIRED. Role defaults to "general", AdapterType to "llm".SpawnSubagentLimits— MaxDepth, MaxChildrenPerParent. Zero values fall back to DefaultSubagentLimits() at call time.DefaultSubagentLimits()— readsSTAPLE_MAX_SUBAGENT_DEPTH(default 3) andSTAPLE_MAX_SUBAGENTS_PER_PARENT(default 5) from env on every call so an operator can flip the values without restart. Invalid values (non-numeric, zero, negative) fall back to the defaults — a typo never silently disables the guards.SpawnSubagent(ctx, pool, params, limits)— inserts a child agent inside a single tenant-scoped transaction. Enforces, in order:- all required fields present
- parent exists, company matches, not deleted
- parent.depth + 1 <= MaxDepth
- parent.direct_children < MaxChildrenPerParent
- then INSERT with status='active', heartbeat_enabled=false, subagent_depth=parent.depth+1, parent_agent_id=parent.ID, spawn_reason=SpawnReason
internal/engine/actions.go — /spawn-subagent verb¶
- New parser case mirrors
/install-agent's shape. Accepts: <name>trailing the verb (REQUIRED)role: <role>hint (optional)reason: <rationale>hint (REQUIRED — verb is dropped otherwise)- a triple-backtick fenced block carrying the new agent's
system_prompt(REQUIRED — verb is dropped otherwise) parseHintLinelearned areasonkey (was only consumed by/status's blocked-transition hint previously; the per-verb acceptHint gate keeps verbs from cross-consuming).hintsForVerballowlistsroleandreasonfor both/spawn-subagentand the underscore variant/spawn_subagent.
internal/handler/chat_actions.go — spawn_subagent action¶
- New chat-side dispatch case calls
handleChatSpawnSubagent. - Handler:
- Refuses when the chat has no current agent (nothing to spawn under).
- Refuses on cross-tenant parent (defense in depth on top of RLS).
- Calls
query.SpawnSubagentwith the parent's adapter_type / adapter_config inherited so the child has a working config out of the gate. - Appends a
system-role chat marker pointing at the new agent's detail page, explicitly noting v2.43.0.0 subagents are inert.
internal/engine/chat_context.go — vocabulary cheatsheet¶
vocabularyBlock()now documents/spawn-subagent. The cheatsheet is injected into every chat-side LLM prompt so agents know the verb exists, what hints/body it needs, and that subagents are inert in v2.43.0.0.
Tests¶
internal/db/query/agents_subagent_test.go:DefaultSubagentLimitsenv defaults + override + invalid-fallback.SpawnSubagentupfront validation table (no DB needed).- DB-gated happy-path: verifies the child gets parent_agent_id, subagent_depth=1, spawn_reason, status='active', and HeartbeatEnabled=false (the v2.43.0.0 inert claim).
- DB-gated children-cap: MaxChildrenPerParent=1 — second spawn fails with the "direct children" error.
internal/engine/actions_spawn_subagent_test.go:- Happy path + missing-reason + missing-body + empty-body
- name-only + underscore variant.
hintsForVerballowlist sanity-check.
Limits¶
STAPLE_MAX_SUBAGENT_DEPTH=3 (default), bounds the parent chain.
STAPLE_MAX_SUBAGENTS_PER_PARENT=5 (default), bounds direct fan-out.
Migration count¶
105.
v2.42.0.0 — 2026-05-28¶
CLI Variant B foundation: staple-cli chat <agent> interactive REPL.
Opens locked-roadmap §4.5. Foundation only — name resolution and streaming land in v2.42.0.1+.
cmd/staple-cli/chat.go (new)¶
staple-cli chat <agent-uuid|"me">opens an interactive REPL with the target agent. Each user line POSTs to the chat substrate; the agent reply prints on the next line.- Auth:
STAPLE_API_KEYenv var (agent bearer key). The CLI callsGET /api/agents/meto confirm the bearer and discover the company. Board-user bearers are not supported in v2.42.0.0 — the identity probe is agent-only — and surface a clear error. - Server URL:
STAPLE_BASE_URLenv var (defaulthttp://localhost:3100). - Chat lifecycle:
POST /api/companies/{companyId}/chatsopens a session attached to the target agent; per-turnPOST /api/chats/{chatId}/messagessends the user message and receives the agent reply synchronously. - Local slash commands:
/quit,/exit,/help,/clear. Dispatched client-side; never reach the server. - HTTP timeout: 60s per request (long enough for a slow LLM call, short enough that a stuck network doesn't hang the REPL).
Scope decision — name resolution deferred¶
The original v2.42.0.0 spec called for agent NAME resolution as well
as UUID. Mid-implementation discovery: the Staple JSON API has no
"list agents by company" or "find agent by name" endpoint today.
Rather than invent a new server-side endpoint mid-release, v2.42.0.0
ships UUID-only with a clear error message ("not implemented in
v2.42.0.0 — pass a UUID or the alias \"me\"") and the literal "me"
alias for the bearer agent itself. v2.42.0.1 adds the name lookup
endpoint and CLI-side resolution.
Scope decision — streaming deferred¶
The server's UIChatSendStream is session-cookie + CSRF-bound today
(UI-only). The CLI uses the synchronous JSON POST instead, which
returns the full agent message in one response. v2.42.0.1 either
adds a bearer-friendly streaming endpoint or wraps the existing one.
cmd/staple-cli/main.go — dispatcher entry¶
- New
case "chat"afterunpair-user. - Usage line added:
chat Interactive REPL with an agent (v2.42.0.0).
Tests¶
cmd/staple-cli/chat_test.go—looksLikeUUIDstructural sniff (length + dash positions + hex).chatResolveTarget: "me" alias, UUID passthrough, name-error surfaces the v2.42.0.0 scope message, whitespace strip.chatProbeIdentity: happy path againsthttptest.Server, 401 surfaces friendly "invalid or expired" message.chatOpenSession: URL + request body shape, ID extraction.chatSendMessage: happy round-trip, server error.message envelope passthrough, empty-reply fallback.chatLoop: local-command short-circuit, EOF graceful exit, blank-line skip, full round-trip with reply printing.
Migration count¶
Unchanged from v2.41.0.1: 104.
v2.41.0.1 — 2026-05-28¶
pgvector embedding closure — provider compute + retrieval-time integration.
Closes locked-roadmap §4.4 C+. v2.41.0.0 shipped the schema + worker shell. v2.41.0.1 wires the actual provider-side embedding call and a retrieval-time helper for chat context.
Feature flag: STAPLE_EMBEDDING_ENABLED=true is REQUIRED to engage.
Default behavior is unchanged from v2.41.0.0 — the worker logs the
backlog and skips provider compute, and RetrieveSimilarNotesForQuery
returns (nil, nil). Operators opt in only after reviewing cost.
internal/engine/adapters/embeddings.go (new)¶
ComputeOpenAIEmbedding(ctx, baseURL, apiKey, text, model)— POSTs to{baseURL}/embeddingswith the openai-style body{"input": text, "model": model}and parses the first vector fromresponse.data. Defaults:model="text-embedding-3-small",baseURL="https://api.openai.com/v1".- Free function (not attached to OpenAICompatibleAdapter) so the worker can call it without threading per-adapter struct allocation.
- Failure modes return error: missing api_key, blank text, non-2xx, empty response.data.
internal/worker/note_embedding.go (rewritten)¶
- New constructor:
NewNoteEmbedding(pool, encryptionKey). The encryption key is needed to decrypt the per-company provider api_key. runOncenow:- Reads
STAPLE_EMBEDDING_ENABLEDon every tick (flippable without restart). - When off: behaves identically to v2.41.0.0 shell ("would embed N notes" log line).
- When on: per note, resolves the first enabled
openai_compatibleprovider for the note's company, computes the embedding oftitle + "\n\n" + body, and writes viaUpsertNoteEmbedding. - Provider lookup is cached per tick per company (one DB read per company per tick rather than per note).
- Per-note failures (missing provider, compute error, upsert error)
Warn-log and continue — never abort the loop. Per-tick summary
logs
embedded,skipped_no_provider,failed.
internal/engine/chat_context_retrieval.go (new)¶
RetrieveSimilarNotesForQuery(ctx, pool, encryptionKey, companyID, queryText, limit) ([]Note, error)— semantic-retrieval helper for dispatchers.- Returns
(nil, nil)when the feature flag is off, when inputs are blank, or when any internal step fails (no provider, embedding compute error, similarity search error). Dispatchers can call it unconditionally and merge the result with their existing recency-based digest, deduping on note ID. - Logs failures at Debug — a missing provider per company is expected and shouldn't spam Warn.
- Uses the same model (
text-embedding-3-small) as the worker; mismatched models produce semantically unaligned vectors and bad rankings.
Cost implications when engaged¶
- Each note write triggers one
POST /v1/embeddingscall. Public OpenAI pricing fortext-embedding-3-smallis ~$0.02 per 1M input tokens; a typical 500-token note costs ~$0.00001. - Each chat turn triggers one additional
POST /v1/embeddingsfor the query side. - Per-tick scan is bounded at 50 notes per company; the worker tick rate is 60s — sustained per-company cost ceiling is bounded.
Wiring¶
cmd/server/main.goupdated to passcfg.EncryptionKeyinto the new constructor signature. Startup log changed from "(shell mode)" to mode-agnostic — the worker logsmode=shellormode=liveitself.
Tests¶
internal/engine/adapters/embeddings_test.go— happy path, default-model substitution, missing-api-key fail-fast, blank-text fail-fast, non-2xx surfaces body, empty-data treated as error. Useshttptest.Serverfor the provider mock.internal/worker/note_embedding_test.go— extends the v2.41.0.0 constructor smoke; adds env-flag parsing table-test (strict "true" match only); guards a nil-pool runOnce against panic.internal/engine/chat_context_retrieval_test.go— feature-flag-off table, blank-input short-circuit, nil-pool safety.
Migration count¶
Unchanged from v2.41.0.0: 104.
v2.41.0.0 — 2026-05-28¶
pgvector embedding foundation — FOUNDATION ONLY.
Opens locked-roadmap §4.4 C+. Ships the pgvector extension + a
note_embeddings table + the query layer (UpsertNoteEmbedding,
ListNotesNeedingEmbedding, FindSimilarNotes) + an embedding worker
SHELL.
Scope: the worker is SHELL-ONLY in v2.41.0.0. It scans for notes
that need embedding and logs "would embed N notes" at Info, but
doesn't actually compute. Staple's adapter layer has no generic
embeddings endpoint plumbed today (no /v1/embeddings call site in
internal/engine/adapters/*.go), so the provider-side call lands in
v2.41.0.1 when the adapter surface grows an Embeddings() method.
Retrieval-time integration (using embeddings to rank notes in chat context) is OUT OF SCOPE for v2.41.0.0 — that's v2.41.0.1+.
Prereq: pgvector extension¶
Migration 103 runs CREATE EXTENSION IF NOT EXISTS vector at goose-up
time. This requires the postgres image to have pgvector available.
docker-compose.yml has been updated to use pgvector/pgvector:pg16
— a drop-in superset of postgres:16 with pgvector pre-installed.
The existing pgdata volume mounts cleanly into the new container
without re-init (the on-disk data format is identical).
Operators running a custom postgres deployment must install
pgvector before v2.41.0.0 migrates. The CREATE EXTENSION fails fast
if the extension's .so isn't available, and goose marks the
migration as not-applied — the operator can fix the prereq and re-run
without DB damage.
Migration 103 — pgvector + note_embeddings table¶
internal/db/migrations/103_note_embeddings.sql:
CREATE EXTENSION IF NOT EXISTS vector.note_embeddings (note_id PK, company_id, embedding vector(1536), model text, updated_at timestamptz).note_idcascades tocompany_notes;company_idcascades tocompanies.- Index on
(company_id)for per-tenant filtering. - HNSW index on
embedding vector_cosine_ops— the standard opclass for normalized-vector cosine queries.
vector(1536) matches OpenAI text-embedding-3-small dimensions
— the worker hard-codes this for v1.
Migration 104 — RLS on note_embeddings¶
internal/db/migrations/104_rls_note_embeddings.sql:
- Mirrors the pattern from migrations 091 / 094 / 096 / 100:
ALTER TABLE … ENABLE ROW LEVEL SECURITY+ per-companyUSING + WITH CHECKpolicy bound tocurrent_setting('app.current_company_id').
internal/db/query/note_embeddings.go (new)¶
UpsertNoteEmbedding(ctx, pool, params)— write-or-replace one row keyed onnote_id. Validates dim (1536), NoteID, CompanyID, Model before any DB call. Goes throughscope.WithTenantScopeso RLS enforces tenant isolation.ListNotesNeedingEmbedding(ctx, pool, limit)— bounded scan for the worker. Returns notes whose embedding doesn't exist OR is stale (note.updated_at > embeddings.updated_at). Default limit 50, max- Archived notes excluded. Goes through
scope.WithBypassbecause the worker is instance-wide. FindSimilarNotes(ctx, pool, companyID, embedding, limit)— cosine-similarity nearest-neighbour using pgvector's<=>operator (returns cosine distance;ORDER BY ASCgives nearest-first). Validates dim. Default limit 10, max 100. Goes throughscope.WithTenantScope.formatVectorLiteral([]float32) string— renders a float slice as[0.1,0.2,...]for pgvector's literal-on-insert path. Hand-rolled to avoid pulling inpgvector-gofor one string formatter.
internal/worker/note_embedding.go (new) — SHELL ONLY¶
NewNoteEmbedding(pool)constructor.Start(ctx)ticks every 60s (first tick fires immediately).- Per tick: scan via
ListNotesNeedingEmbedding(50), lognote embedding: would embed notes (shell mode) {notes_total, companies}at Info if N > 0. Errors log at Warn but never abort the loop. - v2.41.0.1 replaces the log line with the actual embedding compute
UpsertNoteEmbeddingper note.
cmd/server/main.go¶
- Worker wired alongside
pairingCleanupandnotesRetention:noteEmbedding := worker.NewNoteEmbedding(pool); go noteEmbedding.Start(ctx).
Tests¶
internal/db/query/note_embeddings_test.go:
formatVectorLiteralshape pins (empty / single / multi / signs).- 1536-dim render produces a well-formed literal (correct comma count, correct bracketing).
UpsertNoteEmbeddingdim validation fires before DB call (nil pool- bad slice → error).
UpsertNoteEmbeddingrequired-field validation fires before DB call.FindSimilarNotesdim validation fires before DB call.
internal/worker/note_embedding_test.go:
NewNoteEmbeddingreturns a non-nil worker with defaults set.
Smoke-test recipe¶
After deploy, with pgvector image running:
ssh node4 'docker exec staple-postgres-1 psql -U staple staple_dev -c \
"select extname from pg_extension where extname='\''vector'\'';"'
ssh node4 'docker exec staple-postgres-1 psql -U staple staple_dev -c \
"\\d note_embeddings"'
ssh node4 'sudo journalctl -u staple --since "60 seconds ago" | grep "note embedding"'
The journal should show note embedding worker started (shell mode).
v2.40.0.0 — 2026-05-28¶
MCP server foundation — FOUNDATION ONLY.
Opens locked-roadmap §4.3 G. Staple now speaks the Model Context
Protocol (MCP) on POST /mcp/{companyId} as a JSON-RPC 2.0 endpoint.
Tools come from the per-company typed-tools registry built in
engine.TypedToolsForCompany (the v2.39 work). External MCP clients
(Claude Desktop, the MCP inspector, etc.) can discover and invoke
Staple tools using the Authorization-Bearer key infrastructure that
already authenticates the rest of the Staple API.
Scope: MCP SERVER direction only — Staple ACTS AS an MCP server
exposing its typed tools. The MCP CLIENT direction (Staple consuming
external MCP servers via sampling/createMessage) is OUT OF SCOPE
for v2.40.0.0; it lands in v2.40.0.1+.
Methods supported in v2.40.0.0:
initialize— handshake. EchoesprotocolVersion=2024-11-05, advertises{"tools": {}}as the only capability, identifies the server asstaplewith the current build version.tools/list— returns the per-company typed-tools registry as aToolDescriptor[]. Sorted alphabetically (registry.All ordering).tools/call— invokes a tool by name with a JSON-RawMessage arguments payload. Result is the tool's raw JSON output wrapped in a singletextcontent block (the standard MCP convention).notifications/initialized— post-handshake fire-and-forget notification. Sinks to HTTP 200 with empty body per spec.
Methods returning MethodNotFound (-32601):
resources/*— Staple doesn't expose long-lived MCP resources yet; lands in v2.40.0.2+.prompts/*— engine-internal prompt rendering is a different scope.sampling/createMessage— the inverse-client direction (v2.40.0.1+).
internal/mcp (new package)¶
protocol.go:
JSONRPCRequest/JSONRPCResponse/JSONRPCErrorenvelopes with the standard MCP error code constants (ErrCodeParseError,ErrCodeInvalidRequest,ErrCodeMethodNotFound,ErrCodeInvalidParams,ErrCodeInternalError).NewSuccessResponse/NewErrorResponseconstructors.- Method-specific shapes:
InitializeParams/InitializeResult,ToolDescriptor/ToolsListResult,CallToolParams/CallToolResult,ContentBlock. MCPProtocolVersion = "2024-11-05"— the spec revision most extant MCP clients (Claude Desktop, claude-code,@modelcontextprotocol/ inspector) target as of this release.
internal/handler/mcp.go (new file)¶
MCPHandler(pool, eng)— the JSON-RPC handler forPOST /mcp/ {companyId}.- Auth:
BearerAuthmiddleware (the same one the rest of the API uses) resolves the Authorization header into anauth.Actor. The handler enforces thatactor.CompanyID == {companyId}. Cross- tenant access returns HTTP 404 — same opacity shape A2A uses to avoid leaking membership state. - Per-company isolation enforced twice: at the auth layer (above)
AND at the tool layer (every tool in the per-company registry is
constructed with a baked-in
companyID). - Tool-call errors come back as
CallToolResult{IsError: true, Content: [...]}(per MCP spec — tool failures are NOT JSON-RPC errors). - JSON-RPC-level errors (malformed params, unknown method, bad jsonrpc version) come back via the standard error envelope.
Route mount¶
cmd/server/routes.go:
r.Post("/mcp/{companyId}", handler.MCPHandler(pool, eng))mounted in the authenticated-API group (BearerAuth + rate limit + body-size caps inherited from the surroundingr.Group).
Tests¶
internal/mcp/protocol_test.go:
JSONRPCRequest/JSONRPCResponsemarshal + roundtrip.- Success vs. error envelope shape (exactly-one-of result/error).
InitializeResultshape (protocolVersion + capabilities.tools + serverInfo).ToolsListResult— inputSchema embedded as JSON, not stringified.CallToolParamsroundtrip preserving arguments as raw JSON.CallToolResultshape: success omitsisError, error setsisError: true.- Error code numeric values pinned (the spec-mandated -32600 range).
internal/handler/mcp_test.go:
- Missing actor → 404.
- Cross-company actor → 404.
- Non-POST method → 405.
- Bad JSON body → JSON-RPC parse error.
- Wrong
jsonrpcversion → InvalidRequest. initialize→ 200 with protocolVersion + capabilities + serverInfo.notifications/initialized→ 200 with no body.- Unknown method (e.g.
resources/list) → MethodNotFound. tools/list→ 4 tools sorted alphabetically; each Input schema parses as JSON; descriptions non-empty.tools/callwith nonexistent tool → success envelope withIsError: truecontent block.tools/callwith empty name → InvalidParams JSON-RPC error.
Smoke-test recipe¶
After deploy, with a valid Staple Bearer API key for a known company:
curl -s -X POST http://localhost:3100/mcp/<company-id> \
-H "Authorization: Bearer $STAPLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Should return a JSON-RPC envelope with a tools array containing
/get-agent, /list-issues, /list-notes, /list-team.
v2.39.0.1 — 2026-05-28¶
Three more typed-tool migrations: /list-issues, /list-notes, /get-agent.
Continues locked-roadmap §4.2 E. v2.39.0.0 shipped the registry + schema generator + one canary tool (/list-team). This release migrates three more read-style verbs to the typed-tools surface so the registry + schema generator get exercised against diverse Input shapes before the MCP-server work (§4.2 G, v2.40) goes live.
Scope: three new typed-tool wrappers + per-company registration
in engine.typedToolsFor. The string-keyed ParseAgentActions table
still dispatches at runtime; the typed Tools are populated but not
yet consumed by the dispatcher. v2.39.0.1 → v2.40 ships the MCP
bridge; the in-engine dispatcher flip lands separately.
internal/engine/typedtools (extends existing package)¶
list_issues.go (new):
ListIssuesInput: optionalstatus+limit(bothomitempty).ListIssuesOutput:[]ListIssuesItem{id, identifier?, title, status, priority, assignee_agent_id?}— read-only projection of Issue.Invokecallsquery.ListIssueswith aListIssuesParamspayload; default limit 50, max 100 to bound MCP-client requests.
list_notes.go (new):
ListNotesInput: optionallimit(omitempty).ListNotesOutput:[]ListNotesItem{id, title, body_markdown, kind, tags}— read-only projection of Note.Invokecallsquery.ListNotesForUserwith emptyuserID, which the underlying SQL maps to "only agent-authored notes" (the WHERE OR-branches onauthor_agent_id IS NOT NULL). User-private notes never leak via the typed-tool surface — correct framing for MCP exposure where the caller authenticates as the company, not a specific user.- Default limit 50, max 200.
get_agent.go (new):
GetAgentInput: requiredagent_id.GetAgentOutput:{found: bool, agent?: GetAgentInfo}— the shape lets MCP clients distinguish "tool ok, agent missing" from "tool errored".GetAgentInfois a small projection (id, name, role, status, title?, description?, adapter_type, model?, reports_to?) — excludes credentials, system prompt, runtime counters that the LLM doesn't need.- Cross-tenant filtering:
GetAgentByIDis by primary key and has no tenant predicate. The Tool checksagent.CompanyIDagainst the per-company binding and returnsFound: falseon mismatch — doesn't leak "wrong tenant" as a distinct error code.
Engine wiring¶
internal/engine/engine.go:
typedToolsFornow registers all four tools per company viaMustRegister. Collision panics surface at boot, not runtime.
Tests¶
internal/engine/typedtools/schema_test.go:
TestV2390_1ToolSchemasParseAsJSON— all four Input/Output schemas parse cleanly as JSON, all Names start with/, all Descriptions non-empty.TestListIssuesSchemaShapepins the/list-issuesInput shape (status + limit, both optional → norequired[]).TestListNotesSchemaShapepins the/list-notesInput shape (limit only, optional → norequired[]).TestGetAgentSchemaShapepins the/get-agentInput shape (agent_id →required[]).
internal/engine/typedtools_engine_test.go:
TestEngineTypedToolsForCompanyRegistersCanaryupdated to assert all four tools are present.
v2.39.0.0 — 2026-05-28¶
Typed-tools registry foundation — FOUNDATION ONLY.
Opens locked-roadmap §4.2 E. Today, action verbs live as string-keyed
entries in engine/actions.go::ParseAgentActions and each verb's
payload is a free-form string the handler parses inline. Schema lives
in prose in the system prompt. This release lays the groundwork for
the typed-tool surface that the MCP-server work (§4.2 G, v2.40) will
build on.
Scope: registry + dispatcher + JSON-Schema generator + one canary
tool (/list-team) migrated. The string-keyed ParseAgentActions
table keeps shipping unchanged at runtime. The typed registry is
populated but not yet consumed by the dispatcher. v2.39.0.1+ will
flip the dispatcher to prefer the typed registry when both are
present.
internal/engine/typedtools (new package)¶
registry.go:
Toolinterface:Name() / Description() / InputSchema() / OutputSchema() / Invoke(ctx, rawInput) (rawOutput, error).Registrywith O(1) lookup + sorted iteration viaAll().Registerfails fast on duplicates (collision is a programmer error; the silent-overwrite in the string-keyed v0 has bitten us before).MustRegisterpanics for boot-time registration call sites.Invokedispatches by name; missing tool returnsErrToolNotFound.Lencheap counter for hot-path short-circuits.- Concurrency:
sync.RWMutex— safe for concurrent reads + occasional writes.
schema.go:
SchemaFromStructwalks a Go type via reflection and emits a minimal JSON Schema (draft 2020-12).- Supported: string, int, uint, float*, bool, []T, struct, pointer-to-struct.
- Tag handling:
json:"name"rename,json:",omitempty"→ optional,json:"-"→ skip, unexported fields → skip. - Unsupported types fall back to
{}(the anything-goes shape). - Hand-rolled rather than a dependency: schemas are tiny (1-5 fields per tool), full control of output shape matters for v2.40 MCP rendering.
list_team.go:
NewListTeamTool(pool, companyID)constructs the per-company binding. Cross-tenant leakage is impossible because the companyID is baked into the Tool at construction, not Invoke.Invokecallsquery.ListAgentsByCompany, filters tostatus='active', and emits a small read-only projection ({agent_id, name, role, title?, description?}). Matches the observable behavior of the skill version so the dispatcher flip in v2.39.0.1 doesn't cause behavior drift.
Engine wiring¶
internal/engine/engine.go:
Engine.typedToolsByCompany sync.Mapcaches per-company registries.typedToolsFor(companyID)builds lazily on first access; the exported aliasTypedToolsForCompanyis for tests + the future dispatcher.- Each per-company registry gets the
/list-teamcanary registered at build time viaMustRegister.
Tests¶
internal/engine/typedtools/registry_test.go:
New()empty.- Register nil / empty-name / whitespace-name → error.
- Duplicate name → error.
MustRegisterpanics on duplicate.- Lookup hit + miss + whitespace-trim.
All()sorted alphabetically.Len()counts correctly.Invokeroutes correctly + propagates tool errors.Invokemissing tool →ErrToolNotFound(errors.Is-friendly).
internal/engine/typedtools/schema_test.go:
- Primitive types: string / int / uint / float / bool.
- Slice of primitives.
- Empty struct.
- Struct with required + optional fields.
- All-optional struct emits no
requiredkey. jsontag rename +,omitempty+-skip.- Unexported field skip.
- Pointer-to-struct unwraps.
- Nested struct + slice composition.
- Unsupported type (chan) returns
{}. /list-teamschemas smoke (neither input nor output is empty).
internal/engine/typedtools_engine_test.go:
TypedToolsForCompanyregisters the canary.- Same companyID returns the same Registry pointer (cache hit).
- Different companyIDs return different Registry pointers.
v2.38.0.2 — 2026-05-28¶
Notes TTL retention worker + agent settings auto-extract toggle.
Closes locked-roadmap §4.1 Q5 (TTL-by-kind) and surfaces the §4.1 Q1 hybrid-second-half toggle in the agent settings UI. v2.38.0.1 shipped the engine + DB column; this slice adds the operator-facing configurability and the bounded-growth retention pass.
Notes TTL-by-kind worker¶
internal/worker/notes_retention.go (new):
- Ticks every 6 hours (matches the pairing-cleanup cadence).
- Per-tick passes (independent; one failing doesn't short-circuit the other):
kind='auto_extracted': 90-day TTL. Heuristic captures are cheap to re-emit on the next heartbeat; short TTL caps noise-floor growth.kind='agent_authored': 365-day TTL. Findings the agent explicitly wrote via/record-finding. One year balances "real content" against unbounded growth.kind='user_authored': NEVER expired by this worker. Operator data is only touched by operator-initiated archive/delete.- Errors log at Warn but never abort the loop — a transient DB hiccup must not break retention.
- Runs inside
scope.WithBypass: the WHERE is kind-keyed, not company-scoped; the worker is instance-wide.
internal/db/query/notes.go:
CleanupExpiredNotesByKind(ctx, tx, kind, maxAge)adds the per-kind DELETE. Refuses to acceptuser_authored(returns an error) — a misconfigured worker must not silently destroy operator data.
Refetch-tracking (skip-TTL when an auto_extracted note was actually
referenced by a later retrieval) lands in v2.41 with the pgvector
embedding layer. v2.38.0.2 uses pure created_at.
UI toggle on agent settings¶
internal/web/templates/agents/show.html: a new "Auto-extract findings"
row in the Properties card, sitting alongside the existing "A2A access"
toggle. Shows enabled/disabled badge + Enable/Disable button (HTMX
post + page reload). Help text explains the closed-vocabulary tag set
and notes that /record-finding works regardless of this flag.
internal/handler/ui_agents.go:
UIAgentSetAutoExtractFindingsmirrorsUIAgentSetExternallyAvailableexactly: singlevalueform field parsed as boolean, idempotent, HX-Redirect on success.
cmd/server/routes.go:
POST /agents/{agentId}/ui/set-auto-extract-findingsregistered next to the existing externally-available endpoint.
Engine wiring touched¶
cmd/server/main.go:
- Starts the notes retention worker after the pairing-cleanup worker in the boot sequence.
v2.38.0.1 — 2026-05-28¶
Per-agent auto-extract classifier — Q1 hybrid second half.
Closes the hybrid trigger story for locked-roadmap §4.1. The v2.38.0.0
slice shipped the explicit-verb half (/record-finding writes an
agent_authored note when the LLM lands on something worth recording).
This release adds the per-agent classifier backstop: when an operator
opts in via agents.auto_extract_findings=TRUE, the engine scans the
assistant output of each heartbeat run and writes a note with
kind='auto_extracted' when at least one recognized inline #-tag
appears in the prose, even if the agent didn't fire /record-finding.
Heuristic v1 reuses the same closed vocabulary as the explicit verb
(#decision, #finding, #attempted, #failed, #escalation,
#working, #blocked). No second LLM call — the regex walk is
deterministic and fast. v2.38.0.2+ can swap in an LLM-driven
classifier without touching the call site.
Migration 102 — agents.auto_extract_findings¶
internal/db/migrations/102_agents_auto_extract_findings.sql:
auto_extract_findings BOOLEAN NOT NULL DEFAULT FALSE. Opt-in per-agent. Operators toggle via the agent settings UI landing in v2.38.0.2 (this release is the engine half; UI ships next tag).
Migration count: 101 → 102.
Query + engine¶
internal/db/query/agents.go:
AgentgainsAutoExtractFindings bool. Default zero-value FALSE matches the DB default, so callers that don't care continue to work.UpdateAgentParamsgains the matching*boolso the UI handler can flip it without touching the row's other columns.agentColumns+scanAgent+ListAgentsWithLastRun's scan now read the new column in the canonical order.
internal/engine/auto_extract.go (new):
classifyForAutoExtractwalksfindingTagPatternmatches directly (NOT viaextractFindingTags, because that helper substitutes thefindingTagDefaultsentinel when nothing matches, which would collide with a real#findinghit). ReturnsAutoExtractCandidate{Interesting, Tags, Title, Body, Reason}.firstNonEmptyLinederives the auto-extracted note's title from the first non-blank line of the run output, capped at 80 chars with ellipsis truncation.(*Engine).MaybeAutoExtractRunis the integration point: gates onagent.AutoExtractFindings, runs the classifier, writes the note viaquery.CreateNotewithKind=NoteKindAutoExtracted. Errors log at Warn but never fail the heartbeat — auto-extract is best- effort by design.
internal/engine/adapter.go:
- Step 5a inserted after the run's final comment is posted and before
the trace write.
e.MaybeAutoExtractRun(ctx, agent, runID, result.Output)runs there for every adapter the engine dispatches through; no per-adapter wiring needed.
Tests¶
internal/engine/auto_extract_test.go covers:
- Empty / whitespace-only input → not interesting.
- Plain prose without recognized tags → not interesting (the default
fallback in
extractFindingTagsis sentinel-only). - Single recognized
#finding→ interesting withtags=[finding]. - Multiple recognized tags → deduped + sorted alphabetically.
- Unrecognized
#unknownfiltered out. - URL fragment
https://x#decisionis NOT a match (regex requires whitespace/SOL before#). - Case-insensitive matching canonicalizes to lowercase.
- Full vocabulary sweep returns all seven tags sorted.
firstNonEmptyLineskips blank lines, handles empty input, truncates oversize lines at maxLen-1 + ellipsis.
The DB-side MaybeAutoExtractRun integration is not covered by these
tests — it needs a live pool. The engine integration test layer picks
that up when the v2.38.0.1 fixtures land there.
v2.38.0.0 — 2026-05-28¶
Auto-extract to Notes — foundation slice.
First sub-tag of v2.38.0 (locked-roadmap §4.1). The hybrid trigger
model from the roadmap is two halves: (1) an explicit /record-finding
verb the LLM can emit when it lands on a "this is worth remembering"
observation, and (2) a per-agent classifier toggle that auto-extracts
findings from prose even when the verb isn't used.
This release ships half 1 only — the explicit-verb path plus the schema and tag-extraction primitives the half-2 classifier will reuse. The per-agent classifier lands in v2.38.0.1.
Migration 101 — extend company_notes¶
internal/db/migrations/101_company_notes_kind_tags.sql:
kindTEXT NOT NULL DEFAULT'user_authored'with a CHECK constraint allowing'user_authored','agent_authored','auto_extracted'. Backfilled from the existing polymorphic author column at migration time so pre-101 rows arrive with the right kind.tagsTEXT[] NOT NULL DEFAULT'{}'. Distinct from the existinglabelscolumn (operator-applied free-form filtering);tagsis the LLM-extracted closed-vocabulary surface.- Index
idx_company_notes_kind_recent (company_id, kind, created_at DESC) WHERE archived_at IS NULLfor the retrieval-weighted lookups the auto-extract feature will need.
Migration count: 100 → 101.
Query layer¶
internal/db/query/notes.go:
Notestruct gainsKind stringandTags []string.CreateNoteParamsgainsKindandTags. WhenKindis empty the helper derives it from the author polymorphism (user →user_authored, agent →agent_authored) so every existing caller stays correct without code changes.Tagsis treated likeLabels— nil becomes[]string{}so downstream code can range over it without a nil-check.- Three new exported constants:
NoteKindUserAuthored,NoteKindAgentAuthored,NoteKindAutoExtracted. - Every SELECT column list in this file picks up the two new columns
via the central
scanNotehelper.
/record-finding action verb¶
internal/engine/actions.go:
- New verb
/record-finding <one-line title>(with/record_findingunderscore alias) followed by a REQUIRED triple-backtick fenced markdown body. Mirrors the/install-noteparse shape. - Emits
AgentAction{Kind: "record_finding", Value: title, Body: body}. Title-only or body-less invocations are dropped, with the verb line preserved in clean output (visible malformed-attempt signal matching every other v2.14.x install verb).
internal/engine/findings.go:
findingTagAllowlist— closed vocabulary:decision,finding,attempted,failed,escalation,working,blocked.extractFindingTags(body)— regex-based extractor that scans for inline#foohashtags. Pre-filters URLs, markdown headers, and prose#1 prioritypatterns. Deduplicates + sorts. Returns[]string{"finding"}when nothing matches so every recorded finding lands with at least one queryable tag.formatFindingTags(tags)— renders the confirmation-comment tag string (backtick-wrapped, space-separated).
internal/engine/adapter.go:
- New
case "record_finding":in the heartbeat action handler, next toinstall_note. Writes acompany_notesrow withkind=agent_authored,tags=extractFindingTags(body),author_agent_id=agent.ID. Posts a confirmation comment of the shape📝 Recorded finding: **<title>** (/notes/). Tags: …. Publishesnote_created+issue_updatedSSE events. Failure on CreateNote logs Warn but does not pause the heartbeat.
internal/engine/prompt.go:
- Operating-prompt vocabulary picks up a
/record-findingline under the heartbeat verbs section so the LLM knows it exists and how it differs from/install-note(operator scratchpad) and/create-issue(actionable work).
Test coverage¶
internal/engine/actions_test.go— 4 new cases mirroring the/install-notetest shape: valid happy path, underscore alias, missing-title drop, missing-body drop.internal/engine/findings_test.go— 10 cases forextractFindingTags: empty-body fallback, single tag, multi-tag dedup, case-insensitivity, unknown-tag rejection, URL-fragment rejection, markdown-header rejection, prose-numeric rejection, exact-duplicate collapse, full allowlist round-trip. Plus 3 cases forformatFindingTags.
What's deferred to v2.38.0.1¶
- Per-agent classifier toggle: an agent without
/record-findingemission should still get notes auto-extracted from its prose. The classifier readsagents.auto_extract_findings BOOL(column to be added in the next slice) and runs against every heartbeat reply. - Retrieval surface:
query.ListRecentFindingsForCompany(companyID, tags, limit)for the inbox/dashboard auto-extract panel.
Backward compatibility¶
All existing CreateNote callers (chat handlers, UI handler,
heartbeat install_note) leave Kind and Tags empty. The query
helper derives Kind from the author polymorphism, so the behavior
of pre-v2.38.0.0 code paths is byte-identical to before the schema
change.
Migration count¶
Migration count: 100 → 101.
v2.37.0.3 — 2026-05-28¶
DM pairing — auto-revoke + cleanup worker + admin audit UI.
Fourth and final sub-tag of v2.37.0 (locked-roadmap §2.4). Closes the full pairing lifecycle: every pair, unpair, and auto-revoke now writes an audit row, and operators have a read-only admin page to inspect active pairings + recent events per company.
Auto-revoke query helpers¶
internal/db/query/pairing.go:
AutoRevokeUserPairings(ctx, pool, userID, action, reason)— deletes every identity row for a user across all channels in one shot, then writes one audit row per deletion withactor_kind=system, actor_user_id=nil. Used by the user-deactivation hook.AutoRevokeInactivePairings(ctx, pool, threshold)— deletes identity rows whoselast_seen_at < now() - threshold, writes one audit row per deletion withaction=auto_revoke_inactive. Used by the cleanup worker.ListUserChannelIdentitiesForCompany(ctx, pool, companyID, limit)— per-company sibling ofListUserChannelIdentitieswith a LEFT JOIN tousers.emailfor human display. Returns[]CompanyChannelIdentity.
Pairing cleanup worker¶
New internal/worker/pairing_cleanup.go:
PairingCleanupstruct +NewPairingCleanup(pool).Start(ctx)ticks every 1 hour. Each tick runsquery.CleanupExpiredPairingCodesthenquery.AutoRevokeInactivePairings(90 * 24h). Initial run on startup so post-deploy garbage is swept without waiting an hour.- Errors log at Warn but never abort the loop.
- Wired into
cmd/server/main.gonear the other periodic workers (session cleanup, retention, evolution retention).
User deactivation auto-revoke¶
internal/handler/user_handlers.go::UpdateUserAPI:
- Reads pre-change
is_activeto detect a true→false transition (deactivation). On deactivation, callsquery.AutoRevokeUserPairings(userID, PairingActionAutoRevokeDeactivated, "user account deactivated")after the UPDATE succeeds. - Failures log at Warn but do not fail the deactivation — the admin's primary intent (account off) already succeeded.
Admin pairings audit page¶
- New
internal/handler/ui_pairings_audit.go::UIPairingAuditLogatGET /companies/{companyId}/pairings/audit. Instance-admin-only; matches the egress/secrets pattern. - New template
company_pairings_audit/index.htmlwith two tables: "Active pairings" (user email + channel + external_user_id + paired_at + last_seen_at, top 200) and "Recent audit events" (timestamp + action + actor_kind + actor_user_id + subject user + channel + reason, top 200). - Sidebar entry added under the instance-admin block between "Egress" and "Instance Settings": link icon + "Pairings audit".
activePageFromTemplatemapscompany_pairings_audit/→"company_pairings_audit".
Audit coverage¶
Every state transition on a Staple-user × channel pairing now writes a
pairing_audit_events row:
| Path | action | actor_kind |
|---|---|---|
/pair <code> redeemed via bot |
pair |
bot |
/unpair DM |
unpair_bot |
bot |
| UI Unpair button | unpair_ui |
ui |
staple-cli unpair-user |
unpair_cli |
cli |
| User deactivated | auto_revoke_deactivated |
system |
| 90-day inactivity | auto_revoke_inactive |
system |
The membership-removal hook (auto_revoke_membership_removed) is
defined at the data layer but the integration point (a clean
"member removed from company" handler) is deferred — v2.37.0.3 ships
the helper without the trigger so a future slice can wire it in
without re-cutting the schema.
Migration count¶
No database changes — migration count stays at 100.
v2.37.0.2 — 2026-05-28¶
DM pairing — dispatcher refactor + 3-actor revocation.
Third sub-tag of v2.37.0 (locked-roadmap §2.4). Closes the
revocation half of the pairing surface so users (bot + UI),
operators (CLI), and the dispatcher itself all read from the new
user_channel_identities table.
Dispatcher refactor¶
internal/channels/slack/dispatcher.go— newresolveSlackActorhybrid lookup:user_channel_identitiesfirst, fall back to legacyusers.slack_user_id. Toucheslast_seen_aton every successful hot-path identity lookup (fire-and-forget goroutine with 5s timeout) so the 90-day auto-revoke worker (v2.37.0.3) gets fresh data. Errors at the resolve layer log warn but never break message routing.internal/channels/telegram/dispatcher.go— symmetricresolveTelegramActorwith the same fallback + last_seen-touch shape.
Bot /unpair command¶
internal/channels/pairing/pairing.go:UnpairCommandconst =/unpair.IsUnpairCommand(text)— case-insensitive strict parser. Accepts/unpairalone with optional whitespace; rejects/unpairfoo,/unpair extra, embedded mid-sentence.HandleUnpair(ctx, pool, channel, externalUserID)— looks up the identity, deletes it, writes apairing_audit_eventsrow withaction=unpair_bot, actor_kind=bot, returns the user-facing reply. Idempotent — returns "not paired" for already-unpaired users.- Slack dispatcher intercepts
/unpairDMs before the allowlist gate (same shape as/pair). - Telegram dispatcher intercepts
/unpairDMs in the private chat type branch (group unpair has no per-user model).
UI Unpair button¶
internal/handler/ui_pairing.go— newUIUserPairingDelete(pool)handlesPOST /me/pairing-codes/delete. Acceptschannel=slack| telegram, resolves the user's primary company, looks up the identity, deletes it, writes apairing_audit_eventsrow withaction=unpair_ui, actor_kind=ui, redirects to/me/settings?unpaired=<channel>. Idempotent.internal/handler/ui_user_settings.go—UIUserSettingsShownow loadsquery.ListUserChannelIdentitiesfor the user's primary company and surfaces them as.LinkedIdentities. Also picks up?unpaired=…for the success flash.internal/web/templates/users/settings.html— new "Currently paired" section with a table per identity (channel, external_user_id, paired_at, last_seen_at) and an inline Unpair form with a confirm dialog. Empty state renders a muted "No pairings yet" line.- Green flash banner on
?unpaired=<channel>matches the existing?saved=1style.
staple-cli unpair-user¶
cmd/staple-cli/unpair_user.go— newunpair-user <email> <channel>subcommand. ReuseslookupUserForPairingfrompair_code.go, finds the matching identity row viaquery.ListUserChannelIdentities, deletes it, and writes apairing_audit_eventsrow withaction=unpair_cli, actor_kind=cli. Idempotent — prints "nopairing found for " and exits 0 when there's nothing to unpair.
Test coverage¶
internal/channels/pairing/pairing_test.go— 12 new cases forIsUnpairCommandcovering happy path, whitespace tolerance, case insensitivity,/unpairfoorejection,/unpair extrarejection, embedded text rejection, and the cross-confusion case where/pairmust not match.
Audit trail¶
All three revocation paths (bot, UI, CLI) now write
pairing_audit_events rows with the correct action + actor_kind
constants. Combined with v2.37.0.0's pair action on the link path,
the audit log is now a complete record of the pairing lifecycle.
What's deferred¶
- v2.37.0.3: auto-revoke triggers (deactivation, membership removal, 90-day inactivity worker) + admin audit-log UI panel.
Migration count¶
No database changes — migration count stays at 100.
v2.37.0.1 — 2026-05-28¶
DM pairing — bot /pair handler + UI profile button.
Second sub-tag of v2.37.0 (locked-roadmap §2.4). Surfaces the
v2.37.0.0 foundation through both user-facing entry points: the
Slack/Telegram bots intercept /pair <code> DMs before normal
routing, and the Profile → Settings page has new "Pair Slack" and
"Pair Telegram" buttons that mint codes and display them prominently.
New: shared internal/channels/pairing package¶
internal/channels/pairing/pairing.go:
- IsPairCommand(text) — case-insensitive /pair parser. Accepts
/pair, /pair <code>, leading/trailing whitespace, uppercase
command. Rejects /pairfoo, embedded mid-sentence, etc.
- HandlePair(ctx, pool, channel, externalUserID, code) — the full
pair flow: consume code → link identity → write audit row →
return user-facing reply string.
- Errors map to user-safe replies: ErrPairingCodeInvalid →
"code is invalid or has expired", ErrUserChannelIdentityConflict
→ "this account is already paired to a different Staple user".
Wired: Slack dispatcher (internal/channels/slack/dispatcher.go)¶
/pairDMs are intercepted BEFORE the allowlist gate so unpaired users (the whole point of pairing) can redeem codes without being inSTAPLE_SLACK_USER_ALLOWLISTfirst.- The non-allowlisted DM rejection message now includes the
/pair <code>hint. - Channel mentions (
KindChannelMention) don't get pair-routed — the bot was invited there by an operator and per-user identity isn't the gating model.
Wired: Telegram dispatcher (internal/channels/telegram/dispatcher.go)¶
/pairDMs intercepted in them.Chat.Type == "private"branch before normal chat-substrate routing. Returns immediately on success — no chat row, no LLM call, no transcript pollution.
New: UI surface¶
internal/handler/ui_pairing.go + extended
internal/web/templates/users/settings.html:
POST /me/pairing-codes/create— acceptschannel=slack|telegram, mints a 5-min code viaquery.GeneratePairingCode, redirects to/me/settings?pair_channel=…&pair_code=….- Settings page: two new "Pair Slack" / "Pair Telegram" buttons.
After clicking, an indigo-highlighted code panel appears at the
top with the
/pair <code>text the user pastes into the bot. - Existing manual-ID form ("Channel identities") preserved with a "(legacy)" subtitle so operators who prefer the pre-v2.37 workflow keep it.
Test coverage¶
internal/channels/pairing/pairing_test.go — 11 cases for
IsPairCommand: happy path, whitespace tolerance, case insensitivity,
no-code variant, rejection of /pairfoo and embedded text.
Behavior change¶
When STAPLE_SLACK_USER_ALLOWLIST is set (the standard prod posture):
- Users not in the allowlist can now redeem codes — the gate runs
AFTER the /pair intercept. Once paired, normal DM routing works
even though their Slack user_id isn't literally in the allowlist;
the dispatcher resolves via user_channel_identities first
(refactor lands in v2.37.0.2; v2.37.0.1 keeps the legacy lookup
path as the secondary).
What's deferred¶
- v2.37.0.2: dispatcher refactor to read from
user_channel_identitiesfirst, falling back tousers.slack_user_id/users.telegram_user_id. Also:staple-cli unpair-user, bot/unpair, UI Unpair button. - v2.37.0.3: auto-revoke triggers (deactivation, membership removal, 90-day inactivity worker) + admin audit-log UI panel.
v2.37.0.0 — 2026-05-28¶
DM pairing foundation — schema + query layer + operator CLI.
First sub-tag of v2.37.0 (locked-roadmap §2.4). Establishes the
durable surface: tables, query helpers, audit log shape, and an
operator-facing staple-cli pair-code command. The bot /pair
handler + UI button + revocation flows land in subsequent sub-tags.
New: migrations 097, 098, 099, 100¶
097_pairing_codes.sql— transient 8-char codes, 5-min TTL, single-use. Lowercase alphanumeric minus visually-ambiguous chars (no 0/o/1/l/i). Partial unique index on (code, consumed_at IS NULL) so the bot's hot path doesn't fight with expired rows.098_user_channel_identities.sql— durable Staple-user × channel pairings.identity_hash(sha256 ofchannel:external_user_id) links audit rows to identities without exposing the raw external id.last_seen_atadvances on every dispatcher message so the 90-day auto-revoke worker (v2.37.0.x) has fresh data.099_pairing_audit_events.sql— every pair/unpair/auto-revoke writes a row with action enum + actor_kind + actor_user_id + reason. 7 actions × 4 actor kinds. Partial revocations index excludes the high-cardinalitypairevents.100_rls_pairing_tables.sql— RLS isolation for all three.
New: query layer (internal/db/query/pairing.go)¶
GeneratePairingCode(userID, companyID, channel)— mints code viacrypto/randfrom the locked alphabet, inserts row with 5-min TTL.ConsumePairingCode(code, channel)— atomic UPDATE that marks consumed_at + returns user_id + company_id. Single sentinel errorErrPairingCodeInvalidfor missing / expired / already-consumed — no distinguishing detail (a leaked code shouldn't be probable).LinkUserChannelIdentity(...)—ON CONFLICT (user_id, channel, company_id) DO UPDATEfor idempotent re-pairs; returnsErrUserChannelIdentityConflictwhen a different Staple user already owns the external id.GetUserByChannelIdentity(channel, externalUserID)— dispatcher lookup path. UsesWithBypassbecause the dispatcher resolves identity → company in this call.ListUserChannelIdentities(companyID, userID)— for the UI profile page (v2.37.0.1).UnlinkUserChannelIdentity(...)— idempotent delete, used by all three revocation paths (UI, bot, CLI).TouchUserChannelIdentityLastSeen(channel, externalUserID)— the dispatcher calls this on every incoming message for the auto-revoke worker.InsertPairingAuditEvent(...)— durable audit write; must NOT fail silently (matches the pattern from secret_audit_events / egress_anomaly_events / a2a_dispatch_events).ListPairingAuditEventsForCompany(...)— for the admin audit view (v2.37.0.x).CleanupExpiredPairingCodes(...)— periodic worker hook.HashChannelIdentity(channel, externalUserID)— sha256-based helper used by both the identities table and audit rows.
New: staple-cli pair-code¶
cmd/staple-cli/pair_code.go:
Operator escape hatch + the UI's underlying call:
Prints the code + expiry timestamp to stdout. The operator relays
the code; the user DMs the bot with /pair <code> (lands in
v2.37.0.1). Resolves email → user_id + company_id from the user's
alphabetically-first company membership.
Test coverage¶
internal/db/query/pairing_test.go — 10 pure-Go tests covering:
- HashChannelIdentity stability across calls and divergence across
inputs.
- All 7 action constants in sync with migration 099's CHECK.
- All 4 actor-kind constants.
- PairingCodeAlphabet lacks ambiguous chars and uppercase.
- PairingCodeLength == 8 per the locked roadmap.
DB-dependent tests skip cleanly without Postgres (the same pattern
used across the rest of internal/db/query).
What's deferred to v2.37.0.1+¶
- Bot
/pair <code>DM handler (Slack + Telegram). - Profile-page UI button + countdown timer.
/unpairbot DM handler + UI Unpair button +staple-cli unpair-user.- Auto-revoke triggers (deactivation, membership removal, 90-day inactivity worker).
- Admin audit-log UI panel.
v2.36.x recap¶
Closed: v2.36.0 → v2.36.1.10 → v2.36.2 → v2.36.3.0 → v2.36.3.1. Container isolation #3 (full Track D arc) + H1 federation dispatch hardening + H2 streaming + H3 push notifications all live.
v2.36.3.1 — 2026-05-28¶
H2 — A2A streaming (message/stream).
Closes the v2.36.3 train. Adds the A2A-spec message/stream JSON-RPC
method so remote peers can subscribe to live task-state updates over
SSE instead of polling tasks/get. With v2.36.3.0 (push notifications)
already shipped, locked-roadmap §3.4 H2 + H3 are both delivered.
New: message/stream JSON-RPC method¶
internal/handler/a2a_rpc_stream.go:
- Hijacks the response writer to emit text/event-stream.
- Sends one initial frame on connect so the peer doesn't have to poll
just to learn the current state.
- Subscribes to the company-scoped sse.Hub, filters events by
issue_id, re-reads the issue on each matching event, and writes
a fresh Task{ID, State} frame as one SSE data: line.
- Stops streaming and closes the connection on:
- terminal task state (completed / canceled / failed)
- peer disconnect (ctx.Done)
- SSE channel close
Task ID resolution¶
extractTaskID accepts three shapes in order of preference:
1. params.metadata.taskId (string) — the A2A spec's
recommended place for operational metadata.
2. params.configuration.taskId (string) — fallback for peers that
read Configuration as the tuning-knob bucket.
3. Message with a "text" part whose Text starts with "task:<id>" —
v1 escape hatch for barebones clients.
Error message lists all three accepted locations so peer integrators can fix misshapen requests without reading source.
Architectural notes¶
- Reuses the existing company-scoped
sse.Hubrather than introducing a per-issue pub-sub: cardinality is low (dozens of agents per company in practice), and the filter-and-skip cost per event is microseconds. Adding a per-issue hub would have meant subscribing the engine's state-change emit sites in two places, which is a refactor we'd rather defer. - Each matching event triggers a fresh
query.GetIssueByIDbecause the hub'sEvent.Datais just an issue ID, not the new state. The DB hit is bounded by the issue change rate — well under the cost of polling. - The SSE write path uses the standard
data: <payload>\n\nframing. JSON-encoded payloads are single-line so we don't need the multi- linedata:continuation handling.
Test coverage¶
internal/handler/a2a_rpc_stream_test.go:
- TestExtractTaskID — 9 cases covering all three accepted shapes,
precedence order, empty / non-string / wrong-kind values, and the
"task:" prefix escape hatch (positive + negative).
- TestExtractTaskID_ErrorMentionsThreeSources — locks the error
message so peers get clear remediation guidance.
Closes the v2.36.3 train¶
| Sub-tag | What |
|---|---|
| v2.36.3.0 | H3 — push notification config + worker |
| v2.36.3.1 | H2 — message/stream JSON-RPC method |
Both H2 and H3 are now live. v2.36.4 (if needed) will add the admin UI for inspecting both surfaces; v2.37.0 (DM pairing) is the next major locked roadmap milestone.
v2.36.3.0 — 2026-05-28¶
H3 — A2A push notifications.
Second of three sub-slices closing locked-roadmap §3.4. Adds the
A2A-spec tasks/pushNotificationConfig/* JSON-RPC methods so remote
peers can register a webhook URL they want Staple to POST when a
task's state changes. The push notifier worker polls every 5s and
delivers terminal-state pushes with at-least-once semantics.
H2 streaming (message/stream) ships in v2.36.3.1; bundling the two
in one release proved too large for a single review surface.
New: migrations 095 + 096¶
095_a2a_push_notification_configs.sql— one row per (issue × peer-supplied config_id) with UNIQUE constraint sosetis idempotent. Indexed by(company_id, issue_id)for the per-task list path and by(company_id, last_pushed_state)partial-filtered on non-terminal states for the worker's hot path.096_rls_a2a_push_notification_configs.sql— RLS isolation mirroring migrations 082 / 091 / 094.
New: internal/db/query/a2a_push_notification_configs.go¶
UpsertA2APushNotificationConfig— idempotent set semantics withON CONFLICT DO UPDATEpreservinglast_pushed_state/last_pushed_at(re-registering a config must NOT cause a duplicate push of the previous state).GetA2APushNotificationConfig— by (company, issue, config_id), returnsErrA2APushNotificationConfigNotFoundwhen missing.ListA2APushNotificationConfigsForIssue— all configs for one task.DeleteA2APushNotificationConfig— by tuple, returnsErrA2APushNotificationConfigNotFoundwhen no row matched.MarkA2APushNotificationConfigPushed— worker-only path; persists the state we just pushed.ListA2APushNotificationConfigsPending— worker-only path; returns every config the worker needs to scan in one tick (200-cap).
Tenant-scoped paths use scope.WithTenantScope. Worker paths use
scope.WithBypass because the worker walks rows from every company
in a single tick.
New: A2A protocol additions¶
internal/a2a/protocol.go:
- PushNotificationConfig struct (id + url + token + optional
Authentication block per the A2A spec).
- PushNotificationAuthentication (schemes + credentials).
- Four params struct shapes (SetPushNotificationConfigParams,
Get…, List…, Delete…).
- IsTerminalTaskState(s string) bool helper — closed-enum gate that
the worker uses to short-circuit "no further state change possible."
New: JSON-RPC method handlers¶
internal/handler/a2a_rpc_push.go:
- tasks/pushNotificationConfig/set — accepts the spec's full
PushNotificationConfig shape OR the short-form token field;
validates URL is https (plaintext http would leak the bearer
token); creates / replaces idempotently via Upsert….
- tasks/pushNotificationConfig/get — by (taskId, pushNotificationConfigId).
- tasks/pushNotificationConfig/list — returns all configs for a task.
- tasks/pushNotificationConfig/delete — idempotent; re-deleting a
missing config returns null rather than erroring.
All 4 reuse loadOwnedIssue so a peer can only operate on tasks it
actually owns. Cross-tenancy further enforced at the query layer.
New: push notifier worker¶
internal/worker/a2a_push_notifier.go:
- Ticks every 5s — faster than the outbound-task poller (10s) because
terminal-state pushes are time-sensitive (the peer is waiting to
react).
- For every pending config (last_pushed_state NULL or non-terminal):
loads the underlying issue, computes the current task state via
IssueToTaskState, compares against last_pushed_state. On
divergence, POSTs Task{ID, State} JSON to cfg.url with
Authorization: Bearer <token> when token is set.
- On 2xx: persists the pushed state via
MarkA2APushNotificationConfigPushed. Next tick sees no divergence
and skips.
- On non-2xx / transport error: WARN-logs the failure with config_id,
issue_id, state, url; leaves the config unchanged so the next tick
retries. At-least-once semantics — the peer is responsible for
idempotency on duplicate notifications (the A2A spec models this
the same way).
- cmd/server/main.go wires the worker into the boot sequence
alongside the existing A2A workers.
Validation hardening¶
- URL scheme MUST be https — http is hard-rejected at the
sethandler. Tokens in plaintext over the wire would compromise the per-task auth. - URL host MUST be non-empty (
https:///pathis invalid). - ID and URL are required; reason for
pushNotificationConfig.idrequired is the spec's idempotency model (the peer assigns the id so it can reliably re-register or delete later).
Test coverage¶
internal/a2a/protocol_test.go—TestIsTerminalTaskState(8 cases including unknown / empty).internal/handler/a2a_rpc_push_test.go—TestValidatePushNotificationConfig(8 cases: happy https, http rejected, no scheme rejected, missing host, blank id, garbage URL, with token, etc.).
What's deferred to v2.36.3.1¶
- H2 streaming:
message/streamJSON-RPC method that holds open an SSE response to the peer and emits task state updates as the underlying engine processes the issue. - Per-issue stream hub (per-(company, issue) pub-sub separate from the existing company-scoped sse.Hub).
What's deferred to v2.36.3.2 (admin UI)¶
- Per-company panel listing recent push attempts (success + failure).
- Per-issue panel showing registered push configs.
v2.36.2 — 2026-05-28¶
H1 — Federation dispatch hardening.
First of three sub-slices closing locked-roadmap §3.4 (H1 / H2 / H3). Surfaces the A2A cascade dispatch path: typed outcome enum, durable audit events, dispatch_id correlation, and consistent structured logging. Sets the foundation for the v2.36.3 streaming + push work.
New: typed Outcome constants in internal/a2a¶
internal/a2a/cascade.go:
- OutcomeOK / OutcomeRateLimited / OutcomeRPCError /
OutcomeTransportError / OutcomeSkipped / OutcomeNoCandidates /
OutcomeCascadeExhausted — the closed enum the
a2a_dispatch_events.outcome column accepts.
- DispatchResult.DispatchID field — application-generated UUID
populated on entry to Cascade.Dispatch. Stable across the whole
cascade so the N per-candidate audit rows correlate.
New: a2a_dispatch_events table + RLS¶
internal/db/migrations/093_a2a_dispatch_events.sql +
094_rls_a2a_dispatch_events.sql:
- One row per (cascade × candidate) with the typed outcome enum.
- Indexed by (company_id, created_at DESC) for the global view and
by (issue_id, dispatch_id) for per-issue replay.
- Partial index on the failure outcomes so the "recent federation
failures" panel stays fast even with millions of historical rows.
- RLS policy mirrors migrations 082 / 091: staple_app sees only
rows scoped via scope.WithTenantScope.
New: query helpers¶
internal/db/query/a2a_dispatch_events.go:
- InsertA2ADispatchEvent — single row insert (workers call this).
- ListA2ADispatchEventsForCompany — paginated newest-first list.
- ListA2ADispatchEventFailuresForCompany — backed by the partial
failure index. The future admin UI in v2.36.3+ reads this.
- ListA2ADispatchEventsForIssue — chronological per-issue, ordered
by dispatch_id ASC, started_at ASC so multi-cascade retries line
up naturally.
All four go through scope.WithTenantScope (Pattern P1). The audit
insert MUST NOT fail silently — like secret_audit_events /
plugin_approval_events / egress_anomaly_events, the durable row
IS the observability guarantee; callers log + surface insert errors
loudly.
Wired: worker/a2a_routing.go¶
persistDispatchEventswrites one row perDispatchAttemptafter every cascade run (success AND exhaustion). Failure to persist is logged at WARN; the routing decision still lands.persistSynthesizedEventwrites a single synthetic row when the matcher returns no candidates or no candidate scored above zero — these paths previously only emitted slog warnings, so "this issue couldn't even enter the cascade" was un-auditable.- On cascade exhaustion, an additional synthesized row with
outcome=cascade_exhaustedis written so the failure mode is queryable without joining per-candidate rows.
Structured logging cleanup¶
internal/a2a/cascade.go:
- Per-attempt warn-on-retry now includes peer_label alongside
peer_id so operator logs are readable without a peer-id lookup.
- All literal outcome strings replaced with the new Outcome*
constants — no more "ok" / "transport_error" scattered across
the cascade + worker.
Test coverage¶
internal/a2a/cascade_test.go — 3 black-box tests:
- TestOutcomeConstants_StayInSyncWithMigration — locks the enum
values so a constant rename triggers a test failure rather than a
silent DB constraint violation at runtime.
- TestDispatch_EmptyCandidates_ReturnsErrorAndDispatchID — even
with no candidates the DispatchID must be populated (the worker
needs it to write the synthesized row).
- TestDispatch_LocalCandidateMarkedSkipped — local candidates
never reach the transport path; they short-circuit with
OutcomeSkipped.
Operator visibility¶
After running heartbeats with federation enabled, operators can now:
-- All cascade failures for a company, newest-first.
SELECT created_at, outcome, candidate_name, detail
FROM a2a_dispatch_events
WHERE company_id = $1
AND outcome IN ('rate_limited', 'rpc_error', 'transport_error',
'no_candidates', 'cascade_exhausted')
ORDER BY created_at DESC
LIMIT 50;
-- Full attempt trail for one issue (multiple cascade retries OK).
SELECT dispatch_id, started_at, candidate_name, outcome, detail
FROM a2a_dispatch_events
WHERE issue_id = $1
ORDER BY dispatch_id ASC, started_at ASC;
The admin UI panel for these queries lands in v2.36.3 (bundled with the H2 + H3 streaming + push work).
v2.36.1.10 — 2026-05-28¶
Container isolation #3 — Track D admin UI (the final v2.36.1 piece).
Closes Track D by adding the operator-facing surface for managing the per-company egress allowlist and reviewing recent denials. The DNS resolver + container-IP attribution (v2.36.1.5..v2.36.1.9) has been querying the data layer for two releases; v2.36.1.10 finally exposes it.
New: GET /companies/{companyId}/egress¶
internal/handler/egress.go + internal/web/templates/company_egress/index.html:
Renders a 3-panel page (instance-admin only):
- About — short explainer of when enforcement engages
(
STAPLE_EGRESS_DNS_ADDRset + sandboxed adapter). - Add allowed hostname — form with
hostname+ optionalport+ optionalprotocol+reason. Submits toPOST .../egress/create. - Allowed hostnames — table of current entries with per-row
Remove buttons (form-POST to
.../egress/{id}/delete, confirm prompt before submit). - Recent denials — last 100 rows from
egress_anomaly_eventsfor this company: timestamp, attempted hostname, source adapter, agent ID, run ID. The agent ID links to its agent page so operators can drill into "which agent tried to escape."
New: sidebar nav entry "Egress"¶
internal/web/templates/layout.html: instance-admins now see an
Egress nav item between Settings and Instance Settings in
the Company section. SVG icon is a stylized globe (egress = leaving
the boundary).
New handler routes¶
cmd/server/routes.go:
- GET /companies/{companyId}/egress → handler.UICompanyEgress
- POST /companies/{companyId}/egress/create → handler.UICompanyEgressCreate
- POST /companies/{companyId}/egress/{id}/delete → handler.UICompanyEgressDelete
All three require actor.IsInstanceAdmin(); non-admin / non-user
actors get 403. Unknown companyId returns 404 (matches the
BACKLOG #22 fix that landed in UICompanySettings).
Tenancy is enforced at the query layer via scope.WithTenantScope
(unchanged since v2.36.1.3), so even a path-param mismatch can't leak
or modify another company's allowlist.
Validation surface¶
hostnamerequired (non-blank afterTrimSpace)reasonrequired (non-blank afterTrimSpace)portoptional, must be 1..65535 when presentprotocoloptional, free-form string (DB constraint validates if any)- Duplicate
(hostname, port, protocol)for a company returns 409 (ErrEgressAllowlistEntryExists); operator sees a clear message rather than a 500. - Audit:
created_by_user_idis stamped from the actor's user ID on every successful create.
Test coverage¶
internal/handler/egress_test.go — 10 table-driven tests covering:
- Happy-path render with seeded allowlist + anomaly rows.
- 403 on non-admin actor (both list and write paths).
- 404 on unknown company ID (BACKLOG #22 pattern).
- 404 on unknown allowlist row ID.
- 303 redirect target after create + delete.
- 400 on blank hostname, 400 on invalid port (4 cases: negative,
65535, non-numeric, zero). - 409 on duplicate insert. - Tests skip cleanly without a local Postgres (same skip pattern used everywhere else in
internal/handler).
activePage routing¶
internal/handler/render.go: activePageFromTemplate now maps
company_egress/ template prefix to the "company_egress" active
key, matching the sidebar conditional in layout.html.
Wrap¶
This closes v2.36.1. The full Track D arc:
| Release | What |
|---|---|
| v2.36.1.3 | Schema + query layer (migrations 089-091, RLS) |
| v2.36.1.4 | Topology (Docker network + seed migration 092) |
| v2.36.1.5 | DNS decision core (ResolveQuery) |
| v2.36.1.6 | UDP DNS server library (miekg/dns) |
| v2.36.1.7 | Bootstrap (opt-in DNS startup + --dns wiring) |
| v2.36.1.8 | Gateway autowire + SetDefaultDNSServer |
| v2.36.1.9 | Container-IP attribution (PostStartHook + cidfile) |
| v2.36.1.10 | Admin UI (allowlist CRUD + anomaly review) |
v2.36.1.9 — 2026-05-28¶
Container isolation #3 — Track D enforcement (container-IP attribution).
The release where the egress allowlist actually starts denying non-allowlisted traffic. v2.36.1.5..v2.36.1.8 stood up the topology, the resolver, the protocol, and the gateway wiring; v2.36.1.9 closes the loop by registering each sandboxed container's bridge IP with the egress DNS server BEFORE the agent CLI runs its first query.
Safety: container-IP attribution only engages when the operator
has set STAPLE_EGRESS_DNS_ADDR. With that env var unset (the
default), v2.36.1.9 is a no-op upgrade — claude_sandbox and
codex_sandbox containers behave exactly as they did in v2.36.1.8.
New: adapters.PostStartHook + WithPostStartHook on local adapters¶
internal/engine/adapters/claude_local.go, codex_local.go:
- PostStartHook fires synchronously after cmd.Start() and BEFORE
the stdout reader; receives (ctx, *exec.Cmd, runID, agentID,
companyID); returns (cleanup func(), error).
- Hook error → adapter SIGKILLs the spawned process. Refusing to run
an attribution-less sandbox is intentional: a misconfigured run
would NXDOMAIN every DNS query and silently break the agent.
- Local adapter behavior unchanged when no hook is installed.
New: adapters.AttributeContainer + cidfile helpers¶
internal/engine/adapters/dns_attribution.go (new file):
- MakeSandboxCidfilePath(runID) returns /tmp/staple-cids/<runID>.cid,
ensures the parent dir, and removes any stale file.
- WaitForCidfile(ctx, path) polls (20ms interval, 2s cap) for Docker
to write the container ID.
- DockerInspectIPForNetwork(ctx, containerID, network) shells out
to docker inspect <containerID> and parses
.NetworkSettings.Networks.<network>.IPAddress.
- AttributeContainer(ctx, server, cidfile, network, ContainerContext)
wires the three together: wait for cidfile → inspect IP → call
server.Register(ip, cctx) → return cleanup closure that calls
server.Unregister(ip) and removes the cidfile.
New: SandboxConfig.CidfilePath + --cidfile emission¶
internal/engine/adapters/sandbox_wrap.go:
- New CidfilePath field on SandboxConfig. When non-empty,
BuildSandboxRunArgs emits --cidfile <path> so Docker writes
the container ID for the engine's PostStartHook to consume.
- Empty CidfilePath (the default, used by all pre-v2.36.1.9
adapter_configs) → no --cidfile arg → no behavior change.
New: Engine.SetEgressDNSServer(*adapters.Server)¶
internal/engine/engine.go, internal/engine/notifier.go:
- The engine now holds an optional *adapters.Server reference.
- cmd/server/main.go calls eng.SetEgressDNSServer(egressDNS)
after starting the DNS server. Nil-safe: the sandboxed adapters
skip attribution wiring when EgressDNSServer() returns nil.
Wired: adapter_claude_sandbox.go + adapter_codex_sandbox.go¶
Both sandboxed adapters now check e.EgressDNSServer() before
invoking the inner local adapter:
- DNS server installed → generate cidfile, set sandboxCfg.CidfilePath,
rebuild the sandbox adapter with the cidfile-aware config, install
the AttributeContainer-backed PostStartHook. ContainerContext
is populated with SourceAdapter = "claude_sandbox" or
"codex_sandbox".
- DNS server nil → existing v2.36.1.8 behavior (no cidfile, no
attribution). Sandbox containers still use the bridge gateway's
resolver; no allowlist enforcement.
Behavior change¶
When STAPLE_EGRESS_DNS_ADDR is set AND an agent uses
claude_sandbox or codex_sandbox:
- The container's bridge IP is registered with the egress DNS
server before the agent CLI runs.
- DNS queries from the container hit the Staple resolver, which
enforces the per-company company_egress_allowlist.
- Queries for non-allowlisted hostnames → NXDOMAIN. The agent CLI
sees getaddrinfo failures and can react accordingly.
- Each denial writes an egress_anomaly_events row attributed to
(company_id, agent_id, run_id) for admin review.
Operator runbook¶
- Seed the company allowlist (migration 092 inserts the obvious provider endpoints; admins can add more via the API).
- Set
STAPLE_EGRESS_DNS_ADDR=0.0.0.0:15353(or whatever bind address the server should listen on). - Restart the staple-server.
- Verify in the logs:
egress DNS server started ... sandbox_default=...andengine wired with egress DNS attribution. - Run a sandboxed agent. Its first DNS query for an allowlisted hostname should resolve; queries for non-allowlisted hostnames should NXDOMAIN.
- Query
egress_anomaly_eventsfor the run to see what was denied.
Test coverage¶
dns_attribution_test.go: 13 black-box tests covering cidfile path generation, wait + timeout + ctx-cancel, docker-inspect parsing (happy / wrong network / empty IP / malformed JSON / command failure), AttributeContainer integration, error paths.sandbox_wrap_test.go:TestBuildSandboxRunArgs_CidfileEmissionTestBuildSandboxRunArgs_NoCidfileWhenUnset.
v2.36.1.8 — 2026-05-27¶
Container isolation #3 — Track D DNS autowire (gateway discovery + global default).
Closes the per-agent dns_server configuration burden from v2.36.1.7
by auto-discovering the staple-egress-allowlist bridge gateway and
publishing it as the instance-wide SandboxConfig.DNSServer default.
Container-IP attribution still lives in v2.36.1.9 — operators
shouldn't engage this yet on production agents.
New: adapters.GatewayIP(ctx, networkName)¶
internal/engine/adapters/docker_network.go:
- Returns the IPv4 gateway address of the named Docker bridge via
docker network inspect <name> --format '{{(index .IPAM.Config 0).Gateway}}'.
- Empty string + nil error when docker CLI isn't available
(mirrors EnsureEgressNetwork's dev/CI behavior).
- Error when the network exists but has no IPAM gateway (rare —
bridge networks always have one; flagged so cmd/server can decide
whether to publish a default).
New: SandboxConfig.DNSServer instance default¶
internal/engine/adapters/sandbox_wrap.go:
- SetDefaultDNSServer(addr string) installs an instance-wide
default. Thread-safe (sync.RWMutex). Called by cmd/server after
starting its DNS server.
- DefaultDNSServer() string reads it back (exported for diagnostics
/ admin UI).
- ParseSandboxConfig consults the default when adapter_config
doesn't set dns_server explicitly. Per-agent setting overrides
the global default.
cmd/server/main.go wiring¶
When STAPLE_EGRESS_DNS_ADDR is set:
1. Start the DNS server (as v2.36.1.7 did).
2. Resolve the staple-egress-allowlist network's gateway via
GatewayIP.
3. Combine <gateway>:<port> and publish via SetDefaultDNSServer.
4. Boot log includes the published sandbox_default for verification.
Operators who want a different default (e.g. the bind address rather
than the gateway) can override via STAPLE_EGRESS_DNS_DEFAULT:
Test coverage¶
TestSetDefaultDNSServer_ParseSandboxConfigPicksUpDefault— when the default is published and adapter_config has nodns_server, the parsed config inherits the default.TestSetDefaultDNSServer_PerAgentOverridesGlobal— explicit per-agent setting wins over the global default.TestSetDefaultDNSServer_EmptyDefaultLeavesEmptyConfig— no default + no per-agent setting →--dnsstays out of the docker run argv.- Race-detector clean.
What's NOT yet shipped (still v2.36.1.9 / final)¶
- Container-IP attribution. After v2.36.1.8, sandboxed agents
with
STAPLE_EGRESS_DNS_ADDRset will resolve through the egress DNS server, but the server treats unregistered source IPs as denied. So the integration is fully wired but enforcement still returns NXDOMAIN for every query. - Admin UI for allowlist + anomaly review — v2.36.1 final.
Why this incremental shape is safe¶
STAPLE_EGRESS_DNS_ADDR defaults to unset — same behavior as v2.36.1.7
and earlier for any operator who hasn't deliberately turned it on.
Operators piloting the egress DNS path on a non-production company
now have a clean way to test the routing (auto-default publishes the
gateway-based address, sandboxed containers --dns it
automatically) without yet relying on enforcement that doesn't exist.
v2.36.1.7 — 2026-05-27¶
Container isolation #3 — Track D bootstrap (opt-in DNS server startup + adapter --dns wiring).
Wires the v2.36.1.6 adapters.Server into cmd/server startup
behind a new env-gate, and extends SandboxConfig so operators can
point sandboxed containers at it. Disabled by default —
deployments without STAPLE_EGRESS_DNS_ADDR set behave identically
to v2.36.1.6. Container-IP attribution still lives in v2.36.1.8.
New: STAPLE_EGRESS_DNS_ADDR env gate¶
cmd/server/main.go:
- When the env var is non-empty, the server constructs
adapters.NewServer(pool, addr) and launches Start(ctx) on a
background goroutine.
- Common values:
- 0.0.0.0:15353 — bind to all interfaces; containers on any Docker
bridge can reach via the host gateway.
- 172.18.0.1:15353 — bind to the staple-egress-allowlist
bridge's gateway IP specifically (operator inspects the network
to find the actual gateway).
- Boot log lines:
{"level":"INFO","msg":"egress DNS server started","addr":"0.0.0.0:15353",
"note":"container-IP attribution lands in v2.36.1.8; opted-in agents
will get NXDOMAIN until then"}
New: SandboxConfig.DNSServer¶
internal/engine/adapters/sandbox_wrap.go:
- DNSServer string field on SandboxConfig (JSON dns_server).
- When non-empty, BuildSandboxRunArgs emits --dns <DNSServer> in
the docker run argv so the container ignores its image's
/etc/resolv.conf + the bridge's gateway resolver.
- Per-agent adapter_config can set this directly:
Test coverage¶
TestBuildSandboxRunArgs_DNSServerEmission— whendns_serveris set in adapter_config,--dns <addr>lands in the docker run argv.TestBuildSandboxRunArgs_NoDNSWhenUnset— emptydns_serverkeeps--dnsout of the argv (image / bridge default applies).- Race-detector clean.
What's NOT yet shipped¶
- Container-IP-to-company attribution — the v2.36.1.6 DNS server
treats unregistered source IPs as denied (NXDOMAIN). v2.36.1.7
starts the server but does NOT yet register container IPs after
docker runstarts a sandboxed container. Net effect for operators who set bothSTAPLE_EGRESS_DNS_ADDRANDadapter_config.dns_serveron an agent: the agent's container will get NXDOMAIN for every DNS query and likely fail. - Auto-pointing the sandboxed adapters at the egress DNS — there
is no global default yet. Operators opt in per-agent via
adapter_config.dns_server. - v2.36.1.8 ships the IP attribution refactor of the local-adapter
Execute path (
docker create+ inspect +Register+start), making the integration actually useful. - Admin UI for allowlist + anomaly review — v2.36.1 final.
Recommended operator path for v2.36.1.7¶
Don't set STAPLE_EGRESS_DNS_ADDR yet unless you're testing the
plumbing in a non-production company. The release is safe to deploy
because the env gate defaults to off — same behavior as v2.36.1.6.
If you DO want to test the DNS path:
1. Set STAPLE_EGRESS_DNS_ADDR=127.0.0.1:15353 in
/etc/staple/staple.env.
2. Restart the server. Boot logs should show egress DNS server
started.
3. Don't set dns_server on any production agent. The DNS server
exists but will deny every query until v2.36.1.8 wires
attribution.
Migrations¶
None.
v2.36.1.6 — 2026-05-27¶
Container isolation #3 — Track D wire-protocol layer (UDP DNS server).
Wraps the v2.36.1.5 ResolveQuery decision core in a real DNS server
using github.com/miekg/dns (CoreDNS's library — battle-tested,
MIT-licensed). The server runs as a library function with a
Register / Unregister API for container-to-company attribution.
No cmd/server integration yet — the server is callable as a Go
library but nothing in the binary starts it. Adapter --dns wiring +
bootstrap startup land in v2.36.1.7 / v2.36.1 final.
New: adapters.Server + dependency¶
internal/engine/adapters/dns_server.go:
NewServer(pool, listenAddr) *Server— constructor.Server.Start(ctx) error— binds UDP, blocks until ctx cancelled, shuts down gracefully on cancellation.Server.Register(ip string, c ContainerContext)— register a source IP with the company/agent/run/source-adapter to attribute its queries to. Idempotent; thread-safe.Server.Unregister(ip)— remove an entry when a container exits.ContainerContext{CompanyID, AgentID, RunID, SourceAdapter}— attribution shape.
Query handling:
- Only A / AAAA queries are answered. MX / TXT / SRV / etc. return
NotImplemented (Rcode 4). The egress allowlist model covers
hostname resolution only.
- Unattributed source IP (not in the Register map) returns
NXDOMAIN with a warning log. v2.36.1.7+ tightens this once the
adapter integration guarantees every sandboxed container is
registered before it makes any DNS queries.
- Attributed source → ResolveQuery(pool, companyID, hostname, …):
- Denied → NXDOMAIN. The anomaly row was already written by
ResolveQuery; no additional write here.
- Allowed → A or AAAA records from the upstream lookup, with the
decision's TTL.
- ResolveQuery returned an error → SERVFAIL (client sees the
failure, doesn't cache a synthetic answer).
Dependency¶
github.com/miekg/dnsadded (v1.1.72). CoreDNS's DNS library; the canonical Go choice for DNS protocol work. MIT-licensed. The alternative (hand-rolling DNS wire-protocol parsing) is a known anti-pattern; adding the dep is the right call.go.sumgains the transitive deps.
Test coverage¶
TestServer_Register_StoresAndRetrieves+_IgnoresEmptyIPOrCompanyID_Unregister_RemovesEntry— Register/Unregister/attribute contract.TestServer_Register_Concurrency— 100-goroutine Register/attribute/Unregister stress with race detector. Map starts and ends empty.TestServer_HandleQuery_UnattributedNXDOMAIN— real UDP round-trip: client sends A query forapi.anthropic.comfrom an unregistered source IP; server respondsNXDOMAINwithout touching the DB.TestServer_HandleQuery_NotImplementedForNonAQTypes— real UDP round-trip with aTXTquery; server respondsNotImplemented.- Race-detector clean across
internal/engine/adapters,internal/engine,internal/handler,internal/db/query.
What's NOT yet shipped¶
cmd/server/main.gostarting the server — v2.36.1.7.- Sandbox CmdBuilder adding
--dns <server>to docker run — v2.36.1.7. - Source-IP attribution wired into the sandbox adapter dispatch —
v2.36.1.7. The adapter needs to know the container's IP after
starting docker run, then call
Server.Registerbefore the container makes any DNS queries. - Admin UI for allowlist + anomaly review — v2.36.1 final.
What operators see¶
Nothing observable. The Server type exists and is callable as a Go
library, but no code in cmd/server constructs or starts one.
Sandboxed containers continue to resolve via the host's default DNS
exactly as in v2.36.1.4 / v2.36.1.5.
v2.36.1.5 — 2026-05-27¶
Container isolation #3 — Track D enforcement core (ResolveQuery).
Lands the pure decision layer of the DNS resolver: given
(companyID, hostname, attribution hints), decide whether to allow
the lookup, resolve upstream if allowed, and write an
egress_anomaly_events row if denied. No DNS protocol parsing, no
UDP server, no network integration yet — those wrap this core in
v2.36.1.6.
New: adapters.ResolveQuery¶
internal/engine/adapters/dns_logic.go:
ResolveQuery(ctx, pool, companyID, hostname, agentID, runID, sourceAdapter) (DNSResolution, error)- Normalizes the hostname (trim trailing dot, lowercase) before lookup.
- Calls
query.IsHostnameAllowedto consult the allowlist seeded in v2.36.1.4. - Denied path: writes one
egress_anomaly_eventsrow viaquery.InsertEgressAnomalyEvent(the audit row is the security guarantee — a hard error is returned if the audit write fails, even though the DNS server still responds NXDOMAIN to avoid leaking the attempted host). - Allowed path: forwards to the system resolver
(
net.DefaultResolver.LookupIP) with a 5s timeout for IPv4 + IPv6. - Returns a
DNSResolutionwithAllowed,IPs,TTL(default 60s when upstream doesn't expose one), andAnomalyEventID(set on denial for cross-referencing).
Test coverage¶
TestHostnameNormalize_TrimsAndLowercases— pure-logic input normalization (5 cases).TestResolveQuery_RejectsEmptyHostname+_RejectsEmptyCompanyID— early-validation contracts.TestResolveQuery_NormalizesBeforeLookup— integration coverage flagged for node4 post-deploy.- Race-detector clean.
Design choices locked¶
- System resolver for upstream:
net.DefaultResolver.LookupIPhonors the host's/etc/resolv.conf. Operators using a fast public resolver (1.1.1.1/8.8.8.8) get a cheap hop. v2.36.1.6 may add an explicit upstream knob if operators want one. - Default TTL of 60s: short enough to refresh on allowlist changes; long enough to avoid hammering upstream on a busy heartbeat. Operators don't see this directly until v2.36.1.6 wires the wire-protocol layer.
- Hard fail on audit write failure: matches the
secret_audit_events/plugin_approval_eventsconvention — the audit row IS the security guarantee. The DNS server caller still returns NXDOMAIN (so the attempted host isn't leaked back to the requester), but the operator sees the audit-channel break in journalctl as an error.
What's NOT yet shipped¶
- UDP DNS server (wire protocol parsing) — v2.36.1.6.
- Container-to-company source-IP attribution map — v2.36.1.6.
EnsureEgressNetworkupdated to point the network at the DNS server — v2.36.1.6.- Sandbox CmdBuilder updated to add
--dns <server>— v2.36.1.6. - Admin UI for allowlist + anomaly review — v2.36.1 final.
What operators see¶
Nothing observable in v2.36.1.5 itself — the function exists but no
code calls it yet. Sandboxed containers still resolve via the host's
default DNS exactly as they did in v2.36.1.4. v2.36.1.6's UDP server
will start using ResolveQuery for every query a sandboxed container
makes.
v2.36.1.4 — 2026-05-27¶
Container isolation #3 — Track D topology (Docker network + seed + adapter defaulting). No enforcement yet.
Lands the staple-egress-allowlist Docker network, defaults the
sandboxed adapters to use it, and seeds common LLM-provider hostnames
into every existing company's allowlist. The network exists, sandbox
containers join it, and the allowlist data is populated — but DNS
filtering / deny-by-default enforcement still lives in v2.36.1.5.
This release is the topology that v2.36.1.5's enforcement will plug
into.
New: EnsureEgressNetwork + network defaulting¶
internal/engine/adapters/docker_network.go:
- EnsureEgressNetwork(ctx context.Context) error — idempotently
creates the staple-egress-allowlist Docker bridge network if it
doesn't exist. Safe on every boot. Early-returns nil when the
docker CLI isn't installed (CI / dev boxes without docker still
boot cleanly; sandboxed adapters surface clear errors at dispatch
time instead).
- EgressNetworkName — exported constant "staple-egress-allowlist"
for tests, dnsmasq configs, monitoring queries, and other
cross-cutting references.
- Network is created with labels managed-by=staple and
purpose=egress-allowlist so docker network ls --filter
label=managed-by=staple finds it.
- Race-safe: if a concurrent boot beat us to creating the network,
we re-inspect to confirm + return nil.
internal/engine/adapters/sandbox_wrap.go:
- defaultSandboxNetwork flipped from "none" → EgressNetworkName.
- All sandboxed adapters (claude_sandbox, codex_sandbox,
and any future ones built on the helper) default to the new
network when their adapter_config.network field is empty.
- Operators wanting to opt out can set network: "none" or
network: "bridge" explicitly in their adapter_config.
cmd/server/main.go:
- EnsureEgressNetwork(ctx) called at boot. Result logged: success
emits egress network ensured; failure emits a warning (sandboxed
adapters will surface clearer errors at dispatch time so the boot
doesn't hard-fail when docker is temporarily unreachable).
Migration 092 — seed default provider hostnames¶
internal/db/migrations/092_seed_default_egress_allowlist.sql inserts
one row per (existing company, hostname) for the LLM providers most
agents need:
api.anthropic.com— claude_sandbox + anthropic LLM-API adapter.api.openai.com— codex_sandbox + openai_compatible LLM-API adapter.api.perplexity.ai— perplexity LLM-API adapter.bedrock-runtime.us-east-1.amazonaws.com— AWS Bedrock runtime (us-east-1 default; operators in other regions add their region's row manually).console.anthropic.com— claude auth / console.
ON CONFLICT (company_id, hostname, port, protocol) DO NOTHING so
operators who pre-populated rows in v2.36.1.3 are not affected — the
seed is purely additive.
What's NOT yet shipped¶
- The DNS resolver that consumes
query.IsHostnameAllowed(v2.36.1.5). - Deny-by-default behavior — containers in
staple-egress-allowlistcan still reach the entire internet exactly as they could on the default Docker bridge. - Auto-population of
egress_anomaly_events(v2.36.1.5). - Wildcard allowlist support (
*.amazonaws.com) — future expansion if real operator demand surfaces. - Admin UI for allowlist management + anomaly review (v2.36.1 final).
Test coverage¶
TestEgressNetworkName_Stableasserts the exported constant hasn't drifted (operators depend on the literal string in cross-cutting configs).TestEnsureEgressNetwork_NoDockerEarlyReturnsNil— boot path is clean even without docker installed.TestEnsureEgressNetwork_WithDocker_Idempotent— when docker IS available, repeated calls are safe. Skips on hosts without docker so CI doesn't false-fail.- Existing
TestParseSandboxConfig_Defaultsupdated to assert the new network default. - Race-detector clean on
internal/engine/adapters,internal/engine,internal/handler,internal/db/query.
What operators see on upgrade¶
When v2.36.1.4 boots:
- Migrations 092 applies (seeds default hostnames for all existing companies; manually-populated rows survive unchanged).
EnsureEgressNetworkruns and creates thestaple-egress-allowlistDocker network if it doesn't exist.- Boot logs include:
- Newly-dispatched
claude_sandbox/codex_sandboxcontainers default to joining the new network instead ofnone.
Behavior is otherwise unchanged: containers can still reach the internet exactly as before (the deny-by-default DNS resolver lands in v2.36.1.5).
v2.36.1.3 — 2026-05-27¶
Container isolation #3 — Track D schema + query layer (data only, no enforcement).
Lands the storage layer for the per-company egress allowlist that the sandboxed adapters will consult once enforcement ships in v2.36.1.4. This release is safe to deploy by itself: the new tables exist with RLS armed, but nothing consumes them yet — operators can begin populating allowlist rows now so the enforcement release picks them up automatically.
Migrations¶
- 089
company_egress_allowlist.sql— per-company allowlist of outbound (hostname, port, protocol) tuples. Each row carries an operator-suppliedreason(required) and acreated_by_user_idFK to users. UNIQUE on(company_id, hostname, port, protocol). Wildcard hostnames (*.amazonaws.com) are NOT supported in v2.36.1.x; operators add per-subdomain rows. Wildcard support is a future expansion if real demand surfaces. - 090
egress_anomaly_events.sql— append-only audit log of denied outbound DNS lookups from sandboxed containers. Mirrors the shape ofsecret_audit_events/plugin_approval_events:attempted_hostname(required), nullableagent_id/run_id/source_adapter. ON DELETE SET NULL on the FKs so audit rows outlive their referenced agents / runs / heartbeat runs. - 091
rls_egress_tables.sql— engages RLS on both new tables with the standardtenant_isolationpolicy (current_setting('app.current_company_id')). Same pattern as migration 082. The superuser pool (migrations, bootstrap) keeps bypassing; engagement happens insidescope.WithTenantScopeat the query layer.
Query layer¶
internal/db/query/egress_allowlist.go:
- EgressAllowlistEntry struct.
- CreateEgressAllowlistEntry(ctx, pool, params) (*EgressAllowlistEntry, error).
- ListEgressAllowlistForCompany(ctx, pool, companyID) ([]EgressAllowlistEntry, error).
- DeleteEgressAllowlistEntry(ctx, pool, companyID, id) error (scoped).
- IsHostnameAllowed(ctx, pool, companyID, hostname) (bool, error) —
the DNS-resolver hook for v2.36.1.4. A row with hostname=H and
port=NULL authorizes every port for H; explicit-port rows also
satisfy the check (port enforcement is a network-layer concern
handled outside this helper).
- ErrEgressAllowlistEntryExists returned on duplicate insert
(matches the UNIQUE constraint).
- ErrEgressAllowlistEntryNotFound returned by Delete when no row
matched (either the id doesn't exist or it belongs to a different
company).
internal/db/query/egress_anomaly.go:
- EgressAnomalyEvent struct.
- InsertEgressAnomalyEvent(ctx, pool, params) (*EgressAnomalyEvent, error) —
never fails silently; the audit row is the security guarantee.
- ListEgressAnomaliesForCompany(ctx, pool, companyID, limit)
([]EgressAnomalyEvent, error) — most-recent-first.
Both files route through scope.WithTenantScope so the
cross-tenancy guarantee is a compile-time property of the helper
signature.
Test coverage¶
internal/db/query/egress_test.go: 11 tests covering the round-trip
shape, validation (empty hostname / empty reason), the duplicate-insert
contract, list-includes-inserted-row for both surfaces, delete +
not-found semantics, and the IsHostnameAllowed true/false branches.
Uses the established lookup-or-skip fixture pattern; tests skip
cleanly when Postgres isn't reachable.
Race-detector clean on internal/db/query, internal/engine,
internal/handler.
What's NOT yet shipped¶
- The
staple-egress-allowlistDocker network setup helper (v2.36.1.4) - The DNS resolver that consumes
IsHostnameAllowed(v2.36.1.4) - Auto-population of
egress_anomaly_events(v2.36.1.4) - Default allowlist seed for
api.anthropic.com/api.openai.com/ Bedrock endpoints (v2.36.1.4) — operators must hand-populate today - Admin UI surface for managing allowlist entries (v2.36.1, the final tag)
What operators can do now¶
Pre-populate per-company allowlist rows via direct SQL or via the query helpers from a custom script. When v2.36.1.4 lands, those rows become enforced automatically — no migration of operator data needed.
INSERT INTO company_egress_allowlist (company_id, hostname, reason)
VALUES
('<company-uuid>', 'api.anthropic.com', 'claude provider'),
('<company-uuid>', 'api.openai.com', 'openai provider');
v2.36.1.2 — 2026-05-27¶
Container isolation #3 — Track C integration (claude_sandbox + codex_sandbox adapters).
Closes the Track C deferral from v2.36.1.1 by wiring the
sandbox_wrap.go foundation into two new engine-side adapters that
operators can configure via adapter_type: claude_sandbox /
codex_sandbox. The same claude / codex CLI you'd run under
claude_local / codex_local now runs inside a hardened Docker
container with the full v2.36.1.0 Track B + plugin-sandbox hardening
profile.
New: CmdBuilder hook on the local adapters¶
internal/engine/adapters/sandbox_wrap.go exposes:
- CmdBuilder — function-type that intercepts the about-to-exec command
the local adapter would have built.
- DefaultCmdBuilder — the identity wrapper used when no builder is
installed (preserves the original exec.CommandContext(ctx, binary, args...)
behavior verbatim).
- NewSandboxCmdBuilder(SandboxConfig) CmdBuilder — wraps the command
in docker run per the supplied SandboxConfig, translating cmd.Dir
→ -v dir:/workspace -w /workspace mount + workdir, and env []string
→ -e KEY=value flags. Host process env is NOT inherited (the whole
point); only what's in the cfg.ExtraEnv + caller-supplied baseEnv.
ClaudeLocalAdapter and CodexLocalAdapter each grow a
WithCmdBuilder(CmdBuilder) method. Nil = use DefaultCmdBuilder.
The exec.CommandContext call site in each adapter now routes through
the builder. Behavior for existing claude_local / codex_local agents
is unchanged.
New: SandboxConfig.WorkDir¶
sandbox_wrap.go::SandboxConfig grows a WorkDir field (JSON
work_dir). When non-empty, BuildSandboxRunArgs emits -w <WorkDir>
right after the -v mount flags. The sandbox CmdBuilder sets this
automatically when wrapping a local adapter whose cmd.Dir is non-empty
(maps to -v <dir>:/workspace + -w /workspace).
New: adapters.NewClaudeSandbox(pool, SandboxConfig) / NewCodexSandbox¶
Each constructs the matching local adapter pre-configured with a
NewSandboxCmdBuilder hook. Same Execute signature, same stdin /
stdout / stderr / streaming / event / timeout / wait logic — the only
difference is that the wrapped process runs inside a container.
New: engine-side claude_sandbox + codex_sandbox adapter wrappers¶
internal/engine/adapter_claude_sandbox.go + adapter_codex_sandbox.go:
- Same context-gathering as their _local counterparts (company name,
issue context, manager, roster, runtime state, project working dir).
- Parse agent.AdapterConfig as SandboxConfig and build the inner
adapter via NewClaudeSandbox / NewCodexSandbox.
- The claude / codex CLI's own JSON-shape fields in adapter_config
(binary_path, cwd, timeout, etc.) are still parsed by the inner
adapter — both shapes coexist in the same blob.
engine.New() registers both new factories in the adapters map.
Caveat: codex_sandbox does NOT materialize skills¶
Unlike codex_local, which writes Staple's merged skills into
~/.codex/skills/staple-<companyID>/ on the host, codex_sandbox
omits this step — the container has its own $HOME and the host
directory isn't mounted. Operators using codex_sandbox must either:
- Ship a container image with pre-baked skills, or
- Configure
SandboxConfig.ExtraMountsto mount a host skill directory into the container at the in-container CODEX_HOME path.
A follow-up release will add a SandboxConfig.SkillsMount knob that
auto-populates the mount.
Test coverage¶
internal/engine/adapter_sandbox_registration_test.go:TestSandboxAdaptersRegisteredverifies both new factories are in the engine.adapters map and round-trip throughfactory().Name()back to the registry key.- Full repo race-detector clean (touched packages:
internal/engine/adapters,internal/engine,internal/handler).
What operators can do now¶
Set adapter_type: claude_sandbox (or codex_sandbox) on an agent
with an adapter_config like:
{
"image": "ghcr.io/example/staple-claude-sandbox:latest",
"memory_limit": "1g",
"cpu_limit": "1.0",
"pids_limit": 100,
"network": "none",
"extra_security_opts": ["seccomp=/etc/staple/seccomp.json"]
}
The agent's heartbeat then runs the claude CLI inside a hardened
container instead of as a host process. Track D's staple-egress-allowlist
network ships in the next sub-tag (v2.36.1).
Migrations¶
None.
v2.36.1.1 — 2026-05-27¶
Container isolation #3 — Track C foundation (sandbox_wrap.go).
Ships the reusable helper that wraps an unsandboxed adapter command in a
hardened docker run invocation. The new claude_sandbox /
codex_sandbox adapters that consume it ship in v2.36.1.2 — splitting
the track honestly so the foundation lands now and the adapter
integration gets the careful refactoring of claude_local.go /
codex_local.go's stdin/stdout/stream-parsing path that it deserves.
New: internal/engine/adapters/sandbox_wrap.go¶
SandboxConfig— per-agent adapter_config shape (image,memory_limit,cpu_limit,pids_limit,writable,network,extra_security_opts,extra_mounts,extra_env).ParseSandboxConfig(raw []byte) SandboxConfig— JSON parser with defaults: memory=1g, cpu=1.0, pids=100, network=none. Cascade forpids_limit: adapter_config >STAPLE_SANDBOX_PIDS_LIMITenv > 100.BuildSandboxRunArgs(cfg, baseBinary, baseArgs, baseEnv) []string— the docker-run argv. Always-on hardening parity withinternal/plugins/sandbox.go::buildSandboxedCommandand the v2.36.1.0 Track B hardenedinternal/engine/adapters/docker.go::BuildDockerRunArgs:--security-opt no-new-privileges--cap-drop ALL--pids-limit <cfg>--memory-swap = --memory(SEC-020)--read-only+--tmpfs /tmp:rw,nosuid,size=64m(unlesscfg.Writable)- Operator-supplied
extra_security_optslayer on top of the baseline. extra_mountsbecome-v src:tgt[:ro]entries.extra_env+ caller-supplied baseEnv merge with caller-wins, sorted by key for deterministic argv (matchesBuildDockerRunArgsconvention).
Test coverage¶
11 new tests in sandbox_wrap_test.go, including a
TestBuildSandboxRunArgs_PluginSandboxParity assertion that the
always-on hardening flag set matches the plugin-sandbox reference
implementation. If internal/plugins/sandbox.go adds or removes a flag,
this test will fail and the parity floor must move with it.
What's NOT yet shipped¶
claude_sandboxadapter registration in the engine (v2.36.1.2)codex_sandboxadapter registration in the engine (v2.36.1.2)- Engine.New() registration of both new adapter types (v2.36.1.2)
- The custom Docker network + per-company egress allowlist (Track D / v2.36.1)
Operators cannot yet configure an agent with adapter_type:
claude_sandbox. The engine adapter registry remains unchanged in
v2.36.1.1 — only the foundation lands.
Migrations¶
None.
v2.36.1.0 — 2026-05-27¶
Container isolation #3 — Tracks A + B + E (docs + Docker hardening + deprecation gate).
First slice of the locked-roadmap §2.1 work. Ships in three intermediate
tags so operators can deploy hardening incrementally:
- v2.36.1.0 (this release): docs + in-place Docker adapter hardening + the
reserved STAPLE_LOCAL_ADAPTERS env gate. Zero new adapter code surface.
- v2.36.1.1 (next): claude_sandbox + codex_sandbox adapters (Track C).
- v2.36.1 (final): egress allowlist + custom Docker network (Track D).
Track A — adapter isolation documentation¶
- New
docs/user/operators/adapter-isolation.mddocumenting the runtime isolation contract of each adapter (claude_local,codex_local,docker,claude_sandbox,codex_sandbox,lambda,remote): what runs, filesystem visibility, env-var inheritance, network, container hardening, recommended use cases, risks, and a decision tree for picking an adapter by risk profile. ARCHITECTURE.mdgets a new "Adapter runtime isolation" section that cross-links to the new doc and summarizes the adapter taxonomy.- Calls out that
lambdais a generic HTTP-endpoint adapter despite the name (works against AWS Lambda Function URLs but also Cloud Run, custom inference services, etc.).
Track B — Docker adapter hardening (parity with plugin sandbox)¶
internal/engine/adapters/docker.go::BuildDockerRunArgsgains the always-on flags that already live ininternal/plugins/sandbox.go::buildSandboxedCommand, so the two hardening surfaces converge:--security-opt no-new-privileges— blocks setuid escalation.--cap-drop ALL— agent containers never need raw Linux caps.--pids-limit— default 100; cascadeDockerConfig.PIDsLimit→STAPLE_DOCKER_PIDS_LIMIT→100.--memory-swap=--memorywhen--memoryis set — SEC-020 parity: disables swap so the memory cap is honest (without this, containers can swap freely up to the host's swap limit, defeating the cap).DockerConfiggrows apids_limitJSON field (per-agent override).STAPLE_DOCKER_PIDS_LIMITenv var added (instance-wide default; beats the built-in 100; loses to per-agentpids_limit).- Existing
STAPLE_DOCKER_SECURITY_OPTS(SEC-029) operator hook unchanged — seccomp / AppArmor profiles still layer on top of the baseline.
Backwards compatibility: agents that depend on dropped Linux caps
or >100 PIDs break on upgrade. The per-agent pids_limit knob is the
escape valve.
Track E — deprecation runbook + reserved env gate¶
- New
docs/user/operators/local-adapter-deprecation.mddocuments the multi-release path forclaude_local/codex_local: v2.36.1.x → v2.37.0 (env gate enforces) → v3.0.0 (removed). cmd/server/main.goreadsSTAPLE_LOCAL_ADAPTERS; setting it now emits a boot log line confirming the variable is being read. The gate is RESERVED — local adapters still register in v2.36.1.x. Enforcement lands in v2.37.0.- Operators running multi-tenant production are advised to set
STAPLE_LOCAL_ADAPTERS=disabledin/etc/staple/staple.envtoday (the no-op default in v2.36.1.x), so the upgrade to v2.37.0 does not regress when the gate flips to enforcing.
Test coverage¶
- Two existing Docker adapter tests updated to account for the new
always-on
--security-opt no-new-privilegesbaseline. - Five new Docker adapter tests cover the new flags:
TestBuildDockerRunArgs_BaselineSecurityOpts,TestBuildDockerRunArgs_CapDropAll,TestBuildDockerRunArgs_PIDsLimitDefault,TestBuildDockerRunArgs_PIDsLimitConfigOverride,TestBuildDockerRunArgs_PIDsLimitEnvOverride,TestBuildDockerRunArgs_MemorySwapEqualsMemory,TestBuildDockerRunArgs_NoMemorySwapWithoutMemory. - Race-detector clean on
internal/engine/adapters,internal/engine,internal/handler.
Migrations¶
- None. Tracks C and D add migrations 089 + 090 in later v2.36.1.x sub-tags.
v2.36.0.1 — 2026-05-27¶
CLI Variant A follow-up — closes the D2/D3 deferral from v2.36.0.
Ships the two staple-cli subcommands held back from v2.36.0 because
each needed encryption-coordination or binary-hash work outside the
single-query-helper pattern of the existing CLI commands. Reclassified
as a v2.36.0 patch release (rather than v2.36.1) so the locked roadmap's
v2.36.1 slot stays reserved for Container isolation #3.
staple-cli secret {list | reveal | rotate} (§2.2)¶
secret list --company=<id>— tab-separated table of ID / NAME / PROVIDER / UPDATED. Read-only, no audit row.secret reveal --company=<id> --name=<n> [--reason=<text>]— loads the latest version, wirescrypto.InitDefaultagainst the active KEK/DEK Vault, decrypts under the configuredSTAPLE_ENCRYPTION_KEY, writes aSecretAuditRevealedaudit row with actor=__cli__, prints plaintext to stdout. On decrypt failure, a pairedSecretAuditDeniedrow is written before the command exits non-zero.secret rotate --company=<id> --name=<n> --value=<plaintext>—crypto.Encryptagainst the active Vault →query.RotateSecretwithencrypted=true. Thecompany_secret_versions.version_numbersequence is the rotation audit log; no separate row is written (the existingSecretAuditActionenum has norotatedvalue).
staple-cli plugin {allow | ban} (§2.2)¶
plugin allow --company=<id> --id=<pid> --reason="..."— loads the plugin, SHA-256-hashes itsbinary_path, callsquery.SetPluginApprovalAllowed(stampsapproved_binary_hashso subsequentStartPluginverifies binary integrity), writes the pairedCreatePluginApprovalEventwith action=allowed, actor=__cli__.plugin ban --company=<id> --id=<pid> --reason="..."— callsquery.SetPluginApprovalBannedwith the supplied reason, writes the paired audit row with action=banned. Does not touch the on-disk binary.- Both subcommands cross-check that the target plugin actually belongs to the supplied company (the CLI bypasses RLS, so the guard is in the CLI itself).
Audit-row hygiene¶
secret_audit_events.user_idandplugin_approval_events.user_idare bothTEXT NOT NULLwith no FK — operatoruser_idrows hold UUIDs, runtime auto-actions hold__system__, and CLI-initiated actions now hold__cli__. Operators can grep audit history by actor sentinel to attribute reveals / bans across surfaces.
Notes¶
- No new migrations; v2.36.0 already added every schema change this release uses.
STAPLE_ENCRYPTION_KEYis required forsecret revealandsecret rotate(32-byte base64) — same env contract as existingvault-*andencrypt-*commands.
v2.36.0 — 2026-05-27¶
First release of the locked forward roadmap (see
docs/superpowers/specs/2026-05-27-roadmap-locked.md). Bundles three
small-fixes-first sub-features. The fourth, CLI Variant A, ships its
company command family in this release; secret reveal/rotate and
plugin allow/ban are deferred to v2.36.1 because each needs more
encryption/audit coordination than a single query helper exposes.
D — Blocked status + pause-escalation (§3.1)¶
- Issues now carry
blocked_at/blocked_reason/blocked_by_agent_idmetadata (migration 087). Heartbeat dispatcher already excludedblockedfrom the actionable whitelist atengine.go:153; a blocked issue does not fire heartbeats until unblocked. - Parser requires an
unblock-reason:continuation line for/status <new>transitions out ofblocked. Transitions without the hint are dropped viaparserDrop. Transitions INTO blocked may carry an optionalreason:hint that is stamped on the row and surfaced in activity payloads. - UI surfaces a
data-testid="blocked-banner"warning on the issue detail page when status isblocked, rendering the reason, when it was blocked, and the blocking agent's display name. - Optional per-agent notification: when
agents.blocked_notify_enabled = TRUE, a transition intoblockedfires an outboundNotificationvia the newengine.Notifierinterface. Production wiring atinternal/channels/notifyresolves the agent'sreports_touser and DM's the linked external identities (Slack viausers.slack_user_id, Telegram viausers.telegram_user_id). Best effort: DM failures are warn-logged but never break the action. - New activity-log kinds:
issue.status_blocked,issue.status_unblocked.
I — Two-stage stale-run detection (§3.2)¶
- New
heartbeat_runs.last_activity_at+stale_stagecolumns (migration 088). Existing rows back-filled fromheartbeat_run_events. InsertRunEventnow refresheslast_activity_atatomically in the same CTE-based statement as the event insert — no race window for a concurrent watcher tick.- New
StaleRunWatcherworker polls every 30s. Per-run stages: - Stage 1 (stale): stamp
stale_stage='stale', emitrun.stale_detectedactivity event. - Stage 2 (grace): after
grace_secmore, insert a[stale-watcher]final-prompt heartbeat run event, emitrun.grace_prompt_sent. - Stage 3 (cancel): after
cancel_secmore, transition the run tostatus='cancelled'witherror='auto_cancel_stale', emitrun.auto_cancelled. - System defaults: stale=600s / grace=300s / cancel=300s (20-minute
total). Env-tunable via
STAPLE_STALE_RUN_THRESHOLD_SEC/_GRACE_SEC/_CANCEL_SEC. Per-agent and per-company overrides via new columns onagentsandcompanies(migration 088). Resolution order: agent → company → env-default; NULL columns inherit upward. - Operator runbook section added to
docs/user/operators/environment.md.
AST linter codification (§3.3)¶
- The existing AST linter at
internal/handler/no_raw_db_test.gois now architectural canon — documented inARCHITECTURE.mdunder "Multi-tenant access — RLS + AST linter". The audit cataloged all 6 current// rls:allow <reason>annotation sites and confirmed each carries a specific, justified reason.
CLI Variant A — new admin subcommands (§2.2 — partial)¶
staple-cli company {list | create <name> <slug> | delete <id|name|slug> --yes}.staple-cli secret list/reveal/rotateandstaple-cli plugin allow/bandeferred to v2.36.1 — both need encryption-coordination or binary-hash computation that doesn't fit the single-query-helper pattern of the existing CLI commands.
Migrations¶
- 087
blocked_escalation.sql—issues.blocked_at/reason/by_agent_id agents.blocked_notify_enabled+ partial index.- 088
heartbeat_run_stale.sql—heartbeat_runs.last_activity_at/stale_stage - per-agent/per-company override columns + partial index.
Notes¶
- v2.36.0 ships in one tag because the sub-features are independent and deployment risk is bounded; the deferred CLI sub-commands land in v2.36.1.
- The full locked-roadmap entry for v2.36.0 (
docs/superpowers/specs/2026-05-27-roadmap-locked.md§"Release schedule") is updated to reflect ✅ shipped.
v2.35.9 — 2026-05-25¶
Audit follow-up: F-033 per-verb hint allowlist + doc-state sweep.
After the v2.35.8 audit-completion summary, two items remained as
"worth doing before the next LLM-feature work": SECURITY_CONCERNS.md
header reflected stale 0 resolved / 24 open state, and F-033 (the
spec-deferred per-verb hint scoping) was still latent in the parser.
Both now closed.
F-033 — per-verb hint allowlist (parser side)¶
Pre-v2.35.9 the lenient hint loops on /create-note, /edit-note,
/list-notes, /list-issues, /create-issue, /install-agent, and
/install-federation accepted the full union of keys that
parseHintLine recognizes. Handlers silently dropped any they didn't
read — role: foo on a /create-note got into Hints and the note
handler ignored it. Pure vocab-vs-implementation drift inside the
parser itself.
- New
hintsForVerb(verb) map[string]booldefines the per-verb allowlist for each verb that takes hints. - New
acceptHint(verb, line)wrapsparseHintLinewith the allowlist check. Out-of-scope keys route throughparserDrop(the v2.35.7 helper) and break the loop the same way unknown keys do. - The 7 hint-loop call sites switched from
parseHintLinetoacceptHint(cmd, ...)via a singlereplace_all. STAPLE_LOG_LEVEL=debugnow surfaces every out-of-scope hint viaparser: token dropped … reason="hint key not valid for this verb"in journalctl.
Behavior change: hints the LLM emits for the wrong verb no longer
appear in AgentAction.Hints. Handlers that previously silently
ignored them now see nothing — same effective behavior, but the
LLM's intent is now observable in logs.
SECURITY_CONCERNS.md header sweep¶
The 2026-05-21 Audit — In Progress header still showed
0 resolved / 24 open. Per .planning/BACKLOG.md Totals all 24
findings were discharged during the v2.21..v2.32 cycle. Header
renamed to ✅ Fully Resolved (24/24, sweep 2026-05-25) with a
prefatory note pointing at the BACKLOG row and CHANGELOG entry that
shipped each finding. Per-finding detail preserved as historical
record.
Test changes¶
TestParseAgentActions_NewVerbs/list_issues_with_priorityrenamed tolist_issues_priority_is_out_of_scope_v2_35_9and updated to reflect the new contract (priority is dropped at parse time, not silently ignored at the handler).TestHandleChatListIssues_MultiStatusDeduprewritten to use an isolated fresh company. The pre-v2.35.9 version used the shared fixture company and was vulnerable to test-DB pollution from parallel runs (the same brittleness fixed forTestHandleChatListIssues_DefaultExcludesDonein v2.35.1).
v2.35.8 — 2026-05-25¶
LLM-surface audit, Slice 6 — low-severity cosmetic sweep (final slice).
Closes the remaining LOW findings (F-033..F-049) from BACKLOG #37 that weren't already addressed by Slices 1-5's slog instrumentation:
- F-038 —
captureFencedBlockcontract documentation. The function returnsadvance = start - 1on failure, which is meaningless to callers; the docstring now explicitly tells maintainers to gatei = advanceonif ok. - F-040 — Categorized chat-context marker. The "unsupported in chat" marker now distinguishes heartbeat-only verbs (e.g. /status, /assign, /checkout) from install verbs (require approval) from truly unknown verbs. Pre-v2.35.8 every unsupported verb produced the same generic marker.
- F-044 — Evolution trace RunID empty. Documented as intentional (trace path is representative, not exact) rather than threading runID through 5 adapter call sites for a cosmetic gain.
Findings already addressed by Slice 5's slog instrumentation¶
- F-034 (unknown verb log) → D-01
- F-035 (parseHintLine rejection log) → D-02
- F-036 (invalid status/priority log) → D-03
- F-041 (slack drop logs) → D-18
- F-042 (slack no-agent / provider-unavailable logs) → D-19
- F-043 (truncateForSlack log) → D-20
Findings deferred as not worth the effort¶
- F-033 (per-verb hint allowlist scoping) — significant refactor for marginal correctness gain; the slog instrumentation from D-02 surfaces unknown keys, which is the practical concern.
- F-037 (duplicate-key precedence) — current first-wins behavior is implicitly documented by the slog instrumentation; an explicit test contract can be added when an operator actually hits drift.
- F-039 (gate dead branch on F-007) — F-007 fix is live; the branch is no longer dead.
- F-049 (marker reconcile no-op debug log) — audit noted as "optional, visibility only"; passed on volume-vs-value.
Audit completion summary¶
The full audit (BACKLOG #37) is now complete across 6 slices: - Slice 1 (v2.35.3): 8 CRITICAL + HIGH findings (the v2.35.x analogs) - Slice 2 (v2.35.4): 5 heartbeat prompt drift findings - Slice 3 (v2.35.5): 10 role + skill schema findings - Slice 4 (v2.35.6): 10 channel parity + plugin host-services findings - Slice 5 (v2.35.7): 14 slog drop-point findings (centralized parserDrop) - Slice 6 (v2.35.8): 3 low-severity substantive + 7 already-addressed
Total: 46 of 49 findings closed across 6 patch releases over a single
day. The remaining 3 (F-033, F-037, F-049) are explicitly deferred
with documented rationale. Spec at
docs/superpowers/specs/2026-05-25-llm-surface-audit.md remains the
durable record.
v2.35.7 — 2026-05-25¶
LLM-surface audit, Slice 5 — slog drop sweep.
Closes the remaining drop-point inventory items (D-01..D-08, D-15, D-18..D-21, D-24, D-25) from BACKLOG #37 / spec §5. The earlier slices (v2.35.3..v2.35.6) already addressed ~16 of the 30 sites. This slice finishes the audit-trail layer: every silent-drop site in the LLM-facing surface now emits a structured slog entry. The v2.35.2 bug would have been caught in minutes with these logs.
Centralized parser instrumentation¶
- New
parserDrop(verb, reason, raw)helper ininternal/engine/actions.goemitsslog.Debug("parser: token dropped", verb, reason, raw). Routes every drop site in the parser through one call so detailed tracing can be enabled viaSTAPLE_DEBUG=parser(per spec §7).
Sites instrumented this slice¶
- D-01 parser default branch — unknown verbs now logged.
- D-02 parseHintLine rejections — no-colon / empty-value / unknown-key
branches each emit
parserDropwith the specific reason. - D-03 invalid
/statusand/priorityvalues —slog.Warn(v2.35.1 archetype: invalid status values are the most consequential drops). - D-04 unterminated fenced block in
captureFencedBlock—slog.Warn. - D-05 ProcessChatAgentReply early return on no actions —
slog.Infowith chat_id + reply_len. - D-06 catch-all "unsupported in chat" marker —
slog.Infowith kind + chat_id. - D-09 attachment budget exhaustion — aggregate omission marker +
slog.Infowith counts. - D-15 codex prune —
slog.Infoon successful prune (not just on error). - D-18 slack non-im / subtype / empty drops —
slog.Debugper drop reason. - D-19 slack no-agent / provider-unavailable —
slog.Infoper state. - D-20 truncateForSlack —
slog.Warnwhen truncation fires. - D-21 telegram non-text drops —
slog.Debugper drop. - D-24 telegram SendMessage failure — added
text_lenfield. - D-25 chat reprompt results truncation —
slog.Warn.
Operator observability¶
Set STAPLE_LOG_LEVEL=debug in the systemd env file (or equivalent) to
see every parser drop in journalctl. A journalctl -u staple | grep
'parser: token dropped' over a few minutes now produces a complete
inventory of the LLM-emitted tokens the parser is rejecting — the
diagnostic surface that was missing for v2.35.1 and v2.35.2.
v2.35.6 — 2026-05-25¶
LLM-surface audit, Slice 4 — channel parity + plugin host-services.
Closes 10 findings from BACKLOG #37.
Findings closed¶
- F-022 — Telegram history-load failure showed silence. Slack handled the
same case with a visible "internal error: failed to load chat history"
reply (
slack/dispatcher.go:323). Telegram parity fix: matching reply + the existing Error log. - F-023 — Telegram had no
truncateForTelegramhelper. Telegram's 4096- char cap silently failed long LLM replies. Added the helper with a_…truncated…_marker + Warn log. - F-024 — Plugin deny error not actionable. "host capability not allowed
for this plugin:
" → "host capability %q not in this plugin's host_capabilities allowlist; ask the company admin to add it via /admin/plugins/ ". - F-025 — Plugins couldn't self-discover their allowlist. Extended
InitPayloadwithHostCapabilities []stringso plugins (and their LLM-facing tool descriptions) can condition behavior on capability availability up front, not just per-call denies. - F-026 —
loghost-service ignored thelevelfield. Always logged at Info. Plugins emittinglevel=errorwere invisible to ERROR log searches. Now switches on level (debug/info/warn/error). - F-027 — Plugin args used permissive json.Unmarshal. Switched to
json.NewDecoder(...).DisallowUnknownFields()for bothhttp_fetchandlogso future schema extensions cannot drift silently. - F-028 — HTTP body silently truncated at 10MB. Now detected via
cap+1 read, exposed via new
HTTPFetchResult.Truncated boolfield + Warn log so plugins can treat the body as partial. - F-029 — Capability gate one-shot warn when DB pool nil. Subsequent bypassed calls were silent. Now logs every bypass at Warn so a production misconfiguration is visible across journalctl.
- F-031 — "in this workspace" in identity-marker text. Slack-ism; Telegram users saw the same text. Renamed to "in this Staple company".
- F-032 — Telegram identity-marker fired for groups. The per-DM marker
design assumes the chat IS the user's identity, which is false for
groups/supergroups/channels. Slack solved the analog with
isSlackThreadChat; Telegram now skips the marker form.Chat.Type != "private"with an Info log explaining the skip.
v2.35.5 — 2026-05-25¶
LLM-surface audit, Slice 3 — role + skill schema hardening.
Closes 10 findings (F-016, F-017, F-018, F-019, F-020, F-021, F-045, F-046, F-047, F-048) from BACKLOG #37. F-015 (closure-instruction asymmetry across role .md files) is effectively addressed by v2.35.4's F-010 (the heartbeat prompt's Available Actions block now lists every heartbeat-applicable verb, so role .md files don't need to repeat it).
Findings closed¶
- F-016 — seed.go role drift. Demo seed wrote
role='specialist'which is not inValidAgentRoles. Changed to'qa'. - F-017 — silent role-default application. When
roles.GetDefaultPrompt(role)succeeded, no log; when it failed (role accepted but no .md file), no log either, leading to a two-layer silent fallback to the generic heartbeat default. Addedslog.Infoon success andslog.Warnon missing .md file. - F-018 — yaml decoder accepts unknown keys.
fsskills.ParseSkillMetausedyaml.Unmarshalwith default behaviour — unknown YAML keys silently dropped. Switched toyaml.NewDecoder(...).KnownFields(true)so unknown fields surface as parse errors. - F-019 — yaml parse errors silently swallowed in builtin skill loader.
skills.go::SeedBuiltinSkillshadif yaml.Unmarshal(...) == nil && ...— a malformed yaml yielded an empty description with no log. Now logs the error at Warn. - F-020 — skill shadowing was silent.
merge.go::symlinkSkillsFromremoved existing symlinks without logging. Forensic gap: "which version ofhire-agentdid this run see?" had no answer in logs. Now logs at Info withslug+by_layer(instance/company/agent). - F-021 — skill materialization silently overwrote user files. The
additivedoc-comment was false for slug collisions. Detects overwrite with different content and logs at Info. - F-045 — three skill.yamls had no
name:ordescription:. Populated forhire-agent,list-team,terminate-agentso the digest + UI rendering have a useful description. - F-046 —
write-project-file/skill.yamlclaimedcreated_by: db. Wrong; the skill ships embedded. Changed tobuiltin+ addeddescription:. - F-047 —
qa.mdused colloquial "resolved". Replaced with explicit/status done//status blocked//blocks <id>guidance. - F-048 —
triage.mdmixed@prefix and bare name in/assignexamples. Now consistently bare name.
v2.35.4 — 2026-05-25¶
LLM-surface audit, Slice 2 — heartbeat prompt drift cleanup.
Closes 5 MED findings from BACKLOG #37.
Findings closed¶
- F-010 —
## Available Actionsmissing 6 verbs. The heartbeat prompt listed 18 verbs; the parser (and handler) supported 22 of those 6 were absent:/checkout,/release,/promote-routine,/install-note,/needs-routing,/await-input. Agents had no signal these verbs were emittable. Added all 6 to the prompt + addedTestBuildHeartbeatPrompt_AvailableActionsCoverageinvariant test that asserts every heartbeat-applicable parser verb is mentioned in the prompt. - F-011 — Federation "(future)" misleading hint.
renderCapabilityInvokeHintreturned(future) federate to '<label>' — federation dispatch not yet wiredforPrimitiveFederation. The agent read this as actionable but no dispatcher exists. Returns empty until dispatch is wired. - F-012 — Raw JSONB injected into prompt. When
company_skills.definitionwasn't a clean JSON string,loadSkillsForfell back to raw JSONB bytes in the prompt body — agents saw"# Title\\n\\n..."instead of clean markdown. Now drops the skill with aslog.Warninstead. - F-013 — No parent-level child-issue cap. Heartbeat
## Child Issuesblock had per-child body truncation but no cap on child count — a parent with 20 children rendered 12KB+ silently. AddedchildIssueCap=15 - omission marker.
- F-014 — Unknown activity kinds silently dropped.
renderActivityLinereturned""for unrecognized kinds and the prompt builder filtered them out without a log. New activity kinds added without a renderer disappeared from the agent's memory. Addedslog.Debugper drop.
v2.35.3 — 2026-05-25¶
LLM-surface audit, Slice 1 — CRITICAL + HIGH fixes.
First slice of the systematic audit tracked at .planning/BACKLOG.md
Tier 3 #37. Spec at docs/superpowers/specs/2026-05-25-llm-surface-audit.md;
execution plan at docs/superpowers/plans/2026-05-25-llm-surface-audit.md.
This slice closes the 8 findings (1 CRITICAL + 7 HIGH) that share the
failure shape of the v2.35.1 and v2.35.2 hotfixes.
Findings closed¶
- F-001 CRITICAL —
triagerole unreachable.triage.mdshipped a default prompt, the UI offered it, and engine routing logic special-cased it, butquery.ValidAgentRolesrejected it at creation. Nobody could create a triage agent. Added"triage": trueto the allowlist; added a test assertingValidAgentRoleskeys equal the embedded role .md filenames so this drift class is caught at test time. - F-002 HIGH —
urgentpriority in vocab. The verb cheatsheet shippedpriority: high,urgentbutValidIssuePrioritiesis{critical,high,medium,low}. Pure v2.35.1 drift inside the cheatsheet itself. Replaced withpriority: critical,high-class invariant test. - F-003 HIGH —
/list-issuesignored vocab-promised hints. The vocab promisedpriority,assignee,labelsfilters; the handler read onlystatusandlimit. Wiredassigneethrough toListIssuesParams.AssigneeAgentID(cheap, field already existed), droppedpriorityandlabelsfrom the vocab (handler doesn't apply them — queued as a separate feature). Vocab now matches impl. - F-004 HIGH —
/create-issuestrict-indent hint loop. Last verb on the pre-v2.14.7.1 strict-indented hint loop. Production LLMs emit at column 0; pre-v2.35.3 the heartbeat dispatcher saw emptyHintsand the assignee/blocks/blocked_by/relates fields disappeared silently. Applied the same lenient-loop pattern v2.35.2 used for the four chat verbs. - F-005 HIGH — heartbeat switch had no
default:branch. Any verb the parser emits that the heartbeat handler doesn't case-match was silently dropped — no log, nodropAction, no activity row, no visible comment. Chat verbs (create_note,edit_note, etc.) emitted in a heartbeat reply all reached this hole. Added the default branch withdropActionPLUSMarkAwaitingInput— the 5-minute safe default: the heartbeat for that specific issue is paused entirely until a human comments, so a wrong-verb tight loop cannot burn the token budget. - F-006 HIGH —
ProjectNameandLabelsdead branches. Heartbeat prompt template conditionally rendered both, but the in-processbuildIssueContextnever populated them. Only the remote-payload path loaded them, but that path doesn't useBuildHeartbeatPrompt. ThreadedctxthroughbuildIssueContextand loaded both fields. Pool-nil guard preserves the unit-test path. - F-007 HIGH —
/request-approvalbody silently dropped. SKILL.md taught the LLM to include rich human-reviewer context (plan, risk, rollback) as a fenced body. The parser captured only the title; the body never landed on the approvals row. Added optional fenced-body capture in the parser andBodyMarkdownplumbing through toCreateApprovalParams. - F-008 HIGH —
channelModeBlockover-claimed authentication. Notice asserted "The user IS authenticated. Do NOT suggest…" but the dispatcher can resolveactor=nilfor non-member channel users — contradicting the prose. Rewrote to acknowledge the non-member case and defer to runtime markers. Also fixed F-030: explicit acknowledgement that issue verbs are channel-safe.
Operator notes¶
- No migration required.
- The "5-minute safe default" (F-005) is implemented as
MarkAwaitingInput— the issue's heartbeat is paused indefinitely until a human comments. Strictly safer than a fixed cooldown; a future release may add a time-based variant if needed.
v2.35.2 — 2026-05-25¶
Hotfix: LLM-emitted chat-action hints were silently dropped when not indented, making v2.35.1's status fix invisible.
After v2.35.1 landed, the digest correctly reported "6 done issues"
but /list-issues status: done still came back empty. Tracing the
chat log on production showed the LLM was emitting hint lines at
column 0 — no leading indentation — like this:
/list-issues
status: done
limit: 100
The ParseAgentActions parser for /list-issues, /list-notes,
/create-note, and /edit-note required hint lines to start with
a space or tab. Non-indented hints fell through the loop, the
action arrived with empty Hints, and the chat-action handler
applied its default filter (which excludes done). The bot then
truthfully reported zero done issues — but only because it never
saw the status: done hint.
The same parser pattern already had a fix elsewhere: /install-agent
and /install-federation switched to a "lenient" loop in v2.14.7.1
that accepts hints at any indentation and relies on
parseHintLine's recognized-key check for hint-vs-prose
disambiguation. This release applies the same pattern to the four
chat verbs.
What changed¶
internal/engine/actions.go— the hint-consumption loops for/list-issues,/list-notes,/create-note, and/edit-noteno longer require leading whitespace on hint lines. The loop now terminates on:- a blank line,
- a fence opener (
```, for verbs that allow a fenced body), or - a line that doesn't match
<recognized-key>: <value>. /create-issueis intentionally unchanged — its indentation requirement is a deliberate disambiguator from prose and is documented as such.- Tests — five new regression cases in
TestParseAgentActions_NewVerbscover the production-observed no-indent format for all four affected verbs, plus a prose- termination guard.
Operator notes¶
- No migration required.
- The bot will start honoring
status: done(and every other hint) immediately after restart. Existing chat threads adapt on the next user message.
v2.35.1 — 2026-05-25¶
Hotfix: chat bot was reporting zero issues for done/cancelled/backlog/todo work.
Pre-2.35.1, the chat layer (BuildChatContext digest + /list-issues
chat action verb) used the status name "open" everywhere — but
"open" is not a valid issue status. The real statuses (per
query.ValidIssueStatuses) are backlog, todo, in_progress, in_review,
blocked, done, cancelled. The mismatch meant:
- The digest counted only
open + in_progress + in_review + blocked, so a company whose work was all done/cancelled/backlog/todo looked like "0 issues" to the LLM regardless of how many issues existed. /list-issues(no hint) defaulted tostatus IN ('open', 'in_progress', 'in_review')—status='open'matches zero rows, so the default returned nothing.- The LLM-facing vocabulary block taught the model that
status: openwas the way to query unfinished work, propagating the bug into every chat reply.
What changed¶
internal/engine/chat_context.go—chatContextIssueCountsstruct now carries one field per real status:Backlog, Todo, InProgress, InReview, Blocked, Done, Cancelled(no moreOpenfield).- The
CountIssuesByStatus-> digest mapping handles every real status. All counts contribute toTotal(previously done / cancelled / backlog / todo were silently dropped). activeStatusesis now[backlog, todo, in_progress, in_review]so the recent-issues sample actually reflects active work.- The rendered digest now shows an
Active: N (...)line and aCompleted: M (...)line so the LLM can answer questions like "how many done issues?" directly from the digest. vocabularyBlockdocuments the real statuses for/list-issues, explicitly mentionsdone,cancelled,blockedas valid targets, and removes the bogusstatus: openexample.internal/handler/chat_actions.go—handleChatListIssuesdefault is now[backlog, todo, in_progress, in_review](was[open, in_progress, in_review]).- Tests — fixtures with
Status: "open"updated to real statuses. Three new regression tests added: TestHandleChatListIssues_DoneStatus_v2_35_1— explicitstatus: donereturns done issues.TestHandleChatListIssues_DefaultExcludesDone_v2_35_1— default/list-issuesincludes active statuses, excludes done.TestRenderChatContext_IssuesDigest_ShowsAllStatusesandTestRenderChatContext_IssuesDigest_DoneOnlyCompany— the rendered digest surfaces every status count, so a done-only company is no longer invisible.TestVocabularyBlock_DocumentsRealStatuses— guards that"status: open"never re-enters the vocabulary.
Operator notes¶
- No migration required.
- Existing chat sessions adapt within one turn — the next message rebuilds the digest with the corrected counts and the LLM sees the updated vocabulary.
v2.35.0 — 2026-05-25¶
Attachments are now first-class context for the issue agent. Operators can upload text, markdown, and PDF files when creating or editing an issue; the engine loads extracted text into the agent's heartbeat-run context with a configurable budget cap.
What ships¶
- Text + markdown + PDF extraction at upload time via the new
attachment extraction pipeline (uses
ledongthuc/pdf, pure Go, MIT). Other file types upload but produce empty extracted text — the agent sees a "content unavailable" stub. - Migration 086:
issue_attachments.extracted_text TEXT NULLcolumn. - Multipart create:
POST /api/issuesnow acceptsmultipart/form-datawith embeddedfile=…parts. Response is{issue, attachments, attachment_errors?}with partial-failure semantics (the issue is created even if a subset of attachments fails). JSON path unchanged. - UI form on the create-issue page gets a file input.
- Engine integration: heartbeat-run context for an issue
includes a
## Attachmentsmarkdown block, rendered with a budget cap (most-recent first; truncates at the boundary; remaining attachments rendered as skip stubs).
Caps and timeouts¶
Five new env vars, all defaulted:
STAPLE_ATTACHMENT_MAX_UPLOAD_BYTES = 50_000_000 (50 MB)
STAPLE_ATTACHMENT_MAX_FILES_PER_REQUEST = 20
STAPLE_ATTACHMENT_EXTRACT_TIMEOUT_SEC = 5
STAPLE_ATTACHMENT_EXTRACTED_TEXT_MAX_BYTES = 200_000
STAPLE_ATTACHMENT_CONTEXT_CHAR_LIMIT = 50_000
Compatibility¶
- Pre-v2.35.0 attachments have NULL
extracted_text; agent sees a stub. Delete + re-upload to backfill until a future re-extraction CLI ships. - Office docs (Word/Excel/PowerPoint), images via vision, OCR on
image-only PDFs, chat-action attachment verbs, attachments on
chats/notes, and the operator-controlled "agent can see this"
flag are deferred — see
.planning/BACKLOG.mdTier 1.6.
v2.34.1 — 2026-05-25¶
Hotfix: channel-aware BuildChatContext digest.
v2.34.0 filtered user-private notes at the read-verb dispatch layer
(actorForReadScope inside ProcessChatAgentReply), but left
engine.BuildChatContext unaware of channel context. As a result the
per-turn "Your context" digest still loaded the user's private notes
into the LLM's context window in Slack channel threads — both a
privacy concern (the LLM could echo titles into a channel-visible
reply) and a UX problem (when /list-notes returned empty under the
read-verb filter, the LLM hallucinated "you must not be linked"
because the digest told it otherwise).
BuildChatContext now takes a channelContext bool parameter. In
channel mode it skips the user-private notes block entirely and adds
a "Channel mode" notice telling the LLM the user IS authenticated
and that private notes are intentionally filtered. The Slack
dispatcher passes msg.Kind == KindChannelMention. Telegram passes
false (no channel mode there yet).
DM behavior is unchanged.
v2.34.0 — 2026-05-25¶
Concierge agent now participates in Slack channels via @-mention.
The bot listens for app_mention events in channels it has been
invited to. Each Slack thread maps to one Staple chat; the bot replies
in-thread.
Privacy posture¶
In channel context the bot's read verbs (/list-notes, /get-note)
filter out user-private rows — only company-wide notes are surfaced
into the thread. Write verbs (/create-note, /edit-note, etc.)
still attribute to the real user, so a note created from a channel
ends up private to that user; the bot's confirmation in the channel
does not echo the body.
The identity-linkage marker introduced in v2.33.4 does not fire in channel thread chats. Emitting it in a channel thread would expose one user's linkage state to other channel members.
Operator setup¶
Re-install the Slack app with the new scope app_mentions:read and
event subscription app_mention (see the updated manifest YAML in
docs/user/operators/slack.md). Then /invite @staple into each
channel where you want the bot to be reachable.
Compatibility¶
STAPLE_SLACK_USER_ALLOWLISTcontinues to gate DMs and does not apply to channel@-mentions. Channel membership is the access policy for channel context.- Existing v2.32+ DM chats (whose metadata lacks the new
kinddiscriminator) keep working without migration. - No backfill of prior thread history — the bot's memory of a thread starts at its first invitation into it.
v2.33.5 — 2026-05-24¶
Two UX fixes uncovered immediately after v2.33.4 shipped.
Identity marker — corrected nav phrasing¶
The v2.33.4 marker for unlinked external identities pointed users at
"Settings → Profile", but Staple's navigation has no such path. The
actual route is the user-menu dropdown in the top-right corner of any
page, with a "Settings" entry that loads /me/settings (the page is
titled "Profile settings"). Users searching for the literal v2.33.4
phrasing came up empty. The marker now reads "open the user menu
(top-right corner) and choose Settings" and is pinned by a new
regression test that forbids the old phrase.
User-menu trigger — discoverability chevron¶
The user-menu trigger in layout.html was just a smiley character and
the display name, with no visual affordance signalling it was a
dropdown. Added a ▾ chevron after the display name (dimmed to 60%
opacity, brightening on hover) plus aria-haspopup="menu" and
aria-label="Open user menu" so screen readers announce the affordance
correctly. The chevron is the difference between users finding
/me/settings on their own vs filing a ticket about it.
v2.33.4 — 2026-05-24¶
Chat UX — explicit identity-linkage hint markers.
Problem¶
When a Slack or Telegram user whose external identity wasn't linked to a Staple account sent a DM, the agent silently returned zero private notes. The agent had to infer from empty results that the linkage was missing. That inference was unreliable: sometimes it diagnosed correctly, sometimes it gave a confused "data connection hiccup" response.
Fix¶
Both the Slack and Telegram dispatchers now call
handler.ReconcileIdentityMarker on every inbound DM, immediately after
the actor is resolved and the chat is loaded, but before the user's
message is appended. The helper:
- Reads the chat's
metadata.identity.actor_user_idsnapshot. - Compares it to the freshly resolved actor.
- If the state has changed (or is being observed for the first time and
warrants a notice), appends a
role="system"message to the chat transcript with a plain-language explanation. - Writes the new snapshot back to
chat.metadataso subsequent DMs are idempotent (no repeated markers for unchanged states).
The state-transition matrix covers four cases: - First DM, unlinked → "not linked" marker with Settings → Profile hint. - Previously unlinked, now linked → "now linked" marker with user email. - Previously linked, now unlinked → "no longer linked" marker. - Linked to user X, now linked to user Y → "re-linked to different user" marker.
Because the marker lives in chat_messages, the dispatcher's existing
transcript-loading path feeds it to the LLM on the next turn automatically.
No change to BuildChatContext was needed.
Multi-tenant safety¶
The marker text is a pure function of (channel, externalUserID, actor).
It does not reveal whether the external identity has a Staple account in any
other company. The v2.33.1 cross-tenant actor guard still fires as before
(nil actor), and from the marker's perspective "cross-tenant user" and
"never-linked user" look identical — both produce the same "not linked in
this workspace" message. The v2.33.1 regression tests continue to pass
unchanged.
Multi-instance safety¶
The marker references "Settings → Profile" in plain text rather than a hardcoded instance URL.
v2.33.3 — 2026-05-24¶
Hotfix — chat-action re-prompt now ends with a user message.
Bug¶
When a user asked the Concierge agent "What Notes do I have?" via Slack
or Telegram, the agent's first reply contained a /list-notes action
verb. The dispatcher executed the verb and received []ChatReadResult,
then re-prompted the LLM to convert those results into a natural-language
reply. That second call returned HTTP 400 from Anthropic:
This model does not support assistant message prefill. The conversation must end with a user message.
The user saw the raw first-turn reply (with /list-notes still visible)
plus a trailing _(re-prompt failed; results unavailable)_ marker.
Root cause¶
The transcript built for the second ChatComplete call ended with an
assistant message (the first-turn reply). Anthropic's chat API requires
the final message to be user-role. The previous code injected the
action results as a system message via WithChatContext, which is
inserted before the assistant turn at the structural level — the wire
order was still […, assistant].
Fix¶
Dropped WithChatContext from the second call. The action results are
now appended as a synthetic user turn at the end of the extended
transcript, which is the correct tool-use pattern: assistant emits a
tool call, tool result feeds back as a user/tool turn, assistant replies.
The transcript-building logic is extracted into
handler.BuildChatRePromptTurns so both the Slack and Telegram
dispatchers share the fix in one place. Truncation of oversized result
blocks (10 KB soft cap) is also centralised there.
A dedicated unit test (TestBuildChatRePromptTurns_EndsWithUserRole)
encodes the invariant so this regression cannot be reintroduced silently.
v2.33.2 — 2026-05-24¶
Build tooling — reproducible in-place deploys. The v2.33.1 deploy
exposed a footgun: rolling a new tag onto an existing systemd host
required manually deriving ldflags, building, atomically swapping
the running binary, and restarting the service. The first attempt
shipped a "dev"-versioned binary because the ldflags step was
missed, which then required a rebuild + reinstall to surface the
correct version string.
What ships¶
make build-release— buildsstaple-{server,worker,seed,cli}with the same-ldflagsGoReleaser uses (-X main.version,-X main.commit,-X main.date),-s -wto strip symbols, andCGO_ENABLED=0for static linking. Version is derived fromgit describe --tags --exact-match HEAD; an untagged commit builds asdev.make install— depends onbuild-release. Atomically swaps eachstaple-*binary in$(INSTALL_PREFIX)(default/usr/local/bin) viacp → mvso the rename is atomic at the dirent level and the running daemon keeps its open inode until restart. Avoids theETXTBSY("Text file busy") error you get fromcponto a running binary.make deploy— depends oninstall. Restarts the systemd unit named$(SERVICE_NAME)(defaultstaple), waits 3s, and tailsjournalctlfor the"server starting"log line as a smoke check. Linux-only; on macOS, runmake installand restart the LaunchAgent manually.
Variables INSTALL_PREFIX, SERVICE_NAME, and SUDO are
overridable for user-level installs, non-default service names, and
rootless flows. See docs/user/operators/installing.md "Updating a
running deploy in place" for the full operator runbook and the
distinction from make dist (which wraps goreleaser release to
publish signed tarballs).
make build is unchanged — it remains the fast, no-ldflags
developer target.
v2.33.1 — 2026-05-24¶
Multi-tenant hotfix for v2.33.0 chat actions. v2.33.0 introduced chat-driven Notes/Issues management on the Concierge agent, with plumbing that worked correctly for a single-tenant deployment but left two cross-tenant gaps. Both are closed here.
Security fixes¶
-
Cross-tenant note access via full UUID (IDOR).
resolveNoteIDpassed full UUIDs through unscoped, and the downstreamquery.GetNoteByIDdid not filter by company. A chat in company A, given the UUID of a note in company B, could/edit-note,/archive-note,/unarchive-note, or/get-noteagainst company B's note. Each affected handler now compares the resolved note'sCompanyIDagainst the chat's and returns a "not found" marker on mismatch — same shape as a genuine miss, so UUIDs cannot be enumerated. Four regression tests undertests/integration/concierge_chat_idor_test.go. -
Cross-tenant actor attribution in chat dispatchers. Slack and Telegram dispatchers resolved the actor via a global external-ID lookup (
GetUserBySlackID/GetUserByTelegramID) without verifying the resolved user is a member of the dispatcher's bound company. A user with membership in company B and a matching external identity would have been recorded as the author of writes in company A. Both dispatchers now callquery.GetUserCompanyRoleafter resolution and nil out the actor on mismatch with aslog.Warn. Integration tests undertests/integration/dispatcher_cross_company_test.gocover both channels for cross-company-rejection and in-company-preservation.
Observability¶
- Every cross-tenant chat-action rejection (4 note guards + 1
pre-existing belt-and-suspenders issue guard) now emits a
slog.Warnwithverb,chat_id,chat_company_id,target_id,target_company_idso operators can detect probes against a multi-tenant deployment.
Configuration¶
CONCIERGE_CONTEXT_NOTES_LIMITandCONCIERGE_CONTEXT_ISSUES_LIMITare now actually wired up (v2.33.0 documented them but they were hard-coded constants). Defaults preserved at15so env-unset behaviour is unchanged. Seedocs/user/operators/environment.md.
v2.33.0 — 2026-05-24¶
Concierge gets Notes and Issues over chat. The Concierge agent (the one wired to Slack DMs and Telegram) can now answer questions about and mutate the operator's Notes and Issues directly from the chat channel, alongside the existing Staple chat UI.
What ships¶
- 8 new chat action verbs:
/create-note,/edit-note,/archive-note,/unarchive-note,/get-note,/list-notes,/get-issue,/promote-routine. The agent decides when to use them; operators get write+read capability without leaving Slack or Telegram. - Per-message context digest: the Concierge now sees a compact digest of recent notes and in-flight issues injected into every chat turn, so it can answer "what notes do I have?" without always doing a tool call first.
- External identity linking: a new Settings UI lets each user link their Slack and Telegram user IDs to their Staple account for correct attribution of chat-driven writes.
- Note version history:
/edit-notewrites a new row into anote_versionsaudit table atomically with the body change so prior versions are recoverable. - Operator runbook:
docs/user/operators/notes.mdwalks through the verb vocabulary, hint syntax, and tuning the digest size.
See the v2.33.1 entry for the multi-tenant correctness work applied on top of this release.
v2.32.0 — 2026-05-23¶
BACKLOG #6 — Slack channel adapter. First new inbound channel since Telegram (v2.18.0). Closes the only remaining open active item on the BACKLOG. Operators can now chat with their Staple agents via Slack DMs the same way they can via the web UI or Telegram.
What ships¶
internal/channels/slack/ — two files mirroring
internal/channels/telegram/:
- bot.go: wraps
github.com/slack-go/slackfor Socket Mode + the REST API. Connect / Run / SendMessage / UpdateMessage / AuthTest. Token-shape validation up front (catches "pasted xapp- into the bot-token slot"). Echo filter via self user_id. Auto-truncate at 39,900 chars with a marker pointing at the Staple UI for the full record (Slack rejects > 40,000 chars). - dispatcher.go: glue between Slack MessageEvents and
the chat substrate. Allowlist gate before any chat work.
Placeholder-then-edit UX (post "Thinking…" → call
engine.ChatComplete→ edit the placeholder with the final reply). Matches the chat-row + cost-event recording of the Telegram dispatcher.
Design highlights from the BACKLOG #6 conversation¶
- Socket Mode only — no public URL required. Bot opens an outbound WSS to Slack. Mirrors Telegram's no-public-URL posture; right default for self-hosted Staple.
- DMs only for v1 —
channel_type=="im". @mentions in channels, slash commands, and Block Kit are clean v1.1 follow-ups (each carries its own design and isn't blocking). - One bot per instance, one company, one default agent.
Multi-workspace deferred (would need a
team_id → company_idtable; doable later). - Required allowlist from day 1 —
STAPLE_SLACK_USER_ALLOWLISTmust be set to either comma-separated Slack user_ids (e.g.U12345,U67890) or*for everyone in the workspace. Empty/unset is a startup error — the operator must make an explicit policy choice. Non-allowlisted users get a polite rejection reply (not silent ignoring) pointing them at the env var. - Placeholder-then-edit reply UX — Slack supports message edits cheaply; the bot posts "Thinking…" immediately so the user sees a response, then edits that message with the real LLM reply. Big UX win over Telegram's silent-then-pop pattern on long replies.
- Long-message safety — truncate at 39,900 chars with marker.
- Library:
slack-go/slackv0.23.1 — Socket Mode handles the WebSocket protocol details (frames, ping/pong, reconnect with backoff). Rolling our own would have eaten more code than the rest of the adapter combined.
Environment variables¶
STAPLE_SLACK_BOT_TOKEN=xoxb-... # required
STAPLE_SLACK_APP_TOKEN=xapp-... # required, Socket Mode app-level
STAPLE_SLACK_COMPANY_ID=<uuid> # required
STAPLE_SLACK_USER_ALLOWLIST=U1,U2 # required (or "*")
STAPLE_SLACK_DEFAULT_AGENT_ID=<uuid> # optional
When all four required vars are set, the Slack dispatcher
starts at boot and logs team_name, bot_name, self_user_id,
company_id, default_agent_id so operators can confirm the
right workspace and bot identity. Partial-config logs a WARN
with a per-var boolean. Token validation / auth.test /
allowlist parse failures exit(1) — operators need to know
immediately, not silently degrade.
Chat mapping¶
One Slack DM channel (D… id) ↔ one Staple chats row:
{
"slack": {"channel_id": "D…", "user_id": "U…",
"user_name": "alice", "team_id": "T…"},
"strict_actions": false
}
strict_actions: false matches Telegram — DM is operator-
initiated; the user wants the agent able to act, not just
suggest. Per-chat toggle from the UI.
Test coverage¶
internal/channels/slack/bot_test.go (10 assertions, 3 funcs):
- TestTruncateForSlack — size invariant + prefix property at
empty, short, exactly-at-limit, one-byte-over, huge-overrun
- TestNewBot_TokenValidation — happy path, empty, wrong-prefix
paste (xoxb/xapp confusion)
- TestSafePrefix — no full-token leakage in error messages
internal/channels/slack/dispatcher_test.go (14 assertions, 4 funcs):
- TestParseAllowlist — 8 rows: empty (error), whitespace-only
(error), wildcard sentinel, single user, multi-user, whitespace
around entries, trailing-empty tolerance, only-separators
(error)
- TestIsAllowed — wildcard allows anyone; explicit list rejects
non-members and empty
- TestNewDispatcher_RequiresAllowlist — load-bearing guard:
empty allowlist is a startup error
- TestNewDispatcher_RequiresCompanyID — companion guard
Operator runbook¶
docs/user/operators/slack.md includes a paste-able Slack app
manifest YAML for one-click app creation (avoids clicking
through every OAuth scope individually). Setup is ~10 minutes
end-to-end.
Open follow-ups (not blocking)¶
- @mention in shared channels — relax channel_type filter + thread_ts on reply
- Slash commands (e.g.
/staple) - Streaming replies via edit-message
- Multi-workspace support
- UI-driven allowlist on
/admin/settings
New dependency¶
github.com/slack-go/slack v0.23.1 (+ github.com/gorilla/websocket
v1.5.3 transitive). Well-maintained, broadly used.
4 commits this milestone. Closes the last remaining open BACKLOG item; the active queue now contains only Tier 4 wait-for-signal items.
v2.31.0 — 2026-05-23¶
BACKLOG #12 / SEC-019 — encryption key rotation (KEK / DEK envelope). The second-to-last open SEC audit finding closed. Rotating the master encryption key no longer requires re-encrypting your secrets, your provider API keys, or your OAuth tokens.
Architecture¶
Two-key envelope. Your STAPLE_ENCRYPTION_KEY is now the KEK —
it wraps a per-installation DEK that actually encrypts your
data. Rotating the KEK re-wraps the handful of DEK rows in the new
data_encryption_keys table; the rest of your encrypted data
(company_secrets, company_providers, OAuth tokens, trigger
HMACs) is untouched.
Envelope formats:
enc:v1:<aes-gcm-blob>— legacy single-key path (SEC-010, still decryptable indefinitely)enc:v2:<dek_id>:<aes-gcm-blob>— KEK/DEK envelope, canonical going forward
What ships¶
Crypto layer (internal/crypto/vault.go):
Vaultstruct: in-memory DEK registry, KEK reference, KEK fingerprint. RWMutex-protected.NewVault(ctx, pool, kek): loads + unwraps DEKs from the database. Rejects KEK-fingerprint mismatches loud. Bootstraps an initial active DEK if the table is empty.vault.Encrypt/vault.Decrypt: produce / consume the newenc:v2:envelope. Decrypt dispatches on prefix — v1 rows flow through the legacy single-key path.vault.RotateKEK(newKEK): atomic re-wrap of every DEK in one transaction.vault.RotateDEK(): new active DEK + demote previous active to retired. Existing ciphertexts keep working.- Package-level default Vault wired via
InitDefaultso the existing 48crypto.Encryptcall sites transparently start emittingenc:v2:<dek_id>:...once the server boots.
Server bootstrap (cmd/server/main.go): calls
crypto.InitDefault after db.NewPool when
STAPLE_ENCRYPTION_KEY is configured. Logs active_dek_id,
dek_count, kek_fingerprint at INFO. Crashes-on-fail
(KEK mismatch → server doesn't start).
CLI (cmd/staple-cli/):
vault-status— read-only status panel. Supports--json.rotate-kek— accepts--new-key=<base64>or--generate. Single-transaction re-wrap. Interactive y/N confirm unless--yes.rotate-dek— new active DEK, retire previous. Interactive confirm unless--yes.rewrap-all— bulk-migratecompany_secret_versions+company_providersto enc:v2 under the active DEK. Idempotent. Supports--dry-run.
UI: /admin/settings now shows a read-only "Encryption (KEK /
DEK)" panel — active DEK id, registry size, KEK fingerprint, plus
a CLI runbook hint. Web-driven rotation is intentionally NOT
exposed — destructive crypto ops are CLI-only.
Docs: docs/user/operators/key-rotation.md is the operator
runbook. Covers preventive vs incident-response shape, pre-flight
verification, the no-recovery KEK-loss case, common gotchas.
Migrations¶
083_data_encryption_keys.sql — DEK registry table. Partial unique index enforces "one active DEK at a time."
Locked design decisions (from BACKLOG #12 conversation)¶
- Driver: preventive (not incident-driven). Build the rotation muscle before it's needed.
- DEK scope: per-installation (one active DEK at a time). Per-row DEK rejected — too much complexity for no security gain in a single-Postgres-DB posture.
- KMS support: deferred. The env-var KEK path is primary; a cloud-KMS adapter slot can land later without rewriting the envelope.
- Migration: both lazy (next write picks up enc:v2:) and bulk
(
staple-cli rewrap-all). - Rotation surface: CLI-first with a read-only UI banner. No web button for destructive crypto.
Test coverage¶
8 new assertions in internal/crypto/vault_test.go:
TestVault_BootstrapAndRoundTrip, TestVault_DecryptsLegacyV1,
TestVault_RotateKEK (load-bearing: pre-rotation ciphertext
decrypts post-rotation), TestVault_RotateDEK,
TestVault_RejectsKEKMismatch, TestVault_DecryptUnknownDEKID,
TestEncrypt_FallbackV1WhenNoVault, TestNeedsRotation_V1WithVault.
Full handler suite (~175s) and integration tests (~11s) both green; no regression.
Open follow-ups (not blocking)¶
- Cloud KMS KEK adapter (AWS KMS / GCP KMS / Hashi Vault).
- OAuth tokens + trigger HMAC bulk-rewrap (currently lazy-only,
matching the existing
encrypt-secrets/encrypt-providersprecedent).
4 commits shipped this milestone. SEC-019 closed.
v2.30.0 — 2026-05-23¶
BACKLOG #13 / SEC-016 — tenant isolation defense-in-depth. The
largest open audit finding closed. Postgres Row-Level Security is now
the database-layer floor under the application-layer company_id
filters on every high-value tenant table. A future missing-filter bug
in any of ~787 query functions no longer leaks data across tenants —
RLS returns zero rows (or rejects the write) instead.
Architecture¶
Two roles. The application pool stays connected as the superuser
(typically staple); a per-request transaction issues
SET LOCAL ROLE staple_app + SET LOCAL app.current_company_id
to engage RLS for the duration of the transaction. staple_app is
a NOLOGIN NOBYPASSRLS role created by migration 079 with broad
table grants and default privileges for future tables.
Two helpers in internal/db/scope/scope.go:
WithTenantScope(ctx, pool, companyID, fn)— opens a tx, switches role, sets the session variable, runs fn(tx), commits/rolls back. Validates companyID as a UUID before opening the tx (fail-fast on garbage input).WithBypass(ctx, pool, fn)— symmetric API for legitimately cross-tenant paths (login, instance admin,/companiespicker, migrations, bulk CLI). No role switch; RLS stays bypassed.
Two migration patterns for engaging RLS at call sites:
- Pattern P1 — query function wraps
WithTenantScopeinternally. Used wherever the query takes a companyID parameter. Handler call sites stay unchanged; RLS engages automatically. - Pattern P2 — handler opens
WithTenantScopeusingactor.CompanyIDand passes the resulting tx to entity-id query variants (GetIssueByIDTx,CheckoutIssueTx, etc.). Used for handlers that operate on{issueId}/{secretId}/ etc.
What's now in production¶
30 tenant tables have RLS policies armed across two waves:
Wave 1 (migrations 080 + 081) — 14 tables: issues, issue_comments, activity_log, agents, chats, chat_messages, company_secrets, company_secret_versions, company_providers, projects, routines, routine_triggers, approvals, issue_approvals.
Wave 2 (migration 082) — 16 tables: Plugin substrate (7): plugins, plugin_tools, plugin_jobs, plugin_webhooks, plugin_events, plugin_event_subscriptions, plugin_approval_events. A2A substrate (3): a2a_outbound_tasks, a2a_peers, a2a_remote_agents. Core data (6): goals, capabilities, documents, labels, assets, heartbeat_runs.
Policy shape: FOR ALL TO staple_app with USING + WITH CHECK keyed
on either company_id = current_setting('app.current_company_id')::uuid
(direct) or an EXISTS subquery against the parent table (indirect).
FORCE ROW LEVEL SECURITY is intentionally not set so table owners
(migrations, the superuser pool) keep bypassing — engagement only
happens inside WithTenantScope.
38 production query functions migrated to Pattern P1 across 12 files: issues, activity, agents, projects, secrets, approvals, chats, providers, routines, plugins, goals, a2a_peers. Including all 5 CREATE write paths (Issue / Agent / Routine / Secret / Provider) where WITH CHECK now rejects cross-tenant inserts at the DB layer.
2 handlers migrated to Pattern P2: CheckoutIssue + ReleaseIssue
JSON endpoints. Demonstrates the template for the remaining entity-
id handler family.
Test coverage¶
10 cross-tenancy regression assertions in internal/db/scope/:
TestRLSPreventsCrossTenantLeak (4 subtests): tenant_A_sees_only_A, tenant_B_sees_only_B, bypass_sees_both, direct_pool_sees_both. TestRLSWriteCheckBlocksCrossTenantInsert: WITH CHECK rejects cross-tenant inserts. TestWithTenantScope_RejectsInvalidCompanyID (3 subtests): empty, not-a-uuid, partial-uuid all fail-fast. TestRLSDirectTable_AgentsIsolation / _ApprovalsIsolation: direct policy shape on production-shaped tables. TestRLSIndirectTable_IssueCommentsIsolation (3 subtests) / _RoutineTriggersIsolation: EXISTS subquery policy shape on two different parents, including WITH CHECK cross-tenant insert rejection.
Belt-and-suspenders linter (slice 4)¶
internal/handler/no_raw_db_test.go is an AST-based test that runs
in every go test ./internal/handler/... invocation. It walks every
non-test .go file in the handler package and fails the build on any
direct pool.Query / pool.Exec / pool.QueryRow / pool.Begin*
/ pool.SendBatch call that lacks an // rls:allow <reason>
annotation. The annotation supports multi-line comment groups so
operators can write a real rationale, not just a marker.
Ships green: 5 pre-existing legitimate raw accesses each carry a
documented reason (pre-RLS company_id lookups, post-fire metadata
writes on rows the engine just created). Any new raw access added
by a future PR fails this test until the author routes through
query.*, wraps in scope.With*, or annotates with a reason.
Migrations¶
079_tenant_rls_role.sql — staple_app role + grants 080_rls_issues.sql — issues canary RLS policy 081_rls_slice2_high_value.sql — RLS on 13 wave-1 tables 082_rls_slice3_extended.sql — RLS on 16 wave-2 tables
Open follow-ups¶
Mechanical work that extends established patterns; not blocking on this milestone:
- Pattern P2 expansion (~10-15 more entity-id handler families following the CheckoutIssue/ReleaseIssue template).
- RLS on remaining lower-value tenant tables (agent_runtime_state, cost_events, finance_events, evolution_traces, etc.).
- Pattern P2 / handler migrations for the UI surfaces (
UIIssueShow,UpdateIssue, etc.) — same template as the JSON API.
12 commits shipped this milestone. Defense-in-depth tenant isolation is in production on the high-value paths today.
v2.29.0 — 2026-05-23¶
BACKLOG #17 — approvals can now carry operator context. When an
operator resolves an approval from the UI, an optional free-text
resolution field is captured alongside the Approve / Reject verdict
and blockquoted into the comment that auto-posts on every linked issue.
The agent picks up that context on its next heartbeat — so an
operator who rejects with "wrong tone for the audience, try again with
a more formal voice" sees the agent actually use that signal.
What changed¶
The engine substrate already supported this end-to-end: applyApprovalResolution()
has long taken a resolution string, blockquoted it line-by-line into
the auto-posted comment, and persisted the value in the approvals.resolution
column for the Properties panel to render. The UI never caught up — the
two hx-vals buttons on the approval Properties panel sent only the
verdict, and the UI handler hardcoded resolution := "Resolved via UI".
Every linked-issue comment therefore carried that boilerplate filler and
no operator context could ever reach the agent.
Fix:
_properties.html: replaced the twohx-valsbuttons with a single<form>containing a<textarea name="resolution">above two submit buttons (name="action" value="approved|rejected").ui_approvals.go: replaced the hardcoded string withstrings.TrimSpace(r.FormValue("resolution")). Empty submits land cleanly — the engine's no-blockquote branch produces a comment with the verdict only, no boilerplate filler.- Optional on both Approve and Reject — operator-flagged as YAGNI to enforce reason on rejection; the engine's existing copy already spells out "The agent should not proceed with the requested action."
Coverage¶
TestApprovalResolution_ResolutionTextLandsInLinkedIssueComment
(table-driven, 3 rows):
approve_with_reason— asserts the blockquoted text lands in the linked-issue comment AND persists onapprovals.resolution.reject_with_multiline_reason— asserts the engine's per-line"> "prefix preserves newlines.whitespace_only_resolution_trims_to_empty— asserts the trim path produces no"\n> "artifact and no"Resolved via UI"leftover.
Test self-seeds a users + company_memberships row so it runs
regardless of fixture state (the pre-existing two resolution-fanout
tests skip on a fresh dev DB; this one doesn't).
Out of scope (intentionally — corner cases first)¶
- Telegram / chat-channel resolution flow still verdict-only.
- Required-comment-on-reject enforcement.
- Comment templates / structured form fields.
v2.28.3 — 2026-05-22¶
BACKLOG #28 — themed confirm / prompt / alert modals replace native
browser dialogs. Operator-flagged via screenshot during the v2.28.x
review: HTMX action buttons triggered the OS-native confirm() chrome
instead of the dark Staple theme. Cosmetic, not security-affecting,
but the inconsistency was visible on every destructive action.
What changed¶
A single #staple-modal overlay added to layout.html (re-using the
existing .modal-overlay + .modal-card classes that already power
promote-routine-modal and shortcut-help). An IIFE shim exposes:
window.stapleConfirm(message, opts)→Promise<boolean>window.staplePrompt(message, defaultValue, opts)→Promise<string|null>window.stapleAlert(message, opts)→Promise<void>
A htmx:confirm event listener auto-themes every hx-confirm
attribute in the codebase with zero per-template changes — the
sync→async gap that ruled out a drop-in window.confirm replacement
is bridged via HTMX's evt.detail.issueRequest(true) pattern. The
themed dialog opens, and only when the operator clicks OK does the
underlying request fire.
For prompt-style flows (capture a reason before posting), forms add
data-staple-prompt="<paramName>" alongside hx-confirm="<msg>". The
listener switches the dialog to prompt mode, stashes the entered
value on the triggering element, and a htmx:configRequest listener
injects it into the outgoing request parameters. This replaces the
old hx-vals='js:{reason: prompt("…") || ""}' pattern in plugins
Allow/Ban and secrets Reveal.
For non-HTMX buttons (e.g. the formaction-based note delete), a
capture-phase click handler intercepts [data-staple-confirm],
shows the themed confirm, and re-fires the click with a marker
attribute to avoid recursion.
Add data-staple-confirm-danger="1" on a destructive form/button
to render the OK button in red (btn-danger).
Templates converted¶
- 14
onsubmit="return confirm(...)"on HTMX forms →hx-confirm:capabilities/{index,view}.html,plugins/show.html(×2),instance_skills/index.html,secrets/{index,_table}.html,trash/_table.html(×2),files/_table.html,skills/index.html,agent_skills/{index,_partial}.html,fs_skills/_table.html. - 4
hx-vals='js:{reason: prompt(...)}'→hx-confirm+data-staple-prompt="reason":plugins/show.html(3 Allow/Ban reasons),secrets/index.html(1 Reveal reason — preserves the SEC-017 admin-audit contract since the value is still posted to the same/revealendpoint). - 2
alert(...)→await window.stapleAlert(...):portability/index.html(export error paths). - 1 non-HTMX
onclick="return confirm(...)"→data-staple-confirm+data-staple-confirm-danger="1":notes/show.html(Delete button).
The 30 pre-existing hx-confirm attributes across the rest of the
codebase pick up the themed dialog for free — no template edits
needed for them.
Out of scope¶
Focus management beyond returning focus to the previously-active element on close. (Tab-trap is a follow-up if accessibility audit flags it.)
v2.28.2 — 2026-05-22¶
Three loose-end findings from the plugin sandbox validation run. Companion patch to v2.28.1 — none of these are security-affecting on their own, but they tighten the runbook artifacts and the UI bookkeeping the validation run flagged.
F1 (MEDIUM) — UIPluginStart no longer writes misleading state='running' on failure¶
UIPluginStart and UIPluginRestart used to discard the return
value of host.StartPlugin and unconditionally bump
plugins.state to 'running'. When the sandbox approval gate
refused a start (plugin pending / banned / hash mismatch / docker
error), the UI showed running while no process existed.
The handlers now check the error, respond with HTTP 422 and a
short message (rendered as a toast by the layout's
htmx:responseError listener), and only bump the lifecycle
state on success. UIPluginRestart's failed-start branch writes
state='stopped' to reflect that the prior process was
successfully stopped above.
Tests: TestUIPluginStart_DoesNotBumpStateOnRefusal,
TestUIPluginRestart_DoesNotBumpStateOnRefusal.
F2 (LOW) — Evil-plugin probe content-checks PID namespace instead of byte-counting¶
The validation probe's fs-read mode used to mark
/proc/1/root/etc/passwd and /etc/shadow as FAIL whenever
the read succeeded with non-zero bytes. But Alpine ships its
own copies of those files; the container reading its OWN
/etc/shadow is correct sandbox behaviour. The runbook
explicitly called out the false-positive but the probe didn't
act on it.
Now the probe reads each path two ways (/etc/passwd directly
and via /proc/1/root/etc/passwd) and asserts the bytes are
identical. Inside an isolated PID namespace they're the same
file. A mismatch would indicate the host's PID 1 is leaking
through — which is the real failure mode worth checking. New
verdict keys: ns-leak-passwd, ns-leak-shadow.
F3 (LOW) — Network interface probe checks routable addresses, not interface count¶
--network=none containers still expose kernel-default tunnel
stubs (tunl0, gre0, gretap0, erspan0, ip_vti0,
ip6_vti0, sit0, ip6tnl0, ip6gre0) — none configured.
The probe used to mark FAIL when more than lo was listed.
Updated probe enumerates each interface's Addrs() and
ignores loopback / unspecified / link-local. PASS when no
routable address is present, regardless of how many interface
stubs Linux exposes.
What the runbook looks like now¶
After v2.28.1 + v2.28.2, a fresh end-to-end runbook execution against a clean sandbox produces:
- §3.1 — 10 PASS / 0 FAIL (was 9 PASS / 2 false-positive FAIL)
- §4 — 3 PASS / 0 FAIL (was 2 PASS / 1 false-positive FAIL)
No security guarantees changed; the sandbox always behaved correctly. These fixes make the runbook artifact noise-free so future operators don't have to mentally subtract probe-logic bugs from the result.
v2.28.1 — 2026-05-22¶
Three sandbox runtime bugs found by the validation runbook.
First end-to-end execution of the plugin sandbox validation
runbook (.planning/testing/plugin_sandbox_validation.md)
against a throwaway instance (via make sandbox-validate-fresh)
surfaced three real bugs that the unit tests didn't catch.
Structured run report at
.planning/testing/plugin_sandbox_validation_run_20260522.md.
B1 — HIGH: HTTP-initiated plugin starts SIGKILLed immediately¶
PluginHost.StartPlugin derived its process context from the
caller's ctx. HTTP handlers pass r.Context(), which is
cancelled the moment the handler returns. The exec.CommandContext
for the docker run process inherited that cancellation and sent
SIGKILL within microseconds — before docker could create the
container. The host logged "plugin started" because cmd.Start()
returned nil; in reality nothing ran.
This bug pre-dated BACKLOG #7. Existing plugin host unit tests
passed because they call StartPlugin(context.Background(), ...)
directly, not via HTTP. Every UI/API plugin start since the
plugin host was first written was silently failing.
Fix: internal/plugins/host.go — derive procCtx from
context.Background() instead of the caller's context. The host
still controls cancellation via its own cancel() called from
StopPlugin.
B2 — HIGH: --memory defeatable via swap¶
Memhog probe allocated 2GB inside a --memory 256m container
without being OOMKilled. Cgroup memory.max was correctly set,
but Docker's --memory flag leaves memory.swap.max at default
(unlimited). A plugin could allocate beyond the cap by paging
to swap; cgroup memory accounting is RSS-based and swapped pages
don't count.
The SEC-020 memory cap was effectively bypassable — a hostile plugin could exhaust the host's swap and the kernel only OOMs when the SYSTEM runs out, not when the container does.
Fix: internal/plugins/sandbox.go — pass --memory-swap
equal to --memory on every sandboxed container. Verified
post-fix: memhog OOMKilled at ~256MB instead of growing
unbounded. internal/plugins/sandbox_test.go updated to assert
the new flag in the argv shape.
B3 — HIGH: UI denied-audit contract bypassed by middleware¶
Non-admin user POSTs to /plugins/{id}/ui/allow → HTTP 403
{"error":"insufficient permissions"} — but no denied audit
row written.
The three UI approval-transition routes (/ui/allow, /ui/ban,
/ui/rescind-ban) were placed inside a route group with
middleware.RequireRole(pool, "admin"). That middleware
short-circuited every non-admin request before the in-handler
denied-audit code could run. The audit contract designed in
BACKLOG #7 ("denied attempts still write a 'denied' audit row
before responding 403") was silently broken for the UI surface.
The parallel JSON API was correctly placed outside the admin
group.
Fix:
cmd/server/routes.go— move the three UI approval routes OUT of the admin-only group, into the outer auth-only group where the plugin-detail read routes already live.internal/handler/ui_plugin_approval.go—uiPluginApprovalGatenow resolves the actor's company role itself viaquery.GetUserCompanyRole, since the outer group doesn't runRequireCompanyAccessfor these URLs (nocompanyIdparam).
Verified: member user POSTs /ui/allow → HTTP 403 with the
handler's "only company admins may change plugin approval
state" message + denied audit row recorded.
Companion tooling¶
cmd/sandbox-fixtures/main.go— internal one-shot fixture seeder that populates the throwaway sandbox-test instance with admin@test, member@test, Company-A, Company-B. Used by the validation runbook; not built into release binaries.internal/plugins/testdata/evil_plugin/main.go— probe-mode fallback reads/workspace/.modewhenSTAPLE_PROBE_MODEenv var is not set, since the sandbox doesn't currently pipe arbitrary env from plugin config into docker-eflags. The host writes the mode file via the workspace bind-mount before starting the probe.
Sandbox unit tests now reflect the new contract¶
TestBuildSandboxedCommand_ProducesDockerInvocation asserts
that --memory-swap appears alongside --memory in the argv
shape.
Not in this patch — operator validation still required¶
The runbook sections that need human observation (PIDs forkbomb
under docker exec ... ls /proc, CPU throttle under
docker stats, capability allowlist with a protocol-speaking
plugin, sandbox opt-out posture, UI rendering) are NOT validated
by this commit. Re-run the runbook against this patch on a bare
Linux host before signing off the v2.27.0 sandbox milestone
formally.
v2.28.0 — 2026-05-22¶
Login hardening (BACKLOG #8). Closes SEC-013 and SEC-018 — two Medium-severity gaps in the login flow that paired cleanly into one slice.
SEC-013 — Per-(email, IP) account lockout¶
Before this release the rate limiter blocked traffic per-IP/per- token, but there was no per-account lockout. An attacker who knew a valid email could slow-brute the password without tripping the rate limit.
New login_attempts table (migration 078) records every login
attempt with (email, ip, succeeded, attempted_at). The login
handler now consults query.IsLockedOut BEFORE the user lookup
so response timing doesn't leak whether the email exists.
Locked-out requests get the same generic "invalid email or
password" error as wrong-password attempts plus a
Retry-After: <seconds> header — legitimate users see a useful
retry hint while attackers can't enumerate which emails are
locked.
Lockout keyed on (lowercased email, RemoteAddr without port). The legitimate user on a different network keeps working even if an attacker locks themselves out somewhere else, defeating the email-only DOS vector.
Policy is env-tunable with OWASP ASVS v4 defaults:
| Variable | Default | Purpose |
|---|---|---|
STAPLE_LOGIN_LOCKOUT_THRESHOLD |
5 | Failed attempts to trigger lockout |
STAPLE_LOGIN_LOCKOUT_WINDOW |
15 (min) | Sliding window for counting failures |
STAPLE_LOGIN_LOCKOUT_DURATION |
30 (min) | How long a lockout lasts |
"Reset on success" semantics: failures count only since the most recent successful login in the window. A correct password genuinely zeroes the counter — a fat-fingered user who finally gets it right isn't penalised for earlier mistakes.
Recovery: new staple-cli unlock-user <email> clears the lockout
state for an admin who locks themselves out. No password change,
no session change — just unblocks the gate.
SEC-018 — Session invalidation on identity changes¶
Before this release, sessions outlived password changes and role demotions for the full 7-day cookie MaxAge. A compromised session kept working after the user reset their password.
New query.DeleteUserSessionsExcept(userID, keepRawToken)
helper. Empty keepRawToken invalidates every session
(admin-initiated paths); a non-empty token preserves the
matching session row and kills everything else (self-initiated
paths, so the user stays signed in on the device they used).
Wired into four code paths:
POST /api/me/change-password— preserves the current cookie, kills every other session for the user. Standard "sign me out everywhere else" UX.PATCH /api/me— same, but only when the email changes (display-name edits skip the invalidation).PATCH /api/users/{userId}(admin update) — kills ALL of the target user's sessions when email,instance_role, oris_activechange. The admin isn't using one of the target's sessions, so there's nothing to preserve. Role demotion and deactivation actually take effect immediately now.POST /api/users/{userId}/reset-password(admin reset) — kills all of the target's sessions; they have to log back in with the new password.
Tests¶
internal/handler/auth_lockout_test.go ships 7 cases:
TestLoginLockout_HitsThresholdAndSetsRetryAfter— core contract: 5 wrong attempts → 6th locks with Retry-After in the expected range. Even the CORRECT password is refused while locked.TestLoginLockout_DifferentIPNotLocked— IP-scope correctness.TestLoginLockout_SuccessfulLoginResetsCounter— success semantics: post-success failures are counted independently.TestLockoutPolicyFromEnv_OverridesDefaults— env tuning.TestResetLoginAttemptsForEmail— CLI recovery path.TestDeleteUserSessionsExcept_PreservesCurrent— surgical keep-current-kill-rest behaviour.TestDeleteUserSessionsExcept_EmptyTokenKillsAll— admin full-purge behaviour.
Out of MVP (follow-ups)¶
- Email notification to the user when their account is locked.
- Admin UI viewer for "who is currently locked?".
- Stricter / progressive lockout tiers (3-tier escalation).
- Per-account threshold customisation (different policy for admin accounts than regular users).
v2.27.0 — 2026-05-22¶
Plugin sandbox milestone (BACKLOG #7). Closes SEC-011, SEC-012, SEC-020, and the host_services exposure audit note in one coherent workstream. Plugins were previously bare host subprocesses with full filesystem access, no binary integrity check, no resource caps, and no governance gate — a single compromised plugin owned the server. After this release every plugin must be explicitly Allowed by an admin, runs inside a hardened Docker container, and uses a per-plugin capability allowlist for the few host-side calls it can make.
Approval workflow (closes SEC-012)¶
Three orthogonal axes replace the old "enabled bool":
| Axis | Values | Who controls |
|---|---|---|
approval_state |
pending / allowed / banned | Admin (one-time governance call) |
enabled |
true / false | Admin (operational toggle, only meaningful when allowed) |
state |
installed / running / stopped / ... | The runtime itself |
Migration 075 adds approval_state + approved_binary_hash +
approved_by_user_id + approved_at + banned_by_user_id +
banned_at + ban_reason columns. Migration 076 adds a
dedicated plugin_approval_events table (parallels SEC-017's
secret_audit_events). All existing plugins move to pending on
upgrade — admins must consciously re-approve each one.
Three new endpoints:
- POST /api/plugins/{id}/allow — admin-only, audited. Re-
hashes the binary, snapshots SHA-256 into
approved_binary_hash. Optionalreasonfield. - POST /api/plugins/{id}/ban — admin-only, audited. Refuses
start until rescinded. Optional
reasonstored on the row. - POST /api/plugins/{id}/rescind-ban — admin-only, audited. banned → pending. Re-approval still requires a separate Allow click.
- GET /api/companies/{cid}/plugins/audit — admin-only audit log of every transition.
Permission gate is in the handler (not via RequireRole
middleware) so denied attempts still write an audit row with
action='denied' before responding 403 — same pattern SEC-017's
reveal endpoint uses. Forensics over short-circuit.
Binary integrity at start (closes SEC-012)¶
PluginHost.StartPlugin now consults the plugin's approval state
and re-hashes the binary file before launching:
pending→ refuse to start (clear error).banned→ refuse to start.allowed+ hash matches → start.allowed+ hash differs → auto-revert to pending, write aplugin_approval_eventsrow withaction='auto_reverted'anduser_id='__system__', refuse to start. Operator sees in the audit log that the binary was swapped on disk.
Docker sandbox (closes SEC-011 + SEC-020)¶
Each plugin subprocess now runs inside a Docker container with the same hardening profile the heartbeat adapter uses:
docker run -i --rm
--name staple-plugin-<uuid>
--read-only --tmpfs /tmp:rw,nosuid,size=64m
--network=none
--security-opt no-new-privileges
--security-opt <each from STAPLE_PLUGIN_SANDBOX_SECURITY_OPTS>
--memory <STAPLE_PLUGIN_MEMORY_LIMIT default 256m>
--cpus <STAPLE_PLUGIN_CPU_LIMIT default 0.5>
--pids-limit <STAPLE_PLUGIN_PIDS_LIMIT default 100>
-v <host binary>:/plugin/binary:ro
-v <workspace>:/workspace:rw
-e STAPLE_PLUGIN_ID=<uuid> STAPLE_COMPANY_ID=<uuid>
<STAPLE_PLUGIN_SANDBOX_IMAGE default "alpine:3.20">
/plugin/binary
Binary is mounted read-only so a malicious plugin cannot rewrite
itself mid-run to evade the SEC-012 integrity check. The
container has no network — the host's http_fetch capability is
the only outbound path, and it's gated separately (see below).
Operator opt-out: STAPLE_PLUGIN_SANDBOX=disabled. Logged as a
security warning at startup so the posture change is visible.
StopPlugin issues an explicit docker stop -t 5 for the named
container in addition to cancelling the parent context, so a
hung docker run process still releases its container.
Host capability allowlist (closes the audit note)¶
Migration 077 adds a host_capabilities JSONB column on
plugins, default ["http_fetch","log"] (preserves current
behaviour on upgrade). PluginHostServices.HandleRequest now
checks the plugin's allowlist before dispatching, rejecting
unauthorized calls with a structured error.
Today the full host_services surface is just two capabilities:
http_fetch— outbound HTTP via the host. SSRF-protected (blocks RFC 1918, loopback, link-local, cloud metadata endpoints).log— append a structured log line via slog.
Admins can tighten per plugin (set host_capabilities to []
for a pure observer plugin that needs no host-side calls).
Operator UI¶
/companies/{cid}/plugins— list page gains an "Approval" column with badges (allowed / banned / pending), a colour- tinted "N plugins need approval" banner at the top when any are pending, and per-row Start buttons that only render when the plugin is allowed. Pending and banned plugins surface a "Review →" link to the detail page./plugins/{id}— prominent approval panel at the top of the detail page, colour-coded (amber pending / green allowed / red banned). Shows who approved/banned + when + binary hash. Action buttons appropriate to the current state: Allow + Ban when pending, Ban only when allowed, Rescind ban when banned. All buttons prompt for an optional reason via window.prompt (captured in the audit row)./companies/{cid}/plugins/audit— admin-only audit log viewer (parallels the SEC-017 secret audit page). Lists every transition most-recent-first with badges for the five action types (allowed / banned / rescinded / auto_reverted / denied). Drill-in link to filter by a single plugin.
New env vars¶
Operators get a complete set of knobs:
| Variable | Default | Purpose |
|---|---|---|
STAPLE_PLUGIN_SANDBOX |
(enabled) | Set to disabled to opt out |
STAPLE_PLUGIN_SANDBOX_IMAGE |
alpine:3.20 |
Base image |
STAPLE_PLUGIN_SANDBOX_SECURITY_OPTS |
(empty) | Comma-separated --security-opt values (seccomp / AppArmor) |
STAPLE_PLUGIN_WORKSPACE_ROOT |
/var/lib/staple/plugins |
Per-plugin workspace base |
STAPLE_PLUGIN_MEMORY_LIMIT |
256m |
Docker --memory |
STAPLE_PLUGIN_CPU_LIMIT |
0.5 |
Docker --cpus |
STAPLE_PLUGIN_PIDS_LIMIT |
100 |
Docker --pids-limit |
Tests¶
API + audit: TestAllowPlugin_AdminSucceeds,
TestAllowPlugin_NonAdminDeniedAudited,
TestBanPlugin_ThenRescind,
TestRescindBan_OnlyValidWhenBanned,
TestListPluginApprovalEvents_ScopesByCompany,
TestPluginApproval_TenantGuard.
UI: TestUIPluginList_PendingBannerVisible,
TestUIPluginAllow_Flow,
TestUIPluginAuditLog_Renders.
Sandbox: TestSandboxConfigFromEnv_Defaults,
TestSandboxConfigFromEnv_DisabledExplicit,
TestSandboxConfigFromEnv_SecurityOptsParsing,
TestBuildSandboxedCommand_DisabledFallback,
TestBuildSandboxedCommand_ProducesDockerInvocation (every
hardening flag verified by substring).
Capability allowlist: TestPluginCapabilityAllowed_NilDBFailsOpen,
TestPluginCapabilityListShape.
Out of MVP (follow-ups, not blockers)¶
- Per-user approval grants beyond admin (would need a new company_plugin_approvers table).
- UI for editing
host_capabilities(today the operator edits via API or SQL; UI surface is a small follow-up). - Plugin manifest declaring required capabilities so the admin Allow flow shows exactly what's being granted.
- Network policies more granular than "all or nothing" (a per-plugin egress allowlist would extend the sandbox).
v2.26.0 — 2026-05-22¶
Secret-read permission gate + audit log (BACKLOG #11 / SEC-017).
Until this release, GET /api/secrets/{id} returned the
decrypted plaintext value to any authenticated company
member, and nothing recorded who read which secret. Encryption
at rest (SEC-010, v2.22.0) did not bound the blast radius at all
— any compromised account in a company yielded every secret
value, and there was no audit trail to inform incident review.
This release closes that gap.
API split¶
-
GET /api/secrets/{id}now returns metadata only —id,name,company_id,provider,description,external_ref,created_at,updated_at,version_number. Thevaluefield is gone. Existing callers (UI list, picker, etc.) keep working — they just stop receiving a field they shouldn't have been getting. -
POST /api/secrets/{id}/revealis the new audited entrypoint for plaintext. Admin-only (instance admin OR company admin), optionalreasonfield in the request body, returns{ id, name, value, version_number }on success. -
GET /api/companies/{companyId}/secrets/auditis the admin-only read surface for the audit log. Optional?secret_id=narrows to a single secret;?limit=controls page size (default 200, max 1000).
Permission gate¶
Both reveal paths (JSON API + HTMX UI) check permission in the
handler, not via middleware. The reason is forensic: when the
gate rejects a request, the handler still writes a denied
audit row before responding 403. Short-circuiting the request
through middleware would lose that signal — admins want to see
brute-force probing in the log.
Tenant guard: cross-company reveal attempts return 404 (the same code as "not found" to avoid existence-leak across tenants) and write no audit row, since the user never had standing.
Agent actors are rejected unconditionally with 401. SEC-017 is an operator-facing surface; the agent runtime has no secret-resolution code path today (and adding one would itself be a separate design conversation).
Audit table¶
Migration 074 adds secret_audit_events:
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | gen_random_uuid() |
| company_id | UUID NOT NULL FK CASCADE | FK to companies(id) |
| secret_id | UUID NOT NULL | No FK — audit history outlives secret deletion |
| user_id | TEXT NOT NULL | Mirrors company_secrets.created_by_user_id |
| action | TEXT NOT NULL CHECK | One of 'revealed' or 'denied' |
| reason | TEXT NULL | Operator-supplied free-text |
| request_id | TEXT NULL | Correlates with HTTP access logs |
| ip | TEXT NULL | Caller IP for forensics |
| user_agent | TEXT NULL | Caller UA for forensics |
| created_at | TIMESTAMPTZ NOT NULL | Defaults to now() |
Indexes: (company_id, created_at DESC) for the company-wide
audit viewer; (secret_id, created_at DESC) for the per-secret
narrowing tab.
Audit-first-then-respond is the contract on success: the audit row is written before the plaintext is returned. If the audit write fails the reveal aborts with a 500. The whole point of the fix is "you cannot read a secret without leaving a trace" — a write failure has to be a hard refusal.
Admin UI¶
- Reveal button on each row of the secrets list. Clicking it prompts for an optional reason, POSTs to the audited UI endpoint, and on success swaps in an inline fragment with the plaintext value + version number + a Hide button that clears the DOM without another round-trip (so a second click doesn't mint another audit row).
- Audit log page at
/companies/{cid}/secrets/audit, linked from the secrets list header. Shows every reveal attempt (bothrevealedanddeniedrows) most-recent-first, with when / who / which secret / reason / IP. Rows for deleted secrets render as "(deleted secret)" since the audit row outlives the FK target. Each row carries a "just this secret →" drill-in link.
Out of MVP¶
Captured as follow-ups, not blockers:
- Per-user reveal grants beyond admin (would need a new
company_secret_readerstable + grant/revoke UI). - MFA step-up before reveal.
- Required-reason enforcement (env-var-flippable in a follow-up).
- Agent-path secret resolution (no
staple_secret://URI scheme is implemented in the codebase today). - Audit log retention/purge policy.
Tests¶
API: TestGetSecretReturnsMetadataOnly (regression: GET never
returns value), TestRevealSecret_AdminSucceeds,
TestRevealSecret_NonAdminDeniedAndAudited,
TestRevealSecret_InstanceAdminBypassesCompanyRole,
TestRevealSecret_RejectsAgentActor,
TestRevealSecret_NotFoundDoesNotLeakExistence,
TestRevealSecret_TenantGuardBlocksCrossCompany,
TestListSecretAuditEvents_ScopesByCompany.
UI: TestUISecretReveal_AdminFlow,
TestUISecretReveal_NonAdminDenied,
TestUISecretAuditLog_Renders.
v2.25.0 — 2026-05-22¶
A2A Admin UI milestone (BACKLOG #27).
A2A Phase 1 (inbound) shipped in v2.22.0, Phase 2 (outbound + the
six-signal matcher + cascade routing) shipped in v2.23.0, and
neither had a corresponding admin UI. Operators could register
peers and observe delegated work only through curl against the
JSON API or raw SQL — the only A2A control surface in the UI was
the per-agent externally_available toggle on the agent show
page.
This release adds the missing admin surfaces. Every A2A capability the engine already supports is now exposed in the web UI.
A2A Peers admin page (UI.A)¶
New route at GET /companies/{companyId}/a2a/peers with the full
CRUD lifecycle:
- List view with peer label, base URL, status, last-refresh timestamp, and the count of discovered remote agents.
- Create modal captures label / URL / api_key plus an optional advanced section for the rate-limit / backoff tuning knobs the JSON API exposes.
- Show page at
/a2a/peers/{peerId}for inline edit, manual Agent Card refresh, delete, and a sub-list of discovered remote agents with collapsible per-agent Agent Card JSON. - Encrypt-on-write contract for the peer
api_key: the handler refuses to persist a plaintext credential when noSTAPLE_ENCRYPTION_KEYis configured, mirroring SEC-010 posture across providers + secrets + peers.
needs_human_attention inbox surface (UI.B)¶
The routing worker already sets issues.needs_human_attention =
TRUE when the cascade exhausts every candidate without a
runner; until this release nothing surfaced that flag.
- Filter chip on the Issues list page (
/companies/{cid}/ issues?needs_attention=true) — clicking it narrows the list to cascade-exhausted issues. - Per-row badge so an operator scanning the default list can see at a glance which issues are stuck on a human decision.
- Sidebar counter — amber ⚠ pill on the Issues nav entry showing how many cascade-exhausted issues exist for the active company. Renders only when > 0.
Operators get value from UI.B even before they register any A2A peers, because the underlying flag is also set by the local matcher when no local agent can take an issue either.
Outbound Tasks page (UI.C)¶
New route at GET /companies/{companyId}/a2a/outbound lists
every row the routing worker has ever written to
a2a_outbound_tasks. Four tabs:
- All — every row regardless of status.
- Active —
pending,in_progress,input_required(work currently happening on a remote peer). - Done —
completed(the remote returned a deliverable that was rolled back onto the originating issue). - Failed —
failed,canceled(the remote rejected or the poller gave up after retries).
Peer labels are resolved in a single
ListA2APeersByCompany call and joined in-memory, so the table
renders in O(rows) regardless of how many delegations are
visible. Rows whose peer has been deleted fall back to a
"(peer deleted)" label so cascade evidence remains readable.
Per-issue cascade trail (UI.D)¶
The issue detail page gains a self-suppressing "A2A Cascade
Trail" panel. For issues that have never been delegated to a
remote peer, the panel renders nothing — the page is visually
unchanged. For issues that have been delegated, each attempt
gets a card showing the live status badge, the peer label
(linked to the peer detail page), the remote_agent_id, the
remote_task_id once the peer assigns one, the most recent
error if any, and created + last-update timestamps. A
"All outbound tasks →" link in the section header drills out to
the UI.C company-wide table.
Sidebar nav entry + counter (UI.E)¶
A persistent A2A entry now sits in the Company section of the sidebar, next to Providers (both are external-integration admin surfaces). The link lands on the Outbound Tasks page (operational view); the Peers admin page is one click away via the "Manage peers" button on the outbound page header.
A counter badge surfaces pending + in_progress + input_required outbound tasks — "active delegation work happening on a remote peer." Renders only when > 0 so the sidebar stays clean in the common case.
⌘K command-palette entries for both A2A pages and a help-popover mapping for the new sidebar group round out discoverability.
Schema and query additions¶
query.ListA2ARemoteAgentsByPeer(UI.A)query.CountIssuesNeedsAttention(UI.B)ListIssuesParams.NeedsHumanAttentionfilter (UI.B)query.A2AOutboundStatusGroup+statusInGroupClause+query.ListA2AOutboundTasksByCompany(UI.C)query.ListA2AOutboundTasksByIssue(UI.D)query.CountA2AActiveOutboundTasksByCompany(UI.E)
No database migrations — every UI in this release reads from columns and tables that v2.22.0 / v2.23.0 already populate.
Tests¶
internal/handler/ui_a2a_peers_test.go,
internal/handler/ui_issues_needs_attention_test.go,
internal/handler/ui_a2a_outbound_test.go,
internal/handler/ui_a2a_cascade_test.go,
internal/handler/ui_a2a_sidebar_test.go.
v2.24.0 — 2026-05-22¶
Security defense-in-depth sweep + the operator-reported follow-up items.
This release closes 13 audit findings (SEC-014, SEC-015, SEC-021, SEC-022, SEC-023, SEC-024, SEC-025, SEC-026, SEC-028, SEC-029, SEC-030, SEC-031/032/033 as accepted decisions, and the SEC-034 gap that surfaced during BACKLOG #24 work) and ships five operator-reported items grouped as Tier 1.5 of the backlog (BACKLOG #22 through #26). Plus one follow-up: the Bedrock chat provider that completes BACKLOG #23.
Security hardening¶
Defense-in-depth knobs and guards, each documented under its
SEC-### entry in .planning/codebase/SECURITY_CONCERNS.md:
-
SEC-014 — Trusted-proxy allowlist for
X-Forwarded-For. NewSTAPLE_TRUSTED_PROXIESenv var holds a comma-separated CIDR allowlist. Rate limiter only honors X-F-F when the direct caller IP falls inside an allowlisted CIDR; empty list = X-F-F ignored entirely (secure default). Operators behind a proxy must set this env var to keep IP-based rate limiting working — see the migration notes below. -
SEC-015 — Server-level guardrails on Claude adapter dangerous flags. Two env vars:
STAPLE_FORBID_DANGEROUS_CLAUDE_PERMISSIONS(truthy = drop--dangerously-skip-permissionsfrom every spawn) andSTAPLE_CLAUDE_BINARY_ALLOWLIST(basename or absolute-path allowlist for the Claude binary). Both default permissive. -
SEC-021 / SEC-026 — Log redactor extensions. The structured log redactor now strips
staple_session=<token>cookie values andpostgres://user:pass@hostcredentials from any log line. -
SEC-022 — Markdown raw-HTML regression test. Goldmark already strips raw HTML and
javascript:URLs; explicit test rows lock the safety contract so a future config flip can't silently introduce stored XSS. -
SEC-023 — Content-Disposition header-injection hardening.
validateFilenamenow rejects",\r,\n, and control characters. NewcontentDispositionAttachmenthelper produces an RFC 6266 header value that's safe to emit even if a caller skips validation; all six emit sites migrated. Adds RFC 5987 extended form for non-ASCII filenames. -
SEC-024 / SEC-025 — HomeDir + trash-restore path constraints. New
STAPLE_HOMES_ROOTenv var (comma-separated absolute-path allowlist) gates both company HomeDir overrides and trash restore destinations. The trash-restore guard defends against an attacker rewritingtrash-info.jsonto redirect to/etc/cron.d/; the HomeDir guard defends against an admin pointing a company's home at/etc/. -
SEC-028 / SEC-029 — Docker adapter hardening. Containers now run with
--read-onlyplus a 64 MiB nosuid/tmptmpfs by default. New per-agentwritable: trueopt-out for agents that need to write outside/tmp. Operator-suppliedSTAPLE_DOCKER_SECURITY_OPTSenv var lets you point at a custom seccomp/AppArmor profile (e.g.seccomp=/etc/staple/seccomp.json,no-new-privileges:true). -
SEC-030 — SSE periodic membership re-verification. New
Hub.SetMembershipCheckerhook fires on a 60s ticker; a user whose company membership is revoked stops receiving live events within one interval, rather than "until they hang up." Production wires it toquery.GetUserCompanyRoleand treatspgx.ErrNoRowsas "no longer a member." Transient DB errors keep the subscriber connected so a hiccup doesn't disconnect everyone. -
SEC-031 / SEC-032 / SEC-033 — Info-level explicit decisions. All three accepted with documented rationale + revisit trigger in
SECURITY_CONCERNS.md. No code changes; the audit's "is this an intentional design choice?" questions now have explicit answers. -
SEC-034 — Encrypt
company_providers.api_key+custom_headersat rest. SEC-010'senc:v1:envelope now extends to the providers registry. Newstaple-cli encrypt-providersbulk migrates legacy plaintext rows; a ⚠ "Unencrypted" badge surfaces rows still pending migration in the providers UI. Same refuse-on-missing-key contract as secrets — provider writes now return 503 whenSTAPLE_ENCRYPTION_KEYis unset.
Operator-reported items (Tier 1.5)¶
-
BACKLOG #22 — Delete-company picked the wrong company in a multi-company instance. Root cause was the
/admin/settingshandler unconditionally loadingcompanies[0](alphabetically first) and feeding its ID into four destructive forms on that page (edit / delete / home_dir / agent-defaults). Every operator action from/admin/settingshad been silently targeting the wrong company; delete just made it visible. Fix:/admin/settingsis now instance-only; new/companies/{companyId}/settingsroute carries the per-company forms with scope in the URL itself. Sidebar nav gained an "Instance Settings" entry alongside the per-company "Settings". -
BACKLOG #23 — Chat marked LLM-backed agents as "no LLM". Chat's classifier ignored
agent.ProviderID(the FK to the post-v2.21 provider registry). Agents bound through the Providers UI were invisible to the chat layer even though they had a perfectly valid provider attached. FixedresolveChatProviderto consult the registry first (Anthropic / OpenAI-compatible / Perplexity / Bedrock); relabelled the dropdown suffix from"— no LLM"to"— no chat"since the agents in that bucket ARE LLM-backed for heartbeat runs. -
BACKLOG #23 follow-up — Bedrock chat provider. New
engine.NewBedrockProviderso agents bound to a Bedrock registry entry can drive synchronous chat (anthropic.claude-* models in v1). Migration 071 relaxedcompany_providers.adapter_typeCHECK to permitbedrockrows — until this release, Bedrock could only be configured via inlineadapter_config. -
BACKLOG #24 — Custom HTTP headers on outbound LLM requests. Per-provider
custom_headersJSONB oncompany_providers, edited via a JSON textarea on the Providers UI. Headers inject via a defensiveRoundTripperfor Anthropic / OpenAI-compatible / Perplexity, and via AWS SDK Build-stage middleware for Bedrock (so SigV4 signs them). Hard guards: deny protocol-essential names (Authorization / x-api-key / anthropic-version / Content-Type / etc.), reject CR/LF/NUL injection, 16 KiB payload cap. Static values only in v1 — no templating. -
BACKLOG #25 — Promote an Issue to a Routine. New "Promote to Routine" button on every issue detail page opens a modal with title/description/agent prefilled from the issue and a five-option schedule preset (Daily 9am UTC / Weekly Mon 9am UTC / Hourly / Custom cron / Skip — add later). Companion
POST /api/issues/{id}/promote-routineJSON endpoint and/promote-routineagent action verb. The audit comment posted on the source issue carries a link to the new routine. -
BACKLOG #26 — Edit an Issue to associate it with a Project. The properties panel on the issue detail page now renders an inline project dropdown, mirroring the assignee dropdown. The schema and JSON API supported this all along; only the inline- edit UI was incomplete.
Smaller items¶
- BACKLOG #18 — CI Node 24 opt-in. Set
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=trueat the workflow level in all three workflows so Staple stays off the GitHub Actions deprecation warning surface. TestA2AJSONRPCRejectsUnknownMethodwas failing onmainafter A2A Phase 1.C shippedtasks/get; the test was asserting on a method that was no longer "unknown". Replaced with a deliberately-implausible method name that genuinely tests the MethodNotFound dispatch.
Migration notes for operators¶
Three behavior changes worth flagging:
-
X-Forwarded-Foris now ignored by default. Deployments running Staple behind a reverse proxy must setSTAPLE_TRUSTED_PROXIESto the proxy's CIDR (single-IP shorthand accepted, e.g.STAPLE_TRUSTED_PROXIES=10.0.0.5) or IP-based rate limits will start keying off the proxy's IP instead of the real client's. Leaving it unset is the secure default; you'll just want to opt back in if you rely on per-client rate limiting. -
Docker-adapter agents that write outside
/tmpwill fail. New default is--read-onlyplus a 64 MiB writable/tmptmpfs. Agents that need a writable rootfs (rare) must set"writable": truein their adapter_config. The/tmptmpfs covers the scratch-space need of most agent images. -
Provider writes (create/update via API or UI) refuse without
STAPLE_ENCRYPTION_KEY. Same posture SEC-010 applied to secrets. Reading existing plaintext provider rows continues to work viacrypto.Decrypt's legacy-passthrough; only new writes are gated. Runstaple-cli encrypt-providersto bulk-migrate plaintext rows toenc:v1:-prefixed format; the providers UI shows a ⚠ badge on each row that's still pending.
v2.23.0 — 2026-05-22¶
A2A Phase 2 — Outbound: Staple can now delegate work to remote A2A peers.
Companion to v2.22.0's inbound Phase 1. Where Phase 1 made Staple a receiver of external A2A tasks, Phase 2 turns Staple into a client: operators register remote peers, the engine matches issues against a candidate pool that spans local agents AND the cards published by every registered peer, and the cascade dispatcher routes work to the best match. Single-instance Staples gain nothing immediately; fleets gain cross-instance / cross-company work routing.
Five slices, each its own commit on main:
P2.A — Peer registration substrate + Agent Card sync.
Migration 070 adds four tables (a2a_peers, a2a_remote_agents,
a2a_outbound_tasks, a2a_rate_limit_counters) and two issue
columns (needs_routing transient flag, needs_human_attention
sticky flag). New internal/a2a/sync.go fetches each registered
peer's .well-known/agent-card.json directory; new worker
(internal/worker/a2a_refresh.go) runs the sync on the per-peer
schedule (default 15min). Operator API for peer CRUD lives at
/api/companies/{cid}/a2a/peers + /api/a2a/peers/{peerId} with
per-peer encrypted API keys via the SEC-010 crypto path.
P2.B — Six-signal capability matcher.
Pure scoring (internal/a2a/matcher.go) over tag match (default
weight 0.30), description relevance (0.25), past success rate
(0.20), availability (0.10), latency (0.10), and cost (0.05).
Graceful degradation per signal — a freshly-registered peer with
no history still gets a non-zero composite score driven by tag
match alone. Local preference bias (default 10%) tips ties toward
local candidates.
P2.C — Outbound JSON-RPC client + cascade + Postgres-backed rate
limiter.
internal/a2a/client.go ships the v1.0 JSON-RPC client (message/
send, tasks/get, tasks/cancel) with per-call timeout enforcement,
decrypted X-API-Key auth, and three-way failure classification
(transport / JSON-RPC / auth-tier). internal/a2a/cascade.go
walks ranked candidates with retry-once-then-fallback semantics.
internal/a2a/ratelimit.go is the per-peer sliding-window
limiter (operator picked Postgres over in-process at design time
because Staple already deploys on a fleet — in-memory would drift
across replicas).
P2.D — Engine integration.
New /needs-routing <summary> action verb (with optional fenced
body) that agents emit when they want to delegate. New routing
worker (internal/worker/a2a_routing.go) scans flagged issues
every 10s, assembles candidates via the pure
a2a.AssembleCandidates, runs the matcher, and dispatches: local
winners assign + clear the flag; remote winners go through the
cascade + record an a2a_outbound_tasks row + post a
"[A2A] Delegated to remote agent" comment. Cascade exhaustion
flips needs_human_attention=TRUE and posts the full per-
candidate attempt log so operators can see why every candidate
failed.
P2.E — Outbound task status polling.
internal/worker/a2a_outbound_poller.go polls each active
outbound task every 30s, posts state-change comments on the
originating issue, syncs the issue status on terminal A2A states,
and rolls up remote artifacts into a "[A2A] Remote agent
deliverable:" comment so the result lands inline. Handles the
A2A "canceled" ↔ Staple "cancelled" spelling difference.
Routing flow in one paragraph: agent emits /needs-routing →
engine flips issues.needs_routing=TRUE → routing worker scans
on its 10s tick → matcher scores local + remote candidates with
graceful degradation across missing signals → winner is dispatched
(local: assign agent; remote: cascade.Dispatch with the
ranked-fallback list, recording an a2a_outbound_tasks row with
the remote task id) → poller worker syncs state back to the
originating issue on its 30s tick, completing the round-trip.
Cascade exhaustion at any point sets needs_human_attention=TRUE
so operators have a queryable surface for "what's stuck on me".
No new external dependencies. Full test suite green serially with
-race. Tests run when a local Postgres is reachable; skip cleanly
otherwise.
UI work (peer registration page, inbox filters for the two new flags, routing-decision audit visualisation) is deferred to a v2.23.x follow-up; the API + worker substrate ships in v2.23.0.
v2.22.0 — 2026-05-21¶
A2A Phase 1 — Inbound: external agents can now send tasks to Staple.
This release ships the inbound side of Google's A2A (Agent-to-Agent) protocol v1.0 — external A2A clients can discover Staple agents, send them tasks, and poll for results. Phase 2 (Staple as outbound A2A client) is a separate future release.
Four slices, all under the new internal/a2a package:
P1.A — Agent Card discovery substrate
- Migration 069 adds three default-safe columns:
agents.externally_available BOOLEAN DEFAULT FALSE— admin opt-in flag controlling whether an agent appears in the company's A2A surface. Existing agents stay invisible until flipped per-agent via the new UI toggle on the agent edit page.issues.awaiting_input BOOLEAN DEFAULT FALSE— set by the new/await-inputaction verb to pause work pending clarification. Cleared automatically by the next non-agent comment.issues.a2a_metadata JSONB— nullable sidecar carrying the original A2A Message + Task hint when an Issue is created via inbound message/send.- New public endpoints (no auth — discovery has no secret to protect; auth lives on the per-agent /a2a JSON-RPC endpoint):
GET /companies/{cid}/.well-known/agent-card.json— per-company directory of externally-available agents with full Agent Cards inline.GET /companies/{cid}/agents/{aid}/agent-card.json— per-agent Agent Card; 404s for internal-only / wrong-company / unknown agents with an identical response shape so membership doesn't leak.- New
/await-input "optional clarification"action verb. The engine posts the question as a comment BEFORE flipping the flag so the comment doesn't itself clear it;query.ClearAwaitingInputis wired intoCreateComment(system / non-agent authors) andCreateCommentWithUser(all user comments), idempotent.
P1.B — message/send JSON-RPC handler
- New
POST /companies/{cid}/agents/{aid}/a2aendpoint under the Authenticated API group (BearerAuth + RateLimit + MaxBodySize). Auth posture: 401 from BearerAuth for invalid keys; 404 from the handler for mismatched-actor / wrong-company / internal-only agents (same shape as the P1.A card endpoints); JSON-RPC errors on HTTP 200 with the error code in the body. - The
message/sendmethod translates the inbound Message into an Issue via the purea2a.BuildIssueFromMessagefunction (text Parts concatenated into the body; file/data Parts preserved ina2a_metadatafor replay). Returns a Task with state=working whoseidis the new Issue UUID (locked decision Q2 from the P1 plan — operators can jump from an A2A trace into the Staple UI).
P1.C — tasks/get and tasks/cancel polling
tasks/get: returns the current Task state mapped from the Issue viaa2a.IssueToTaskState(in_progress→working, in_progress+ awaiting_input→input-required, done→completed, etc.).tasks/cancel: transitions in_progress → cancelled; idempotent on already-terminal tasks (re-cancelling a done task returns the preserved state, not an error).- Both methods share an ownership guard (
loadOwnedIssue) that returns the same opaque "task not found" error for unknown IDs AND wrong-agent IDs so probes can't enumerate task IDs that belong to other agents.
P1.D — Messages + Artifacts materialisation
tasks/getnow returns the full A2A Task: conversation Messages (the original inbound message anchors the list, then issue comments in chronological order, with system comments skipped) and, for terminal states ("done" / "failed"), an Artifact with the most recent non-empty agent comment as a single text Part.- Comments and metadata loads are best-effort: a partial DB outage surfaces as a Task with id + state only, logged as a WARN, rather than failing the whole request.
UI additions:
- Agent edit page (Configuration tab) has a new "A2A access" row
showing the current state with a single-purpose Enable/Disable
button posting to
POST /agents/{id}/ui/set-externally-available.
Testing:
- Pure-function tests in
internal/a2a/: Agent Card composition (every field shape locked), skill description extraction from the three CompanySkill.Definition formats, BuildIssueFromMessage across text-only / multi-part / non-text-part / empty-parts / long-title-truncation cases, A2A metadata round-trip, the full state mapper, BuildTaskFromIssue Messages ordering + Artifact selection rules. - Handler-level tests in
internal/handler/: Card endpoint 404 vs 200 paths, directory exclude-internal-only, JSON-RPC version / method validation, message/send happy path with DB persistence - metadata storage, tasks/get ownership guard, tasks/cancel idempotency. Run when a local DB is reachable; skip cleanly otherwise.
No new dependencies. Full test suite remains green serially with
-race.
v2.21.1 — 2026-05-21¶
Packaging fix: include staple-cli in the release tarballs.
v2.21.0 shipped without the staple-cli binary because the new
cmd/staple-cli entrypoint wasn't in .goreleaser.yaml's builds
matrix or the archive ids list. The release workflow succeeded with
just the three pre-existing binaries (staple-server, staple-worker,
staple-seed), so the omission was a silent miss — but operators
who upgraded to v2.21.0 and tried to run the staple-cli encrypt-secrets
migration mentioned in the v2.21.0 notes would have hit
command not found.
This patch adds:
- A
staple-clibuild entry to.goreleaser.yamlwith the same target matrix (linux + darwin × amd64 + arm64) andCGO_ENABLED=0/-trimpath/-s -wflags as the other three. Build-metadata ldflags (-X main.version=…) are intentionally omitted becausecmd/staple-cli/main.godoes not declare those symbols; adding them to the linker invocation would fail the build. staple-cliadded toarchives.idsso it's bundled into every release tarball.
No source changes — cmd/staple-cli itself shipped correctly in
v2.21.0 (and is on the v2.21.0 commit). Only the packaging was wrong.
v2.21.0 — 2026-05-21¶
SEC-010: STAPLE_ENCRYPTION_KEY is now required for encrypted-at-rest writes.
The previous silent plaintext-fallback path is closed. With this change:
- Creating or rotating a secret returns 503 if
STAPLE_ENCRYPTION_KEYis unset. Existing reads continue to work; rows whose latest stored value lacks the newenc:v1:prefix surface in the secrets list with a ⚠ Unencrypted badge. - OAuth bearer-token persistence and HMAC-signed trigger creation are also gated — OAuth login fails cleanly when no key is configured rather than silently storing tokens in plaintext.
- New
crypto.NeedsRotationhelper andenc:v1:prefix on freshly encrypted values, forward-compatible with SEC-019's KEK/DEK rotation work. - New
cmd/staple-clibinary with theencrypt-secretssubcommand for bulk migration of legacycompany_secret_versionsrows. Idempotent (WHERE encrypted_value NOT LIKE 'enc:v1:%'), supports--dry-run. - Startup warning on three surfaces: stderr at boot, structured WARN
log with
finding=SEC-010+ remediation hint, and a UI banner on the secrets-create form.
SEC-027: govulncheck now blocks CI on findings.
The existing CI step at .github/workflows/ci.yml was warn-only
(continue-on-error: true); findings now block the build.
govulncheck pinned to v1.1.4 for reproducibility. New make vuln
target lets developers catch findings before pushing.
AWS Bedrock adapter (v1 — Claude only, sync).
New adapter exposes AWS Bedrock as a provider type alongside the existing Anthropic / OpenAI-compatible / Perplexity adapters. Primary motivation: enterprise compliance for instances running in AWS-bound environments.
Day-one scope:
anthropic.claude-*model family only. Other families (Llama, Mistral, Titan, Cohere) surface a clear "not supported in v1" soft error; they land in a follow-up slice once there's a concrete use case.- Sync execution only. Streaming arrives in a follow-up.
- Auth via the AWS SDK's default credential chain (auto-detects IAM
instance profile / IRSA /
AWS_PROFILE/ env vars), with optional explicit access-key override. - Optional
BaseURLfor VPC / FIPS endpoint support.
Operator setup: select bedrock in the provider type dropdown;
paste a JSON blob into the API Key field
({"region":"us-east-1"} minimum, plus optional access_key_id /
secret_access_key / session_token for explicit-key deployments).
See docs/user/operators/providers.md
for the full setup walkthrough.
New dependencies: github.com/aws/aws-sdk-go-v2 (config, credentials,
service/bedrockruntime).
v2.20.0.1 — 2026-05-18¶
Hotfix: Notes UI handlers white-screened plain <form> POSTs.
Smoke-testing v2.20.0 on node4: creating a note redirected to a
blank page. Cause: the six UI handlers (UINoteCreate,
UINoteUpdate, UINoteArchive, UINoteUnarchive,
UINotePromote, UINoteDelete) set HX-Redirect (an HTMX-only
response header) and then called w.WriteHeader(http.StatusSeeOther)
without an actual Location: header. HTMX clients would have
followed HX-Redirect, but the Notes UI uses plain <form
method="post"> (no hx-post), so the browser saw a 201 / 200
with no body and no redirect — hence the white screen.
Fix: every handler now does BOTH:
w.Header().Set("HX-Redirect", target) // HTMX clients http.Redirect(w, r, target, http.StatusSeeOther) // plain browsers
Same pattern as UIChatCreate (which already had this fix). No
behavior change for HTMX callers; non-HTMX form posts now get a
real 303 with a Location: header and follow it cleanly.
No schema migration. No new tests required (the handlers' business logic is unchanged; the response-shape fix is mechanical and covered by manual smoke after redeploy).
v2.20.0 — 2026-05-18¶
Notes — pre-Issue scratchpad.
Captured from the operator while smoke-testing v2.19.0: a place to write down something for later that isn't ready to become an Issue yet. Issues carry lifecycle, assignment, status, and agents; Notes deliberately carry none of that. The framing: "things that need to be serialized somewhere but are not complete enough yet to be Issues."
Surface
───────
- New sidebar entry "Notes" under the Work section, peer to
Issues / Projects / Chats. Also added to the ⌘K command palette.
- /companies/{companyId}/notes — list page with a "New note"
form on top, mixed list of YOUR private notes and
agent-authored (company-wide) notes ordered by recent activity.
- /notes/{noteId} — detail page with edit-in-place form,
label chips, and the Promote / Archive / Delete actions.
- Markdown body rendering via the same {{markdown}} helper
used by chats and issue comments.
Visibility model (locked 2026-05-18)
────────────────────────────────────
- User-authored notes are PRIVATE to the authoring user.
Other operators on the same company can't see them; the query
layer filter (ListNotesForUser) enforces this at read time
by scoping to author_user_id = <actor> OR
author_agent_id IS NOT NULL.
- Agent-authored notes are COMPANY-WIDE. When an agent
surfaces a note via /install-note, every operator with
viewer+ on the company sees it. An agent's "remember to look at
X" is worthless if nobody reads it.
- 404 (not 403) on unauthorized GETs, so existence doesn't leak
to operators outside the visibility scope.
Storage
───────
Migration 068 — company_notes:
- Polymorphic author: author_user_id XOR author_agent_id,
enforced by CHECK constraint (same shape as issue_comments).
- labels TEXT[] NOT NULL DEFAULT '{}' — array, never NULL, so
callers can range without nil-checks.
- archived_at TIMESTAMPTZ — soft-delete marker; the
"Promote to Issue" path sets this in the same logical
operation as creating the issue.
- Partial indexes on (company_id, author_user_id, updated_at DESC)
and (company_id, author_agent_id, updated_at DESC), both
scoped to archived_at IS NULL so the active-list query
stays index-only.
- updated_at auto-maintained via PL/pgSQL trigger.
Promote to Issue
────────────────
"Promote to Issue" creates an Issue with the note's title + body
pre-filled and archives the note in the same logical operation.
Defaults: status=backlog, priority=medium, no assignee. The
reverse direction (Issue → Note) is intentionally not provided
— Issues archive naturally when they're done.
Agent authoring
───────────────
New action verb /install-note <title>\n\``\n
:
- Title on the verb line; body in a required triple-backtick
fenced block (same shape as/install-skill,/install-routine`).
- Lightweight by design: no approval, no capability, no
activity-log row. Notes are intentionally cheap.
- The author is the agent that emitted the verb; resulting note
is visible to every operator on the company.
- A confirmation comment lands on the source issue linking to
the new note.
API
───
- POST /api/companies/{cid}/notes — create (user only via
API; agents use the action verb)
- GET /api/companies/{cid}/notes — list, visibility-scoped
- GET /api/notes/{nid}
- PATCH /api/notes/{nid} — partial update (author only)
- POST /api/notes/{nid}/archive
- POST /api/notes/{nid}/unarchive
- POST /api/notes/{nid}/promote — returns {issue, note}
- DELETE /api/notes/{nid} — hard delete (admin / cleanup)
Out of scope for v2.20.0 ──────────────────────── Search. The trigram index is trivial to add, but operator-private visibility scoping needs care in the search-result joining; list-only ships first. Adds in v2.20.1 once "scroll to find" stops feeling adequate.
v2.19.0.3 — 2026-05-18¶
Hotfix: heartbeat trace pipeline was a silent no-op for every modern adapter path.
Empirical bake check after v2.19.0.2 deploy: zero rows in
evolution_traces despite live agent traffic. Root cause: the
v2.19.0 trace-write call in executeWithAdapter is gated on
len(result.TraceMessages) > 0, but only the legacy fallback
llmAdapter (used when an agent has no provider_id and no
registered adapter_type) populated those fields. Every other
adapter — providerAdapter (the v2.18.x provider-based path used
by anthropic / openai_compatible / perplexity agents),
claudeLocalAdapter, codexLocalAdapter, dockerAdapter,
lambdaAdapter, remoteAdapter — left TraceMessages zero-valued
and the trace write was silently skipped. No log line, no row.
Fix:
- New Engine.buildHeartbeatTrace(ctx, agent, issue, wakeup)
helper in evolution_trace.go builds a representative
(messages, skills) snapshot from the same inputs the prompt
assembly path uses. Sub-process adapters (claude_local,
codex_local, docker, lambda, remote) call it once before their
return so the trace reflects what the engine would have built
if the agent had run in-process. The snapshot is "approximately
representative" — exact for the structured-LLM paths
(llmAdapter / providerAdapter), an approximation for
sub-process paths whose actual prompts are constructed inside
opaque external runners. The settler reasons about
issue/context/skill IDs, all of which are correct in both
cases.
- providerAdapter.Execute (engine_provider.go) now sets
TraceMessages + TraceSkillsInContext from its already
in-scope messages and agentCtx.Skills. Two-line wart.
- The five sub-process adapters call the helper once before
return and stash its results.
- Engine.WriteTrace gains an INFO-level "evolution trace
written" log line on the happy path so the next time someone
asks "is the trace pipeline alive?" the answer is in
journalctl, not a DB query. Logged fields: source_kind,
source_id, provider, model, tokens_in, tokens_out,
skills_in_context count, skills_used count.
Validation:
- Test issue → Triage Officer reply via providerAdapter → row
in evolution_traces with source_kind='heartbeat',
source_id = the agent comment id.
- Existing thumbs feedback row's polymorphic re-settlement
trigger now has a trace to bump (re_settle_after is updated
to ~30s in the future) — settler picks it up after the
heartbeat quiet period (default 3600s on node4).
- No new schema migration. No client-visible changes.
v2.19.0.2 — 2026-05-18¶
UX polish: thumbs pre-render as active on page load.
v2.19.0 added 👍/👎 buttons to chat messages and (intended to) issue comments, but the buttons always rendered in neutral state on page load — the operator's existing thumb wouldn't appear "active" until they re-clicked it. That broke the affordance the buttons promised: "I already told you this was helpful, why are you asking again?"
Fix on the chat side: ListUserFeedbackForChatMessages batches
a single agent_feedback lookup keyed by the visible message
IDs and the current feedback_user_id. chatMessageView gains
UserThumb ("up", "down", or ""); UIChatShow populates it
before rendering. The chat partial template chats/_messages.html
then applies active-up / active-down so the buttons start
in their correct state and survive a full page reload without a
client-side fetch.
Fix on the issue side surfaced a dead-code wart: v2.19.0 had
added the thumbs UI to issues/_comments.html, but the live
issue page actually renders comments through
issues/_timeline.html (a unified comments + activity timeline).
The unused _comments.html is deleted. The thumbs UI now lives
in _timeline.html, scoped to entries where IsAgentAuthored
is true. TimelineEntry gains IsAgentAuthored (populated by
SQL) and UserThumb (populated by enrichTimelineWithUserFeedback,
which mirrors the chat-side batched lookup). All three callers
of ListIssueTimeline (UIIssueShow, UIComments, and the
comment-post handler that returns a refreshed timeline) call
the enrichment helper so partial swaps stay in sync.
The shared static/agent-feedback.js already handled both
container selectors (.chat-msg-feedback and
.issue-comment-feedback), so no JS changes were needed beyond
updating the header comment to reflect the new home for issue
thumbs.
No schema migration, no behavior change for clients that never thumbed anything — they still see neutral buttons. Existing thumbs simply render correctly on first load now.
v2.19.0.1 — 2026-05-18¶
Hotfix: thumbs UI returned 401 because feedback endpoints were in the wrong auth group.
Smoke test of v2.19.0 found that clicking 👍/👎 on a chat message produced
"failed to save" in the UI. Server logs showed POST
/api/chat-messages/{id}/feedback returning 401 Unauthorized.
Cause: the feedback CRUD routes (added in v2.19.0) were registered
inside the "Authenticated API routes" group, which has
BearerAuth middleware ONLY (designed for API-key callers — agents,
external automation). The chat UI sends requests from the browser
with a session cookie; that auth shape requires UIAuth middleware,
which lives in the "User session API routes" group alongside
BearerAuth.
Fix: moved the four feedback routes into the user-session group. Cookie-based UI calls now work; bearer-token callers also still work because that group has both middlewares stacked.
CSRF was a related concern but not the actual block — Go 1.26's
built-in net/http.CrossOriginProtection (used by Staple's
CSRFProtect middleware) allows same-origin POSTs without
additional configuration. The browser's fetch from /chats/{id}
to /api/chat-messages/{id}/feedback is same-origin, so it passes.
v2.19.0 — 2026-05-18¶
Self-Evolution Phase 1 — substrate + observation slice.
First of four slices for the Self-Evolution feature line (full plan in docs/SELF_EVOLUTION_PLAN.md). No evolution loop yet — this slice lays the data foundation that v2.19.1 → v2.19.3 will build on.
What lands¶
-
Migration 067 —
evolution_traces+agent_feedbacktables, with the late-signal re-settle trigger on agent_feedback covering both chat-message and issue-comment targets. -
Trace writer in the engine — every LLM invocation in both heartbeat (
adapter_llm.Execute→executeWithAdapter) and chat (ChatComplete/ChatStream→ handler) writes anevolution_tracesrow. Source ID = the chat_message.id or issue_comment.id that the operator might later thumb. -
Settler worker —
internal/worker/evolution_settler.gobackground worker pulls unsettled traces past their source-kind quiet period (STAPLE_EVOLUTION_SETTLER_CHAT_QUIET_SEC= 900 default;STAPLE_EVOLUTION_SETTLER_HEARTBEAT_QUIET_SEC= 3600 default), runs the per-signal scoring rules, and writes outcome_quality + breakdown JSONB back. Re-settlement on late signals via the agent_feedback trigger + ListUnsettledForSettler'sre_settle_after <= now()branch. -
Scoring rules in new
internal/evolution/package: - Heartbeat: run-status proxy, issue-progression, operator intervention (with strong-language detection), output edit/delete.
- Chat: next-message pattern (continuation/clarification/ silence), agent switch, action verbs (placeholder neutral until v2.19.2 wires action outcomes), explicit thumbs.
- Chat silence: context-conditional decision tree (archived after a question → 0.3; agent's reply closed the loop → 0.8; never returned within ~48h → 0.5; etc.).
-
Aggregation: weighted across signal categories with renormalization when categories are missing. Weights from SELF_EVOLUTION_PLAN.md (commented thumb 0.6, bare thumb 0.4, implicit 0.4, judge 0.2).
-
Reflection hints for failed samples (
outcome_quality < 0.5): rule-based failure-signal classification (wrong_target, verbose_response, terse_response, missed_context, scope_creep, unhelpful_meta, unknown) + raw evidence (operator comments, thumb comments, next-message text) for GEPA's reflective mutation step in v2.19.2. -
Retention worker —
internal/worker/evolution_retention.gohard-deletes traces older thanSTAPLE_EVOLUTION_TRACE_RETENTION_DAYS(default 90). Runs daily. -
Thumbs UI —
- Chat: thumb-up / thumb-down buttons on agent-authored messages
in the chat detail page (
chats/_messages.html). - Issue: same shape on agent-authored comments in the issue
detail page (
issues/_comments.html). - 5-second undo affordance + optional "+ note" expand for operator explanations. Drives the explicit-signal layer of the scoring pipeline.
-
All driven by a single shared static script
/static/agent-feedback.js. -
/api/chat-messages/{messageId}/feedback+/api/issue-comments/{commentId}/feedbackendpoints (POST + DELETE). Cross-company access checks; instance-admin bypass mirrors approvals. -
Query layer (
internal/db/query/evolution_traces.go+agent_feedback.go) with full CRUD + aggregate helpers.
Tests¶
- 8 evolution_traces query tests (create/list-unsettled/settle/ re-settle/prune)
- 5 agent_feedback query tests (upsert idempotency, invalid thumb rejection, delete, aggregate counts including up/down × bare/ commented split, down-comments surfacing)
- 4 feedback API handler tests (happy path, invalid thumb, cross-company forbidden, delete)
- 13 evolution-scoring tests (aggregate edges, classify failure signals across taxonomy, helpers)
Operator-visible behavior¶
- Thumbs appear on every agent-authored message immediately. Operators can use them or ignore them; no other UI changes.
- Background workers start at server boot (no opt-in needed).
- Trace data accumulates silently; settler scores it; nothing changes about agent behavior.
- New
journalctl -u staplelog subsystems:evolution_settlerandevolution_retention. Settler logs at INFO when a tick finds traces to score.
Validation milestone before v2.19.1¶
Watch for ~1-2 weeks of real operator behavior:
- Are operators actually thumbing messages?
- Does the trace data look right? (Spot-check
outcome_quality_breakdown for sensible per-signal contributions)
- Are settled outcome_quality values bimodally distributed
(signal of meaningful scoring) or clustered around 0.5 (signal
the rules aren't extracting much)?
If anomalies surface, calibrate before committing to v2.19.1's versioning + test substrate.
Env vars (all env-tunable, sensible defaults)¶
| Variable | Default |
|---|---|
STAPLE_EVOLUTION_SETTLER_CHAT_QUIET_SEC |
900 (15 min) |
STAPLE_EVOLUTION_SETTLER_HEARTBEAT_QUIET_SEC |
3600 (60 min) |
STAPLE_EVOLUTION_TRACE_RETENTION_DAYS |
90 |
Not in v2.19.0¶
- Skill versioning / revisions (v2.19.1)
- Test substrate / test fixtures (v2.19.1)
- GEPA algorithm (v2.19.2)
- Guardrail gates (v2.19.2)
- Approval flow for evolved variants (v2.19.2)
- Scheduled triggers (v2.19.3)
- Cost budget enforcement (v2.19.3)
- LLM threshold escalator for unknown failure signals (v2.19.2+; deferred until enough unknowns accumulate for the rule-based taxonomy to demonstrate gaps)
v2.18.4 — 2026-05-18¶
Version visible in the UI footer.
Operator request: knowing what's deployed shouldn't require opening
the help popover. The build version now lives next to the ? button
in the sidebar footer, and clicking it opens the GitHub release page
for that tag.
Added¶
- Version label in
layout.htmlsidebar footer. Small, muted, tabular-numeric, monospace — positioned right of the?button. Release builds render as a link to<RepoURL>/releases/tag/<version>; thedevplaceholder renders as plain text since no such tag exists.
Fixed¶
reset.shnow injects build metadata via-ldflags. Before this release,reset.shdidsudo go build -o ... ./cmd/serverwith no ldflags, which meant the deployed binary'sversion.Version()was permanently stuck at the placeholder string"dev"— defeating the help-popover and (now) UI-footer version display.
Updated to resolve VERSION=$(git describe --tags --always),
COMMIT=$(git rev-parse --short HEAD), and a UTC ISO-8601
BUILD_DATE, then pass them via
-X main.version=... -X main.commit=... -X main.date=.... The
vars are already declared at package level in
cmd/server/main.go (line 42-44) — only the build invocation
was missing.
The previous reset.sh is preserved at reset.sh.bak on node4
for reference.
v2.18.3 — 2026-05-18¶
Chat detail page: template error when an agent reply had a cost row.
Smoke test of the chat UI found that opening any chat containing an
LLM-driven agent reply rendered as template error (HTTP 500). The
cause: chats/_messages.html called deref on
Message.CostUSD — deref is a *string helper but CostUSD is
*float64, so Go's html/template balked with wrong type for
value; expected *string; got *float64.
User-message-only chats and not-yet-replied-to chats opened fine,
which is why this slipped through CI: the test fixture for
TestUIChatShow_Renders only seeded a user message. v2.18.3 adds a
regression test that seeds an agent reply with a populated
cost_usd column — same shape ChatComplete produces in
production — so the path is now covered.
While in the area: the Telegram dispatcher's getUpdates 409
Conflict logs (which fire for ~30-60s after every reset.sh
restart, until Telegram tears down the previous long-poll
connection on its side) are demoted from WARN to DEBUG so they
don't dominate journalctl -u staple during normal redeploy
cycles. Other transient errors (401, 5xx, network) stay at WARN.
Added¶
-
derefFloat(*float64) float64template helper alongside the existingderef(*string) string. Returns 0.0 for nil pointers so a nil-guarded{{if .X}}upstream still keeps the visual element off the page. -
isExpectedTelegramTransient(err)predicate. Pattern-matches Telegram's 409 Conflict shape (status code AND description-text fallback for resilience to upstream wording changes). -
2 tests:
TestUIChatShow_RendersMessagesWithCostUSD— seeds an agent reply with the full optional-field payload (tokens, model, provider, cost) and asserts the page renders 200 and the cost ($0.0042) appears in the output.TestIsExpectedTelegramTransient— 7 cases: nil, 409 by status code, 409 by description text, 401, 5xx, network blip, unrelated message.
Changed¶
-
chats/_messages.htmlline 21 swapsderef→derefFloatforMessage.CostUSD. -
telegram.Dispatcher.RunlogsgetUpdateserrors at DEBUG when the predicate flags them as expected transients; WARN otherwise.
v2.18.2 — 2026-05-18¶
Telegram chats default strict_actions=false.
Channel context matters for the action-verb default. Web-UI chats
stay strict_actions=true (the safe brainstorming default — verbs
in agent replies render as text, nothing fires). But Telegram is
inherently operator-initiated: you opened the bot, you typed at
it, you want it to do something. Defaulting Telegram chats to
strict-mode silently swallows the agent's escalation intent.
v2.18.2 flips the default for Telegram-originated chats only:
new chats created via the Telegram dispatcher start with
metadata.strict_actions=false, so a Concierge (or any agent)
reply that includes /create-issue or /request-approval
actually fires.
Changed¶
-
telegram.Dispatcher.findOrCreateChatnow injects"strict_actions": falseinto the new chat's metadata alongside the existing"telegram": {chat_id, chat_type}mapping. The mergeChatMetadata helper still lets operators flip it back totrueper-chat via the UI checkbox. -
Web-UI chats are unchanged — they continue to default to
strict_actions=true(the v2.16.5 behavior).
Added¶
- Test
TestDispatcher_FindOrCreateChat_StrictActionsFalseByDefaultverifies the new behavior end-to-end (creates a chat via the dispatcher, unmarshals metadata, assertsstrict_actions=falseand that the telegram mapping wasn't clobbered).
Operational note¶
This release migrates existing Telegram chats too: a one-shot SQL
patches every chats row with metadata ? 'telegram' and no
explicit strict_actions key to set it to false. Applied on
node4 at deploy time; idempotent.
v2.18.1 — 2026-05-17¶
Chat UX fix: surface the LLM-provider gap up-front instead of as a wall-of-error.
Smoke test of v2.16.x found that chatting with a claude_local agent
returned a cryptic chat complete: no LLM provider configured for
agent (at: before) error. Two things were wrong:
- The error message was implementation-vocabulary ("provider", "at: before") with no actionable next step for the operator.
- The picker didn't mark which agents could actually chat, so the operator could pick an agent that was guaranteed to fail.
The architectural shape is correct (chat needs an HTTP LLM API path;
subprocess adapters like claude_local don't have one), but the UI
was hiding it instead of explaining it.
Added¶
engine.AgentSupportsChat(agent)— predicate exported for the UI handlers. Returns true if the agent has a resolvable LLM provider (either via its ownadapter_configor via the instance-levelSTAPLE_DEFAULT_LLM_*fallback).
The fallback is the important bit: operators with claude_local
agents can still chat with them by setting
STAPLE_DEFAULT_LLM_PROVIDER + STAPLE_DEFAULT_LLM_API_KEY on
the staple-server. The chat layer then uses the default LLM for
reply generation while the agent's system_prompt continues to
drive personality.
-
Agent picker labels in both
chats/index.html(new-chat form) andchats/show.html(switch-agent sidebar): agents without chat support are displayed asAgent Name — no LLMin the dropdown. Picker text underneath explains the meaning of the suffix and how to fix it. -
"This agent can't reply in chat" warning card in the chat detail page's right rail, shown when the currently-attached agent doesn't support chat. Three bullet-point remediation options (per-agent config, instance default, switch to a different agent).
-
Improved
ErrChatProviderUnavailabletext. Operator-facing now: "this agent has no LLM provider configured — chat needs either an API-key-driven adapter (set adapter_config.api_key / .provider on the agent) or an instance default (STAPLE_DEFAULT_LLM_API_KEY + STAPLE_DEFAULT_LLM_PROVIDER on the server). The agent's heartbeat-side adapter (claude_local, codex_local, docker, etc.) is not used here". -
5 new tests for
AgentSupportsChatcovering the three regimes (agent with own provider, no provider + instance default, no provider + no default), the nil case, and the custom-provider-without-api-key path (local Ollama use case).
Changed¶
-
Stripped
(at: before|during|after)suffix from the streaming error UI rendering. Theatfield was meant for client-side reasoning about whether the user message had landed (so a retry would or wouldn't duplicate); rendering it as parenthetical noise was leaking implementation detail and confusing operators. The chat handler still preserves the user message before any error fires, so the suffix was informationally redundant. -
UIChatList(pool)→UIChatList(pool, eng)andUIChatShow(pool)→UIChatShow(pool, eng)— both handlers now take the engine pointer so they can callAgentSupportsChatper agent. cmd/server/routes.go updated accordingly.
Operator quick-start to unblock chat¶
If you have claude_local agents (the common case) and want
chat to "just work" with any of them, set on the server:
STAPLE_DEFAULT_LLM_PROVIDER=anthropic
STAPLE_DEFAULT_LLM_API_KEY=sk-ant-...
STAPLE_DEFAULT_LLM_MODEL=claude-sonnet-4-6
(Or openai / custom with corresponding base URL for local
models). Restart staple-server. Every agent will then show
chat-supported in the picker; the agent's system_prompt
continues to drive personality, the default LLM provides the
text generation.
Not in v2.18.1¶
- Native chat-via-claude_local (spawning the Claude CLI as a subprocess for chat replies). The substrate change for this is meaningful — chat would no longer be synchronous and the v2.16.3 streaming SSE format would need an additional "process started / process running" event family. Open as a future enhancement.
v2.18.0 — 2026-05-17¶
Telegram channel adapter — first cross-channel transport on top of chat.
Validates the v2.16.x chat substrate's promise that "each external
channel is a transport that reads/writes the same chat_messages
table" (ROADMAP § Chat with agents). Operators chat with their
agents from Telegram now; the conversation appears in the Staple UI
side-by-side with browser-driven chats, and vice versa. No new
substrate — just a transport.
Added¶
-
internal/channels/telegram/package — minimal Telegram Bot API client (Bot.GetUpdates,Bot.SendMessage) plus aDispatcherthat bridges inbound updates to the chat substrate and outbound replies back to Telegram. -
Long-polling loop. Polls
getUpdateswithtimeout=30(Telegram's recommended long-poll idiom; the server blocks up to 30s waiting for a new message rather than us busy-polling). On network blips we back off 5s and retry. Stops cleanly onctx.Done(). -
Per-Telegram-chat → per-Staple-chat 1:1 mapping stored in
chats.metadata.telegram = {chat_id: <int64>, chat_type: "..."}. Looked up by scanning the company's chats for a matching JSONB payload — fast enough at expected sizes; can be promoted to an indexed query later if needed. -
Bot.SendMessageparses agent replies as Markdown by default with a plain-text fallback when Telegram returns 400 (e.g., unbalanced backticks in the model output). Operators see styled bullet lists and code blocks where Telegram can render them, and raw text when it can't. -
Env-driven activation in
cmd/server/main.go:
| Env var | Required | Purpose |
|---|---|---|
STAPLE_TELEGRAM_BOT_TOKEN |
yes | the 123:ABC… token from BotFather |
STAPLE_TELEGRAM_COMPANY_ID |
yes | which company's chats list to write into |
STAPLE_TELEGRAM_DEFAULT_AGENT_ID |
no | agent attached to fresh chats on first inbound message (blank → chat starts unattached) |
Both BOT_TOKEN and COMPANY_ID are required together; partial
config logs a WARN and the dispatcher stays disabled (rather than
silently doing the wrong thing).
- Per-Telegram-chat title heuristics.
DisplayNameForChatpicks a Staple chat title from the inbound message: - Group: chat title (e.g., "Engineering")
- Private with username:
@alice - Private without username:
First Last -
Otherwise:
Telegram chat <id> -
Cost attribution. Each agent reply via Telegram writes a
cost_eventsrow withbilling_type = "chat"andmetadata = {source: "telegram", chat_id}— same pattern the UI chat handler uses, so existing cost dashboards roll up Telegram-driven spend without further changes. -
Per-message metadata for traceability. Each
chat_messagesrow inserted from Telegram carriesmetadata = {source: "telegram", telegram_message_id: N, telegram_from_user_id: M}so audit / replay can reconstruct who sent what. -
16 tests:
DisplayNameForChatcovering 6 inbound shapes (group, private with username, private with first+last, private with first only, no-from-no-title fallback, nil message).Bot.GetUpdates: happy path (validates path / query params / response parsing), Telegram API error (ok=false), HTTP error (5xx).Bot.SendMessage: happy path (validates JSON shape and default Markdown parse mode), markdown 400 falls back to plain-text retry, non-markdown 4xx surfaces upstream description.Dispatcher.HandleUpdate: ignores non-text and empty-text updates.Dispatcher.Run: stops promptly on context cancel.- DB integration:
findOrCreateChatcreates on first message with the right metadata, reuses the same Staple chat on subsequent inbound messages with the same telegram chat_id, full HandleUpdate end-to-end appends user message AND sends a "no agent attached" notice when no default agent. Runend-to-end: seededgetUpdatesreturns one update, the dispatcher routes it, the test asserts that at least one outboundsendMessagehappened.
Design notes¶
-
One bot, one company. Multi-bot / multi-company routing would force operators to wire Telegram identity → Staple company mapping somewhere, which is real work. v2.18.0 takes the simpler shape: one bot binds to one company. Operators who need richer routing can come back with a concrete use case.
-
Polling, not webhooks. Webhooks need a public HTTPS URL, TLS termination, and a webhook handler endpoint. Long-polling needs none of that — it works from any deployment, including the operator's homelab on
node4behind NAT. -
Sync ChatComplete, no streaming. Telegram supports editing messages, so we could stream by sending an empty message then editing it with progressively larger content. That's worth doing if the operator finds latency annoying, but v2.18.0 ships the simple path first.
-
Author attribution = unauthored. Telegram messages land with
author_user_id = NULL. Mapping Telegram users to Staple users would require a linking step (e.g., "open the bot, send/link <code>") that's substrate enough to defer. The Telegram sender's user_id IS preserved in the message's metadata for audit. -
Errors are visible to the operator. Internal failures (chat lookup, agent load, LLM call, message append) all attempt to send a
_internal error: …_reply back over Telegram. Without that, an operator types into a silent bot and has no idea what's wrong.
Not in v2.18.0¶
- Webhook mode (operator-facing convenience; long-polling works for any deployment)
- Streaming via message-edit (latency improvement; ship if operator finds blocking annoying)
- Multimedia (photos, voice, stickers, documents); v2.18.0 silently ignores non-text updates
- Bot commands (
/start,/help,/link); every text message is treated as a chat turn - Outbound chunking; long agent replies > 4096 chars (Telegram's
per-message limit) will currently fail with an upstream error
that surfaces as
_internal error_. Adding chunking is straightforward and lands as a follow-up if it bites.
v2.17.0 — 2026-05-17¶
SSE seq+replay — lossless reconnect for generic events.
Closes the v2.14.10 follow-up: before this release, when a subscriber's channel buffer overflowed (or the browser briefly disconnected on laptop sleep / tab background), dropped events were lost forever — "a permanently stale UI element until the next related event lands" (quoting the v2.14.10 changelog).
v2.17.0 adds a per-company ring buffer + monotonic seq + browser
Last-Event-ID replay. The browser EventSource API already sends
Last-Event-ID automatically on reconnect; we just had to make the
server honor it for generic Event (it already worked for the live
transcript's RunEvent).
Added¶
-
sse.Event.Seq— monotonic per-company sequence number assigned byHub.Publish. 0 means "unassigned" (pre-Publish or pre-v2.17.0 events fed in via tests). The SSE wire format now includes anid: Nline per event so browsers persist it. -
Per-company replay ring buffer. 1000 events deep (~64KB per active company; lazy-allocated). When full, oldest entries drop; the reclaim happens once per overflow to keep memory bounded.
-
Reconnect / replay protocol on
/api/live/events: - Read
Last-Event-IDheader (browser sets it automatically) or?last_event_id=Nquery param fallback. - Snapshot buffer for events with seq > resume point.
- Write them out (with
id:so the browser tracks correctly). - Subscribe to live events.
- Re-snapshot to catch any events published between (2) and (4) — overlap with the channel is fine, the live loop dedups by seq.
- Send a
connectedsentinel with the current high-water seq. -
Live loop: skip any event whose seq is already covered.
-
event: gapframe. When the client's resume point is older than what the buffer still holds, the handler emits a syntheticevent: gapdata frame with{requested: N, oldest_available: M}. The client can react by reloading its view from authoritative sources rather than silently missing events. (No JS client changes in v2.17.0 — the frame is wire-only; UI integration comes in a follow-up if real-world traffic shows gaps happen.) -
Hub.SnapshotSince(companyID, sinceSeq) (events, currentSeq, gap)— public read API for diagnostics and tests. -
Admin metrics extension.
/api/admin/sse-metricsnow reportsreplay_current_seq,replay_oldest_seq, andreplay_bufferedper company. Operators can see "how far back can a client safely reconnect from?" without grepping logs. -
13 new tests:
replayBufferunit tests: monotonic seq assignment, ring drops oldest at capacity, snapshotSince filtering, gap detection boundary cases.- Hub-level tests: Publish assigns seq, seq is isolated per-company.
- Handler end-to-end: Last-Event-ID query param replays missed
events with
id:lines, header takes precedence over query param,event: gapframe on too-old resume point, concurrent publishers and replay subscribers don't lose events (200 publishes × 5 subscribers, each verifies no missing seqs). parseResumePoint/parseUinthelpers with edge cases (malformed input, header-vs-query precedence, defaults).
Design notes¶
-
Publish appends to buffer BEFORE fanning out to subscribers. This is the key race-correctness invariant: a subscriber that snapshots-then-subscribes can rely on "every published event is in the buffer before it reaches any channel," so the post-subscribe re-snapshot catches anything in flight during the subscribe call.
-
Dedup by seq in the live loop, not just at snapshot time. Events published between the second snapshot and the first channel read might appear in both the snapshot and the channel. The
if evt.Seq <= sentMax { continue }guard handles that cleanly without requiring a "subscribe + atomic snapshot" primitive on the hub. -
Per-company seq, not global. Two companies can both publish seq=1 simultaneously; that's expected and correct. A client resuming "Last-Event-ID: 42" only makes sense in the context of the SSE stream's
company_id, which the URL already carries. -
No persistence of the ring buffer. On process restart the buffer empties and seq resets to 1 per company. Clients that were holding a Last-Event-ID from the previous boot will get a
gapframe on first reconnect — the right outcome, since the server has no way to know what events the client missed across the restart. -
Wire format is backward compatible. Clients that ignore
id:lines (or don't speak Last-Event-ID) keep working exactly as before; they just don't benefit from replay.
Not in v2.17.0¶
- Client-side handling of
event: gapframes. The browser-side JS in the live transcript and chat pages doesn't yet listen for this event; if real traffic shows gaps occurring, the right move is a small JS handler that triggerslocation.reload()on gap. - Event coalescing (collapsing repeated
issue_updatedfor the same id into a single event) — the v2.14.10 follow-up. Still open if drop rates stay non-trivial after observing v2.17.0 in production.
v2.16.5 — 2026-05-17¶
Chat action verbs — opt-in execution with strict_actions toggle.
Sixth and final slice of the "Chat with agents" feature line. Agents
can now drive real operations from a chat reply via action verbs
(/create-issue, /request-approval) when the chat's
strict_actions toggle is off. Default is on (safe) — verbs render
as plain text in brainstorming chats and only the operator's
explicit opt-in unlocks execution.
This completes the v2.16.x line. The Chat substrate is end-to-end: sidebar entry → list → detail with streaming send → personality switching + forking → action-verb processing.
Added¶
chats.metadata.strict_actionstoggle. Per-chat boolean controlling whether action verbs in agent replies are processed by the engine or rendered read-only. Default istrue(read-only) — the safer choice so casual brainstorming chats don't fire unintended actions. Operators opt in per-chat.
Toggling is via a new checkbox in the chat sidebar (POST
/chats/{chatId}/ui/strict-actions), which UpsertChats the
metadata blob without disturbing other settings.
internal/handler/chat_actions.go— action processor. Invoked from bothUIChatSend(blocking) andUIChatSendStream(streaming) after the agent reply has been persisted:- Parses the reply with
engine.ParseAgentActions(the same parser the heartbeat path uses). - If
strict_actions = true, returns immediately — verbs stay as text in the agent's reply. -
Otherwise, for each parsed action, either applies it (chat- supported subset) or appends a system marker explaining why the verb is parsed but not applied in chat context.
-
Chat-supported action verbs (v2.16.5):
| Verb | What lands |
|---|---|
/create-issue <title> (optional fenced body) |
Top-level issue under the chat's company with origin_kind = "chat" and origin_id = chat.id. System marker links to the new issue. |
/request-approval <title> (optional fenced body) |
Freeform approval row with metadata = {source: "chat", chat_id}. System marker links to the new approval. |
- Verbs parsed but not chat-applicable (each emits a system
marker explaining why):
install_skill,install_routine,install_memory,install_agent,install_federation,use_skill,fire_routine,status,priority,assign,checkout,release,blocks,blocked-by,relates,write_file.
These all require either a heartbeat-run context (capability
registry actions touch run-scoped audit-trail rows) or an issue
context (status changes, checkout/release). Folding them into
chat would duplicate substantial portions of adapter.go; the
marker explanation tells the operator "ask the Triage Officer
via an issue if you want this applied".
-
Streaming endpoint emits
action_markerSSE events afteragent_message_completeso the client can append the markers to the transcript live. The blocking endpoint includes them in the returned HTML partial directly. -
10 new tests:
chatStrictActionsdefault + explicit + malformed JSON fallback (4 cases).mergeChatMetadatapreserves other keys, handles empty.processChatAgentReplyskips apply in strict mode.- Non-strict creates issue with
origin_kind = "chat"and appends a marker. - Unsupported verb (
/status) emits an explanation marker. UIChatToggleStrictActionsroundtrip (off → on → metadata reflects the change).
Design notes¶
-
Default is strict=true, not strict=false. The ROADMAP comment was symmetric ("toggles whether ... processed by the engine or rendered read-only") and could be defaulted either way. We chose read-only-by-default because the chat surface is conversational — agents discussing "we should create an issue for this" should NOT inadvertently fire
/create-issue. Operators opt in per-chat with one click when they want the agent driving real state. -
The action processor runs AFTER the agent reply lands. If the reply is persisted but the action processor fails (or the applied action errors), the chat still has the agent's reply visible — operators can re-read and try again manually. We don't make the action processor's failure unwind the chat turn.
-
Markers are markdown bodies with links.
_Created issue **[<title>](/issues/<id>)** from /create-issue action verb._renders as italic + bold + clickable in the v2.16.2 transcript CSS, so the operator clicks straight through to the newly created issue. -
Issue origin_kind = "chat" with origin_id = chat.id. Closes the audit loop — issue lists / cost reports / future analytics can filter "chat-originated issues" without scanning content.
v2.16.x recap¶
The chat feature line landed in six slices over a single day:
- v2.16.0 — substrate (migration 066, query layer, locked schema commitments)
- v2.16.0.1 — hotfix for goose's $$ dollar-quote parsing
- v2.16.1 — sync handler (open, send + blocking reply, list, archive, fork primitives)
- v2.16.2 — UI (sidebar entry, list page, detail page with HTMX-driven blocking send)
- v2.16.3 — streaming SSE (token-by-token replies via fetch()
- streaming body, OpenAI + Anthropic providers)
- v2.16.4 — personality switching (system marker on agent change) + chat forking (transactional copy with parent link)
- v2.16.5 — action verb processing (this release)
v2.16.4 — 2026-05-17¶
Chat personality switching + forking.
Fifth slice of the "Chat with agents" feature line. The agent picker in the chat sidebar now inserts a transcript marker on each non-no-op switch so the LLM (and the operator) sees the personality boundary; a new "Fork chat" panel saves the current state into a new chat with a different (or the same) agent, leaving the original untouched.
Added¶
- System-marker insertion on
SwitchChatAgent. Three shapes depending on the transition: _**Alice** joined the chat._(no-agent → Alice)_**Alice** left the chat._(Alice → no-agent)_Personality switched from **Alice** to **Bob**._(Alice → Bob)
No-op switches (Set with the same agent that's already current) insert nothing so the operator can hit the button twice without polluting the transcript.
The marker is a role=system chat_message row, so it's persistent
(audit trail + v2.15.0 self-evolution trace exporter see it) and
the next LLM turn includes it in the prompt context. Markers
contain markdown so they render with emphasis in the UI.
query.ForkChat— single-transaction primitive that:- Loads the parent
- Creates a new chat with
parent_chat_idpointing at the parent - Copies every parent message in seq order — the trigger renumbers seq in the destination so each insert gets a fresh monotonic value
- Commits
Token/cost metadata is intentionally NOT copied (parent kept the billing for the original turns; fork starts at zero so cost reports don't double-count).
-
POST /chats/{chatId}/ui/fork— UI handler that runsForkChat, appends a system marker noting the fork on the NEW chat (parent stays untouched), and HX-Redirects to the new chat's detail page so the operator can continue typing. -
Fork panel in the chat right rail. Below the Agent panel: a title input (defaults to "Fork of
"), an agent picker (defaults to "keep current agent"), and a Fork button. -
6 new tests:
- Query:
ForkChat_CopiesMessagesAndLinksParent(4 messages copied, parent_chat_id set, seq renumbered 1..N), default-title prefix, fallback for empty parent title. - Handler: switch-agent inserts marker with the right wording for attach-from-none, switch-agent no-op skips the marker insert, fork creates the new chat with the linked parent_chat_id and both copied messages + the system fork marker.
- Helpers:
agentSwitchMarkercovers all three transition shapes plus the empty edge;forkMarkerincludes the parent-link markdown.
Design notes¶
-
Marker text is markdown, not plaintext. Wrapping in
_..._makes them visually distinct in the transcript (italicized bubbles aligned center, per the v2.16.2 CSS for chat-msg-system). Bold agent names (**Alice**) draw the eye to who's now at the helm. -
Fork copies messages, not references. A future change to the parent (new messages, archive, agent switch) does NOT propagate to the fork — they diverge from the moment of forking. This is the natural shape for "save current state and continue separately"; if branching-with-live-sync ever becomes a requirement, that'd be a separate feature with a different data model.
-
Fork preserves message authorship. Copied user rows keep their
author_user_id; copied agent rows keepauthor_agent_id. This means a fork's audit trail truthfully says who wrote what, even if the fork ends up powered by a different agent for new turns. -
System-marker insertion is best-effort on switch. If the marker
AppendChatMessagefails (DB unavailable, etc.) we log but don't unwind the switch — the agent change itself already succeeded and unwinding would leave the chat in an inconsistent state where the previous turn was attributed to the no-longer- current agent.
Not in v2.16.4¶
- Action-verb emission inside chat (next: v2.16.5; final slice).
Will add a
chats.metadata.strict_actionstoggle that determines whether action verbs the chat agent emits are processed by the engine or rendered read-only.
v2.16.3 — 2026-05-17¶
Chat streaming — token-by-token via SSE.
Fourth slice of the "Chat with agents" feature line. Replaces v2.16.2's blocking "POST and wait" with a streaming response: tokens render into the transcript as they arrive from the provider, with the user seeing partial output within hundreds of milliseconds of pressing Send instead of waiting for the full reply.
Added¶
-
engine.StreamingProviderinterface — optional companion toProvider. Providers that can produce token-level streams implement both; callers detect support via type assertion. Implementations land here for openai-compatible (OpenAI, Ollama, vLLM, Together), Anthropic, and Perplexity (via the openai wrapper). -
engine.Engine.ChatStream— streaming sibling ofChatComplete. Returns<-chan ChatStreamChunk; the terminal chunk hasDone == trueand carries provider name + model + prompt/completion tokens + computed CostUSD so the handler can persist + bill in a single step. Falls back to a synthesized one-delta-then-done stream for providers that don't implementStreamingProvider, so the UI's streaming surface works uniformly across all configured providers. -
POST /chats/{chatId}/ui/send-stream— text/event-stream endpoint that produces the following event sequence per send: user_message— the just-appended user row (so client can render it immediately even if the LLM call fails)agent_message_start— agent id + name- many
deltaevents, each carrying a text chunk agent_message_complete— the persisted agent row with cost / tokens / model-
error(any time) — with anatfield indicatingbefore/during/afterso the client knows what state the user message is in -
Chat detail page now streams by default. The send form is intercepted by ~110 lines of vanilla JS that opens a
fetch()with a streaming response body, parses SSE events as they arrive, and appends bubbles to the transcript live. ⌘/Ctrl+Enter still submits. The original/ui/send(blocking) endpoint stays available as a fallback path; the form keeps it asdata-fallback-urlfor future use. -
SSE chunk parsers for both wire formats:
- OpenAI:
data: {...}followed bydata: [DONE], with deltas inchoices[*].delta.contentand final usage inusage. -
Anthropic: multi-event format with
message_start(sets model + input_tokens),content_block_delta(text chunks),message_delta(final output_tokens + stop_reason), andmessage_stop. Error payloads (e.g.overloaded_error) are surfaced as a single terminalDonechunk with the upstream message wrapped inErr. -
6 tests:
- Engine: OpenAI SSE happy path with two deltas + usage stats, malformed chunk → error chunk, Anthropic SSE happy path with final-token reconciliation, Anthropic upstream error propagation.
- Handler: streaming no-agent fast path (validates SSE encoding,
user_message+system_noteevents), streaming cross-company 403 IDOR. - Plus compile-time
var _ StreamingProvider = (*X)(nil)guards so future provider-struct changes break the build, not a runtime call.
Design notes¶
-
Cost on the terminal chunk, not the handler.
Engine.ChatStreamwraps the provider channel and enriches the terminal chunk withProvider,Model, andCostUSD(computed against the engine's pricing table). The handler reads those values when persisting the agent message — no pricing-table imports in the handler layer, no double bookkeeping of model names. -
User message lands BEFORE the SSE headers go out. So if the LLM call fails mid-stream, the user's text isn't lost — it's already in the transcript and visible in the chat history. The
errorevent'satfield tells the client whether they should retry-resend (before/during) or surface a partial-reply banner (during/after). -
No mid-stream markdown re-render. Deltas accumulate into a
textContentblock — fast, no parser load — and stay that way even afteragent_message_complete. Trade-off: links / code blocks / lists render as plain text in the streaming bubble. Refreshing the page applies full markdown rendering viachats/_messages.html. This is intentional; the alternative (running a markdown parser per delta) bloats the JS bundle and re-flows the DOM 50-200 times per reply. -
agent_message_completecarries the full row (including id, seq, created_at, cost, tokens, model). Client uses that to swap from "streaming" mode to "persisted" mode if needed — but in v2.16.3 we just leave the streamed text in place; v2.16.4 can add full markdown re-rendering on the complete event without changing the wire format.
Not in v2.16.3¶
- Personality-switch system marker injection (next: v2.16.4 — backend route + dropdown UI already live; what's missing is the system-role chat_message row that should land at the moment current_agent_id changes mid-chat)
- Action-verb emission inside chat (next: v2.16.5)
- Markdown rendering during streaming (deferred — see Design notes above)
- Forking / "save state and continue with another agent" (also v2.16.4)
v2.16.2 — 2026-05-17¶
Chat UI — sidebar entry, list page, detail page with sync send.
Third slice of the "Chat with agents" feature line. The operator now has an end-to-end UI surface for chatting with agents: a Chats item in the Work sidebar, a list page with a "New chat" form, and a detail page with the transcript, a send box, and an agent-picker side rail. v2.16.3 will replace the blocking send with token-by- token streaming; v2.16.2 ships the synchronous version that already works for everything except the typing-indicator UX.
Added¶
-
Sidebar entry. New "Chats" item in the Work section of the layout sidebar, alongside Issues / Projects / Goals / Routines / Approvals. Active-page class lights up on
/companies/.../chatsand/chats/.... -
/companies/{companyId}/chats— list page. New-chat form at the top (title + agent picker + submit), then a table of recent chats sorted by activity. "Show archived" toggle round-trips the?include_archived=truequery param. -
/chats/{chatId}— detail page. Two-column layout: transcript -
send box on the left, agent picker + chat metadata on the right. The transcript uses HTMX
hx-swap=beforeendso the sync reply appends both messages (user + agent) into the existing transcript without a full page reload. ⌘/Ctrl+Enter submits the send form; on success the textarea clears and the transcript scrolls to the bottom. -
UI handlers — eight endpoints in
internal/handler/ui_chats.go: GET /companies/{companyId}/chats(viewer+) — listGET /chats/{chatId}— detailPOST /companies/{companyId}/chats/ui/create(member+) — create and HX-Redirect to the new chatPOST /chats/{chatId}/ui/send— append + sync reply, returns the rendered message pair as an HTMX partialPOST /chats/{chatId}/ui/archiveand/ui/unarchivePOST /chats/{chatId}/ui/delete(withhx-confirmtripwire)-
POST /chats/{chatId}/ui/switch-agent— backend half of v2.16.4's personality-switch; route is live now so the dropdown has a working endpoint when v2.16.4 wires the system-marker insertion -
Templates.
chats/index.html— list pagechats/show.html— detail page (two-column)-
chats/_messages.html— partial for one or more message bubbles (used both for the initial page render and as the swap target after/ui/send) -
CSS additions in
static/app.css— chat message bubbles with role-based alignment (user right, agent left, system centered) and role-based colors. All compositor-friendly (background/border/ color only — no animated layout shifts on bubble append). -
7 UI tests covering list rendering, detail rendering, foreign- company forbidden (IDOR), create + HX-Redirect, send-without-agent fast path with the system note, archive/unarchive round-trip, and the agent switch + detach flow.
Design notes¶
-
No new API surface; UI calls the v2.16.1 query layer directly. The UI handlers don't shell out to the JSON endpoints — that would double the round trips for no benefit. They're peers, both built on the
query.Chat/query.ChatMessageprimitives. -
chatMessageViewdecoration in the handler, not the template. Templates can't callviewFordirectly (Go's html/template can't call methods that return multiple values, and tying the role label resolution to template logic was getting tangled). The handler builds the views; the template iterates them. -
System note on no-agent send is synthetic. The "No agent attached…" message is rendered into the partial but NOT persisted. Persisting it would leak template chrome into the audit trail and the v2.15.0 self-evolution trace exporter. Real system rows (the v2.16.4 personality-switch markers) get persisted because they're meaningful conversation state.
-
Error path is visible, not silent. If
engine.ChatCompletefails after the user message has already landed, the user message stays in the chat (their text is preserved) and a transient system-styled bubble surfaces the error inline. v2.16.3's streaming will let us also resume from the partial output if the provider chunks before failing.
Not in v2.16.2¶
- Token-by-token streaming (next: v2.16.3, ~3-4 days)
- Personality-switch system marker injection (next: v2.16.4 — the backend route + dropdown UI are already live; what's missing is the system-role chat_message row that should land at the moment current_agent_id changes mid-chat)
- Action-verb emission inside chat (next: v2.16.5)
- Forking / "save state and continue with another agent" (also v2.16.4)
v2.16.1 — 2026-05-17¶
Chat sync handler — open, send, receive (blocking).
Second slice of the "Chat with agents" feature line. Builds on the v2.16.0 substrate to expose the conversational primitive end-to-end over HTTP. The user POSTs a message, the handler calls the agent's configured LLM synchronously, and returns the reply in the same response. v2.16.3 will layer streaming SSE on top without changing this contract.
Added¶
-
engine.ChatComplete— the conversational sibling of the heartbeatExecuteHeartbeatRunloop. Takes an agent and a transcript, returns the LLM's reply plus the cost / model / token metadata. No issue lookup, no checkout, no work-finding, no action parsing, no heartbeat-run row — just one LLM call. ReturnsErrChatProviderUnavailablewhen the agent has no resolvable provider so the handler can surface a 4xx. -
internal/handler/chats.go— eight HTTP endpoints: POST /api/companies/{companyId}/chats(member+) — open a chatGET /api/companies/{companyId}/chats(viewer+) — listGET /api/chats/{chatId}— chat + last 50 messages + latest seqGET /api/chats/{chatId}/messages— paginated withsince_seqPOST /api/chats/{chatId}/messages— send + sync reply (the primary surface)PATCH /api/chats/{chatId}— title / current agent / metadata (current_agent_id swap is the v2.16.4 personality-switch primitive, backend-ready but not yet UI-exposed)POST /api/chats/{chatId}/archiveand/unarchive(idempotent soft-delete)-
DELETE /api/chats/{chatId}— hard delete (cascades) -
Cost attribution. Each agent reply writes a
cost_eventsrow withbilling_type = "chat"andmetadata = {chat_id, chat_message_id}, so existing cost dashboards (/api/companies/{companyId}/costs) pick up chat spend without further changes. The Heartbeat-run id is NULL — chats don't traverse the heartbeat substrate. -
SSE events —
chat_created,chat_updated,chat_deleted,chat_message_created. Published per company. v2.16.3 will add the streamingchat_message_delta/chat_message_completepair alongside these. -
14 handler tests covering create, list, get (with three access paths: same-company → 200, foreign-company → 403, instance-admin bypass → 200), no-agent fast path, bad JSON, empty content, title update, archive + unarchive round-trip, hard delete, message pagination with
since_seq, and the LLM-role normalization (agent→assistant).
Design notes¶
-
Role normalization at the boundary. The DB enum is
user / agent / system, matching the rest of Staple's domain language. Providers wantuser / assistant / system. ThenormalizeRoleForLLMhelper mapsagent → assistantat the wire boundary; the persisted column staysagentso reports / queries / future analytics see consistent vocabulary. -
History window of 50 messages. Picked to fit a comfortable token budget without forcing the model to re-process the entire transcript every turn. Older history is implicitly summarized by being excluded; v2.16.3's UI will let users scroll back to the full record via the
since_seqcursor. -
Access control lives in handlers, not middleware. Chat-id- scoped routes (
/api/chats/{chatId}/...) can't pre-check company access in middleware because the URL doesn't carrycompanyId— the chat itself is the source of truth. The handlers load the chat first and then callchatActorAllowed(actor, chat)which matchesactor.CompanyIDagainstchat.CompanyID(with an instance-admin bypass for diagnostics, mirroring how other admin-scoped paths behave). Same pattern/api/approvals/{id}uses. -
Cost on agent reply only. The user message has no LLM call so no
cost_eventsrow. If the LLM call fails after the user message lands, the user message stays in the chat (the user said what they said — losing it would be data loss); only the agent reply is retried.
Not in v2.16.1¶
- UI page (next: v2.16.2, ~2 days — sidebar button, chat list, right- rail metadata, agent picker)
- Streaming SSE / token-by-token (next: v2.16.3, ~3-4 days)
- Personality-switch UI (next: v2.16.4 — backend primitive ready; needs system-marker insertion + dropdown)
- Action-verb emission inside chat (next: v2.16.5; toggle via
chats.metadata.strict_actions)
v2.16.0.1 — 2026-05-17¶
Hotfix: migration 066 fails under goose.
v2.16.0 shipped with two PL/pgSQL function definitions in migration
066. Goose parses migration files by splitting on ; outside string
literals, but its default mode does not recognize PostgreSQL's
$$ ... $$ dollar-quoting — so it tried to execute the partial
CREATE OR REPLACE FUNCTION ... $$ DECLARE lock_key BIGINT; as a
standalone statement and the server failed to start with
unterminated dollar-quoted string at boot.
Wrapped both function bodies in -- +goose StatementBegin /
-- +goose StatementEnd annotations so goose treats each as a
single statement and forwards it to PostgreSQL intact.
Local DB applied cleanly via psql -c (which doesn't need the
markers) so this slipped through the local test pass; node4 caught
it because goose is the production migration path. Going forward,
any migration with PL/pgSQL function bodies needs these markers.
v2.16.0 — 2026-05-17¶
Chat substrate — schema + query layer.
First slice of the "Chat with agents" feature line (see docs/ROADMAP.md § Execution priority). Chat is a sibling substrate to issues, not a specialization of it. Issues carry lifecycle, routing, assignment, and approval semantics that don't fit conversational sessions; chats carry conversation state with a current-agent pointer that mutates over the session (foundation for personality switching in v2.16.4).
v2.16.0 is intentionally substrate-only — no HTTP handlers, no UI, no streaming. The schema commitments land first so the four subsequent slices (v2.16.1 sync handler → v2.16.5 action emission) can build on a locked shape without churning user data.
Added¶
-
Migration 066 —
chatsandchat_messagestables. Each row captures(company, optional creator, optional current agent, title, metadata JSONB, optional parent for forking, timestamps, soft-delete marker). Messages carry(chat, monotonic seq, role enum, optional author user, optional author agent, body, optional cost/model metadata, JSONB metadata, timestamp). -
Two BEFORE/AFTER INSERT triggers on
chat_messages. The first assignsseqviapg_advisory_xact_lockkeyed on the chat ID, so concurrent inserts to the same chat serialize but cross-chat inserts do not contend. The second bumpschats.updated_aton every message insert so the sidebar list ordering by recent activity stays honest. -
internal/db/query/chats.go— Go query layer. ExposesCreateChat,GetChatByID,ListChatsForCompany,UpdateChat,ArchiveChat/UnarchiveChat(idempotent soft-delete),SwitchChatAgent(personality-switch primitive — wired through but not yet UI-exposed),DeleteChat(hard delete, cascades),AppendChatMessage,ListChatMessages(withSinceSeqfor the v2.16.3 SSE replay path),GetLatestChatMessageSeq,CountChatMessages. -
Tests. 17 table-driven integration tests cover: minimal create + defaults, metadata roundtrip, list ordering by
updated_at DESC, archived hidden by default + visible withIncludeArchived=true, partial-update + empty-update error path, agent switch + detach, seq monotonicity over 5 inserts,updated_atbump on message append, role CHECK enforcement for unknown roles,SinceSeqgap query,MAX(seq)for empty vs populated, archive idempotence, cascade-on- delete, NotFound paths.
Design commitments (locked at v2.16.0)¶
Captured inline in the migration so future-us doesn't have to re-litigate them:
-
chats.current_agent_idis mutable. Personality switching (v2.16.4) works by updating this column; the chat's history is preserved across switches with a system-role marker inserted at the transition. -
chats.metadatais the per-chat-settings bag. v2.16.5 reads{strict_actions: bool}to decide whether action verbs the chat agent emits are processed by the engine or rendered read-only. Future settings land here without further migrations. -
chats.parent_chat_idis reserved for forking. Set when a chat was forked off another; the forked chat starts with a snapshot of the parent's messages plus a system marker. Reserved for v2.16.4's "Fork chat" button. -
chat_messages.seqis reserved for streaming gap recovery. The v2.16.3 streaming SSE machinery uses it to detect missed messages and replay; v2.16.0 just reserves the field and the index so the schema stays stable when streaming lands. -
Two FK columns (
author_user_id+author_agent_id) instead of a polymorphic actor_id. Same patternissue_commentsalready uses; keeps referential integrity intact and cascades cleanly when a user or agent is deleted. -
archived_atsoft-delete, not hard delete. The self-evolution trace exporter (v2.15.0) and the company's audit trail both expect chat history to survive the user's "delete" action.
Not in v2.16.0¶
- HTTP handlers (next: v2.16.1, "Open a chat / send a message" sync endpoints, ~2 days)
- UI (next: v2.16.2, "Chat" sidebar button + page with right-rail metadata, ~2 days)
- Streaming SSE / token-by-token (next: v2.16.3, ~3-4 days)
- Personality switching UI (next: v2.16.4, ~1 day — backend primitive
SwitchChatAgentalready exists) - Action verb emission inside chat (next: v2.16.5, ~2-3 days)
v2.14.12 — 2026-05-16¶
Routine runtime invocation — on-demand slice.
Closes the loop on routine-kind capabilities. Before v2.14.12, an
installed routine was just a registry pointer — its execution
descriptor said (future) /fire-routine <name>. Operators (and the
Triage Officer) could match a routine to an ask but had no way to
actually run it; the fallback was "leave a comment requesting it be
run", which is exactly the soft-hallucination pattern v2.14.6.2 was
built to avoid.
v2.14.12 wires the on-demand path. Scheduling (cron/webhook, catch-up
policy, idempotency-key dedup) is still operator-wired via the
routines UI — that's the larger substrate piece tracked in
docs/ROADMAP.md and out of scope for this slice.
Added¶
-
/fire-routine <title>action verb (internal/engine/actions.go). Single-line shape, no fenced body. Empty title drops viaaction.dropped(v2.14.6.2 surface). -
fire_routinehandler (internal/engine/adapter.go). Looks up the routine by(company_id, title). On success: - Spawns a child issue assigned to the routine's runner with
the routine's description as the body. Status=
backlog, priority=medium, parent_issue_id pointing back at the firing issue. - Creates a
routine_runsrow withsource="fire_action",status="started",linked_issue_idset to the new child. No idempotency-key — every emission is its own run; dedup belongs to the future scheduling layer. - Inserts a
routine.firedactivity_log row carrying{routine_id, routine_title, runner_agent_id, child_issue_id}. - Posts a confirmation comment on the firing issue with a clickable link to the spawned child.
- SSE publishes
issue_updated(parent) +issue_created(child).
Drop paths (via e.dropAction):
- Empty title → "/fire-routine needs a title on the verb line"
- Title doesn't match → "no routine titled X exists in this
company"
- Routine has no assignee → "open the routine in the UI and pick
which agent runs it; then re-fire"
-
query.GetRoutineByCompanyAndTitle(internal/db/query/routines.go). Composite-key lookup. Picks the most-recently-updated row on the unlikely chance of a title duplicate. -
Capability invocation hint update (
internal/engine/engine.go). The skill-kind hint update from v2.14.9 set the precedent; v2.14.12 does the same for routine- kind. Was:(future)
/fire-routine X— routine not yet directly fireable, /assign the routine owner or leave a comment...
Now:
`/fire-routine X` — spawns a new issue assigned to the
routine's runner with the routine's step list as the body...
Also: the renderer now accepts both routine_title and
routine_name in the execution descriptor (the v2.14.1
bootstrap and /install-routine write routine_title; some
older fixtures use routine_name).
-
Timeline verb label for
routine.firedactivity kind (internal/handler/render.go): "fired a routine from". -
## Available Actionsrendering documents/fire-routinewith the same shape as/use-skill's entry from v2.14.9. -
3 new parser tests (
internal/engine/actions_test.go): happy path, underscore form, empty title dropped.
Trade-offs in this slice¶
- On-demand only. No cron triggers, no webhook triggers, no catch-up policy. The scheduling layer is the bigger remaining routine substrate work.
- Single-fire semantics. Each
/fire-routineproduces a separate routine_run and a separate child issue. Concurrent fires from different agents (or the same agent across rapid heartbeats) will produce multiple runs — operators can dedupe via the existing concurrency_policy field on routines, but the runtime doesn't enforce it forfire_actionsource yet. - Body inheritance. The child issue gets the routine's
description verbatim. Future slices may inject parameters
(e.g.,
/fire-routine deploy-website env=staging), but the v2.14.12 shape is parameter-free.
What this unblocks¶
The Triage Officer can now respond to a user ask matching a
routine-kind capability by emitting /fire-routine <name> directly.
End-to-end: install routine via /install-routine (v2.14.5) →
operator sets assignee in UI → Triage matches a future ask → fires
via /fire-routine → runner heartbeats on the spawned issue and
processes the steps. The full install→approve→invoke loop is now
closed for memory, skill, agent, AND routine. Federation remains
deferred (its invocation depends on the cross-instance HTTP dispatch
substrate, which is a larger piece).
Migration / deployment¶
No schema migration. Standard rebuild + restart via ./reset.sh.
v2.14.11 — 2026-05-16¶
Bulk context import — MVP slice (memory-kind only).
First slice of the deferred "Bulk context import" item from
docs/ROADMAP.md. Operators can now seed a company's Capability
Registry with a JSON payload of memory-kind facts, instead of having
to drive each one through an interactive elicitation conversation
with the Triage Officer.
Scope on purpose: memory-kind only. Other primitives (routine, skill, agent, federation) have approval-gate or substrate complexity that doesn't compose cleanly with a batch importer; they'll follow in subsequent slices once the corpus format settles in real use.
Added¶
POST /api/companies/{companyId}/bulk-import(internal/handler/bulk_import.go). Authn: instance-admin OR company-admin (the handler enforces — member/agent get 403). Request body:
{
"items": [
{
"kind": "memory",
"name": "budget-for-project-openfang",
"trigger": "budget for project openfang",
"body": "The budget for project openfang is $50,000. Approved 2026-04 by the CEO.",
"description": "(optional)"
}
]
}
Response is per-item:
{
"total": 1,
"created": 1,
"skipped": 0,
"unsupported": 0,
"errors": 0,
"items": [
{"index": 0, "kind": "memory", "name": "...", "status": "created", "capability_id": "..."}
]
}
Per-item status one of: created / skipped (name already
exists) / unsupported (kind not yet implemented) / error.
Non-atomic on purpose — a malformed item doesn't fail the batch. Re-running after fixing the bad item is safe because of the idempotence rule below.
-
Idempotence. The importer checks for
(company_id, name)collisions before inserting; existing names land asskippedwith the currentcapability_idreturned. Operators can re-run with a partially-edited corpus and only the new items land. -
bulk_importsource value. New constantquery.SourceBulkImport = "bulk_import". Migration 065 extends thecapabilities.sourceCHECK constraint to include it. Provenance now spans five values:bootstrap,elicitation,manual,federation_handshake,bulk_import. The Capabilities page (v2.14.6.1) automatically surfaces the new source value via its existing per-row "Source" column. -
Migration 065 (
internal/db/migrations/065_capabilities_bulk_import_source.sql). Drops and re-adds thecapabilities_source_checkconstraint with the extended set. Mirror Down for rollback. No data movement — every existing row uses one of the original four values. -
5 new tests (
internal/handler/bulk_import_test.go): MemoryHappyPath— 3 items create 3 memory capabilities, descriptor.fact populated correctly, source=bulk_import.Idempotent— second run of identical payload yieldsskipped=Nrather than errors.UnsupportedKindIsSurfacedNotFatal— items with kind != memory returnstatus="unsupported"with a clear reason; other items in the same batch are unaffected.ValidationErrors— missing name / missing kind / missing body each land aserrorwith informative reason; the valid item in the same batch still succeeds.RoleEnforcement— member-role actor gets 403.
What's NOT in this slice (deferred to future slices)¶
- CLI subcommand
staple-cli import <dir>. A future slice parses markdown-with-frontmatter files on the operator's side and POSTs them to this endpoint. Keeping the wire format JSON means the CLI is a thin, replaceable shim. - Routine / skill / agent / federation kinds. Each needs its own slice because each has substrate quirks:
- Routine needs an assignee resolution path (or accept NULL like
/install-routine). - Skill needs the approval-gate decision: auto-approve on import
(operator-driven trust), or stay disabled-until-approved (the
/install-skillposture). My instinct is the former because bulk import is already gated by admin role. - Agent has heartbeat-receiving implications + adapter config inheritance question.
- Federation has the no-credentials-in-action rule from v2.14.8 that needs to be reconciled with a batch interface.
- Export symmetry. The roadmap entry called for export of an existing company's registry in the same corpus format. Deferred until the import side has stabilized through a couple of real-world uses.
Operator playbook¶
# Import a JSON payload from disk
curl -X POST \
-H "Authorization: Bearer <admin-key>" \
-H "Content-Type: application/json" \
--data @corpus.json \
https://your-staple-host/api/companies/<company-id>/bulk-import
Inspect the response. Anything in status="error" needs the
corpus edited; anything in status="unsupported" will wait for a
future slice.
Migration / deployment¶
Migration 065 runs automatically on goose up. Standard rebuild +
restart via ./reset.sh. No data movement.
v2.14.10 — 2026-05-16¶
SSE live-update lag: instrumentation + buffer bump.
Direct response to the long-standing "UI doesn't refresh timely after
agent state changes" concern tracked in docs/ROADMAP.md § Live
update UI lag. Last session's investigation identified the strongest
candidate: Publish drops events silently when a subscriber's
buffer is full, and generic events (issue_updated, approval_created,
etc.) have no seq+replay recovery — only the live transcript's
RunEvent does — so dropped generic events become permanently stale
UI elements.
v2.14.10 ships two things and DOES NOT ship a speculative fix:
- Per-company drop instrumentation so the next time the operator sees lag, there's data to point at instead of guessing.
- A buffer bump from 128 → 1024 events per subscriber. This is the smallest helpful change — 1024 events is roughly 60–90s of burst activity for a busy company at ~10-15 events/sec (the Council cascade peak we observed in v2.13–v2.14 testing) and negligible memory (~16KB per subscriber).
If the instrumentation reveals drops are still happening at scale, the next move is event coalescing (collapse repeated issue_updated events for the same issue) rather than another buffer bump — documented in the ROADMAP entry.
Added¶
-
Per-company SSE metrics (
internal/sse/hub.go). NewcompanyMetricsstruct with atomic counters:publishes,sent,dropped. Stored in async.Mapkeyed by company_id so counters outlive subscription lifecycles (we want totals even when a company has churned subscribers). -
Debounced drop warning. When a Publish causes one or more drops, the hub logs a
WARNline with the per-company drop total, publish total, and an actionable hint pointing at the ROADMAP entry. The warning is debounced to once per 30s per company so a sustained burst doesn't flood the log. -
Counters reset on staple-server restart (no persistence — this is debugging metadata, not historical capacity-planning data).GET /api/admin/sse-metricsendpoint (internal/handler/sse_metrics.go). Instance-admin only. Returns a JSON snapshot of every company's SSE counters: -
2 new tests (
internal/sse/hub_test.go): TestMetrics_HappyPath— verifies publishes/sent/dropped/ drop_rate compute correctly when a subscriber buffer overflows.TestMetrics_NoSubscribersIsNoOp— verifies Publish without subscribers doesn't pollute the metrics with phantom counts.
Changed¶
-
subscriberBufferSize128 → 1024 (internal/sse/hub.go). Decision rationale captured inline in the constant's comment. -
Publishno longer holds the read lock across the metrics update. Pre-fix, the entire publish loop ran underh.mu.RLock. Post-fix, the lock is released as soon as the per-subscriber send fan-out finishes, and the metrics update happens lock-free via the atomic counters. Slightly tighter critical section under contention. -
The existing
sse: publishinfo log now includesdropped_this_publishso operators can see per-event drop patterns without needing the admin endpoint. -
TestSlowSubscriberDroppedupdated to use thesubscriberBufferSizeconstant directly instead of the hardcoded 128 — survives future buffer tuning and now also verifies the drop counter caught the overflow.
Operator playbook¶
Hit the new endpoint when the UI feels laggy:
Look at drop_rate per company. Interpretations:
< 1%over a busy hour: drops are sporadic; UI lag if any is probably not the buffer. Check HTMXhx-swap-oobselectors or browser HTTP/1.1 connection limits.1–10%: buffer is getting overwhelmed in bursts. Next move: event coalescing (collapse repeatedissue_updatedfor the same issue id within a short window).> 10%: the subscriber is consistently slower than the publisher. Could be a single slow tab; could be a runaway agent flooding events. Investigate active_subscribers + watchdog for the offending agent.
Migration / deployment¶
No schema migration. Standard rebuild + restart via ./reset.sh.
v2.14.9 — 2026-05-15¶
Phase 6 of the Learning Hub — runtime skill invocation.
This release closes the loop that was opened in v2.14.6
(/install-skill). Until now, skills could be installed and approved
but couldn't actually be invoked at runtime — the registry showed
them as (future) /use-skill <name>. v2.14.9 lifts that placeholder:
every agent now sees approved skills in its prompt, can naturally
apply their guidance, and emits /use-skill <name> for the audit
trail. The Learning Hub install→approve→invoke loop is end-to-end
complete for all five primitives.
Added¶
## Available Skillssection in every heartbeat prompt (internal/engine/prompt.go). Renders enabled company skills with the full markdown body inlined. Unlike## Capabilities Available(routing-role only), this section is populated for EVERY agent — any agent can pull from any approved company skill.
Wire-format: same JSON-encoded markdown string that
SeedBuiltinSkills and /install-skill use. The engine unmarshals
to plain markdown at load time so the prompt template stays
format-agnostic.
Rendering pattern per skill:
-
SkillContextstruct (internal/engine/prompt.go). MirrorsCapabilityContext. Three fields: Name, Description, Body. Body is the unmarshaled markdown ready for inline rendering. -
Engine.loadSkillsFor(internal/engine/engine.go). Loads enabled skill-kind capabilities for the agent's company, resolves each via itsexecution_descriptor.skill_id, fetches thecompany_skillsrow, and builds a SkillContext. Wired into bothadapter_llm.goandengine_provider.goso every llm-adapter heartbeat sees the skill set. -
/use-skill <name>action verb (internal/engine/actions.go). Single-line shape (no fenced body). The skill body is ALREADY in the agent's## Available Skillssection; this verb doesn't inject anything new into the current heartbeat. Its job is the audit trail. -
use_skillhandler (internal/engine/adapter.go). Three artifacts on success: skill.invokedactivity_log row with{skill_id, skill_name}payload. Picked up by the existing timeline renderer; v2.14.9 adds the verb label "applied a skill to" ininternal/handler/render.go.- Confirmation comment from the invoking agent: "Applied skill X to this response."
- SSE
issue_updatedfor live refresh.
Drop paths (via e.dropAction):
- Empty name → "the skill name was empty…"
- Name doesn't resolve in this company → "no skill named X exists
in this company. Check the ## Available Skills section of your
prompt for the exact name."
query.GetSkillByCompanyAndName(internal/db/query/skills.go). Composite-key lookup for theuse_skillhandler. Returnspgx.ErrNoRowson miss so callers can distinguish "not found" from "DB error".
Changed¶
-
Skill capability invocation hint (
internal/engine/engine.go,renderCapabilityInvokeHint). Dropped the(future)prefix for skill-kind capabilities. Was:(future)
/use-skill release-note-bullet— skill invocation not yet wired; /assign an agent that can use the skill...
Now:
`/use-skill release-note-bullet` — the skill body is already
in your `## Available Skills` section; emit this verb to
record that you applied it
- Triage Officer prompt (
prompts/triage/Triage_Officer.md). Step 1's "Strong match" branch gets a new sub-bullet specifically for skill-kind capabilities. Two paths: - If the skill alone is enough to answer (e.g., a formatted
release note bullet), Triage applies it directly +
/use-skill/status done.
- If the skill needs an agent that has tooling Triage lacks
(e.g.,
summarize-prneedsghCLI), Triage/assigns the appropriate agent and mentions the skill in the assignment.
Removes the awkward "(future)" fallback the Triage prompt previously instructed agents to follow.
## Available Actions(internal/engine/prompt.go). New/use-skill <name>description added so every agent knows the verb exists.
Tests¶
- 4 parser tests in
internal/engine/actions_test.go(TestParseAgentActions_UseSkill_*): happy path, underscore form, empty name dropped, fenced-block-after-verb is NOT consumed as body. - 2 prompt-rendering tests in
internal/engine/prompt_test.go(TestBuildHeartbeatPrompt_AvailableSkills,TestBuildHeartbeatPrompt_NoSkills_NoSection). - The existing
TestRenderCapabilityInvokeHinttable keeps passing — substring matches against/use-skill Xcontinue to hold under the new wording.
What's complete now¶
The Learning Hub primitive set is feature-complete:
| Primitive | Install verb | Invocation |
|---|---|---|
| Memory | /install-memory |
Inline in prompt via capability matching (no separate verb needed) |
| Routine | /install-routine |
/fire-routine (queued for future — currently (future) in hint) |
| Skill | /install-skill |
/use-skill (v2.14.9) ✅ |
| Agent | /install-agent |
/assign <name> |
| Federation | /install-federation |
/federate-to (queued for future, paired with runtime dispatch layer) |
Memory and Agent are invokable today. Skill is invokable as of v2.14.9. Routine and Federation invocation verbs are queued (see ROADMAP deferred items — they depend on substrate work beyond just adding a parser verb).
Migration / deployment¶
No schema migration. Pure code. Standard rebuild + restart via
./reset.sh on node4.
What's next¶
The Learning Hub line (v2.14.x) is now feature-complete. Remaining
work is in docs/ROADMAP.md Deferred items — none blocking the
elicitation+install loop.
v2.14.8.2 — 2026-05-15¶
Hygiene patch covering three Tier-1 items from the ROADMAP's deferred- items list. All three were small and high-friction; bundling them keeps the rollback story per-fix clean (3 commits inside the tag, plus a DB operational change).
Fixed¶
- Board-user
CreateCommenthandler bug (internal/handler/comments.go). API-key driven comment POSTs were returning HTTP 500 because the handler's else-branch setauthorID = &actor.AgentIDeven whenAgentIDwas empty (the board-user case:ActorType="board_user",UserID="",AgentID=""). The empty-string FK toagents(id)failed at the DB layer.
Fix: replaced the two-branch if/else with a three-way switch:
- IsUser() && UserID != "" → CreateCommentWithUser (user attrib)
- IsAgent() && AgentID != "" → CreateComment with agent attrib
- default → CreateComment with nil authorID (unauthored, which
is the correct semantic for an API-key driven action)
This bug was first hit during the v2.14.7 E2E test on node4 — I worked around it with direct SQL inserts but every future API-key driven script would have inherited the friction.
Regression test: TestCreateCommentHandler_BoardUserActor in
internal/handler/comments_test.go. Uses a real board-user actor
shape (empty UserID and AgentID, only UserLabel) and verifies
the comment lands with both author_user_id = NULL and
author_agent_id = NULL.
- Triage Officer scope creep on
/install-agent(prompts/triage/Triage_Officer.md). Observed during the v2.14.7 E2E: Triage emitted/install-agentcorrectly, then ALSO emitted/create-issue "Tag and publish v2.14.7"(work the new agent would do, not work Triage should do) plus two duplicate/status done.
Root cause: the LLM read the fenced system_prompt body as instructions to itself rather than as the new agent's identity. Boundary between "emit this as the new agent's identity" and "execute this content yourself" was insufficiently sharp.
Fix: added a STRICT BOUNDARY block to step 4's /install-agent
branch explicitly listing what NOT to emit after /install-agent:
- No /create-issue to start work the new agent would do
- No /assign to route the work — the user wanted the agent to
exist, not for Triage to also do its work
- No duplicate /status done
- No elaboration on what the new agent should do (it's in the
system_prompt the agent will see)
The block ends with a single mental check Triage runs before emitting any post-install action: "would this action make sense if the user had just said 'create the X agent and that's all for now'?"
The v2.14.8 federation E2E did NOT exhibit this failure mode
(actions: [install_federation status] only), so the failure is
specific to /install-agent. The matching block is added to the
agent branch only.
Changed¶
- Opus model bump on node4: 6 Council/Lead agents bumped from
claude-opus-4-5→claude-opus-4-7. Affected agents: Council Head, Critical Lead, Empirical Lead, Historical Lead, Practical Lead, Synthesist Lead. Same pricing tier ($0.015 / $0.075 per 1K), latest Opus generation, matches Github Agent's existing config. Pre-emptive — Opus-4-5 isn't on the current deprecation list yet but is two generations behind and likely next.
SQL applied operationally (not in repo):
UPDATE agents SET model = 'claude-opus-4-7', updated_at = now()
WHERE model = 'claude-opus-4-5'; -- 6 rows
-
internal/engine/adapters/anthropic.go:claude-opus-4-5dropped from the hardcoded fallback model list to match the DB state. Kept inpricing.gofor cost lookups against any historicalheartbeat_runs.modelrows that captured the old value. -
prompts/council/README.md: doc table updated — Council head + Leads now showclaude-opus-4-7(matching reality on node4).
Deployment¶
No schema migration. Rebuild + restart via ./reset.sh on node4 (see
docs/user/operators/upgrades.md for the canonical procedure).
v2.14.8.1 — 2026-05-15¶
Model bump: claude-sonnet-4-5 → claude-sonnet-4-6.
Operational hygiene patch. Anthropic announced claude-sonnet-4-5 is
on the deprecation list. claude-sonnet-4-6 is the clean successor:
same pricing tier (already in pricing.go at $0.003 / $0.015 per 1K),
same context window, comparable quality. The /v1/models call on the
live API returns both claude-sonnet-4-6 and the dated snapshot
claude-sonnet-4-5-20250929 — so the bump is forward-compatible.
Changed¶
-
internal/engine/adapters/anthropic.go:claude-sonnet-4-5removed from the hardcoded fallback model list;claude-sonnet-4-6is now the index-0 entry (the valueDefaultModel()returns). Two preserved models (claude-opus-4-5 and claude-haiku-4-5) stay in the list — Anthropic's deprecation notice was scoped to Sonnet-4-5 only. -
internal/engine/pricing.go: added explicit entries forclaude-opus-4-7andclaude-opus-4-5at the Opus rate ($0.015 / $0.075 per 1K). They were both falling through to the generic fallback ($0.003 / $0.015), so the cost-tracking page has been under-reporting Opus spend. The dollar impact is small at current usage volumes but worth fixing as we add more Opus-backed agents. -
DB on node4 (operator action, not in the repo): 7 agents on
claude-sonnet-4-5updated toclaude-sonnet-4-6viaUPDATE agents SET model = 'claude-sonnet-4-6' WHERE model = 'claude-sonnet-4-5'. Affected agents: Triage Officer, Release Manager, and the five<Lens>-Clauderesearchers (Critical-Claude, Empirical-Claude, Historical-Claude, Practical-Claude, Synthesist- Claude). The sixclaude-opus-4-5agents (Council Head + five<Lens> Leads) are NOT touched — the deprecation notice the operator received was scoped to Sonnet-4-5. -
prompts/council/README.md: doc table updated to reflect Sonnet-4-6 as the current researcher-tier model. Opus entry left at 4-5 to match reality until/unless the operator decides to bump it. -
Tests (
internal/engine/engine_provider_test.go,internal/handler/ui_agents_models_test.go): two test expectations that hardcodedclaude-sonnet-4-5(default-fallback test + UI hint-text test) updated toclaude-sonnet-4-6. No behavioral changes.
Why not also bump claude-opus-4-5¶
The operator's email called out claude-sonnet-4-5 specifically. Opus
4-5 is one version behind the latest Opus (claude-opus-4-7) but
isn't yet on the deprecation list returned by /v1/models. Bumping
six Council/Lead agents proactively would change a tier of the
multi-lens triangulation that the operator tuned by hand earlier;
better to do that on an explicit ask. The patch flags this as a
deferred question rather than acting on it unilaterally.
Deployment¶
DB update is already applied on node4. Code changes need a rebuild
+ restart (no migration). See the v2.14.8 release section for the
canonical ssh node4 && cd /mnt/Media/staple && ./reset.sh flow.
v2.14.8 — 2026-05-15¶
Phase 5d of the "Learning Hub" roadmap — the last install action:
/install-federation. Closes the five-primitive set: memory, routine,
skill, agent, federation. Every kind in the registry now has an
install verb, and the elicitation loop can teach Staple any of them
through a normal conversation.
A federation is a pointer to an EXTERNAL system the company wants to
delegate certain question types to: another Staple instance, a
third-party API, an outbound webhook, etc. v2.14.8 ships the
registry substrate — the install action, the underlying
company_federations table, the approval gate, the approval
resolution callback. The runtime dispatch layer (the code that
actually sends an HTTP request to the federation endpoint when a
matching ask comes in) is intentionally NOT in scope for this
release. That's a separate, larger piece of work; v2.14.8 makes
federations DISCOVERABLE first so the dispatch wiring has something
concrete to plug into.
Added¶
/install-federation <Name>action verb (internal/engine/actions.go). Trailing text is the federation name. Optionaldescription: <one-line>hint overrides the bare name for the capability's trigger description. A REQUIRED triple-backtick fenced block carries the contract — a markdown description of what the external system handles. Hint parsing uses the v2.14.7.1-style lenient indentation rules.
Critically, the parser+handler+prompt are designed so the agent NEVER sees, proposes, or collects credentials or endpoint URLs. Those are operator-supplied via UI post-approval. Five new parser tests covering happy path, underscore form, no hints, missing body, empty name.
install_federationhandler (internal/engine/adapter.go). Creates three coupled rows in a single heartbeat:company_federationsrow: name + description + contract (JSON-encoded markdown body).endpoint_urlandauth_methodcolumns are deliberately left NULL — the install action does not collect them.capabilitiesrow:primitive_kind=federation,enabled=false. Disabled rows are invisible to the Triage Officer's## Capabilities Availablerendering.approvalsrow:type=capability_federation_install. Body shows the proposed name, description, full contract body, AND an explicit "Operator action required after approval" section listing the three follow-up steps (set endpoint_url, set auth_method, add credential). Metadata JSONB carries{action, capability_id, federation_id}for the resolution callback.- Plus the standard
capability.installedactivity entry withpending_approval: true.
Failure handling: if CreateCapability fails, the federation row
is rolled back via DeleteFederation to avoid orphans. If
CreateApproval fails, the federation + capability stay (operator
can still flip enabled by hand). All drop paths go through
e.dropAction (v2.14.6.2) so failures are visible.
- Capability-aware approval resolution extended
(
internal/handler/approvals.go). TheapplyCapabilityApprovalResolutionhelper now knows aboutinstall_federation: - Approved: capability.enabled=true. The federation row is
untouched — endpoint + auth remain NULL until the operator wires
them. The capability becomes DISCOVERABLE (visible to Triage's
## Capabilities Available), but actual dispatch still depends on the operator wiring step. This mirrors/install-routine's "registered but needs setup" posture. -
Rejected:
DeleteCapabilityANDDeleteFederation. No runtime state was created (no live dispatches), so cleanup is one delete each. -
Migration 064 (
internal/db/migrations/064_company_federations.sql). Adds thecompany_federationstable — company-scoped, UNIQUE on (company_id, name). Endpoint URL + auth method columns nullable. Rationale for a new table rather than extendingcompany_providers(LLM-adapter-specific CHECK) orremote_workers(instance-scoped) documented in the migration header. -
Query helpers (
internal/db/query/federations.go):CompanyFederationstruct,CreateFederation,GetFederationByID,ListFederations,DeleteFederation. Intentionally minimal — endpoint_url/auth_method are NOT inCreateFederationParamsso an LLM-emitted action cannot inject them. -
Triage Officer prompt (
prompts/triage/Triage_Officer.md). Decision-tree step 4 now describes the/install-federationflow with an explicit guardrail: never propose credentials, endpoint URLs, or any secret. The agent's job is the contract, not the wiring. -
## Available Actionsrendering (internal/engine/prompt.go). Heartbeat prompts now describe/install-federationincluding the no-secrets guardrail.
What this completes¶
The Learning Hub install loop, end to end:
| Primitive | Install verb | Risk posture | Approval gate? |
|---|---|---|---|
| Memory | /install-memory |
low (stated fact) | no — installs enabled |
| Routine | /install-routine |
low (assignee + trigger gated by human) | no — installs enabled, but unassigned |
| Skill | /install-skill |
high (prompt-injection surface) | yes — installs DISABLED |
| Agent | /install-agent |
highest (heartbeat privileges) | yes — installs DISABLED + paused + heartbeat-disabled |
| Federation | /install-federation |
high (external dispatch boundary) | yes — installs DISABLED; runtime dispatch wiring also gated |
Every kind a company might want to teach Staple is now reachable through the elicitation loop. The Triage Officer's decision tree has a branch for each.
Migration / deployment¶
Migration 064 runs automatically on server startup (goose up). The
new table is empty on first deploy. No data migration needed.
What's next¶
- v2.14.9 — Skill auto-generation. The LLM drafts the SKILL.md
body from the elicitation transcript so the human reviewer audits
rather than authors. Council Head can be invoked to research first
when the skill calls for it. Closes the
(future) /use-skill ...invocation hint that's currently stuck in the registry. - Beyond v2.14.x: federation runtime dispatch (the actual HTTP handshake), credential-entry UI, observability for federated asks. Multi-week subsystem — out of scope for the Learning Hub per se.
v2.14.7.1 — 2026-05-15¶
Parser fix surfaced by the v2.14.7 E2E test on node4. The first
end-to-end run of /install-agent — Triage Officer drafting a
Release Manager via elicitation — produced valid Staple syntax
in the comment chain, but the engine parser dropped the entire
action: only [status] ran, no install_agent. No new agent row,
no capability, no approval.
Root cause¶
Claude Sonnet 4.5 emits hint lines at column 0 (not indented), like:
/install-agent Release Manager
role: general
title: Release Coordinator
description: Orchestrates release cuts and announcements
reports_to: Engineering Manager
` ` ` markdown
# Release Manager
...
` ` `
The v2.14.7 parser's hint loop copied /create-issue's strict
indentation requirement (next[0] != ' ' && next[0] != '\t' → break).
That convention exists in /create-issue to disambiguate hints from
prose continuation lines. But for /install-agent the LLM's natural
format is the column-0 form, so the indentation check broke at line
1 and the verb fell into the "no hints, no body" path → the verb
line went to clean output as prose, no action emitted.
The disambiguation signal that actually works for /install-agent is
parseHintLine's recognized-key check: only lines matching one of
role / title / description / reports_to / blocks / blocked_by /
relates / assignee (with optional underscore/dash variants) and the
<key>: <value> shape are consumed; anything else stops the loop.
That signal works whether the line is indented or not.
Fixed¶
/install-agentparser (internal/engine/actions.go): hint loop no longer requires indentation. Stops at:- blank line (next is the fence opener or end of message)
- line starting with ``` (fence opener)
- line where
parseHintLinereturns ok=false (not a recognizedkey: valuehint)
This makes /install-agent accept the LLM's natural column-0
output without changing behavior for /create-issue (which keeps
the indentation requirement because its hints can collide with
prose continuation).
- 3 new regression tests in
internal/engine/actions_test.go: TestParseAgentActions_InstallAgent_UnindentedHints— the exact column-0 shape Sonnet emitted in the E2E test, verifying all four hint keys + fenced body land correctlyTestParseAgentActions_InstallAgent_HintLoopStopsAtFence— defensive check that the fence opener is never consumed as a hintTestParseAgentActions_InstallAgent_HintLoopStopsAtProse— a non-hint, non-fence prose line after the verb causes the action to be dropped (no fenced body), which is the correct behavior: better to drop than to silently emit an incomplete install
Why this is a separate patch, not v2.14.8¶
This is purely a parser fix for a verb that shipped 30 minutes ago.
v2.14.8 (/install-federation) is a new feature. Bundling them
would make a rollback of the parser fix harder; keeping them
separate also gives a clean git bisect anchor if the parser change
ever needs revisiting.
Deployment¶
No schema migration. Rebuild + restart on node4. The next E2E test should produce a complete install-agent flow (agent paused, capability disabled, approval pending).
v2.14.7 — 2026-05-15¶
Phase 5c of the "Learning Hub" roadmap (see docs/ROADMAP.md): the
fourth install action — /install-agent. The highest-risk install
verb so far, because provisioning a new agent is functionally identical
to giving an arbitrary system_prompt heartbeat privileges: it
becomes an ongoing identity that runs every N seconds, consumes API
budget, and routes work through the Triage Officer's roster.
To bound that risk, the install path mirrors v2.14.6's /install-skill
approval gate plus defense-in-depth: the agent installs paused
AND with heartbeats disabled AND with its capability registry entry
disabled. The roster filter in engine.loadRosterFor excludes
non-active agents, so a freshly installed agent is invisible to the
Triage Officer too. Only an operator approval flips all three flags.
Added¶
/install-agent <Name>action verb (internal/engine/actions.go). Trailing text on the verb line is the agent name. Optional indented hints between the verb line and the fenced body:role: <role>— defaults togeneraltitle: <short title>— optionaldescription: <one-line>— used as the capability's trigger description if present, falling back to the bare namereports_to: <existing agent name>— escalation target (looked up by name; ignored with a visible note in the approval body if not found, rather than blocking the install on a typo)
A REQUIRED triple-backtick fenced block follows carrying the new agent's system_prompt. Name-only and empty-body invocations are dropped with the verb line preserved in clean output.
install_agenthandler (internal/engine/adapter.go). Creates four coupled rows in a single heartbeat:agentsrow: status defaults to "active" via the column default then is immediately UPDATEd topaused.heartbeat_enabled=falseis passed on insert. Adapter config (adapter_type,provider_id,model) is inherited from the installer since they presumably have a working setup; the reviewer can adjust before activating. System prompt is the fenced body.reports_tois resolved to an agent ID by name lookup.capabilitiesrow:primitive_kind=agent, then UPDATEenabled=false. Execution descriptor carries{agent_id, agent_name, role, how}so future bootstrap paths can surface it under## Capabilities Available.approvalsrow:type=capability_agent_install. Body shows the proposed name, role, title, reports_to (with resolution status), inherited adapter config, full system prompt body, and current state of the paused row — so the reviewer has everything they need to approve or reject without opening the agent detail page. Metadata JSONB carries{action, capability_id, agent_id}for the resolution callback.capability.installedactivity entry withpending_approval: trueand the inline agent fields.
Failure handling: if CreateCapability fails, the agent row is
rolled back via DeleteAgent to avoid paused orphans. If
CreateApproval fails, the agent and capability stay (operator can
still flip the flags by hand on the agent detail page). All drop
paths go through e.dropAction (v2.14.6.2) so failures are visible
to the user, not silent.
- Capability-aware approval resolution extended
(
internal/handler/approvals.go). TheapplyCapabilityApprovalResolutionhelper now knows aboutinstall_agent: - Approved: the existing capability-enable path runs, then
additionally
UpdateAgent(status="active", heartbeat_enabled=true)on the agent row. The two-step flip is ordered: capability is enabled first, agent is activated second. If the agent flip fails, the capability is discoverable but no agent is running — strictly better than the reverse (a running agent with no registry entry). -
Rejected:
DeleteCapabilityANDDeleteAgent. The agent never ran (status was paused, heartbeat_enabled was false), so there are no heartbeat runs to clean up; FK cascades handle the rest. -
parseHintLineextended (internal/engine/actions.go). New recognized keys:role,title,description,reports_to. Other actions' handlers (notablycreate_issue) silently ignore hint keys they don't recognize, so the wider key set doesn't change their behavior — but it lets the/install-agenthandler read agent-config hints without a parallel hint parser. -
Triage Officer prompt (
prompts/triage/Triage_Officer.md). Decision-tree step 4 now describes the/install-agentflow, including a deliberate guardrail: only emit this verb if the user is genuinely asking for a NEW persistent agent. If they're describing a one-off task that an existing agent could handle,/assignis the right answer — don't accidentally spawn a permanent agent for a one-off ask. -
## Available Actionsrendering (internal/engine/prompt.go). Heartbeat prompts now describe/install-agentincluding the highest-risk warning and the inheritance behavior. -
Parser tests (
internal/engine/actions_test.go). Six new cases: happy path with all three hint keys, no-hints, underscore form, missing body dropped, empty name dropped, empty body dropped. The parser-internal kind name across all install verbs is now table-tested.
How the loop closes (worked example)¶
User opens issue: "We need a Release Manager to handle release cuts
and announcements." Assigned to Triage Officer.
T+0 Triage heartbeat: no capability or roster match. Posts a
clarifying comment asking the smallest question; /status blocked.
T+1 User replies: "It should own the release cut, tag it in git,
and post the announcement to #releases. Reports to the
Engineering Manager."
T+2 Triage heartbeat sees the reply. Recognizes a new-agent ask
(not a one-off task). Emits:
/install-agent Release Manager
role: pm
description: Owns release cuts, tags, and announcements.
reports_to: Engineering Manager
` ` `
You are the Release Manager. Your job:
1. On every release: bump version.go, tag v<X>, push tags.
2. Generate release notes from commit log since last tag.
3. Post announcement to #releases.
4. Update the company changelog under docs/.
` ` `
Engine creates:
- agents row "Release Manager" with role=pm,
status=paused, heartbeat_enabled=false, inherited
adapter config (llm + Anthropic + claude-sonnet-4-5),
reports_to=<resolved>.
- capabilities row "agent-release-manager"
(enabled=false, source=elicitation).
- approvals row "Approve new agent: Release Manager"
with metadata.action="install_agent", body shows the full
system prompt + identity card + adapter config.
Posts confirmation comment naming the paused state and pointing
at the Approvals page. /status done.
T+3 User opens the Approvals page, reviews the proposed
system_prompt + adapter config + identity card, clicks Approve.
applyCapabilityApprovalResolution:
1. capabilities.enabled = true
2. agents.status = active, agents.heartbeat_enabled = true
The Triage Officer is woken on the source issue and sees
`agent-release-manager` in its `## Capabilities Available` for
the next heartbeat. The Release Manager itself starts running on
its normal heartbeat cadence.
Later: user asks "Can you cut a release for me?" assigned to Triage.
Triage sees `agent-release-manager` under
`## Capabilities Available`. Strong match → /assign Release
Manager. No re-elicitation.
Migration / deployment¶
No schema migration. Pure code. Vanilla rebuild + restart on node4.
What's next¶
- v2.14.8
/install-federation— register cross-instance capability handshake (last install verb). Same approval-gate pattern as /install-skill and /install-agent; metadata likely{action, capability_id, federation_id}. - v2.14.9 Skill auto-generation — LLM drafts skill body from
elicitation transcript; Council Head can be invoked to research
first; human approves before activation. Closes the runtime
skill-execution path that's currently "(future)" in
renderCapabilityInvokeHint.
v2.14.6.2 — 2026-05-15¶
Soft-hallucination fix: agent actions that the engine drops are now visible to the user.
The v2.14.6 field-test on node4 surfaced the trust-breaking pattern:
the Github Agent confidently said "Wrote the release note to
docs/release-notes/b85320a.md" in an issue comment, but the engine
had silently dropped the /write-file action because the issue had
no project attached. The user looked for the file in the UI; it
didn't exist; nothing in the conversation indicated anything had gone
wrong. The LLM doesn't know its action was dropped — it has already
shipped its response — so its claim of success is a soft hallucination
the operator has no way to refute without grepping server logs.
v2.14.6.2 closes the gap with a single engine helper that fires on
every deliberate drop site. Each drop now produces three artifacts:
an activity_log row, a visible issue comment authored by the agent,
and an SSE event for live UI refresh. Pre-v2.14.6.2 drop sites used
some combination of logger.Warn (server logs only) and
e.logEvent("stderr") (the run detail page, not the issue page) —
neither of which the operator would reasonably check.
Added¶
Engine.dropAction(ctx, runID, issue, agent, action, reason, logger)ininternal/engine/adapter.go. Central surface for "the engine refused to apply this action". Lands three artifacts:action.droppedactivity_log row carrying{action_kind, reason, original_value, body_bytes?}. Picked up by the existing issue timeline renderer; v2.14.6.2 also adds a distinct verb label so the entry stands out: "⚠️ had an action dropped on this issue".- Visible issue comment authored by the agent, in the first
person: "⚠️ I tried to emit
/install-skill foobut the engine dropped it: the name was empty. The action was emitted but could not be applied. Address the issue above and re-trigger me with a fresh comment if you'd like me to retry." Verbs are auto-converted from parser-internalinstall_skillto user-facing/install-skill. -
SSE issue_updated so live dashboards refresh.
-
Eight drop sites converted to call
dropAction(plus the existing logger.Warn for server-side observability, which we keep): create_issuecapped (maxCreateper heartbeat)blocks/blocked_by/relatestarget not resolvedblocks/blocked_by/relatesself-referencewrite_fileissue has no projectwrite_fileproject load failedwrite_filecompany load failedwrite_fileactual write failed (path validation / size cap / fs)install_memoryempty topicinstall_routineempty title or body (with kind-specific reason messages)-
install_skillempty name or body (same pattern) -
Timeline verb labels for three Learning Hub event kinds that previously rendered as raw
kindstrings: action.dropped→ "⚠️ had an action dropped on"capability.installed→ "installed a capability on"-
project.file_written→ "wrote a file for" -
Three new tests in
internal/engine/adapter_drop_action_test.go(DB-backed): TestDropAction_LandsActivityAndComment— happy path; verifies both the activity row and the visible comment land with the expected payload + verb + reason + valueTestDropAction_BodyOnlyAction— covers actions with emptyValue(onlyBody); confirms the comment renders cleanly andbody_byteslands in the payloadTestDropAction_VerbDashConversion— table-driven; confirms all five parser-internal kinds (install_memory,install_routine,install_skill,write_file,create_issue) render as user-facing dash form (/install-memory, etc.) in the comment
Reason-message standards (informal)¶
Every drop reason is a single sentence the user can act on, not a stack trace. The verbose error text (if any) goes in the activity_log payload, where power users can inspect it via the timeline detail or SQL. Examples:
| Drop | Reason in comment |
|---|---|
write_file no project |
"this issue has no project attached, so there's no workspace to write into. Attach this issue to a project from the issue detail page, then re-trigger me with a comment and I'll retry the write." |
install_memory empty topic |
"the topic was empty. The /install-memory action needs a one-line natural-language topic on the same line as the verb (e.g., /install-memory budget for project openfang). Re-emit with a topic and I'll retry." |
create_issue capped |
"hit the per-run create_issue cap of N; subsequent /create-issue actions in this heartbeat are dropped to protect against runaway issue creation" |
blocks/relates target not found |
"no issue matched the reference %q (try the short identifier like ACME-42, the 8-character id prefix, or the exact issue title)" |
Why the agent will stop hallucinating success over time¶
The next heartbeat's prompt context — the v2.13.4 timeline renderer
— picks up the new comment AND the action.dropped activity entry.
So when the same agent fires again on the same issue, it sees its
own ⚠️ comment in conversation history. It can no longer claim "I
already wrote the file" without contradicting the drop notice
immediately above. This is the prompt-conditioning side of the same
fix.
What's NOT in this release (queued for later)¶
/use-skillaction verb — the runtime skill-invocation path. Still "(future)" inrenderCapabilityInvokeHint. Probably aligns with v2.14.9 (skill auto-generation) since both touch skill execution.- Operator-managed retry from the UI — a "retry this action"
button on
action.droppedtimeline entries that would re-queue the original action against the current issue state. Useful but not urgent — re-triggering via a fresh comment already works.
Migration / deployment¶
No schema migration. Pure code + template-func additions. Vanilla rebuild + restart on node4.
v2.14.6.1 — 2026-05-15¶
UI completeness pass. Field-testing v2.14.6 (the /install-skill
action) on node4 surfaced four real gaps in operator-facing surfaces
that the Learning Hub had grown past. None are functional bugs in the
install loop itself — that all works — but each one breaks the
operator's mental model in a way that erodes trust in what the agents
say they're doing. v2.14.6.1 closes those four gaps with pure UI work
plus one engine 1-liner.
Added¶
- Skills: view + edit + body in create modal (Bucket A). Up to v2.14.6 the company DB skills page rendered only Name + Description + a Delete button. There was no way to view the markdown body that gets injected into agent prompts, no way to edit it, and the "New Skill" modal didn't even capture a body — making manually created skills useless.
GET /companies/{cid}/skills/{sid}/view— name, description, full markdown bodyGET /companies/{cid}/skills/{sid}/edit— full edit formPOST /companies/{cid}/skills/{sid}/ui/save— applies edits, re-materializes on-disk copy, cleans up old slug if renamed- Skills index page: each row now has View / Edit / Delete buttons
- New Skill modal: gained a Content (SKILL.md) textarea
- IDOR guard: cross-company access returns 404, not 403
-
2 new tests: end-to-end round-trip (create → view → edit → save → DB verify) and cross-company 404
-
Capabilities Registry page (Bucket B). The capabilities table has existed since v2.14.1 with bootstrap seeding + agent-prompt rendering, but had no UI surface — operators needed SQL to see what was registered. Now there's:
GET /companies/{cid}/capabilities— table view (Name, Kind, Trigger, Source, State, Actions) with filter chips per primitive_kind and an "enabled only" toggle. Header summary shows total / enabled / disabled / per-kind counts.GET /companies/{cid}/capabilities/{capId}/view— detail page with pretty-printed execution descriptor, provenance (source, installed_via_issue, installed_by_agent), and notesPOST .../{capId}/ui/toggle— flip enabled (disabled rows are invisible to the Triage Officer's prompt rendering — kept for audit)POST .../{capId}/ui/delete— hard-delete the registry row; does NOT cascade to the underlying primitive (a deliberate difference from the rejection path inapplyCapabilityApprovalResolution, which DOES cascade — that path knows the primitive was just created for the install)- New "Capabilities" item in the company sidebar nav
-
3 new tests: list, toggle+delete round-trip, cross-company 404
-
Project files browser on the issue detail page (Bucket D). The Project Show page already had a Workspace Files section. What was missing was a per-issue view, so operators could see "for THIS issue, what did agents write?" without hunting through a project's full file tree. Now the issue detail page has a
## Files written by agentssection that: - Reads
project.file_writtenactivity_log entries already loaded in the timeline (no extra DB round-trip) - Renders each as a row with path, size, when, and a Download link
pointing at the project's existing
/api/projects/{pid}/files/*download endpoint - When the issue has NO project, the empty-state explicitly warns
that
/write-fileactions will be silently dropped by the engine — making the silent-drop UX failure visible without requiring an engine change (which is queued for v2.14.6.2)
Fixed¶
renderCapabilityInvokeHintfor skill-kind (Bucket C — engine 1-liner). The v2.14.1-era code looked forslugin the execution descriptor, but/install-skill(v2.14.6) stores the field asskill_name. Result: the invocation hint always rendered as(future) /use-skill ...with no name, leaving the Triage Officer no way to pass the skill's identity forward when routing to a human-selected runner. Now readsskill_namefirst, falls back toslugfor legacy/future bootstrap compatibility. 8 sub-cases in the newTestRenderCapabilityInvokeHinttable-driven test.
What this means in practice¶
A worked example from the field-test issue that surfaced these gaps:
User opens issue: "Can you write me a release note bullet for commit abc123?" assigned to Triage Officer.
Pre-v2.14.6.1: - Triage saw
skill-generate-release-note-from-commitin## Capabilities Available✓ - Invocation hint rendered as(future) /use-skill ...(Bucket C bug) — Triage couldn't identify the skill by name in the hint and fell back to routing to Github Agent without passing the skill body - Github Agent did the work viaghCLI, emitted/write-file docs/release-notes/...but the issue had no project - Engine dropped/write-filesilently (stderr log only); the LLM's "I wrote the file" claim was a soft hallucination - User had no way to find the (non-existent) file or to know the write had been droppedPost-v2.14.6.1: - Invocation hint reads
(future) /use-skill release-note-bullet — skill invocation not yet wired; /assign an agent that can use the skill and reference this capability by name so the runner can read the skill body(Bucket C fix) - User can browse/companies/{cid}/capabilitiesand see the skill registered, enabled, and where it came from (Bucket B) - User can click into the skill and read/edit the markdown body directly (Bucket A) - Issue detail page now shows "This issue has no project attached. Agent /write-file actions on this issue will be silently dropped" — failure surfaced before the user wastes time looking for the file (Bucket D)
What's NOT in this release (queued for v2.14.6.2)¶
- Engine-side write_file UX hardening — when
/write-filedrops (no project / write error / etc.), post a visible issue comment instead of stderr-only logging. The activity log would also gain awrite_file.droppedrow that the issue page could surface. This closes the soft-hallucination gap at the source rather than just the surface. /use-skillaction verb — currently the hint reads "(future)" because the runtime skill invocation path isn't wired. Probably aligns with v2.14.9 (skill auto-generation) since both touch the skill-execution surface.
Migration / deployment¶
No schema migration. Pure code + templates. Vanilla rebuild + restart on node4.
v2.14.6 — 2026-05-15¶
Seventh slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
the third install action — /install-skill. Triage Officer can now
turn an elicitation conversation about a reusable prompt-text snippet
into three coupled rows: a company_skills row (the markdown body), a
capabilities row pointing at it, and an approvals row gating
activation.
Skills are the first install verb that requires explicit human approval before the capability becomes discoverable. Memory and routine capabilities (v2.14.4, v2.14.5) install enabled because their risk surface is bounded — memory is a stated fact, routine has a human-set assignee+trigger gate before it can run. Skills inject text directly into agent prompts, which is a prompt-injection surface; the roadmap's "Skill body generation: LLM-generated → human approves" anchor lands here.
Added¶
-
/install-skill <name>action verb (internal/engine/actions.go). Mirrors/install-routineshape: trailing text is the skill name (used as bothcompany_skills.nameand the capability's trigger_description), required fenced body carries the SKILL.md markdown. Name-only and empty-body invocations are dropped with the verb line preserved in clean output. -
install_skillhandler (internal/engine/adapter.go). Creates three coupled rows in a single heartbeat: company_skillsrow: name + JSON-encoded markdown body indefinition(matches the wire-format used bySeedBuiltinSkillsso renderers don't need to know provenance).capabilitiesrow:primitive_kind = skill, then immediatelyUPDATE enabled = false— the schema default is enabled, so the follow-up UPDATE is what makes this install gated. Disabled capabilities are already filtered out of## Capabilities Availablebyengine.loadCapabilitiesFor'sOnlyEnabled: trueflag (v2.14.2 work), so the skill is invisible to every agent until approved.approvalsrow:type = capability_skill_install, body shows the proposed skill markdown for inspection,metadataJSONB carries{action, capability_id, skill_id}so the resolution callback can enable or roll back precisely.- Activity log entry (
capability.installedwithpending_approval: trueflag). - Confirmation comment on the elicitation issue explaining the pending state and pointing at the Approvals page.
-
Atomicity: if
CreateCapabilityfails, the skill row is rolled back. IfCreateApprovalfails, the skill+capability stay (operator can manually enable from the UI if they want) — partial install is salvageable rather than re-asking the user. -
Capability-aware approval resolution (
internal/handler/approvals.go). - New
applyCapabilityApprovalResolutionhelper runs at the top ofapplyApprovalResolution. - Inspects
approval.Typeforcapability_prefix and readsapproval.Metadataforcapability_id/skill_id/action. - Approved:
UpdateCapability(enabled=true)so the Triage Officer's next heartbeat surfaces it under## Capabilities Available. The existing approve flow then wakes the agent assigned to the linked issue, so resumption is immediate. - Rejected:
DeleteCapability+DeleteSkillso the registry doesn't accumulate rejected drafts. -
Failures are logged but never fail the HTTP response — the approval row itself is already resolved; partial rollback leaves a disabled (invisible) capability that an operator can clean up.
-
metadataJSONB column onapprovals(internal/db/migrations/063_approvals_metadata.sql+internal/db/query/approvals.go). NOT NULL DEFAULT '{}'. Generic payload — existing/request-approvalcalls leave it empty and function unchanged. Future install verbs (/install-agent,/install-federation) will use it the same way/install-skilldoes. -
## Available Actionsrendering (internal/engine/prompt.go). Heartbeat prompts now describe/install-skillincluding the prompt-injection rationale for the approval gate. -
Triage Officer prompt (
prompts/triage/Triage_Officer.md). Step 4 of the decision tree now describes the skill flow including the "DON'T/status blockedwaiting for approval" guidance — the install issue is done; the approval is its own surface. -
Parser tests (
internal/engine/actions_test.go). Four new cases (happy path, underscore form, missing body dropped, empty name dropped). The shared parser logic is now exercised across all three install verbs (memory, routine, skill).
How the loop closes (worked example)¶
User opens issue: "Can Staple write release-note bullets from a commit
SHA for me?"
Assigned to: Triage Officer.
T+0 Triage heartbeat: no capability for "release-note bullets".
Posts a comment asking the smallest clarifying question; emits
/status blocked.
T+1 User replies: "Yes, it's a reusable skill. The skill should
fetch the commit, extract the title + scope + risk, then output
a single-line bullet."
T+2 Triage heartbeat sees the reply. Emits:
/install-skill release-note-bullet
` ` `
# Release-note bullet
Given a commit SHA:
1. Fetch the commit (title, files touched, +/- LOC).
2. Classify scope (feat/fix/refactor/...).
3. Score risk (low/med/high) based on diff size + critical
paths touched.
4. Output one line: "- <type>: <title> (<files-touched> files,
risk: <level>)"
` ` `
Engine creates:
- company_skills row "release-note-bullet" with the body in
`definition` (JSON-encoded string).
- capabilities row "skill-release-note-bullet" pointing at it,
enabled=FALSE.
- approvals row "Approve new skill: release-note-bullet" with
metadata.action="install_skill", body shows the markdown for
review.
Posts confirmation comment:
"Drafted new skill capability: skill-release-note-bullet
(kind: skill). The skill is disabled pending human approval --
review and approve from the Approvals page before it becomes
discoverable."
/status done.
T+3 User opens the Approvals page, reviews the markdown body, clicks
Approve.
applyCapabilityApprovalResolution flips
capabilities.enabled = true.
The Triage Officer is woken on the source issue.
Later: user asks "Generate a release-note bullet for commit abc123".
Triage's `## Capabilities Available` now includes
`skill-release-note-bullet`. It /assigns the request to whichever
agent has access to the skill (or invokes the skill directly via
its standard execution path). No re-elicitation.
Migration / deployment¶
063_approvals_metadata.sql runs automatically on server startup. The
column has a default of '{}'::jsonb, so existing approval rows are
backfilled with no data movement. Rollback (-- +goose Down) drops
the column cleanly.
What's next¶
- v2.14.7
/install-agent— provision a new specialist from a description (same approval-gate pattern as skill) - v2.14.8
/install-federation— register cross-instance capability handshake - v2.14.9 Skill auto-generation (LLM drafts skill body from elicitation transcript; Council Head can be invoked to research first; human approves before activation — uses the v2.14.6 approval substrate)
v2.14.5 — 2026-05-15¶
Sixth slice of the "Learning Hub" roadmap (see docs/ROADMAP.md): the
second install action — /install-routine. Triage Officer can now
turn an elicitation conversation about a multi-step procedure into two
coupled rows: a routines row (so the procedure is durable + editable
in the UI) and a capabilities row (so the Triage Officer's next
heartbeat surfaces it under ## Capabilities Available for matching
future asks).
Per the "Human-as-gate" anchor locked in docs/ROADMAP.md, the
installed routine has no assignee on purpose: the operator picks
which agent runs it (and adds any cron/webhook trigger) from the UI.
The capability is registered + discoverable + matchable, but the
runtime activation step stays with the human. This is the right
default for routines that may touch deployment, billing, or external
systems.
Added¶
/install-routine <title>action verb (internal/engine/actions.go). Mirrors the/install-memoryshape but with a stricter contract: the fenced step-list body is REQUIRED (a routine with no steps is not a useful registry entry). Title-only invocations and empty-fenced-body invocations are both dropped, with the verb line preserved in the clean output so the malformed attempt is visible to the human.- Accepts both
/install-routineand/install_routine(dash and underscore synonyms, same convention as/install-memory). -
Title becomes the routine's
titlecolumn AND the capability'strigger_description. The slugified title (prefixed withroutine-) becomes the capabilityname, capped at 80 chars. -
install_routinehandler (internal/engine/adapter.go). Creates two coupled rows in a single heartbeat: - A
routinesrow:title= the trailing text on the verb line,description= the fenced body (what the assignee agent reads at runtime),assignee_agent_id= NULL,status=active,priority=medium. - A
capabilitiesrow:primitive_kind=routine,execution_descriptor={routine_id, routine_title, requires_setup},source=elicitation, with back-references to the issue and agent that installed it. - Emits a
capability.installedactivity log entry (visible in the v2.13.4 timeline) and posts a confirmation comment telling the user where to finish wiring it up in the UI. -
Failure of the capability insert leaves the routine row intact (it's still editable from the UI) — degrade gracefully rather than rolling everything back.
-
Triage Officer prompt (
prompts/triage/Triage_Officer.md). Decision-tree step 4 now distinguishes facts from procedures and documents the concrete/install-routineflow alongside/install-memory. The narrowed "v2.14.5–8" comment is shrunk to "v2.14.6–8" since/install-routinehas shipped. -
## Available Actionsrendering (internal/engine/prompt.go). Heartbeat prompts now describe/install-routineso every agent (not just the Triage Officer) can see when the action is appropriate. -
Parser tests (
internal/engine/actions_test.go). Five new cases: InstallRoutine_HappyPath— title + fenced body, surrounding narrative kept in clean output, verb line consumed.InstallRoutine_UnderscoreForm—/install_routinesynonym.InstallRoutine_MissingBodyDropped— no fenced block at all, action dropped, verb line preserved.InstallRoutine_EmptyTitleDropped— no title text after the verb.InstallRoutine_EmptyBodyDropped— fenced block exists but contains only whitespace.
Fixed¶
- Latent bootstrap bug in routine seeding
(
internal/db/query/capabilities.go). The v2.14.1 bootstrap queriedSELECT r.name FROM routinesbut the actual column istitle. The surroundingif err == nilswallowed the SQL error silently — so no existing routine has ever been bootstrapped into the capabilities table on any company. Fixed alongside/install-routinebecause the bootstrap path needs to see freshly installed rows AND any pre-existing routine rows the operator created via the UI before the registry existed.
How the loop closes (worked example)¶
User opens issue: "Can Staple deploy the website for me?"
Assigned to: Triage Officer.
T+0 Triage heartbeat: no capability for "deploy the website".
Comments back:
"I don't have a capability for this yet. Is this a one-off
external action, or a recurring procedure you'd like me to
remember?"
/status blocked.
T+1 User replies in a comment:
"It's a recurring procedure. Steps are:
1. SSH to node4
2. git pull on /srv/staple
3. docker-compose up -d
4. Curl /healthz to verify"
T+2 Triage heartbeat sees the reply in its v2.13.4 timeline. Emits:
/install-routine deploy the website
` ` `
1. SSH to node4
2. git pull on /srv/staple
3. docker-compose up -d
4. Curl /healthz to verify
` ` `
Engine creates:
- routines row "deploy the website" with description = steps,
assignee_agent_id = NULL.
- capabilities row "routine-deploy-the-website" pointing at it.
Posts confirmation comment:
"Installed new routine capability: routine-deploy-the-website.
The procedure is registered but has no assignee yet — open
the routine in the UI to pick which agent runs it, and add a
trigger if you want it to fire on a schedule."
/status done.
Later: user opens "How do I deploy the website?".
Triage heartbeat sees `routine-deploy-the-website` under
`## Capabilities Available`. Responds directly with the steps
(or, once an assignee is wired up, /assigns to that agent with
"Run the routine 'deploy the website'"). No re-elicitation.
Migration / deployment¶
No schema migration required — the routines and capabilities tables
already exist. Deployment is a vanilla rebuild + restart on node4.
What's next¶
- v2.14.6
/install-skill— register a reusable single-action capability - v2.14.7
/install-agent— provision a new specialist from a description - v2.14.8
/install-federation— register cross-instance capability handshake - v2.14.9 Skill auto-generation (LLM drafts skill body from elicitation transcript; Council Head can be invoked to research first; human approves before activation)
v2.14.4 — 2026-05-15¶
Fifth slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
the first install action — /install-memory. Triage Officer can
now turn an elicitation conversation into a durable, registered
capability without operator intervention. The next time someone asks
the same kind of question, the answer is already in the registry and
gets matched automatically.
Added¶
-
/install-memory <topic>action verb parsed byParseAgentActions. The first line after the verb is the natural- language topic (e.g.,budget for project openfang); an optional triple-backtick fenced block carries the fact content. Body-less invocations are allowed — they register the topic with a placeholder the operator (or a subsequent agent heartbeat) can fill in. -
Engine handler in
internal/engine/adapter.go. When an agent emits the verb, the engine: - Slugifies the topic into a stable capability name
(
memory-<slug>, max 80 chars). - Inserts a row in
capabilitieswithprimitive_kind = 'memory',source = 'elicitation',execution_descriptor = {"fact": "<body>"}, and provenance pointing at the originating issue + author agent. - Inserts a
capability.installedactivity_log entry so the install shows up in the v2.13.4 timeline. - Posts a confirmation comment on the issue: "Installed new capability: memory-budget-for-project-openfang (kind: memory). Future asks matching budget for project openfang will use this..."
-
Publishes an
issue_updatedSSE so the UI refreshes. -
query.SlugifyForCapability(s)— exported version of theslugifyhelper from v2.14.1, so engine code uses the same slug rule the registry's bootstrap path uses. Capabilities installed via any path share a consistent name shape. -
## Available Actionsdocumentation — the heartbeat prompt's action catalog now describes/install-memory, alongside the other verbs. Every agent that uses the standard prompt builder sees the new verb in its action documentation.
Changed¶
- Triage Officer prompt — the v2.14.3 prompt placeholder ("Until
Phase 4 lands you can only acknowledge the answer and
/assignthe closest existing agent") is replaced with concrete/install-memoryguidance. When the user's reply describes a durable fact, Triage now emits the install verb and the fact lives in the registry from that heartbeat forward. The updated prompt is committed toprompts/triage/Triage_Officer.mdand applied to node4.
Tests¶
TestParseAgentActions_InstallMemory_TopicOnly— body-less form is parsed cleanly;Kindisinstall_memory,Valueis the topic,Bodyis empty.TestParseAgentActions_InstallMemory_WithFact— verb + topic + fenced fact block produces a single action withBodypopulated.TestParseAgentActions_InstallMemory_EmptyTopicDropped—/install- memorywith no topic is rejected (the line falls through to clean output for human visibility but produces no action). Keeps the registry clean.- The previously-existing v2.14.2
TestBuildHeartbeatPrompt_NoCapabilities_NoSectionwas tightened to look for the section header (## Capabilities Available\n) rather than the bare substring, because the new/install-memoryaction description naturally includes the phrase "Capabilities Available."
How the loop closes (worked example)¶
- User creates an issue: "What's the budget for project openfang?"
- Triage reads the issue, sees no matching capability → posts a
comment: "I don't have a record of that yet. Could you tell me
what the budget is?" →
/status blocked. - User replies in a comment: "It's $50,000 — approved 2026-04 by the CEO."
UIIssueCommentfirestriggerAgentForIssue. Triage wakes up.- Triage sees the reply in conversation history (v2.13.4 timeline).
Decides this is a durable fact. Emits:
Followed by
/status done. - Engine processes
install_memory: row inserted withname='memory-budget-for-project-openfang',trigger_description='budget for project openfang',execution_descriptor={'fact': 'The budget for ... $50,000 ...'}. - Engine posts a confirmation comment. Issue is
done. - Next time anyone asks "What's the openfang budget?" — Triage
sees
memory-budget-for-project-openfangin## Capabilities Available, matches on trigger description, and the answer is in the prompt without re-asking the user.
Notes for operators¶
- No DB migration. Re-uses
capabilitiesfrom v2.14.1. - Memory is just a capability. No new substrate, no filesystem
writes, no dependency on
para-memory-filesorqmdfor this install path. The fact lives incapabilities.execution_descriptorand renders into the registry's## Capabilities Availablesection directly. A future release may unify this with thepara-memory-filesskill for cross-conversation portability, but for v2.14.4 the registry-only path is simpler and fully functional. - Provenance is preserved. Every
/install-memory-created row carriescreated_via_issue_id(the conversation it came from) andcreated_by_agent_id(the agent that performed the install) so you can audit what was learned, when, and by whom.
v2.14.3 — 2026-05-15¶
Fourth slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
Triage Officer elicitation loop. When an incoming ask doesn't
match any registered capability AND doesn't fit any teammate's role,
Triage now opens a comment thread asking the smallest clarifying
question instead of /release-ing the issue. The pattern reuses
existing primitives — comments + /status blocked + the existing
triggerAgentForIssue wakeup on user reply.
Changed¶
-
Triage Officer system prompt completely rewritten to embrace the four-step decision tree:
-
Match against
## Capabilities Available(v2.14.2). Strong match → emit the invocation hint. Weak/ambiguous → confirm via a yes/no question +/status blocked. - No capability match → check
## Team Roster. If a teammate clearly fits →/assign <agent>. - No match anywhere → elicit. Ask the smallest clarifying
question in a comment,
/status blocked, and stop. - Subsequent heartbeats (user replied) → re-run 1–3 with the new context now visible in the conversation history (v2.13.4 timeline renders the reply).
Locked-in anti-rules: never /release on no-match, never fabricate
a capability, never stack more than one question per comment.
Added¶
prompts/triage/Triage_Officer.md— canonical, source- controlled copy of the new prompt. Same pattern asprompts/council/from v2.13.7.prompts/triage/README.md— documents the decision tree, the anti-rules, the provenance from v1 → v2.14.3, and how to apply on a fresh instance.
How the elicitation loop actually works (no engine changes needed)¶
The clever bit is that nothing new in the engine was required — the loop is built entirely from primitives that already exist:
- Triage Officer (LLM) decides no match → posts a comment via its
normal output → emits
/status blocked. - Engine processes the
/status blockedaction → issue moves to blocked. The scheduler doesn't pick blocked issues, so Triage doesn't keep firing. - User reads the comment in the UI, replies via the existing comment form.
UIIssueCommenthandler callstriggerAgentForIssueafter creating the comment (existing behaviour — no change needed).- Triage Officer wakes up. Its prompt now contains the user's reply in the v2.13.4 conversation-history timeline.
- Triage re-applies the decision tree with the new info.
The watchdog's long_blocked heuristic doesn't fire until 2 hours
of silence, which gives the user plenty of time to reply. If they
genuinely abandon the conversation, the watchdog correctly flags it.
What this release does NOT yet do¶
- Install new capabilities. Triage can now ASK for clarification
but can't act on the answer beyond
/assign-ing an existing agent. The/install-memory,/install-routine,/install-skill,/install-agent,/install-federationaction verbs land in v2.14.4–v2.14.8. Until then, Triage's elicitation conversations end with either a routing decision or an honest "I'd need a new capability for this; please configure one." - Confirm matches via a button instead of a yes/no comment. The
comment-thread pattern is what we agreed to for v2.14.x; this is
intentional, not a gap. See
docs/ROADMAP.mdunder "Locked design decisions."
Notes for operators¶
- No DB migration. Just an UPDATE to the Triage Officer's
agents.system_prompt. Already applied to node4. - No code change. This release is entirely a prompt update + a canonical source-controlled copy + documentation.
- The change takes effect immediately on the next Triage Officer heartbeat post-deploy; no restart needed (prompts are loaded per- heartbeat).
v2.14.2 — 2026-05-15¶
Third slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
Triage Officer reads from the Capability Registry. Routing-role
agents now see a ## Capabilities Available section in their
heartbeat prompt alongside the existing ## Team Roster, listing
every enabled capability in the company and how to invoke it.
Added¶
engine.CapabilityContext— prompt-side shape of a registry row:Name,TriggerDescription,PrimitiveKind,InvokeHint. TheInvokeHintis pre-rendered at load time from the capability'sexecution_descriptorso the template doesn't need substrate- specific logic.AgentContext.Capabilities []CapabilityContext— new field populated by the engine for routing agents (triage role); nil for other agents so the prompt template omits the section.Engine.loadCapabilitiesFor(ctx, agent)— engine helper that loads the company's enabled capabilities. Returns nil for non-routing agents. On the first call for a company with zero capabilities, auto-invokesBootstrapCapabilitiesto seed the registry from existing routines and non-orchestrator agents; safe because the bootstrap is idempotent and points to substrates the operator already created.renderCapabilityInvokeHint(c)— per-primitive-kind formatter:agent→`/assign <agent_name>`routine→ "(future)/fire-routine <name>…" (not yet wired)skill→ "(future)/use-skill <slug>…" (not yet wired)federation→ "(future) federate to" (not yet wired) memory→ "lookup viapara-memory-filesskill —qmd query …"
"(future)" labels are deliberate — the install actions land in
v2.14.4–8. For now the registry surface helps Triage make smarter
decisions (e.g. "I should /assign Council Head for research") even
before every substrate is directly fireable.
Prompt rendering¶
The new section renders between ## Team Roster and ## Your
Assignment:
## Capabilities Available
These are the things this company knows how to do — match the issue
against this list. If a capability matches, use its invocation hint
(`→` field). If NONE of these match, do **not** `/release` — instead,
comment on the issue asking for the smallest clarification you need.
- **research-via-council** (kind: agent) — Research or investigate a
topic that benefits from multiple perspectives... → `/assign Council Head`
- **agent-frontend-reviewer** (kind: agent) — Frontend code review
tasks... → `/assign Frontend Reviewer`
- ...
The "do not /release" instruction is a load-bearing hint for the
elicitation loop (Phase 3, v2.14.3): when the agent sees no match, it
should ask the user a clarifying question instead of dropping the
issue.
Tests¶
TestBuildHeartbeatPrompt_Capabilities— fixture-driven render test; locks in the name / kind / trigger / invoke-hint format and the "do not /release" hint.TestBuildHeartbeatPrompt_NoCapabilities_NoSection— section is omitted for bothniland[]CapabilityContext{}(matches the Roster/ChildIssues pattern).TestWatchdog_IdleAssignedwas made resilient to test-suite contamination by picking an active+enabled agent rather thanagents[0]blindly. Pre-existing test, polished alongside this work.
Auto-bootstrap behavior¶
On the very first heartbeat where a routing agent renders its
capabilities and the registry is empty for the company,
BootstrapCapabilities runs once and seeds:
- One
research-via-councilcapability (covers the Council multi-lens workflow). - One
routine-<slug>capability per routine in the company. - One
agent-<slug>capability per non-orchestrator agent (skips Council Head, Leads, lens researchers, and Triage Officer itself).
Subsequent heartbeats see a non-empty registry and skip the
bootstrap path entirely. To start over, delete every row with
source = 'bootstrap' and the next heartbeat will re-seed.
Notes for operators¶
- No new DB migration. Re-uses the
capabilitiestable from v2.14.1. - Triage Officer prompts will change shape on the first
heartbeat after redeploy: a new
## Capabilities Availablesection appears between the roster and the assignment header. Other agents' prompts are unchanged. - The Triage Officer agent itself does not need a prompt rewrite to take advantage of this — the rendered capability list is conversational context that the LLM picks up naturally. (A future release may sharpen the Triage prompt to explicitly reference the capabilities section; for now the existing routing logic plus the new context should work.)
v2.14.1 — 2026-05-15¶
Second slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
the Capability Registry foundation. A new capabilities table +
Go query API + bootstrap helper. Nothing user-visible yet — this is
the data layer that Phase 2 (Triage Officer reads from the registry)
will consume.
Added¶
migrations/062_create_capabilities.sql— newcapabilitiestable, company-scoped (FK tocompanieswithON DELETE CASCADE). Five core columns + provenance:name(unique per company, slug-stable)trigger_description(natural language, renders into agent prompts)primitive_kind— enum:memory | routine | skill | agent | federation(CHECK-constrained)execution_descriptor(JSONB substrate-specific pointer)notes(optional longer-form rationale)source— enum:bootstrap | elicitation | manual | federation_handshakecreated_via_issue_id,created_by_user_id,created_by_agent_id(nullable FKs withON DELETE SET NULL)enabled(boolean, default true — prefer disabling over deleting in production) Five supporting indexes (company, company+kind, company+name, via_issue, enabled-partial).internal/db/query/capabilities.go— full CRUD API plus aBootstrapCapabilities(ctx, pool, companyID)helper that seeds the registry from existing primitives:- One
research-via-councilcapability pointing at the Council Head agent (covers the multi-lens triangulation workflow as a single ask). - One capability per non-orchestrator routine in the company.
- One capability per non-orchestrator agent (skips Council Head,
Leads, lens researchers, and Triage Officer — those are covered
by
research-via-councilor are the dispatcher itself). Idempotent — re-running inserts 0 rows for capabilities whose names already exist. - Exported constants:
PrimitiveMemory,PrimitiveRoutine,PrimitiveSkill,PrimitiveAgent,PrimitiveFederationandSourceBootstrap,SourceElicitation,SourceManual,SourceFederationHandshake. Use these instead of magic strings; the values are persisted and CHECK-constrained.
Tests¶
TestCreateCapability_HappyPath— round-trip insert with all the default-fill paths working.TestCreateCapability_UniqueNamePerCompany— second insert with the same (company_id, name) returns a unique-violation error so callers can branch on it.TestListCapabilities_FiltersByCompanyAndKind—OnlyEnabledandOnlyKindfilters each work in isolation; disabled rows don't leak into enabled lists.TestUpdateCapability_Partial— only the fields the caller passes are updated;updated_atadvances.TestDeleteCapability_NotFound— missing id returnspgx.ErrNoRowsso handlers can map cleanly to 404.TestBootstrapCapabilities_Idempotent— second invocation inserts zero rows.TestSlugify— table-driven; sanity-checks that slugs never trail in a dash.
Notes for operators¶
- One new DB migration to apply.
goose up(or however your deployment runs migrations) — the file is atinternal/db/migrations/062_create_capabilities.sql. IdempotentCREATE IF NOT EXISTS. - No agent prompt changes yet, no UI changes. This release is the data layer only. v2.14.2 wires it into Triage Officer's prompt.
- Bootstrap is operator-initiated. No code path auto-calls
BootstrapCapabilitieson startup yet — that comes in v2.14.2 alongside the Triage prompt extension. For now, operators can call it manually via a one-shot or from an admin UI (TBD).
v2.14.0 — 2026-05-15¶
First slice of the "Learning Hub" roadmap (see docs/ROADMAP.md):
a Progress Watchdog subsystem that detects stuck issues and
makes the stuck-ness visible to both agents and humans. Day-1 is
observation only — no auto-corrections — so we trust the
heuristics before automating any response.
Added¶
internal/worker/progress_watchdog.go— new background worker that runs alongsideHeartbeatTimer. Default tick interval is 60 s. Five heuristics, each one a separate SQL scan:
| Heuristic | Triggers when |
|---|---|
idle_assigned |
Issue is in_progress + assigned, but the assignee's last heartbeat against this issue was longer than max(10 min, 5 × heartbeat_interval_sec) ago. Suggests scheduler missed it or agent stopped firing. |
actionless_loop |
Last 5 consecutive completed heartbeats for an in_progress issue emitted no actions (or only [release] / "no actionable work"). The agent is firing but doesn't know what to do. Conservative threshold per 2026-05-15 architecture discussion. |
long_blocked |
Issue is blocked with updated_at > 2 hours ago AND no comment activity since. Agent self-blocked and no one followed up. |
orphaned_in_review |
Issue is in_review (via the v2.13.5 issue.blocked_on_children marker), open children exist, but NONE of those children have had a heartbeat in the soak window. The auto-unblock chain cannot fire on its own. |
approval_orphan |
Approval is pending for > 4 hours with no comment activity. Surfaces on the approval's linked issue (skipped if no linked issue). |
When a heuristic fires the watchdog:
1. Posts a comment on the issue: "Watchdog flag: issue.stuck_detected activity_log entry with the
heuristic name + structured details in the payload. This entry
renders in the agent's next heartbeat via the v2.13.4 timeline
and in the parent's next heartbeat via the v2.13.6 child
summaries — so the signal lives where the agent is already
looking.
3. Publishes an issue_updated SSE event so UI watchers refresh.
-
Idempotency — each heuristic flag is suppressed if the same
(issue_id, heuristic)was flagged within the last 30 minutes (silentWindow). Stops the watchdog from spamming the same stuck issue on every tick while still re-surfacing the signal if the problem persists for hours. -
Wired into
cmd/server/main.go—go pw.Start(ctx)next to the HeartbeatTimer. No configuration knobs yet; thresholds are package consts and tunable struct fields (tests use 1-second thresholds to drive single ticks deterministically).
Constant export¶
worker.StuckDetectedKind = "issue.stuck_detected"— exported so the prompt rendering / UI / activity feed renderers can match on a typed constant rather than a magic string.
Tests¶
TestWatchdog_Idempotency— second flag insilentWindowis a NO-OP (no extra comment, no extra activity row).TestWatchdog_IdleAssigned— agent never heartbeated against an in_progress issue → flag fires.TestWatchdog_LongBlocked— blocked issue with backdatedupdated_at→ flag fires.TestWatchdog_LongBlocked_RecentCommentSilences— same blocked issue but with a follow-up comment → flag is suppressed.TestWatchdog_Tick_NoPanic_OnEmptyDB— every heuristic returns gracefully on an empty DB.
Notes for operators¶
- No DB migration. Uses existing tables (
issues,heartbeat_runs,issue_comments,activity_log,approvals,issue_approvals). - Watchdog runs in the server process, not the worker process,
matching where
HeartbeatTimerlives. If you split server/worker deployment in the future, the watchdog should follow the HeartbeatTimer. - Cost = negligible. Each tick is 5 small SELECT queries. No LLM calls. No external HTTP.
- Auto-corrections (auto-escalate, auto-cancel, auto-retrigger)
are explicitly deferred. See
docs/ROADMAP.mdfor the deferred backlog.
See also¶
docs/ROADMAP.md— overall Learning Hub roadmap + locked architectural decisions + deferred items (auto-corrections, richer approvals, multi-channel inbound).
v2.13.9 — 2026-05-14¶
Delete approvals — single + bulk — with a safety rule that pending approvals can never be deleted, only resolved.
Fixed¶
- Approvals accumulated forever once resolved. There was no way to
clean up
approved/rejectedrows from the UI or the API. They stayed in the database, on the index list, in counts, and in the history view. This release adds delete paths on both surfaces, with the same two-way safety check on both: - Single-row delete refuses with HTTP 409 when the approval's status
is
pending("resolve it first"). - Bulk delete only ever touches
approved+rejectedrows;pendingis never in scope.
Added¶
query.DeleteApproval(ctx, pool, id)— single-row delete. FK CASCADE handlesapproval_commentsandissue_approvals; linked issues themselves are preserved (only the link rows go away). Returnspgx.ErrNoRowson missing id.query.DeleteResolvedApprovalsByCompany(ctx, pool, companyID)— bulk delete. Returns the count of rows removed.DELETE /api/approvals/{approvalId}— JSON API single delete. Returns 204 on success, 409 if pending, 404 on missing or cross- company access (existence not leaked).DELETE /api/companies/{companyId}/approvals/resolved— JSON API bulk delete. Returns{"deleted": N}.POST /approvals/{approvalId}/ui/delete— UI HTMX path used by:- the per-row × button in the approvals table (only rendered for resolved rows)
- the "Delete" button in the approval-detail properties card
(only rendered when not pending)
On success, sets
HX-Redirectback to the company's approvals page. POST /companies/{companyId}/approvals/ui/delete-resolved— bulk-delete UI action, hung off a new "Delete all resolved" button at the top of the approvals page. Returns the freshly-rendered table partial so HTMX swaps it inline; status-filter tab selection is preserved across the swap. Fires a toast on success showing the delete count.authorizeCompanyMembershiphelper — mirrorsauthorizeApprovalAccessfor cases where the action is scoped to a company id rather than a single approval row (the bulk endpoint).
Auth + safety rules¶
- Agents cannot delete approvals. Same rule as
/ui/resolve— approvals are a human-in-the-loop construct; only users can resolve or remove them. - Pending approvals are protected. Both the row endpoint (409 Conflict if pending) and the bulk endpoint (status filter excludes pending) refuse to remove an in-flight approval. Resolution is the only path to deletion.
- Cross-company access returns 404 in both single and bulk paths,
to avoid leaking existence (same pattern as
authorizeApprovalAccess). - SSE notification — both single and bulk emit
approval_resolvedso the existing badge / list refresh path picks them up without needing a new event kind.
Tests¶
TestDeleteApproval_HappyPath— create + resolve + delete an approval; verify the row and its comment are both gone (cascade).TestDeleteApproval_NotFound— deleting a missing id returns thepgx.ErrNoRowssentinel so handlers can map to 404.TestDeleteResolvedApprovalsByCompany— 3-approval scenario (one approved, one rejected, one pending); only the resolved two are removed.
Notes for operators¶
- No DB migration. The existing FK CASCADE rules from migrations 018 and 019 do the cleanup work; the new functions are pure deletes.
- For automation / scripting, the JSON API endpoints (single + bulk) are the cleanest entry points; the UI POST endpoints are HTMX- specific and return rendered HTML.
v2.13.8 — 2026-05-14¶
Locks in the canonical Council agent adapter configuration as reference data in source control. No code change.
Added¶
prompts/council/agents.json— JSON snapshot of all 21 Council agents (Council Head + 5 Leads + 5×3 researchers) with their canonical adapter setup:adapter_type,provider_label,model, fulladapter_configJSONB,reports_to_name, and scheduling parameters. Provider IDs are intentionally not captured — they are instance-local UUIDs; appliers look up the matchingcompany_providersrow bylabelat apply time.
The canonical mapping at the time of capture:
| Tier | Agents | Adapter | Provider | Model |
|---|---|---|---|---|
| Orchestrator | Council Head + 5 Leads | llm |
Anthropic | claude-opus-4-5 |
| Researcher (Claude line) | 5 <Lens>-Claude |
llm |
Anthropic | claude-sonnet-4-5 |
| Researcher (Codex line) | 5 <Lens>-Codex |
llm |
OpenAI | gpt-5 |
| Researcher (Perplexity line) | 5 <Lens>-Perplexity |
llm |
Perplexity | sonar-deep-research |
This is the configuration that produced the first fully-hands-off Council Test convergence on 2026-05-14 — all 3 provider lines active, 0 manual interventions, $1.31 total cost, full multi-lens synthesis.
Updated¶
prompts/council/README.md— documents the newagents.jsonfile, explains the rationale for every Council agent being on thellmadapter (the v2.13.4 and v2.13.6 architectural fixes arellm-adapter-specific), captures the explicit trade-off (training- data-only Claude/Codex researchers, mitigated by Perplexity's live web search in the 3rd researcher of every lens), and adds a validation-runs table comparing v5 prompts (10/15 researchers, Codex paused) to v2.13.8 (15/15 researchers, fully hands-off).
Notes for operators¶
- No DB migration. Reference data only; nothing is auto-applied on
startup. To apply to a fresh instance: read
agents.json, look up eachprovider_labelagainstcompany_providers.label, andUPDATEthe matchingagentsrow. - The 16 Council agents on
llmadapter are deliberate — reverting any of them toclaude_local/codex_localwould silently re-introduce the duplicate-spawn cascade (v2.13.4) and the parents-blind-to-children gap (v2.13.6). See the README's "Trade-off" section before considering a revert.
v2.13.7 — 2026-05-14¶
Three small fixes from forensic observations of the v2.13.6 cascade test, plus the canonical Council prompts now live in source control. No schema migration.
Fixed¶
-
triggerAssignedAgentsnow respects the pause switch. v2.13.5 added the pause check to the handler-sidetriggerAgentForIssue, but the engine-sidetriggerAssignedAgents(called post-heartbeat to wake other agents that were assigned new work) only checkedStatus != "active". Agents in the canonical-but-not-quite-perfect statestatus=active+paused_at IS NOT NULL+heartbeat_enabled = false(the pattern produced by manual SQL pause operations) would still be triggered. On node4 with 5 paused Codex agents this produced 33 wasted heartbeats per test (each one a 200 ms HTTP 429 fast-fail against OpenAI — $0 cost but real run-row noise + DB writes). The engine path now adds&& ag.HeartbeatEnabledto the skip predicate. -
/status blockednow counts as a terminal-for-unblock signal. Pre-fix,MaybeUnblockParentFromChildrenonly treateddoneandcancelledas terminal. A child that emitted/status blocked(e.g. Critical Lead in a State 1 routing deviation self-blocking its own researcher slot) would pin its parent inin_reviewindefinitely — the parent's auto-unblock chain never fired because the blocked child was counted as "still open". The reasoning: ablockedchild cannot make further progress without external intervention, so the parent should be allowed to synthesise from whatever children did finish and surface the blocked one as a known gap. Five sites updated:query.terminalIssueStatusesconstantMaybeBlockParentOnChildrenSQLNOT IN (...)MaybeUnblockParentFromChildrenSQLNOT IN (...)engine.adapter.go/statusaction handler (which callsMaybeUnblockParentFromChildrenon terminal transitions)handler.UpdateIssue+handler.UIIssueUpdate+handler.UIIssueStatusInline(same hook from the API + UI paths)
-
Council Head State 4 emits
/status doneexplicitly. Previously the prompt told Council Head to "Then/assign <CEO's name>to hand the source back. Done." The model interpreted this as "/assign is the closure" and didn't emit a separate/status done. The result: Council Test reacheddoneonly because the CEO's heartbeat fired and closed the issue. If the CEO had been paused or otherwise unavailable, Council Test would have stayedin_progressindefinitely. New State 4 prescribes BOTH verbs in order:/status donefirst (the verb that actually closes), then/assign <CEO's name>(hand-off ceremony). Explicit STRICT anti-rule against the/assign-onlypattern.
Added¶
prompts/council/— canonical Council agent prompts now live in source control. All 6 system prompts (Council Head+ 5 Leads) are committed as markdown files, with a README documenting the v1 → v6 iteration history, what each anti-rule defends against, and how to apply them to a fresh instance. Before this, the prompts existed only asagents.system_promptrows on a single node4 DB — vulnerable to reseeds, restores, or accidental edits. The repo is now the source of truth.
Tests¶
TestMaybeUnblockParentFromChildren_BlockedIsTerminal— verifies the new behaviour: parent with onedonechild + oneblockedchild auto-unblocks. The four pre-existing parent block/unblock tests still pass without modification.- Full test suite (
go test -count=1 -race ./...) green on all 18 packages.
Notes for operators¶
- No DB migration. The status value
blockedalready exists inValidIssueStatuses; this change only affects two callers that enumerate the terminal set. - The prompts/council files are reference, not auto-applied.
Applying them to a non-fresh instance overwrites existing prompts;
use the same anchor-based update pattern as
/tmp/staple-prompt-fix-v*if you need to migrate forward without clobbering operator edits.
v2.13.6 — 2026-05-13¶
Closes the "parents can't see children" architectural gap on the
single-turn llm adapter. No schema change, no API break.
Fixed¶
- Orchestrators (Council Head, Leads, any parent issue) now see their
children's status + last comment inside their prompt. Pre-fix, the
llmadapter built the parent's prompt from comments + activity_log on the parent's own issue. The children were invisible — a Lead waiting on 3 researcher children had no way to read what those researchers wrote. Forensic evidence from the 2026-05-13 node4 test: Synthesist Lead correctly detected the timeout and tried to synthesize per State 5, but reported "0 of 3 researchers returned" even though 2 of its 3 researchers had actually completed successfully — the Lead literally couldn't see their findings.
The fix renders a new ## Child Issues section in the prompt under
Conversation History, listing each direct child's status, assignee,
and most recent comment (truncated to 600 chars). Now a Lead's
synthesis heartbeat actually has the researcher findings in front of
it. The section is only rendered when the issue has children — leaf
tasks (researcher issues, etc.) see no extra noise.
This was the deeper architectural finding under the v2.13.4 + v2.13.5 fixes: stopping duplicate spawns and idle polling was necessary but not sufficient. Parents need to see their children to synthesize.
Added¶
query.ChildIssueSummary+query.ListChildrenForIssue— returns each child of a given parentID with id, title, status, assignee_name (resolved via JOIN), and most recent comment (body, timestamp, author). One round-trip via a LATERAL join, oldest- first ordering.engine.ChildIssueContext— prompt-side shape for a single child. Optional fields render cleanly when empty (assignee unknown, no comment, etc.).engine.BuildHeartbeatPromptsignature gainedchildren []ChildIssueContextbetweenactivitiesandwakeCtx. Internal-only signature change — only callers are insideinternal/engine/. Tests updated to passnilwhere children aren't under test.engine.Engine.loadChildIssues— mirror of the existingloadComments/loadActivityLoghelpers.truncateForChildSummary— collapses newlines and caps comment bodies at 600 chars with a… (truncated, N total chars)marker so the agent knows there's more.
Render format¶
A Lead's view of its researchers after this change:
## Child Issues
Direct children of this issue. Status and the most recent comment on
each. Use this to decide whether children are still working,
finished, or stuck.
- **[done]** [Empirical/research] Benchmarks — Perplexity pass — _Empirical-Perplexity_
Last comment from Empirical-Perplexity (2026-05-14 05:15):
> Findings: Hermes 3 70B ranks 12th on FunctionBench; OpenFang has 45k GitHub stars...
- **[cancelled]** [Empirical/research] Benchmarks — Codex pass — _Empirical-Codex_
- **[done]** [Empirical/research] Benchmarks — Claude pass — _Empirical-Claude_
Last comment from Empirical-Claude (2026-05-14 05:12):
> Findings: Independent eval suite shows OpenFang adoption clustered...
Tests¶
TestListChildrenForIssue— parent with 2 children; verifies newest comment wins, assignee resolves, and a no-comment child still appears with empty fields.TestListChildrenForIssue_NoChildren— empty non-nil slice.TestBuildHeartbeatPrompt_ChildIssues— verifies the rendered section includes status, assignee, and comment body for each child; the cancelled child (no comment) renders without aLast comment fromline.TestBuildHeartbeatPrompt_ChildIssues_Truncation— oversized comment body is truncated with thetruncated, N total charsmarker.TestBuildHeartbeatPrompt_NoChildIssues_NoSection— section is omitted entirely whenchildrenis nil or empty.
Notes for operators¶
- No DB migration. Uses existing
issues+issue_commentstables via a LATERAL-join query. - The
claude_localandcodex_localadapters still build their own prompts insideinternal/engine/adapters/*and do not receive child summaries. Those adapters have tool calls so they can read children out-of-band — a parallel fix would be valuable for parity, but it's not blocking single-turnllm-adapter cascades anymore.
v2.13.5 — 2026-05-13¶
Stops orchestrators from idle-polling Opus every 60 s while they wait for children; closes off auto-triggering for paused agents. No schema change, no API break.
Fixed¶
- Orchestrators no longer idle-poll while waiting on children. After v2.13.4 stopped the duplicate-spawn cascade, the surviving problem was cost: Council Head and the 5 Leads kept getting picked up by the scheduler every ~60 s and would re-run on Opus just to see "all my children are still in_progress, nothing to do". Forensic evidence from the 2026-05-13 node4 test:
Council Head: 34 heartbeats / 5 children created
Practical Lead: 32 heartbeats / 3 children created
Critical Lead: 19 heartbeats / 3 children created
Each idle heartbeat was ~$0.02 of Opus. Per test that's ~$5+ of
pure waiting cost. The fix: when a heartbeat emits /create-issue
actions and the parent has non-terminal children, the engine now
auto-transitions the parent to in_review and writes an
issue.blocked_on_children activity marker. The scheduler does
not pick up in_review issues, so the orchestrator stops polling.
-
Parent auto-unblocks when every child reaches a terminal status. Mirror behaviour: when a child transitions to
done(orcancelled), if every sibling is also terminal AND the parent's most recent marker is the engine-set block marker (i.e., no human override has happened since), the parent is moved back toin_progressand its assignee is auto-triggered for a fresh heartbeat — that's where the synthesis happens. -
triggerAgentForIssuenow skips paused agents. Pre-fix, the issue-creation auto-trigger path queued a heartbeat run for the assigned agent regardless of pause state. On node4 this caused 22 failed OpenAI runs (HTTP 429) over 5 minutes for the five intentionally-paused-Codexresearchers; $0 in adapter cost but noise in the run log and broken expectations about what "paused" means. The function now reads the agent, short-circuits ifHeartbeatEnabled = falseorStatus = "paused", and logs aauto-trigger skipped: agent is pausedinfo line.
Added¶
query.MaybeBlockParentOnChildren(ctx, pool, parentID, agentID, runID)— transitions parent fromin_progresstoin_reviewwhen it has at least one open child, writes anissue.blocked_on_childrenactivity marker, and returns(blocked bool, err).query.MaybeUnblockParentFromChildren(ctx, pool, childIssueID)— mirror unblock function. Returns the un-blocked parent issue (so callers can trigger its assignee), or nil when no action is appropriate. Respects human overrides: if the parent's most recent marker is a regularstatus_change(not the engine block marker), the parent is left alone.engine.Engine.triggerIssueAssignee— engine-side equivalent ofhandler.triggerAgentForIssue, used by the auto-unblock path to wake the parent's assignee. Honours the same pause check as the handler-side trigger.- Constant
query.ParentBlockMarkerKind("issue.blocked_on_children") — the canonical activity kind for the engine-set block marker.
Hooks added¶
internal/engine/adapter.go:- After
/status doneor/status cancelledaction, callsMaybeUnblockParentFromChildrenand triggers the parent's assignee if the parent un-blocks. - After the action loop, if
createdIssueCount > 0, callsMaybeBlockParentOnChildrenon the current issue. internal/handler/issues.goUpdateIssue(PATCH API):- After a terminal status update, calls
MaybeUnblockParentFromChildren+triggerAgentForIssue. internal/handler/ui_issues.goUIIssueUpdateandUIIssueStatusInline:- Same auto-unblock-on-terminal logic.
Tests¶
TestMaybeBlockParentOnChildren_HappyPath— verifies the in_progress → in_review transition AND that theissue.blocked_on_childrenactivity marker lands.TestMaybeBlockParentOnChildren_NoOpWhenAllChildrenTerminal— parent has no open children, no transition.TestMaybeBlockParentOnChildren_NoOpWhenAlreadyInReview— parent is already in_review, no double-block.TestMaybeUnblockParentFromChildren_HappyPath— child transitions to done, parent un-blocks back to in_progress.TestMaybeUnblockParentFromChildren_RespectsHumanOverride— if a human-writtenstatus_changeactivity is the most recent marker, the auto-unblock does not override it.
Notes for operators¶
- No DB migration. Uses existing
statusfield and existingactivity_logtable with a newkindvalue (issue.blocked_on_children). - v2.13.4's in-process prompt builder (BuildHeartbeatPrompt) already renders activity_log entries — the new marker shows up in the orchestrator's timeline as a low-noise italic line, providing visibility from inside the prompt as well.
- Existing pre-v2.13.5 issues in
in_revieware not affected. The auto-unblock logic only fires when the parent's most-recent marker is the engine block marker.
v2.13.4 — 2026-05-13¶
Bug fix: agents no longer have amnesia about their own past actions. No schema change, no API break.
Fixed¶
- Heartbeat prompts now include
activity_logentries, not just comments. The in-processllm-adapter prompt builder (internal/engine/prompt.go:BuildHeartbeatPrompt) used to render onlyissue_commentsin the conversation history section. Activity entries likeissue.child_created,issue.assignee_changed,issue.assignee_unresolved,status_change,approval_requested, andapproval_resolvedlived inactivity_logand were invisible to the agent on subsequent heartbeats. Result: an orchestrator like Council Head would see "Council Test, no prior activity" on every heartbeat, re-emit/create-issue× 5, and the cascade would duplicate on every tick. Forensic data from node4 (2026-05-13): Council Head heartbeated 5 times and created 10 child Lead issues; Practical Lead heartbeated 6 times and created 12 child researcher issues (vs expected 5 and 3 respectively). This was also the root cause of the previous test's "57 heartbeats per researcher" pattern — researchers had no memory of work they had done.
The fix interleaves comments and activities by created_at into a
single chronological timeline rendered under
## Conversation History. Activity entries appear as compact one-
line italic items (_(2026-05-14 04:35) **Council Head** spawned
child issue: [COUNCIL/empirical] What benchmarks show? → Empirical
Lead_). This matches the contract already documented in
remote_prompt.go for the remote-worker prompt path — the
in-process path now has parity.
Added¶
-
query.ListActivityForIssue(ctx, pool, issueID)returns everyactivity_logrow for a single issue, ordered oldest-first. Used by the newEngine.loadActivityLoghelper to populate the prompt timeline. Per-agent name resolution is cached in-call so a 20-entry timeline with 5 distinct agents costs at most 5GetAgentByIDcalls. -
engine.ActivityContextstruct carryingKind,AgentName,CreatedAt, and the raw JSONPayloadfor use by the prompt renderer. -
engine.BuildHeartbeatPromptsignature gained anactivities []ActivityContextparameter (betweencommentsandwakeCtx). Pre-existing call sites inengine_provider.goandadapter_llm.gowere updated to load activities; tests were updated to passnilwhere activities are not the subject of the test. This is a breaking change to an internal-only exported helper — external code does not call this function directly. -
engine.renderActivityLinemaps each supportedactivity_log.kindto a compact human-readable line. Supported kinds today:issue.child_created,issue.assignee_unresolved,issue.assignee_changed,status_change,approval_requested,approval_resolved. Unknown / low-signal kinds (issue.updated,issue_created) are intentionally skipped — adding new kinds is a one-linecasein the switch.
Tests¶
TestBuildHeartbeatPrompt_ActivityTimeline— regression test that the four key activity kinds (issue.child_created,issue.assignee_unresolved,status_change,issue.assignee_changed) render into the prompt with the agent-readable text the engine expects.TestBuildHeartbeatPrompt_TimelineMerge— verifies chronological ordering when comments and activities are interleaved byCreatedAt.TestBuildHeartbeatPrompt_NoActivities—niland empty activity slices both render cleanly (back-compat with callers that don't load activities yet).
Notes for operators¶
- No database migration. The
activity_logtable is unchanged; the fix is a pure query-and-render addition. - No prompt-window management for activities yet. If timelines grow
past a reasonable token count, this becomes the next thing to tune
(parallel to
windowComments). - The
claude_localandcodex_localadapters take a different code path (internal/engine/adapters/claude_local.gobuilds its own prompt) and do not yet receive activities. That is a follow-up bug fix — the Anthropic / OpenAI / Perplexity providers (anything on thellmadapter) now have correct continuity.
v2.13.3 — 2026-05-13¶
Bug fix + activity-log visibility additions. No schema change, no API
breakage — only one new InsertActivity kind and one tightened lookup
function.
Fixed¶
query.GetAgentByNameno longer drops hyphens before lookup. The previous implementation unconditionally replaced-withand matched only on the normalized form. That made every agent with a hyphenated literal name (e.g., the 15 Council Researchers named<Lens>-<Provider>—Empirical-Claude,Empirical-Codex,Empirical-Perplexity, etc.) unresolvable from any/assign,assignee:hint,@mention, or UI assign-by-name path. The function now tries the literal name first and only falls back to the slug-normalized shape (hyphens → spaces) when the literal misses. Both lookups are case-insensitive, and when both shapes exist the literal match wins. Root cause of the Council Test failure where every Lead'screate_issuesucceeded but every child landed unassigned because the assignee name (a hyphenated literal) silently failed resolution.
Added¶
issue.assignee_unresolvedactivity entry. When acreate_issuehint (assignee: <name>) or an/assign <name>action references a name that doesn't resolve to an agent, the engine now (a) posts a visible comment on the affected issue explaining what was attempted and that triage will route it, and (b) inserts a dedicatedissue.assignee_unresolvedactivity entry on the issue's timeline. Without this, the failed routing intent used to disappear intoslog.Warnlines that no operator ever sees.issue.assignee_changedactivity entry. When the assignee actually changes (engine/assign,PATCH /api/issues/{id}, or the UI properties panel), a dedicated activity entry is now inserted withold_assignee_agent_id,new_assignee_agent_id, and aviatag identifying the path (assign_action,api_patch,ui_update). No-op assignments (re-issuing the same assignee) are intentionally filtered out so the timeline doesn't get polluted.issue.child_createdpayload enrichment. Whencreate_issuecarries anassignee:hint, the parent'sissue.child_createdactivity entry now also recordsassignee_attempted(the raw name the agent supplied),assignee_resolved(bool), and — on success —assignee_agent_id. This makes it possible to reconstruct from the timeline alone whether a child was created with the intended owner.
Tests¶
- New table-driven
TestGetAgentByName_HyphenatedLiteralininternal/db/query/query_extended_test.gocovers five cases: hyphenated literal exact match, hyphenated mixed-case literal, spaced literal exact match, slug fallback (hyphenated input → spaced name), and not-found.
Notes for operators¶
- No database migration is required. The new activity kinds
(
issue.assignee_unresolved,issue.assignee_changed) are stored in the existingactivitiestable; UIs that filter onkindwill simply ignore them until updated. - The fix is back-compatible. Existing slug-normalized agent names
(e.g., names containing spaces that operators write as hyphens in
@mentions) continue to resolve via the fallback path.
v2.13.2 — 2026-05-13¶
Skill content fix for para-memory-files. No code change.
Fixed¶
- The shipped
para-memory-filesskill (internal/skills/data/para-memory-files/SKILL.md, also seeded into theinstance_skillstable) now explicitly documents three things that were previously implicit and routinely tripped up agents: - First-time bootstrap. An idempotent
mkdir -p+ heredoc snippet agents run at the start of any heartbeat where they'll touch memory. Costs nothing when the tree already exists. - Create before referencing. Strict rule that any path mentioned
under
life/<entity>/...(inMEMORY.md, a daily note, or anotheritems.yaml) MUST have the entity folder +summary.md+items.yamlcreated in the same heartbeat. Observed failure mode: an agent wrote a reference tolife/resources/<topic>/summary.mdwithout ever creating the folder, leaving a dangling link thatqmd querycan't follow. qmd index $STAPLE_AGENT_HOMEis mandatory after any write, not just batches. Without it,qmd querywon't surface what the agent just wrote — silent recall degradation.- The same three sections were folded into the per-agent prompt
customisations for the 6 agents whose system_prompt was updated to
use the skill in the previous session (
CEO,CTO,Documentation Agent,Project Manager,Product Researcher,Council Head). The write-trigger table gained a "Before any write, bootstrap" row; the "after writes" line was sharpened from "after batches" to "after ANY write"; and a standalone "Create before referencing — STRICT" reminder was added.
Notes for operators¶
- The skill change is content-only; no SQL migration, no API change, no schema delta. Existing data is unaffected.
- The per-agent
system_promptupdates were applied directly to the running instance'sagentstable (out-of-band; not in this commit). A backup of the priorinstance_skills.definitionand the 6 pre-editsystem_promptvalues is at/tmp/staple-skill-fix/backup.jsonon the host where the change ran. - Next server restart will see the embedded SKILL.md and the
instance_skills.definitionalready in sync (content-hash-skip applies), so no re-seed surprise.
v2.13.1 — 2026-05-13¶
Bug fix only. No API changes, no schema changes, no new dependencies.
Fixed¶
Engine.findWorkno longer short-circuits triage-role agents past their own assigned bucket. Previously, an issue explicitly assigned to a triage agent (via a routine, a manual assignment, or/assignfrom another agent) was invisible to that agent — the function jumped straight to the unassigned-actionable routing path and returned, never querying the agent's own bucket. Triage agents now check directly-assigned issues first and fall back to the unassigned bucket only when they have nothing of their own to do, matching the priority order every other role has always had. Non-triage agents are unaffected — their code path is byte-for-byte identical to before. Two regression tests (TestFindWork_TriageAgentFindsAssignedIssueandTestFindWork_TriageAgentFallsBackToUnassigned) lock both branches in.
Docs¶
- Clarified the docstring on
isRoutingRoleininternal/engine/engine.goso it accurately describes the new "assigned first, unassigned as fallback" scope for routing-role agents, instead of the old "company-wide unassigned, rather than the assigned bucket" wording that implied the (now-fixed) bug was the intended design.
v2.13.0 — 2026-05-12¶
/create-issue can now carry a body. Before this release the verb
could pass a title and the v2.8.0 continuation hints (assignee,
blocked-by, blocks, relates) but had no way to set the new issue's
body_markdown — every spawned child landed with an empty body.
Agents that needed to file a real bug report, a research brief, or
any sub-task with non-trivial context had no clean path.
The parser now opportunistically captures a triple-backtick fenced
block following the verb line (and any continuation hints). The
fence contents become the new issue's body_markdown. Mirrors the
shape /write-file already uses for file contents — same helper,
same language-hint behaviour, same unterminated-fence safety.
Added¶
/create-issuebody capture: after the verb line and any continuation hints, an optional triple-backtick fenced block is consumed as the new issue's body_markdown. The fence opener may include a language hint (e.g.+ "```markdown" +) which is part of the opener, not the body — same shape as/write-file.- Auto-injected prompt sections in
internal/engine/prompt.go::BuildHeartbeatPromptandinternal/engine/adapters/claude_local.go::RenderPromptnow include a "Creating an issue with a description" example, so every local-adapter agent discovers the capability automatically without the operator having to edit system prompts. - Remote worker operating prompt (
engine/RemoteWorkerOperatingPrompt) updated with the same example;RemoteWorkerPromptVersionbumped to1.2.0. Workers that re-fetch viaGET /api/workers/operating-promptafter a server upgrade will see the new contract. - Four new parser tests in
internal/engine/actions_decomposition_test.go:TestParseAgentActions_CreateIssueBody(body alone),TestParseAgentActions_CreateIssueBodyWithHints(body + hints),TestParseAgentActions_CreateIssueNoBody(backwards-compat for three pre-v2.13.0 shapes), andTestParseAgentActions_CreateIssueUnterminatedFence(the safety net: an unclosed fence yields no body and the opener is preserved as prose so the operator can see what happened). - Documentation: new "Setting a body (v2.13.0+)" subsection in
docs/user/concepts/action-verbs.mdunder/create-issue, with the worked example and the explicit list of supported shapes (title only, title + hints, title + body, title + hints + body).
Changed¶
internal/engine/adapter.gocreate_issue handler now plumbsaction.Bodyintoquery.CreateIssueParams.BodyMarkdownwhen non-empty. Empty action.Body leaves BodyMarkdown NULL, matching every pre-v2.13.0 result row.
Why¶
The previous gap was visible the moment someone tried to write a prompt that needs the agent to file a structured sub-task — e.g. "when you find a bug, create an issue with reproduction steps". The agent could put the title together but the body would land empty, forcing the prose into a comment on the parent issue and breaking the child's standalone usefulness. This release closes that gap with a backwards-compatible, parser-only change.
v2.12.0 — 2026-05-11¶
One-shot installer + user-level service support for the native install path. Before this release the "native install" story was a hand-rolled sequence — create system user, install env file, render systemd unit, docker compose up Postgres, generate secrets, hope you didn't miss anything. v2.12.0 collapses all of that into:
The default mode is user-level: no sudo, paths under $HOME,
service runs as the invoking user. This is the right default for
Staple's primary audience because the service has access to ~/.claude
and ~/.codex, which the claude_local and codex_local adapters
require. A --system flag is provided for production installs where
every agent uses the cloud llm adapter and a hardened system service
is preferred.
Added¶
scripts/release/install.sh— one-shot installer with both--user(default) and--systemmodes, supporting Linux (systemd) and macOS (launchd). Generates and persists secrets (Postgres password, session secret, AES-256 encryption key), writes the env file with admin bootstrap, renders the service unit, starts Postgres via the bundled compose file, and waits for/api/health. Idempotent — re-runs reuse existing secrets and admin credentials so user sessions and encrypted secrets survive upgrades.scripts/release/uninstall.sh— clean teardown. Default keeps state (DB, env, secrets);--purgeremoves everything including the dedicated system user in--systemmode.scripts/release/docker-compose.yml— Postgres-only, bound to127.0.0.1:5432, password loaded from a file mounted as a Docker secret. Distinct from the dev-modedocker-compose.ymlat repo root.scripts/release/README.md— the archive's top-level install quick-start. Replaces the project README inside the release archive.scripts/service/staple.user.service— user-level systemd unit (deploys to~/.config/systemd/user/staple.service). Lighter hardening than the system unit (noProtectHome=read-only) so the local-CLI adapters can write to~/.claudeand~/.codex.scripts/service/com.staple.server.user.plist— user-level launchd LaunchAgent (deploys to~/Library/LaunchAgents/).scripts/service/staple-worker.serviceandscripts/service/staple-worker.user.service— optional remote-worker service units, installed but disabled by default. Operators register a worker, drop the key intoworker.env, andsystemctl enablewhen ready.make dist,make dist-snapshot,make dist-check,make dist-clean— Makefile wrappers aroundgoreleaserso the build is one command locally for iteration on the install scripts, and CI keeps usinggoreleaser release --cleanon tag push.
Changed¶
.goreleaser.yamlarchive layout: archive root now holdsREADME.md(the install quick-start, sourced fromscripts/release/README.md, not the project root README),install.sh,uninstall.sh, and the release-variantdocker-compose.yml. Service files keep their existingscripts/service/layout inside the archive, with the new user-level units added alongside the system-level ones.
Why¶
The local-adapter constraint — claude_local / codex_local agents
spawn host CLIs that read OAuth state from ~/.claude and ~/.codex
— rules out running Staple inside a Docker container. The native binary
was the only viable path. But "binary on a host" without a managed
service is operationally rough: it doesn't survive reboots, doesn't
restart on crash, and getting Postgres + secrets + env file lined up by
hand is error-prone. This release closes that gap with a real installer
while preserving the local-adapter access pattern.
v2.11.0 — 2026-05-11¶
Remote-worker parity with in-process adapters. The v2.10.0 task envelope
shipped only the issue's title + body — remote workers had no
conversation history, no team roster, no manager info, no project
name, and no label list, so they couldn't reason about prior turns,
escalate cleanly, or route work the way a local-adapter agent could.
This release brings the remote envelope to feature parity with what
claude_local, codex_local, and the in-process LLM adapter already
have on hand at every heartbeat.
Bundle/wire-protocol version bump: RemoteWorkerPromptVersion →
1.1.0. Workers built against 1.0.0 continue to work — every new
field is omitempty, so the wire shape is strictly additive.
Added¶
RemoteTaskPayload.ManagerName/ManagerRole— name and role of the agent this agent reports to, resolved fromagents.reports_to. Nil for top-level agents.RemoteTaskPayload.Roster— teammates the agent can/assignwork to. Populated only for routing roles (matches what the in-process triage path does), serialised away viaomitemptyotherwise.IssueContext.ProjectNameandIssueContext.Labels— the project name and alphabetically-sorted label list, fields thatBuildHeartbeatPrompthas rendered for in-process agents since the engine's first iteration.IssueContext.Timelineis now populated for remote tasks — comments + activity-log entries in order, the same data the localclaude_localadapter renders into the user prompt. This is the largest of the parity gaps: without it, every heartbeat was a goldfish-memory re-run of the issue's title and body.query.ListLabelsForIssue— focused helper for fetching just an issue's labels (previously the only path was the heavyGetIssueDetail).- Two new tests:
TestBuildRemoteTaskPayload_PopulatesAllParityFields— wires up a realistic scenario (manager + worker + project + labels + comment) and asserts every new field is populated.TestBuildRemoteTaskPayload_TopLevelAgentHasNilManager— locks in the "nil, not empty string" contract for top-level agents so workers can rely onmanager_name != nullas the "should I render Reports to:" check.TestRemoteTaskPayload_JSONShape_StableKeys— guards every documented JSON key against accidental Go-CamelCase leakage.
Changed¶
adapters.IssueContext,adapters.TimelineEntry, andadapters.RosterEntrynow carry JSON tags (lowercase snake_case) so the wire format matches what the operating prompt promises and what the rest of the Staple API uses.- Operating prompt (
engine.RemoteWorkerOperatingPrompt) updated: §3 task-envelope table now listsmanager_name,roster,issue.timeline,issue.project_name,issue.labels; §4 user- message construction guidance now mirrors the in-processBuildHeartbeatPromptstructure (## Youwith manager line,## Team Rosterfor routing roles,## Pinned Context,## Conversation Historyfromissue.timeline); §7 replaces the v1.0 "comments not in payload" caveat with positive guidance on rendering the timeline and respecting roster discipline; §8 reference envelope expanded to show a realistic payload with all the new fields. internal/engine/adapter_remote.gorefactored: payload assembly extracted intobuildRemoteTaskPayloadfor direct unit testing.Executenow just calls the helper and runs the poll loop.
Why¶
A worker reading the v1.0.0 prompt was told the task envelope
contained the issue's body but not its history, and was advised to
"make the best decision you can with title + body alone." That's
not parity with what a claude_local agent gets — a local agent
has the full conversation history, can see what other agents have
been saying about the issue, and knows who its manager is for
escalation. Closing this gap is what makes the remote-worker
feature actually functional for anything beyond the trivial case.
v2.10.0 — 2026-05-11¶
Remote workers (openclaw, openfang, hermes, and any other implementation of the Staple worker protocol) now have a canonical operating contract they can read on startup. Previously a third-party worker had no authoritative source for the action vocabulary, payload shape, or lifecycle expected by Staple — the integration was effectively oral-tradition. This release ships the contract as a documented constant and exposes it through three surfaces.
Added¶
engine.RemoteWorkerOperatingPrompt— single source of truth for the worker contract: lifecycle (register / heartbeat / claim / complete / fail), full task envelope reference, model-prompt assembly guidance, action vocabulary mirroring the local-adapter prompt, and operating rules (timeouts, idempotency, tag discipline, "no prose escalation"). Baked into the binary, evolves with the codebase.engine.RemoteWorkerPromptVersion— independent semver tag for the prompt content so workers can detect contract drift after a server upgrade. Starts at1.0.0.GET /api/workers/operating-prompt— public, unauthenticated endpoint that returns{prompt, version}. Lets a worker fetch the current contract before/after registration without bearer auth./admin/workersUI now renders the operating prompt below the worker table, with a Copy-to-clipboard button and a collapsible preview. Operators can paste the contract directly into their worker's system-prompt template.- Two test files cover the new surface:
remote_prompt_test.goguards the prompt's required sections + action verbs + endpoint list against accidental deletion, andremote_workers_test.gocovers both the new endpoint and the fields embedded in the registration response.
Changed¶
POST /api/workers/registernow returnsoperating_promptandprompt_versionalongsideworker_idandapi_key. Fresh workers get the whole contract in one round-trip — no second call, no out-of-band setup. Existing fields are unchanged.
v2.9.1 — 2026-05-11¶
Patch: spawned issues from a routine now inherit the routine's description as their body, instead of being created with the title only and an empty body.
Fixed¶
fireNewRunininternal/worker/policy.gonow passesroutine.Descriptionas the new issue'sBodyMarkdown. All four routine-fire paths (cron scheduler, manual UI, manual API, webhook) funnel through this function, so every entry point is covered. Routines with no description (nil) continue to produce issues with a NULL body — no regression for description-less routines.- Added
TestApplyConcurrencyPolicy_IssueInheritsRoutineDescriptionwith three sub-cases (text description, nil, empty string). The test correctly fails against the v2.9.0 code path before the fix, and passes after. TestPortabilityExportCompanywas asserting the bundle version against a literal"2.0"; it now asserts againstCurrentBundleVersion. This should have shipped with v2.9.0 but was masked because the integration test silently skips when the dev postgres isn't reachable.
v2.9.0 — 2026-05-11¶
Bundle format v2.1: portability bundles can now carry LLM provider
configuration (Anthropic / OpenAI-compatible / Perplexity) without
shipping secrets. Importing a fresh company now sets up its provider
records and wires each llm-adapter agent to the right provider_id
in one transaction. Solves the "agents fail their first heartbeat after
import" gap.
Added¶
PortabilityBundle.Providers— new top-level array describing eachcompany_providersrow to materialize. Each entry haslabel,adapter_type, optionalbase_url, optionalapi_key_env, andenabled. API keys are never serialized.PortabilityAgent.ProviderLabel— agent-level reference that resolves to the new provider's UUID at import time and is written toagents.provider_id(same shape asreports_to_name).- Import-time key resolution: operator-supplied keys (sent in the
wrapped request body under
provider_keys) take precedence; otherwise the importer readsos.Getenv(provider.api_key_env). When neither source produces a key, the provider is created withenabled=falseand the agent fails its first heartbeat with a clear "provider disabled" message instead of the generic "no LLM provider configured" fallthrough. - Import preview now returns a
providersarray with per-providerenv_present/needs_operator_keyflags so the UI can prompt the operator only for the keys actually missing. - New route
POST /portability/ui/import-preview(mirrors the existing/api/portability/import-preview) so the browser-session import flow can show the provider preflight before committing. DeriveProviderEnvName(label)helper: derivesPERPLEXITY_API_KEYfrom a label ofperplexity, etc. — used as the fallback when a bundle omitsapi_key_envexplicitly.
Changed¶
PortabilityBundle.Versionbumped from2.0to2.1. Old2.0bundles import unchanged; theprovidersarray is treated as empty and per-agentprovider_labelis unset.ExportCompanynow emits theproviderssection (without API keys) and fillsprovider_labelfor any agent linked to a provider row.ImportCompanysignature gained a fourth parameter*ImportOpts(carries operator-suppliedProviderKeys). Passnilfor the legacy behavior.handler.ImportCompanyaccepts a wrapped request body{"bundle": {...}, "provider_keys": {...}}for callers that want to supply operator overrides. Raw-bundle bodies still work for backward compatibility with existing CLI users — they just can't supply overrides.- Import UI is now a two-step flow: select bundle → preview (lists providers + env-var status) → submit. Operators get a password input per provider whose env var is missing.
Fixed¶
- Importing a bundle whose
llm-adapter agents depend on Perplexity (or any non-Anthropic-via-Claude-CLI provider) no longer leaves them in a permanently broken state. Either the server env supplies the key automatically, or the operator fills it in at import time, or the provider is created disabled with a clear error path.
v2.8.2 — 2026-05-10¶
Patch: consolidates the duplicated builtin-skill source-of-truth, fixes
the documented seed path, and incidentally adds write-project-file to
the set of skills actually embedded into the binary.
Changed¶
-
internal/engine/skills/deleted. The directory was a manual mirror ofinternal/skills/data/(the path actually embedded via//go:embed data). The only thing referencinginternal/engine/skills/was a handful of cross-link paths inAPI.mdanddocs/user/agents/*.md. With one canonical location the mirror has nothing to fall out of sync with. -
Cross-references updated to point at
internal/skills/data/: API.md—hire-agentandwrite-project-fileSKILL.md linksdocs/user/agents/hiring-and-firing.md— three SKILL.md linksdocs/user/agents/writing-a-skill.md— three SKILL.md links and the prose sentence ("The shipped skills … live underinternal/ skills/data/in the source tree") that was previously pointing skill authors at the wrong directory.
Fixed¶
write-project-fileskill is now actually shipped. It existed only in the deletedinternal/engine/skills/mirror and was referenced by docs and CHANGELOG entries, but had never been copied tointernal/skills/data/where the//go:embedseed mechanism actually reads from. That meanswrite-project-filewas documented as a builtin instance skill but never seeded into theinstance_skillstable on startup. Migrating itsSKILL.md+skill.yamlinto the canonical location closes the gap — fresh installs and re-imported bundles now getwrite-project-filealong with the other five builtins.
Internal¶
- The
internal/skills/data/directory now holds all six builtins:hire-agent,terminate-agent,list-team,para-memory-files,request-approval, andwrite-project-file. - Full
./...suite passes with-race, including the integration suite.
v2.8.1 — 2026-05-10¶
Patch: documents the v2.8.0 delegation primitive inside the built-in
list-team skill so agents that invoke it to discover a teammate's
name immediately learn how to route work to that name. No code
behavior change.
Changed¶
list-teamskill gains a "Delegating Work to a Teammate You Just Discovered" section that documents both delegation patterns side by side:/assign <name>— transfer the issue you currently hold/create-issue ...+assignee: <name>continuation — create a new child issue and route it directly (the v2.8.0 capability)-
plain
/create-issue— Triage routes Plus a short "which one do I use?" decision guide. -
Both canonical copies updated and kept in sync:
internal/skills/data/list-team/SKILL.md(embedded into the binary via//go:embedand seeded into theinstance_skillstable at server start) andinternal/engine/skills/list-team/SKILL.md(the browseable doc copy referenced fromAPI.mdanddocs/user/agents/).
Notes¶
- Fresh installs and bundle imports against an empty DB seed the updated skill text automatically on first server start.
- Existing deployments that already seeded the previous version of
this builtin skill will see it overwritten on next startup —
SeedBuiltinSkillsupserts builtin rows only (rows withbuiltin = true), so any user-edited copy is left alone.
v2.8.0 — 2026-05-10¶
A MINOR bump that adds two small, related primitives needed to express
"agent A creates a child issue and routes it to specific agent B"
cleanly from inside an agent's response. Backwards-compatible — existing
/create-issue calls without the new hint continue to produce unassigned
children that Triage routes, exactly as before.
Added — /create-issue accepts an assignee: hint¶
Recognized inside the existing indented continuation-line block,
alongside blocked-by:, blocks:, and relates::
/create-issue Empirical analysis: Should we adopt X?
assignee: Empirical-Claude
/create-issue Empirical analysis: Should we adopt X?
assignee: Empirical-GPT5
blocked-by: Some other ticket
- The hint key supports
assignee,assign-to, andassign_to. - The value is resolved to an agent by name (case-sensitive), using the
same
query.GetAgentByNamelookup/assignalready uses. - An unresolvable name leaves the child unassigned and logs a warning — the action still partially succeeds, and Triage picks the child up the normal way.
- Auto-injected
### Available Actionsblocks in both prompt builders (RenderPrompt for claude_local/codex_local, BuildHeartbeatPrompt for the LLM-API path) advertise the new key with a concrete example.
This closes the gap that previously forced agents wanting to delegate to a specific peer to either (a) bypass the action vocabulary entirely and curl the issues API directly, or (b) rely on a Triage Officer prompt update to recognize title-marker conventions. Both worked but were brittle; this is the first-class form.
Added — activity payloads rendered inline in the auto-injected timeline¶
Previously, the timeline section in an agent's auto-injected prompt
showed activity entries as bare _[issue.child_created by Lead]_. The
activity_log row's payload JSON was loaded into the DB but dropped
on the way to the prompt — so an agent literally could not see the
IDs of children it had created on its previous heartbeat without an
HTTP API roundtrip.
ListIssueTimeline now selects activity_log.payload, the result
type carries it through, and RenderPrompt emits it inline when
non-empty:
_[issue.child_created by Lead: {"child_issue_id":"abc-123","child_issue_title":"Empirical-Claude task"}]_
Empty / {} / null payloads render in the original concise form
so trivial activities (release, checkout, status_change without
detail) stay one-liners.
This makes multi-tier orchestration patterns (one agent dispatching children, then synthesizing their results) tractable without external state-tracking: the agent reads its own timeline on each heartbeat and extracts the IDs it needs.
Internal¶
TimelineEntry(bothdb/queryandengine/adapters) gains aPayloadfield. The DB layer surfaces it asjson.RawMessage; the adapter struct usesstringfor verbatim pass-through.parseHintLinelearns theassigneekey.- Three new tests:
TestParseAgentActions_CreateIssueAssigneeHint,TestRenderPrompt_TimelineActivityPayload,TestRenderPrompt_TimelineActivityPayload_NullOrEmptyJSON. - Full
./...suite passes with-race, including integration tests.
v2.7.0 — 2026-05-10¶
A MINOR bump that adds a third local-CLI adapter alongside claude_local:
codex_local, which executes agents via OpenAI's codex exec
subprocess. Backwards-compatible — existing agents continue to run on
their configured adapter; this just adds a new option to the dropdown.
Added — codex_local adapter¶
- Engine adapter at
internal/engine/adapters/codex_local.go. Spawnscodex exec --json --skip-git-repo-check …with the prompt on stdin, parses codex's JSONL stream format (thread.started→item.completed→turn.completed), captures the thread_id for session resume, and emits the sameKindInit/KindAssistant/KindToolCall/KindToolResult/KindResultengine events the claude_local adapter does — so any consumer of those events sees a consistent shape regardless of which CLI ran. - Allowlist at
internal/engine/adapters/codexflags/. Mirrors the claudeflags package: validatesextra_argsagainst the set ofcodex execflags worth exposing (profile, ephemeral, image, ignore-rules, enable/disable, output-schema, etc.) and rejects the dangerous ones that have typed UI fields (--cd,--model,--sandbox,--dangerously-bypass-approvals-and-sandbox). - Engine bridge at
internal/engine/adapter_codex_local.go. Registers"codex_local"in the engine's adapter map, plumbs the agent'ssystem_promptthroughRenderPrompt, and computes cost from token counts via the existingpricing.goEstimateCostpath (codex does not emit a per-run cost in its stream). - Skill materialization at
internal/fsskills/codex.go. Mirrors Staple's merged skill view (instance + company + agent, with agentcompany > instance precedence) into
~/.codex/skills/staple-<companyID>/<slug>/SKILL.mdbefore each run. Codex discovers skills only at$CODEX_HOME/skills/**— the--add-dirflag does not trigger skill scanning, verified empirically before settling on this strategy. Writes are content-hash-skip: the new SKILL.md body is SHA-256-compared against what's on disk and only rewritten when content actually changes. Stale slugs (present on disk but no longer in the desired set) are pruned so codex's listing matches the live DB state. - TestEnvironment validator for codex_local. Checks the
codexbinary is on PATH, CWD is valid, and codex auth file at$CODEX_HOME/auth.jsonexists. Optional live probe sends "Respond with hello" throughcodex execand confirms a clean response.
Added — UI surfaces¶
- Agent show page (
agents/show.html) gains a Codex Local card with: working directory, model (empty defers tocodex config.toml), sandbox selector (read-only / workspace-write / danger-full-access), "bypass approvals" checkbox, timeout, max-turns (advisory), extra CLI args, and the canonical Prompt Template textarea. The session card shows the active thread_id and "Test Environment" / "Clear Session" buttons mirroring the claude card. - New Agent form (
agents/index.html) and company defaults (settings/index.html) dropdowns gain a "Codex Local (OpenAI Codex CLI)" option alongside Claude Local. - POST
/agents/{id}/ui/heartbeat-config-codexpersists the codex_local card toadapter_config+system_promptvia the sameUpdateAgentHeartbeatConfigpath the claude flow uses. - POST
/agents/{id}/ui/test-codex-adapterruns the validator and returns an HTMX-friendly HTML partial. - POST
/api/companies/{cid}/agents/{aid}/test-codex-adapterexposes the same validator as JSON.
Added — LLM-facing documentation¶
GET /llms/agent-configuration/codex_local.txtreturns a full config-shape doc covering the new adapter_config fields, the skill-materialization strategy, the thread-resume behavior, and how cost is computed.
Skill compatibility note¶
Codex's skill format is the same Anthropic SKILL.md frontmatter
(---\nname: …\ndescription: …\n---\n<body>) that claude uses, so
DB skills imported via the portability bundle work identically on
both adapters — no separate authoring required. Verified empirically
by planting a test skill at ~/.codex/skills/<slug>/SKILL.md with
frontmatter and confirming codex discovered, listed, and invoked it.
Internal¶
- 26 new unit tests covering: codexflags allowlist + sanitizer, codex CLI arg building (fresh + resume + empty-optional shapes), env composition with empty-value skip, JSONL stream parsing (happy path, multi-message accumulation, error-result on non-zero exit code, malformed-line tolerance, unknown-event-type tolerance), and the fsskills content-hash-skip + JSON-string-or-fallback decode paths.
- All existing tests still pass with
-race, including the integration suite.
v2.6.0 — 2026-05-09¶
A MINOR bump for visual identity. Staple ships its first logo, favicon, and in-app brand mark. No code changes outside templates, CSS, and three new static SVG assets — fully backwards-compatible, no migrations, no new dependencies.
Added — brand identity¶
- Logo: an "S" constellation. Thirteen dots arranged along an S
curve, connected by straight polyline segments, in slate (
#111827) with brand-blue (#3b82f6) connections. The metaphor is agents as discrete actors and routines as the cooperation that links them — literal enough to read as "S for Staple," figurative enough to mean the product. Three SVG assets ship ininternal/web/static/:favicon.svg,logo-mark.svg(light), andlogo-mark-dark.svg(dark). - Browser-tab favicon.
<link rel="icon" type="image/svg+xml">added to the layout<head>. The favicon SVG embeds an@media (prefers-color-scheme: dark)rule so it inverts on dark browser chrome without a second file or extra request. - Sidebar rail brand mark. A 28 px inline-SVG copy of the mark
sits at the top of
.company-rail, above the per-company icons, and links to/. Inline (not<img>) so the existingdata-theme="dark"attribute can re-color the dots and connecting line via CSS variables — no dual files served, no flash on theme toggle. The per-company.brand-dotindicator is untouched; it continues to mark the active company's color.
Notes¶
- The wordmark "Staple" is rendered as page text using the existing system-sans stack, not baked into the SVGs. The mark assets stay font-free and portable.
- Design rationale, decision log, and the dot-position table are in
docs/superpowers/specs/2026-05-09-staple-logo-design.md.
v2.5.0 — 2026-05-09¶
The second MINOR bump in the v2.x line. SemVer-correct: backwards- compatible. Two data-model refactors land here, both with automatic migrations on the live DB and matching coerce logic on the import path, so existing v2.4.x deployments and exported bundles continue to work without manual intervention.
Added — agent capabilities¶
/write-fileaction now advertised toclaude_localagents. The action verb was implemented in v2.4.0 but only auto-injected by the LLM-API prompt builder. Theclaude_localRenderPromptpath now mirrors the same documentation block (verb, fenced-block body example, path-validation rules), so agents on the local CLI adapter can persist files to a project workspace too. Same parser, same 4 MiB cap, same../absolute-path rejection. Closes a parity gap between the two prompt builders.
Added — routines UI¶
- Edit and delete a routine from the UI. The routine show page
previously had only "Run now" and trigger-add. Edit opens a modal
prefilled with the current values (title, description, status,
priority, assignee, concurrency, catch-up); Delete cascades to
triggers and run history. New
DELETE /api/routines/{id}endpoint for parity with the UI. Cross-company IDOR check matches the existingUpdateRoutinehandler. - Edit and delete a schedule trigger from the routine show page.
Each trigger row gains an Actions column; Edit toggles an inline
form prefilled with the trigger's cron / label / timezone /
enabled flag; Save validates the cron with the same parser used at
create time and recomputes
next_run_at. Delete useshx-confirm. Both refresh#triggers-listin place via HTMX — no full page reload, scroll position preserved. Webhook triggers stay on the JSON API path because their secret cannot be flashed safely through HTML.
Added — agent UX¶
- Title and Description on the New Agent form. The
UIAgentCreatehandler had always accepted these fields, but the modal never posted them — newly created agents started with both empty. Triage's auto-injected team roster prints(<role>, <title>) — <desc>when populated and falls back to bare(<role>)otherwise; without titles and descriptions on create, every new agent shipped a partially-blank roster line until somebody remembered to edit it. The fields now appear directly under Name, with one-line hints explaining how Triage consumes each. The same hint copy was added to the existing fields on the agent show page for symmetry.
Changed — agent prompt unified onto agents.system_prompt¶
The engine had two prompt-source paths reading from different
fields depending on adapter_type, with no UI affordance to tell
users apart:
claude_localreadadapter_config.prompt_template(and ignoredsystem_prompt).- The LLM-API path read
agents.system_prompt(and ignoredprompt_template).
Real-world consequence: agents whose content lived in the wrong
field ran with the 4-line default stub. The bug was confirmed when
an agent with a 46k-char playbook in system_prompt and an empty
prompt_template turned out to be running on the default — its
playbook was dead config because the adapter was claude_local.
The reverse failure also existed: changing an agent from
claude_local to lambda would silently swap its persona because
the prompt buried in adapter_config does not follow the agent
across adapter changes.
This release collapses onto the top-level column.
- Migration
060_unify_prompt_field.sqlcopies any non-emptyadapter_config.prompt_templateintoagents.system_promptforclaude_localagents (where it was the canonical read), then strips theprompt_templatekey from every agent'sadapter_config. Idempotent on re-run. claude_localadapterExecute()readsagent.SystemPromptinstead ofcfg.PromptTemplate. ThePromptTemplatefield is removed from theClaudeLocalConfigstruct so callers cannot accidentally read or write it; JSON unmarshal silently drops residual keys.- Remote worker does the same.
RemoteTaskPayloadalready carriedSystemPrompt; the worker now points at it instead of the deadcfg.PromptTemplate. BuildHeartbeatPromptnow applies the same{{agent.name}}/{{company.name}}/{{run.id}}substitution thatRenderPrompthas done all along.AgentContextgainedID/CompanyID/CompanyName/RunIDfields; both LLM-path callers (adapter_llm,engine_provider) populate them. The UI promise of "Variables: {{agent.name}}, …" is now honored regardless of adapter type.portability.ImportCompanyhoists legacyadapter_config.prompt_templateintoSystemPromptfor any pre-060 bundle, only whenSystemPromptis empty (a non-empty value is canonical).prompt_templateis stripped from importedadapter_configeither way. Covered by 8 table-driven cases inTestHoistLegacyPromptTemplate.- Agent show page now has a single editable Prompt Template
textarea per adapter card, bound to
agent.system_promptwithname=system_prompt. The redundant System Prompt textarea inside the Claude Local card is gone. The label is consistent across every adapter card. Substitution-token help text appears on each.
Changed — process adapter type renamed to llm¶
The agents.adapter_type column had two equivalent values flowing
through the same engine path: "process" (legacy) and "llm"
(newer). Both fall through to newLLMAdapter — neither is registered
in the adapter map (claude_local / docker / lambda / remote)
— so they were behaviorally identical. Their existence was an
accident of history: "process" was the original name, but the
adapter never spawns a process; it makes an HTTP call to the
resolved provider. Newer code already used "llm" (company default
in migration 058, the worker switch case, the remote adapter's
LocalAdapterType default, the built-in skill data, the settings
UI dropdown). Only agents.adapter_type and a few Go defaults still
produced "process" rows.
This release consolidates onto "llm".
- Migration
061_rename_process_adapter_to_llm.sqlrewrites existing"process"rows to"llm"and changes the column DEFAULT from'process'to'llm'. Idempotent. - Go defaults flipped in
query.CreateAgentandhandler.UIAgentCreateto"llm". adapterDocsmap inhandler/llm_config.gorenamed key"process"→"llm"; body explains the rename and why the old name was misleading. 404-fallback list of supported types updated.- UI dropdowns in three templates collapse the legacy option with a backward-compat selected-when-process check, so a row that hasn't been migrated yet still renders correctly.
portability.ImportCompanycoerces incomingadapter_type: "process"to"llm"so legacy bundles imported after migration 061 don't re-introduce stale values (the migration only runs once per DB; goose tracks applied versions).
Fixed¶
- Modal forms with content taller than the modal. The previous
sticky-positioned
.form-actionsbar overlaid the bottom of the scrollable form on initial render — for tall modals (New Routine, Edit Routine, New Issue), the last field sat behind the Cancel/Save bar. Replaced sticky positioning withmargin-top: autoso short forms keep the bar flush against the modal's bottom edge, and tall forms put the bar at the natural end of the scroll content. - Tall modals truncated content. Bumped
.modal-contentmax-heightfrom 84vh to 90vh and pulled.modal-overlaypadding in from8vh 16pxto4vh 16pxso tall forms render without clipping on standard laptop viewports. - Browser cache served stale CSS/JS after rebuild. Embedded
static assets via
embed.FScarry noLast-Modifiedheader, so browsers happily served the cached version after a server restart. Layout now appends?v={{.AssetVersion}}to every/static/*URL, whereAssetVersioncombines the build version with the process boot time. Dev rebuilds bust the cache on every restart; release builds bust on every version change.
Internal¶
TestHoistLegacyPromptTemplate— 8 table-driven cases covering the import-side compatibility helper for legacy bundles.TestMaterializeSkill/definition_with_YAML_frontmatter_survives_JSON_decode— proves a skill definition string containing YAML frontmatter materializes to aSKILL.mdClaude Code recognizes as a real skill (frontmatter survives JSON-decode, no JSON quotes leak through). Closes a sanity-check that previously could only be verified post-import.- Test fixtures using
"process"mass-renamed to"llm"across 12 files. Behavior was identical (fall-through is shared) but one previously-silent assertion would have started failing as soon as anyone ran the suite against a real Postgres with migration 061 applied.
Documentation¶
- README.md adapter list updated: "Process — built-in LLM execution …" → "LLM — built-in LLM execution …" with a one-line note pointing at the migration. ARCHITECTURE.md and CONTRIBUTING.md were already clean. PLUGIN.md uses "process" in the unrelated sense of OS subprocesses for plugins; left as-is.
v2.4.1 — 2026-04-28¶
Improves how the agent loop preserves context across long-running issues.
Backwards-compatible: existing comments continue to work; the new
is_pinned column defaults to false.
Added¶
- Pinned comments. Humans can pin a comment via the new pin/unpin
button on each timeline row in the issue page. Pinned comments are
rendered in a dedicated
## Pinned Contextsection at the top of the agent prompt and are NEVER subject to conversation-window truncation. Use this for instructions, constraints, or reminders that must survive even after a thread accumulates dozens of comments. POST /api/issues/{issueId}/comments/{commentId}/pin— toggles by default; accepts an optional{"pinned": <bool>}body to set an explicit value. Emitsissue_updatedover SSE so timelines refresh live.- DB migration
059_comment_is_pinned.sqladdsissue_comments.is_pinned BOOLEAN NOT NULL DEFAULT FALSEplus a partial index on(issue_id) WHERE is_pinned = TRUE.
Changed — agent prompt windowing¶
loadCommentsno longer drops earlier instructions silently. Previously the heartbeat prompt only included the last 20 comments, which meant load-bearing details posted earlier in a thread fell off once the comment count grew. The newwindowCommentshelper keeps the first 3 comments (kickoff context), every human-authored comment in the middle (where instructions usually live), and the last 17 (recent activity), capped at 40 total. Synthetic_...[N earlier comments omitted]..._markers are inserted between non-contiguous segments so the agent knows context was elided.CommentContextgainedIsHuman,IsPinned,OmissionMarker, andOmittedCountfields;BuildHeartbeatPromptrenders pinned comments in their own section above conversation history and tags pinned rows inline with[pinned].
v2.4.0 — 2026-04-27¶
The first MINOR bump in the v2.x line. SemVer-correct: everything below is backwards-compatible additive functionality. Existing v2.3.x clients continue to work; no schema or API breaks.
Added — agent capabilities¶
- Per-company agent defaults. Each company carries default
adapter_type,adapter_config,heartbeat_enabled, andheartbeat_interval_secfields used byPOST /api/companies/{id}/agentswhenever a request omits them. Agents hired via thehire-agentskill now come up usable out of the box. Configurable from/admin/settings→ "Hired Agent Defaults" and viaPATCH /api/companies/{id}/agent-defaults. /write-fileaction verb. Agents can persist files to a project's workspace directly from a heartbeat reply — noclaude_localadapter required. Subdirectories auto-created, traversal-rejected, 4 MiB cap. Works againstproject.working_dirwhen set, otherwise falls back to$STAPLE_HOME/companies/<cid>/projects/<pid>/files/. Same backing logic powers the new/api/projects/{id}/files/*endpoints.- Project workspace files API.
GET / POST / PUT / GET / DELETEat/api/projects/{id}/files(with a{path...}wildcard supporting subdirectories) for listing, multipart-uploading, writing, downloading, and deleting files in a project's workspace. write-project-fileskill documenting the new verb. New skills also added:hire-agent,terminate-agent,list-team,para-memory-files,request-approval— previously untracked.
Added — UI surfaces for inverse gaps¶
- Project files UI on the project show page — list, upload, download, delete workspace files.
- Plugin restart button on the plugin show page (mirrors the existing
JSON
/api/plugins/{id}/restartendpoint). - Remote workers admin page at
/admin/workers— list registered workers with status, tags, last-seen. - "Comment & Resume" button on the issue show page. Posts a comment
AND flips the issue back to
in_progressin one click. Visible onblocked,done,in_review, andcancelledissues; humans only.
Added — JSON API parity sweep¶
Closes the UI-only gaps surfaced by an audit. Every UI mutation now has a JSON equivalent.
POST /api/issues/{id}/commentsacceptsresume_after: trueto flip a stalled / closed issue back toin_progressafter the comment. Honored only for human users (agents have/statusas their explicit verb).POST /api/agents/{id}/heartbeat/invoke— third-party heartbeat trigger for any agent in the caller's company. (/api/agents/me/heartbeat/invokewas self-only.)POST /api/issues/{id}/trigger-agent— invoke the issue's assigned agent.POST /api/providers/{id}/test— live connection check; sameListModelsprobe as the UI button.DELETE /api/companies/{id}/inbox-dismissals/{itemKey}— un-dismiss / restore.- Full CRUD at
/api/admin/skills{,/{skillId}}— list / get / create / patch / delete instance skills. Previously UI-only. PATCH /api/agents/{id}now acceptsadapter_type,adapter_config(full JSONB replacement),system_prompt,heartbeat_enabled, andheartbeat_interval_sec. The four UI heartbeat-config forms remain for ergonomics; the API can now do the same job.
Added — runtime / observability (carry-overs from v2.3.x)¶
- Live activity widget on the company dashboard: one-row-per-running-agent status list with pulse dot, latest tool call / assistant text, and elapsed time. Updates over SSE; terminal statuses remove rows live.
- Live transcript on the agent detail page (same component as the issue page), plus a paginated Past-runs table linking to per-run detail.
GET /runs/{runId}historical transcript page: renders up to the last 500 events of any run the caller is authorized to see.- Structured engine events captured in
heartbeat_run_eventsper run:init,assistant,thinking,tool_call,tool_result,result, pluslifecyclebookkeeping rows. Rendered as a live transcript on the issue, agent, dashboard, and per-run surfaces. - New SSE events
run.log,run.started, andrun.statusalongside the existingheartbeat_*events (unchanged).run.logcarries structured{runId, agentId, seq, kind, payload, ts}data. GET /api/runs/{runId}/events?since=<seq>&limit=<n>— replay endpoint for the transcript's gap-recovery path.- Configurable retention:
STAPLE_RUN_EVENTS_RETENTION_DAYS(default 30). Succeeded runs' events deleted after the retention window; failed runs retained indefinitely.
Changed¶
- Claude local agents now post a single summary comment per run
instead of one comment per streaming text chunk. Issue comment threads
are no longer flooded with fragmentary chunks during long reasoning
passes. The final comment is the Claude CLI's cumulative
result.summaryvalue, posted after the run completes (same code path as non-streaming adapters).
Documentation¶
API.md— comprehensive HTTP API reference. Every/api/...endpoint with auth requirements, request/response shapes, and curl examples. Linked fromREADME.md.docs/user/— 38-page user guide spanning four audiences in task-oriented procedural prose:- Board users (12 pages): start, mental-model, heartbeat, approvals, your-first-issue, hiring/configuring agents, projects + workspaces, handling approvals, dashboard / costs / inbox.
- Agent authors (7 pages): the agent's POV, full action-verb
reference, writing skills, the
/write-fileaction, hiring-and-firing, system prompts and roles, debugging field-guide. - Operators (11 pages): installing, environment, database, storage, providers, adapters, security, secrets-and-encryption, upgrades, monitoring, remote-workers.
- Integrators (8 pages): overview / four wiring patterns, auth, JSON API recipes, inbound webhooks, SSE streaming, plugin authoring, custom-worker construction, three end-to-end worked patterns (Slack pinger, GitHub bridge, daily cost report).
Developer-visible¶
sse.Eventgains aPayload anyfield that is JSON-marshalled on send. Existing callers usingData stringare unchanged.- Subscriber buffer increased from 16 to 128; gap recovery is handled
client-side via monotonic
seqvalues + the replay endpoint. query.InsertRunEventsignature changed to(ctx, pool, runID, kind string, payload any) (int64, error)— returns the assigned per-runseq. Legacystream/chunkcolumns onheartbeat_run_eventsare retained for one release.- New
events.EngineEventpackage (internal/engine/events) defines the typed event vocabulary.adapters.ExecutionParams.OnOutputhas been replaced byOnEvent. - New
home.ProjectWorkspaceDir,home.ValidateProjectFilePath,home.SafeJoinWithinDir,home.WriteProjectFile— shared helpers used by both the engine action handler and the project-files HTTP endpoints. query.UpdateAgentParamsextended withAdapterConfig,SystemPrompt,HeartbeatEnabled,HeartbeatIntervalSecfor PATCH-style updates.query.UpdateCompanyParamsextended with the fourAgentDefault*fields backing the per-company agent-defaults feature.