Skip to content

OpenBao Credential Broker

This is the authoritative bounded-context reference for the OpenBao Credential Broker that ships under internal/provisioning/credentials/. The broker is the sub-context of plexsphere provisioning that owns the lifecycle of project-scoped secret material: issuance, rotation, revocation, and TTL-driven expiry. The Postgres broker row is the durable inventory; the secret bytes themselves live in OpenBao KV-v2 at a path the broker derives deterministically from (project_id, credential_id). Every aggregate state change appends a typed domain event to the shared platform outbox inside the same Postgres transaction as the broker-row mutation, preserving the at-least-once delivery contract used elsewhere.

The broker has no HTTP surface of its own. Callers reach it through the in-process Custodian facade; the external resolution path for a Kubernetes workload is the External Secrets Operator + Crossplane composition plumbed by the e2e suite. The closed Custodian port is the contract that lets every emitter remain unaware of which adapter is wired (internal/provisioning/credentials/ports.go).

Pages

This bounded-context reference is intentionally a single page. The broker's surface area is narrower than identity or audit, and the ports / events / sentinels travel in lockstep — splitting them across files would force a reader to chase the same DECISION block through three pages. Cross-cutting code anchors are linked inline from each section.

Cross-references

Ubiquitous language

The terms below travel together across the Go code, the SQL migration, the outbox event payloads, the structured-log attributes, and operator-facing tooling. Names are preserved verbatim in error messages and outbox payloads so a reader chasing a string from a log line finds it in the source without translation.

