|
| 1 | +# Agent-native state delta — design principles |
| 2 | + |
| 3 | +How to expose changing state to an **autonomous consumer** (a patrol agent, a |
| 4 | +sync job, an external mirror) rather than to a person looking at a screen. |
| 5 | + |
| 6 | +The two audiences want different things, and the difference is not cosmetic. A |
| 7 | +person can sort by "recently updated", glance at the list, and skip what looks |
| 8 | +familiar — approximate is fine, because a human filters the remainder. An agent |
| 9 | +cannot. It asks *"what changed since I last looked?"* and acts on the answer, so |
| 10 | +**one missed row is a missed report and one extra row is a duplicate report**. |
| 11 | + |
| 12 | +These principles came out of the feedback-triage delta work. The concrete |
| 13 | +schema and query contract live with that feature; this document is the |
| 14 | +reusable part, and the last two were learned by shipping the mistake first. |
| 15 | + |
| 16 | +## 1. Give an exact cursor, not a timestamp |
| 17 | + |
| 18 | +An agent needs a total order it can resume from: *"everything after N"*. |
| 19 | + |
| 20 | +`updated_at` looks like it works and does not: |
| 21 | + |
| 22 | +- **Same-millisecond collisions.** Two changes to one row inside a millisecond |
| 23 | + are indistinguishable, so a cursor of `updated_at > T` silently drops one. |
| 24 | +- **It is bumped by everything.** Any write that touches the row moves it, |
| 25 | + including internal processing the consumer does not care about. |
| 26 | + |
| 27 | +Use a **DB-managed monotonic sequence**. In this codebase the existing shape is |
| 28 | +`feedback_comments.reporter_sequence` (migration `0054`): a high-water row, an |
| 29 | +`AFTER INSERT` trigger that advances it, a `CHECK` enforcing +1, and a second |
| 30 | +trigger making an assigned value immutable. Reuse that shape rather than |
| 31 | +reinventing monotonicity. |
| 32 | + |
| 33 | +**Cost to measure before choosing where the sequence lives.** A trigger-assigned |
| 34 | +sequence turns one INSERT into an INSERT plus an UPDATE, and any index |
| 35 | +containing the sequence column pays a delete and re-insert on every advance. |
| 36 | +Measure it as an opcode delta on `EXPLAIN` of the write, and compare carriers: |
| 37 | +a column on the existing row (cheap if that row is already being updated) versus |
| 38 | +an append-only side table (no update to a hot row, but a new row write, a join |
| 39 | +on read, and unbounded growth needing retention). |
| 40 | + |
| 41 | +Index the cursor with the scope first — `(app_id, sequence)`, not `(sequence)` — |
| 42 | +or the scan walks across other tenants' rows and filters them out afterwards. |
| 43 | + |
| 44 | +## 2. Advance only on *material* change |
| 45 | + |
| 46 | +Define which events move the sequence, and keep internal machinery out of it: |
| 47 | + |
| 48 | +- **Material:** the row is created; a field the consumer reports on changes; |
| 49 | + a human adds a comment. |
| 50 | +- **Not material:** symbolication and other post-processing, no-op writes that |
| 51 | + set a field to its current value, and the consumer's own read receipts. |
| 52 | + |
| 53 | +If housekeeping advances the cursor, the agent is woken repeatedly by its own |
| 54 | +system's noise and re-reports unchanged items — the exact failure the cursor |
| 55 | +exists to prevent. **Unchanged content must produce zero delta**, and that |
| 56 | +deserves a test, not a comment. |
| 57 | + |
| 58 | +## 3. Do not store the consumer's progress in the source of truth |
| 59 | + |
| 60 | +Two temptations, both refused: |
| 61 | + |
| 62 | +- A `needs_report` flag. That records whether *a particular consumer* has acted. |
| 63 | + It is the consumer's state living in the SSOT, and it breaks the moment there |
| 64 | + is a second consumer. |
| 65 | +- Field-level change history, so the consumer can be told exactly which fields |
| 66 | + moved. The consumer already holds the previous snapshot and can diff locally |
| 67 | + for the same answer, so this is a second source of truth to keep consistent |
| 68 | + for no new information. |
| 69 | + |
| 70 | +Return **the current snapshot** of anything past the cursor. The consumer knows |
| 71 | +where it was; the SSOT only needs to know what is true now. |
| 72 | + |
| 73 | +## 4. Internal-only metadata must not reach any consumer-visible carrier |
| 74 | + |
| 75 | +A sequence that advances on internal-only events (staff notes, private triage) |
| 76 | +**is itself internal data**, even though it carries no text. |
| 77 | + |
| 78 | +Content-level permission filters do not cover it. The reporter-facing queries in |
| 79 | +`worker/src/routes/reporter_feedback.ts` carry seven `internal = 0` predicates |
| 80 | +guarding comment *bodies*; a counter column slips past every one of them, |
| 81 | +because it is metadata rather than content. But an external reporter watching |
| 82 | +the number jump learns **how often and when internal work happened** on their |
| 83 | +ticket. No text leaks; the existence and frequency of activity does. |
| 84 | + |
| 85 | +So the rule covers carriers, not just fields: **no internal sequence, no gap |
| 86 | +between sequences, and no derived activity count** in list responses, detail |
| 87 | +responses, events, webhooks — **or pagination cursors**. A difference leaks what |
| 88 | +a value leaks. |
| 89 | + |
| 90 | +Generalise it when reviewing permissions: counters, ETags, `updated_at`, result |
| 91 | +totals and pagination cursors can all carry an inference that the field-level |
| 92 | +redaction was written to prevent. |
| 93 | + |
| 94 | +## 5. Scope the counter per tenant, so a leak is bounded |
| 95 | + |
| 96 | +Prefer **one sequence per app** over a single global one. |
| 97 | + |
| 98 | +Functionally they are equivalent — the query filters by app either way. They |
| 99 | +differ only when something goes wrong. A global counter that escapes tells its |
| 100 | +reader about *every* tenant's activity volume; a per-app counter that escapes |
| 101 | +tells that app's own users about their own app. Bad, but bounded. |
| 102 | + |
| 103 | +The cost of per-app is a state row per app instead of one, with the same trigger |
| 104 | +shape and an index that already leads with `app_id`. |
| 105 | + |
| 106 | +This is not hypothetical. It is the shape of a real defect found while designing |
| 107 | +the feature above: a global comment sequence reached reporters through a |
| 108 | +base64-encoded pagination cursor, and differencing two cursors revealed |
| 109 | +platform-wide comment volume across tenants. Principles 4 and 5 are written down |
| 110 | +because both were missing at once. |
| 111 | + |
| 112 | +## Checklist |
| 113 | + |
| 114 | +For any new agent-consumable feed: |
| 115 | + |
| 116 | +- [ ] Monotonic sequence, DB-managed, not a timestamp |
| 117 | +- [ ] Material-change definition written down, with a test that unchanged input |
| 118 | + produces zero delta |
| 119 | +- [ ] Write cost measured (opcode delta), carrier chosen on evidence |
| 120 | +- [ ] Cursor index leads with the query scope |
| 121 | +- [ ] Snapshot returned; no consumer progress and no derivable data stored |
| 122 | +- [ ] Internal-only values absent from every consumer-visible carrier, cursors |
| 123 | + included, enforced by a test that fails if one is added |
| 124 | +- [ ] Sequence scoped per tenant |
0 commit comments