Files
2026-07-13 12:20:01 +08:00

11 KiB

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 writer; every peer agent (source connectors, the analyzer, the orchestrator) reads the file and mutates 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/written field-preservingly; the engine only adds rules per version, 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, 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 (SKILL.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 — 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

Writers own keys, 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 mutates only the keys it owns 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 — on acquire, and re-stamped on every owned mutating write.
ttl_minutes Minutes after which an un-refreshed lease is reclaimable (default 60).

Rules the engine enforces:

  • lease-acquire succeeds (OK) when the lease is free or already held by the same writer (re-entrant, re-stamps). It returns LOCKED when a live lease is held by another writer, or STALE-RECLAIMED (with previous_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 returns LEASE-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-release clears the caller's own lease (OK, also OK if none is held); releasing another writer's lease returns LEASE-LOST and does not write.
  • Staleness is only asserted when it can be proven from parseable timestamps; an unparseable lease timestamp is treated as live (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 who owns the sweep; the file lock decides who 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-owner, or releasing another'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"], {}) — 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.