Status: consolidated authoritative reference for every schema the evo-core steward speaks, writes, reads, or emits. Audience: plugin authors, distribution authors, consumer authors, anyone writing tooling that validates or generates evo data. Related: every other engineering doc is a narrative companion to one or more schemas defined here.
This document is the single source of truth for evo-core schemas. If a field, type, or shape described here disagrees with another doc, this document wins and the other doc has a bug. Narrative docs explain context, rationale, and usage; SCHEMAS.md defines the schemas themselves.
A distribution author writing an evo-device-<vendor> repository, a plugin author, or a consumer author reaches for at least one schema as soon as they start. Before this document, every schema lived in a different place: some in engineering docs, some in Rust source comments, some implicit in tests. This document puts them all in one navigable reference.
Every schema below includes:
- Location - the file in the repo where its Rust type is defined.
- Shape - the full TOML or JSON structure.
- Fields - a reference table: name, type, required / optional, default, constraints.
- Validation - any rules enforced beyond type shape.
- Example(s) - copy-pasteable canonical forms.
- See also - the narrative doc that explains context.
SCHEMAS.md is authoritative for schema definitions: field names, types, validation rules, wire shapes. Narrative docs remain authoritative for meaning: what a field is for, when to use it, design rationale. A schema change lands here first; narrative docs are updated to match.
In-source Rust types (in crates/evo and crates/evo-plugin-sdk) are the implementation. A divergence between a Rust type and SCHEMAS.md is a bug in SCHEMAS.md if the type is stable, or in the type if the schema has been intentionally revised. Either way, the two are kept in sync.
Per-shelf shape schemas. Brand-neutral framework-tier shelf schemas (under the org.evoframework.* namespace) live in the sibling repository foonerd/evo-catalogue-schemas, separate from evo-core. Distributions and plugin authors pin a specific tag of the sibling repo; distribution packages bundle the schemas at /usr/share/evo-catalogue-schemas/ so plugin authors can validate locally. The in-tree skeleton at dist/catalogue/schemas/ retains a single worked example (example/echo.v1.toml) as on-disk-shape reference; framework-tier schemas migrate to (or originate in) the sibling repo. Validation is via evo-plugin-tool catalogue validate-shelf-schema, which walks a schemas tree and validates every per-shelf TOML file against the shape rules; resolution cascade is --schemas-path flag, $EVO_SCHEMAS_DIR, then /usr/share/evo-catalogue-schemas/.
Schemas you author as TOML files. A distribution writes all three; an individual plugin author writes the manifest.
Location: crates/evo-plugin-sdk/src/manifest.rs (the Manifest struct and related types).
See also: PLUGIN_PACKAGING.md section 2 (narrative), PLUGIN_AUTHORING.md section 5.2 and 6.3 (tutorial).
Every plugin ships with a manifest.toml. The steward validates it against the catalogue before admitting the plugin.
[plugin]
name = "<reverse-dns>" # required; e.g. "com.example.metadata"
version = "<semver>" # required; e.g. "0.1.0"
contract = 1 # required; currently only 1 supported
[target]
shelf = "<rack>.<shelf>" # required; fully-qualified shelf name
shape = <u32> # required; shelf shape version
[kind]
instance = "<singleton|factory>" # required
interaction = "<respondent|warden>" # required
[transport]
type = "<in-process|out-of-process>" # required
exec = "<path>" # required; relative to plugin dir
[trust]
class = "<platform|privileged|standard|unprivileged|sandbox>" # required
[prerequisites]
evo_min_version = "<semver>" # required
os_family = "<any|linux|...>" # optional; default "linux"
outbound_network = <bool> # optional; default false
filesystem_scopes = [<paths>] # optional; default []
[resources]
max_memory_mb = <u32> # required
max_cpu_percent = <u32> # required
[lifecycle]
hot_reload = "<none|restart|live>" # required
autostart = <bool> # optional; default true
restart_on_crash = <bool> # optional; default true
restart_budget = <u32> # optional; default 5
# One of the following must be present, consistent with [kind].interaction:
[capabilities.respondent]
request_types = ["<string>", ...] # required for respondents
response_budget_ms = <u32> # required for respondents
[capabilities.warden]
custody_domain = "<string>" # required for wardens
custody_exclusive = <bool> # required for wardens
course_correction_budget_ms = <u32> # required for wardens
custody_failure_mode = "<abort|partial_ok>" # required for wardens
# Required if [kind].instance is "factory":
[capabilities.factory]
max_instances = <u32> # required for factories
instance_ttl_seconds = <u32> # required for factories; 0 = no TTL[plugin]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
name |
string | yes | - | Matches ^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$. Reverse-DNS. Must equal describe().identity.name. |
version |
string | yes | - | Valid semver. |
contract |
u32 | yes | - | Must equal SUPPORTED_CONTRACT_VERSION (currently 1). |
[target]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
shelf |
string | yes | - | Must be a declared shelf in the steward's catalogue. |
shape |
u32 | yes | - | Must equal the shape of the targeted catalogue shelf. Range support is not part of the schema today; the slot is a single integer. |
[kind]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
instance |
enum | yes | - | singleton or factory. v0 admits singleton only. |
interaction |
enum | yes | - | respondent or warden. |
[transport]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
type |
enum | yes | - | in-process (kebab-case) or out-of-process. |
exec |
string | yes | - | Path to the artefact, relative to the plugin directory. |
[trust]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
class |
enum | yes | - | One of: platform, privileged, standard, unprivileged, sandbox. Ordered most-trusted first. |
[prerequisites]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
evo_min_version |
string | yes | - | Valid semver. |
os_family |
string | no | "linux" |
Free-form; "any" is common. |
outbound_network |
bool | no | false |
Plugin asserts whether it makes outbound network calls. |
filesystem_scopes |
array<string> | no | [] |
Absolute paths the plugin may access. Empty = no filesystem access. |
[resources]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
max_memory_mb |
u32 | yes | - | Declared memory ceiling in megabytes. |
max_cpu_percent |
u32 | yes | - | Declared CPU-share ceiling in percent. |
[lifecycle]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
hot_reload |
enum | yes | - | none, restart, or live. |
autostart |
bool | no | true |
Start plugin at steward startup. |
restart_on_crash |
bool | no | true |
Restart plugin on unexpected exit. |
restart_budget |
u32 | no | 5 |
Max restarts in a rolling 1-hour window before deregistration. |
[capabilities.respondent] (required iff kind.interaction == "respondent")
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
request_types |
array<string> | yes | - | Every request type the plugin's handle_request accepts. |
response_budget_ms |
u32 | yes | - | Per-request deadline in milliseconds. |
[capabilities.warden] (required iff kind.interaction == "warden")
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
custody_domain |
string | yes | - | Distribution-chosen tag, e.g. playback, mount. |
custody_exclusive |
bool | yes | - | true = only one custody at a time. |
course_correction_budget_ms |
u32 | yes | - | Fast-path deadline for corrections. |
custody_failure_mode |
enum | yes | - | abort (terminate on failure) or partial_ok (leave partial results). |
[capabilities.factory] (required iff kind.instance == "factory")
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
max_instances |
u32 | yes | - | Maximum concurrent instances. |
instance_ttl_seconds |
u32 | yes | - | Instance TTL; 0 = no TTL. |
Enforced by Manifest::validate:
plugin.namematches the reverse-DNS regex above.plugin.contractequalsSUPPORTED_CONTRACT_VERSION.[capabilities.respondent]present iffkind.interaction == respondent, absent otherwise.[capabilities.warden]present iffkind.interaction == warden, absent otherwise.[capabilities.factory]present iffkind.instance == factory, absent otherwise.
Enforced by the steward at admission time (outside the SDK):
target.shelfexists in the catalogue.target.shapeequals theshapefield of that shelf (enforced at admission; seeSTEWARD.mdsection 12.4). A supported range on the shelf (multiple admissible shape values) is not in the schema yet.- Signing, if required by the declared trust class (
VENDOR_CONTRACT.md).
Minimal singleton respondent manifest:
[plugin]
name = "com.example.metadata.local"
version = "0.1.0"
contract = 1
[target]
shelf = "metadata.providers"
shape = 2
[kind]
instance = "singleton"
interaction = "respondent"
[transport]
type = "out-of-process"
exec = "plugin.bin"
[trust]
class = "unprivileged"
[prerequisites]
evo_min_version = "0.1.0"
os_family = "linux"
outbound_network = false
filesystem_scopes = []
[resources]
max_memory_mb = 64
max_cpu_percent = 5
[lifecycle]
hot_reload = "restart"
autostart = true
restart_on_crash = true
restart_budget = 5
[capabilities.respondent]
request_types = ["metadata.query"]
response_budget_ms = 5000Location: crates/evo/src/catalogue.rs (the Catalogue struct and related types).
See also: CATALOGUE.md (narrative), CONCEPT.md section 4 (racks), RELATIONS.md section 3 (relation predicates).
A distribution declares its fabric in a catalogue file. The steward reads it at startup and refuses to start if it is malformed.
Every catalogue document carries a top-level schema_version integer. The field is required at parse time; a document without it is rejected with a structured error pointing at the offending path. The integer indexes a versioned grammar so distributions know which catalogue grammar they author against, and the steward declares the supported range via two compile-time constants:
| Constant | Current value |
|---|---|
CATALOGUE_SCHEMA_MIN |
1 |
CATALOGUE_SCHEMA_MAX |
1 |
A document declaring schema_version = N is admissible iff MIN <= N <= MAX. Out-of-range is a hard startup failure rather than a partial bring-up because the catalogue is essence: a distribution authored against the wrong grammar produces silent feature loss the operator cannot diagnose.
Schema bumps are integer-valued; semver does not apply. A breaking grammar change (removed field, newly-required field, type narrowed, semantic shift) requires incrementing CATALOGUE_SCHEMA_MAX. Additive grammar changes (new optional field, new optional section) stay within the current schema version because parsers tolerate unknown fields. Migration is forward-only — the steward never silently rewrites an operator-edited catalogue. Distributions update the field deliberately when adopting a new shape.
Per-shelf shape: u32 (#[[racks.shelves]] shape = N, see §3.2.2) is preserved unchanged: that field versions a shelf's plugin contract; schema_version versions the catalogue document. They evolve independently.
The evo-plugin-tool catalogue lint <path> tool parses and validates a catalogue and surfaces any violation as a non-zero exit. The optional --schema-version N flag additionally pins the document's schema_version to N exactly, useful at distribution-author time to catch a fixture-update slip-through.
The shape documented in §3.2.1 (and refined in §3.2.2/§3.2.3) is schema version 1. It comprises:
schema_version: u32(required, must equal 1 for documents authored against this version).[[racks]]array (optional; defaults to empty).[[subjects]]array (optional; defaults to empty).[[relation]]array (optional; defaults to empty).
Every other field is per-table as defined below. Additional schema versions are documented as further ##### Schema version N subsections, with migration guidance from version N-1.
schema_version = 1 # required; must lie in [CATALOGUE_SCHEMA_MIN, CATALOGUE_SCHEMA_MAX]
[[racks]]
name = "<lowercase>" # required; no dots
family = "<domain|coordination|infrastructure>" # required
kinds = ["<producer|transformer|presenter|registrar>", ...] # optional; default []
charter = "<one-sentence>" # required
[[racks.shelves]]
name = "<lowercase>" # required; no dots; unique within rack
shape = <u32> # required
description = "<one-sentence>" # optional; default ""
# Repeat [[racks]] and [[racks.shelves]] as needed.
[[relation]]
predicate = "<lowercase>" # required; snake_case; no dots
description = "<sentence>" # optional; default ""
source_type = "<type>" | ["<t1>", "<t2>", ...] # required; "*" = any
target_type = "<type>" | ["<t1>", "<t2>", ...] # required; "*" = any
source_cardinality = "<cardinality>" # optional; default "many"
target_cardinality = "<cardinality>" # optional; default "many"
inverse = "<predicate-name>" # optionalValid cardinality values: exactly_one, at_most_one, at_least_one, many.
Top level
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
racks |
array of rack | no | [] |
TOML spelling: [[racks]]. |
relation |
array of predicate | no | [] |
TOML spelling: [[relation]]. Note singular, per #[serde(rename = "relation")]. |
Rack ([[racks]])
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
name |
string | yes | - | Lowercase; no .; unique across all racks. |
family |
string | yes | - | Free-form string in practice; canonical values: domain, coordination, infrastructure. |
kinds |
array<string> | no | [] |
Canonical values: producer, transformer, presenter, registrar. A rack may have multiple kinds. |
charter |
string | yes | - | One-sentence description. Non-empty. |
shelves |
array of shelf | no | [] |
TOML spelling: [[racks.shelves]]. |
Shelf ([[racks.shelves]])
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
name |
string | yes | - | Lowercase; no .; unique within its rack. |
shape |
u32 | yes | - | Shelf shape version. Plugins target specific shape versions. |
description |
string | no | "" |
One-sentence description. |
Relation predicate ([[relation]])
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
predicate |
string | yes | - | Lowercase, snake_case; no .; unique in catalogue. |
description |
string | no | "" |
One-sentence description. |
source_type |
string | array<string> | yes | - | Subject type name, array of type names, or "*" (any). Arrays must be non-empty. |
target_type |
string | array<string> | yes | - | Same shape as source_type. |
source_cardinality |
string | no | "many" |
One of exactly_one, at_most_one, at_least_one, many. |
target_cardinality |
string | no | "many" |
Same as above. |
inverse |
string | no | - | If set, names another declared predicate with swapped source/target types. |
Enforced by Catalogue::validate:
- No duplicate rack names.
- No rack name contains
.. - No rack has an empty name or an empty charter.
- Within a rack, no duplicate shelf names.
- No shelf name contains
.. - No shelf has an empty name.
- No duplicate relation predicates.
- No predicate name is empty or contains
.. - If
source_typeortarget_typeis an array, it is non-empty.
Roadmap items not enforced today:
- Inverse consistency: if
p.inverse = q, thenq.inverse = pand their source/target types are swapped. (Note: subject-type cross-references and inverse symmetry are validated at catalogue load today; the residual gap here is shelf-shape registry coupling described next.) - Shelf shape must match some registered shape schema.
Minimal catalogue with one rack, one shelf, and one relation:
[[racks]]
name = "example"
family = "domain"
kinds = ["registrar"]
charter = "Minimal example rack."
[[racks.shelves]]
name = "echo"
shape = 1
description = "Echoes inputs back."
[[relation]]
predicate = "related_to"
description = "Generic relation for testing."
source_type = "*"
target_type = "*"Realistic catalogue (abbreviated):
[[racks]]
name = "audio"
family = "domain"
kinds = ["transformer"]
charter = "Carry a stream from acquired source to present output."
[[racks.shelves]]
name = "playback"
shape = 1
description = "The active playback engine."
[[racks.shelves]]
name = "delivery"
shape = 1
description = "The output stage delivering decoded audio to hardware."
[[racks]]
name = "metadata"
family = "domain"
kinds = ["registrar"]
charter = "Provide factual and descriptive information about content."
[[racks.shelves]]
name = "providers"
shape = 2
description = "Respondents that look up metadata for subjects."
[[relation]]
predicate = "album_of"
description = "Track belongs to an album."
source_type = "track"
target_type = "album"
source_cardinality = "at_most_one"
target_cardinality = "many"
inverse = "tracks_of"
[[relation]]
predicate = "tracks_of"
source_type = "album"
target_type = "track"
source_cardinality = "many"
target_cardinality = "at_most_one"
inverse = "album_of"Location: crates/evo/src/config.rs (the StewardConfig struct and related types).
See also: CONFIG.md (narrative), STEWARD.md section 10.
Controls where the steward listens, where it reads its catalogue from, and its admission policy. All fields have defaults; the file may be absent entirely.
[steward]
log_level = "<level>" # optional; default "warn"
socket_path = "<path>" # optional; default "/var/run/evo/evo.sock"
[catalogue]
path = "<path>" # optional; default "/opt/evo/catalogue/default.toml"
[plugins]
allow_unsigned = <bool> # optional; default false
plugin_data_root = "<path>" # optional; default "/var/lib/evo/plugins"
runtime_dir = "<path>" # optional; default "/var/run/evo/plugins"
search_roots = ["<path>", ...] # optional; default ["/opt/evo/plugins", "/var/lib/evo/plugins"]
trust_dir_opt = "<path>" # optional; default "/opt/evo/trust"
trust_dir_etc = "<path>" # optional; default "/etc/evo/trust.d"
revocations_path = "<path>" # optional; default "/etc/evo/revocations.toml"
degrade_trust = <bool> # optional; default true
# Optional: per-trust-class Unix identity for out-of-process plugin
# spawns; default = disabled (entire [plugins.security] is optional)
[plugins.security]
enable = <bool> # default false; when false, uid/gid tables ignored
[plugins.security.uid] # optional: keys are trust class names, values are u32
# platform = 2010
# standard = 2011
[plugins.security.gid] # optional; if a class is missing, gid = uid for that class
# sandbox = 2015
[happenings]
retention_capacity = <usize> # optional; default 1024
retention_window_secs = <u64> # optional; default 1800 (30 minutes)[steward]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
log_level |
string | no | "warn" |
One of error, warn, info, debug, trace, or a tracing_subscriber directive string like "evo=info,tokio=warn". |
socket_path |
string (path) | no | "/var/run/evo/evo.sock" |
Path to the client socket the steward binds. |
[catalogue]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
path |
string (path) | no | "/opt/evo/catalogue/default.toml" |
Path to a catalogue.toml file. The file must exist and parse or the steward refuses to start. |
[plugins]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
allow_unsigned |
bool | no | false |
If true, unsigned plugins are admitted (at sandbox trust class only; see VENDOR_CONTRACT.md). |
plugin_data_root |
string (path) | no | "/var/lib/evo/plugins" |
Parent for per-plugin state/ and credentials/. |
runtime_dir |
string (path) | no | "/var/run/evo/plugins" |
Directory for out-of-process plugin socket files *.sock. Paralleling the steward's own socket at /var/run/evo/evo.sock per FHS. |
search_roots |
array of paths | no | ["/opt/evo/plugins", "/var/lib/evo/plugins"] |
Plugin bundle search order; later entry wins on duplicate plugin.name. Must not include plugin-stage/ (incoming uploads); see PLUGIN_PACKAGING.md section 7. |
trust_dir_opt |
string (path) | no | "/opt/evo/trust" |
*.pem public keys; each x.pem requires x.meta.toml (see PLUGIN_PACKAGING.md §5). |
trust_dir_etc |
string (path) | no | "/etc/evo/trust.d" |
Additional operator *.pem keys. |
revocations_path |
string (path) | no | "/etc/evo/revocations.toml" |
Install-digest revocations. Missing file is an empty set. |
degrade_trust |
bool | no | true |
If a signing key is weaker than the manifest’s declared class, admit at the key’s max instead of refusing. |
security.enable |
bool | no | false |
When true (Unix), out-of-process spawns with a security.uid entry for the effective trust class use that UID; optional per-class GID in security.gid, defaulting the GID to the UID. |
security.uid |
table | no | (empty) | Map trust class (string key, same names as the manifest) → UID. Unmapped classes: child runs as the steward. |
security.gid |
table | no | (empty) | Optional per-class GID; if absent for a class, that class’s GID = its UID. |
[happenings]
| Field | Type | Required | Default | Constraint |
|---|---|---|---|---|
retention_capacity |
usize | no | 1024 |
In-memory broadcast ring capacity, in events. Caps how many unconsumed live happenings the bus buffers per subscriber before a slow consumer receives a structured lagged signal. |
retention_window_secs |
u64 | no | 1800 |
Minimum durable retention window the steward guarantees for cursor replay. Cursors older than the oldest retained event get a structured replay_window_exceeded response. |
Config file path:
--config PATHCLI flag, if given. Missing file is an error.- Otherwise
/etc/evo/evo.toml(the default). Missing file is treated as "use defaults".
Per-field overrides (highest precedence first):
log_level:--log-levelCLI flag >RUST_LOGenv var >config.steward.log_level> hardcoded"warn".socket_path:--socketCLI flag >config.steward.socket_path> default.catalogue.path:--catalogueCLI flag >config.catalogue.path> default.allow_unsigned,plugin_data_root,runtime_dir,search_roots,trust_dir_opt,trust_dir_etc,revocations_path,degrade_trust,plugins.security.*: config file only.
Minimal (all defaults):
# evo.toml - empty or missing is fineTypical development:
[steward]
log_level = "info"
socket_path = "/tmp/evo.sock"
[catalogue]
path = "/home/dev/evo-device-audio/catalogue.toml"Production with strict admission policy:
[steward]
log_level = "warn"
socket_path = "/var/run/evo/evo.sock"
[catalogue]
path = "/opt/evo/catalogue/default.toml"
[plugins]
allow_unsigned = falseSchemas you speak over a socket. The client protocol is what consumers use; the plugin wire protocol is what out-of-process plugins use.
Location: crates/evo/src/server.rs (the ClientRequest and ClientResponse enums).
See also: CLIENT_API.md (consumer-facing reference with language examples), STEWARD.md section 6 (normative wire spec).
Length-prefixed JSON over a Unix socket. Every request is one JSON object; every response is one JSON object (or a stream of objects for subscriptions).
Every frame, in either direction:
[4-byte big-endian length] [length bytes of UTF-8 JSON]
Maximum frame size is 64 MiB (matches the absolute hard ceiling on prepare_for_live_reload state blobs). Zero-length frames are rejected. One JSON object per frame; no delimiters inside the payload.
Every request carries an op discriminator.
op |
Shape | Streaming |
|---|---|---|
"request" |
Sync request/response | No |
"project_subject" |
Sync request/response | No |
"describe_alias" |
Sync request/response | No |
"list_active_custodies" |
Sync request/response | No |
"list_subjects" |
Sync request/response (paginated) | No |
"list_relations" |
Sync request/response (paginated) | No |
"enumerate_addressings" |
Sync request/response (paginated) | No |
"subscribe_happenings" |
Streaming (ack + stream) | Yes |
op = "request":
{
"op": "request",
"shelf": "<rack>.<shelf>",
"request_type": "<string>",
"payload_b64": "<base64>"
}| Field | Type | Required | Notes |
|---|---|---|---|
op |
string | yes | Must be "request". |
shelf |
string | yes | Fully-qualified shelf name. Must exist in the catalogue. |
request_type |
string | yes | One of the request types declared by the target plugin. |
payload_b64 |
string | yes | Base64-encoded bytes. May be empty (""). |
op = "project_subject":
{
"op": "project_subject",
"canonical_id": "<uuid>",
"scope": {
"relation_predicates": ["<predicate>", ...],
"direction": "forward" | "inverse" | "both",
"max_depth": <u32>,
"max_visits": <u32>
},
"follow_aliases": <bool>
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
canonical_id |
string (UUID) | yes | - | Canonical subject ID. |
scope |
object | no | - | Omit for no relation traversal. |
scope.relation_predicates |
array<string> | no | [] |
Predicates to traverse. |
scope.direction |
string | no | "forward" |
forward, inverse, or both. |
scope.max_depth |
u32 | no | 1 |
Traversal depth limit. Caller-supplied values above the steward's hard cap (32) are silently clamped. |
scope.max_visits |
u32 | no | 1000 |
Total visit cap across the walk. Caller-supplied values above the steward's hard cap (100 000) are silently clamped. |
follow_aliases |
bool | no | true |
Auto-follow alias chains for stale canonical IDs. When false, a queried ID retired by merge or split returns subject: null plus the populated aliased_from so the consumer chooses how to follow. |
op = "describe_alias":
{
"op": "describe_alias",
"subject_id": "<uuid>",
"include_chain": <bool>
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
subject_id |
string (UUID) | yes | - | Canonical subject ID to inspect. |
include_chain |
bool | no | true |
Walk the full alias chain (default). When false, only the immediate alias record is returned (single-hop view). |
op = "list_active_custodies":
{ "op": "list_active_custodies" }No other fields.
op = "list_subjects":
{
"op": "list_subjects",
"cursor": "<opaque base64>",
"page_size": <usize>
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
cursor |
string | no | absent | Opaque base64 returned in the previous response's next_cursor. Absent on the first page. |
page_size |
u32 | no | 100 | Maximum subjects in this page. Values above 1000 are clamped to 1000. |
The response carries current_seq so consumers can pin the snapshot to a happenings position; see §4.1.3.
op = "list_relations":
{
"op": "list_relations",
"cursor": "<opaque base64>",
"page_size": <usize>
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
cursor |
string | no | absent | Opaque base64 returned in the previous response's next_cursor. Absent on the first page. |
page_size |
u32 | no | 100 | Maximum edges in this page. Values above 1000 are clamped to 1000. |
The response carries current_seq; see §4.1.3.
op = "enumerate_addressings":
{
"op": "enumerate_addressings",
"cursor": "<opaque base64>",
"page_size": <usize>
}| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
cursor |
string | no | absent | Opaque base64 returned in the previous response's next_cursor. Absent on the first page. |
page_size |
u32 | no | 100 | Maximum addressings in this page. Values above 1000 are clamped to 1000. |
The response carries current_seq; see §4.1.3.
op = "subscribe_happenings":
{ "op": "subscribe_happenings" }No other fields. Promotes the connection to streaming mode.
Success response to op = "request":
{ "payload_b64": "<base64>" }Success response to op = "project_subject": shape varies with whether the queried ID resolved live or required alias resolution.
Live-subject path — a full SubjectProjection object (see section 5.3); the aliased_from key is absent, not serialised as null:
<SubjectProjection>Alias-aware path — emitted whenever the queried ID has been merged or split, regardless of whether follow_aliases was set:
{
"subject": <SubjectProjection> | null,
"aliased_from": <AliasedFrom>
}| Field | Type | Notes |
|---|---|---|
subject |
object | null | The terminal subject's projection when the chain resolved to one live subject AND follow_aliases was true (the default); null otherwise (forked split, depth cap hit, or auto-follow disabled). |
aliased_from |
object | Always present in this branch. See AliasedFrom shape in section 5.5. |
Consumers test for the presence of the aliased_from key (not its value) to discriminate the two paths. An unknown canonical_id returns the existing not-found error shape verbatim with no aliased_from field.
Success response to op = "describe_alias":
{
"ok": true,
"subject_id": "<uuid>",
"result": <SubjectQueryResult>
}| Field | Type | Notes |
|---|---|---|
ok |
bool | Always true on success; the key set { ok, subject_id, result } distinguishes this response from every other shape. |
subject_id |
string (UUID) | Echoes the queried ID so callers correlating pipelined responses match without holding state. |
result |
object | The lookup outcome. SubjectQueryResult is internally tagged by kind ("found", "aliased", "not_found"). See section 5.7. |
Success response to op = "list_active_custodies":
{ "active_custodies": [ <CustodyRecord>, ... ] }Where each record has the shape defined in section 5.2.
Success response to op = "list_subjects":
{
"subjects": [
{
"canonical_id": "<uuid>",
"subject_type": "<string>",
"addressings": [
{ "scheme": "<string>", "value": "<string>", "claimant_token": "<token>" }
]
}
],
"next_cursor": "<opaque base64>" | null,
"current_seq": <u64>
}Pages iterate in canonical-ID order. next_cursor is null when the snapshot is exhausted; otherwise the consumer passes it back as cursor on the next request. current_seq is the bus's monotonic cursor sampled at snapshot time and is identical across pages of the same iteration; pin reconcile-style happening replay to it.
Success response to op = "list_relations":
{
"relations": [
{
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"claimant_tokens": ["<token>", ...],
"suppressed": <bool>
}
],
"next_cursor": "<opaque base64>" | null,
"current_seq": <u64>
}Pages iterate in (source_id, predicate, target_id) order. Suppressed edges are included so the snapshot is structurally complete; consumers that want only visible edges filter on suppressed == false. Pagination and current_seq semantics match list_subjects.
Success response to op = "enumerate_addressings":
{
"addressings": [
{ "scheme": "<string>", "value": "<string>", "canonical_id": "<uuid>" }
],
"next_cursor": "<opaque base64>" | null,
"current_seq": <u64>
}Pages iterate in (scheme, value) order. Pagination and current_seq semantics match list_subjects.
Ack response to op = "subscribe_happenings":
{ "subscribed": true }Followed by an indefinite stream of happening and lagged frames (below).
Streamed happening frame (one per emitted happening, after the ack):
{ "happening": <HappeningVariant> }Where <HappeningVariant> is one of the shapes in section 5.1.
Streamed lagged frame (when the subscriber has fallen behind):
{
"lagged": {
"missed_count": <u64>,
"oldest_available_seq": <u64>,
"current_seq": <u64>
}
}missed_count is the number of happenings dropped from the broadcast ring since the last successful delivery to this subscriber. oldest_available_seq is the smallest seq the steward currently retains in happenings_log; a consumer whose last observed seq is at or above this value can resume cleanly via a fresh subscribe with since set to that seq, while a consumer whose last seq has rotated past the window MUST fall back to the snapshot list ops pinned to current_seq. current_seq is the bus's cursor at signal time.
Error response (to any sync op):
{
"error": {
"class": "<class>",
"message": "<human-readable message>",
"details": { "subclass": "<subclass>", ...class-specific extras }
}
}The presence of the "error" key signals the response is an error. The stable contract is the structured object: class is the top-level taxonomy class (one of the eleven snake-case strings below); message is operator-readable but advisory and not contractual; details is optional and weakly-typed JSON refining the class with a subclass discriminator and any class-specific extra fields.
The class is one of:
| Class | Connection-fatal | Retryable | Meaning |
|---|---|---|---|
transient |
no | yes | Operation may succeed on retry without state change. Network blip, lock contention. |
unavailable |
no | yes | Plugin or backend currently down; retry with backoff. |
resource_exhausted |
no | yes | Quota, memory, disk; retry once pressure relieves. |
contract_violation |
no | no | Caller violated the contract (wrong shape, wrong type, cardinality breach). |
not_found |
no | no | Addressed entity does not exist. |
permission_denied |
no | no | Caller lacks the capability for the operation. Distinct from trust_violation. |
trust_violation |
yes | no | Verified-identity check failed; trust class, signature, revocation, role. |
trust_expired |
yes | no | Key in the trust chain is outside its not_before / not_after window. |
protocol_violation |
yes | no | The wire frame itself is malformed; the version handshake failed; codec disagreement. |
misconfiguration |
no | no | Operator-level configuration error; retrying without operator action is pointless. |
internal |
yes | no | Steward invariant violated internally. Caller did nothing wrong. |
Connection-fatal is derived from the class — there is no independent fatal flag on the wire. A consumer that observes a class it does not recognise MUST degrade to treating it as internal and log a warning rather than crash; this preserves forward-compatibility against future class additions.
details.subclass (when present) is a snake_case string refining the class. The taxonomy is additive: existing names are stable across releases; new subclasses are appended without renaming or repurposing earlier names. Documented subclasses:
| Class | Subclass | Meaning |
|---|---|---|
trust_violation |
admin_trust_too_low |
Effective trust class is below the admin minimum. Extras: plugin_name, effective, minimum. |
contract_violation |
cardinality_violation |
A relation assertion would violate a declared cardinality. (Reserved; not yet emitted on the wire — surfaced today as a relation_cardinality_violation happening because the relation graph is permissive on assert.) |
contract_violation |
unknown_predicate |
Relation predicate not declared in the catalogue. Extras: predicate. |
contract_violation |
unknown_subject_type |
Subject type not declared in the catalogue. Extras: subject_type. |
contract_violation |
merge_self_target |
Operator-supplied addressings resolve to the same canonical subject. |
contract_violation |
merge_cross_type |
Merge sources have differing subject types. Extras: a_type, b_type. |
contract_violation |
split_target_index_out_of_bounds |
Explicit relation assignment names a partition index outside the operator's partitions. Extras: index, partition_count. |
contract_violation |
replay_window_exceeded |
subscribe_happenings since cursor is older than the oldest retained seq. Extras: oldest_available_seq, current_seq. |
contract_violation |
invalid_page_cursor |
A paginated list op was issued with a cursor that did not decode (bad base64 or non-utf8 payload). |
contract_violation |
invalid_base64 |
Caller-supplied payload_b64 field did not decode as base64. |
contract_violation |
invalid_request |
Caller's request body parsed as JSON but did not satisfy the schema for any documented op (typically a missing required field). |
not_found |
merge_source_unknown |
Merge source addressing is not registered. Extras: addressing. |
not_found |
target_plugin_unknown |
Privileged retract names a plugin not currently admitted. Extras: plugin. |
not_found |
unknown_subject |
Caller-supplied canonical_id is not in the registry and no alias chain resolves it. |
permission_denied |
resolve_claimants_not_granted |
Connected peer did not satisfy the client_acl.toml policy for resolve_claimants (no UID / GID / local-grant match). |
protocol_violation |
invalid_json |
Request frame body did not parse as JSON. Wire-level malformation; the connection is closed per protocol_violation semantics. |
internal |
alias_lookup_failed |
Storage-layer query for the alias chain failed. Operator-facing diagnostic; consumer should retry or fall back to a snapshot. |
internal |
alias_terminal_missing |
The alias-chain walk reached a terminal canonical_id that the registry has no record of. Steward invariant breach. |
internal |
dispatch_misroute |
Internal dispatch routed a request to the wrong handler. Steward invariant breach. |
internal |
replay_decode_failed |
A persisted happenings_log row did not deserialise into a known Happening variant. Storage corruption or cross-version drift. |
internal |
replay_query_failed |
Storage-layer query for the replay window failed. Consumer should fall back to a snapshot pinned to a fresh current_seq. |
internal |
replay_window_query_failed |
Storage-layer query for the oldest retained seq failed; the steward could not evaluate the replay-window cutoff. |
internal |
unsupported_variant |
The steward hit a code path that should be unreachable for the validated request shape. Steward invariant breach. |
misconfiguration |
catalogue_invalid |
Catalogue parse or validation failure (including out-of-range schema_version). (Reserved; not yet emitted on the wire — catalogue errors surface only at boot today and reach operator logs, not consumers. Will emit when an operator-callable reload-catalogue verb lands.) |
misconfiguration |
manifest_invalid |
Plugin manifest parse or validation failure. (Reserved; not yet emitted on the wire — manifest errors surface only at admission today and reach operator logs, not consumers. Will emit when an operator-callable reload-manifest verb lands.) |
Consumers wanting to act on a subclass must agree on the vocabulary out of band; the contract is on top-level class and the subclass strings published here. Unknown subclasses fall through to the top-level class semantics with no degradation in behaviour.
- Errors do not close the connection unless their
classis one ofprotocol_violation/trust_violation/trust_expired/internal; clients may send another request on the same socket for the non-fatal classes. - A subscribed connection is output-only from then on; client frames are ignored.
- One request in flight at a time per connection; pipeline across connections.
- The wire
Errorframe on the plugin protocol carries the sameclassfield; translation between the plugin-wire and the client-API surface is lossless.
Location: crates/evo-plugin-sdk/src/wire.rs (the WireFrame enum and PROTOCOL_VERSION constant).
See also: PLUGIN_CONTRACT.md sections 6-11 (normative spec).
Out-of-process plugins speak this protocol with the steward over a Unix socket. Same framing as the client protocol (4-byte big-endian length + UTF-8 JSON).
Every frame carries three envelope fields:
| Field | Type | Notes |
|---|---|---|
v |
u16 | Protocol version. Current: 1 (PROTOCOL_VERSION). |
cid |
u64 | Correlation ID. Steward requests carry a cid the response echoes; plugin events carry their own cid. |
plugin |
string | Canonical plugin name (reverse-DNS). Validated against the manifest. |
The op field discriminates between frame types. Frames are internally tagged per serde convention (#[serde(tag = "op", rename_all = "snake_case")]).
Forty-two op values across handshake (Hello / HelloAck), steward-to-plugin requests, plugin-to-steward responses to those requests, plugin-to-steward async events plus their per-event acks, plugin-to-steward requests (alias-aware queries and admin verbs), steward-to-plugin responses to those requests, and bidirectional error frames.
Requests (steward-to-plugin)
op |
Verb | Additional fields |
|---|---|---|
describe |
Core: identify yourself | - |
load |
Core: prepare to operate | config (JSON), state_dir, credentials_dir, deadline_ms? |
unload |
Core: shut down | - |
health_check |
Core: report liveness | - |
handle_request |
Respondent: dispatch request | request_type, payload (base64), deadline_ms? |
take_custody |
Warden: assign work | custody_type, payload (base64), deadline_ms? |
course_correct |
Warden: modify active custody | handle (CustodyHandle), correction_type, payload (base64) |
release_custody |
Warden: release work | handle (CustodyHandle) |
Responses (plugin-to-steward, echo the request's cid)
op |
Answers | Additional fields |
|---|---|---|
describe_response |
describe |
description (PluginDescription) |
load_response |
load |
- |
unload_response |
unload |
- |
health_check_response |
health_check |
report (HealthReport) |
handle_request_response |
handle_request |
payload (base64) |
take_custody_response |
take_custody |
handle (CustodyHandle) |
course_correct_response |
course_correct |
- |
release_custody_response |
release_custody |
- |
Plugin-initiated requests (plugin-to-steward, carry their own cid)
These reverse the polarity of the request / response axis: the plugin issues the request, the steward answers with the matching *_response frame (or an error frame echoing the same cid). Out-of-process plugins use these to invoke the alias-aware SubjectQuerier callback over the wire; in-process plugins call the trait directly without serialising a frame.
op |
Purpose | Additional fields |
|---|---|---|
describe_alias |
Look up the alias record (if any) for a canonical subject ID | subject_id (string) |
describe_subject |
Look up the live subject for a canonical subject ID, walking alias chains as far as a single terminal | subject_id (string) |
Steward responses to plugin-initiated requests
op |
Answers | Additional fields |
|---|---|---|
describe_alias_response |
describe_alias |
record (AliasRecord | null) |
describe_subject_response |
describe_subject |
result (SubjectQueryResult) |
Async events (plugin-to-steward, carry their own cid)
op |
Purpose | Additional fields |
|---|---|---|
report_state |
State change | payload (base64), priority (ReportPriority) |
announce_subject |
Subject identity claim | announcement (SubjectAnnouncement) |
retract_subject |
Withdraw addressing | addressing (ExternalAddressing), reason? |
assert_relation |
Claim a relation edge | assertion (RelationAssertion) |
retract_relation |
Withdraw a relation | retraction (RelationRetraction) |
report_custody_state |
Custody state update | handle (CustodyHandle), payload (base64), health (HealthStatus) |
Event ack (steward-to-plugin, echoes the event's cid)
The wire-side announcer / reporter trait implementations await an event_ack (success) or an error (rejection) per event cid, surfacing the same Result<(), ReportError> to the trait caller as in-process plugins receive. See PLUGIN_CONTRACT.md.
op |
Answers | Additional fields |
|---|---|---|
event_ack |
any of the six * events above |
- |
Handshake (bidirectional, exchanged once before any other dispatch)
The connecting peer (the steward) sends hello; the answerer (the plugin) replies with hello_ack. Frames carry cid: 0 by convention. Negotiation rejection produces an error frame (fatal: true) in place of hello_ack.
op |
Direction | Additional fields |
|---|---|---|
hello |
steward → plugin | feature_min (u16), feature_max (u16), codecs (string[]) |
hello_ack |
plugin → steward | feature (u16), codec (string) |
Admin verbs (plugin-to-steward, carry their own cid)
Plugins admitted at admin trust class invoke SubjectAdmin and RelationAdmin over the wire through these frames. The steward enforces capability gating server-side; a plugin without admin capability gets a non-fatal error frame ("admin capability not granted"). Each request has a paired *_response frame whose body is envelope-only (the trait methods return Result<(), ReportError>); failures collapse to error.
op |
Purpose | Additional fields |
|---|---|---|
forced_retract_addressing |
Force-retract another plugin's addressing claim | target_plugin, addressing, reason? |
forced_retract_addressing_response |
Success ack | - |
merge_subjects |
Merge two canonical subjects into one new ID | target_a, target_b, reason? |
merge_subjects_response |
Success ack | - |
split_subject |
Split one canonical subject into N new IDs | source, partition, strategy, explicit_assignments, reason? |
split_subject_response |
Success ack | - |
forced_retract_claim |
Force-retract another plugin's relation claim | target_plugin, source, predicate, target, reason? |
forced_retract_claim_response |
Success ack | - |
suppress_relation |
Hide a relation from neighbour queries | source, predicate, target, reason? |
suppress_relation_response |
Success ack | - |
unsuppress_relation |
Restore a suppressed relation | source, predicate, target |
unsuppress_relation_response |
Success ack | - |
Error (bidirectional)
op |
Additional fields |
|---|---|
error |
message, fatal (bool) |
Opaque bytes are base64-encoded in JSON. Applied fields: payload on handle_request, handle_request_response, take_custody, course_correct, release_custody data, report_state, report_custody_state.
describe request:
{ "op": "describe", "v": 1, "cid": 42, "plugin": "com.example.metadata" }handle_request request:
{
"op": "handle_request", "v": 1, "cid": 100, "plugin": "com.example.metadata",
"request_type": "metadata.query",
"payload": "c29tZSBieXRlcw==",
"deadline_ms": 5000
}report_custody_state event:
{
"op": "report_custody_state", "v": 1, "cid": 400, "plugin": "com.example.playback",
"handle": { "id": "custody-42", "started_at": "2024-01-15T10:30:00Z" },
"payload": "cG9zaXRpb249MTIz",
"health": "healthy"
}describe_alias request (plugin-initiated):
{
"op": "describe_alias", "v": 1, "cid": 500, "plugin": "com.example.metadata",
"subject_id": "stale-canonical-id"
}describe_alias_response (steward-to-plugin):
{
"op": "describe_alias_response", "v": 1, "cid": 500, "plugin": "com.example.metadata",
"record": {
"old_id": "stale-canonical-id",
"new_ids": ["new-id-after-merge"],
"kind": "merged",
"recorded_at_ms": 1700000000000,
"admin_plugin": "com.example.admin"
}
}describe_subject request (plugin-initiated):
{
"op": "describe_subject", "v": 1, "cid": 501, "plugin": "com.example.metadata",
"subject_id": "stale-canonical-id"
}describe_subject_response (steward-to-plugin):
{
"op": "describe_subject_response", "v": 1, "cid": 501, "plugin": "com.example.metadata",
"result": {
"kind": "aliased",
"chain": [
{
"old_id": "stale-canonical-id",
"new_ids": ["new-id-after-merge"],
"kind": "merged",
"recorded_at_ms": 1700000000000,
"admin_plugin": "com.example.admin"
}
],
"terminal": {
"id": "new-id-after-merge",
"subject_type": "track",
"addressings": [
{
"addressing": { "scheme": "mbid", "value": "abc-def" },
"claimant": "com.example.metadata",
"added_at_ms": 1700000000200
}
],
"created_at_ms": 1700000000200,
"modified_at_ms": 1700000000600
}
}
}error frame:
{
"op": "error", "v": 1, "cid": 100, "plugin": "com.example.metadata",
"message": "shelf shape mismatch",
"fatal": true
}Schemas you receive as responses or streamed data. All defined in terms of JSON shape.
Location: crates/evo/src/happenings.rs (the Happening enum), crates/evo/src/server.rs (the HappeningWire serialisation type).
See also: HAPPENINGS.md section 3.
Emitted on the happenings bus, streamed to subscribe_happenings subscribers. Internally tagged by the type field. The enum is #[non_exhaustive]: consumers must tolerate new variants.
Every variant carries:
| Field | Type | Notes |
|---|---|---|
type |
string | Variant discriminator. Snake-case. |
at_ms |
u64 | Steward's clock at emission, milliseconds since Unix epoch. |
Custody variants additionally carry plugin and handle_id. Subject and relation variants carry the canonical IDs and (where applicable) the predicate. Admin variants carry admin_plugin and (where the action targeted a specific claim) target_plugin. Field reference per variant is below.
Sixteen variants ship today. The Happening enum is #[non_exhaustive]; consumers MUST tolerate unknown type values and treat them as ignorable. Variants are grouped here by category for navigation; on the wire they are one flat tagged union.
Custody
type = "custody_taken":
{
"type": "custody_taken",
"plugin": "<string>",
"handle_id": "<string>",
"shelf": "<rack>.<shelf>",
"custody_type": "<string>",
"at_ms": <u64>
}type = "custody_released":
{
"type": "custody_released",
"plugin": "<string>",
"handle_id": "<string>",
"at_ms": <u64>
}type = "custody_state_reported":
{
"type": "custody_state_reported",
"plugin": "<string>",
"handle_id": "<string>",
"health": "healthy" | "degraded" | "unhealthy",
"at_ms": <u64>
}Relation graph
type = "relation_cardinality_violation":
{
"type": "relation_cardinality_violation",
"plugin": "<string>",
"predicate": "<string>",
"source_id": "<uuid>",
"target_id": "<uuid>",
"side": "source" | "target",
"declared": "exactly_one" | "at_most_one" | "at_least_one" | "many",
"observed_count": <usize>,
"at_ms": <u64>
}side indicates which side's bound was exceeded; declared echoes the predicate's bound on that side; observed_count is the count after the assertion was stored. Only exactly_one and at_most_one bounds emit this variant in practice (other bounds cannot be exceeded by a single assertion).
type = "relation_forgotten":
{
"type": "relation_forgotten",
"plugin": "<string>",
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"reason": <RelationForgottenReason>,
"at_ms": <u64>
}reason is internally tagged by kind:
{ "kind": "claims_retracted", "retracting_plugin": "<string>" }{ "kind": "subject_cascade", "forgotten_subject": "<uuid>" }For claims_retracted, plugin and reason.retracting_plugin are the same canonical name (the plugin that retracted the last claim) - except on cascade-from-admin paths, where reason.retracting_plugin is the admin plugin (see RelationClaimForcedRetract). For subject_cascade, plugin is the plugin whose retract triggered the parent SubjectForgotten; reason.forgotten_subject is the canonical ID of the subject the cascade removed and may equal source_id or target_id.
type = "relation_suppressed":
{
"type": "relation_suppressed",
"admin_plugin": "<string>",
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"reason": "<string>" | null,
"at_ms": <u64>
}Re-suppressing an already-suppressed relation with the same reason is a silent no-op and emits no happening. A re-suppress with a DIFFERENT reason emits relation_suppression_reason_updated (next entry).
type = "relation_suppression_reason_updated":
{
"type": "relation_suppression_reason_updated",
"admin_plugin": "<string>",
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"old_reason": "<string>" | null,
"new_reason": "<string>" | null,
"at_ms": <u64>
}Emitted when an admin re-suppresses an already-suppressed relation with a DIFFERENT reason. The suppression record's reason field is mutated in place; the record's admin_plugin and suppressed_at are preserved (the suppression itself was already valid; only the rationale evolved). The transitions Some -> None, None -> Some, and Some(a) -> Some(b) (where a != b) all count as "different reason" and emit this happening. Same-reason re-suppress is a silent no-op.
type = "relation_unsuppressed":
{
"type": "relation_unsuppressed",
"admin_plugin": "<string>",
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"at_ms": <u64>
}No reason field on the unsuppress variant. Unsuppressing a non-suppressed or unknown relation is a silent no-op.
Subject registry
type = "subject_forgotten":
{
"type": "subject_forgotten",
"plugin": "<string>",
"canonical_id": "<uuid>",
"subject_type": "<string>",
"at_ms": <u64>
}subject_type is captured from the registry record before removal. Emitted BEFORE any cascade relation_forgotten events for the same forget.
Admin (privileged) operations
type = "subject_addressing_forced_retract":
{
"type": "subject_addressing_forced_retract",
"admin_plugin": "<string>",
"target_plugin": "<string>",
"canonical_id": "<uuid>",
"scheme": "<string>",
"value": "<string>",
"reason": "<string>" | null,
"at_ms": <u64>
}scheme and value are the components of the retracted ExternalAddressing, carried flat so the wire form does not nest. Fires BEFORE any cascade subject_forgotten and relation_forgotten events.
type = "relation_claim_forced_retract":
{
"type": "relation_claim_forced_retract",
"admin_plugin": "<string>",
"target_plugin": "<string>",
"source_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"reason": "<string>" | null,
"at_ms": <u64>
}Fires BEFORE any cascade relation_forgotten event. The cascade event's reason.retracting_plugin is the ADMIN plugin (not target_plugin), because the admin's action caused the retract.
type = "subject_merged":
{
"type": "subject_merged",
"admin_plugin": "<string>",
"source_ids": ["<uuid>", "<uuid>"],
"new_id": "<uuid>",
"reason": "<string>" | null,
"at_ms": <u64>
}source_ids has length 2 today; modelled as an array for forward compatibility with multi-way merge. The two source IDs survive in the registry as Merged aliases. Fires BEFORE the relation-graph rewrite; cascading relation_cardinality_violation events fire afterwards.
type = "subject_split":
{
"type": "subject_split",
"admin_plugin": "<string>",
"source_id": "<uuid>",
"new_ids": ["<uuid>", "<uuid>", ...],
"strategy": "to_both" | "to_first" | "explicit",
"reason": "<string>" | null,
"at_ms": <u64>
}new_ids has length at least 2. The source ID survives in the registry as a single Split alias carrying every new ID. strategy records the relation-distribution policy the operator chose; under explicit, gap relations produce trailing relation_split_ambiguous happenings. Fires BEFORE per-edge relation-graph rewrites.
type = "relation_split_ambiguous":
{
"type": "relation_split_ambiguous",
"admin_plugin": "<string>",
"source_subject": "<uuid>",
"predicate": "<string>",
"other_endpoint_id": "<uuid>",
"candidate_new_ids": ["<uuid>", "<uuid>", ...],
"at_ms": <u64>
}source_subject is the OLD canonical ID (no longer resolves directly after the parent subject_split); other_endpoint_id is the relation's other endpoint (may be on either side). candidate_new_ids lists every new ID the relation was replicated to under fall-through to_both semantics. One emission per gap relation; multiple emissions per split are possible. Fires AFTER the parent subject_split.
type = "relation_rewritten":
{
"type": "relation_rewritten",
"admin_plugin": "<string>",
"predicate": "<string>",
"old_subject_id": "<uuid>",
"new_subject_id": "<uuid>",
"target_id": "<uuid>",
"at_ms": <u64>
}Emitted once per edge whose endpoint changed canonical ID during a merge rewrite or a split-by-strategy. old_subject_id and new_subject_id identify the endpoint that was rewritten; target_id is the OTHER endpoint (the side that did not change). Lets subscribers indexing on (source_id, predicate, target_id) keep their index coherent through merges and splits without snapshot reconcile. Fires AFTER the parent subject_merged or subject_split.
type = "relation_cardinality_violated_post_rewrite":
{
"type": "relation_cardinality_violated_post_rewrite",
"admin_plugin": "<string>",
"subject_id": "<uuid>",
"predicate": "<string>",
"side": "source" | "target",
"declared": "exactly_one" | "at_most_one" | "at_least_one" | "many",
"observed_count": <usize>,
"at_ms": <u64>
}Emitted when a merge rewrite or a split-by-strategy consolidates two valid claim sets on (subject_id, predicate) into a count that exceeds the catalogue bound. Cardinality is checked only on assert; this variant covers the rewrite path. side matches the convention of relation_cardinality_violation: source means too many targets via predicate from subject_id; target means too many sources point at subject_id via predicate. Observational - administration plugins decide reconciliation. Fires AFTER the parent subject_merged or subject_split.
type = "claim_reassigned":
{
"type": "claim_reassigned",
"admin_plugin": "<string>",
"plugin": "<string>",
"kind": "addressing" | "relation",
"old_subject_id": "<uuid>",
"new_subject_id": "<uuid>",
"scheme": "<string>",
"value": "<string>",
"predicate": "<string>",
"target_id": "<uuid>",
"at_ms": <u64>
}Emitted once per plugin claim transferred from a source subject onto a new canonical ID by merge or split. plugin is the affected claimant; admin_plugin is the privileged actor that triggered the reassignment. kind selects which optional fields are populated:
kind = "addressing":schemeandvalueare present;predicateandtarget_idare absent.kind = "relation":predicateandtarget_idare present;schemeandvalueare absent.
Absent fields are omitted from the JSON object (they do not appear as null). Fires AFTER the parent subject_merged or subject_split.
type = "relation_claim_suppression_collapsed":
{
"type": "relation_claim_suppression_collapsed",
"admin_plugin": "<string>",
"subject_id": "<uuid>",
"predicate": "<string>",
"target_id": "<uuid>",
"demoted_claimant": "<string>",
"surviving_suppression_record": <SuppressionRecord>,
"at_ms": <u64>
}Emitted when suppression-collapse during a merge rewrite demotes a previously-visible claim to invisible: the surviving relation inherited a suppression marker from one of the colliding edges and the other edge's claim is now carried by a suppressed record. demoted_claimant is the canonical name of the plugin whose claim was demoted.
surviving_suppression_record is the suppression provenance now applied to the surviving edge:
{
"admin_plugin": "<string>",
"suppressed_at_ms": <u64>,
"reason": "<string>" | null
}Fires AFTER the parent subject_merged.
Location: crates/evo/src/custody.rs (the CustodyRecord and StateSnapshot structs).
See also: CUSTODY.md section 3.
Returned by op = "list_active_custodies". One record per live custody.
{
"plugin": "<string>",
"handle_id": "<string>",
"shelf": "<rack>.<shelf>" | null,
"custody_type": "<string>" | null,
"last_state": <StateSnapshot> | null,
"started_at_ms": <u64>,
"last_updated_ms": <u64>
}| Field | Type | Always populated? | Notes |
|---|---|---|---|
plugin |
string | yes | Key component. |
handle_id |
string | yes | Key component. |
shelf |
string | null | after record_custody |
null during the take/report race window. |
custody_type |
string | null | after record_custody |
Same. |
last_state |
object | null | after first record_state |
See StateSnapshot below. |
started_at_ms |
u64 | yes | First-insertion timestamp, stable across UPSERTs. |
last_updated_ms |
u64 | yes | Updated on every UPSERT. |
{
"payload_b64": "<base64>",
"health": "healthy" | "degraded" | "unhealthy",
"reported_at_ms": <u64>
}| Field | Type | Notes |
|---|---|---|
payload_b64 |
string | Opaque bytes, base64-encoded. Shape is warden-defined. |
health |
string | healthy, degraded, or unhealthy. |
reported_at_ms |
u64 | Steward's clock at receipt, not the warden's. |
Location: crates/evo/src/projections.rs (the SubjectProjection struct).
See also: PROJECTIONS.md.
Returned by op = "project_subject".
{
"canonical_id": "<uuid>",
"subject_type": "<string>",
"addressings": [ <ExternalAddressing>, ... ],
"related": [ <RelatedSubject>, ... ],
"composed_at_ms": <u64>,
"shape_version": <u32>,
"claimants": [ "<plugin-name>", ... ],
"degraded": <bool>,
"degraded_reasons": [ "<string>", ... ],
"walk_truncated": <bool>
}| Field | Type | Notes |
|---|---|---|
canonical_id |
string (UUID) | The subject's canonical ID. |
subject_type |
string | Subject type (e.g. track, album). |
addressings |
array | External addressings that resolve to this subject. |
related |
array | Related subjects found via scoped walk. |
composed_at_ms |
u64 | Steward's clock when the projection was composed. |
shape_version |
u32 | Shape version of the projection itself. |
claimants |
array<string> | Plugins whose contributions are represented. |
degraded |
bool | true if composition had to skip contributions (e.g., plugin unresponsive). |
degraded_reasons |
array<string> | Advisory strings explaining any degradation. |
walk_truncated |
bool | true if the relation walk hit max_depth or max_visits. |
ExternalAddressing:
{ "scheme": "<string>", "value": "<string>", "claimant": "<plugin-name>" }RelatedSubject:
{
"predicate": "<string>",
"direction": "forward" | "inverse",
"target_id": "<uuid>",
"target_type": "<string>",
"relation_claimants": [ "<plugin-name>", ... ],
"nested": <SubjectProjection> | null
}nested is populated when the scope's max_depth permits further recursion.
Location: crates/evo-plugin-sdk/src/contract/subjects.rs (the AliasRecord struct, the AliasKind enum).
See also: SUBJECTS.md section 10.4.
When an admin plugin merges or splits a canonical subject, the OLD canonical IDs survive in the registry as alias records so consumers holding stale references can resolve them via the steward's describe_alias operation. The framework does NOT transparently follow aliases on resolve; chasing the alias is an explicit consumer step.
{
"old_id": "<uuid>",
"new_ids": ["<uuid>", ...],
"kind": "merged" | "split",
"recorded_at_ms": <u64>,
"admin_plugin": "<string>",
"reason": "<string>"
}| Field | Type | Required | Notes |
|---|---|---|---|
old_id |
string (UUID) | yes | The canonical ID that no longer addresses a live subject. |
new_ids |
array<string> | yes | The new canonical IDs. Length 1 for merge, length at least 2 for split. Distinguish by inspecting kind rather than by counting new_ids. |
kind |
enum | yes | merged or split. See section 5.4.2. |
recorded_at_ms |
u64 | yes | When the alias was recorded, milliseconds since UNIX epoch. |
admin_plugin |
string | yes | Canonical name of the administration plugin that performed the operation. |
reason |
string | omitted | no | Operator-supplied reason. Omitted on serialise when None. |
Serialises as a snake_case string.
| Value | Meaning |
|---|---|
"merged" |
The old subject was merged into another subject. The alias's new_ids has length 1. |
"split" |
The old subject was split into multiple subjects. The alias's new_ids has length at least 2. |
"tombstone" |
The old subject was forgotten with no successor. The alias's new_ids is empty so consumers walking the chain see "this canonical ID was forgotten" rather than a bare not-found. |
"type_migrated" |
The old subject was re-stated under a new subject_type via the operator-issued migrate_grammar_orphans verb. The alias's new_ids has length 1 (the new ID minted for the migrated record). Distinguishes type-change identity flips from semantic-change merges. See CATALOGUE.md §5.3 and SUBJECTS.md §10.4. |
Location: crates/evo/src/server.rs (the AliasedFromWire struct).
See also: CLIENT_API.md section 4.2.2 (consumer-facing semantics), SUBJECTS.md section 10.4.
Attached to a project_subject response whenever the queried canonical ID has been merged or split. The envelope mirrors the on-wire shape of the SDK SubjectQueryResult::Aliased variant (section 5.7) so consumers can carry the same parser through both surfaces; the only difference is that this struct surfaces the queried ID and the terminal ID directly (instead of a fully-projected terminal SubjectRecord) because the corresponding subject field on the response already carries the projection.
{
"queried_id": "<uuid>",
"chain": [ <AliasRecord>, ... ],
"terminal_id": "<uuid>" | null
}| Field | Type | Required | Notes |
|---|---|---|---|
queried_id |
string (UUID) | yes | The canonical ID the consumer originally addressed. |
chain |
array<AliasRecord> | yes | The alias chain the steward walked, oldest-first. Length is at least 1 whenever this struct is emitted. |
terminal_id |
string (UUID) | null | yes | Canonical ID of the terminal subject if the chain resolved to a single live subject; null when the chain forks (a split, or a chain that hit the steward's depth cap of 16 hops). |
The maximum chain length the steward will walk is 16 hops (defence-in-depth). Hitting the cap returns the partial chain with terminal_id: null; the caller can re-query the last entry's new_ids to continue.
Location: crates/evo-plugin-sdk/src/contract/subjects.rs (the SubjectRecord and SubjectAddressingRecord structs).
See also: SUBJECTS.md section 10.4, PLUGIN_CONTRACT.md section 5.2 (the SubjectQuerier callback that returns these types).
A snapshot of one canonical subject as visible to consumers of the alias-aware describe operations. Mirrors the steward's internal SubjectRecord shape but projected onto SDK-visible types: timestamps are stored as milliseconds since the UNIX epoch, addressings carry their per-addressing claimant and recording timestamp.
{
"id": "<uuid>",
"subject_type": "<string>",
"addressings": [ <SubjectAddressingRecord>, ... ],
"created_at_ms": <u64>,
"modified_at_ms": <u64>
}| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (UUID) | yes | Canonical subject ID. |
subject_type |
string | yes | Subject type, as declared in the catalogue. |
addressings |
array | yes | All addressings currently registered to this subject, with per-addressing provenance. |
created_at_ms |
u64 | yes | When this subject was first registered, milliseconds since the UNIX epoch. |
modified_at_ms |
u64 | yes | When the subject was last modified (addressing added or removed), milliseconds since the UNIX epoch. |
{
"addressing": <ExternalAddressing>,
"claimant": "<plugin-name>",
"added_at_ms": <u64>
}| Field | Type | Required | Notes |
|---|---|---|---|
addressing |
object | yes | The (scheme, value) pair. See section 5.3 for the ExternalAddressing shape. |
claimant |
string | yes | Canonical name of the plugin that first asserted this addressing. |
added_at_ms |
u64 | yes | When the claim was recorded, milliseconds since the UNIX epoch. |
Note: the SubjectRecord's ExternalAddressing is the SDK shape { scheme, value }. The SubjectProjection's ExternalAddressing (section 5.3) flattens the claimant onto the same object as { scheme, value, claimant } because projections aggregate addressings across plugins; here, the claimant lives on the SubjectAddressingRecord wrapper instead.
Location: crates/evo-plugin-sdk/src/contract/subjects.rs (the SubjectQueryResult enum).
See also: SUBJECTS.md section 10.4, PLUGIN_CONTRACT.md section 5.2.
Returned by op = "describe_alias" (as the result field), the SDK SubjectQuerier::describe_subject_with_aliases callback, and the wire describe_subject_response frame. Carries enough information for a consumer holding a stale canonical ID to recover the current identity, including the audit chain of merges / splits that produced it.
Internally tagged by the kind field; serialises as snake_case. The enum is #[non_exhaustive]: consumers MUST tolerate unknown kind values (treat as "ignore" or "log and continue", never crash).
kind: "found" — the queried ID is current:
{
"kind": "found",
"record": <SubjectRecord>
}| Field | Type | Required | Notes |
|---|---|---|---|
record |
object | yes | The current subject record at the queried ID. See section 5.6. |
kind: "aliased" — the queried ID was retired by a merge or split:
{
"kind": "aliased",
"chain": [ <AliasRecord>, ... ],
"terminal": <SubjectRecord> | null
}| Field | Type | Required | Notes |
|---|---|---|---|
chain |
array<AliasRecord> | yes | The alias chain the steward walked, oldest-first. Length is at least 1. Each entry records one merge or split that touched the path from the queried ID toward the current subject set. |
terminal |
object | null | yes | The current subject if the chain resolves to one (a single merge target, or a chain of merges ending in a live subject). null when the chain forks (a split, or a chain that hit the steward's depth cap of 16 hops). |
kind: "not_found" — no subject ever existed at the queried ID, and no alias either:
{ "kind": "not_found" }No additional fields.
The maximum chain length the steward will walk is 16 hops (defence-in-depth against pathological chains). Hitting the cap returns the partial chain with terminal: null; the caller can re-query the last entry's new_ids to continue manually.
Location: crates/evo-plugin-sdk/src/contract/subjects.rs (the SplitRelationStrategy enum, the ExplicitRelationAssignment struct).
See also: RELATIONS.md section 8.2, SUBJECTS.md section 10.2.
Used by the SDK's SubjectAdmin::split primitive to control how relations on the source subject are distributed to the new subjects.
Serialises as a snake_case string.
| Value | Meaning |
|---|---|
"to_both" |
Every relation involving the source subject is replicated once per new subject. No information is lost; cardinality violations may surface as relation_cardinality_violation happenings. The conservative default. |
"to_first" |
Every relation goes to the FIRST new subject in the partition; subsequent new subjects start bare. |
"explicit" |
Each relation is assigned to a specific new subject by operator-supplied ExplicitRelationAssignment entries. Relations with no matching assignment fall through to to_both and the steward emits one relation_split_ambiguous per gap. |
{
"source": <ExternalAddressing>,
"predicate": "<string>",
"target": <ExternalAddressing>,
"target_new_id_index": <integer>
}| Field | Type | Required | Notes |
|---|---|---|---|
source |
object | yes | ExternalAddressing of the relation's source endpoint. See section 5.3 for the shape. |
predicate |
string | yes | Predicate of the relation. |
target |
object | yes | ExternalAddressing of the relation's target endpoint. |
target_new_id_index |
integer (>= 0) | yes | Zero-based index into the operator's partitions directive on the surrounding split request. Must be strictly less than partitions.len(). The framework maps the index to the freshly-minted canonical ID after the split commits, so operators never need to know UUIDs the framework has not minted. Validation runs BEFORE any registry mint; out-of-bounds indices are refused with SplitTargetNewIdIndexOutOfBounds and the registry remains untouched. |
The triple (source, predicate, target) identifies a single relation in the graph at the time of the split.
Location: crates/evo/src/admin.rs (the AdminLogEntry struct, the AdminLogKind enum).
See also: PERSISTENCE.md (the admin_log table this struct is shaped to fit), BOUNDARY.md section 6.1.
Every privileged administration action a plugin takes through the SubjectAdmin or RelationAdmin callbacks is recorded in the steward's in-memory admin ledger. The shape parallels CustodyLedger's record model and is shaped to align with the persistence story documented in PERSISTENCE.md.
The admin ledger is not exposed on the client socket today; the entry shape is documented here because (a) it is the canonical home for the audit trail of admin actions and (b) a future client-socket op (or part of the broader happenings expansion) will surface it.
{
"kind": <AdminLogKind>,
"admin_plugin": "<string>",
"target_plugin": "<string>" | null,
"target_subject": "<uuid>" | null,
"target_addressing": <ExternalAddressing> | null,
"target_relation": <RelationKey> | null,
"additional_subjects": ["<uuid>", ...],
"reason": "<string>" | null,
"prior_reason": "<string>" | null,
"at_ms": <u64>
}| Field | Type | Always populated? | Notes |
|---|---|---|---|
kind |
enum | yes | One of the AdminLogKind values in section 5.9.2. |
admin_plugin |
string | yes | Canonical name of the admin plugin that performed the action. |
target_plugin |
string | null | per kind | Canonical name of the plugin whose claim was modified. null for kinds that do not target a specific plugin (subject_merge, subject_split, relation_suppress, relation_suppression_reason_updated, relation_unsuppress). |
target_subject |
string (UUID) | null | per kind | Canonical ID of the subject involved. For subject_merge this is the NEW canonical ID; for subject_split this is the SOURCE (old) canonical ID. |
target_addressing |
object | null | per kind | Addressing targeted, populated for subject_addressing_forced_retract. |
target_relation |
object | null | per kind | Relation key targeted, populated for relation operations. |
additional_subjects |
array<string> | sometimes | Extra canonical subject IDs. Populated for subject_merge (the source IDs, length 2) and subject_split (the new IDs, length at least 2). Empty array otherwise. |
reason |
string | null | optional | Free-form operator-supplied reason; mirrors the reason field on the underlying primitive. For relation_suppression_reason_updated this is the NEW reason; the prior reason is carried separately on prior_reason. |
prior_reason |
string | null | per kind | Reason on the relevant record before the action overwrote it. Populated only for relation_suppression_reason_updated. null for every other kind. Within relation_suppression_reason_updated, a null here means the prior reason was literally null on the suppression record. |
at_ms |
u64 | yes | When the action was recorded, milliseconds since UNIX epoch. |
Serialises as a snake_case string. The enum is #[non_exhaustive]; readers must tolerate unknown values.
| Value | Meaning | Paired happening |
|---|---|---|
"subject_addressing_forced_retract" |
An admin force-retracted an addressing claimed by another plugin. | subject_addressing_forced_retract |
"relation_claim_forced_retract" |
An admin force-retracted a relation claim made by another plugin. | relation_claim_forced_retract |
"subject_merge" |
An admin merged two canonical subjects into one. target_subject carries the NEW ID; additional_subjects carries the source IDs. target_plugin is null. |
subject_merged |
"subject_split" |
An admin split one canonical subject into two or more. target_subject carries the SOURCE (old) ID; additional_subjects carries the new IDs. target_plugin is null. |
subject_split |
"relation_suppress" |
An admin suppressed a relation. target_relation carries the relation key. target_plugin is null. |
relation_suppressed |
"relation_suppression_reason_updated" |
An admin re-suppressed an already-suppressed relation with a DIFFERENT reason. target_relation carries the relation key. target_plugin is null. reason is the NEW reason; prior_reason is the reason on the suppression record before the update. Same-reason re-suppress is a silent no-op and produces no entry. |
relation_suppression_reason_updated |
"relation_unsuppress" |
An admin unsuppressed a previously-suppressed relation. target_relation carries the relation key. target_plugin is null. |
relation_unsuppressed |
AdminLogKind and the corresponding Happening variant are paired but not identical: AdminLogKind is the persisted audit kind (snake_case singular verb form: subject_merge); the happening's type is the streamed event tag (snake_case past tense: subject_merged). The wire representations are intentionally distinct so a future audit-log reader and a happenings subscriber need not multiplex on the same string.
Applied across all schemas:
| Context | Convention | Example |
|---|---|---|
| Plugin names | Reverse-DNS, lowercase, dots | com.example.metadata.local |
| Rack names | Lowercase, no dots | audio, metadata |
| Shelf names | Lowercase, no dots | echo, providers |
| Fully-qualified shelves | <rack>.<shelf> |
metadata.providers |
| Relation predicates | snake_case, no dots | album_of, tracks_of |
| TOML enum values | lowercase or snake_case | singleton, at_most_one |
| Transport enum | kebab-case | in-process, out-of-process |
| Wire protocol op names | snake_case | handle_request, report_custody_state |
| Client protocol op names | snake_case | project_subject, list_active_custodies |
Happening type values |
snake_case | custody_taken, custody_state_reported |
| JSON fields | snake_case | payload_b64, at_ms, started_at_ms |
Time fields ending in _ms |
Milliseconds since Unix epoch, u64 | at_ms, started_at_ms, reported_at_ms |
| Opaque bytes in JSON | Base64-encoded, suffixed _b64 in client protocol |
payload_b64 |
| Opaque bytes on wire protocol | Base64-encoded, no suffix | payload |
Different schemas version differently:
| Schema | Version field | Semantics |
|---|---|---|
| Plugin manifest | plugin.contract |
Integer. Current: 1. A plugin declaring a version the SDK does not support is rejected at parse time. |
| Plugin wire protocol | v on every frame |
Integer (PROTOCOL_VERSION). Current: 1. A wire-version mismatch closes the connection. |
| Catalogue | No top-level version | Each shelf has shape: <u32>. The steward enforces equality with manifest.target.shape at admission. Range semantics (multiple admissible shapes per slot) are not implemented. |
| Shelf shape | shape on each shelf and target.shape on the manifest |
Integer. Must match exactly for admission today. |
| Steward config | No version | Forward-compatible via default-valued fields. |
| Client protocol | No version | Response shapes are fixed for the steward's lifetime; a new steward version with incompatible shapes requires coordinated consumer updates. |
| Happening variants | #[non_exhaustive] enum |
New variants added without breaking source compatibility; consumers must tolerate unknown type values. |
| SubjectProjection | shape_version |
Integer. Allows future schema evolution of projection responses. |
Pre-1.0 policy (BOUNDARY.md section 8): all schemas may change in patch releases. Post-1.0, breaking schema changes require a major-version bump.
PLUGIN_PACKAGING.md- narrative on manifest, signing, distribution.CATALOGUE.md- narrative on catalogue concepts and authoring.CONFIG.md- narrative on steward config and runtime configuration.CLIENT_API.md- consumer-facing client protocol with language examples.PLUGIN_CONTRACT.md- normative plugin wire protocol.HAPPENINGS.md- happenings bus semantics and variant design.CUSTODY.md- ledger record model and UPSERT semantics.PROJECTIONS.md- projection composition and traversal rules.RELATIONS.md- relation graph and predicate semantics.SUBJECTS.md- subject registry and canonical identity.STEWARD.md- steward module structure and responsibilities.BOUNDARY.md- where schemas cross the framework/distribution boundary.VENDOR_CONTRACT.md- trust classes and signing hierarchy referenced by the manifest.