Threads
A thread is a coordination context, the shared scope inside which records are ordered, folded, and closed. Every task, dispatch, federation pair, and config domain is a thread.
Overview
A thread is a named context that records attach to. Its ID is stable and derived from what it is, its contents are immutable, and its meaning is whatever the fold that consumes it decides. A task is a thread. A dispatch is a thread. The engine config is a thread. A federation sync subscription targets a thread.
Threads give Syncropel its coordination semantics: records written to the same thread participate in the same story. Two records on different threads are independent by construction, no fold mixes them unless you explicitly ask.
If you're mentally importing from elsewhere: a thread is closer to a Git branch than to a Slack channel. It's the unit of history, not the unit of conversation. A single agent dispatch is a thread. A conversation with a user may span dozens.
Thread identity
Thread IDs are deterministic, derived from their seed inputs, and carry a th_ prefix. The prefix makes thread IDs visually distinct from record IDs and from actor DIDs (did:sync:*). This distinction matters, the same function can take all three as arguments, and misreading one for another is a class of bug.
The seed inputs vary by creator:
- Task threads are seeded from the alias and a nonce, so
TASK-0001maps to one stable thread ID. - Dispatch threads are seeded from the parent thread, target actor, and dispatch timestamp, so each run gets a fresh ID.
- Reserved threads (
th_engine_config,th_instance_registry,th_fleet_control,th_consent,th_namespace_registry,th_actor_registry) have hand-assigned, well-known IDs baked into Syncropel and documented in the spec.
Client code never invents a thread ID freely. It either uses a well-known constant, derives from a seed, or asks the server to mint one. This is what lets a federation pair target a thread by ID alone, the ID is portable across instances.
Lifecycle
Every thread follows a natural arc, though the shape is flexible:
INTEND → DO/CALL → KNOW → KNOW(fulfills)
(open) (active) (observed) (closed)- Opened by an
INTENDstating the goal or purpose. Every thread starts with an INTEND; if you see a thread whose first clock isDOorKNOW, that's a bug or a partially-migrated import. - Active while
DOandCALLrecords land. An adapter executing work emits these as it goes. - Observed when
KNOWrecords capture intermediate facts, verdicts, or summaries. - Closed by a
KNOWcarryingbody.fulfills: "<intend_id>"referencing the opening INTEND. The fold transitions the thread to "done" on that signal. A thread can also close via cancellation, aDOwithbody.cancelsor aKNOWwithbody.verdict: "reject".
Lifecycle state is derived from records, not stored as a column. Two threads with different visible "statuses" have different record sets. There is no UPDATE threads SET status = ... anywhere in the system.
Fork and merge
Threads can fork into sub-threads and merge results back. This is how fan-out works, how task sub-tasks nest, and how federation subscribes per-thread without having to own the parent:
Parent: th_deploy_v2
├─ INTEND "deploy v2"
├─ INTEND sub-thread th_run_tests ─ child
│ └─ KNOW "458/458 pass"
├─ INTEND sub-thread th_migrate_db ─ child
│ └─ KNOW "migration complete"
└─ KNOW fulfills "deployed"A child thread has its own ID. The parent records an INTEND that references the child (body.sub_thread), and the reconciler dispatches the work on the child thread. When the child closes, a synthesising record on the parent thread says "child X completed with result Y". The join predicate in the parent INTEND decides when the parent itself should close, all (every child), any (first accept), k_of_n, or an arbitrary CEL expression.
This gives clean audit: the child thread has the full execution trace; the parent thread carries a summary and the fold result. You can drill in or stay at the summary level depending on what question you're answering.
Tasks are threads (with conventions)
A task in Syncropel is a thread whose opening INTEND has body.task = true and whose body carries alias, assignee, domain, priority, labels, and success criteria. Nothing in the runtime enforces this, it's the task fold that recognizes the pattern and projects it into the spl task show view.
This has three practical consequences:
- Every task has a thread with real records. If you want the ground truth on what a task did and when, read its thread.
spl debug replay TASK-XXXXwalks the records with the fold's status transitions annotated. - Task state can never drift from record state. There's no status column to get stuck; the status is a pure function of the records on the thread.
- Two sibling threads are two independent tasks. Forking a thread creates a child task; the parent task status depends on the child's close signals per the join predicate.
Reserved threads
A handful of thread IDs are reserved because Syncropel uses them to bootstrap itself:
| Thread | Purpose | Read by |
|---|---|---|
th_engine_config | Engine config: routing rules, triggers, CEL policies, auth config. | The engine on every broadcast. |
th_actor_registry | Registered actors: DID, display name, category, status. | Actor resolution in the router, CEL record.actor binding. |
th_namespace_registry | Namespace hierarchy: ORG / PROJECT / ENV / JOB entries. | The namespace narrowing check at ingest. |
th_instance_registry | Live fleet: heartbeats, endpoint URLs, capabilities per instance. | Fan-out dispatch target selection. |
th_fleet_control | Kill switch state: soft / hard / emergency freeze records. | CEL permission rules on every write. |
th_consent | Cross-namespace consent grants. | The federation consent filter on every sync pull. |
Every one of these is just a thread. Records on them follow the same rules. The runtime treats them specially only in the sense that derived state is eagerly rebuilt when new records land, same reload pattern for all.
Participants and roles
Every actor who writes a record on a thread is a participant. Participation is derived automatically from the record log, no separate participant table. spl thread show <id> lists participants with record counts and inferred roles (opener, reviewer, adapter, observer).
Role inference is convention-based, not enforced. The opener is whoever emitted the first INTEND. A reviewer is an actor emitting KNOW with a verdict. An adapter is an actor whose records are paired INTEND/DO/CALL/KNOW with dispatch_handled = true. The fold computes these on-demand; you don't have to maintain a participants list by hand.
Why threads instead of labels or rooms
The thread is deliberately a single-dimensional construct: a record belongs to exactly one thread. No multi-tag, no multi-room. This isn't an oversight, it's the property that makes folds composable. If a record could belong to multiple threads, the fold would need a join semantics, and which join would depend on the consumer. Single-thread keeps folds pure and cacheable.
Categorisation across threads happens through labels (in body.labels), through body.topic, through namespace (body.namespace), and through DID filters (actor). These are query-time dimensions; the thread is the coordination-time dimension.
When a new thread vs a continuation
Two rules of thumb:
- New thread when the goal is new. A new task, a new dispatch, a new deployment, a new federation subscription, each gets its own thread. Thread IDs are cheap; there is no penalty to having many.
- Continuation on the same thread when the goal is the same. Follow-up observations, correction records, the final verdict on a long-running task, these all land on the original thread. Forking into a sub-thread is appropriate when a branch of work is independent enough to need its own close signal.
A typical engineer's weekly footprint is dozens of task threads, a handful of dispatch threads per active task, plus participation in the six reserved threads. All immutable, all auditable, all foldable on demand.
What's next
- Records: the unit records attach to.
- Actors: who writes to threads.
- Trust: how thread verdicts produce reputation.
- Task management guide: threads with task conventions, end to end.
Records
The immutable, verifiable unit, the only primitive Syncropel writes. Every intent, action, observation, and decision is a record.
The three shapes
The Studio is not a hundred screens with a hundred layouts. It is a fold over your records, and exactly three shapes cross between you and it. A sentence you complete, a ledger you read, and an address that says where you are.