---
title: Write contract
description: 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.
sidebar:
  order: 4
---

`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](/concepts/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:

```json
{"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 |

## Related

- [Store collections](/reference/store-collections)
- [Store and write contract](/concepts/store-and-write-contract)
- [Learning](/concepts/learning)
- [CLI reference](/reference/cli#write)
