Cosmix Specification Suite v0.4.2
‹ 12b12d ›cosmix.dev ↗
12cSubstrate
Chapter 12c

Cosmix Property Substrate — Authorization, Transport, Errors, Audit

version 0.2.2status draftdated 2026-06-05layer Substrate

Split out of SPEC 12 §7–10 (2026-06-05). The cross-cutting machinery the verbs and namespaces run under: capability-based authorization (§7), the transport binding for changed/audit events (§8), the error taxonomy (§9), and the HMAC-chained audit trail (§10). Capability gating + audit HMAC are normative and code-backed; the signed peer-identity story depends on SPEC 01 mesh-identity amendments and key rotation is deferred. Section numbers are preserved as §7–§10 so cross-references resolve here.

7. Authorization

The substrate intentionally does no authentication of its own. It maps peer identity (presented by the ABP transport) to a set of capability tokens via the namespace's AuthPolicy, and verbs check capabilities before dispatch.

7.1 Capabilities

A capability is a string of the shape props.<action>:<svc>.<namespace>[:<scope>]. The leading props. token namespaces all property-substrate capabilities so they do not collide with future SPEC-level capability families (mesh, storage, repair). The action slot is one fixed token (read, write, describe, audit); the <svc>.<namespace> slot is the fully qualified namespace name; the optional scope slot is a free string. Wildcards are permitted only in the <svc>.<namespace> slot, and only as the literal * (no globbing). Examples:

  • props.read:maild.accounts — list and get accounts (non-secret fields only).
  • props.read:maild.accounts:secrets — also read fields marked secret.
  • props.write:maild.accounts — set and delete any account.
  • props.write:maild.accounts:self — set fields on records keyed by the caller's own identity (used for user-self-service).
  • props.describe:maild.accounts:public — fetch the redacted schema (§5.6 view: public).
  • props.describe:maild.accounts:full — fetch the full schema including secret-field declarations and all validators.
  • props.audit:maild.accounts — subscribe to the namespace's audit stream (§10).
  • props.audit:* — subscribe to every namespace's audit stream on this node (the * form is a power capability for mesh-wide security monitors).

The capability vocabulary is open — a namespace MAY define namespace-specific scope strings — but action tokens are closed in v0.1. Every namespace SHOULD define at least: props.read:<svc>.<ns>, props.read:<svc>.<ns>:secrets (if any field is secret), props.write:<svc>.<ns>, props.describe:<svc>.<ns>:public, props.describe:<svc>.<ns>:full, and props.audit:<svc>.<ns>.

7.2 AuthPolicy

#[derive(Clone)]
pub struct AuthPolicy {
    /// Resolve a peer identity to a capability set.
    /// Called once per ABP request, before verb dispatch.
    resolve: Arc<dyn Fn(&PeerIdentity) -> CapabilitySet
                    + Send + Sync + 'static>,
}

impl AuthPolicy {
    pub fn new<F>(resolve: F) -> Self
    where F: Fn(&PeerIdentity) -> CapabilitySet + Send + Sync + 'static
    { Self { resolve: Arc::new(resolve) } }

    pub fn resolve(&self, peer: &PeerIdentity) -> CapabilitySet {
        (self.resolve)(peer)
    }
}

PeerIdentity carries everything the transport knows about the caller (§7.3). The function is a pure mapping from identity to capabilities; a namespace whose policy is "any unix-peer in the cosmix-admin group is admin" implements it as a one-liner via AuthPolicy::new(|peer| { … }).

resolve is stored as Arc<dyn Fn> rather than a bare fn pointer so policies can close over per-namespace state (capability sets, in-memory trust/grants caches built from substrate watches, etc.) without resorting to globals. AuthPolicy is Clone but not Copy; NamespaceSpec clones share the underlying closure via the Arc. The field is private — construction is AuthPolicy::new(closure) and evaluation is policy.resolve(&peer). resolve remains synchronous: it is called once per ABP request before verb dispatch, on the request-handling path. Policies that need to consult substrate state MUST do so against an in-memory cache populated by a separate watch subscriber, not by issuing ABP calls from inside resolve.

