The Merkle chain was hashing the envelope and leaving the letter unsigned.

Six fields went into the leaf hash — subjectId, seqNum, entryType, actorId, actorRole, occurredAt. That proves an entry existed at position N, by actor Y, at time T. It does not prove what the entry said. A DBA could change caseStatus = REJECTED to APPROVED and the chain wouldn’t notice. The agent signature covered the same six fields — the bilateral signing infrastructure proved the agent signed something, not what it signed.

The DESIGN.md rationale called this intentional: “domain labels do not participate in tamper detection.” I’d written that myself, and it was wrong. It treated an implementation shortcut as a security principle. RFC 9162, Git, blockchain — every serious append-only log hashes content. We were the outlier.

The pipeline problem underneath

The fix wasn’t just expanding the canonical form. The JPA save path computed the digest before em.persist(), which triggered @PrePersist, which ran the enricher pipeline. So even if we’d added supplementJson to the hash months ago, provenance supplements set by ProvenanceCaptureEnricher would have been excluded — the digest was already locked in.

The in-memory path had it right by accident. It ran enrichers explicitly before computing the digest. Two implementations of the same repository, producing hashes with different content coverage, and no test caught it because each path was verified in isolation.

Signing is not enrichment

AgentSignatureEnricher sat in the enricher pipeline at @Priority(20), running before ProvenanceCaptureEnricher at 30 — except that ProvenanceCaptureEnricher never actually had @Priority(30). The annotation was in the javadoc but never on the class. Both it and TraceIdEnricher sorted to Integer.MAX_VALUE.

But the deeper problem wasn’t ordering. The signature is not content enrichment — it’s a seal. It has a hard dependency on the digest being final. Treating it as “enricher with a priority” obscured the architectural distinction between adding data and closing the envelope.

We extracted it into AgentEntrySigner — a direct call in the save pipeline, not a CDI-discovered enricher. The pipeline now reads linearly: prepareKey → enrich → hash → sign → persist. The prepareKey phase exists because ActorIdentityValidationEnricher needs the agent’s public key to check DID bindings, but the actual signature has to come after hashing. Two-phase signing: key material first, cryptographic seal last.

What the hash covers now

subjectId|seqNum|entryType|actorId|actorRole|occurredAt
  |tenancyId|actorType|causedByEntryId|supplementJson|domainContent

canonicalBytes() moved from LedgerMerkleTree (a static utility) to LedgerEntry (a public final instance method). The structural encoding is sealed — subclasses can’t change it. They contribute domain-specific bytes through domainContentBytes(), a protected override point. A build-time guard in LedgerProcessor fails deployment if an @Entity subclass has persistent fields but doesn’t override it.

KeyRotationEntry and ActorIdentityBindingEntry already have their overrides. Engine and qhorus need theirs — mechanical, one method each.

The chain now proves what entries contain, not just that they exist.


<
Previous Post
Running the Harness
>
Next Post
The Qualifier Nobody Inherits