Skip to main content

0005. Model user feedback as one turn-anchored table with a frontend-owned instrument

Context and problem statement

HAI-Co² is a research instrument, so it needs structured feedback on the co-construction: the reply itself, the objective, plan, artifact(s) and preferences, plus a whole-trajectory reflection and technical reports. Before this change there was no way to give any of it. The hard constraint is that feedback must survive snapshots and branching (the same forces as ADR-0003): a fork must never lose or mis-attribute the prefix it shares with its parent, and the research aggregates must count each genuine opinion exactly once. Two further tensions make the design non-obvious: the question set is a research instrument that will keep changing, and a single turn can produce more than one artifact, each of which the user may rate separately.

Decision drivers

  • Branch and snapshot safety. Feedback must line up with the exact key the rest of the system already uses, and forks must inherit their shared prefix without double-counting.
  • An evolvable instrument. Researchers must be able to add, rename, or re-word questions and options without a backend change or a migration.
  • Per-artifact granularity. Several artifacts in one turn must be rateable independently, on Postgres and on the SQLite test database alike.
  • One opinion, counted once. Admin aggregates must reflect genuine opinions only.
  • Admin observability. An admin must be able to see what happened behind a piece of feedback (the trace, the conversation) to triage it.

Considered options

  1. Anchor: per-message index, turn_index, or a raw feedback id. Where a feedback row attaches.
  2. Vocabulary: backend enum vs frontend-owned taxonomy. Where the question/answer set lives and what the backend validates.
  3. Artifact identity: descriptive nullable column vs part of the unique key with a 0 sentinel. How several artifacts of one turn stay distinct.
  4. Branch feedback: read-through vs copy-on-branch with an origin flag. How a fork shows its inherited prefix feedback.

Decision outcome

One unified feedback table, discriminated by scope + aspect, with these choices:
  • Anchor on (thread_id, turn_index). This is exactly the key workspace_snapshots, artifacts and the trajectory-graph nodes already use, and the frontend already maps every assistant message to its turn. Feedback therefore lines up 1:1 with graph nodes and is branch/snapshot-safe by construction.
  • Structural-only backend validation; the instrument lives in the frontend taxonomy. The backend checks only the shape of a row (a known scope, a well-formed aspect slug, and the report/sentiment split). The full vocabulary (questions, options, reasons, conditions) lives only in frontend/src/lib/feedback-taxonomy.ts, so the instrument evolves with no backend change or migration.
  • artifact_id is part of the unique key, NOT NULL with a 0 sentinel. The unique key is (thread_id, turn_index, user_id, scope, aspect, artifact_id). 0 means “no specific artifact” (every non-artifact row, and a rating of the live document). The sentinel avoids the SQL trap where a nullable column in a unique index treats every NULL as distinct, which would silently break per-aspect uniqueness for all the non-artifact rows.
  • Copy-on-branch with an origin flag. On a fork the parent’s prefix feedback is copied (origin='branch_copy') so the shared history is visible; admin aggregates count only origin='user' so copies never inflate figures; editing a copy promotes it to origin='user', a genuine, independent opinion on that path (and re-stamps its created_at). Per-artifact ids are re-pointed through the map copy_artifacts returns.
Layered on top, config-gated so they are inert when unset: admin observability. Wherever a conversation is listed, a Phoenix trace deep-link is resolved once from the Phoenix REST API (spans are grouped by session.id = thread_id) and cached on conversation_users.phoenix_url; and a report can be filed as a GitHub issue carrying the report, the trace link, and the conversation transcript, with the issue URL stored on the row so it is not filed twice.

Positive consequences

  • Feedback survives branching and snapshots with no special read-through logic on the hot path; reads are keyed by the viewed thread’s turn.
  • The instrument can change UI-side without touching the backend or the schema; there is no vocabulary list to keep in sync.
  • Several artifacts per turn are each rated independently, portably across Postgres and SQLite.
  • Every genuine opinion is counted once; forks are first-class for new feedback.
  • An admin can jump from a piece of feedback to its trace and its conversation, and turn a report into a fully-contextualised issue.

Negative consequences

  • Copy-on-branch duplicates the prefix feedback per fork (cheap rows, but O(branch_point x forks)); at wide-usage scale this should move to copy-on-write (tracked in #164).
  • Promotion semantics (“editing a copy makes it a per-path opinion”) are subtle and needed several correctness fixes (mass-promotion, mis-dating, viewer leakage) caught by adversarial validation.
  • Structural-only validation means a malformed but well-shaped aspect/category is accepted; correctness of the vocabulary is the frontend’s responsibility.
  • Trace links and issue creation depend on external services (Phoenix, GitHub) and configuration; they degrade to no-ops when absent.

Pros and cons of the options

Anchor

  • turn_index: + identical to the snapshot/artifact/graph key, branch-safe, already mapped on the client. requires the client to know a message’s turn (it already does).
  • Per-message index: + trivial to compute. not stable across branches/snapshots; breaks on fork.
  • Raw feedback id: + simplest table. cannot line feedback up with turns/nodes at all.

Vocabulary

  • Frontend-owned taxonomy: + the instrument evolves with no migration; no cross-file enum to sync. the backend cannot reject an unknown-but-well-formed aspect.
  • Backend enum: + the server validates the exact vocabulary. every wording/option change is a backend deploy and possibly a migration; couples research iteration to backend releases.

Artifact identity

  • In the key, 0 sentinel: + per-artifact rating, portable, no NULL trap. a sentinel value to remember; a new column semantics.
  • Descriptive nullable: + simplest. only one artifact per aspect per turn (the second overwrites the first).

Branch feedback

  • Copy-on-branch + origin: + the fork shows inherited feedback, no read-through, no double-count, promotable per path. duplicates data; promotion semantics are subtle.
  • Read-through lineage: + no duplication. every read walks the ancestry; copy-on-write on edit; more moving parts (revisited in #164).