WiseAI Realtor — Aria Feedback Loop (capture + triage-queue foundation) Expected Output Acceptance Spec
📍 CANONICAL LOCATION NOTE (read first)
The canonical home for portfolio acceptance specs is the separate
C:\dev\knowledgegit repo atknowledge/acceptance/realtor-feedback-loop-mvp.md. This agent built the feature inside the isolatedchurchwiseai-webworktree and could not write to the siblingknowledge/repo from here, so the spec is committed alongside the code indocs/acceptance/for reviewability. REVIEWER ACTION: copy this file toknowledge/acceptance/realtor-feedback-loop-mvp.md, add it toknowledge/acceptance/README.md's index if needed, and runpnpm derivefromC:\dev\knowledge. (Same pattern the founder uses for cross-repo features per project_cdev_three_git_repos.)
WiseAI Realtor — Aria Feedback Loop (capture + triage-queue) Expected Output Spec
⛔ STATUS: DRAFT — NOT APPROVED. CLAUDE.md Rule #17 (HARD GATE) is NOT satisfied.
Stage-1 agent research. The MVP code (capture + queue) is authored against this spec for the QA reviewer, but the migration is FOUNDER-GATED (do not apply) and the triage half is DESIGN + STUB ONLY. Do not schedule any cron or build the triage agent until the founder approves this spec and confirms the open items in §11.
Sourced from (read-only, 2026-07-02): the founder's vision — "the realtor types an issue to Aria — a bug, or 'I don't understand X' — that gets captured and routed so Claude agents can later triage it on a schedule and PROPOSE a fix the founder approves before anything ships";
do-not-reinvent.md(Aria today is READ-ONLY, setup-only, zero write tools —onboarding/assistant/*);auth-rbac.md(the realtor RBAC resolver +settings:profile:readfloor held by every active role and the legacy synthetic owner);ai-config/route.ts(the canonical per-tenant realtor API pattern — RBAC guard,account_idfrom query/header never body, honest degrade when a migration is unapplied); the CRITICAL memory rules (per-account writes UPSERT not.update().eq; authed client fetches send?account_id=in the query because middleware strips the header; verify DB columns before use).
0. Scope — what this MVP covers, and what it does NOT
Covers (this PR):
- A durable, tenant-scoped capture queue table
realtor_feedback(migration, founder-gated — reviewer/founder applies it). - A capture API —
POST /api/realtor/feedback(append a feedback row) andGET /api/realtor/feedback(list this account's rows, paginated) — owner/agent authed, tenant-scoped,account_idin the query. - A capture UI — a small, honest "Report an issue or ask for help" affordance
in the realtor dashboard (surfaced from the Aria panel) that posts the message
- current screen route.
- A triage-loop DESIGN (this spec §6) + a read-only cron stub proving the auth + query shape for the future founder-approved triage agent.
Does NOT cover (explicitly out of scope / deferred to a founder-approved phase):
- Any auto-classification, auto-diagnosis, or auto-fix by an LLM.
- Any auto-merge, auto-deploy, or auto-send (email/SMS/push) of anything.
- Wiring the triage cron into
vercel.json(a schedule is a founder action). - Giving the Aria LLM any write tool — Aria stays read-only; capture is a deterministic form, not an LLM tool call.
- A full founder review console (Phase 2 — for the MVP the founder reads the
realtor_feedbacktable directly; a dashboard read-view is optional, §6.3). - Editing/deleting feedback rows from the client; a realtor can only append and list their own.
Build posture: mirror ai-config/route.ts exactly (RBAC guard, account_id
resolution, honest degrade on unapplied migration). This is a capture-and-queue
foundation, not a fix-it engine. The "self-annealing" name describes the intended
END STATE (agents propose, founder approves, fixes ship) — this MVP builds only
the first link (capture) and specs the rest.
1. The vision this serves (why capture, why a queue)
The realtor is the customer. When something breaks or confuses them, that signal
today is lost — Aria can only point at existing controls; there is no path for
"this is wrong" to reach the team. This MVP gives that signal a durable home so a
future scheduled Claude agent can read it, propose a diagnosis/fix into
ai_triage, and surface it for the founder to approve. Nothing about the loop
ever acts on the customer's account or ships code without the founder pressing go
— consistent with the AI-Bridge principle (we facilitate a human, we don't
silently act) and every CLAUDE.md guardrail (founder presses sends/merges/deploys).
2. Data model — realtor_feedback (the queue)
One append-only row per submission. Columns (migration
churchwiseai-web/migrations/20260702_realtor_feedback.sql, founder-gated):
| Column | Type | Notes |
|---|---|---|
id | uuid PK (gen_random_uuid()) | |
business_id | uuid NOT NULL → local_businesses(id) | tenant anchor (== account) |
account_id | uuid NOT NULL | mirrors business_id for query symmetry with the RBAC resolver; both set to the resolved account |
submitted_by_user_id | uuid NULL | Supabase Auth uid of the submitter, or NULL for a legacy synthetic-owner session |
screen_route | text NULL | the /realtor/app/* route the realtor was on (context for triage) |
message | text NOT NULL | the realtor's words (bounded length; see §3) |
category | text NOT NULL DEFAULT 'unclassified' | CHECK ∈ {bug, confusion, feature_request, unclassified} — the realtor may self-tag; the future agent may re-classify |
status | text NOT NULL DEFAULT 'new' | CHECK ∈ {new, triaged, approved, dismissed, resolved} |
ai_triage | jsonb NULL | RESERVED for the future agent's proposed diagnosis/fix — never written by this MVP's capture path |
founder_note | text NULL | RESERVED for the founder's decision note |
created_at / updated_at | timestamptz NOT NULL DEFAULT now() | updated_at maintained by the shared local_business_set_updated_at() trigger |
Indexes: (business_id), (status), (business_id, created_at DESC) for the
tenant list.
RLS + grants: ENABLE ROW LEVEL SECURITY deny-by-default; GRANT … TO service_role only (no anon/authenticated grant, no permissive policy) — identical
to the phase-6 tables. The app mediates all access through the service-role client
- the RBAC resolver (app-layer authz is the pattern;
get_advisorsstays clean).
Status lifecycle (who moves it):
new ──(future triage agent, writes ai_triage)──▶ triaged
triaged ──(FOUNDER, approves proposal)──▶ approved ──▶ resolved
triaged ──(FOUNDER, rejects)──▶ dismissed
This MVP only ever creates rows in new. Every later transition is a
founder-gated action; the cron stub in this PR does NOT transition rows (§6.2).
3. Capture API — POST / GET /api/realtor/feedback
Auth: requireRealtorCapability(request, 'settings:profile:read') — the same
floor Aria's endpoint uses; held by every active role template AND the legacy
synthetic owner, so any legitimate signed-in realtor can report an issue, and a
suspended/no-membership caller gets 401 (fail-closed).
Tenancy: account_id resolved via resolveRealtorContext(request) (header →
query). A business_id/account_id in the POST BODY is IGNORED — a caller only
ever writes their own account (mirrors ai-config/route.ts).
POST — appends one row:
- Body:
{ message: string, category?: 'bug'|'confusion'|'feature_request', screen_route?: string }. - Input is sanitized by the pure
sanitizeFeedbackInput()(client-safe, unit-tested): message trimmed, required, ≤ 4000 chars; category coerced to the enum (unknown →unclassified);screen_routeaccepted only if it is a/realtor/app…path, else dropped to null. - Writes
{ business_id: accountId, account_id: accountId, submitted_by_user_id: ctx.user?.id ?? null, screen_route, message, category, status: 'new' }via a plain.insert()(append-only — NO upsert needed; each report is a new row). - Error-checked: a DB error → 500 with the message; a missing-table error (42P01,
migration unapplied) → 503 with an honest "not enabled yet" message, never a
fake success. On success returns
{ id }(the created row id) +201.
GET — lists this account's feedback (the read side for a future review surface):
- Tenant-scoped
.eq('business_id', accountId), newest first, paginated with.range()(default page 50, cap 100) — never an unbounded select. - Returns
{ items: [...], nextOffset }. Missing table →{ items: [], available: false }(honest degrade).
Never: accept a cross-tenant id from the body; send any email/SMS/push; call an
LLM; write to any table other than realtor_feedback.
4. Capture UI — "Report an issue or ask for help"
A small, on-brand (navy #1B365D / teal #0F766E) affordance reachable from the
Aria panel footer ("Something not working, or unclear? Report it"), opening a
lightweight modal:
- Fields: a short category chooser (Bug · I don't understand this ·
Feature idea — maps to
bug/confusion/feature_request) and a message textarea. The current screen route is captured automatically (fromusePathname()), shown read-only so the realtor knows what context is attached. - Honest expectation-setting copy (load-bearing, AI-Bridge): "This goes to the WiseAI team. They'll review it and follow up. Nothing on your account is changed automatically." No promised timeline, no fake "an agent is fixing this now" language.
- States: idle → submitting → success ("Thanks — we've got it. The WiseAI team will review and follow up.") / error (honest retry). On the migration-pending 503, a clear "Issue reporting isn't switched on yet" message, never a false "saved".
- Submits via
feedback-client.ts→POST /api/realtor/feedbackwith?account_id=in the query (middleware strips the header — CRITICAL memory rule). - AODA: labelled modal dialog, Esc closes, focus moves to the textarea on open,
status announced via
aria-live, buttons labelled (not icon-only).
Aria's LLM gains NO new tool — the panel simply surfaces a deterministic form. Aria stays a read-only concierge; the capture path is plain HTTP.
5. Role-based visibility
| Bucket | Roles | Can report? |
|---|---|---|
| Any active member | every role template (agent … brokerage_owner) + legacy synthetic owner | YES — settings:profile:read is universal; anyone who can use the dashboard can report an issue |
| Suspended / invited / no membership | — | NO — 401 fail-closed |
Feedback is intentionally NOT gated to owners: the person who hits a bug is whoever should be able to report it. A future review console (§6.3) MAY gate the READ side more tightly (e.g. owners only) — deferred, and called out as an open item (§11).
6. Triage loop — DESIGN + STUB ONLY (no auto-fix)
6.1 The intended (founder-approved, future) loop
- A scheduled cloud agent (repo
/scheduleroutine or avercel.jsoncron) runs on a cadence and readsrealtor_feedbackrows wherestatus = 'new'. - For each row it classifies (bug/confusion/feature_request), and — for bugs —
proposes a diagnosis and a candidate fix (files, approach), writing that
PROPOSAL into
ai_triage(jsonb) and moving the row totriaged. - It surfaces the proposals for the founder (a digest, or a review view).
- The founder approves or dismisses each proposal. Only on approval does any
code change get authored — through the normal branch → PR → founder-merge flow.
The agent NEVER edits the customer's account, NEVER merges, NEVER deploys, and
NEVER auto-applies a fix.
founder_noterecords the decision.
This is "self-annealing" in the CLAUDE.md sense: a bounded, founder-gated propose-and-approve loop — explicitly NOT a fix-it-all machine.
6.2 What ships in THIS PR (the stub)
GET /api/cron/realtor-feedback-triage — CRON_SECRET-gated (Bearer or Vercel's
x-vercel-cron), runtime = nodejs. In this MVP it is read-only: it counts
status = 'new' rows and logs a summary, then returns { new: n, note: 'stub — triage agent not built; no LLM, no writes, no state change' }. It does NOT
transition rows, does NOT call an LLM, does NOT write ai_triage. It is NOT wired
into vercel.json (scheduling is a founder action). Its only job is to prove the
auth + query shape the real triage build will extend.
Design decision (recorded for the reviewer): the task allowed the stub to mark rows
triaged. We deliberately kept it read-only instead — a row intriagedimplies a human/agent actually looked at it, so an unattended cron flipping status with no real triage content would be dishonest state and could mask un-reviewed feedback. Read-only is the honest, lowest-risk stub and still proves the wiring. The real transition totriagedhappens only when the founder-approved agent writes an actualai_triageproposal.
6.3 Founder review surface (Phase 2 — optional for MVP)
For the MVP the founder reads realtor_feedback directly (Supabase). A read-only
in-dashboard list (owner-gated) that renders GET /api/realtor/feedback is a
nice-to-have and may ship later; it is NOT required for this spec to pass. The
GET endpoint is built now so that surface has a data source.
7. Guardrails (the honesty + safety contract the reviewer checks)
- No auto-fix / auto-classify / auto-merge / auto-deploy / auto-send anywhere
in this PR. Grep proof: the only writer of
realtor_feedbackis the capture POST (statusnew); nothing writesai_triage,status != new, orfounder_note; the cron stub performs no writes. - Founder-approval gate on every downstream transition — documented here, enforced by there being NO code path that transitions a row in this PR.
- Tenant isolation — every query filters
business_id = accountId; the POST ignores any body-supplied id; RLS deny-by-default + service-role-only grants. - Honest degrade — an unapplied migration yields a clear 503 /
available: false, never a fabricated success (mirrorsai-config/isMissingTableError). account_idin the query on every authed client fetch (middleware strips the header — CRITICAL memory rule).- Append-only capture —
.insert()not upsert; the upsert-vs-update footgun (silent 0-row no-op) does not apply because we never update an existing row on the capture path. - Aria stays read-only — no LLM write tool is added; capture is a plain form.
- Column verification — the route only touches columns defined in
20260702_realtor_feedback.sql; the reviewer must confirm the migration applied cleanly (information_schema.columns) before the endpoint returns anything but a 503. (This agent cannot run SQL — the migration is the source of truth and the reviewer applies + verifies it.)
8. Empty / loading / error states
| State | Expected output |
|---|---|
| Table not provisioned (migration unapplied) | POST → 503 honest "not switched on yet"; GET → { items: [], available: false }; modal shows the honest not-enabled message, never a false "saved". |
| Empty message | 400 "message required"; the modal disables submit until there's text. |
| Message too long (> 4000) | client trims/limits; server truncates via sanitizeFeedbackInput (never 500). |
| Submitting | button shows a busy state; double-submit prevented. |
| Success | the modal shows the honest thank-you + the returned id; the composer clears. |
| DB error | 500 with the message surfaced; modal shows an honest retry. |
| Loading the list (GET) | (future review view) skeletons, not a spinner. |
9. Accessibility (AODA → WCAG 2.1 AA)
- Modal is a labelled
role="dialog" aria-modal="true"; Esc closes; focus moves to the textarea on open and returns to the trigger on close. - Category chooser is a real fieldset/radio group or labelled buttons; status
changes announced via
aria-live. - Contrast ≥ 4.5:1; reduced-motion honored; no icon-only actionable controls.
10. Acceptance checklist (QA runs on the deployed URL)
Behavioural verification on wiseaiagency.com (real host — the realtor app
runs behind the hostname rewrite) against the demo tenant
(Terry & Sheri Real Estate, …0c01); never "build passes". Sample at ≥2
timepoints. All test.fixme until the migration is applied + founder approves.
test.fixme('an active member can open the Report-an-issue modal from the Aria panel and submit; a row lands in realtor_feedback with status=new, the correct business_id/account_id, the captured screen_route, and submitted_by_user_id', () => {});
test.fixme('POST ignores a body-supplied business_id/account_id — the row is written for the RESOLVED account only (cross-tenant write is impossible)', () => {});
test.fixme('a suspended / no-membership caller gets 401 from both POST and GET (fail-closed)', () => {});
test.fixme('GET returns only THIS account\'s rows, newest first, paginated via .range() (never unbounded)', () => {});
test.fixme('with the migration UNAPPLIED, POST returns an honest 503 and the modal shows the not-enabled message — never a false "saved"', () => {});
test.fixme('the modal copy sets honest expectations ("goes to the WiseAI team… nothing changed automatically") — no promised timeline, no "an agent is fixing this now"', () => {});
test.fixme('the cron stub is CRON_SECRET-gated, performs NO writes and NO state transition, and is NOT scheduled in vercel.json', () => {});
test.fixme('grep proof: no code path in this PR writes ai_triage, sets status!=new, writes founder_note, sends email/SMS, or calls an LLM', () => {});
test.fixme('Aria\'s LLM has no new write tool — the assistant endpoint\'s tool set is unchanged', () => {});
11. Guardrails for agents building against this spec + open items
- Rule #17 not satisfied — do not treat as approved; the migration is founder-gated (DO NOT apply as an agent).
- Reuse, do not reinvent: the RBAC guard (
rbac-server.ts), theaccount_id-in-query pattern (aria-client.ts), the honest-degrade helper (isMissingTableError), the sharedupdated_attrigger, and the Aria panel/store pattern. Do not add a new auth path. - Do NOT add any LLM step, cron schedule, review-console write path, or status-transition endpoint in this PR.
- If code diverges from this spec, update the spec first (founder approval), then the code.
Open items for Stage-2 founder interview:
- Should the GET/review side be gated tighter than the POST (e.g. owners-only read), or is any member seeing their account's feedback fine?
- Cadence + delivery for the future triage digest (in-dashboard? email to the
founder? a
/scheduleroutine?). - Is a Phase-2 in-dashboard review console wanted, or does the founder prefer to read the table directly indefinitely?
- Should the realtor get any acknowledgement follow-up (email) when the team acts on their report — and if so, that is a founder-gated send, not automatic.
End of spec. STATUS: DRAFT — NOT APPROVED. This MVP builds capture + queue only; the triage/self-annealing half is design + a read-only stub, gated on founder approval at every downstream step.