TermDefinitionCode anchor
CredentialThe aggregate root. One row in plexsphere.credential carrying (credential_id, project_id, domain_id, kv_mount, kv_path, version, kv_version, expires_at, revoked_at, expired_at, created_at, updated_at). The shape is frozen — the broker changes no field without a numbered migration.internal/provisioning/credentials/ports.go, 0019_credential_broker.sql
CredentialIDThe 16-byte UUIDv7 primary key of the broker row. The String() projection is the canonical 8-4-4-4-12 hyphenated form so log lines from the broker join cleanly with sibling contexts. The zero value is rejected by every aggregate invariant that requires a concrete reference.internal/provisioning/credentials/ports.go
MaterialThe opaque secret value the caller hands the broker on Issue and Rotate. Carries Payload []byte, a flat KeyValues map[string]string, and the broker's TTL time.Duration from which expires_at is derived. Caller-owned bytes — the broker MUST defensively copy both before any persistence call so subsequent caller mutation cannot reach the stored row.internal/provisioning/credentials/ports.go
CustodianThe in-process facade through which transport handlers and CLI callers interact with the broker. Methods: Issue, Rotate, Revoke, Lookup. Implementations orchestrate the Repository, Materialiser, audit, and outbox in a single Postgres transaction; callers branch on the package's sentinel errors via errors.Is.internal/provisioning/credentials/ports.go
MaterialiserThe KV-v2 adapter port. Put writes data at /<mount>/data/<path> with a CAS expectation and returns the new KV-v2 version. Delete soft-deletes the secret. DerivePath returns the deterministic (mount, path) pair so the broker, the audit log, and the Sweeper all agree on where a credential lives. The default in-package adapter exposes DerivePath only; the OpenBao-backed adapter ships under the credentials_openbao build tag.internal/provisioning/credentials/materialiser/materialiser.go
RepositoryThe persistence port. Carries Create, FindByID, RotateCAS, Revoke, ListExpired, MarkExpired, AppendOutboxEvent, and RunInTx. The Postgres adapter is a thin wrapper over the sqlc-generated queries; constraint-name dispatch maps SQLSTATE 23505 collisions onto the canonical sentinels.internal/provisioning/credentials/ports.go, internal/provisioning/credentials/repo/credentials_pg.go
SweeperThe steady-state TTL expiry worker. Run(ctx) walks Repository.ListExpired in pages, applies MarkExpired + AppendOutboxEvent per row, and flips the /readyz readiness flag on its first clean return. Boot-time Run gates /readyz; subsequent ticks honour Config.SweepInterval.internal/provisioning/credentials/sweeper/sweeper.go
DomainResolverThe audit-package port the broker depends on to map every credential operation onto a Domain UUID. Per-Domain residency is non-negotiable: a credential whose (Project, Resource) cannot be resolved fails closed via ErrDomainUnresolved. Re-aliased through the credentials package so emitters branch on a credentials-package error and the depguard no-cross-context-imports rule stays satisfied.internal/provisioning/credentials/ports.go, internal/provisioning/credentials/errors.go
AuditSinkPortThe audit-emission port the Sweeper writes through (Record(ctx, entry) error). The shape mirrors internal/audit.Sink so the composition root wires a thin shim that translates AuditEntry into audit.Entry without pulling the audit package into this module.internal/provisioning/credentials/sweeper/sweeper.go
broker-row versionThe CAS counter on the plexsphere.credential row. Incremented by every application-side mutation (issue / rotate / revoke / mark-expired). Repository writers pass an expected_version precondition; a mismatch returns ErrBrokerCASConflict rather than silently overwriting.0019_credential_broker.sql, C0_credentials.sql
kv_versionThe OpenBao KV-v2 store's own monotonic per-path counter. Surfaces in bao kv metadata output. The broker mirrors the post-write value on the broker row so operators can correlate the broker row against KV-v2 metadata without round-tripping to OpenBao. Distinct from the broker-row version because the remediation differs (see Error sentinels).0019_credential_broker.sql
lifecycle stampsexpires_at is the wall-clock instant the credential ceases to be valid. revoked_at is non-NULL once the operator has issued a soft-delete. expired_at is non-NULL once the Sweeper has observed the credential's expiry. The Sweeper's eligibility predicate is revoked_at IS NULL AND expired_at IS NULL AND expires_at <= now() and is backed by a partial index. A SQL CHECK gates the revoked_at / expired_at exclusivity.0019_credential_broker.sql
credential_outbox_tokenThe sibling table holding one row per (credential_id, event_type) pair under a PRIMARY KEY. Structural at-most-once guarantee for every broker lifecycle event: a retried Revoke or a Sweeper-issued MarkExpired never appends a second CredentialRevoked / CredentialExpired row to the outbox.0019_credential_broker.sql

Aggregates

The Credential aggregate is the only aggregate this context owns. The aggregate root is the plexsphere.credential row; the plexsphere.credential_outbox_token row is a structural at-most-once token, not a domain object — it carries no value the domain reasons about beyond the (credential_id, event_type) identity.

Credential — invariants

The aggregate enforces six invariants that travel across the SQL schema, the Repository adapter, and the Custodian application service:

InvariantLayerFailure mode
(project_id, kv_path) is unique.SQL UNIQUE constraint.ErrPathAlreadyMaterialised — a chosen-credential-id collision against an existing Project's path.
credential_id is unique.SQL PRIMARY KEY.ErrCredentialAlreadyExists — a re-Issue with mismatched fields.
revoked_at IS NULL OR expired_at IS NULL.SQL CHECK.The CHECK fires on the corner case where both stamps are non-null; in normal operation the application transitions through one terminal stamp.
kv_path is the deterministic projection of (project_id, credential_id).Application service (Materialiser.DerivePath) + persistence (broker row INSERT).Drift between the two surfaces produces a chain-of-custody mismatch the Sweeper would detect on the next pass.
Issue / Rotate / Revoke / MarkExpired runs inside one Postgres transaction with the matching outbox append.Application service (Custodian + RunInTx).A partial commit would either leak a KV-v2 row without a broker row (ErrIssueAtomicityViolated) or commit a broker row without an outbox event (the at-least-once contract would degrade to no-shot).
Every Issue / Rotate / Revoke resolves a Domain through DomainResolver before opening the Postgres transaction.Application service.ErrDomainUnresolved — the broker fails closed because emitting an outbox row without a Domain anchor would violate per-Domain residency.

