Status: built (V1). Every security-relevant action is written to an append-only, hash-chained
audit_log table by a single serialized writer, and all twenty-three AuditAction variants have a
production emit site — authentication, authorization denials, the identity lifecycle, agent
descriptors, mesh trust changes, the admin data-lifecycle operations, A2A push-notification
configs, and the plugin framework’s boot record. Security-state changes are recorded
synchronously and fail-closed; hot-path data operations are best-effort buffered. Deferred:
true per-tenant gap-free seq under multiple tenants (V1 is single-tenant with a global sequence;
DEF-0031), the database-level append-only GRANT (INSERT-only is enforced by the writer, not yet
by the role; DEF-0032), external chain-head anchoring (DEF-0033), and transactional atomicity
between a domain write and its audit row.audit_log table is the system of record. Each event is also mirrored as a tracing event at
target = "audit", so the same data flows to your log aggregation pipeline — but the row, not the log
line, is authoritative.
What each record carries
Every row is oneAuditEvent:
The action taxonomy
TheAuditAction enum is a bounded set — bounded so it can safely be a metric label. Grouped by
the area that emits it:
The identity, data-lifecycle, and descriptor actions are emitted by the
admin control plane and the identity registry; the data-plane
actions come from the memory-service facade; the push-config actions come from the
A2A gateway’s config CRUD;
PluginSelected is emitted once at the end
of boot resolution as a durable record of the active extension-point → plugin set; and
TrustChange comes from the mesh crate through a dependency-cycle-free emit seam (below).
Tamper-evidence: the hash chain
Structured logs answer “what happened” but not “has this history been edited”. A hash chain makes the trail tamper-evident:hash_n = BLAKE3(canonical(event_n) ‖ hash_{n-1}), so removing, reordering, or
editing any row breaks every subsequent hash. The canonical encoding length-prefixes each field and
sorts JSON keys, so the hash is stable regardless of map ordering.
verify_chain(tenant, from_seq, to_seq) recomputes each row’s hash from its fields plus the prior
hash and returns a ChainVerification { ok, checked, first_broken_seq }. A break is a finding, not
an error — the caller gets ok = false and the exact seq where the chain first failed:
- An edited field → that row’s recomputed hash no longer matches.
- A deleted row → the next row’s
prev_hashand theseqcontiguity both fail. - An inserted or reordered row → contiguity and the following
prev_hashfail.
INSERT only. Retention is the single sanctioned deletion, and it is a
head-truncation, never an interior edit: the data-lifecycle GC worker
removes the contiguous oldest rows and records an AuditCheckpoint — the surviving genesis row’s
unchanged prev_hash, sealed so that verify_chain over the remaining range seeds from the checkpoint
and stays valid. Surviving rows are never rewritten.
Synchronous vs. buffered: the fail-closed contract
Whether an action is recorded synchronously is fixed by the action, not by config — an operator cannot accidentally make a security-state event best-effort.- Security-state actions (
AuthFailure,AuthzDenied, all five identity events,TrustChange,Erase,NodeDelete,Reembed,DescriptorSet,DescriptorImport,PushConfigSet,PushConfigDelete,PluginSelected) are recorded synchronously. The contract has two shapes:- State mutations (register / revoke / rotate / suspend / reinstate / trust change / node delete / reembed / erase / descriptor set / descriptor import / push-config set / push-config delete / the boot plugin record): a failed audit write fails the operation — it is not reported complete unless its row is durable. Stored push configs are future egress destinations and a descriptor import binds an external document to an identity, which is why both families fail closed.
- Denials and failures (
AuthFailure,AuthzDenied): the operation already failed, so a failed audit write does not change the returned error — the caller stays denied — but the write is still awaited and surfaced (atracingwarning plushyphae.audit.write.errors.total); it is never silently dropped.
- Hot-path data actions (
Store,Recall,Query,InboxDelivery,BeaconPlace,Consolidate, plusAuthSuccess) are best-effort buffered: enqueued to an in-process channel and batch-written. On overflow they are dropped and counted, never blocking the request.
Denials are always audited
The memory-service facade routes every authorization denial through a singledeny_audit path: a Forbidden emits an AuthzDenied / Denied row before the error
propagates, and any other failure is recorded with outcome = Error. No denial leaves the facade
unaudited.
This composes with the anti-oracle ownership pattern. An operation against a resource the caller
does not own — including a cross-tenant target — resolves to an absent-equivalent idempotent no-op
rather than a distinguishable “forbidden” error, so neither the response nor the audit row becomes a
cross-tenant existence oracle. An explicit authorization denial, by contrast, is recorded as
AuthzDenied because the caller was entitled to reach the check and was refused.
The cross-crate emit seam
The mesh crate (hyphae-mesh) cannot depend on the server’s AuditSink without a dependency cycle, so
TrustChange is emitted through a narrow AuditEmitter trait defined in hyphae-core (a no-op
NoopAuditSink is the default for tests and unwired paths). The one PgAuditSink implements both
AuditSink and AuditEmitter, so the same serialized writer and the same chain serve every layer —
the mesh audits through the seam, and no crate takes a dependency on hyphae-server.
Metrics
The audit log contributes exactly one per-event metric, plus three unlabelled health counters:
Per the observability cardinality rule,
agent_id, target, and
tenant_id are never metric labels — they live only in the audit row and on the tracing event
attributes. An audit health probe reports Down (and flips /readyz to 503) if the writer task dies
or its backlog exceeds the alarm threshold, so a dead audit writer is a readiness failure.
Configuration
Two configuration fields govern the log:What is deferred
- Multi-tenant gap-free
seq. V1 mints all principals into a single default tenant and uses a global monotonicseq. True per-tenant chains under multiple tenants need a composite(tenant_id, seq)key and a per-tenant counter; the per-tenantlast_hashand verification seeding are already structured for it. - Database-level append-only enforcement. The writer is
INSERT-only by construction, but theGRANT/trigger that would denyUPDATE/DELETEat the database role level is convention-only in V1. - External chain-head anchoring. Publishing the periodic chain head to an external append-only store — so even a full-database rewrite is detectable — is deferred; the sealed checkpoint is the hook.
- Transactional atomicity. A state-mutation domain write and its audit row are separate storage
calls, not one transaction, so a writer-down window can leave a durable mutation un-audited (surfaced
on
hyphae.audit.write.errors.total, never silent). Transactional write-plus-audit is a V2 hardening. - Crash loss of buffered events. Best-effort hot-path events can be lost on an ungraceful exit by design; security-state events stay synchronous and durable before their operation returns.