Feedback triage¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/feedback-triage.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What feedback-triage is for¶
The invariant you must not break: the agent and cron must act ONLY on
feedback submitted through the feedback UI, or on an issue an operator has
explicitly opted in with the feedback-agent label — and nothing on the
money path is ever auto-implemented. Break the first half and the agent
could open PRs against arbitrary in-flight development issues, corrupting
unrelated work. Break the second half and an agent could ship an unreviewed
change to charge, refund, dunning, or payment-method code. The gate is
enforced in two independent places, not one: the cron's data scope
(feedback_items only) and tools/feedback-gate/check.mjs's label-plus-
footer check. (Source:
autonomy-gate.ts:45-67,
check.mjs::shouldProceed, GH #1884.)
This domain covers three capabilities a recipient will reason about as one loop:
- In-product feedback capture — a merchant flags a bug or request from the admin without leaving the app; the widget auto-attaches the console-error trail, viewport, route, and build id so the report is actionable (GH #1884)
- Agentic triage — submitted feedback is classified (bug/enhancement/ question/kudos, priority, and a work-lane) within a cron cycle, and actionable items become tracked GitHub issues automatically (GH #1884)
- Autonomous fix for low-risk items — a
ui/contentbug can be implemented by an agent as a PR-to-devthat a human reviews and merges; the agent never merges its own work (GH #1884)
The load-bearing decisions:
- The autonomy gate lives in code, not the model —
autonomy-gate.ts::applyAutonomyGateis authoritative; the judge model'sstatusfield is advisory only. Onlybug+ui/contentmay stayready-for-agent; anything touching the money path (charge, refund, payment, dunning, billing, card, vault — matched over both the item text and the judge's own rationale) is forced toready-for-human. A hallucinating judge cannot self-escalate. (GH #1884; rally-hqdocs/decisions/0005-feedback-triage-loop.mdshape — a sibling repo at~/Workspace/dev/ref/rally-hq/, outside this repo tree, so cited by path rather than a resolvable link; money-path override added in this port.) - Provenance is a structural double-gate, not a convention — the cron
reads only
feedback_items, whose sole writer is the capture route, so ongoing-dev issues are unreachable from the triage side; the implementer workflow additionally requires (labelfeedback+ afeedback-metafooter) OR an operatorfeedback-agentopt-in label, enforced by the unit-testedtools/feedback-gate/check.mjs. This is the operator's stated constraint — the agent must never touch work it wasn't handed. (GH #1884) - Four separated substrates, ported from a proven loop — capture (D1) / judge (cron + LLM) / queue (GitHub issues) / implementer (GHA) are independent, so a failure or a missing secret in one stage degrades gracefully instead of breaking the others (rally-hq ADR-0005 lineage).
- The PR is the human gate —
feedback-agent.ymlopens a PR todevand never merges; combined with the existing operator-gateddev → mainrelease, no agent-authored change reachesmainwithout two human checkpoints. (GH #1884)
Canonical-framing attestation (operator-ratified 2026-07-02). The loop
is a four-stage pipeline, each stage on its own substrate, ported from
rally-hq's ADR-0005 feedback-triage loop: capture (apps/admin FeedbackWidget
→ POST /api/v1/feedback → D1 feedback_items) → judge
(apps/api/src/crons/feedback-triage.ts, a forced-tool Claude call) → work
queue (GitHub issues, filed with a machine-readable feedback-meta footer)
→ implementer (.github/workflows/feedback-agent.yml, which opens a PR to
dev and never merges). There is no competing hand-written or
model-authoritative gate; the deciding artifact is GH #1884, and the
autonomy gate and provenance gate are both canonical in code, confirmed by
reading autonomy-gate.ts (a pure, dependency-free function) and
check.mjs::shouldProceed directly — not inferred from the workflow's
coarse label if, which is only a pre-filter.
How it actually works¶
Capture — POST /api/v1/feedback¶
handleFeedbackPost
(routes/feedback.ts:132-229)
validates the body (8-4000 chars, optional 140-char title, optional 16KB
JSON context, optional base64 screenshot capped at 2MB decoded), then:
- Checks a per-store hourly cap (10/hr) with a direct D1
COUNT(*)query — deliberately not the WorkersRATE_LIMITERbinding, whose fixed 5/hr budget is the wrong shape for this surface. - If a screenshot is present, decodes and size-checks it before the R2
PUT; theFEEDBACK_SCREENSHOTSbinding is optional, so a submission still lands without the attachment when the binding is absent (tests, pre-provision deploys). - Derives
personaserver-side from the verified JWT (derivePersona,routes/feedback.ts:128-130) — the client never asserts who it is. - Inserts the row with
status='new'. A same-store, same-body, same-UTC-day resubmission hits theidx_feedback_items_daily_dedupunique index and is acknowledged as{deduplicated: true}with a 200, not an error — so a double-click never surfaces a failure to the widget.
Judge — the triage cron¶
runFeedbackTriage
(crons/feedback-triage.ts:201-310)
runs on a 5-minute clock gate registered in worker.ts's scheduled handler,
and no-ops immediately if ANTHROPIC_API_KEY is unset or no status='new'
rows exist. Per tick:
- Pulls up to 10
newrows, oldest first. - Loads the last 50 open items (across
ready-for-agent,ready-for-human,needs-info,needs-design,needs-product) to give the judge duplicate-detection context. - Per row, claims it with an atomic conditional
UPDATE ... SET status='triaging' WHERE id=?1 AND status='new'— a concurrent tick cannot double-process the same row (claimed.meta.changes === 0skips it). - Calls the Anthropic Messages API with
tool_choice: {type: 'tool', name: 'triage_feedback'}— a forced tool call, so the verdict is schema-validated at the model boundary rather than hand-parsed from free text (callJudge,crons/feedback-triage.ts:137-168). - Passes the raw verdict through
applyAutonomyGate— this is the only place the model'sready-for-agentclaim can survive or get downgraded. - If the (gated) status is one of
ready-for-agent,ready-for-human,needs-design,needs-productandFEEDBACK_GITHUB_TOKENis set, ensures the label catalog once per run and files a GitHub issue viacreateIssue, appending thefeedback-metafooter (buildFeedbackFooter) that both the loop-back webhook and the provenance gate key on. - Writes the verdict onto the row regardless of whether an issue was filed.
- On any error mid-item, releases the claim (
triaging → new) so a later tick retries it, and incrementssummary.errorsrather than aborting the batch.
Read the classification decision tree as: not-understandable/vague →
needs-info; spam/out-of-scope → wontfix; restates an open item →
duplicate; praise-only → wontfix; a bug needing a UX decision →
needs-design, else gated by blast radius (ui/content →
ready-for-agent, everything else → ready-for-human); a change request
not yet agreed worth building → needs-product, else needs-design or
ready-for-agent/ready-for-human by mechanical-vs-non-trivial. The system
prompt states the money-path exclusion explicitly to the model
(crons/feedback-triage.ts:70)
— but that instruction is advice to the model, not the enforcement
mechanism; applyAutonomyGate is the enforcement mechanism, applied
unconditionally after the call returns.
Implementer — the GitHub Actions agent¶
.github/workflows/feedback-agent.yml triggers on issues: labeled and
gates on github.event.label.name == 'ready-for-agent'. That if is a
coarse pre-filter only; the real gate is its Provenance gate step, which
shells out to tools/feedback-gate/check.mjs against the live issue's
labels and body. shouldProceed
(tools/feedback-gate/check.mjs:18-24)
returns true only when the issue carries the feedback label and a
<!-- feedback-meta footer, or carries an operator-applied
feedback-agent label — exit 0 proceeds, exit 1 is a no-op the workflow
still treats as a successful run. Only if that passes does the
claude-code-action@v1 step run, and its prompt explicitly instructs the
agent to branch off origin/dev, implement only the brief's stated scope,
never touch charge/refund/payment/dunning/billing/card/vault code, and
never merge — opening a PR whose body (not commit subject) carries `Fixes
N`.¶
Loop-back — POST /webhooks/github¶
handleGithubFeedbackWebhook
(routes/feedback.ts:309-372)
verifies X-Hub-Signature-256 with a timing-safe HMAC-SHA256 compare
(verifyGithubSignature) before touching the payload, ignores any event
that isn't issues, and matches the issue back to a feedback_items row
by parsing the feedback-meta footer out of the issue body
(parseFeedbackMeta) — never by issue number alone, so an unrelated repo
issue can never flip a feedback row. deriveStatusFromIssueEvent
(routes/feedback.ts:291-307)
maps a closed action to resolved or wontfix (by state_reason), and a
labeled/unlabeled/reopened action to whichever status label is
present, in a fixed precedence order.
No dedicated sequence diagram exists for this domain yet — Input-B records
this explicitly rather than a diagram being omitted by oversight. A
capture → judge → issue → implementer → loop-back sequence is the obvious
candidate for docs/architecture/sequence-diagrams.md; until it's authored
there, this section's prose is the only mechanism reference, and the
citations above point at the actual functions rather than a picture of
them.
Where intent and reality diverge¶
Four typed deltas, each cited to its source:
- Named-deferred — the entire pipeline is dormant. No stage has run
live: no cron tick has processed a row, no issue has been filed by the
judge, and no agent PR has opened. The code is merged to
devand unit-verified, but inert untildocs/runbooks/feedback-assistant-activation.mdruns — the triage cron no-ops withoutANTHROPIC_API_KEYon thesubs-apiworker, issue-filing no-ops withoutFEEDBACK_GITHUB_TOKEN, the webhook returns 502 withoutFEEDBACK_GITHUB_WEBHOOK_SECRET, andfeedback-agent.ymlneeds theANTHROPIC_API_KEYActions secret. Activation is operator-gated:subs-apilives on a personal Cloudflare account reachable only via a GH secret the local agent's tokens can't read, and Anthropic key creation is Console-only. - Verified-but-incomplete — the capture API accepts and stores an
opt-in screenshot to R2 (
handleFeedbackPost, base64 → decode → size-cap →FEEDBACK_SCREENSHOTS.put), but the admin widget (apps/admin/src/components/Feedback/) does not yet capture one — the backend supports the affordance the UI doesn't yet expose. - Built-but-untrodden — the capture route writes exactly one
feedback_itemsrow per submission, and the webhook matches rows only via thefeedback-metafooter; there is deliberately no bulk or mass-mutation surface. A recipient should not look for one, and adding one would need a new gate of its own — the current gate design assumes one row, one issue, one footer. - Contract-verified, not live-verified — the webhook loop-back
(
handleGithubFeedbackWebhook, timing-safe HMAC over the raw body) is unit-tested for signature validity, invalidity, and the unconfigured case (feedback.test.ts:207,:215), but has never received a real GitHub delivery. It returns 502, not 503 — the typed-error system has no 503 variant, andupstreamFailure(which both this route and the sibling/webhooks/striperoute call) is hardcoded to 502 inerrors/translate.ts:26. This matches Input-B's claim exactly, and directly contradicts a comment at the top ofroutes/feedback.tsclaiming "503... mirrors /webhooks/stripe" — see Confidence notes below.
Live-state attestation (operator-ratified 2026-07-02). As of this
trace, no feedback_items row has been written by a deployed worker, no
issue has been filed, and no agent PR has been opened. apps/api has no
own wrangler.toml — its config is infra/cloudflare/wrangler.toml
(worker name = subs-api) — and the CF account the repo's local wrangler
token authenticates against is not the account subs-api actually runs on;
the agent cannot enumerate that worker's live secret set from this
environment. deploy-api.yml only runs on tag or manual dispatch, and no
deploy carrying migration 0048 has been dispatched. The no-op guards are
confirmed by reading the code directly: feedback-triage.ts reads
env.ANTHROPIC_API_KEY and github.ts's caller reads
env.FEEDBACK_GITHUB_TOKEN; both are optional fields on Env
(types.ts).
How to operate & extend¶
- Activate the loop: follow
docs/runbooks/feedback-assistant-activation.mdin order — it is operator-gated end to end (Console-only Anthropic key creation, personal-CF-account worker secrets, Actions-secret write access none of the local agent tokens have). Feedback capture itself works as soon as migration0048is applied; screenshots additionally need thesubs-feedback-screenshotsR2 bucket to exist first, or the deploy step fails on the binding. - Widen the auto-PR lane:
AUTO_PR_SUBTYPES(autonomy-gate.ts:45) is the single set controlling which bug subtypes may stayready-for-agent. Adding a subtype here is a real autonomy expansion — it is not gated by anything else once the money-path regex doesn't match, so treat it as a decision, not a config tweak. - Extend the money-path exclusion:
MONEY_PATH_RE(autonomy-gate.ts:48) is the one pattern that forcesready-for-humanregardless of subtype. It matches over both the raw item text and the judge's own rationale — extending the vocabulary (a new payment-adjacent term) is a one-line, low-risk change; narrowing it is not. - The invariant you must not break: the cron's data scope
(
feedback_itemsonly) andtools/feedback-gate/check.mjs's label-plus-footer check are the two independent enforcement points for "the agent only touches feedback-loop-owned work." A change to either one needs the other re-verified alongside it — they're independent by design, not redundant. - Extension seam — the judge model:
DEFAULT_MODELand the system prompt live incrons/feedback-triage.ts; the structured-output contract (TRIAGE_TOOL) is the only surface the model can affect, sinceapplyAutonomyGatere-derives the actionable decision regardless of what the model claims forstatus. - Extension seam — new issue-state labels:
STATE_LABELS_BY_PRECEDENCE(routes/feedback.ts:272-278) is the fixed precedence list the loop-back webhook reads; a new decision lane needs an entry here and inLABEL_CATALOG(feedback/github.ts:39-56).
Confidence notes¶
- A code comment in
routes/feedback.tsis factually wrong about the webhook's unconfigured-secret status code. The file header (routes/feedback.ts:17-18) says the webhook route returns "503 when unconfigured... mirrors /webhooks/stripe." Tracing the actual call (upstreamFailure('github', 'feedback_github_webhook_not_configured')) througherrors/translate.ts:26showsupstream_failuremaps to 502, and a direct test assertion atfeedback.test.ts:207confirms 502. The sibling Stripe webhook (routes/webhooks-stripe.ts:268) calls the sameupstreamFailurehelper, so the behavior genuinely mirrors Stripe's — the comment's specific number is stale. This matches Input-B's typed delta exactly (which already states 502, not 503) and is a clean example of why this corpus traces code instead of trusting a header comment. - The traced unit-test count does not match Input-B's cited "47/47."
Input-B's live-state attestation cites "
applyAutonomyGate+ capture route 47/47 (autonomy-gate.test.ts,feedback.test.ts)." Countingit(blocks directly in this tree:autonomy-gate.test.tshas 13, andfeedback.test.tshas 19 — 32 total, not 47. Thecheck.mjs::check.test.mjs6/6 figure Input-B cites for the provenance gate does match (6it(blocks counted). I could not run the suites in this worktree to get a live pass/fail count —apps/api's vitest config fails to resolve@cloudflare/vitest-pool-workershere (a known worktree-npm-install gap, not specific to this domain) — so this is a static block-count discrepancy, not a proof that fewer assertions exist (a singleitcan carry more than oneexpect, and the 47 could be an assertion count rather than a block count). Flagging as a contradiction worth a human look, not resolving it here. - No sequence diagram exists for this domain. Input-B records this
explicitly (
## Visual aidsin the Input-B file says "none yet"), and I confirmed no feedback-specific entry exists indocs/architecture/sequence-diagrams.md's table of contents. This page therefore has no transcluded diagram in Move 2 by design, not by omission — the obvious candidate (capture → judge → issue → implementer → loop-back) is noted as future work, not fabricated here.