Credential — state machine

The Credential is a three-stage terminal aggregate. Once the row enters either terminal stamp (revoked_at or expired_at) no further rotation is possible — operators re-issue rather than resurrect.

text
  ┌──────────────────────────────────────────────────────────┐
  │  Issue                                                    │
  │   · DomainResolver gate (fail-closed)                    │
  │   · Materialiser.Put (KV-v2 CAS=0 — first write)         │
  │   · INSERT plexsphere.credential                         │
  │   · INSERT plexsphere.credential_outbox_token            │
  │   · INSERT plexsphere.outbox_events (CredentialIssued)   │
  └──────────────────┬───────────────────────────────────────┘
                     │  version = 1, kv_version = 1

  ┌──────────────────────────────────────────────────────────┐
  │  Active                                                   │
  │   · Rotate → version' = version + 1                      │
  │   · Lookup → SELECT row                                   │
  └────────┬───────────────────────────────────┬─────────────┘
           │ Revoke                            │ Sweeper.Run + expires_at ≤ now
           ▼                                   ▼
  ┌─────────────────────┐             ┌─────────────────────┐
  │ Revoked (terminal)  │             │ Expired (terminal)  │
  │   revoked_at != NULL│             │   expired_at != NULL│
  │   CredentialRevoked │             │   CredentialExpired │
  └─────────────────────┘             └─────────────────────┘

Ports

The credentials package declares four ports (internal/provisioning/credentials/ports.go) plus one helper alias declared in the Sweeper. Each port is the narrow seam through which the bounded context reaches its collaborators; the depguard no-cross-context-imports rule keeps the credentials module free of direct imports of internal/identity, internal/audit, or internal/platform/secretstore.

PortMethodsAdapterTest seam
CustodianIssue, Rotate, Revoke, Lookupconcrete *custodian in custodian.go; wired into the production binary by cmd/plexsphere/credentials_factory_prod.go (composition root). The factory's closure resolves three sibling ports — audit.Sink, credentials.DomainResolver, *secretstore.Client — at Run time, falling back to audit.NewSlogSink, a pgxpool-backed GetProjectByID lookup, and a zero-value *secretstore.Client when the caller passes nil.unit-level fakes in custodian_test.go cover Issue / Rotate / Revoke / Lookup including the compensating-delete and CAS-conflict branches; integration-level tests at tests/integration/credentials_broker_*_test.go drive the production Custodian against a real Postgres + OpenBao stack via the testcontainers fixtures.
MaterialiserPut, Delete, DerivePathdefault stub in materialiser/materialiser.go; real OpenBao adapter under //go:build credentials_openbao in materialiser/openbao/openbao.go. The OpenBao adapter wraps *secretstore.Client.KVPut and translates SDK CAS-mismatch wording onto credentials.ErrKVStoreCASConflict.DerivePath is pure logic — the unit tests exercise it without a fake; the build-tagged adapter has its own unit tests at materialiser/openbao/openbao_test.go; integration coverage runs against a real OpenBao container.
RepositoryCreate, FindByID, RotateCAS, Revoke, ListExpired, MarkExpired, AppendOutboxEvent, RunInTxPostgres in repo/credentials_pg.go, wrapping the sqlc-generated Queries from C0_credentials.sql. Constraint-name dispatch maps credential_pkey to ErrCredentialAlreadyExists and credential_kv_path_unique to ErrPathAlreadyMaterialised.the unit tests inject a fake sqlc layer that asserts each constraint-name dispatch path.
DomainResolverResolve(ctx, projectID) → (domainID, error)wired in cmd/plexsphere/credentials_factory_prod.go via NewCredentialsDomainResolverAdapter against a pgxpool-backed GetProjectByID lookup; tests inject a deterministic fake (fixedDomainResolver, fakeUnresolvedResolver).the Custodian unit tests pin the fail-closed branch when the resolver returns a synthetic outage error or a zero domainID.
AuditSinkRecord(ctx, AuditEntry) → errorwired via NewCredentialsAuditAdapter(audit.Sink) in the composition root. The closure-time fallback wraps audit.NewSlogSink until the per-Domain hash-chained Sink is exposed at main.go scope (deferred to a sibling story).the Custodian unit tests use an in-memory recording sink; the integration tests reuse the same sink shape for emission-ordering assertions.
SweeperRunconcrete *Sweeper in sweeper/sweeper.go; ProbeFunc is the /readyz mount.unit tests use a fixed Clock and an in-memory Repository; the boot-probe + ticker contracts are pinned by tests/integration/credentials_broker_sweeper_boot_probe_test.go and tests/integration/credentials_broker_sweeper_ticker_test.go.

