Data model
Control State: counters, caps, and distinct sets.
Control State keeps the safety and throttling primitives close to the request path: bucketed counters, frequency caps, velocity checks, and distinct sets — for fraud, rate limits, ad frequency capping, and agent safety.
What it is
One request-time home for the "how many / how often / how unique" question.
Throttling and safety decisions all reduce to a small number read at request time. Control State keeps that number next to the request path so the check is a fast read, not a round trip to a separate rate-limit service or a warehouse.
- Counter — bucketed counts, sums, and rates over sliding or tumbling windows.
- Frequency cap — per-entity impression, action, or spend caps over an hour, a day, or a campaign, enforced at read time.
- Velocity check — how many events in the last N minutes, to catch bursts for fraud and abuse throttling.
- Distinct set — exact or approximate unique devices, merchants, IPs, sessions, or tools seen in a window.
- Selection state — chosen or blocked entities kept consistent across requests, including agent safety counters beside Context Management memory.
Why TemporalStore
Why caps and distinct sets belong on the request path, not in a side service.
Throttling and safety checks share an awkward property: they sit directly on the hot path, yet they depend on recent history. The usual answers each add a moving part. A separate rate-limiter service is another network hop, another thing to scale and page on, and it only knows about the one counter you gave it — the moment fraud needs "distinct merchants this card touched in 24h" it has nowhere to put that. A warehouse has the history but answers in seconds, far too slow for an allow-or-block gate. And cache-TTL hacks — an expiring Redis key per window — approximate a window with an eviction timer: they drop old counts by throwing the key away, so you get coarse fixed windows, thundering-herd resets at the TTL boundary, and no way to ask a question the key wasn't shaped for.
TemporalStore keeps the counter, the cap, and the distinct set next to the request path as first-class temporal objects. An increment is an append-structured write — a cheap ordered append, not a read-modify-write of a shared hot key — so a viral campaign or a fraud burst does not serialize every request behind a single contended counter. The window is a real bounded read over recent events, not a TTL, so "impressions in the last 24h" is exact and rolling rather than a bucket that resets on the hour.
The checks themselves run as model-aware executors at the shard. A frequency
cap is a bounded count compared to a threshold; a velocity check is a
count over the last few minutes; a distinct set is an exact-or-approximate unique
count over a window — each computed where the data lives, so the gate is one fast read, not
a round trip to a service that then queries something else. Because each key keeps its own sparse
state, the model scales to millions of counters (per user, per card, per
campaign, per agent session) without a limiter cluster sized for the sum of them.
And because the same engine already holds agent memory, safety counters live beside Context: rate limits, risky-action counts, and escalation history are read in the same call as the memory an agent acts on, and they replay from the WAL after failover so the audit trail survives an incident. One temporal object — not a limiter, a warehouse, and a pile of TTL keys — answers how many, how often, and how unique.
No contended hot key on increment; the gate is one bounded read, not a round trip.
In practice
Increment, count against a cap, and count distinct.
ts.incr(
table="impressions",
entity="camp_5:user_42",
ts_ms=now_ms,
by=1,
)
n = ts.count(
table="impressions",
entity="camp_5:user_42",
range="24h",
)
allow = n < DAILY_CAP
u = ts.distinct(
table="card_touch",
entity="card_9",
field="merchant",
range="24h",
)
How to use it
An end-to-end gate, from increment to allow-or-block.
Each primitive is the same two-move shape: append an event when something happens, then read a bounded count, cap, distinct, or velocity check at the moment you have to decide. The write is cheap and uncontended; the read is the gate.
Record the action
An append-structured write to the entity's counter — no contended hot key.
Count vs threshold
A bounded rolling-window count compared to the limit, at read time.
Spot the pattern
Unique fan-out and burst rate over the last minutes for fraud and abuse.
Allow or block
Combine the reads into one decision on the request path, beside agent memory.
ts.incr(
table="impressions",
entity="camp_5:user_42", # compound key: campaign x user
ts_ms=now_ms,
by=1,
)
Compose the entity key to match the cap you enforce —
camp_5:user_42 caps per user per campaign, user_42 caps across all
campaigns. The increment is an ordered append, so concurrent writes to a hot campaign do not
serialize behind a single counter.
n = ts.count(
table="impressions",
entity="camp_5:user_42",
range="24h", # exact rolling window, not a TTL bucket
)
allow = n < DAILY_CAP
The range is a true rolling window ending now, so the cap does not
snap back at an hour boundary the way an expiring key does. Read the count and compare in the caller;
for a strict cap under heavy concurrency, increment-then-read so the current action is included in
n.
merchants = ts.distinct(
table="card_touch",
entity="card_9",
field="merchant",
range="24h",
)
suspicious = merchants > 20 # many distinct merchants, fast
recent = ts.count(
table="card_touch",
entity="card_9",
range="5m",
)
burst = recent > BURST_LIMIT # rate spike over the last few minutes
distinct counts unique values of a field over the
window (exact for small sets, approximate at high cardinality); pairing it with a short-window
count catches both unusual fan-out and raw bursts. Keep velocity windows short
(minutes) so a spike stands out against the baseline instead of being diluted by a long range.
def guard_agent_action(session, action):
calls = ts.count(table="agent_actions", entity=session,
where={"kind": action.kind}, range="1m")
if calls >= RATE_LIMIT[action.kind]:
return "throttle"
ts.incr(table="agent_actions", entity=session, ts_ms=now_ms,
by=1, attrs={"kind": action.kind})
return "allow" # same store as the agent's memory
Because control state lives in the same engine as Context Management, an agent's rate limits and risky-action counters are read in the same request that assembles its memory — and they replay from the WAL after failover, so the safety audit trail survives an incident. Gotchas: shape the entity key to the cap, keep windows tight on the hot path, prefer approximate distinct at very high cardinality, and increment-then-read when a cap must be strict.
When to use it
When a "yes / no" gate depends on recent activity.
Reach for Control State whenever an allow-or-block decision hinges on how much, how often, or how many distinct things an entity has done lately — and the check sits on the hot path.
| Use case | Primitive | Why it fits |
|---|---|---|
| Ad frequency caps | Frequency cap | Enforces per-user impression limits over hour/day/campaign windows. |
| API rate limits | Counter | Bucketed counts per key gate requests without a separate limiter. |
| Fraud velocity | Velocity + distinct | Flags bursts and unusual distinct-merchant fan-out in minutes. |
| Agent safety | Selection + counters | Rate limits and risky-action counters beside agent memory. |
Related models