Skip to main content
RStack’s governed loop stops at human gates by design — a missing artifact approval, a spent attempt budget, an unresolved Decision Queue item. The Business Hub is where a manager actually sees and clears those gates from a browser, without shelling into the run directory. This page covers the three surfaces that carry that work: the Action Inbox (one aggregated queue), the Approvals page (the page that can actually act), and the Decisions page (the DoR/Decision Queue). Guardrail-override one-shot approvals and the audit trust boundary that every approval record passes through are covered inline, because both pages render them.

How it works

Action Inbox — one source-linked queue, read-only

src/observability/dashboard/ui/pages/action-inbox.js renders s.actions into a single filterable list (all / blocking / approvals / decisions / failures / resolved, via ACTION_INBOX_FILTERS). Each action already carries a type (approval, decision, configuration, alert, audit, or a generic failure), a severity, a status, an availability flag, and an allowedActions array. actionInboxIsResolved treats approved/rejected/consumed/resolved/expired as closed. The Inbox never mutates state itself — actionInboxCardHtml renders a route button (actionInboxRoute) that sends you to the surface that can: an approval action routes to approvals, a decision routes to decisions, an audit action (a rejected approval record) also routes to approvals since that’s where the Audit Rejections panel lives, and everything else falls through to alerts-guardrails. If the server didn’t list any allowedActions for the action (or availability !== 'available'), the card says plainly “No mutation available here” instead of implying a button exists.

Approvals page — the surface that can act

src/observability/dashboard/ui/pages/approvals.js splits s.approvals into pending (!item.status || item.status === 'pending') and resolved (anything else, capped to the last 20). Each pending card renders an Approve / Reject button pair that calls resolveApproval(id, action). Two things a card can say about itself, both server-derived, never guessed client-side:
  • One-shot guardrail overrides. An approval whose artifact starts with guardrail-override: is flagged with a note explaining that approving it grants exactly one further attempt for the blocked task, after which the override is spent and the task blocks again on the next attempt.
  • Consumed lifecycle. item.lifecycle === 'consumed' (computed server-side by annotateApprovalLifecycle in state/approvals.js, cross-referencing the run-level approval history) flips the card’s status to consumed so a spent override never reads as a standing “approved” forever.

Audit Rejections — the tampering-visibility panel

The same module injects an Audit Rejections panel (OPS_AUDIT_PANEL_HTML) fed from approval_audit_failed events in the feed. Every approval record is audited before it is trusted (harness-side, see below); a record that fails that audit is treated as absent — the gate it would have unblocked stays closed — and this panel is where that rejection becomes visible instead of silently disappearing. Each entry shows the artifact, the audit’s stated reason, any structured issues[], the record id, and the claimed status.

Decisions page — the Decision Queue and DoR status

src/observability/dashboard/ui/pages/decisions.js renders three things from buildDecisionState (state/decisions.js, which reads readDecisions/dorCheck per run): the pending Decision Queue items (decision_id, question, recommendation, impact, required_before_stage), a Readiness list (per-run Definition-of-Ready PASS/WARN/FAIL with a score), and a Decision Log — the chronological record of who resolved or waived each decision and when, built client-side from any decision whose status is resolved or waived. This page is read-only in the sense that resolving a decision happens through sdlc_decisions / rstack-agents decisions, not a Hub button — no fetch call for decisions appears in this module.

Guardrail triggers, retry state, and overrides (Alerts & Guardrails)

ui/pages/alerts-guardrails.js is the deeper operational view behind a guardrail block: a Retry State panel folds retry_decision and task_retry_* events per task into a state (scheduled, exhausted, human_required, validator_blocked, resolved), and a Guardrail Triggers panel lists guardrail_triggered events. For each trigger, opsOverrideStatusHtml looks up the exact override — checking for a guardrail_overridden consumption event first, then the run’s own approvals[] for a guardrail-override:<taskId> record — and reports one of four honest states: consumed, approved (with approver), rejected, or “no override on file, the task stays blocked.”

How a browser approval reaches .rstack/approvals.json

resolveApproval(id, action) in approvals.js first resolves an approver name (prompted once, cached in localStorage) and an approval token (prompted once, cached in sessionStorage), then POSTs to /api/approve or /api/reject with the token in the x-rstack-approval-token header. Server-side (src/observability/dashboard/server.js):
  • The route is disabled by defaultapprovalAuthError returns 403 unless RSTACK_APPROVAL_TOKEN (or RSTACK_APPROVAL_TOKEN_FILE, re-read per request so rotation needs no restart) is set.
  • CSRF is closed by requiring the token header and a same-origin/absent Origin (LOCALHOST_ORIGIN) — a cross-site form POST can’t set custom headers and would carry a foreign origin anyway.
  • The handler calls into resolveApproval(projectRoot, id, decision, resolvedBy, options) in src/core/tracker/approvals.js, which locks the project’s .rstack/approvals.jsonl queue for the read-modify-write, then (outside the lock) writes the run-level approvals.json record via appendRunApproval with status: APPROVED or REJECTED.
Content/digest binding. For an APPROVED record whose artifact is file-backed, resolveApproval calls computeApprovalArtifactDigest (from src/core/harness/approval-audit.js) to hash the artifact’s current bytes with SHA-256 and stores it as artifact_sha256 on the run record. Virtual approvals (guardrail overrides, stage ids) resolve to no file and carry no digest — that’s expected, not a gap. This digest is what lets the claim gate detect a TOCTOU: if the artifact’s bytes change after approval but before the gated work is claimed, the approval no longer matches what was signed off. The written record also carries actor: { name, via: 'api', tokenVerified: false, ts } and a source: 'business-hub' marker, so anything reading run history can tell a dashboard-originated approval apart from one made via sdlc_approve. Every approval record — regardless of origin — passes through validateApprovalRecord / auditRunApprovals / trustedApprovedArtifacts in approval-audit.js before a gate will trust it: casing, actor shape, timestamp validity, run binding, and (if RSTACK_APPROVAL_SIGNING_KEY is configured) an HMAC signature over artifact, status, runId, id, timestamp, approver, and artifact_sha256 together. A record that fails this audit is dropped — which is exactly what feeds the Audit Rejections panel above.
There is no email-based or link-based approval flow in this code path — approvals happen either via sdlc_approve from an agent/CLI context, or through this browser flow gated by RSTACK_APPROVAL_TOKEN. Don’t assume any other approval channel exists without checking the source.

Try it

Enable dashboard approvals and approve a blocked gate from the browser:
Open the Hub, go to Approvals, and click Approve on a pending card — you’ll be prompted once for your name and the token (both cached for the session). Reject the same way with Reject. To see the aggregated queue across approvals, decisions, and failures in one filterable list instead, open Action Inbox.
Without RSTACK_APPROVAL_TOKEN (or _FILE) set, /api/approve and /api/reject return 403 — the dashboard cannot mint manager identity from an unauthenticated request. Approve via sdlc_approve instead, or set the token to enable the browser path.