The Clock and OutboxAppender ports are convenience seams used by the Sweeper and the outbox path respectively; they live in the same ports.go file so a reviewer auditing the bounded-context surface finds every external dependency in one place.

Outbox events

The broker emits four typed domain events to plexsphere.outbox_events inside the same Postgres transaction as the broker-row mutation. Event types are written to the event_type column verbatim — the string form is part of the wire contract once a row has been emitted.

Event type (column value)TriggerPayload struct
credentials.CredentialIssuedCustodian.Issue succeeds.events.CredentialIssued
credentials.CredentialRotatedCustodian.Rotate succeeds (CAS-protected).events.CredentialRotated
credentials.CredentialRevokedCustodian.Revoke succeeds. Carries the operator-supplied Reason.events.CredentialRevoked
credentials.CredentialExpiredSweeper.Run observes a row with expires_at <= now() AND revoked_at IS NULL AND expired_at IS NULL.events.CredentialExpired

Every payload carries a UUIDv7 EventID, a UTC OccurredAt timestamp, and the relevant credential identity. The event_type set is closed — adding a fifth value is a breaking schema change, not a switch-statement extension. The package-local drift gate TestEventTypesAreClosedSet enforces the four-event allow-list. Field-level shapes are pinned in the reference page.

At-most-once via credential_outbox_token

The sibling plexsphere.credential_outbox_token table holds one row per (credential_id, event_type) pair under a PRIMARY KEY. Each broker transition (issued / rotated / revoked / expired) MUST emit the corresponding outbox event at-most-once per credential. The PRIMARY KEY is the smallest invariant that closes the door at the storage layer: a retried Revoke or a Sweeper-issued MarkExpired's INSERT trips a unique violation, the application catches it and skips the outbox append while leaving the broker row in its terminal state. This is the structural guarantee that the at-least-once outbox contract degrades to exactly-once for every broker lifecycle event — see the file-header DECISION block on 0019_credential_broker.sql for the full rationale.

Error sentinels

Every operation funnels through one of the package-local sentinels. Callers branch on these via errors.Is — wrapping is fine, identity must remain intact. The set is closed: adding a new sentinel is a deliberate edit to internal/provisioning/credentials/errors.go and a corresponding update to this table.

