Appearance
Secret Store — real-time NSK-rewrap proxy for Node secret reads
This document is the authoritative bounded-context reference for the Secret Store — the real-time NSK-rewrap proxy that delivers Project-scoped secret material to plexd Nodes over GET /v1/nodes/{id}/secrets/{name} without ever exposing plaintext on the wire. It is an operational hot path: every workload-level secret read in plexd's Node API traverses it. The context owns the Secret aggregate and its value objects, the in-process AES-256-GCM rewrap pipeline, the names-and-versions-only inventory event, and the per-Node / per-Domain rate-limit policy that guards the read hotpath. The domain root that pins the ubiquitous language is ../../internal/secrets/doc.go.
The Secret Store is the platform's only meet-and-rewrap point between the OpenBao secret backend and the per-Node Node Secret Key (NSK). Secret plaintext lives in OpenBao; the NSK plaintext is reconstructed from the per-Domain wrap key and the persisted node_secret_key.wrapped_nsk row at request time; the rewrap runs in-process; and the response body carries only NSK-wrapped ciphertext. The store never builds, writes, rotates, or persists a secret value — that is the write-side OpenBao Credential Broker, a separate context (see What the Secret Store is not).
Ubiquitous language
The terms below travel verbatim across the domain root, the persistence layer, the application services, the single domain event, and the transport surface. Internal code never paraphrases them; documentation, JSON fields, and database columns adopt the exact spelling.
| Term | Definition | Code anchor |
|---|---|---|
| Secret | The aggregate root: the Project-scoped metadata for one named secret — its id, owning Domain and Project, logical name, current version, and the OpenBao path its plaintext lives at. The aggregate carries metadata only; the secret VALUE is never a field on it. Invariants are established only through New (a fresh secret) or Hydrate (a persisted one), which both reject a zero Domain / Project, a version below 1, and a malformed name. | ../../internal/secrets/secret.go (Secret) |
| SecretName | The Project-scoped logical name a Secret is addressed by on the read path. A value object enforcing the grammar ^[a-z][a-z0-9_-]{0,62}$ at construction time, so a malformed name fails fast rather than as a bad path deep in the handler. | ../../internal/secrets/types.go (SecretName) |
| SecretVersion | The monotonically increasing OpenBao KV v2 version a read targets. A defined int with a >= 1 invariant; the zero value means "latest" on the read command. | ../../internal/secrets/types.go (SecretVersion) |
| OpenBaoPath | The KV v2 logical path a secret's plaintext lives at in the backend. A value object with a non-empty invariant. | ../../internal/secrets/types.go (OpenBaoPath) |
| NSK / Node Secret Key | The per-Node AES-256 symmetric key issued at registration time and held by plexd. The Secret Store recovers its plaintext at read time to rewrap the secret under it, so only the addressed Node can open the response body. | ../../internal/secrets/services/nsk_resolver.go |
| NSKKid | The wrap-key id the NSK rewrap used, echoed back in the X-Plexsphere-Secret-KID response header so plexd can pick the right NSK to unwrap with during a key-rotation overlap window. Value object with the kid grammar ^[A-Za-z0-9._:-]+$. | ../../internal/secrets/types.go (NSKKid) |
| WrappedCiphertext | The in-memory NSK-wrapped ciphertext the rewrap pipeline produces — the only material the read endpoint ever returns. Destroy() zeroes its backing slice on a best-effort basis (calling it is optional — the bytes are ciphertext, not plaintext, and the transport copies them out before the value is dropped), and the type is non-comparable by construction so it can never be used as a map key that outlives the wipe. | ../../internal/secrets/types.go (WrappedCiphertext) |
| DomainID / ProjectID / NodeID / SecretID / EventID | Context-local [16]byte UUIDv7 identifier value objects — opaque byte arrays rather than wrappers around an internal/identity type, because the depguard cross-context rule denies the secrets context that import. The composition root adapts between the identity ids and these via the underlying [16]byte. | ../../internal/secrets/types.go |
| NodeSecretsUpdated | The single domain event (discriminator secrets.NodeSecretsUpdated, wire literal node_secrets_updated). It records a per-Node inventory delta as a list of {name, version} refs — never a value — so a Node learns its inventory changed without the Signed Event Bus ever carrying secret bytes. | ../../internal/secrets/events/events.go (NodeSecretsUpdated) |
The three structural invariants
Three invariants bound this context and are the reason the ubiquitous language is anchored in internal/secrets rather than spread across the transport and persistence layers. Everything below is a consequence of one of them.
- Plaintext never leaves the in-process rewrap pipeline. Secret plaintext is read from OpenBao, rewrapped under the NSK on the stack, and wiped on every exit path. It is never persisted in Postgres, never logged, never carried by the Signed Event Bus, and never returned over the API in cleartext. The response body is opaque NSK-wrapped ciphertext.
- Every read is audited. The acting subject, the secret name, the ReBAC relation, and the outcome land in the hash-chained Platform Audit Log — on both a grant and a denial — as one row.
- Per-Domain and per-Node token-bucket rate limits guard the hotpath. A compromised or runaway plexd agent is throttled at the Node scope before it can consume its Domain's shared budget.
The Secret aggregate and its invariants
The Secret aggregate is metadata-only. It models that a named, Project-scoped secret exists at a given current version and OpenBao path — never what the secret is. The value lives exclusively in OpenBao. The aggregate boundary is the single Secret; its invariants are established only through the two constructors:
Newmints a fresh aggregate with a new UUIDv7SecretID.Hydraterebuilds a persisted aggregate from its stored columns.
Both reject a zero DomainID or ProjectID, a SecretVersion below 1, and a SecretName that does not match the grammar. Because the aggregate carries no value field, there is no construction path through which a secret VALUE could be attached to it — the plaintext-never-persists invariant is enforced by the shape of the domain model, not only by the persistence layer (see Plaintext never persists).
The rewrap pipeline
The fetch application service runs a fixed, ordered pipeline — the order is the contract. It is implemented in ../../internal/secrets/services/fetch.go (FetchService.Fetch).
text
caller: plexd Node, authenticated by its NSK plaintext (Bearer)
|
v
(1) audit guarantee -- a nil audit sink fails the read CLOSED
(2) boundary validate -- node, project, name must be present
(3) rate limit (node) -- per-Node token bucket admits or refuses
(4) metadata lookup -- GetByProjectName resolves the Secret
(3b) rate limit (domain) -- per-Domain bucket, keyed on the Secret's Domain
(5) subject resolve + ReBAC HARD GATE on secret:<id>#read
| (deny -> insufficient_relation audit row + error)
v
(6) granted audit -- emitted ONCE, right after the grant
(7) backend read -- OpenBao KV v2: plaintext + served version
(8) NSK recover -- NSKResolver returns NSK plaintext + kid
(9) AES-256-GCM rewrap under the NSK with a FRESH 12-byte nonce
|
v
response body: <12-byte nonce> || <ciphertext + 16-byte GCM tag>
X-Plexsphere-Secret-Version: <served version>
X-Plexsphere-Secret-KID: <nsk kid>
Cache-Control: no-storeTwo ordering decisions are load-bearing:
- The granted audit row is emitted once, immediately after the authorization grant (step 6), before the backend read. So a backend, NSK, or rewrap failure that happens after a grant STILL leaves a granted row: the read genuinely WAS authorized, and the operational outcome of the attempt rides the fetch-duration metric, never the audit chain.
- The per-Node rate-limit check runs before the per-Domain check, so one noisy Node is throttled before it can consume the Domain's shared aggregate budget.
Byte-level rewrap
The plaintext read from OpenBao is encrypted under the recovered NSK with AEAD_AES_256_GCM and a fresh, crypto/rand 12-byte nonce. The emitted envelope is the concatenation the NSK custody seam wraps under elsewhere, so a Node already holding its NSK can open it:
text
plaintext (from OpenBao) NSK (recovered in-process, 32 bytes)
| |
+----------------+ +-----------------+
v v
AEAD_AES_256_GCM.Seal
|
v
+-----------------------------------------------+
| 12-byte nonce | ciphertext | 16-byte GCM tag | <- response body
+-----------------------------------------------+
recovery on the Node: AES-256-GCM-Open(NSK, nonce, ct||tag) -> plaintextA fresh nonce per call guarantees nonce uniqueness under one NSK across repeated reads of the same secret. A recovered NSK of any length other than 32 bytes is refused rather than silently truncated or padded.
Plaintext never persists
This is the context's defining safety property. Three independent mechanisms enforce it, so the failure of any one does not breach the invariant:
- Stack-only handling with wipe-on-every-path. The OpenBao plaintext and the recovered NSK are held only on the stack and zeroed via
deferon every exit path — success, backend error, NSK error, or rewrap error. The returnedWrappedCiphertextis destroyed by the handler once the response is written. - No-plaintext-logging enforced at lint time. The depguard
no-plaintext-logging-in-secretsrule deniesfetch.goalog/slogimport outright; every log line on the read path must go through the injected logger held on the service, never an ad-hoc one next to the plaintext. - No plaintext column in the schema. The persistence layer stores metadata and the per-Node visibility projection only. A reflection gate over the generated schema asserts there is no value / plaintext / ciphertext column anywhere in the secret tables.
The end-to-end guarantee is pinned by an integration test that writes a 32-byte sentinel through the real pipeline (real Postgres + OpenBao via testcontainers), scans node_secret_key, secret_metadata, node_secret_visibility, audit_log_entry, and outbox_events byte-for-byte for the sentinel and finds zero occurrences, then confirms the response body unwraps back to the sentinel under the Node's NSK: ../../tests/integration/secrets_rewrap_pipeline_test.go (TestSecretsRewrapPipeline_PlaintextNeverPersists).
Authentication and authorization
A secret read crosses two distinct gates before any backend material is touched.
- NSK authentication (transport). The caller presents its NSK plaintext in the
Authorization: Bearerheader. A missing, malformed, or revoked credential is refused with401(code: unauthorized) before the handler is invoked — only a Node proving possession of its NSK reaches the rewrap pipeline. The transport-side authn wiring lives in../../internal/transport/http/v1/secrets/wiring.go. - ReBAC hard gate (service). The fetch service resolves the subject for the calling Node and runs a ReBAC
readcheck on the objectsecret:<id>. The resolved subject is the canonical node subjectnode:<node-uuid>— the Node identity the NSK middleware already authenticated — not a Project-scoped service-identity principal; translating the Node onto a Project-scoped service identity is a deferred seam (tracked for the Credential Broker / service-identity stories). The gate lives in the service, not the transport, on purpose: a read must be authorized and audited as one atomic decision, and only the service holds both the authorizer and the audit sink. An authenticated caller without the relation receives403 PermissionDeniedwith the denial recorded asinsufficient_relation.
The 404 for a non-existent secret is surfaced only after authentication and carries no audit row — nothing was authorized, so there is nothing to record — and the 403 is reachable only by an authenticated caller, so the endpoint cannot be abused as an unauthenticated secret-name oracle.
Audit contract
Every authorization decision emits exactly one audit entry through the required audit sink. The sink is not nil-tolerated: a fetch service constructed without one fails the read closed with an audit-sink-required error rather than serving an unauditable read. The emission point is in ../../internal/secrets/services/fetch.go; the entry shape is secrets.AuditEntry in ../../internal/secrets/ports.go.
| Field | Granted read | Denied read | Notes |
|---|---|---|---|
Subject | node:<node-uuid> | same | The canonical node subject the read runs as, resolved from the calling Node. A Project-scoped service-identity translation is a deferred seam (see Authentication and authorization). |
Relation | secrets.read | secrets.read | The audit relation the read required (the operation name, distinct from the ReBAC relation read the gate checks). |
Object | secret:<secret-uuid> | secret:<secret-uuid> | The single source of truth for the gated object and the audit object, so the two always agree. The served version is reported on the response header and the fetch metric, not stamped into the audit object. |
Reason | granted | insufficient_relation | System-supplied rationale. |
Outcome | granted | denied | One row captures both verdicts. |
CorrelationID | request correlation id | same | Threads the read through the audit trail and pairs with the 403 body. |
Timestamp | decision time (UTC) | same | Supplied by the injected clock. |
A grant emits its row before the backend read, so a later backend / NSK / rewrap failure does not erase the fact that the read was authorized. A 404 (no such secret) and a 429 (rate-limited) emit no audit row — neither reaches an authorization decision. This contract is verified end-to-end by ../../tests/integration/secrets_audit_coverage_test.go.
Rate-limit policy matrix
The read hotpath is guarded by two hand-rolled, clock-injected token-bucket limiters: one keyed per Node, one keyed per Domain. A read is admitted only when both buckets have a token; the per-Node bucket is checked first. Each bucket starts full (so the configured burst of back-to-back reads is admitted before the sustained rate applies) and refills by elapsed × per-second up to the burst ceiling on every take. The limiter is in ../../internal/secrets/services/ratelimit.go.
| Scope | Sustained rate | Burst ceiling | Domain policy column | Sentinel on breach | HTTP |
|---|---|---|---|---|---|
| Per-Node | 100 reads/s | 200 | secret_read_per_node_per_second / secret_read_per_node_burst | per-node rate limit exceeded | 429 per_node_rate_limited + Retry-After |
| Per-Domain | 10000 reads/s | 20000 | secret_read_per_domain_per_second / secret_read_per_domain_burst | per-domain rate limit exceeded | 429 per_domain_rate_limited + Retry-After |
The four policy columns ship NOT NULL with the defaults above on domains, added by ../../internal/platform/db/migrations/0047_domain_secret_policy.sql, so every existing Domain row carries the persisted budget at migration time.
Scope of enforcement (important). The limiter today enforces these budgets process-wide, not per-Domain-row: every Node bucket and every Domain bucket is sized by the same four numbers (the package defaults, optionally overridden once at the composition root via the PLEXSPHERE_SECRETS_RATE_* environment variables). Those defaults are pinned to mirror the migration-0047 column defaults exactly, so on a deployment that has not tuned any Domain's columns the enforced budget is identical to the persisted policy. Reading a per-Domain column value that an operator tuned away from the default into that Domain's bucket is a deferred seam — it would require the limiter to hold a GetDomainByID-backed read port, a cross-context read of the tenancy-owned domains table that is intentionally not wired in this story. The DECISION block in ../../internal/secrets/services/ratelimit.go records the same deferral. The per-Node bucket throttles a single compromised or runaway agent; the much larger per-Domain bucket caps the aggregate read rate one Domain can drive so it cannot exhaust the control plane on behalf of the others. Both behaviours, including the Retry-After header and the metric delta, are exercised by ../../tests/integration/secrets_rate_limit_test.go.
The node_secrets_updated inventory event
When a Node's secret inventory changes, the inventory service computes the per-Node visibility delta and appends one node_secrets_updated outbox row per affected Node, inside the same transaction as the visibility-projection write. The payload carries names and versions only — never a value:
json
{
"event_id": "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0",
"occurred_at": "2026-06-08T12:00:00Z",
"node_id": "0190a8b8-a0c0-7a0a-8a0a-b0b0b0b0b0b0",
"secrets": [
{ "name": "db-password", "version": 7 },
{ "name": "api-token", "version": 2 }
]
}The names-and-versions-only invariant is what lets the inventory delta travel on the Signed Event Bus without ever exposing secret bytes: a Node learns that its inventory moved and to which versions, then re-fetches each value through the authenticated, audited, rate-limited read path. The wire shape is owned by ../../internal/secrets/events/events.go; the events subpackage is, by depguard rule, never permitted to import the secret backend. The closed single-member event set is pinned by an AST gate, and the publisher routes the outbox event_type onto the node_secrets_updated SSE wire literal. The full fan-out — one row per Node, no value bytes reaching a stub consumer — is verified by ../../tests/integration/secrets_outbox_event_test.go.
OpenAPI surface
The context exposes one read operation, FetchNodeSecret, at GET /v1/nodes/{id}/secrets/{name}. The wire contract originates in ../../api/openapi/plexsphere-v1.yaml and is mapped field-by-field — NSK authn seam, ReBAC gate, response headers, and the closed Problem.code taxonomy — in the HTTP API reference at ../reference/api/secrets.md. A summary:
- Request. Path
id(Node UUID) andname(the secret-name grammar); optionalversionquery param; requiredAuthorization: Bearer <NSK>header. The operation is flaggedx-plexsphere-no-payload-loggingso the no-payload-logging Spectral rule and downstream log scrubbers treat the body as opaque. Version selection is latest-only in the running binary: the production backend shim reads the latest KV v2 version via OpenBao'sKVGetand has no versioned read, so a?version=Nselector is accepted but degrades to "serve latest" (the read still succeeds and the served version is echoed on the response header). A real versioned read is a deferred seam — the version-aware path lands when the platform OpenBao client growsGetVersion. See the DECISION block onsecretsKVBackendin../../cmd/plexsphere/secrets_factory_prod.go. 200. Body isapplication/octet-stream, the raw<nonce> || <ct + tag>envelope, capped at 1 MiB. Headers:X-Plexsphere-Secret-Version(the version OpenBao actually served, which wins over the metadata current version),X-Plexsphere-Secret-KID(the NSK kid used), andCache-Control: no-store(no intermediary or client cache ever retains the ciphertext).- Error responses (
application/problem+json):401 unauthorized(NSK missing / malformed / revoked),403 permission_denied(PermissionDeniedschema, ReBAC denial withreason: insufficient_relation),404 secret_not_found/secret_version_not_found,429 per_node_rate_limited/per_domain_rate_limited(withRetry-After),503 openbao_unavailable(sealed / unreachable backend, after a granted audit row). - Deferred-wiring posture. When the OpenBao mount opt-in (
PLEXSPHERE_SECRETS_OPENBAO_MOUNT) is unset the production composition root leaves the surface disabled and every request returns501withcode: secrets_not_provisionedso log scrapers can alert on the deferred-wiring state.
Persistence
Two tables back the context, added by ../../internal/platform/db/migrations/0046_node_secrets.sql:
secret_metadata— one row per Project-scoped secret: UUIDv7 PK, Domain and Project foreign keys (cascade), the name (CHECK-constrained to the grammar), the current version (CHECK>= 1), the OpenBao path, and the soft-delete / timestamp columns, withUNIQUE(project_id, name).node_secret_visibility— the per-Node projection of which secret is visible at which version, keyed on(node_id, secret_id), the source the inventory diff reads.
Neither table carries a secret value. The migration's Down step raises 0A000 (forward-only) so a destructive rollback of a security-critical table cannot be applied accidentally. The aggregate-shaped repository binds the generated queries to the context's Repo port and appends the inventory outbox event inside RunInTx; the persistence adapter is the single seam touching the database, in ../../internal/secrets/repo/.
Observability and metrics
The fetch service emits two collectors (registered with the default Prometheus registerer at the composition root, nil-safe and AlreadyRegistered-tolerant) from ../../internal/secrets/services/metrics.go:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
plexsphere_secret_fetch_rate_limited_total | counter | scope (node / domain) | Secret reads refused by the token-bucket limiter, by throttling scope. |
plexsphere_secret_fetch_duration_seconds | histogram | outcome (granted / denied / error) | Duration from request to rewrapped ciphertext, by outcome. |
Operational outcomes (a backend-unavailable read, a denied read) are carried on these metrics, never on the audit chain — the audit row records only the authorization decision.
Composition root and operations
The production wiring is assembled in ../../cmd/plexsphere/secrets_factory_prod.go. It is opt-in on PLEXSPHERE_SECRETS_OPENBAO_MOUNT (not PLEXSPHERE_DSN): with the OpenBao mount unset the surface stays on the 501 secrets_not_provisioned path described above, so a dev or CI cluster that sets PLEXSPHERE_DSN but has provisioned no OpenBao secrets mount still boots with the surface inert. When the mount is set, PLEXSPHERE_DSN, the OpenBao address, and an auth strategy become required (an empty value is a hard config-load error, not a deferred first-fetch surprise); the factory then refuses to build unless the authorization check is wired and refuses an unsafe multi-replica configuration, and the read closure refuses without an audit sink — fail-closed at every seam.
Operators configure the context through PLEXSPHERE_SECRETS_* environment variables: the OpenBao mount (the opt-in gate), the OpenBao connection and auth strategy, the four rate-limit knobs, and the NSK wrap key reused from the heartbeat path. The full knob set read by productionSecretsConfigFromEnv:
| Variable | Required | Default | Meaning |
|---|---|---|---|
PLEXSPHERE_SECRETS_OPENBAO_MOUNT | opt-in gate | (unset — surface disabled) | KV v2 mount path. Unset keeps the surface on the 501 secrets_not_provisioned stub; set makes the connection and auth knobs below required. |
PLEXSPHERE_SECRETS_OPENBAO_ADDRESS | yes, when mount is set | (none — hard error when empty) | Base URL of the OpenBao server the rewrap pipeline reads from. |
PLEXSPHERE_SECRETS_OPENBAO_NAMESPACE | no | (empty) | OpenBao namespace header sent on every request, for namespaced enterprise deployments. |
PLEXSPHERE_SECRETS_OPENBAO_TLS_SKIP_VERIFY | no | false | true (case-insensitive) disables TLS certificate verification — dev rigs only. |
PLEXSPHERE_SECRETS_OPENBAO_APPROLE_ROLE_ID | one auth strategy required | (empty) | AppRole role id. Together with the secret id below this selects AppRole auth. |
PLEXSPHERE_SECRETS_OPENBAO_APPROLE_SECRET_ID | with the role id | (empty) | AppRole secret id. |
PLEXSPHERE_SECRETS_OPENBAO_K8S_ROLE | one auth strategy required | (empty) | Kubernetes-auth role. Setting it selects Kubernetes auth; AppRole takes precedence when both are set. |
PLEXSPHERE_SECRETS_OPENBAO_K8S_JWT_PATH | no | /var/run/secrets/kubernetes.io/serviceaccount/token | Path to the projected service-account token used for Kubernetes auth. |
PLEXSPHERE_SECRETS_RATE_NODE_PER_SECOND | no | 100 | Per-Node sustained read rate (reads/s). |
PLEXSPHERE_SECRETS_RATE_NODE_BURST | no | 200 | Per-Node burst ceiling. |
PLEXSPHERE_SECRETS_RATE_DOMAIN_PER_SECOND | no | 10000 | Per-Domain sustained read rate (reads/s). |
PLEXSPHERE_SECRETS_RATE_DOMAIN_BURST | no | 20000 | Per-Domain burst ceiling. |
When the mount is set and neither AppRole nor Kubernetes auth resolves, config load fails hard. The four rate knobs accept positive numbers only and mirror the migration-0047 column defaults described in the rate-limit matrix above. A boot-time reconcile probe (KVGet against a probe path on the configured mount) classifies a 200 or canonical-404 as green and a sealed / 5xx backend as red, gating /readyz so the surface is only advertised once the backend is reachable: ../../internal/platform/bootstrap/secrets_reconcile.go. The probe behaviour is verified by ../../tests/integration/secrets_reconcile_probe_test.go, and the OpenBao KV v2 latest-only read (and the ?version=N degrades-to-latest contract) plus the failure classification by ../../tests/integration/secrets_openbao_kv_v2_test.go.
Threat model
The context defends a specific set of attacker shapes:
- A compromised plexd Node reading secrets outside its grant. Mitigated by the ReBAC hard gate on
secret:<id>#read, run inside the service so the authorization and the audit are one decision. Every denial is recorded asinsufficient_relation. - An attacker without a valid NSK. Mitigated by NSK authentication at the transport boundary — a missing / malformed / revoked credential is refused with
401before the handler runs. - Secret plaintext disclosure via logs, persistence, the event bus, or a response. Mitigated by stack-only handling with wipe-on-every-path, the lint-enforced no-plaintext-logging rule, the value-free schema, and the names-and-versions-only event — pinned by the byte-for-byte sentinel scan.
- A runaway or compromised agent driving a denial-of-service against the read hotpath. Mitigated by the per-Node and per-Domain token buckets, with the per-Node check first so one Node cannot exhaust its Domain's budget.
- Secret-name enumeration. Mitigated by surfacing
403only after authentication and404without an audit row, so the endpoint is not an oracle. - Nonce reuse / ciphertext use-after-free. Mitigated by a fresh 12-byte
crypto/randnonce per rewrap and a non-comparableWrappedCiphertextwhose backing bytes can be zeroed (best-effort) after the response is written. The load-bearing wipe — the OpenBao plaintext and the recovered NSK, zeroed on every pipeline exit path — is enforced in the fetch service, not on the emitted ciphertext.
The NSK authentication path — no-bearer and tampered-bearer both refused with 401, handler never invoked — is verified by ../../tests/integration/secrets_nsk_authn_test.go.
What the Secret Store is not
This context is the read side only. Four neighbours connect to it and are deliberately out of scope; conflating them with the Secret Store is the most common modelling mistake.
- Not the write side. It never issues, writes, rotates, or deletes a secret value. That is the OpenBao Credential Broker, which owns the Project-scoped secret material and writes it onto the KV v2 paths this context reads. See
./provisioning/credentials.md. - Not the operator CLI. The
plexctl secretsubtree (put / list / rotate) is the operator-facing tooling, deferred to its own story and not part of this context's surface. - Not hook integrity gating. The Hook Catalog & Integrity Gating context is a consumer of this read endpoint when a hook needs an external credential; it does not own secret material. See the policy-context
hooksandintegritypages for the discovery and integrity surfaces it does own. - Not observability ingestion. The metrics this context emits are carried downstream by the Observability Ingestion context in a later story; the Secret Store only exposes the collectors.
Cross-references
../../internal/secrets/doc.go— the domain root that pins the ubiquitous language and the deferred seams.../reference/api/secrets.md— the HTTP API reference for theFetchNodeSecretoperation.../architecture/overview.md— the system map, including the NSK custody established at Node registration that this context recovers at read time../audit/index.md— the Platform Audit Log every read decision writes to../access.md— the Access Orchestrator, the structural cousin whose service-side authorize-and-audit ordering this context mirrors../provisioning/credentials.md— the write-side Credential Broker that issues the secrets this context serves.