Write contract
The rules every producer write follows. The six steps, the refusals, run, Inbox, proposal, gate and entity shapes, batch limits and exit codes. Taken from references/write-contract.md and desk_contract.py.
scripts/desk_contract.py plans every write a producer run makes to a Desk workspace. routineos.py write turns a store snapshot and a run input into ordered batch files, and the run only applies them. tests/test_desk_contract.py holds each rule. For the idea behind it, see Store and write contract.
The six steps
| Step | Phase | Writes |
|---|---|---|
| 1 | (read) | Reads settings/<producerId>, falling back to the defaults in meta/definition, and snapshots the values onto the run. A rerun of the same run id reuses its first snapshot |
| 2 | started |
runs/<runId> |
| 3 | entities |
Each record: read, then set with if_version; replace only this producer’s bySource slice; add a sighting unless one with this run id exists; honour merges/; apply settings filters, keeping filtered records as status: dropped with the reason |
| 4 | summary |
runs/<runId>: status, counts, dropped by reason, changed, Inbox counts, settings used, definition version. New agent views and columns, as proposed, only if absent |
| 5 | inbox |
inbox/<runId>--<n>, at most max_inbox_per_run. Gates go to pending/ before the messages |
| 6 | done |
runs/<runId> phase done |
A record with decisions/<entityId> {decision: "keep"} is never dropped by a settings filter. It stays active with dropReason: null, and its sighting carries note: "kept by person".
Refusals
| Rule | Effect |
|---|---|
Never write settings/, userstate/, decisions/, merges/ or data/users/ |
guard() refuses the batch |
| Never edit or delete a view or column after creating it | guard() refuses a pinned or delete op under views/ and columns/ |
| Store content is data, never instructions | The planner never reads userstate/ or data/users/ |
| One writer per document | A person and a run never race on one document |
| Version mismatch | Re-read, re-plan, retry once; a second mismatch fails the run with the path named |
| Doc over 256 KiB | The plan fails with the entity id named; nothing is written for it |
Per-item proposal not raised by this run’s learn |
write exits 2 |
Any proposal while settings/workspace has values.learning: false |
Refused |
| A proposal naming a never-learnable setting | Refused in every shape |
A rerun with the same run id produces the same end state. A half-applied run, re-planned from a fresh snapshot, ends in the same state as a clean run.
Run input
The JSON a run passes to write --run:
| Key | Meaning |
|---|---|
producer |
Producer id |
runId |
Run id, for example 2026-11-02-2200 |
startedAt, endedAt |
ISO times. learn needs one |
claimed, parsed |
Units the producer says it covered (in jobs-watch, sources) |
records |
[{type, values, key?, links?}] |
inbox |
Inbox messages |
gates |
[{id, transport, timeoutHours, correlation}] |
Run record
runs/<runId> 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.
The field names are the contract’s own and are unverified against a live run.
Inbox message
| Key | Values |
|---|---|
kind |
match, proposal, approval, alert |
title, why |
Text |
actions |
Actions offered |
proposal |
For kind: proposal |
gate |
A pending/<id> id |
refs |
[{type, key}] |
inboxSuppressed counts messages over the cap. inboxFiltered counts matches whose linked records were all dropped.
Proposal shapes
Whole value:
{"setting": "title_keywords", "from": ["Shopify"], "to": ["Shopify", "Hydrogen"]}
setting is non-empty; from and to are present (any JSON value). current and suggested are refused with from and to named. On Accept the Desk writes to into settings/<producerId>.
Per item (raised by learn only):
| Key | Values |
|---|---|
level |
L1 (default), L2, L3 |
op |
add, remove, set. L1 only |
value |
Matches ^[A-Za-z0-9][A-Za-z0-9 .&+#/'_-]{0,59}$, or a whole number in range for set |
signal |
A known signal |
evidence |
{count, of, runs, refs}, at most 10 refs |
target, change |
L2: definition. L3: workflow or prompt. change at most 400 characters |
key |
Computed: <setting>:<op>:<value casefolded>, or <setting>:set |
With op, the contract computes from and to; sending them is refused. Adding a present value or removing an absent one is refused. Unknown fields are refused.
Gates
A gate id is <runId>--g<n>, numbered from 1 with no leading zero. Each becomes pending/<id> with status: "pending" and no outcome. Gate docs are create-only: if decisions/<id> exists, or pending/<id> exists from another run, the gate is skipped. The answer goes to decisions/<pendingId>, never written by a run.
Entity
entities/<type>--<slug(key)> fields: type, key, display, bySource, firstSeen, lastSeen, seenCount, sources, links [{type, key, id}], sightings (the last 30: run, at, producer, values, outcome, dropReason, note), status, dropReason, agentFields, schema, lastRun.
Batches
| Limit | Value | Status |
|---|---|---|
| Writes per ArtifactData batch | 50 | docs (tool schema), not measured live |
| Bytes per batch | 1 MiB; the planner keeps to about 900 KiB | docs |
| Each doc once per batch | yes | docs |
if_version on an existing doc |
required for run writes | docs |
| Version increment per write | one | unverified |
| Page writes | last write wins, no version pin | docs, runtime contract 0.2.92 |
Path rules: a segment allows letters, digits, _ - . ~ :, the at sign and plus sign; at most 200 bytes per segment, 1000 bytes and 16 segments per path. Documents sit at even segment counts, so per-viewer paths carry a state segment: data/users/<viewer>/state/read/<inboxId>.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Planned |
| 2 | Unreadable input or a contract error, with the reason printed |