SentinelLayerTriggerRemediation
ErrCredentialNotFoundRepository / CustodianFindByID / Lookup for an absent CredentialID.Transport would map to 404 Not Found if the broker grew an HTTP surface; today it does not.
ErrCredentialAlreadyExistsRepository (constraint-name dispatch on credential_pkey PRIMARY KEY)A second Issue for the same id with mismatched fields.Caller treats the row as established; the broker is idempotent on the credential id.
ErrPathAlreadyMaterialisedRepository (constraint-name dispatch on credential_kv_path_unique UNIQUE on (kv_mount, kv_path))A chosen-credential-id collision against an existing Project's deterministic path.The deterministic path derivation guarantees this is structurally impossible inside a Project — the sentinel exists for the chosen-collision attack scenario and surfaces it loudly, distinct from ErrCredentialAlreadyExists.
ErrCredentialRevokedCustodianRotate or Revoke on a row whose revoked_at is non-null.Re-issue rather than resurrect; the aggregate is terminal.
ErrBrokerCASConflictRepository.RotateCASBroker-row version has advanced past the caller's ExpectedVersion.Re-Lookup to observe the winning rotation, then retry with the new version.
ErrKVStoreCASConflictMaterialiser.PutKV-v2 store's own version has advanced past the broker's expected kv_version.Distinct from ErrBrokerCASConflict: the KV-v2 store is out of sync with the broker row. Caller escalates the reconciliation incident — this is not a normal contention loss.
ErrDomainUnresolvedCustodiancredentials.DomainResolver returns an error or a zero domainID during a broker operation.Fail closed: the broker refuses to emit an outbox row without a Domain anchor (per-Domain residency).
ErrMaterialiserUnavailableMaterialiser.Put / DeleteNon-CAS KV-v2 failure (network, transport timeout, OpenBao unsealed-but-blocked).Fail closed: writing the broker row without a KV-v2 record would leave the platform with a credential id resolving to nothing.
ErrIssueAtomicityViolatedCustodianCompensating Materialiser.Delete fires after a Postgres rollback AND the delete itself also fails — the KV-v2 row is orphaned.The broker logs and re-raises with the original tx error AND the compensating-delete error joined; operators reconcile the orphaned KV-v2 row.
ErrAuditUnavailableCustodian (counter only)AuditSink.Record fails after the broker has committed its Postgres transaction. The broker decision is durable but the audit chain has gapped.Operators alert on AuditSinkFailuresTotal() (the process-wide counter) and chase down the audit-side outage.
ErrInvalidPathInputRepository / Materialiserkv_path or kv_mount violates the path-format invariant before the SQL UNIQUE/NOT NULL gate runs.Programmer error; surfaces in tests, not in production.

The credentials-package ErrDomainUnresolved is a distinct sentinel rather than a re-export of the audit package's value. The DECISION block on the sentinel (errors.go) explains why: callers should branch on a credentials-package error so the failure surface stays inside the bounded context, and the depguard no-cross-context-imports rule denies pulling internal/audit into this module without an explicit ACL exception. The composition root translates the audit sentinel into this one with errors.Join so a curious caller can still inspect the original cause via errors.Is.

Sweeper cadence

The Sweeper runs on two cadences, both driven by the production composition root in cmd/plexsphere/credentials_factory_prod.go:

  1. Boot Run — synchronous, inside the shared reconcile-probe boot timeout horizon. The first successful pass flips the *Sweeper.ready atomic so ProbeFunc returns nil and /readyz turns green. Until then ProbeFunc returns the errProbePending sentinel and the orchestrator drains traffic. The probe is registered under the canonical name credentials-sweeper — dashboards and runbooks grep for that string verbatim.
  2. Steady-state tickertime.NewTicker(Config.SweepInterval). The default is 30 s (defaultCredentialsSweepInterval, PLEXSPHERE_CREDENTIALS_SWEEP_INTERVAL overrides it). Per-tick errors are logged and the next tick retries; the ticker honours ctx.Err() on every iteration. Each tick re-runs Sweeper.Run, which is idempotent and self-terminating.

The boot probe and the ticker share one *Sweeper instance. ProbeFunc does NOT re-trigger Run — the bootstrap reconcile probe contract is that the probe re-runs the closure the composition root threads in. The DECISION block on ProbeFunc (sweeper.go) explains why a self-driving probe would double-count credentials_sweeper_invocations_total on every /readyz scrape.

Metrics

The Sweeper exports two zero-value-tolerant counter vectors via metrics.go:

