Skip to content

BootstrapToken bounded context

This document is the authoritative bounded-context reference for the BootstrapToken aggregate that ships under internal/identity/bootstraptokens. It covers the ubiquitous language, the Kind matrix, the TTL policy, the issued → consumed | expired | revoked state machine, the plaintext format, the shared Argon2id hashing parameters, the project-scoped nonce-set algorithm, the audit contract, the OpenAPI surface, the threat model, and the boundary between this story (issuance, validation, lifecycle persistence) and the consumer stories that build on it.

A BootstrapToken is a single-use, project-scoped bearer credential an operator hands out-of-band to a fresh Node or Bridge so it can authenticate its very first call to plexsphere. The plaintext is returned exactly once at issuance time; everything after that point — list, get, revoke, expire — works against the persisted Argon2id hash plus consumption metadata.

Cross-references

Ubiquitous language

The terms below travel together across the Go code, the audit log, the OpenAPI spec, the SQL migration, and operator-facing tooling. Names are preserved verbatim in error messages and structured log attributes.

TermDefinitionCode anchor
BootstrapTokenThe aggregate root of this context: a single row carrying (id, project_id, kind, token_hash, env_prefix, issued_at, expires_at, consumed_at, consumed_nonce, consumed_by_node_id, revoked_at, issued_by_user_id). Only the Argon2id hash is persisted; the plaintext leaves the process exactly once via IssueResult.Plaintext.internal/identity/bootstraptokens/token.go
KindThe node | bridge discriminator that decides which redemption endpoint accepts the token. Encoded as a literal segment in the plaintext so kind/endpoint mismatch can be rejected before any database lookup.Kind, ParseKind in internal/identity/bootstraptokens/token.go
TTLThe validated time.Duration between IssuedAt and ExpiresAt. Bound by [MinTTL=5m, MaxTTL=24h]; out-of-range inputs surface as ErrTTLOutOfRange.TTL, NewTTL, MinTTL, MaxTTL in internal/identity/bootstraptokens/token.go
PlaintextThe one-shot bearer string. A bootstraptokens.Plaintext value object owns its backing slice and exposes Destroy() so the Issuer can deterministically zero the secret bytes after the response is written.Plaintext, NewPlaintext, Destroy in internal/identity/bootstraptokens/token.go
EnvPrefixThe lowercase environment segment (^[a-z]+$) embedded in the plaintext. Same regex enforced by the aggregate, the parser, the OpenAPI schema, and the SQL CHECK so the surface and the storage layer agree on the alphabet.envPrefixPattern in internal/identity/bootstraptokens/token.go, env_prefix ~ '^[a-z]+$' in 0007_bootstrap_tokens.sql
IssuerThe application service that mints a fresh plaintext, hashes it via the shared Argon2id primitive, persists the aggregate, and returns the plaintext exactly once. Stateless and reusable across goroutines.internal/identity/bootstraptokens/issuer/issuer.go
ValidatorThe application service that consumes a presented plaintext, applies the documented precedence ordering, and atomically flips the row to consumed via the Repository port.internal/identity/bootstraptokens/validator/validator.go
consumed_nonceThe replay-protection nonce sealed into the redemption envelope by the consuming Node/Bridge. The partial UNIQUE on (project_id, consumed_nonce) backs ErrNonceCollision at the SQL boundary.bootstrap_token_consumed_nonce_uq in 0007_bootstrap_tokens.sql
audit grant / denyOne audit.Entry per Issue / Consume / Revoke / Expire decision. Reason ∈ {granted, insufficient_relation, caveat_violation}; outcome ∈ {granted, token_expired, token_consumed, revoked, kind_mismatch, project_mismatch, nonce_collision, insufficient_relation}.internal/identity/bootstraptokens/audit/middleware.go, audit/classify.go

Kind matrix

A BootstrapToken is bound to exactly one Kind at issuance time, and the Kind decides which redemption endpoint accepts the plaintext. The matrix below pins the binding the Validator enforces through the ExpectedKind parameter on ConsumeParams.

