Argus hub API conventions¶
Locked before the phase-3 app surfaces multiplied .json endpoints
(ARGUS-263). Every NEW endpoint follows these rules; existing endpoints that
predate them are grandfathered until touched, at which point they conform.
The app repo's PLAN.md mirrors the per-endpoint contracts; this file is the
cross-cutting rules.
Auth¶
- Mutations and non-public reads:
Authorization: Bearer <ARGUS_CAPTURE_TOKEN>, exact match, constant-time compare. Missing/wrong ->401with the error envelope. - Browser-fetched surfaces that cannot send headers (the OTA install page,
manifest, .ipa) use per-resource capability tokens (
?k=, rotated on every re-issue, constant-time compared). Existence checks come BEFORE token validation (404 first) so an unprovisioned resource is not a token oracle. - Human-only actions (task Approve/reject) require the SEPARATE
ARGUS_APPROVE_TOKENthat agents never hold — "an agent cannot approve" stays structural, not behavioral (decision on ARGUS-265). - "Non-public reads" above is a short list (
/events, agent notifications, the approve group). The general*.jsonsurface (/tasks.json,/runs.json,/artifacts.json, and similar) is intentionally unauthenticated on this Tailscale-only ingress — see Security posture for the accepted-risk rationale (ARGUS-814).
Error envelope¶
Every non-2xx response is {"error": "<message>"} with a matching HTTP
status. No other error shape, including from routing defaults (404/405 are
JSON too). Validation failures are 400; auth 401; missing resources
404; forbidden capability tokens 403; not-configured subsystems 503.
Request bodies¶
- JSON with strict decoding: unknown fields are a
400, so a typo'd or tampering field never silently no-ops. - Body size is capped at 10MB globally; a route needing more gets an explicit
path-scoped cap (only
/app/upload, 200MB).
Timestamps¶
RFC3339 UTC (2026-07-03T16:19:30.174839Z). Timestamps that have not
happened yet (finished_at, done_at) are null or omitted — never zero
values, never sentinel dates.
List pagination¶
Two modes, mutually exclusive (400 if both given), items ALWAYS ascending
by id in the response:
?after_id=N— forward page: ids > N, oldest first.limitoptional (1-200, default 50). For incremental polling.?recent=N— the LAST N (1-200), still ascending. For cold loads that want the newest items.
Endpoints whose natural order is newest-first (feeds like /inbox.json) say
so explicitly in their contract; the default is the pagination above.
GET /config/audit.json (ARGUS-801) is one such feed: it returns rows
newest-first (ORDER BY id DESC) and pages backward with ?before_id=N
(strictly older than that id) instead of after_id/recent -- limit is
the same 1-200, default 50. The response's next_before_id is the cursor
for the following page, null once there is nothing older left.
Action endpoints (state changes)¶
- Idempotent: repeating the same action returns
200with analready_*marker (e.g.{"status":"ok","already_resolved":true}) and no double effect — clients on flaky mobile networks retry safely. Most kinds express this as analready_resolved: truefield on the standard success envelope; the five derived-lint kinds (tasklint,ciopt,linkcyclelint,linkinvertlint,partiallint— see the verbs table below) instead treat an already-dismissed id as a silent no-op: the dismissal lookup swallows the not-found and the same200{"resolved":"dismissed"}envelope comes back with no marker. - Every action writes an audit/event row; a state change with no trace is a contract violation.
- Side effects that can fail (a push, a Telegram send) are logged AND recorded in the runs ledger — never silent.
- Action endpoints that dispatch async work (an agent run, not just a state
flip) follow the same idempotency spirit via COALESCING rather than a
no-op: a repeat call while the prior dispatch is still queued or running
returns
202with that SAME run's id (analready_queuedmarker, not a new row) instead of enqueuing a duplicate.POST /inbox/{id}/reconcile(ARGUS-743) is the reference example, backed by theagent_runssingleton-skill mechanism (ARGUS-415).
Filters¶
Query-parameter filters are applied SERVER-side. A recognized-but-ignored filter param is a bug (a client cannot distinguish it from an applied filter): apply it or reject the request.
Pipeline reads (/tasks/{id}/pipeline, /pipelines) — ARGUS-512¶
The pipeline surface is read-only and derived at request time from existing
ledgers only: task_items / task_events, agent_runs, landing_queue,
deploys, and task_evidence. It does not persist a pipeline table or cache.
GET /tasks/{id}/pipelinereturns one task pipeline with ordered stepsBUILD,PR,LAND,DEPLOY,VERIFY. Each step hasstate: pending|active|ok|failed|skippedplus optionalat,detail,reason, andlink_key. A failedLANDstep also carries optionalfailing_check+excerpt(ARGUS-572): the failing check name + a bounded log excerpt the landing queue fetched via ForgeSource when the failure was a red CI check -- best-effort, so both are absent on non-CI failures (rebase conflict, closed PR, timeout) or when the fetch itself errored.BUILD'slink_keyisrun:<agent_runs.id>when a matching run row exists. A verified task always composesBUILDasok(ARGUS-543): if the run row that did it never got markeddone(crashed heartbeat, killed process), the verified outcome overrides the danglingactivestate rather than leaving it stuck. A task'srepofalls back to the first repo on its project registry entry when no PR/landing/deploy evidence has supplied one yet (a pre-PR task).GET /pipelinesreturns in-flight task pipelines (lifecycle=in_progressandverified=false) plus recently failed land/deploy rows, grouped by repo in landing-queue order, MINUS any composed pipeline whoseVERIFYstep isokwith no other stepfailed(ARGUS-543): the failed-land/deploy branches match on a row up to 72h old with noverifiedcheck, so a task fixed forward and reverified since would otherwise linger for the rest of that window. Task-less PRs in the landing queue are represented as pipelines that start atLAND.- The existing bearer-gated
GET /eventsstream is the invalidation contract:taskevents invalidate task state/evidence/verification,landingevents invalidate land status, anddeployevents invalidate deploy status. These reads need no new SSE kind; clients refetch the composed pipeline on any of those existing signals.
Model config (/models.json, PATCH /models) — ARGUS-376¶
The backend is the single source of truth for which model each ROLE runs.
Roles are chat (the chat worker) and builder (dispatch / board-watch
hub builds). Env vars (ARGUS_CHAT_MODEL, ARGUS_BOARD_WATCH_MODEL)
are bootstrap-only defaults; the store wins when it has a value, so the app
retunes models without a hub redeploy.
GET /models.json(public read, like/projects.json) returns:
{
"models": ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
"roles": ["chat", "builder"],
"config": {
"global": {"chat": "claude-sonnet-5", "builder": "claude-opus-4-8"},
"projects": {"argus": {"builder": "claude-haiku-4-5"}}
}
}
models is the RunModels allowlist (ARGUS-229) the pickers offer; config
is the effective store state — global is the per-role default, projects
holds only the roles a project overrides. Absent keys mean "inherit".
- PATCH /models (bearer) sets one role's model:
{"role":"builder","project":"argus","model":"claude-opus-4-8"}. project
omitted or "" targets the global default. A non-empty model must be on
the allowlist (400 otherwise); an empty model CLEARS that override and
falls back to the wider scope / env. Unknown role or unknown field is a
400. Returns the refreshed {"models","roles","config"} document.
- Resolution precedence at every call site is
per-run > per-project > global > env. The per-run RunModels override
(the agent-run model field) is unchanged and still wins over the store.
The app repo's PLAN.md mirrors this per-endpoint contract; this section is the hub-side source it tracks.
Agent-run force + model actions (POST /runs/agent/{id}/force, POST /runs/agent/{id}/model) — ARGUS-888¶
Bearer-protected JSON counterparts of the unauthenticated htmx
/ui/agent-runs/{id}/force and /model buttons (ARGUS-218/229), which
render their result into an HTML table fragment and surface failure only as
text inside it. The app talks a JSON contract and cannot reliably parse
that HTML, so these two additive endpoints exist purely to give it a real
success/failure signal; the htmx routes are unchanged.
POST /runs/agent/{id}/force(bearer) -- no request body. Marks a QUEUED run forced (Run-now: skips the budget gate, claims ahead of other queued runs). Success:
{"run": {"id": 42, "status": "queued", "forced": true, "model": "", ...}}
(same shape as GET /runs/agent/{id}.json). A not-queued race -- the
runner already claimed it -- is 409, never a bare 200:
{"error": "agent run 42 is not queued"}
A non-integer {id} is 400.
- POST /runs/agent/{id}/model (bearer). Body:
{"model": "claude-opus-4-8"}, or {"model": ""} to clear back to the
runner default. Changes a QUEUED run's model override. model must be on
the RunModels allowlist (GET /models.json's models array) or empty;
off-allowlist is 400. Success is {"run": <AgentRun>} as above; a
not-queued race (already claimed) is 409.
The app repo's PLAN.md mirrors this per-endpoint contract; this section is the hub-side source it tracks.
Skill run trigger (POST /skills/{name}/run) — ARGUS-279/894¶
Kicks a promoted skill through the same agent-run queue the Telegram slash commands use. Bearer; body optional:
{"prompt": "...", "project": "argus", "output_kind": "note", "force": true, "model": "claude-sonnet-5"}
All fields are optional. prompt defaults to "Run the <name> skill.";
output_kind defaults to report. force (bool, ARGUS-894) applies the
Run-now override at enqueue time -- the same effect as a follow-up
POST /runs/agent/{id}/force, in one round trip; omitted (or false) is
today's behavior (forced: false). model (string, ARGUS-894) must be on
the RunModels allowlist (GET /models.json's models array) or empty;
off-allowlist is 400, same validation PATCH /models and
POST /runs/agent/{id}/model use. Omitting model leaves the run on the
runner default, unchanged from pre-ARGUS-894 behavior. Any field outside this
set is a strict-decode 400 ("invalid JSON: ..."). Success is 202 with
{"run_id": <int64>}. Gate checks (404 unknown skill, 409 orphan/deprecated,
400 not promoted to executable) run before any field is applied.
Repo discovery (/projects/discover.json, POST /projects/discover/attach) — ARGUS-573¶
The hub half of Settings->Projects DISCOVER (ARGUS-560, US-33 Phase A):
onboarding by picking repos from the connected forge rather than typing
owner/name by hand. Built on the ForgeSource/CachingForgeSource
visibility layer (ARGUS-558) — repo listing is only as fresh as that layer's
TTL (DefaultForgeCacheTTL, 20s in production); the attached-project marker
below is always a live registry read, never cached.
GET /projects/discover.json(public read, like/projects.json) returns every repo the wired forge connection can see, each marked with the ACTIVE project it is already attached to:
{
"repos": [
{"repo": "runonyourown/argus", "project": "argus"},
{"repo": "runonyourown/widgets", "project": null}
]
}
503 when no forge source is configured (dev/smoke); 502 if the forge
call itself fails.
- POST /projects/discover/attach (bearer) is per-repo only — there is no
bulk variant. Body: {"repo":"owner/name","project":"slug"}.
- project set to an EXISTING slug attaches the repo to it (repos are a
replace-whole-set patch under the hood, so this is idempotent — repeating
the same attach is a 200 no-op). Unknown project is 404.
- project omitted or "" proposes a slug from the repo's name segment
(lowercase, non-alnum runs collapsed to one hyphen) and creates a NEW
project carrying just that repo (201). A proposed slug that collides
with an existing project answers 409 — never a silent second project;
the caller must retry with an explicit project.
- Either branch fires the same onboarding bootstrap PR (ARGUS-349) newly
attached repos always get.
Lifecycle terminal state (done) — ARGUS-580¶
task_items.lifecycle (backlog|ready|in_progress|blocked|cancelled) was
originally agent-writable only, with NO terminal value — verified was the
only source of truth for "is this actually finished." That left every reader
that lists work by lifecycle alone (an app empty-state count, a board lane, a
pipeline view) needing to separately remember AND NOT verified, and one
that forgot showed phantom ready items that had, in fact, already shipped.
The checker now resolves this AT THE SOURCE. lifecycle gained a sixth,
checker-only value: done.
- When the verify sweep (or the rollup pass, for features/epics with no
direct evidence) flips an item's
verifiedtotruewhile its lifecycle is stillbacklog,ready, orin_progress, it stampslifecycle=donein the SAME write and records the prior value inlifecycle_before_done.blockedandcancelledare left alone — they are not pre-terminal, and are outside this rule. - If the checker later un-verifies that item (a reverted PR, CI turning red),
lifecycleis restored to the rememberedlifecycle_before_done(falling back toin_progressif none was recorded), andlifecycle_before_doneis cleared. doneis never agent-writable.PATCH /tasks/{id}/lifecycle(and the chatset_lifecycletool) validate against the same five agent-facing states as before; sending"to":"done"is a400, exactly like any other unrecognized value.
Practical effect: WHERE lifecycle = 'ready' (or IN ('backlog','ready',
'in_progress')) is now correct on its own — a verified item can never match
it, no per-consumer AND NOT verified required. Existing call sites that
already carried that belt-and-suspenders filter (e.g. flow.go's pipeline
query, next-task's NOT t.verified) keep it; it is now redundant, not
wrong, and cheap to leave in place as defense in depth.
Board list defaults to open-only (GET /tasks.json) — ARGUS-979¶
GET /tasks.json defaults to open-only: items with done, verified,
or lifecycle=cancelled are hidden, matching the MCP list_tasks tool's
open_only default (true) so the two board surfaces never disagree on what
"the board" shows. ?open_only=false (or any value strconv.ParseBool
doesn't read as true, e.g. 0) returns the full set, terminal items
included, for history/audit views. The filter is applied AFTER
Items.AllNodes derives rollups, so an epic/feature's rollup counts still
reflect every child regardless of this flag — only the top-level items
list is filtered.
Corrected-acceptance signal on the board list (GET /tasks.json) — ARGUS-1097¶
Each item in GET /tasks.json carries acceptance_corrected: true when the
task has at least one row in task_acceptance_revisions (the ARGUS-1080
correction ledger), omitted (not false) otherwise — same omitempty
treatment as blocked_on (ARGUS-1090). This is a cheap EXISTS check, not the
full acceptance_revisions array GET /tasks/{id}.json returns (ARGUS-1096);
it exists so the app's board/list rows (TaskRowView's verified checkmark)
can render an ACCEPTANCE CORRECTED marker without an extra per-task fetch,
matching what the detail view already shows. ?open_only=false and the
existing pagination/filters are unaffected — this only adds a field to each
row already in the response.
The app repo's PLAN.md mirrors this per-endpoint contract; this section is the hub-side source it tracks.
Inbox feed (GET /inbox.json, POST /inbox/{id}/resolve) — ARGUS-264¶
GET /inbox.json (bearer) is the app's anchor screen: ONE merged,
best-effort list of everything wanting Aaron's attention, aggregated from the
same sources the web Triage/Review pages render — no new truth, one more
view. A failing source logs and is dropped; it never blanks the rest.
Returns:
{"items": [ ... ], "badge": <int>, "blocking_badge": <int>}
badge is unchanged (ARGUS-264): total row count, the current single-tab
app's existing rendering. blocking_badge (ARGUS-1296, additive) is the
ARGUS-1295 "badge can never lie" count -- contract: "blocking" rows only --
the same count PushNotifier.inboxBadge now puts in the OS push badge, so a
consumer wanting "how many things must I tap" should read blocking_badge,
not badge.
Each item's kind is triage | approval | review | info; id is unique
within the feed, "<source>:<native id>" (e.g. noshow:42, stuck:17,
docsfreshlint:519,517). Grouped findings — memlint, linklint,
docsfreshlint, mld (the ARGUS-493/610/634/908 pattern), and partiallint
(ARGUS-1308) — fold every open finding from the same
check/PR-page/recurring-signal/task into ONE row instead of one row per
finding; the group's id is its members' ids comma-joined, and the row
carries per-member detail (notes, tasks, docs, or mld_runs) so the
client can expand it. partiallint's grouping key is the task id instead —
partialPRTaskGroups (partial_pr_lint.go) collapses every merged,
non-closing PR finding for the same task into one partiallint:<task_id>
row whose subtitle lists every contributing PR — rather than one row per
(task, PR) pair. Every other review-lint kind (containerlint,
linkcyclelint, linkinvertlint) surfaces one row per finding — folding
them would hide the per-finding detail (which container, which PR, which
cycle) a reviewer needs to act. Items sort newest-first by
created_at; timestampless sources keep stable source order at the end. The
one exception is kind: "info" (ARGUS-1181): it always sorts below every
other row regardless of timestamp, so a purely informational notice (e.g.
the podcast plugin's new-episode digest, source Podcast digest) can never
crowd out an approval or a true alert just because it happens to be newest.
An info row carries no warning glyph client-side and resolves the same
dismiss-only way as a legacy triage row (see the verbs table below).
needs_human (bool, ARGUS-986) is computed server-side per item and is true
exactly for rows whose PRIMARY resolution is a human decision: source kind
approval, memflag, blocked, gate, containerlint (ARGUS-994 — a
lingering-container finding is a real DECLARE COMPLETE/DISMISS decision, not
informational lint noise), or update (the last has no source wired into
the feed yet — no plugin/podcast update-approval gate feeds an inbox row
today — but classifies true in advance). Everything else — triage
no-shows/stuck runs/alerts, info rows, lint findings (skill/task/link/
memory/docs/attestation/acceptance/partial-PR/link-graph), parked retries,
and MLD recurring signals — is informational review noise and classifies
false. Centralized so every client agrees instead of each one hardcoding its
own kind list.
contract (string, ARGUS-1295/1296) is a broader, three-way classification
of what the row DEMANDS of Aaron, computed once server-side by
deriveContract (internal/hub/inbox_contract.go) off the same
<source>:<native id> ID-prefix needs_human reads — a client must never
re-derive which tab/section a row belongs to (the OSS-9/1272 class of
client-rule drift this field exists to end):
blocking— the future stops until Aaron acts: everyapproval:/gate:approval:row, andgate:live_check:*.review— batchable judgment, nothing stuck: the everything-else default (triage/lint/memflag/parked/MLD/divergence rows), plusgate:disarmed_lane:*,gate:cross_stack_held:*, andgate:orphaned_partial:*— those threeneeds_humanbut stayreviewrather thanblockingbecause none carries an inline resolving action (see Blocking-gate rows below); the 1274 invariant requires everyblockingrow be exercisable, enforced byTestInboxBlockingRowsWithoutActionand the livePushNotifier.CheckBlockingRowsActionablewatchdog.record— describes the past: everyalert:/alertitem:row, including the info-kind podcast digest.
contract and needs_human are distinct fields, not aliases — a
review-contract row (a lint finding, a memflag) can still carry
needs_human: true. Push eligibility for inbox-event pushes is expected to
gate on contract == "blocking" (pushEligibleForContract), plus the two
pre-existing critical-alert carve-outs that bypass contract entirely
(RouteAlert's routeCritical path and the emergencyAlertSources relay
carve-out) — a record row with neither carve-out never pushes through that
predicate.
verify_hint (ARGUS-1131, gate:live_check:* rows only) is the human-facing
how-to-observe instruction — surface, URL/screen, expected observation — so a
review row never asks Aaron to attest something it gave him no way to see.
Empty when the auto-filer had no [deferred: ...] note to seed it from.
needs_you_group / tap_ready_flavor (ARGUS-1626, additive) mirror
TreeNode.NeedsYouGroup/TapReadyFlavor (rollup.go) for any row whose
task_id carries a gateStatus entry — the same three-way NOW/Waiting/Parked
classification the board and Command/command.json derive, so a client
wants the richer split (rather than just contract's
blocking/review/sign_off) never has to re-derive it. Empty for a row
with no gateStatus entry (every kind outside
approval/live_check/blocked_on-human). Purely additive: never changes
contract's own value, computed independently.
mld_review (ARGUS-1141) is set only on an approval row whose task title
matches the mld-review digest convention: the full suggested+rejected
checklist for that pass (pass_id, artifact_url, suggested[],
rejected[], each finding carrying id/title/rationale/task_id) so
approving a digest is never a blind tap. A lookup failure falls back to a
plain approve/reject row with mld_review omitted.
pass_id / namespace (ARGUS-1659) are the structured identity of a
podcast/karakeep promotion-digest approval row: pass_id is the pass's
numeric id (a pointer, omitted when absent), namespace is "podcast" or
"karakeep". Both are computed server-side from the same title convention
(podcastPassIDFromDigestTitle / karakeepPassIDFromDigestTitle) the
release/guard code already uses, so the app can link a digest's pass detail
endpoint without re-deriving the title parse. Omitted on non-digest
approval rows; title's prefix text is unchanged as a fallback for older
clients still parsing it.
task_title / project (ARGUS-1658) name the task a gate:approval row
belongs to — before this, a backlog/in_progress/blocked-lifecycle
approval gate's row carried task_id but no human-readable title or project,
so identifying which task six such rows mapped to needed a query_sql join on
task_evidence. Both are set on every gate:approval row, empty on every
other row kind.
actions is computed server-side per item — never inferred client-side — so
the app never renders a button resolve would reject; it is empty for
purely informational rows. Every non-approval item also gets a uniform
SNOOZE action (until RFC3339 or duration a Go duration string, in the
resolve body) that hides the row until the deadline without touching
whatever it derives from; it reappears on its own once the deadline passes,
if still applicable. Approvals resolve exclusively via POST
/approvals/{id} with the separate human-only ARGUS_APPROVE_TOKEN — never
through this endpoint, and never carry SNOOZE. gate:approval:* rows
share that exclusion (ARGUS-1696): SnoozeItem would otherwise hide a
pending decision approval exactly like an ordinary review row, since the
approval: rejection above keys on the kind prefix and these rows' prefix
is gate. Their action set carries no SNOOZE and posting one is a 400
pointing at the evidence-review endpoint, matching how the gate id-prefix
is absent from the resolve switch above.
Two distinct sources feed kind: "approval" rows, both resolved the same
way (POST /approvals/{id} with approval_id as {id}):
- Task approvals:
idisapproval:<task-id>, narrowed to EXERCISABLE tasks (ARGUS-477 — a freshly filedverify=approvaltask with nothing built yet is not exercisable).task_idandapproval_idboth equal the task id. - Schedule-fire approvals (
id: "approval:<gate-id>", ARGUS-989): a per-fire approval-gated schedule (the podcast-sync pattern) coming due files aschedule_approval_gatesrow directly — never atask_itemsrow — so it never lingers on the board as a done/cancelled one-shot after being acted on.approval_idis the gate's own id (schedgate-N); there is notask_id.
divergence:<id> rows (ARGUS-1269) surface pending D1/D2/D5
landing_divergences adjudications (landingDivergenceInboxItems,
inbox_api.go) — the janus shadow-diff classifier's disagreements needing a
human verdict; see docs/runbook/landing-runbook.md's Janus shadow-diff
section. divergence_id (InboxItem.DivergenceID) is the
dedicated-endpoint target for POST /landing/divergences/{id}/adjudicate
(body {"adjudication": "janus_correct"|"janus_wrong"|"inconclusive"},
same ARGUS_APPROVE_TOKEN tier as task Approve/reject) — same shape as
evidence_id/approval_id, not a generic resolve verb. No
/inbox/{id}/resolve verb exists for divergence: (falls through to the
same 404 "unknown inbox item kind" as gate:).
docsfreshlint rows additionally carry reconcile_run_id /
reconcile_status ("queued"|"running", both omitted when nothing is
dispatched) reflecting any docs-reconcile agent run in flight for that row's
findings — dispatched via POST /inbox/{id}/reconcile (ARGUS-743), the
coalescing example under Action endpoints above.
ciopt rows (ARGUS-1690) surface an optional-CI-context advisory: a merged
PR whose branch-protection REQUIRED checks were all green but whose OPTIONAL
(non-required) contexts stayed red — the red optional job is recorded as an
advisory, never as a satisfaction block, so it can hold a merged PR's
verification hostage no longer. id is ciopt:<task_id>, task_id set, and
the subtitle names the repo/PR and every red optional context. The row clears
itself once the task verifies or cancels, and like every other review-lint
kind its dismiss hides it in inbox_review_dismissals without touching the
task (the dismissal side table, not a status column on the advisory — the
advisory itself is a task_event, not a row with a state to flip).
POST /inbox/{id}/resolve (bearer) dispatches on the kind prefix of id
(everything before the first :); body is {"action": "<verb>", ...} plus
kind-specific fields (task_id for linklint's mark-independent;
until/duration for snooze, handled uniformly ahead of the per-kind
switch). Verbs by kind:
| kind | verbs | effect |
|---|---|---|
memlint |
dismiss, resolve |
memory lint finding(s) dismissed/actioned; id may be a comma-joined group |
memflag |
approve, reject |
dedup verdict review, same verbs as the UI |
parked |
retry |
re-queues the parked distill unit; no drop op exists server-side |
skilllint |
prune |
orphan findings only — deprecated/stale resolve by editing the skills repo |
tasklint |
dismiss |
derived (ARGUS-166); hides a reviewed row without giving the task an evidence path |
linklint |
dismiss, mark-independent, cancel-task |
dismiss hides the whole group; mark-independent (+ task_id) is the real fix (ARGUS-742) — sets that one member's independent flag, never a group-wide op; cancel-task (+ task_id, ARGUS-836) is the other real fix — sets that one member's lifecycle to cancelled, for a stale member that will never get a link. Both require task_id to name exactly ONE member of this row's group. |
blocked |
none | 400 explains: unblock by changing the task's lifecycle |
attestlint |
dismiss |
only resolution — a merged PR body cannot be re-attested |
acceptfid |
dismiss |
hides without changing the judge's verdict/confidence/rationale |
docsfreshlint |
dismiss |
only resolution — a landed PR's diff cannot change; id may be a comma-joined group |
partiallint |
dismiss |
declared-partial finding (ARGUS-870) dismissed; the task still has no closing PR until one actually closes it |
linkcyclelint |
dismiss |
dependency-cycle finding (ARGUS-891) dismissed; task_links is unchanged and the cycle reappears on a future lint pass until an edge is removed |
linkinvertlint |
dismiss |
inverted-depends_on finding (ARGUS-891) dismissed; the backwards edge is unchanged and reappears on a future lint pass until it is removed and re-added correctly |
ciopt |
dismiss |
optional-CI-context advisory (ARGUS-1690) dismissed; the underlying task_event advisory is untouched and the row clears itself once the task verifies/cancels |
containerlint |
declare-complete, dismiss |
lingering-container gate (ARGUS-994): declare-complete sets decomposition_complete=true so the normal rollup can close it once all live children verify; dismiss requires a note naming the missing scope and suppresses re-raise until the child set changes |
pathcollision |
dismiss |
new-path collision advisory (ARGUS-649) dismissed; does not touch either colliding path or the PR |
alertitem |
ack, dismiss |
native alert-store (ARGUS-596) lifecycle |
approval |
none | 400 explains: use POST /approvals/{id} with the human token |
noshow, alert |
dismiss |
legacy triage row (ARGUS-677); the runs-ledger row itself is untouched |
alert (info kind, e.g. Podcast digest) |
dismiss |
informational alert-relay notice (ARGUS-1181); the runs-ledger row itself is untouched |
stuck |
dismiss, cancel |
dismiss same as noshow; cancel (ARGUS-680) marks the run cancelled in the runs ledger — a run that has since completed cancels to a no-op |
gate (gate:live_check:*, gate:live_check_awaiting:*, gate:disarmed_lane:*, gate:cross_stack_held:*, gate:orphaned_partial:*, gate:approval:*) |
none | not wired into this switch — 404 "unknown inbox item kind"; see Blocking-gate rows below for the real action paths |
divergence |
none | not wired into this switch — 404 "unknown inbox item kind"; resolve exclusively via POST /landing/divergences/{id}/adjudicate (ARGUS_APPROVE_TOKEN) |
any except approval and gate:approval:* |
snooze |
defers the item until until/duration; see above. gate:approval: rows are rejected with a 400 (ARGUS-1696) pointing at POST /tasks/{task_id}/evidence/{evidence_id}/review — the kind prefix reads gate, not approval, so the plain approval rejection alone would have let these slide through and hide a pending decision approval exactly like an ordinary review row |
Every kind's terminal states repeat the idempotent-retry contract under
Action endpoints above: re-dismissing an already-dismissed row, re-cancelling
an already-cancelled run, and similar repeats resolve to the same 200
rather than erroring.
Blocking-gate rows (gate:live_check:*, gate:disarmed_lane:*, gate:cross_stack_held:*, gate:orphaned_partial:*, gate:approval:*, gate:citation_pass_stalled:*) — ARGUS-787/788/792/865/1325/1514/1658/1965¶
Five of the seven human-gated blocking states the board can be in (an
unsatisfied live_check task_evidence row, a disarmed mac_runner lane with
ready work behind it, — ARGUS-865 — a lifecycle=ready task held only by
the cross-stack gate, — ARGUS-1514 — a build task whose ARGUS-1303 "no
PR linked" hold has zero remaining unsatisfied live_check/approval
evidence, and — ARGUS-1965 — an in_progress task whose citation-pass
dispatcher has hit its no-progress bound) surface as their own kind:
"review" rows, id gate:live_check:<evidence_id> /
gate:disarmed_lane:<task_id> / gate:cross_stack_held:<task_id> /
gate:orphaned_partial:<task_id> / gate:citation_pass_stalled:<task_id>.
(The other two — unsatisfied approval evidence, and lifecycle=blocked
naming a human action — already had their own purpose-built rows, kind:
"approval" and id blocked:<task_id> respectively; folding them in here too
would duplicate those rows.) All five gate rows carry task_id, the gated
task's id — the same join key approval_id and the blocked: prefix already
give a client for building a "needs me" predicate, so a consumer can OR
together kind == "approval", id prefix blocked:, and id prefix
gate:live_check: (reading task_id off the matched rows) purely from the
one GET /inbox.json fetch it already makes — no per-task detail fetch, no
extra round trip. gate:live_check:* rows additionally carry evidence_id,
the task_evidence row id POST /tasks/{task_id}/evidence/{evidence_id}/review
targets — the inline review action, same dedicated-endpoint shape
approval_id uses for POST /approvals/{id}. None of the five gate rows has
a /inbox/{id}/resolve verb: the gate id-prefix is not in the resolve
switch, so posting to one 404s "unknown inbox item kind" (unlike
blocked/approval, which are wired in to explain the redirect with a
400); gate:disarmed_lane:* has no per-item action at all — the fix is
arming the mac_runner.armed tunable, not touching the row.
gate:cross_stack_held:* is the same shape — no per-item action, no
evidence_id (there is no task_evidence row to review) — the fix is
splitting the task or overriding the cross-stack gate, not touching the row;
this closed the 862 incident, where a ready task silently held by the
cross-stack heuristic had no inbox row and surfaced only via a manual
investigation. gate:orphaned_partial:* (orphanedPartialGates,
blocking_gates.go) is the same no-per-item-action shape too, no
evidence_id — the fix is closing the task or adding a fresh acceptance
clause, not touching the row. gate:citation_pass_stalled:*
(citationPassStalledGates, blocking_gates.go) is the same
no-per-item-action shape — no evidence_id (there is no task_evidence row to
review) — the fix is providing the citation evidence the citation-pass
dispatcher has been retrying for, or cancelling the task if the work is no
longer wanted; the row surfaces a stalled task that would otherwise look
entirely normal (lifecycle=in_progress, BUILDING on the Command page) while
its citation-pass runs quietly spin with no progress (ARGUS-1965's incident:
seven no-op passes over 14 hours before the stall was noticed at all).
gate:approval:* rows (ARGUS-1658). An unsatisfied approval
task_evidence row on a non-ready-lifecycle task (backlog, in_progress,
blocked) surfaces as its own kind: "review" row, id
gate:approval:<evidence_id>. Unlike the four gate rows above, gate:approval
rows ALWAYS appear in the feed — blockingGateInboxItems no longer skips a
backlog-lifecycle approval finding on gateStatus (the old
NeedsMe/SignOff-gated skip dropped the row entirely for exactly the two
demoted shapes this file documents: a GroupParkedDecision task like
1018/1207/HOMELAB-INFRA-8/9, and a GroupWaiting agent-filed verify-followup
like the 1532/1594/1607 shape). The shared gateStatus override in
collectInboxItems still demotes contract/needs_you_group for those two
shapes (the same mechanism that already handles
gate:live_check_awaiting:*), but row existence is decided entirely by
pendingEvidenceGates's own lifecycle<>'cancelled' AND NOT verified
filter. Every gate:approval row carries task_id, task_title (the task's
title), and project (the task's project slug) — before ARGUS-1658, only
task_id was set and identifying the task required a query_sql join on
task_evidence (the ARGUS-1658 incident: Aaron could not find TDARR-27 on any
surface despite its pending approval). No /inbox/{id}/resolve verb exists
for gate:approval: (same 404 as the other gate: shapes); the fix is
exercising the approval via POST /approvals/{id} with the
ARGUS_APPROVE_TOKEN, not touching the row.
gate:live_check:* collapses by task (ARGUS-1325). ARGUS-1301/1304
mints one held task_evidence row per live acceptance clause, correct for
the verify gate's AND-rule but wrong for the review surface — a task with
several clauses used to produce that many separate blocking rows
simultaneously. liveCheckGateInboxItems (internal/hub/inbox_api.go)
groups every pending live_check finding by task before building rows, so
there are exactly two shapes now:
- One pending clause: unchanged from before this task — id
gate:live_check:<evidence_id>,evidence_idset,subtitleis that clause's own text directly,live_check_clausesomitted. - Two or more pending clauses: id
gate:live_check:task:<task_id>,evidence_idomitted (no single id represents N clauses),subtitleis"<N> clauses pending review on <task title>", andlive_check_clauses— a list of{evidence_id, identifier, verify_hint}, one per still-pending clause — carries the per-clause detail a client expands. Reviewing the collapsed row isPOST /tasks/{task_id}/live_check/review-all(sameARGUS_APPROVE_TOKENboundary as the single-evidence endpoint,handleEvidenceReviewAll): it satisfies every currently pendinglive_checkrow on that task in one call, still writing onelive_check_reviewedtask_eventsrow per clause — the audit trail is as granular as calling the single-evidence endpoint once per clause would have produced. A clause that resolves independently (reviewed singly viaPOST /tasks/{id}/evidence/{evidence_id}/review, or superseded) drops out of the group on the next fetch without touching its siblings; once only one clause remains, the row reverts to the single-clause shape above.blocking_badge(blockingBadgeCount,inbox_contract.go) countscontract == "blocking"ROWS, so this collapse is also what makes the badge count tasks needing review rather than raw evidence rows — no separate badge logic exists to patch. The web Inbox page (handleUIInboxReview) routes on the same id-prefix distinction: agate:live_check:task:id posts toreview-all, everything else keeps posting to the single-evidence endpoint.
All three rows are entirely state-derived (recomputed live from
BlockingGates on every /inbox.json call, same convention as the
approval/human-blocked rows above) — pendingEvidenceGates batch-joins
task_evidence to task_items for every unsatisfied live_check/approval
row in a single SELECT, and crossStackHeldGates runs one query naming
exactly the ready tasks the cross-stack gate itself is holding — not a
per-task fetch — so there is nothing to dismiss, and no N+1 either in this
feed or in a client that joins it.
gate:live_check:* splits into tappable vs awaiting-citation
(ARGUS-1327/1338). Aaron's ruling: NEEDS YOU should hold only rows
"READY for me to actually tap on." A freshly-filed live_check row's
Identifier is, at mint time, always a verbatim echo of the acceptance
clause itself (both auto-filers — mintLiveEvidenceRows,
RunLiveCheckAutoFile — seed it that way) — nobody has recorded an actual
observation yet. liveCheckGateInboxItems now routes every row (single or
collapsed) through liveCheckClauseTapReady
(internal/hub/live_check_citation.go) before choosing its id scheme:
- Tappable — every remaining clause has a citation basis: id stays
gate:live_check:<evidence_id>(orgate:live_check:task:<task_id>for a collapsed row),deriveContractmaps itblocking, and it counts towardblocking_badge. - Awaiting citation — at least one remaining clause is still a bare
echo: id becomes
gate:live_check_awaiting:<evidence_id>(orgate:live_check_awaiting:task:<task_id>for a collapsed row).deriveContractmaps thisreview— a subdued, discoverable waiting state, never a blind decision — and it is excluded fromblocking_badge. There is still no/inbox/{id}/resolveverb for either id scheme (same 404 asgate:live_check:*always had); the fix is a citation landing, not a resolve call.
Citation detection (isBareClauseEcho + verifyHintNamesFileLineCitation,
live_check_citation.go, combined inline into BlockingGateFinding.IsCitation
by pendingEvidenceGates in blocking_gates.go): a row is citation-bearing when
EITHER its Identifier no longer matches the clause text verbatim (a real
observation was recorded, e.g. "docker inspect: argus-memory-1 Created
timestamp unbroken") OR its VerifyHint names a concrete file:line
reference (ARGUS-1338's carve-out for a [met: cite path/to/file.go:93]
attestation — mint-time Identifier is always the bare clause regardless
of tag, so without this a same-PR citation had nowhere to register).
VerifyHint is otherwise deliberately NOT trusted as a citation signal on
its own: it is the builder's [deferred: reason] explanation of what
still needs checking, not a completed observation — rich, detailed
verify_hint prose that names tests and task ids is exactly the shape
that sat in NEEDS YOU all evening pre-ARGUS-1327 despite citing nothing
that had actually happened. A collapsed multi-clause row also promotes via
citesClause — a sibling row in the same group whose citation names
another member's clause counts for that member too. Each clause's own
per-clause readiness rides along on InboxLiveCheckClauseRef.TapReady
(json:"tap_ready") regardless of the row's overall id scheme, for a
client that wants finer-grained rendering inside an otherwise-review card.
Promotion needs no separate signal: liveCheckGateInboxItems recomputes
tap-readiness live on every /inbox.json fetch, so a citation landing
(orchestrator add_evidence — both the MCP tool and the raw POST
/tasks/{id}/evidence route now fire sitrep_changed for a fresh
live_check row — builder attestation, or machine resolution) promotes
the row within one derivation cycle. SweepGateNudges
(blocking_gate_push.go) reuses the same liveCheckClauseTapReady rule so
it never pages Aaron's phone for an awaiting-citation gate, and
handleUIInboxReview (ui_inbox.go) parses the gate:live_check_awaiting:
id scheme so the row stays actionable from the web board even though it
never appears in NEEDS YOU or the badge.
Plugin artifacts (POST /plugins/artifacts, GET /plugins/artifacts.json, GET /plugins/artifacts/{id}.json) — ARGUS-903¶
The public, bearer-authenticated write/list/get surface a plugin bundle's
out-of-process container uses to land its output as a bundle-namespaced
artifact. ARTIFACT-FIRST (Aaron ruling, 2026-07-21): plugin output lands here
and is never auto-distilled into recall, the same posture as the existing
internal agent_artifacts store (research reports / audit findings) this
surface deliberately does NOT share a table with — that store is for
in-process agent-run output; this one is the public door a container holding
only the shared capture bearer can reach. The podcast service's episode
writes (ARGUS-880) are the first consumer.
- All three routes sit in the same bearer-gated "capture group" as
/plugins/adoptand/plugins/update—Authorization: Bearer <ARGUS_CAPTURE_TOKEN>, the shared bearer every out-of-process plugin container holds. There is no per-bundle credential yet. - Namespace isolation. A bundle only ever declares SOME set of artifact
namespaces via its manifest's
capabilities.artifacts(provisioned intoplugin_bundle_artifact_namespacesin the same transaction as the bundle row, ARGUS-897) — the namespace string need not equal the bundle's own name (e.g. bundlepodcast-rssmight declare namespacepodcast-rss-notes). Every write'sbundlefield is validated against that table (mirrorsBundleSecretStore.Allowed's pattern forcapabilities.secrets,plugin_secrets.go): an unregistered namespace is400, never silently accepted or misrouted into someone else's namespace. There is no per-bundle write token to scope the call by instead — the registered-namespace check IS the isolation boundary. - Retention hooks.
plugin_artifacts.namespaceis a foreign key intoplugin_bundle_artifact_namespaces(namespace)with noON DELETE CASCADE: a namespace registration can never be dropped out from under artifacts that still reference it. There is no uninstall flow yet: this is the structural hook a future one must go through — it will need to consultplugin_bundle_artifact_namespaces.retainedand handle existingplugin_artifactsrows explicitly (retain or explicitly purge) rather than being able to cascade them away silently.
POST /plugins/artifacts — body:
{"bundle": "podcast-rss-notes", "kind": "episode", "title": "Episode 12",
"body_markdown": "# Episode 12\n\nshow notes", "metadata": {"feed": "acme", "guid": "g1"},
"external_id": "g1"}
bundle, kind, title, body_markdown, and external_id are required
(400 on any missing); metadata is an optional arbitrary JSON object
({} when omitted). Idempotent upsert on (bundle, external_id): a repeat
write with the same external_id in the same namespace updates that same
row (title/body/metadata/kind all refresh, updated_at advances) — it never
creates a duplicate. The SAME external_id in a DIFFERENT namespace is a
different artifact (isolation applies to idempotency too). Success is 200
{"artifact": {"id", "bundle", "kind", "title", "body_markdown", "metadata",
"external_id", "created_at", "updated_at"}}.
GET /plugins/artifacts.json?bundle=<namespace>&before=<id>&limit=<n> —
bundle is required (400 without it; there is no cross-namespace list).
Newest-first, ?before=<id> cursor (same semantics as GET
/config/audit.json above, NOT the ascending after_id/recent default);
limit 1-200, default 50. Response: {"artifacts": [...], "next_before":
<id>}, next_before omitted once nothing older remains. ?meta.<key>=<value>
(repeatable) ANDs top-level string-valued metadata containment filters,
e.g. ?meta.guid=g1.
GET /plugins/artifacts/{id}.json — one artifact's full body_markdown.
404 for an unknown id.
Plugin schedule member spec ownership (POST /plugins/update) — ARGUS-1242¶
A kind: schedule provides[] member's live schedule row carries a
spec JSON object that is NOT entirely manifest-controlled, and a bundle
author needs to know which half is which before relying on either:
- Manifest-owned keys — the ones
registerScheduleMember(internal/hub/plugin_install.go) itself derives from the manifest at install/update time:calls(built from the member'sentryagainst its bundle's connector-container endpoint, ARGUS-917). A plugin update always overwrites a manifest-owned key with whatever the new manifest computes, same as any other declared field. - Hub-owned keys — anything set on the live schedule OUTSIDE the
manifest, most commonly
spec.heartbeat_sourcestaged by hand throughPOST /schedules(a direct-call schedule declaring itself as a liveness source foringest_heartbeat, ARGUS-1225) ortimeout_seconds/asynctuned after install. A manifest never declares these, so a plugin author cannot set them fromplugin.yaml— they exist only because an operator (or a hotfix) added them straight to the schedule row.
Before ARGUS-1242, BundleUpdater.apply rebuilt a member's spec from the
manifest alone on every update, silently discarding every hub-owned key —
concretely, spec.heartbeat_source on plugin/karakeep/karakeep-sync,
hotfixed in on 2026-08-05 and then wiped by the very next routine update
(karakeep tracks latest). registerScheduleMember now merges: it starts
from the manifest-derived spec, then adds back every key the schedule's
CURRENT live row carries that the manifest-derived spec does not itself
declare. The manifest always wins for a key it declares; a hub-owned key
survives untouched across every future update, reinstall, or reissue of the
same member — a bundle author never needs to (and cannot) declare it to
keep it alive.
Separately: a schedule member the new manifest DROPS from provides[] is
tombstoned (deleteScheduleTx, the same append-only tombstone uninstall
uses — never a hard delete) rather than left an orphaned row that keeps
firing under a name no longer in the manifest. An adopt: true member is
never touched either way (its lifecycle stays entirely core-owned,
ARGUS-885), matching uninstall's existing adopted exception.