ADR-0015: Append-Only review_notes JSONB Array for Task Rejection History

Status

Accepted

Tags

fieldforce, jsonb, audit, history, immutability

Decision

Task rejection feedback is stored as an append-only JSONB array on ff_tasks.review_notes (default '[]'). Each rejection appends a {date, note, reviewer_id, reviewer_name} object. Nothing is ever overwritten or deleted from this column. The Go domain type is []ReviewNote — never nil, empty slice when no rejections.

Why

A task can go through multiple rejection cycles (completed → needs_revision → completed → needs_revision). A single review_note text column silently overwrites previous feedback, losing context that field teams need to understand what changed between cycles. A separate table adds a join and migration complexity for an ordered list of at most 2–3 entries per task. Rejected alternatives:
  • Single review_note text column: Only the latest rejection is preserved. Field team loses context on earlier revision requests. Rejected.
  • Separate ff_review_notes table: Adds a join and migration complexity. JSONB append is sufficient for the volume. Rejected.

How it works

ff_tasks.review_notes JSONB NOT NULL DEFAULT '[]'

Each entry:
{
  "date":          "2026-05-18T10:30:00Z",
  "note":          "Photo of the repaired unit is missing",
  "reviewer_id":   "user_abc",
  "reviewer_name": "Alice Wong"
}
Append flow (state machine reject action):
// In the reject transition's Action func:
notes := tctx["review_notes"].([]ReviewNote)
notes = append(notes, ReviewNote{
    Date:         time.Now(),
    Note:         tctx["review_note"].(string),
    ReviewerID:   tctx["reviewer_id"].(string),
    ReviewerName: tctx["reviewer_name"].(string),
})
tctx["review_notes"] = notes
// Service persists the updated notes via repo.Update after Trigger returns
The UI displays all entries chronologically. The most recent entry is shown as the active feedback banner when status = needs_revision.

Known limitations

  • The review_notes array grows indefinitely if a task is rejected many times. In practice, tasks rarely exceed 2–3 rejection cycles. If a task accumulates >20 entries, it is a process problem, not a data problem.
  • Entries cannot be amended after the fact — the immutability is intentional (audit integrity). If a reviewer entered incorrect feedback, they must add a corrective entry.

Rules for agents

  • Only the state machine’s reject action may append to review_notes — no other service method may modify this column
  • Never UPDATE or clear review_notes — treat any SQL UPDATE ff_tasks SET review_notes = '[]' outside the application as a data corruption event
  • review_notes is always []ReviewNote in Go — never nil; initialize to empty slice when scanning a NULL value from DB
  • Do not expose a write endpoint for review_notes — it is system-populated only

Bad pattern (do not generate)

// Overwriting the column — destroys history
task.ReviewNotes = []ReviewNote{{ Note: newNote }} // wrong — append, do not replace

// Clearing on status change
db.Model(&task).Update("review_notes", "[]") // wrong — append-only invariant

// Nil check that may NPE
if task.ReviewNotes != nil { ... } // wrong — always initialize to []ReviewNote{}

Good pattern

// Append in the reject Action, persist in the service
func rejectAction(tctx statemachine.TransitionContext) error {
    notes := tctx["review_notes"].([]domain.ReviewNote)
    tctx["review_notes"] = append(notes, domain.ReviewNote{
        Date:         time.Now(),
        Note:         tctx["review_note"].(string),
        ReviewerID:   tctx["reviewer_id"].(string),
        ReviewerName: tctx["reviewer_name"].(string),
    })
    return nil
}

// Scan with safe default
func (r *TaskRow) toDomain() *domain.Task {
    notes := r.ReviewNotes
    if notes == nil {
        notes = []domain.ReviewNote{} // never return nil
    }
    return &domain.Task{ReviewNotes: notes}
}