KindPlaintext segmentRedeems atSurfaces as on mismatch
nodepsb_<env>_<projectId>_node_<random>The Node bootstrap endpoint (Registration's POST /v1/register with kind=node).bootstraptokens.ErrKindMismatch — wrapped on the audit row as outcome=kind_mismatch.
bridgepsb_<env>_<projectId>_bridge_<random>The Bridge bootstrap endpoint (Registration's POST /v1/register with kind=bridge).bootstraptokens.ErrKindMismatch — wrapped on the audit row as outcome=kind_mismatch.

The Kind is encoded inside the plaintext so the Validator can reject a Bridge token presented at the Node endpoint before the candidate scan against ListLiveByProjectKind runs — failing fast spares the repository a futile scan when the caller obviously holds the wrong token (Validator.Consume in validator.go).

TTL policy

Every BootstrapToken carries a strict [MinTTL, MaxTTL] lifetime window:

BoundValueReason
MinTTL5 * time.MinuteOperators need a few minutes between issuing the token and the Node/Bridge actually presenting it. Anything shorter fails issuance with ErrTTLOutOfRange so the operator does not silently mint a token that has already expired by the time the OpenAPI response lands on the client.
MaxTTL24 * time.HourA forgotten BootstrapToken cannot become a long-lived bearer credential. The 24-hour ceiling matches the OpenAPI ttl_seconds maximum: 86400 so the API surface and the aggregate agree on the upper bound.

The bound is enforced three times: by bootstraptokens.NewTTL at the aggregate boundary, by the OpenAPI schema's minimum: 300, maximum: 86400 on ttl_seconds, and by the bootstrap_token_expires_after_issued CHECK constraint on the SQL table. Triple enforcement is deliberate — a future call path that bypasses one of the three layers cannot silently widen the window .

State machine

A BootstrapToken lives in exactly one state at any instant. The transitions below are pure functions on the aggregate; the persisted row materialises the state through the (consumed_at, revoked_at, expires_at) triple plus the SQL bootstrap_token_consumption_pair_valid CHECK that keeps the three consumption columns in lockstep.

mermaid
stateDiagram-v2
    [*] --> issued: Issuer.Issue
    issued --> consumed: Validator.Consume\n(stamps consumed_at, consumed_nonce, consumed_by_node_id)
    issued --> expired: ReconcileExpiry sweep\n(expires_at < now AND not yet consumed/revoked)
    issued --> revoked: Issuer.RevokeBootstrapToken\n(stamps revoked_at)
    consumed --> [*]
    expired --> [*]
    revoked --> [*]
    note right of issued
      Live row predicate (validator candidate scan):
      consumed_at IS NULL
      AND revoked_at IS NULL
      AND expires_at > now()
    end note

The three terminal states are mutually exclusive:

  • consumed — the Validator atomically flipped the row via UPDATE … RETURNING WHERE consumed_at IS NULL AND revoked_at IS NULL AND expires_at > now. Single-use enforcement is the WHERE clause on a single SQL statement, not a read-then-write pattern; 32 concurrent goroutines that race to consume the same plaintext see exactly one success and 31 ErrTokenConsumed (pinned by tests/integration/bootstrap_tokens_concurrency_test.go).
  • expired — the reconcile sweep registered through RegisterBootstrapTokensReconcileProbe (bootstraptokens_reconcile.go) selects rows with expires_at < now AND consumed_at IS NULL AND revoked_at IS NULL, stamps revoked_at, and emits one audit Entry per swept row.
  • revoked — an operator called RevokeBootstrapToken (issuer.go). The repository's Revoke is idempotent at the SQL layer: it gates on revoked_at IS NULL, so a second revoke is a no-op; the audit middleware nevertheless writes one Entry per call so re-revocation by the same operator is still visible in the stream.

Validator precedence

When the Validator inspects a matched candidate row, it surfaces exactly one sentinel from the precedence ordering below. Re-ordering any of these would flatten distinct audit signals — a redemption attempt against a revoked token must NOT report as "expired" just because the row also happens to be past expires_at (Validator.Consume DECISION block in validator.go):

  1. ErrTokenNotFound — no live candidate matched the presented plaintext.
  2. ErrTokenRevoked — operator-driven; highest priority because a human marked the token dead.
  3. ErrTokenConsumed — replay; the most common attacker shape.
  4. ErrTokenExpired — operational; the token simply timed out.
  5. ErrProjectMismatch — request-side scope drift on the parsed plaintext.
  6. ErrKindMismatch — request-side endpoint drift.
  7. ErrNonceCollision — request-side replay marker produced by the SQL UNIQUE index on (project_id, consumed_nonce).

Project- and kind-mismatch checks run before the candidate scan (both signals are derivable from the parsed plaintext alone), and again as defense-in-depth on the matched row in case of schema drift.

Plaintext format

Every plaintext bearer string a BootstrapToken hands back follows the canonical regex:

text
^psb_[a-z]+_[a-z2-7]+_(node|bridge)_[a-z2-7]{20,}$

The four segments map onto the four ParsePlaintext outputs (format.go):

SegmentSourceEncoding
psb_Literal prefix bootstraptokens.TokenPrefix. Distinct from the API-token prefix psk_ so secret-scanners can tell the two credential families apart at a glance.Literal ASCII.
<env>IssueParams.EnvPrefix after ParseEnvPrefix validation.Lowercase ASCII letters (^[a-z]+$).
<projectId-base32>IssueParams.ProjectID.UUID() 16-byte raw bytes.RFC 4648 base32 without padding, lower-cased. Decoded back to a 16-byte tenancy.ID by ParsePlaintext.
<kind>IssueParams.Kind after ParseKind validation.Literal node or bridge.
<random>16 bytes (randomSecretBytes in issuer.go) read from crypto/rand.Reader — 128 bits of entropy, well above the 96-bit floor required for bearer credentials.RFC 4648 base32 without padding, lower-cased; 26 characters.

FormatPlaintext and ParsePlaintext live in the same file (format.go) so any future drift in the canonical regex is one diff to review (FormatPlaintext DECISION block). Round-trip is asserted by format_test.go; golden vectors pin the regex byte-for-byte.

Hashing parameters

BootstrapToken plaintexts are persisted through the shared Argon2id primitive in internal/identity/tokens/hashing.go. There is exactly one source of truth for the hashing parameters across all plexsphere bearer credentials:

ParameterConstantValueReason
Memory costArgon2idMemory64 * 1024 KiB (64 MiB)RFC 9106 §4 first-class recommendation for server-side password hashing; tuned so the verify path is dominated by memory access rather than CPU.
Time costArgon2idIterations3RFC 9106 §4; balances throughput against attacker hardware budget.
ParallelismArgon2idParallelism4Matches the typical core count on plexsphere control-plane Pods; a verify call saturates four lanes.
Salt lengthArgon2idSaltLen16 bytesRFC 9106 §3.1 minimum for cryptographically secure use.
Key lengthArgon2idKeyLen32 bytes256-bit derived key — wide enough that brute-force search of the key space is infeasible.

The drift gate at internal/identity/tokens/hashing_golden_test.go pins each parameter byte-for-byte; tweaking a constant without updating the golden value fails CI immediately. The Issuer never calls argon2.IDKey directly — it goes through tokens.HashPlaintext so a parameter change moves uniformly across this context, the API token context, and any future bearer-credential context.

The Validator's verify path uses tokens.VerifyHash which decodes the persisted PHC string, derives the same parameters, and compares in constant time. The candidate scan in Validator.Consume calls VerifyHash once per live row — bounded by the per-(project, kind) live set, not by the full bootstrap_token table.

Nonce-set algorithm

Replay protection at consumption time is enforced by the partial UNIQUE index on (project_id, consumed_nonce):

sql
CREATE UNIQUE INDEX IF NOT EXISTS bootstrap_token_consumed_nonce_uq
    ON plexsphere.bootstrap_token (project_id, consumed_nonce)
    WHERE consumed_nonce IS NOT NULL;

The algorithm has three load-bearing properties:

  1. Project-scoped, not global. Two unrelated projects can legitimately mint the same random nonce. The replay invariant is "no token within a project may reuse a nonce", not "no nonce ever repeats across the platform". A global UNIQUE would surface a benign cross-tenant collision as ErrNonceCollision and leak cross-project information through the error path.
  2. Partial index excludes unconsumed rows. The WHERE consumed_nonce IS NOT NULL predicate keeps unconsumed rows out of the index entirely, so the index size tracks redeemed bootstrap tokens rather than every issued token.
  3. Surfaced as ErrNonceCollision. The PG 23505 SQLSTATE produced by a partial-index conflict is mapped by repo.classifyPgError to bootstraptokens.ErrNonceCollision, and the audit middleware records it with reason=caveat_violation, outcome=nonce_collision per the classification table in audit/classify.go.

The single-use guarantee is enforced by the same Consume statement — a single UPDATE … RETURNING whose WHERE clause gates on consumed_at IS NULL AND revoked_at IS NULL AND expires_at > now(). The nonce UNIQUE is a second invariant on the same row, layered on top so a request that races past the consumed-at gate is still rejected if it presents a duplicate nonce.

Audit contract

Every Issue / Consume / Revoke / Expire call emits exactly one audit.Entry via internal/identity/bootstraptokens/audit/middleware.go — regardless of outcome. Grants are as interesting as denials for forensic replay.

FieldValue
Subjectservice:bootstrap-tokens (fixed). Mirrors the signing convention service:signer so operators have one stable subject per service to filter on.
Relationissue for Issuer.Issue; consume for Validator.Consume; revoke for Issuer.RevokeBootstrapToken; expire for the per-row sweep emitted by ExpireMiddleware.Expire.
Objectbootstrap-token:<token-id>:<outcome> — a single object LIKE 'bootstrap-token:%' filter catches every entry this bounded context emits. On the not-found / pre-allocation paths the <token-id> segment is the literal unknown so audit readers can grep bootstrap-token:unknown: to find pre-allocation failures.
Reasongranted on success; insufficient_relation on ErrTokenNotFound, ErrKindMismatch, ErrProjectMismatch, ErrTTLOutOfRange, ErrInvariant, ErrRandomSourceUnavailable, ErrInvalidParams, or any unmapped error; caveat_violation on ErrTokenExpired, ErrTokenConsumed, ErrTokenRevoked, or ErrNonceCollision (state-time lifecycle refusals).
OutcomeOne of the eight stable strings: granted, token_expired, token_consumed, revoked, kind_mismatch, project_mismatch, nonce_collision, insufficient_relation. Pinned as exported constants so callers (tests, dashboards, hash-chained sink filters) do not stringly-type the wire form.
TimestampThe injected clock() at decision time, always UTC.

The classification is centralised in audit/classify.go; adding a new sentinel requires updating the switch and a matching unit test in classify_test.go. The reconcile sweep emits one entry per swept row with outcome=token_expired — pinned by tests/integration/bootstrap_tokens_reconcile_test.go, and an idempotent re-run produces zero additional entries.

A nil sink degrades silently with one construction-time slog.Warn per wrapper — the bootstrap path can clear thousands of RPCs per second at steady state and a per-call warn would swamp the logs.

Composition root

The bounded-context types ship today; the composition root that wires them into the running cmd/plexsphere binary is exposed through a single seam: Config.BootstrapTokensFactory returns a BootstrapTokensWiring bundle carrying the reconcile closure, the admin-handler Deps, and the redemption Validator. When the seam is unset:

  • the four /v1/projects/{id}/bootstrap-tokens admin endpoints answer 501 Not Implemented from bootstraptokens_dispatch.go;
  • the /v1/register redemption endpoint dispatches to the Registration Service when the registration wiring bundle is also installed; with no bundle the handler in register.go answers 501 so a stand-alone bootstrap-tokens deployment does not silently accept enrolment;
  • the bootstraptokens-reconcile probe is NOT registered on /readyz — operators reading the probe list see only the platform- side probes.

The wiring contract is exercised end-to-end by cmd/plexsphere/app_bootstrap_tokens_test.go, which plugs in an in-memory bundle and asserts (a) /readyz advertises bootstraptokens-reconcile, (b) POST /v1/projects/{id}/bootstrap-tokens returns 201 with a non-empty plaintext under admin authz, and (c) /v1/openapi.json carries all four operationIds.

Production wiring is supplied by BuildProductionBootstrapTokensFactory in cmd/plexsphere/bootstraptokens_factory_prod.go. It opens a Postgres pool, builds the *issuer.Issuer + *validator.Validator + *repo.BootstrapTokenRepo graph wrapped in the bootstraptokens audit middleware, binds the SpiceDB-backed authorizer, and returns the BootstrapTokensWiring bundle. cmd/plexsphere/main.go constructs the factory and threads it onto Config.BootstrapTokensFactory once PLEXSPHERE_DSN is set and the SpiceDB dial completes — the same nil-DSN opt-out and AuthzCheck-required posture the sibling production factories use. An unset PLEXSPHERE_DSN keeps the four handlers on the 501 stub and skips the reconcile probe; the operator-facing boot breadcrumb states which posture is active. The factory closure is exercised against a real Postgres testcontainer by cmd/plexsphere/bootstraptokens_factory_prod_integration_test.go, and the source-level wiring receipt is pinned by tests/workspace/bootstraptokens_factory_wiring_receipt_test.go.

Audit transport boundary

The bootstraptokens bounded context emits its own local audit.Entry shape via the Sink port declared in audit/port.go. A package under internal/identity cannot import internal/audit directly (the no-cross-context-imports-identity depguard rule forbids it), so the canonical audit chain is reached through an explicit composition-root adapter:

  • cmd/plexsphere/bootstrap_tokens_audit_adapter.go declares bootstrapTokensAuditAdapter. It implements bootaudit.Sink, translates the local Entry onto the canonical internal/audit.Entry, and forwards through an audit.Sink. The adapter preserves Relation verbatim — revoke rows (DELETE /v1/projects/{project_id}/bootstrap-tokens/{id}) stay distinct from POST-shaped issue / consume rows in the chain.
  • BuildProductionRegistrationFactory and BuildProductionBootstrapTokensFactory both bind their audit middleware Sink to the adapter wrapping the canonical audit.NewSlogSink for now. When the hash-chained audit.NewChainedSink from audit_factory_prod.go is threaded through, the inner Sink swaps; the adapter's contract does not.
  • Object is copied through unchanged (bootstrap-token:<token-id>:<outcome>) so a DomainResolver routes the row onto the per-Domain chain backing /v1/domains/{domainId}/audit/entries — not a flat /v1/audit path.

OpenAPI surface

The four operations live on the bootstrap-tokens tag in api/openapi/plexsphere-v1.yaml :

OperationPathAuthz (project-relation)Notes
IssueBootstrapTokenPOST /v1/projects/{project_id}/bootstrap-tokensmanage OR deployThe plaintext token is returned exactly once. The Spectral rule plexsphere-write-once-on-issue-only (in tools/openapi/.spectral.yaml) gates the x-plexsphere-once: true extension to ONLY this response so the marker cannot drift onto a list/get response. The persisted issued_by_user_id is bound to the authenticated principal id from the request context — the OpenAPI request schema deliberately omits the field so a wire-level validator refuses any body that still encodes it.
ListBootstrapTokensGET /v1/projects/{project_id}/bootstrap-tokensdeployReturns paginated BootstrapTokenMetadata — never the plaintext, only id, kind, env_prefix, issued_at, expires_at, consumed_at, revoked_at, issued_by_user_id.
GetBootstrapTokenMetadataGET /v1/projects/{project_id}/bootstrap-tokens/{id}deploySame hash-only metadata view as List.
RevokeBootstrapTokenDELETE /v1/projects/{project_id}/bootstrap-tokens/{id}manage OR deployIdempotent on already-revoked rows; emits one audit Entry per call regardless.

The redemption surface — POST /v1/register — is implemented by internal/transport/http/v1/handlers/register.go, which routes the inbound plaintext through bootstraptokens.Validator. Consume inside the registration transaction owned by the Registration Service. The Validator is wired into the handler's Deps from the BootstrapTokens wiring bundle, so the redemption surface stays live as long as both the BootstrapTokens and Registration bundles are installed.

Authorization denials return 403 Forbidden with the project-relation error envelope; the audit middleware records them with reason=insufficient_relation, outcome=insufficient_relation per the matrix in tests/integration/bootstrap_tokens_authz_test.go.

Threat model

The threat model below names the attacker shapes this context defends against, the invariant that holds the line, and the test that proves the invariant at integration time. This is the assertion surface a reviewer should walk before approving any change to the issuance, validation, or persistence path.

ThreatDefenceTest anchor
Plaintext exfiltration from storage — an attacker reads the bootstrap_token table directly.Only the Argon2id hash is stored. The tests/workspace/bootstraptokens_no_plaintext_column_test.go regex-grep over the sqlc-generated row types fails the build if any field name matches (plaintext|plain_text|cleartext|secret_value).tests/workspace/bootstraptokens_no_plaintext_column_test.go
Plaintext leak through API surface — an operator reads a previously-issued token via List or Get.The OpenAPI BootstrapTokenMetadata schema OMITS the token field; only BootstrapTokenIssueResponse (the POST response) carries token flagged x-plexsphere-once: true, and the Spectral rule fails any spec mutation that puts the marker on a non-Issue response.tests/integration/openapi_bootstrap_tokens_spectral_test.go, tests/integration/bootstrap_tokens_issue_test.go
Plaintext leak through process memory — the secret survives in the Issuer's heap after the response is written.bootstraptokens.Plaintext.Destroy() zeroes the backing slice in place; the Issuer uses defer plaintext.Destroy() so the secret is wiped on every return path including errors.bootstrap_tokens_issue_test.go "Plaintext shown once + zeroed" scenario
Replay — an attacker presents the same plaintext (or the same nonce against a re-mint) twice.Three layers: (a) ConsumeBootstrapToken is a single UPDATE … RETURNING WHERE consumed_at IS NULL; (b) the partial UNIQUE on (project_id, consumed_nonce) rejects the second envelope as ErrNonceCollision; (c) the audit middleware records every attempt with the precise outcome.tests/integration/bootstrap_tokens_concurrency_test.go, tests/integration/bootstrap_tokens_scope_test.go
Cross-project token use — a token issued for Project A is presented at Project B's bootstrap endpoint.Project ID is encoded in the plaintext AND on the persisted row; the Validator rejects mismatch before the candidate scan and again as defense-in-depth on the matched row.bootstrap_tokens_scope_test.go "project mismatch" scenario
Endpoint kind confusion — a Bridge token presented at the Node bootstrap endpoint.Kind is encoded in the plaintext and validated against ExpectedKind before any database lookup; mismatch surfaces as ErrKindMismatch.bootstrap_tokens_scope_test.go "kind mismatch" scenario
Token resurrection — an operator rolls back the schema to drop the consumption metadata.The 0007_bootstrap_tokens.sql Down block REFUSES the downgrade with RAISE EXCEPTION … USING ERRCODE = '0A000' (feature_not_supported), pinning the audit-trail invariant against accidental schema rollback.tests/integration/db_migrations_test.go
Authorization bypass — a project viewer issues or revokes a token.Each handler authorises the caller through the project-relation matrix (manage|deploy for Issue/Revoke; deploy for List/Get). Denials surface as 403 with reason=insufficient_relation on the audit row.tests/integration/bootstrap_tokens_authz_test.go
Schema-drift exemption from coverage — a future PR moves bootstraptokens code outside the security-critical 90% tier.internal/identity/bootstraptokens/** is pinned to the security-critical Codecov flag's path globs, with tests/workspace/codecov_config_test.go asserting the glob is present.tests/workspace/codecov_config_test.go

Information disclosure note. The Validator. Consume early-parse path returns ErrProjectMismatch / ErrKindMismatch BEFORE running the candidate scan when the presented plaintext is well-formed and decodes to a different project or kind. This lets a caller distinguish "well-formed plaintext targeting a different project" from "malformed plaintext" by the typed sentinel — a small probe primitive that reveals well-formedness, but NOT the existence of any specific token row. The early checks are intentional (they short-circuit a common mis-routing failure mode without paging Postgres) and the remaining-row existence is still hidden behind the candidate scan and the constant-time hash compare. If a future deployment requires the stronger "indistinguishable wrong-project / not-found" behaviour, the early-parse arms can collapse to ErrTokenNotFound at the cost of a wasted candidate scan per malformed-or-misrouted bearer string.

What this story is NOT

The BootstrapToken context ships issuance, validation, persistence, lifecycle sweep, and the four-operation administration surface for BootstrapTokens. Adjacent capabilities live in dedicated stories so this context's surface stays minimal:

  • NOT a Node/Bridge enrolment handler. The POST /v1/register redemption flow is owned by the Registration context under internal/identity/nodes/registration; the HTTP handler at internal/transport/http/v1/handlers/register.go is the one consumer of bootstraptokens.Validator.Consume. The Validator's contract (precedence ordering, ConsumeParams, ConsumeResult) is the entry point that context consumes.
  • NOT a Node identity store. The consumed_by_node_id column references plexsphere.nodes (id) but the Node aggregate itself is owned by the Node Registration sub-context (see the internal/identity/nodes row in ../../contributing/layout.md); the per-node reconciliation / state-plane surface lives under internal/mesh. Bootstrap tokens record which node consumed them; they do not manage the node lifecycle.
  • NOT an operator IAM surface. issued_by_user_id is the operator who called Issue; the User aggregate, role bindings, and the project-relation tuples that authorise manage|deploy come from the IdP & Tenancy contexts. Story planned re-issues operator credentials; this context consumes the existing tuples but does not mint them.
  • NOT a long-lived bearer credential. The token is single-use and bounded by [5m, 24h]. The long-lived ServiceIdentity API token a redeemed Node holds afterwards is owned by internal/identity/tokens and rotated under a separate policy defined in story planned — see the Identity & IdP context reference for the API-token rotation contract.
  • NOT a secret-distribution channel. Operators MUST hand the plaintext to the redeeming Node/Bridge out-of-band (kubectl exec, sealed-secret, password manager). The HTTP response is the one and only surface that ever holds the plaintext on the plexsphere side; subsequent reads will always surface BootstrapTokenMetadata only.