Three small issues, one branch. The kind of batch where each change is individually boring — an SPI method, a config flag, a record component — but the design conversations around them turn out to be more interesting than the code.

The Query That Wasn’t Slower

Issue #124 asked for per-capability database queries so ComputedTrustScoreSource wouldn’t load all attestations for an actor when only one capability was being queried. Sounds reasonable until you look at the cache: ComputedTrustScoreSource already loads everything once per actor and serves all subsequent calls from a ConcurrentHashMap. The per-capability query would bypass the cache or require partial-cache invalidation — both worse than what exists.

What the issue actually described was a different problem: the two-step findEventsByActorId → extract IDs → findAttestationsForEntries pattern that forced every caller to know how attestations are fetched via entry IDs. A leaky abstraction, not a performance problem. We added findAttestationsByActorId as a single SPI method — JPQL subquery under the hood, because LedgerAttestation has no @ManyToOne mapping to LedgerEntry — and updated the two callers. The number of SQL queries stays at two. The improvement is that callers no longer bridge them manually.

Materialization Is Not the Opposite of Computation

Issue #125 proposed excluding TrustScoreJob and IncrementalTrustUpdateObserver via build-time CDI exclusion when ComputedTrustScoreSource is active. I started designing that and got challenged: why are you trying to exclude it? Are they mutually exclusive at runtime?

They’re not. ComputedTrustScoreSource reads from raw attestation history. TrustScoreJob writes to ActorTrustScoreRepository. TrustExportService reads from ActorTrustScoreRepository — not through TrustScoreSource. A deployment could legitimately want computed reads for low-latency trust queries while keeping the batch job running for federation exports.

The right answer was a runtime config flag — casehub.ledger.trust-score.materialization.enabled — that lets deployments opt out of the write path without removing the beans from the CDI graph. Two guard clauses, one new config interface. The build-time CDI exclusion would have created a false coupling between source selection and persistence lifecycle.

Tenancy Through the Async Gap

Issue #129 was the straightforward one. ActorIdentityBindingObserver receives identity validation events via @ObservesAsync and persists binding entries — but the async handler has no CDI request scope, so CurrentPrincipal.tenancyId() is unreachable. The interim fix defaulted to DEFAULT_TENANT_ID in the repository.

The proper fix threads tenancyId through the event records themselves: AgentIdentityValidatedEvent and AgentIdentityViolationEvent in casehub-platform-api each gain tenancyId as their second component, per the platform convention. The enricher reads it from the entry being persisted. The observer reads it from the event. The repository fallback goes away — a null tenancyId at save time is now a bug, not a default.

The cross-repo search confirmed zero consumers outside ledger, so the record component addition is a contained compile break.


<
Previous Post
The card that knows its frameworks
>
Next Post
The Row That Wouldn't Lock