|
| 1 | +# Designing state for agents to consume |
| 2 | + |
| 3 | +Hands is agent-native: an agent is expected to poll for what changed and act on |
| 4 | +it without a person in the loop. That changes what an API has to guarantee. |
| 5 | + |
| 6 | +A person can sort by "recently updated", scan the list, and skip what looks |
| 7 | +familiar. Approximate is fine, because a human filters the remainder. An agent |
| 8 | +cannot. It asks *"what changed since I last looked?"* and acts on the answer, so |
| 9 | +**a missed row is a missed action and an extra row is a duplicate action**. |
| 10 | + |
| 11 | +This page describes the pattern Hands uses for its own change feeds. It applies |
| 12 | +equally if you are building an integration that mirrors Hands state, or |
| 13 | +designing a feed of your own for agents to read. |
| 14 | + |
| 15 | +## 1. Expose an exact cursor, not a timestamp |
| 16 | + |
| 17 | +An agent needs a total order it can resume from: *everything after N*. |
| 18 | + |
| 19 | +A `updated_at` timestamp looks sufficient and is not: |
| 20 | + |
| 21 | +- **Same-millisecond collisions.** Two changes to one record inside the same |
| 22 | + millisecond are indistinguishable, so `updated_at > T` silently drops one. |
| 23 | +- **Everything moves it.** Any write touching the record bumps it, including |
| 24 | + internal processing the consumer does not care about. |
| 25 | + |
| 26 | +Use a monotonic sequence assigned by the database, exposed as an opaque cursor. |
| 27 | +The consumer stores the last value it saw and asks for the next page after it. |
| 28 | + |
| 29 | +If your platform already has a sequence primitive, reuse it rather than |
| 30 | +reinventing monotonicity — getting concurrent assignment, gap behaviour, and |
| 31 | +immutability right is more subtle than it looks. |
| 32 | + |
| 33 | +**Measure the write cost before deciding where the sequence lives.** A |
| 34 | +trigger-assigned sequence typically turns one insert into an insert plus an |
| 35 | +update, and every index containing the sequence column pays a delete and |
| 36 | +re-insert each time it advances. Two carriers are worth comparing directly: |
| 37 | + |
| 38 | +| Carrier | Cost | Trade-off | |
| 39 | +|---|---|---| |
| 40 | +| Column on the existing record | Cheap when that record is already updated by the same operation | Slightly more index maintenance on a hot row | |
| 41 | +| Append-only side table | No update to the hot record | An extra row write, a join on read, and unbounded growth needing a retention policy | |
| 42 | + |
| 43 | +Index the cursor with its scope first — `(tenant, sequence)`, not `(sequence)` — |
| 44 | +or the scan crosses other tenants' rows and filters them out afterwards. |
| 45 | + |
| 46 | +## 2. Advance only on material change |
| 47 | + |
| 48 | +Write down which events move the sequence, and keep machinery out of it. |
| 49 | + |
| 50 | +| Material | Not material | |
| 51 | +|---|---| |
| 52 | +| The record is created | Background post-processing | |
| 53 | +| A field the consumer reports on changes | No-op writes setting a field to its current value | |
| 54 | +| A person adds a comment | The consumer's own read receipts | |
| 55 | + |
| 56 | +If housekeeping advances the cursor, the agent is woken repeatedly by noise from |
| 57 | +your own system and re-reports items that did not change — the exact failure the |
| 58 | +cursor exists to prevent. |
| 59 | + |
| 60 | +**Unchanged content must produce zero delta.** That is worth a test rather than |
| 61 | +a comment, because it is easy to break later without noticing. |
| 62 | + |
| 63 | +## 3. Keep the consumer's progress out of your source of truth |
| 64 | + |
| 65 | +Two things that look convenient and are not: |
| 66 | + |
| 67 | +- **A "needs processing" flag.** It records whether *one particular consumer* |
| 68 | + has acted, which is that consumer's state living in your data. It breaks as |
| 69 | + soon as a second consumer exists. |
| 70 | +- **Field-level change history**, so the consumer can be told exactly which |
| 71 | + fields moved. The consumer already holds the previous snapshot and can diff |
| 72 | + locally for the same answer. Storing it creates a second source of truth to |
| 73 | + keep consistent, for no new information. |
| 74 | + |
| 75 | +Return the **current snapshot** of anything past the cursor. The consumer knows |
| 76 | +where it was; your system only needs to know what is true now. |
| 77 | + |
| 78 | +## 4. Keep internal-only values out of every externally visible carrier |
| 79 | + |
| 80 | +If a sequence advances on internal-only events — private notes, internal triage |
| 81 | +— then **the sequence is itself internal data**, even though it carries no text. |
| 82 | + |
| 83 | +This is easy to miss, because permission checks are usually written against |
| 84 | +content. A filter that hides internal comment bodies does not hide a counter, |
| 85 | +since the counter is metadata rather than content. But an external reader |
| 86 | +watching the number move learns **how often and when internal activity happened** |
| 87 | +on a record. No text is disclosed; the existence and frequency of activity is. |
| 88 | + |
| 89 | +The rule therefore covers carriers, not just fields. An internal sequence, the |
| 90 | +**gap between two sequence values**, and any count derived from either must stay |
| 91 | +out of: |
| 92 | + |
| 93 | +- list and detail responses |
| 94 | +- event payloads and webhooks |
| 95 | +- **pagination cursors** |
| 96 | + |
| 97 | +That last one is the easiest to overlook. A cursor that is base64-encoded JSON |
| 98 | +is readable by anyone holding it — encoding is not concealment. If a cursor must |
| 99 | +carry an internal value, sign it and treat it as opaque, or derive the cursor |
| 100 | +from a value that is safe to reveal. |
| 101 | + |
| 102 | +Generalise this when reviewing permissions: counters, ETags, `updated_at`, |
| 103 | +result totals, and pagination cursors can each carry an inference that the |
| 104 | +field-level redaction was written to prevent. |
| 105 | + |
| 106 | +## 5. Scope the sequence per tenant |
| 107 | + |
| 108 | +Prefer one sequence per app or tenant over a single global one. |
| 109 | + |
| 110 | +Functionally they are equivalent, because the query filters by tenant either |
| 111 | +way. They differ only when something goes wrong: a global counter that escapes |
| 112 | +tells its reader about *every* tenant's activity volume, while a per-tenant |
| 113 | +counter that escapes tells one tenant about itself. |
| 114 | + |
| 115 | +The cost is a state row per tenant instead of one, with the same assignment |
| 116 | +logic and an index that already leads with the tenant. |
| 117 | + |
| 118 | +## Checklist |
| 119 | + |
| 120 | +For any feed an agent will consume: |
| 121 | + |
| 122 | +- [ ] Monotonic, database-assigned sequence — not a timestamp |
| 123 | +- [ ] Material-change definition written down, with a test that unchanged input |
| 124 | + produces zero delta |
| 125 | +- [ ] Write cost measured, carrier chosen on that evidence |
| 126 | +- [ ] Cursor index leads with the query scope |
| 127 | +- [ ] Snapshot returned; no consumer progress stored, nothing stored the |
| 128 | + consumer can derive |
| 129 | +- [ ] Internal-only values absent from responses, events, webhooks and cursors, |
| 130 | + enforced by a test that fails if one is added |
| 131 | +- [ ] Sequence scoped per tenant |
0 commit comments