Migrating from v0.1's pub resolve: fn(&PeerIdentity) -> CapabilitySet field is mechanical: every AuthPolicy { resolve: f } literal becomes AuthPolicy::new(f), and every (spec.auth.resolve)(peer) call site becomes spec.auth.resolve(peer). The v0.2 amendment PR performs that migration in-tree.

7.3 Peer identity

PeerIdentity is populated by the ABP layer from the underlying transport. SPEC 01 v0.6 does not yet normatively define peer-identity extraction; the substrate REQUIRES the following extensions to SPEC 01, formalised in a SPEC 01 amendment landing alongside or before SPEC 12 v0.1.x:

FieldTransport requirement
unix_uid / unix_gidUnix-socket transports MUST read SO_PEERCRED at accept and attach (uid, gid) to every message from that connection. Connection MUST be refused if SO_PEERCRED is unavailable.
unix_groupsResolved once at accept via getgrouplist(3) on the uid; cached for the connection lifetime.
wg_peerTCP/TLS transports over a WireGuard interface MUST attach the WG peer's tunnel IP (per kernel wireguard.ko association). Connections from outside the WG /24 are refused before reaching the substrate.
signed_identCross-mesh transports MUST verify the SPEC 10 daemon-identity signature on a per-connection handshake envelope. The verified principal name is attached as signed_ident. Connections with absent or invalid signatures present an empty signed_ident; namespaces SHOULD NOT grant capability on the absence of signed_ident.
service_nameWhen the peer is a registered service, the broker attaches its registered name. The broker is the source of truth here; clients cannot self-assert a service name.

Until those SPEC 01 amendments land, a substrate implementation MUST refuse to enable any verb other than <svc>.props.describe with view: public on transports where the required identity bits are not provided. The substrate provides no fallback default — a namespace whose resolve cannot find any matching credential in the PeerIdentity MUST return an empty capability set, yielding auth_denied on the verb. Silent grants are the canonical source of substrate bugs in this category.

7.4 Watch and audit-watch authorisation

Per §5.5 and §5.8, the owning service — not the broker — is the enforcement point for props.read:<svc>.<ns> on watch and props.audit:<svc>.<ns> on audit-watch. This avoids requiring the broker to express dynamic per-request capability rules (props.write:<svc>.<ns>:self, role-derived predicates) that change per peer.

