CockroachDB Built for the CockroachDB × AWS Hackathon — Agentic Memory

Most labels start every release from zero. Spindle remembers who said yes.

Spindle is a music distribution agentic OS. It maps everyone who can carry a record — creators, curators, stations — into one vector index, works out who should hear it, and writes to them individually. Not a blast. Radio is the 0 rows we could earn first, because its registers are public. The creators are the point.

0counterparties
0embedded
0with genre
0lessons learned
0sent, ever
Counterparty index

Every country we can already take a record to.

One query over the whole index, on page load. Countries light in the order we first reached them; click one to search inside it. Radio is lit because its registers are public and it was the cheapest reach to earn. The creator platforms are listed dark because that is where this is going and we have not got there — a creator enters through a scout who pastes what they actually posted, never a scraper, and lands in the same index as every station on this map.

counterparties indexed
countries reached
placed on the map
added today

Tick rate

A million iterations a second is the wrong unit. Nobody reads their email a million times a second.

Throughput is the right unit for a trading bot racing other machines. Spindle's work is a correspondence with a person who opens their inbox on Tuesday, so its tick is a curator's reply, a programming meeting, a register that republishes weekly. The schedule below is not throttled — it is paced, and every interval in it is a constant you can read in the source.

The same eight bars, a bar each in turn. Only the rate differs — and one of the two stops being music.

a realtime agent loop
48 pins · 100 rpm · 20 notes a second
spindle
6 pins · 25 rpm · one bar, played
elapsed day 0 · 00:00 of nine days
a realtime agent loop 60 iterations a second, never sleeping, never waiting for anybody
0 iterations 0 conversations opened 0 curators who wrote back
spindle seven ticks in nine days — and four of them inside the first hour
  1. day 0 · 00:00 A shortlist opens a thread. Ranked against 0 embedded counterparties, and the instant is stamped as a hybrid logical clock so the decision can be read back later. 035_decision_ledger.sql
  2. day 0 · 00:15 The spend gate holds. The cap refused it, so nothing was delivered and the attempt budget was not spent. The row comes back in fifteen minutes. sender.py · _record_refused
  3. day 0 · 01:00 A human approves the pitch. Our clock does not run here at all. There is nothing this system can do to make this step arrive sooner, and it is not supposed to. somebody else's clock
  4. day 0 · 01:01 Sent. One message, out of a batch of five, fenced so it cannot go twice. The thread moves to awaiting_reply rather than sent, because the next real event belongs to the curator. sender.py · BATCH = 5
  5. day 5 · 01:01 next_action_at comes due. Five days is not a retry and not a timeout. It is how long you leave a curator alone before you follow up, and it is written onto the row at the instant of sending. sender.py:188 · INTERVAL '5 days'
  6. day 6 · 01:00 The curator writes back. On their clock, not ours. Six days after the pitch — which, for a station that puts its programming together once a week, is quick. somebody else's clock
  7. day 9 · 00:00 The thread reaches a terminal state and a lesson is distilled from it. That lesson reranks the next shortlist. It is the only thing on this rail that compounds. lessons.py
a tick this system chose a tick it waited on
7 ticks 1 curator who wrote back 1 lesson that outlives the release

Nine days on a broken axis — the first hour would otherwise be half a pixel wide. Every interval is a constant in this repository: five days is sender.py:188, fifteen minutes is _record_refused, the batch of five is BATCH. The two ticks that set the pace are the orange ones, and we own neither. Throughput is not the constraint here; somebody's attention is.

The filters are inside the index

It is a vector search, not a scan with a filter bolted on.

Four predicates — tenant, embedding model, party class, contact state — sit in the index prefix, so the search happens inside the filtered subspace instead of over everything followed by a discard. A test asserts this plan on every run: a query that degrades to a full scan still returns rows that look correct.

party@party_shortlist — the index, as declared apps/spindle/schema/009_counterparty_index.sql
cosine profile_embedding target count 20
Unbind a column to see what the planner can still use.

What we cannot take back

A missed pitch costs nothing. A second pitch costs us the curator.

