Record Format
The 8-field record — wire format, canonical JSON rules, ID derivation, act types, data types, common body conventions.
The 8 fields
Every record has exactly 8 fields. Seven are hashed into the ID; one (judged_by) is not.
| # | Field | Type | Hashed | Description |
|---|---|---|---|---|
| 1 | parents | string[] | yes | Record IDs this builds on. Sorted ascending. [] for a root record. |
| 2 | thread | string | yes | Thread ID: th_ + 64 hex characters. |
| 3 | actor | string | yes | Actor DID (did:sync:user:alice, did:sync:agent:dev, did:key:…, etc.). |
| 4 | act | string | yes | One of 8 act types (see below). |
| 5 | body | object | yes | JSON payload. Kernel-opaque; conventions documented per topic. |
| 6 | clock | integer | yes | Logical timestamp. Monotonic per (actor, thread). Non-negative, fits in i64. |
| 7 | data_type | string | yes | One of 6 data types (see below). |
| 8 | judged_by | string | null | no | Pointer to the record whose judgment this reflects. Often null. |
ID derivation
id = SHA-256(canonical_json(act, actor, body, clock, data_type, parents, thread))Seven fields, alphabetical key order, canonical JSON encoding, SHA-256. The result is lowercase hex, 64 characters, no prefix.
The judged_by field is excluded from the hash because the same observation reviewed by two different parties is the same observation. Excluding judged_by keeps the ID stable across reviews while letting trust attribution vary.
Canonical JSON encoding
The hashing function operates on canonical JSON bytes. Every implementation must produce identical bytes for the same semantic input.
Rules:
- Keys sorted alphabetically at every depth.
{"b": 1, "a": 2}serializes as{"a":2,"b":1}. - No whitespace outside of string values. No newlines, no indentation.
- Numbers as the minimal representation. No leading zeros. No trailing zeros in fractions. No exponent unless required by magnitude.
- Strings use JSON escaping. Control characters
�throughare\uXXXX-escaped."and\are escaped. UTF-8 bytes pass through otherwise. - Booleans and null as
true,false,null. - Arrays preserve element order. This matters for
parents— preserve ascending sort.
A reference encoder is in syncropel-algebra::canonical::encode (Rust) and syncropel.canonical.canonical_json (Python SDK). Both produce identical bytes for identical semantic input. See the 7 test vectors for conformance cases.
Thread IDs
thread_id = "th_" + SHA-256(<seed inputs>)The th_ prefix is non-negotiable — it's how downstream code distinguishes thread IDs from record IDs (bare 64-hex) and DIDs (did:…). Validators reject records whose thread field doesn't start with th_ followed by exactly 64 hex characters.
Seed inputs vary by creator:
- Task threads —
SHA-256(alias + nonce); the CLI generates the nonce atspl task addtime. - Dispatch threads —
SHA-256(parent_thread + target_actor + dispatch_timestamp_ms); each dispatch run has a fresh thread. - Reserved threads — hand-assigned canonical IDs baked into the kernel:
th_engine_configth_actor_registryth_namespace_registryth_instance_registryth_fleet_controlth_consent
Client code never invents a thread ID freely. Use a well-known constant, seed-derive through an SDK helper, or ask the server to mint one.
Act types
Eight types in two groups of four.
Coordination (human/agent/service writes)
| Type | Purpose | Typical body fields |
|---|---|---|
INTEND | State a goal, open a thread. | goal, task, assigned_to, domain, priority, labels, namespace |
DO | Record an action performed. | tool, args, cancels, topic |
KNOW | Record an observation, fact, or verdict. | topic, observation, fulfills, verdict, judged_by_did |
LEARN | Record an insight or configuration change. | topic, rule, trigger, config |
Effect (adapter/tool writes)
| Type | Purpose | Typical body fields |
|---|---|---|
GET | Retrieve a value. | key, result |
PUT | Store a value. | key, value |
CALL | Invoke a tool or service. | tool, cmd, provider, request, response |
MAP | Transform data. | from, to, transform |
Data types
| Type | Meaning |
|---|---|
SCALAR | A single value. The default for most records. |
FORMULA | A computation specification — the body carries an expression. |
DISTRIBUTION | A probability distribution, interval, or range. |
REFERENCE | A pointer to external content (URL, hash, content-addressed blob). |
MORPHISM | A transformation function between types. Used by MAP. |
VOID | No value. Used by pure side-effect records. |
Clock rules
- Monotonically increasing per
(actor, thread). Cannot go backwards. - Starts at 0 for an actor's first record on a thread.
- Gaps allowed —
0, 1, 5, 7is valid. Gaps don't imply missing records; they typically mean the actor wrote elsewhere in between. - Per-actor per-thread. Two actors on the same thread each have independent clock sequences.
- Duplicate clocks return
DuplicateClockon ingest. This is the retry-safe idempotency hook — if you see this error, either your client's clock is wrong or there's a concurrent writer. Increment and retry. - Clocks are i64. Values from 0 to 9,223,372,036,854,775,807 are valid. Long-running threads well outside normal use before overflowing.
Wire format
On the HTTP API, records are JSON. The 8 fields appear at the top level:
{
"parents": [],
"thread": "th_a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890",
"actor": "did:sync:user:alice",
"act": "INTEND",
"body": {
"goal": "Deploy the authentication service v2",
"namespace": "acme-corp/auth/prod"
},
"clock": 0,
"data_type": "SCALAR",
"judged_by": null
}The response always echoes the stored record plus an id field:
{
"parents": [],
"thread": "th_a1b2c3d4…",
"actor": "did:sync:user:alice",
"act": "INTEND",
"body": { "goal": "…", "namespace": "…" },
"clock": 0,
"data_type": "SCALAR",
"judged_by": null,
"id": "a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890"
}The id is never sent by the client and never stored in the hash pre-image — it's purely the output of the hash function, shown for convenience.
Common body conventions
The kernel treats body as opaque, but conventions are stable enough to document.
Opening a task thread:
{
"act": "INTEND",
"body": {
"task": true,
"alias": "SKL-0334",
"goal": "Docs depth pass",
"assigned_to": "did:sync:agent:dev",
"domain": "code",
"priority": "high",
"labels": ["v0.18-foundation", "docs"],
"namespace": "default"
}
}Closing a thread with a verdict:
{
"act": "KNOW",
"body": {
"fulfills": "3292991f311ea69ce02fa290c7517d0bd20658d0f4d379ffdeb39b52180f87ec",
"verdict": "accept",
"summary": "All deliverables shipped; build clean."
},
"judged_by": "3292991f311ea69ce02fa290c7517d0bd20658d0f4d379ffdeb39b52180f87ec"
}A routing rule (LEARN on th_engine_config):
{
"act": "LEARN",
"thread": "th_engine_config",
"body": {
"topic": "routing_rule",
"name": "code-to-dev",
"expression": "record.act == 'INTEND' && record.body.domain == 'code'",
"target": "did:sync:agent:dev",
"priority": 100
}
}A correction record:
{
"act": "KNOW",
"body": {
"topic": "correction",
"supersedes": "<bad_record_id>",
"reason": "value was wrong; correct value is X"
},
"parents": ["<bad_record_id>"]
}A cancellation:
{
"act": "DO",
"body": {
"cancels": "<intend_id>",
"reason": "requirements changed"
}
}See CEL expressions for the full set of topics the engine recognizes, and the body.kind grammar (ADR-034) for the SDK-enforced structure of body.kind in integration records.
What the kernel validates at ingest
The kernel validates a small, fast set:
- All 8 fields present with the correct types.
threadmatches^th_[0-9a-f]{64}$.actoris a syntactically valid DID. Unknown actors are allowed; invalid syntax is rejected.actis one of the 8 types.data_typeis one of the 6 types.clockis a non-negative i64.parentsis a sorted-ascending array of valid record IDs (64-hex each).body.namespace(if present) passes namespace narrowing — every ancestor must exist in the registry and be Active.
What the kernel does not validate:
- Semantic correctness of
body. The kernel doesn't know whetherbody.fulfillsrefers to a real record, whetherbody.topicis a recognised topic, or whether thebodyshape fits the declareddata_type. All of that is consumer-side. - Signature verification. Signatures live in the federation layer; local records are not signed.
- Duplicate content (apart from duplicate clocks). Two records differing only in clock are two distinct records; that's allowed.
Errors
The POST /v1/records endpoint returns structured JSON errors:
| Code | Meaning |
|---|---|
INVALID_SHAPE | 8 fields missing, wrong types, or malformed thread/actor/act/data_type. |
DUPLICATE_CLOCK | Another record with the same (actor, thread, clock) already exists. Retry with clock+1. |
NAMESPACE_REJECTED | body.namespace (or an ancestor) is missing or non-Active in the registry. |
AUTH_REQUIRED | auth.required=true and no valid bearer token was presented. |
FORBIDDEN | A CEL permission rule denied the write. |
Error responses include the failing field and, where applicable, a recovery hint. See API reference for the full error shape.
What's next
- Records concept — the design story behind the 8 fields.
- API reference — HTTP endpoints that accept and return records.
- CEL expressions —
recordbindings available in triggers, routing, preconditions. body.kindgrammar — SDK-level structure insidebody.- Glossary — vocabulary used across the reference.