Skip to content

Commit 9c52baa

Browse files
TennyZhuangclaude
andauthored
Add public guide: designing state for agents to consume (#395)
Hands is agent-native: agents poll for what changed and act without a person in the loop, which places requirements on a change feed that a human-facing list does not have - a missed row is a missed action, an extra row a duplicate one. Documents the pattern as public guidance: an exact monotonic cursor rather than a timestamp, material-change semantics, keeping consumer progress out of the source of truth, keeping internal-only values out of every externally visible carrier including pagination cursors, and scoping the sequence per tenant. Also registers the page in admin/scripts/build-docs.mjs. Files in docs/public are not discovered by directory scan, so an unregistered page builds nothing and silently never appears on the site. Requested by @artin; merged on his authorization. Documentation only - no code, schema or behaviour change, and no security surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rhea Rafferty <hands-rhea@mail.build>
1 parent 7a23462 commit 9c52baa

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

admin/scripts/build-docs.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const pages = [
3636
description: "Read and triage feedback/crash tickets from the command line with @botiverse/hands-cli.",
3737
source: "agent-cli-feedback.md",
3838
},
39+
{
40+
slug: "agent-native-state-delta",
41+
title: "Designing State for Agents",
42+
category: "For agents",
43+
description: "How to expose changing state to an agent that polls: exact cursors instead of timestamps, material-change semantics, and what must never reach a cursor.",
44+
source: "agent-native-state-delta.md",
45+
},
3946
{
4047
slug: "ios-testflight",
4148
source: "ios-testflight.md",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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

Comments
 (0)