MetricTypeIncrements when
credentials_sweeper_invocations_totalcounterSweeper.Run is entered (per call).
credentials_sweeper_expirations_totalcounterA row is successfully MarkExpired and the matching CredentialExpired outbox row is appended.

A nil prometheus.Registerer (WithRegisterer(nil) or no option) keeps the counters in zero-value mode — Run still increments the in-memory pointers, but no scrape surface is registered. This is the deliberate posture for unit tests so registry collisions across parallel test runs cannot trip a global registry shared by production code paths.

KV-v2 path derivation

The broker, the audit projector, and the Sweeper all agree on where a credential lives by computing its path from the same deterministic helper. The helper lives in materialiser/materialiser.go and is the only path-shaping logic in the bounded context — every caller that needs a KV-v2 location reaches DerivePath rather than re-rolling the format.

text
  mount = Config.KVMount                              (verbatim, e.g. "kv")
  path  = "projects/<projectID>/credentials/<credentialID>"

<projectID> and <credentialID> are the canonical 8-4-4-4-12 hyphenated UUID textual form, matching credentials.CredentialID.String(). The OpenBao adapter applies the KV-v2 layout on top, so the data row for a credential lives at /<mount>/data/<path> and the metadata row at /<mount>/metadata/<path>. That extra layer is the OpenBao adapter's concern — DerivePath returns only the logical (mount, path) pair so the audit projector can compute it without importing the OpenBao client (and the depguard rules hold).

The format is part of the wire contract: once the broker has issued a credential, its KV-v2 location is stable for the lifetime of the credential. Changing the format is a migration, not a refactor. The DECISION block in materialiser/doc.go pins the path format and explains why DerivePath lives outside the build-tagged adapter.

Zero-UUID handling

Both projectID and credentialID must be non-zero. The DECISION block next to (*Materialiser).DerivePath (materialiser.go) documents why a zero argument returns the empty (mount, path) pair rather than panicking: the broker's transactional path would abort an in-flight Postgres transaction without firing the compensating Materialiser.Delete that ErrIssueAtomicityViolated expects. The empty-pair return is a value that fits the existing failure surface and the unit test TestDerivePathRejectsZeroUUID pins this behaviour.

Operational model

The broker is opt-in at the composition root. The PLEXSPHERE_CREDENTIALS_KV_MOUNT env var is the activation gate; when empty the binary keeps the broker stub and the Sweeper does not run. The DECISION block on productionCredentialsConfigFromEnv (credentials_factory_prod.go) explains why the broker activates on KV mount rather than DSN: the external dependency (OpenBao KV-v2) is more sensitive than Postgres, and a half-wired KV path would write secret material to a place the operator did not authorise.

Env varEffectDefault
PLEXSPHERE_CREDENTIALS_KV_MOUNTBroker activation gate. Empty = inert. Non-empty + empty PLEXSPHERE_DSN = build-time error."" (inert).
PLEXSPHERE_DSNThe Postgres connection string the broker shares with the rest of the platform. Required when PLEXSPHERE_CREDENTIALS_KV_MOUNT is set."".
PLEXSPHERE_CREDENTIALS_OPENBAO_ADDRESSThe OpenBao cluster URL the secretstore client dials. Held on the config so a future env-driven step can build the *secretstore.Client from (KVAddress, KVAuth)."".
PLEXSPHERE_CREDENTIALS_SWEEP_INTERVALSteady-state ticker period. Parsed with time.ParseDuration; non-positive is rejected at build time.30s.
PLEXSPHERE_CREDENTIALS_ALLOW_INSECURE_MATERIALISEROpts the binary into the in-package Materialiser stub when no *secretstore.Client is wired. For early-boot dev clusters only.false.

Build-time validation refuses construction on every nil dependency combined with a non-empty DSN — the four sentinels (ErrCredentialsKVMountRequired, ErrCredentialsSecretClientRequired, ErrCredentialsAuditSinkRequired, ErrCredentialsDomainResolverRequired) surface a misconfigured operator before /readyz lights up green. The trade-off mirrors BuildProductionHeartbeatFactory and BuildProductionAuditFactory.

