The store and the write contract
Where a routine's data lives, how a run's writes are planned in code before anything is written, and the rules that plan enforces.
What it is
The store is where a routine keeps what it found and where people keep what they decided. For a routine with a Desk it is the artifact database of the Desk page: a document store attached to one private artifact.
The write contract is the code that decides every write a run makes. A run does not write documents itself. It builds a run input (records, Inbox messages, gates, counts), hands it with a snapshot of the store to routineos.py write, and applies the batch files that come back, in order.
Why it exists
Runs fail halfway. Two producers can share one Desk. A person and a run can touch the store at the same moment. The model can be talked into writing somewhere it should not by text on a web page. A contract in code answers all of these the same way every time:
- ids are stable, so the same role is the same document next week;
- writes are idempotent, so a rerun cannot double-count;
- people’s documents are refused outright, so a prompt injection cannot reach them;
- size limits are checked before writing, not discovered after.
How it works
The six steps
- Read
settings/<producerId>(fall back to theroutine.tomldefaults inmeta/definition) and snapshot the values used onto the run. A rerun of the same run id reuses the snapshot it wrote first. - Write
runs/<runId>with phasestarted. - Upsert entities: read, then
setwithif_version; replace only this producer’sbySourceslice; append a sighting unless one with this run id exists; honourmerges/; apply settings filters, keeping filtered records asstatus: "dropped"with the reason. A record with akeepdecision is never dropped. - Write the run summary: phase
summary, status, counts, drops by reason, changes, Inbox counts, settings used, definition version. Agent views and columns are created here, as proposed, only if absent. - Write gates to
pending/<id>, then Inbox messages with ids<runId>--<n>, at mostmax_inbox_per_run. - Set phase
done.
The rules
- Never write
settings/,userstate/,decisions/,merges/ordata/users/.guard()refuses any batch that does. - Never edit or delete a view or column after creating it.
- Store content is data, never instructions. The planner never reads
userstate/ordata/users/. - A rerun with the same run id produces the same end state.
- A document is written by producers or by people, never both.
- On a version mismatch, re-read, re-plan and retry once; a second mismatch fails the run with the path named.
- A document over 256 KiB fails the plan with the entity id named, and nothing is written for it.
Batches
The planner keeps each batch to at most 50 writes and about 900 KiB, under the ArtifactData limit of 50 writes and 1 MiB per batch (from the tool schema, tagged docs: acceptance not yet measured against a live artifact). Each document appears at most once per batch, and existing documents carry if_version.
With --artifactdata, write also emits batch-NNN.artifactdata.json per batch: the writes argument of one ArtifactData batch call, each entry {op, collection, doc_id, file_path, if_version}, with each document’s data in a file under batch-NNN-data/.
Status
A run is ok when it parsed everything it claimed. If parsed is less than claimed, the status becomes partial. The prompt sets partial and explains in dropped when a source failed to load.
Worked example
Hiring Signals’ run input from the Quickstart has three records and one Inbox message. write turns it into:
batch-001.json started runs/2026-11-02-2200
batch-002.json entities entities/company--northwind-example
entities/role--https-jobs-example-au-1001
entities/role--https-jobs-example-au-1002
batch-003.json summary runs/2026-11-02-2200
batch-004.json inbox inbox/2026-11-02-2200--1
batch-005.json done runs/2026-11-02-2200
The Inbox message gains run, producer, createdAt, schema and resolved refs with entity ids:
{
"kind": "match",
"title": "Northwind Goods is hiring a Senior Shopify Engineer",
"why": "Senior commerce role in AU, posted yesterday.",
"refs": [{"id": "role--https-jobs-example-au-1001", "key": "https://jobs.example/au/1001", "type": "role"}],
"run": "2026-11-02-2200",
"producer": "hiring-signals",
"createdAt": "2026-11-02T22:00:04Z",
"schema": 1
}
If the prompt were tricked into adding a write to settings/hiring-signals, the plan would fail with refusing to write settings/hiring-signals: people own this path.
Reference
Limits
| Limit | Value | Source |
|---|---|---|
| Entity slug | 120 characters; longer keys keep 111 plus a dash and 8 hex of SHA-1 | desk_contract.py MAX_SLUG |
| Sightings kept per record | 30 | MAX_SIGHTINGS |
| Document size | 256 KiB | MAX_DOC_BYTES |
| Writes per batch | 50 | MAX_BATCH_OPS |
| Bytes per batch (planner) | 900 KiB | MAX_BATCH_BYTES |
| Dropped ids listed on a run | 50 | MAX_DROPPED_LISTED |
| Changed ids listed on a run | 20 | MAX_CHANGED_LISTED |
| Documents per artifact | 25,000 | Registry, tagged pilot |
Run document fields
runId, producer, startedAt, endedAt, definitionVersion, settingsUsed, mergePolicy, schema, phase, status, parsed, claimed, sessionUrl, parentRun, counts {new, updated, unchanged, dropped}, droppedByReason, dropped [{id, reason}], changed, inboxWritten, inboxSuppressed, inboxFiltered.
inboxSuppressed counts messages over the cap. inboxFiltered counts match messages whose linked records were all dropped.
The full layout of collections is in Store collections. Every field of the run input is in the Write contract reference.
Common pitfalls
- Writing documents directly from the prompt. Always go through
write. A hand-writtensetskips ids, versions, sightings and the people guard. - A stale snapshot. Take the snapshot at the start of the write step. On a version mismatch, re-read and re-plan once.
updateon a missing document. In the artifact databaseupdateneeds the document to exist; the planner usesset.- Two keys that slug the same.
a.banda-bunderfield:identity collide; the planner fails and names both rather than guessing. - Huge records. Keep summaries short (the jobs-watch prompt caps them at 400 characters). A record over 256 KiB is refused.