state-schema.md
157 lines · skills/ce-sweep/references/state-schema.md at 3449230
Most marks come from matching words in the file, so treat them as pointers.
Sweep state schema (v1)
This is the canonical, versioned contract for the ce-sweep state file. The deterministic state engine (scripts/sweep-state.py) is the only program that writes the file. Every peer agent (source connectors, the analyzer, the orchestrator) reads the file and changes it exclusively through the engine's subcommands, so the rules below are enforced in one place. Read this before touching state.
Top-level shape
schema_version: 1
lease:
writer: "sweep-2026-07-02-cron"
timestamp: "2026-07-02T12:00:00+00:00"
ttl_minutes: 60
sources:
"slack:C42":
cursor: "1699999999.000100"
sensitive: true # optional; config-derived
items:
"slack:C42:1699999999.000100":
source: "slack:C42"
status: "acknowledged"
# ...arbitrary connector fields...
last_run:
timestamp: "2026-07-02T12:05:00+00:00"
outcome: "completed"
writer: "sweep-2026-07-02-cron"
counts: {"ingested": 5, "closed": 1}| key | type | meaning |
|---|---|---|
schema_version | int | Contract version. Currently 1. A file missing this key is treated as corrupt. |
lease | map | Single-writer mutex (see Lease). Absent when no writer holds it. |
sources | map keyed by source id | Per-source resume cursor and optional flags. |
items | map keyed by <source-id>:<item-id> | Per-item lifecycle record. The key is source-scoped so a source-native id (a Slack ts, a GitHub issue number) never collides with the same id from another source. Personas pass a source-native id plus --source; the engine composes the storage key and records both source and id on the item so it stays self-describing. |
last_run | map | Bookkeeping for the most recent sweep (see run-record). |
Compatibility rule (forward/backward)
The engine is deliberately additive-safe so a newer writer and an older reader can share a file:
| situation | engine behavior |
|---|---|
| Unknown top-level key | Preserved on every write-back. Never dropped. |
| Unknown field on an item or source | Preserved on write-back. Never dropped. |
Unknown status value | Preserved and passed through. Skip, never drop. The closed enum below is not a whitelist. |
File parses but has no schema_version | Treated as CORRUPT; the engine refuses to write over it. |
schema_version greater than the engine knows | Still read and written with every field preserved. The engine only adds rules per version and never removes fields. |
Bump schema_version only for a change that a v1 reader could misinterpret. Purely additive fields do not require a bump.
Status enum
Closed set of known lifecycle states. Unknown values are preserved, never dropped, so a future state can roll out writer-first.
| status | meaning |
|---|---|
ingested | Captured from a source; not yet triaged. |
ack_deferred | Receipt noted, but triage deferred to a later pass. |
acknowledged | Triaged and accepted into the sweep pipeline. |
needs_download | Referenced media/attachment must be fetched before analysis. |
needs_analysis | Content present, awaiting analysis. |
manual_stuck | Blocked; needs manual intervention to proceed. |
analyzed | Analysis complete; findings recorded on the item. |
in_plan | Folded into a plan or tracked work item. |
fix_pending | A fix is underway or awaiting verification. Also the downgrade target for an under-evidenced closed. |
closed | Resolved and verified. REQUIRES all three evidence fields (see below). |
source_gone | The originating source or message no longer exists. |
Evidence fields and the validate downgrade rule
A closed item is a claim that work shipped and was verified, so the engine holds it to proof. An item may only remain closed if it carries all three:
| field | meaning |
|---|---|
fix_ref | Reference to the fix (PR/commit/issue link). |
verified_merge_sha | The merge commit SHA the fix landed on. |
verified_at | ISO timestamp the fix was verified. |
validate scans every item and downgrades any closed item missing (or with a falsy value for) any of these back to fix_pending. It then rewrites the file and returns the list of downgraded ids. This self-heals a state left inconsistent by a crashed run. validate is lease-agnostic. It is a repair, run at sweep start.
sensitive semantics
The primary sensitivity mechanism is per-item. The orchestrator reads each source's config sensitive flag and includes "sensitive": true in the item JSON on every upsert-item for that source (run.md phase 2d). A sensitive: true on a source entry in state is a defensive fallback the engine also honors, but nothing seeds it today. The per-item flag is what enforces R28, so sensitivity works even though source entries carry only a cursor. On any upsert-item where either the item or its source entry is sensitive, the engine **drops body and quote before writing**, so redacted content never reaches disk. All other fields (title, url, status, ids) are retained. Redaction happens at write time, so flipping a source to sensitive protects only items written after the flag is set. Re-ingest to redact prior items.
id-keyed merge rule
Each writer changes only the keys it sends, not the whole file. upsert-item performs an id-keyed merge. It loads the existing item, replaces only the keys present in the incoming JSON, and preserves every other field already on that item. source is always (re)set from --source. No subcommand semantically rewrites the whole file. Each one changes only the keys it was given and preserves the rest, even though the physical write re-emits the file atomically. This lets independent connectors and the analyzer touch the same item across passes without clobbering each other's fields.
Lease (single-writer mutex)
| field | meaning |
|---|---|
writer | Unique id of the writer holding the lease. |
timestamp | ISO time the lease was last stamped. It is stamped on acquire and re-stamped on every mutating write by the holder. |
ttl_minutes | Minutes after which an un-refreshed lease is reclaimable (default 60). |
Rules the engine enforces:
lease-acquiresucceeds (OK) when the lease is free or already held by the same writer (re-entrant, re-stamps). It returnsLOCKEDwhen a live lease is held by another writer, orSTALE-RECLAIMED(withprevious_writer/previous_timestamp) when it takes over a lease older than its TTL.- Every mutating call (
upsert-item,cursor-advance) re-checks ownership before writing and returnsLEASE-LOST(no write) if the caller is not the current holder. On success it re-stamps the lease timestamp so a long sweep keeps the lease alive. lease-releaseclears the caller's own lease (OK, alsoOKif none is held). Releasing another writer's lease returnsLEASE-LOSTand does not write.- Staleness is only asserted when it can be proven from parseable timestamps. An unparseable lease timestamp is treated as live and is never stomped.
Topology scope
The lease's guarantee depends on where the state file lives:
| topology | lease scope | protocol |
|---|---|---|
| local-commit mode (default) | Single writer per checkout. | The lease serializes overlapping sweeps in the same working tree (e.g. a cron sweep and a manual one). The file is written in-tree (and may be committed locally). No cross-machine guarantee. |
| pushed-shared-branch | One writer per repo. | The state file lives on a shared branch multiple checkouts push to. lease-acquire must be committed, pushed, and confirmed (fetch back and verify our writer won) before any source-side write. This makes the lease a repo-wide mutex across machines. |
TTL-based reclaim (STALE-RECLAIMED) is what lets a crashed or killed writer's lease be taken over after ttl_minutes without manual cleanup.
run-record
Records the outcome of a sweep run under last_run.
| field | source | meaning |
|---|---|---|
timestamp | --timestamp (required) | Caller-supplied ISO run time. The engine never invents it. |
outcome | --outcome | One of completed, aborted-locked, partial, failed. |
writer | --writer | The writer id that recorded the run. |
counts | --counts (JSON object) | Free-form tallies (per status, per source, etc.). |
run-record is intentionally lease-agnostic. A run that aborted precisely because the lease was LOCKED (outcome: aborted-locked) must still be able to record that fact, but that write happens while the lease holder is mid-sweep. To keep it from clobbering the holder's concurrent upserts, every mutating subcommand holds an OS advisory lock (flock on <state>.lock) across its whole load-modify-write, so two concurrent invocations serialize their writes regardless of lease ownership. The lease decides which writer is running the sweep. The file lock decides which process is writing the file right now. The .lock file is ephemeral and never committed. The skill's commit step adds only the state file and the plan, never -A.
Engine status words
Every subcommand prints one status word on line 1, then an optional JSON payload on line 2. Operational conditions exit 0 (never a traceback). Only CLI misuse exits non-zero.
| word | when | payload |
|---|---|---|
OK | success | command-specific JSON (or none) |
NO-STATE | read on a file that does not exist yet | — |
CORRUPT | file exists but does not parse as this schema | — |
LOCKED | lease-acquire: a live lease is held by another writer | — |
STALE-RECLAIMED | lease-acquire: an expired lease was taken over | {previous_writer, previous_timestamp} |
LEASE-LOST | mutating call by a non-holder, or releasing another writer's lease (no write) | — |
REFUSED | cursor-advance: unknown past-item, or a cursor that would regress | — |
ERROR | unexpected internal error (defensive; still exit 0) | — |
YAML subset
The state file is genuine YAML restricted to a small, deterministic subset so a stdlib serializer/parser round-trips it exactly. Any YAML parser can read it; only the engine writes it.
| construct | rule |
|---|---|
| Indentation | 2 spaces per level, no tabs. |
| Keys | Bare when they match ^[A-Za-z_][A-Za-z0-9_.-]*$; otherwise a JSON double-quoted string (so ids with : are quoted). |
| Scalars | null / true / false / integers / floats are bare. Strings are always JSON double-quoted on a single line (fully escaped). Never block scalars or multiline. |
| Non-empty maps | Emitted as block mappings, recursing to any depth. |
| Lists and empty maps | Emitted as inline JSON flow on one line (e.g. ["a", "b"], {}), which is itself valid YAML. |
| Key order | Deterministic: a preferred order for known keys, then remaining keys sorted, so diffs stay stable. |
| Comments / blank lines | Ignored on read. The engine does not emit comments. |
A file that fails to parse under these rules, or that parses without a schema_version, is CORRUPT. The engine reports it and refuses to overwrite, so a hand-mangled file is never silently clobbered.