Service property contract

Status: accepted, version 0.2.1. This records the existing structured namespace wire surface implemented by props' optional store and router. The ownership decision places it in libs/props. Verbs belong to the service exposing them and must be registered under that owner in the Bus registry.

Addressing

Commands are <svc>.props.<suffix>. The namespace header names a registered unqualified namespace; its capability name is <svc>.<namespace>. Collection records require a non-empty key. Singleton requests omit key or send an empty key; replies use the schema's canonical key.

if_version is an optional decimal u64 fence, required when the namespace declares require_version; zero fences creation. Record version and namespace nseq increase on commits. cause is an optional correlation string on mutations. Actor identity comes from the authenticated peer, never a caller-supplied actor header. _lifecycle is reserved.

This surface requires structured addressing. set refuses path with flat_path_mutation_deferred; other verbs refuse it as mixed addressing. The compositor's flat {path,value,generation} mutation remains its own contract. field_path is refused on set/delete/get, even when empty. Get refuses any non-empty body. List refuses cursor, limit, order_by, filter, fields and any non-empty body. These refusals carry diagnostic prefixes field_path_deferred, get_projection_deferred or list_option_deferred under validation_error.

Verbs

SuffixHeaders/body and successful reply
setJSON object body; merge=true/patch (default) or false/replace. Simple reply: {namespace,key,version,nseq,created,warnings?}. Saga reply: {namespace,key,set_version,set_nseq,complete_version,complete_nseq,lifecycle,created,reason?}.
deleteCollection only; optional if_version, cause. Reply: {namespace,key,deleted:true,tombstone_version,nseq}.
get{namespace,key,version,nseq,fields}; nseq is the observed namespace cursor.
list{namespace,nseq,records:[{key,version,fields}],next_cursor:null}.
describeview=public (default) or full; {namespace,schema_version,cardinality,fields,primary_key_field?}. Fields carry name, type, secret flag, default, validators, help and optional since/until.
watchOptional decimal u64 since_nseq (default zero); grant live delivery then return {namespace,observed_nseq,events,live:true,caught_up:{event_type:"caught_up",namespace,nseq}}.
audit.watchSame cursor and grant sequence; audit rows in events, with no caught_up field.

Get/list/describe are queries; set/delete/watch/audit.watch are intents. Watch verbs install an observable broker subscription. Replay-only live:false is available exclusively in the library's unit tests.

Capabilities and redaction

Policies resolve PeerIdentity into exact capability strings: props.read:<svc>.<ns>, props.write:<svc>.<ns>, props.describe:<svc>.<ns>:public, props.describe:<svc>.<ns>:full, props.audit:<svc>.<ns> and props.read:<svc>.<ns>:secrets. Set/delete require write, get/list/watch require read, and audit.watch requires audit independently of read. Describe requires the selected view's capability; schema_public=deny refuses public describe.

Get/list replace fields declared secret:true with null unless the caller also has the explicit secrets capability. Stored values are projected verbatim without filling missing defaults, preserving audit reproducibility. Public describe omits secret fields and redacts secret validator names. Passive event topics never carry record values or secret field names. Audit keys remain in the owner's private storage and never enter the Bus.

Watch and topics

The reserved topics are <svc>.props.records.changed and <svc>.props.audit. Only the owner publishes; direct topic.subscribe is refused, including for the owner. After its capability check, the service calls noded.props.subscribe_grant with non-empty topic, target_peer and namespace headers. The broker authenticates the topic owner, requires a connected registered target, and installs a top-level namespace body filter. Success returns {subscription_id,namespace} with rc 0; refusal uses rc 10 (including target_peer_not_connected). The registered verb's owner is noded, class intent, version 0.1.0.

The grant completes before the observed cursor is captured. Replay contains since_nseq < nseq <= observed_nseq; later events are eligible for live delivery. The caught_up.nseq is always the observed cursor, including when the caller supplied a higher cursor. Process replay, then the marker, and discard overlapping live events through that cursor; deduplicate by (namespace,key,nseq).

Records-changed bodies contain namespace,key,kind,verb,nseq,version, audit_epoch,actor,ts and optional fields_changed, secret_fields_changed_count, cause, plus lifecycle,reason? on completed events. Kinds are created, updated, deleted, completed, reconciled. Audit bodies contain namespace,key,verb,version,nseq,audit_epoch,actor,at, audit_digest and optional fields_changed; they omit kind, lifecycle, cause and secret count. Timestamps use RFC 3339 UTC with milliseconds. Records-changed bodies omit the digest. Both topics use retain:false.

Replay and live publication share the same projection functions. Each dispatcher starts at the current cursor, preserving older history for replay. Publication is best-effort: errors are logged and the cursor still advances; recover through watch. A missing granter returns unavailable; grant failure returns grant_failed without leaking upstream routing detail.

Errors

Failure bodies contain {error_code,message}; the Bus error field carries the human message. Success is rc 0. The closed vocabulary is:

rcerror_code
10auth_denied, not_found, validation_error, conflict, version_mismatch, replay_window_exceeded, grant_failed
20storage_error, hook_error, unavailable

Version mismatches add expected_version,current_version; replay-window errors add since_nseq,oldest_nseq. Validation-hook rejection maps to validation_error; an operational hook failure maps to hook_error.

Storage

SqliteStore owns exactly five table names and their frozen DDL: __props_meta (namespace nseq and audit epoch), __props_records (versions, lifecycle and tombstones), __props_events (durable replay/audit history), __props_audit_keys (per-namespace private keys) and __props_values (the optional default JSON mapping). The schema-compatibility test pins all five definitions from sqlite_master.sql. Existing databases open without migration. Domain mappings own their business tables and must participate in the substrate's single BEGIN IMMEDIATE transaction.

Hard delete removes metadata and restarts a recreated record at version 1; it cannot require version fencing. Soft delete retains the tombstone version and recreates against that version. Tombstones do not yet expire. Saga writes commit provisioning before completion to active or failed. Reconciliation alone increases audit epoch. Each event digest is lowercase hex HMAC-SHA256 over canonical sorted-key JSON plus its nseq as eight big-endian bytes, using a private 32-byte namespace key; deletes hash null. There is no previous-digest chain. The in-memory backend is for tests and in-process services and implements hard deletion only.

Logging

The opt-in logging/props bridge registers singleton log, canonical key current. Its fields are level (none,error,warn,info,debug, default info), filter (EnvFilter string, default empty), format (human,json, default human), applied_at and source (strings, default empty), and applied_by (cli,env,props,default, default default). All remain mutable. Hooks reject invalid enums/filter syntax and all deletes using prefixes log_unknown_enum_variant:, log_filter_invalid: and log_undeletable:. The watcher applies level/filter, using off for none; it neither rebuilds sinks nor writes metadata back. Its private Notify is separate from the dispatcher signal. Registration/read failures map to LogError::Props.

This page as Markdown · This page describes the current main branch of MixOS.