Skip to content

Commit cdc3808

Browse files
Rhea Raffertyclaude
andcommitted
Document the agent-native state-delta principles
Written up at artin's request after the feedback-triage delta discussion. Captures the reusable part only; the schema and query contract stay with the feature. Five principles: an exact monotonic cursor rather than a timestamp; advance only on material change; keep consumer progress and derivable data out of the source of truth; keep internal-only metadata out of every consumer-visible carrier including pagination cursors; and scope the sequence per tenant so a leak is bounded. The last two are written down because both were missing at once in a real defect found during that discussion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Rhea Rafferty <hands-rhea@mail.build>
1 parent 493a338 commit cdc3808

1 file changed

Lines changed: 124 additions & 0 deletions

File tree

docs/agent-native-state-delta.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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

Comments
 (0)