The broker's contribution to property-substrate authorisation is the reserved-prefix rule: topic names matching either <svc>.props.audit or <svc>.props.records.changed are reserved (§15.5), and the broker refuses topic.publish from any peer other than the owning service and refuses topic.subscribe from any peer at all. Audit event flow goes through <svc>.props.audit.watch; structured-record event flow goes through <svc>.props.watch. In both cases the owning service performs capability checks (props.audit:<svc>.<ns> or props.read:<svc>.<ns>), replays from since_nseq, then routes events to authorised subscribers directly via ABP. Both topics have per-namespace authorisation, which the broker cannot express because the topics are per-service; routing through the owning service is the substrate's mechanism for that gap. (The implementation MAY use topic pub/sub as transport beneath the verb, but the subscriber-facing contract is the substrate's.)

<svc>.props.changed is not reserved at the broker: SPEC 07 already documents it as a directly-subscribable topic, with sensitive-field redaction in the event body (§5.5). Capability- gated watch (with since_nseq replay) goes through <svc>.props.watch; SPEC 07's unauthenticated topic subscribe remains available for low-policy consumers as before.

This split keeps the broker oblivious to capability vocabulary — it enforces one static prefix reservation only — and concentrates all per-namespace policy in one place: the owning service.

8. Transport

8.1 ABP framing

All property verbs are ABP messages (SPEC 01). The command: header takes one of the values in §5. The body is JSON unless otherwise stated. JSON is chosen over a binary form because schemas are user-facing and field names in the wire format make ABP-level debugging (cat | mix) practical without an out-of-band schema.

8.2 Addressing

Local: send to the owning service's registered name (to: maild). The broker routes to the service's port.

Cross-node (within the cosmix mesh): use the SPEC 01 §4.1 local address form. The two-label <service>.<node> form auto-implies .amp (e.g. to: maild.alpha); the full DNS form <service>.<node>.amp is equivalent (e.g. to: maild.alpha.amp). The three-label <sub>.<service>.<node>[.amp] form addresses a sub-protocol within the service (e.g. to: imap.maild.alpha.amp). The broker handles the cross-node hop per SPEC 01 §10. The substrate adds no new addressing.

Migration note. The legacy <service>@<node> short-form (e.g. maild@alpha) is removed in this revision: the @ is now reserved exclusively for the cross-mesh form below. Existing uses migrate verbatim to <service>.<node> (the .amp suffix is implicit). Likewise the pre-revision <service>.cosmix.<node>.amp shape is replaced — the cosmix middle label is gone; mesh identity is now an address-level suffix (§4.2), not a per-address label.

Cross-mesh (between cosmix meshes): use the SPEC 01 §4.2 cross-mesh form <local-address>@<mesh-fqdn> (e.g. to: maild.beta.amp@example.org). The parser MUST accept this form; the router MUST refuse it with rc=10 and the message cross-mesh routing not implemented until federation transport lands. See _decisions/2026-05-15-federated-amp-addressing.md for the prior-art survey and the design path toward lifting the refusal.

8.3 Wire shapes

The substrate defines stable JSON shapes for record payloads and schemas:

// A record envelope (response to <svc>.props.get in structured mode)
{
  "namespace": "accounts",
  "key": "user@alpha.amp",
  "version": 3,
  "nseq": 1042,
  "fields": {
    "email": "user@alpha.amp",
    "spam_enabled": true,
    "password_hash": null   // secret, redacted
  }
}

// A <svc>.props.watch reply envelope (structured mode). Under
// current single-response ABP transport this single message IS
// the §5.5 "watch reply stream": `events` carries replay rows in
// the <svc>.props.records.changed body shape, then the top-level
// `caught_up` field carries the §5.5 v0.2.1 marker. Live record
// events arrive afterwards on the granted
// <svc>.props.records.changed subscription, not in this envelope.
{
  "namespace": "accounts",
  "observed_nseq": 1042,
  "events": [ /* records.changed body shapes; see §5.5 */ ],
  "caught_up": {
    "event_type": "caught_up",
    "namespace": "accounts",
    "nseq": 1042
  },
  "live": true
}

// A schema (response to <svc>.props.describe in structured mode)
{
  "namespace": "accounts",
  "schema_version": "0.1.0",
  "cardinality": "collection",
  "primary_key_field": "email",
  "fields": [
    {
      "name": "email",
      "type": "string",
      "secret": false,
      "default": null,
      "validators": ["regex:^.+@.+$"],
      "help": "The account's email address.",
      "since": "0.1.0"
    },
    ...
  ]
}

Flat-path describe responses continue to use SPEC 07 §2.4's envelope shape; structured describe responses use the PropertySchema shape above. Implementations MUST tolerate unknown top-level keys for forward compatibility. New top-level keys MAY appear in subsequent versions.

9. Errors

The substrate defines a small, closed error taxonomy. Verbs return errors as ABP replies per SPEC 01 §5.3: the rc: header carries the numeric severity (10 for operational errors, 20 for failures that may have degraded the port), the error: header carries a human-readable description, and the response body carries a JSON object with { "error_code": "<token>", "message": "...", ... }. error_code is the machine-readable taxonomy token; it is specified in the body rather than as a new top-level ABP header to avoid extending the SPEC 01 header set with a property-substrate-specific token.

error_codercCause
auth_denied10Caller lacked the capability for the verb.
not_found10Namespace, path, key, or schema-version does not exist.
validation_error10A field failed type, range, regex, or before_set hook validation. The body's fields array lists each failing field. Also returned when a request mixes path: and namespace: headers.
conflict10A uniqueness constraint was violated (e.g., creating a record with an already-used primary key).
version_mismatch10if_version did not match current. The body's current_version field gives the actual current value.
storage_error20Backend I/O failure (disk full, lock contention, SQL error).
hook_error20A before_* hook returned a non-validation error.
unavailable20The owning service is not registered or is shutting down.
replay_window_exceeded10A since_nseq on <svc>.props.watch / <svc>.props.audit.watch falls outside the namespace's retained replay window (§5.5, §6.6). Caller MUST re-list to recover.

Implementations MAY add diagnostic fields to the body; callers MUST ignore unknown fields. The taxonomy is closed in v0.1 — new error_code values appear only via the spec's versioning process (§12).

The taxonomy is deliberately small. Mapping rich backend errors onto substrate errors is the owning service's job; surfacing every backend's idiosyncratic error class as a top-level substrate error would explode the surface area without buying callers anything.

10. Audit

Every successful write emits an entry to the namespace's audit topic <svc>.props.audit (a SPEC 07 §3.5.2 activity-event topic). Entries carry:

Body fieldDescription
namespaceNamespace name.
keyRecord key.
verbOne of <svc>.props.set, <svc>.props.delete, <svc>.props.complete (saga transition, §6.5), <svc>.props.reconcile (hand-edit reconciliation, §11).
versionNew version.
nseqNamespace sequence number (§5.5).
audit_epochPer-namespace generation counter (§11). Incremented only on <svc>.props.reconcile; attached to every entry so chain-verification can detect discontinuities. HMAC continuity does not cross an epoch boundary.
actorPeer identity, in SPEC 07 §3.5.1 actor-variant form (<svc> / <runtime>:<uuid>[:<seq>] / operator:<principal>), plus the special values daemon:reconciliation (synthetic reconcile events) and daemon:<svc> (saga complete events).
atISO 8601 timestamp.
fields_changedArray of changed field names. For set and complete, the changed fields per the operation (typically ["_lifecycle"] for complete). For reconcile, the union of field names that differ between the sidecar's last-known state and the post-edit on-disk state. Absent on delete. Secret field names are redacted per §5.5.
audit_digestHMAC-SHA256 over canonical serialisation (below).

The audit body carries a keyed HMAC of the record's canonical serialisation, never the raw bytes or a plain hash:

audit_digest = HMAC-SHA256(
    key   = namespace_audit_key,
    data  = canonical_serialise(record) || nseq_bytes
)

canonical_serialise is the namespace's storage-backend canonical form (defined per backend; e.g. SQLite emits column tuples in declared order, Toml and MixData emit fields in schema order with normalised whitespace). namespace_audit_key is a 32-byte secret generated at namespace registration and persisted alongside the namespace's storage; it is never exposed via any property verb. v0.1 does NOT rotate this key automatically — automatic rotation would require versioning every emitted digest with a key_id and retaining superseded keys for the audit window to verify historical digests, both of which are deferred to v0.2. Operators wishing to rotate the key today re-create the namespace (losing historical digest verification, which is acceptable since historical entries remain authentic for the original key's audit period).

Three things follow from the HMAC choice:

  • Low-entropy secret fields (a boolean, a short PIN) cannot be recovered from audit_digest by brute-forcing the input, because the per-namespace key is unknown to audit subscribers.
  • Audit consumers can detect tampering (replay of an old digest, alteration of a record between writes that didn't update the version) by re-deriving the HMAC against an authoritative <svc>.props.get. This requires possessing the audit key — which is intended: tamper-detection is a privileged operation, not a passive-subscriber one.
  • The audit stream contains no value bytes; secret-field redaction is automatic.

Audit subscription, like <svc>.props.watch, is a substrate verb routed through the owning service, not a direct topic.subscribe. See §5.8 for the <svc>.props.audit.watch shape. The per-service-per-topic naming (<svc>.props.audit not a global audit.property) makes the capability story per service and prevents a single subscribe from leaking the existence-and-cadence of changes across every service on a node — itself a meaningful side-channel.