Cosmix Property Substrate — Authorization, Transport, Errors, Audit
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 markedsecret.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.6view: 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:
| Field | Transport requirement |
|---|---|
unix_uid / unix_gid | Unix-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_groups | Resolved once at accept via getgrouplist(3) on the uid; cached for the connection lifetime. |
wg_peer | TCP/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_ident | Cross-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_name | When 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.ampsuffix is implicit). Likewise the pre-revision<service>.cosmix.<node>.ampshape is replaced — thecosmixmiddle 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_code | rc | Cause |
|---|---|---|
auth_denied | 10 | Caller lacked the capability for the verb. |
not_found | 10 | Namespace, path, key, or schema-version does not exist. |
validation_error | 10 | A 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. |
conflict | 10 | A uniqueness constraint was violated (e.g., creating a record with an already-used primary key). |
version_mismatch | 10 | if_version did not match current. The body's current_version field gives the actual current value. |
storage_error | 20 | Backend I/O failure (disk full, lock contention, SQL error). |
hook_error | 20 | A before_* hook returned a non-validation error. |
unavailable | 20 | The owning service is not registered or is shutting down. |
replay_window_exceeded | 10 | A 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 field | Description |
|---|---|
namespace | Namespace name. |
key | Record key. |
verb | One of <svc>.props.set, <svc>.props.delete, <svc>.props.complete (saga transition, §6.5), <svc>.props.reconcile (hand-edit reconciliation, §11). |
version | New version. |
nseq | Namespace sequence number (§5.5). |
audit_epoch | Per-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. |
actor | Peer 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). |
at | ISO 8601 timestamp. |
fields_changed | Array 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_digest | HMAC-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_digestby 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.