Skip to content

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 -> 401 with 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_TOKEN that 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 *.json surface (/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. limit optional (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 200 with an already_* marker (e.g. {"status":"ok","already_resolved":true}) and no double effect — clients on flaky mobile networks retry safely. Most kinds express this as an already_resolved: true field 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 same 200 {"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 202 with that SAME run's id (an already_queued marker, not a new row) instead of enqueuing a duplicate. POST /inbox/{id}/reconcile (ARGUS-743) is the reference example, backed by the agent_runs singleton-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}/pipeline returns one task pipeline with ordered steps BUILD, PR, LAND, DEPLOY, VERIFY. Each step has state: pending|active|ok|failed|skipped plus optional at, detail, reason, and link_key. A failed LAND step also carries optional failing_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's link_key is run:<agent_runs.id> when a matching run row exists. A verified task always composes BUILD as ok (ARGUS-543): if the run row that did it never got marked done (crashed heartbeat, killed process), the verified outcome overrides the dangling active state rather than leaving it stuck. A task's repo falls 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 /pipelines returns in-flight task pipelines (lifecycle=in_progress and verified=false) plus recently failed land/deploy rows, grouped by repo in landing-queue order, MINUS any composed pipeline whose VERIFY step is ok with no other step failed (ARGUS-543): the failed-land/deploy branches match on a row up to 72h old with no verified check, 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 at LAND.
  • The existing bearer-gated GET /events stream is the invalidation contract: task events invalidate task state/evidence/verification, landing events invalidate land status, and deploy events 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 verified to true while its lifecycle is still backlog, ready, or in_progress, it stamps lifecycle=done in the SAME write and records the prior value in lifecycle_before_done. blocked and cancelled are 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), lifecycle is restored to the remembered lifecycle_before_done (falling back to in_progress if none was recorded), and lifecycle_before_done is cleared.
  • done is never agent-writable. PATCH /tasks/{id}/lifecycle (and the chat set_lifecycle tool) validate against the same five agent-facing states as before; sending "to":"done" is a 400, 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: every approval: / gate:approval: row, and gate:live_check:*.
  • review — batchable judgment, nothing stuck: the everything-else default (triage/lint/memflag/parked/MLD/divergence rows), plus gate:disarmed_lane:*, gate:cross_stack_held:*, and gate:orphaned_partial:* — those three needs_human but stay review rather than blocking because none carries an inline resolving action (see Blocking-gate rows below); the 1274 invariant requires every blocking row be exercisable, enforced by TestInboxBlockingRowsWithoutAction and the live PushNotifier.CheckBlockingRowsActionable watchdog.
  • record — describes the past: every alert: / 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: id is approval:<task-id>, narrowed to EXERCISABLE tasks (ARGUS-477 — a freshly filed verify=approval task with nothing built yet is not exercisable). task_id and approval_id both 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 a schedule_approval_gates row directly — never a task_items row — so it never lingers on the board as a done/cancelled one-shot after being acted on. approval_id is the gate's own id (schedgate-N); there is no task_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_id set, subtitle is that clause's own text directly, live_check_clauses omitted.
  • Two or more pending clauses: id gate:live_check:task:<task_id>, evidence_id omitted (no single id represents N clauses), subtitle is "<N> clauses pending review on <task title>", and live_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 is POST /tasks/{task_id}/live_check/review-all (same ARGUS_APPROVE_TOKEN boundary as the single-evidence endpoint, handleEvidenceReviewAll): it satisfies every currently pending live_check row on that task in one call, still writing one live_check_reviewed task_events row 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 via POST /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) counts contract == "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: a gate:live_check:task: id posts to review-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> (or gate:live_check:task:<task_id> for a collapsed row), deriveContract maps it blocking, and it counts toward blocking_badge.
  • Awaiting citation — at least one remaining clause is still a bare echo: id becomes gate:live_check_awaiting:<evidence_id> (or gate:live_check_awaiting:task:<task_id> for a collapsed row). deriveContract maps this review — a subdued, discoverable waiting state, never a blind decision — and it is excluded from blocking_badge. There is still no /inbox/{id}/resolve verb for either id scheme (same 404 as gate: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/adopt and /plugins/updateAuthorization: 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 into plugin_bundle_artifact_namespaces in the same transaction as the bundle row, ARGUS-897) — the namespace string need not equal the bundle's own name (e.g. bundle podcast-rss might declare namespace podcast-rss-notes). Every write's bundle field is validated against that table (mirrors BundleSecretStore.Allowed's pattern for capabilities.secrets, plugin_secrets.go): an unregistered namespace is 400, 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.namespace is a foreign key into plugin_bundle_artifact_namespaces(namespace) with no ON 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 consult plugin_bundle_artifact_namespaces.retained and handle existing plugin_artifacts rows 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's entry against 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_source staged by hand through POST /schedules (a direct-call schedule declaring itself as a liveness source for ingest_heartbeat, ARGUS-1225) or timeout_seconds/async tuned after install. A manifest never declares these, so a plugin author cannot set them from plugin.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.