Sending is the one act this system cannot undo, so the guarantees are constraints rather than conventions — things the database will not let us get wrong, even when we are in a hurry.

two workers · one lead · b844314c… · both named the same thing
ingest-cliworker A
no lease
ingest-cliworker B
no lease
“lease expires” stalls worker A past its TTL.
    messages actually sent 0 duplicates 0 try to make it two
    The name cannot tell them apart. The token can — it is stamped at claim time, only the database mints it, and every write is fenced on it being current. Serializable isolation, FOR UPDATE SKIP LOCKED, and a fence that fails closed.
    what an agent does when it finishes a piece of work
    BEGIN
    1. the writewhatever it learned — a fact, a metric, a lesson
    2. agent_runone row: state, duration, tokens, and what it cost
    3. complete the leadfenced on the lease token, which releases it
    COMMIT
    Three statements, one transaction — so a worker that dies halfway leaves nothing behind rather than a fact with no run, or a run with no completion. The network call that produced the write happens outside the boundary, on purpose.

    One open thread per counterparty

    A partial unique index on (tenant_id, counterparty_id) where the thread is not closed. Two campaigns cannot work the same person at once, and the lock releases the moment the conversation ends.

    One outbox row per message

    UNIQUE (message_id). A double approval is a failed insert, not a second copy in flight. The gate is safe to double-click.

    A token only the database mints

    Every agent action is fenced on a lease_token stamped at claim time. Two workers with the same name, one lead: the name cannot tell them apart, the token can. The stale claim is refused.

    Opting out is terminal

    Ask us to stop and opted_out is a state no discovery stage can overwrite. Guessed addresses are refused outright. Not spam by construction, not by policy.

    Where Postgres would do

    Most of this is replaceable. One thing is not.

    A vendor who claims every part of their stack is special is telling you about their marketing, not their engineering. So here is the audit we ran on ourselves, and the single row that survived it.

    What we usePostgres equivalentVerdict
    pgvector does this, and has for years. Our prefix trick is a composite index with the vector column last — an idea Postgres can express directly. If the shortlist were all this system did, there would be no argument here.
    SET default_transaction_isolation = 'serializable'. The honest difference is that we did not have to remember, and a default nobody has to remember is worth something — but it is a default, not a capability.
    Postgres 9.5, in 2016. The lease fence you just played with would work unchanged on it. We are not going to claim a 2016 feature as a 2026 reason.
    Several managed Postgres products scale to zero, some of them more aggressively than we do. This is a cost property of a deployment, not a property of a database, and it belongs in the concessions column.
    Our agents write to real people, so why did you contact me has to be answerable — which means re-running the ranking against the index as it stood at that second, not as it stands now. Four extra words of SQL, no snapshot table, no audit copy of the embeddings. Every decision carries the hybrid logical clock it happened at — decision.at_hlc, written by 035_decision_ledger.sql — which is the coordinate that query is issued against.

    One row survived. It is the row that lets an autonomous system be held to account for what it did, and it is why this is built on CockroachDB.

    What it costs

    A tier is a budget cap, and the cap is a constraint.

    Most pricing pages are a promise. This one is a predicate. Every plan below is a row in budget, and the spend gate that reads it refuses the work rather than invoicing for it afterwards. The unit is an open conversation — the one thing the schema was already counting, not a meter invented to bill you with.

    18

    Free / Judge $0
    • 5 open conversations per calendar month
    • 1 artist

    Everything the console does, on one artist, with sending off. Enough to judge the product without a card.

    Label $49 / month
    • 50 open conversations per calendar month
    • 3 artists

    An independent label running three acts. Fifty conversations a month is roughly one campaign per act per quarter, worked properly.

    Roster $199 / month
    • 250 open conversations per calendar month
    • Unlimited artists

    A full roster, no cap on how many acts. The meter is conversations, so an artist you are not pitching costs nothing.

    Catalogue Talk to us
    • Conversation volume by agreement
    • Unlimited artists

    Back catalogue, multiple labels, or a shape none of the above fits. Priced against the work; talk to us.