Threat model

The broker shoulders the credential lifecycle for project-scoped secrets. Four attacker shapes drive its defences:

Attacker A — concurrent rotations / split-brain rotation

Two callers race a Rotate on the same credential. Without protection the loser would either overwrite the winner's KV-v2 write or persist a broker row whose kv_version no longer matches the live secret.

Defence. The broker carries two distinct CAS counters:

  • version on the broker row — RotateCAS runs UPDATE … WHERE id = $1 AND version = $expected so the loser observes zero rows updated and surfaces ErrBrokerCASConflict.
  • kv_version on the KV-v2 store — Materialiser.Put passes the expected version as the OpenBao CAS argument; a CAS mismatch surfaces ErrKVStoreCASConflict.

The two sentinels are deliberately distinct so the remediation differs: a broker CAS miss means another rotate already won and the caller should re-Lookup; a KV CAS miss means the KV-v2 store is out of sync with the broker row and the caller should escalate the reconciliation incident. The integration test credentials_broker_kv_cas_test.go asserts the two are distinguishable via errors.Is.

Attacker B — chosen-credential-id path collision

A caller crafts a credential UUID that, after deterministic path derivation, collides with an existing credential in another Project. Without the UNIQUE on (project_id, kv_path) the second Issue would either overwrite the first credential's broker row or write to the same KV-v2 path.

Defence. The deterministic path includes the Project UUID prefix, so any cross-Project collision is structurally impossible. The PRIMARY KEY on credential_id gates the within-Project case. A constraint-name dispatch in the Repository adapter maps the SQLSTATE 23505 collision to ErrPathAlreadyMaterialised so a second Issue surfaces a typed error rather than corrupting the inventory.

Attacker C — Domain residency drift

A caller emits a credential that the audit DomainResolver cannot map to a Domain. Without fail-closed enforcement the broker would either drop the audit row, attach it to a synthetic system Domain, or leak the credential into the wrong tenant's audit chain.

Defence. Custodian.Issue / Rotate / Revoke run DomainResolver before opening the Postgres transaction. A failure surfaces ErrDomainUnresolved and the broker writes nothing — no broker row, no KV-v2 write, no outbox event. The fail-closed integration test credentials_broker_fail_closed_test.go asserts the post-failure state: plexsphere.credential row count = 0, KV-v2 list empty, plexsphere.outbox_events row count for credential_* = 0.

Attacker D — orphaned KV-v2 row

The Postgres transaction that wraps Issue / Rotate fails to commit after the Materialiser.Put already wrote to KV-v2. Without a compensating delete the platform would carry a KV-v2 row that no broker row references — exactly the forensic gap that makes Custodian operations untrustworthy.

Defence. Custodian defers a compensating Materialiser.Delete that runs on rollback only. The compensating delete is best-effort: a transport failure during the compensation itself surfaces ErrIssueAtomicityViolated so an operator can reconcile the orphan from the structured log. The DECISION block on the Custodian explains the loud-but-non-fatal posture — the broker prefers to surface the orphan over papering it over with silent retry logic.

Out of scope

  • NSK lives under internal/identity/registration and is governed by a separate threat model. The broker does NOT issue Node Secret Keys.
  • Plaintext secret return. The broker stores caller-supplied Material via the KV-v2 adapter; it does not generate secret material itself. Generation lives in the caller (the credential consumer's own provisioning logic).
  • Rotation cadence. The broker enforces CAS but does not run scheduled rotations. The Sweeper is for TTL expiry, not for rotation.
  • HTTP surface. The broker has no /v1/credentials* endpoint. A workspace-level drift gate scans the generated OpenAPI document for any /v1/credentials* path and asserts the count is zero so a future change cannot accidentally widen the broker's blast radius onto the public HTTP surface.