Appearance
Repository layout and bounded-context map
This document is the authoritative map between the Go packages under internal/ and the subsystems described in the top-level README.md. It is the first thing a new contributor should read before adding code: it tells you where a change belongs, why the boundary exists, and how the depguard linter enforces it.
For the higher-level architectural picture (planes, control flow, trust model), read the companion Architecture Overview alongside this document.
Why bounded contexts
plexsphere is organised around Domain-Driven Design (see CLAUDE.md — DDD is the repository's primary engineering principle). Each subsystem described in the README owns a slice of the ubiquitous language (Identity, Peer, Policy, Bridge, Session, Secret, Blueprint, …) and a set of invariants that only code inside that context may enforce. Concretely:
- Each bounded context lives in its own module directory under
internal/. - A context owns its aggregates, domain events, repositories, and the application services that orchestrate them.
- Sibling contexts do not reach into each other. Cross-context integration happens either via domain events or via an explicit transport adapter under
internal/transport/. - Shared, cross-cutting infrastructure (telemetry, HTTP client factories, health endpoints, server lifecycle) lives under
internal/platform/.
This is not a stylistic preference: the layout is enforced by the no-cross-context-imports depguard rule in ../../.golangci.yml.
Bounded-context map
Every module under internal/ maps to exactly one README subsystem (or, for shared infrastructure, to the ## Subsystems umbrella). When a README heading exists, the README Subsystem column links to it; where no exact heading exists, the row links to the closest parent section. The final three rows cover the OpenAPI spec and its generated artefact trees: these sit outside internal/ but are the compilation unit for every /v1 caller, so they belong on the same map — see openapi.md for the authoring workflow.
| Module | README Subsystem | Purpose |
|---|---|---|
internal/identity | Identity & Registration | Issues bootstrap tokens, handles POST /v1/register, owns the Identity aggregate and its lifecycle (issued → consumed / expired / revoked). |
internal/identity/bootstraptokens | Identity & Registration | BootstrapToken sub-context: aggregate, plaintext format, single-use Consume, lifecycle persistence, and the four-operation administration surface (IssueBootstrapToken, ListBootstrapTokens, GetBootstrapTokenMetadata, RevokeBootstrapToken). The bootstraptokens.Validator seam is the sole public consumption surface for the Node/Bridge enrolment handler — its consumer is the Registration handler at internal/transport/http/v1/handlers/register.go, which routes POST /v1/register plaintexts through Validator.Consume inside the registration transaction. The bounded-context reference at ../contexts/identity/bootstrap-tokens.md carries the ubiquitous language, state machine, threat model, and audit contract; the operator runbook lives at ../how-to/enrolment/issue-a-bootstrap-token.md. |
internal/identity/nodes | Identity & Registration | Node Registration sub-context: aggregate orchestration for the atomic five-step commit (BootstrapToken consume → mesh-IP allocation → Node insert → NSK issuance → outbox append) that backs POST /v1/register. The tenancy.NodeRegistered outbox event is the sole public consumption surface for downstream contexts — its consumers are SSE Signed Event Bus consumer that reads the outbox row, reconciliation pull GET /v1/nodes/{id}/state, and Node deregistration handler. The bounded-context reference at ../contexts/identity/registration.md carries the ubiquitous language, state machine, threat model, and OpenAPI surface; the operator how-to lives at ../how-to/enrolment/register-a-node.md. The handler internal/transport/http/v1/handlers/register.go is where the Registration Service is wired into Deps. |
internal/authz | ReBAC: Authorisation via Relation Tuples | Request-authorisation context: the thin Authorizer wrapper over the SpiceDB PermissionsService (Check, Write, Delete, LookupResources, LookupSubjects), the per-request Session that captures zedtokens for read-your-writes consistency, the CEL caveat-context builder (within_time_window, from_cidr, requires_assurance), the idempotent ApplySchema driver that installs schema/authz.zed on cmd/plexsphere startup, and the middleware/ subpackage (PrincipalMapper + the per-route ReBAC gate). This context is the sanctioned single entry point for every ReBAC decision in the system — siblings call Authorizer.Check for read gates, Authorizer.Write / Delete for relation-tuple mutation, and LookupResources / LookupSubjects for the dashboard's filter projections. The no-authzed-go-outside-authz depguard rule in .golangci.yml confines github.com/authzed/authzed-go imports to this package, and internal/authz is intentionally absent from the generic no-cross-context-imports deny list so every handler can reach the Authorizer; cross-context tuple mutation flows through internal/authz/sync per the per-context allow-list. The bounded-context reference at ../contexts/authz/index.md carries the ubiquitous language, the operation matrix, the caveat catalogue, the audit emission contract, the schema-apply state machine, and the middleware ordering invariants. The schema itself, the zedtoken consistency flow, and the per-resource-type relation roster live one level over at ../contexts/identity/rebac.md. |
internal/mesh | Key & Peer Manager | Owns the mesh-fabric aggregate: per-Domain IP allocation, PSK custody, Curve25519 rotation, peer deltas, endpoint freshness, relay-fallback assignment. The sse/ sub-package owns the Signed Event Bus publish pipeline: it consumes signed envelopes from the upstream Signing Service contract, publishes them onto the canonical JetStream stream PLEXSPHERE_NODE_EVENTS under the per-node plexsphere.node.events.<domain>.<node> subject layout, and serves the per-node SSE channel its downstream consumers reach via /v1/nodes/{id}/events — namely the reconciliation pull (GET /v1/nodes/{id}/state fallback when replay is impossible), Reachability, Observability, the Dashboard, the Node deregistration lifecycle, and Dashboard signing-key rotation. The state/ sub-package owns the data-shape half of the reconciliation-pull surface: it composes the canonical NodeStateSnapshot envelope returned by GET /v1/nodes/{id}/state, projects the per-Domain peer set through the PeerSource seam in a single SQL round-trip ordered by node_id ASC, and enforces per-peer invariants with WARN-on-drop logging. The future state and reports blocks of that envelope are deferred (currently null placeholders); the remaining relay-loop wiring is tracked in ../architecture/mesh-event-bus-roadmap.md. There is deliberately no separate top-level internal/state skeleton context — the snapshot composition and the SSE/state plane already live here and under sse/, so an empty sibling would only imply unbuilt work that is in fact built. The state/ sub-package's bounded-context reference at ../contexts/mesh/reconciliation-pull.md carries the ubiquitous language, the four-block convergence with the SSE event taxonomy (peers / policy / bridge / state/reports → peer_* / policy_updated / bridge_config_updated / node_state_updated), the security-gate ordering, and the deferred-wiring posture. The sse/ sub-package is the single seam under internal/mesh allowed to import internal/access, internal/audit, internal/signing/envelope, and internal/identity/tenancy/events; the no-cross-context-imports-mesh-sse depguard rule in ../../.golangci.yml encodes that allow-list, and the workspace test tests/workspace/mesh_sse_depguard_test.go backs it at go test time so the lint exception cannot silently broaden. The bounded-context reference at ../contexts/mesh/sse.md carries the ubiquitous language, envelope contract, and replay-window state machine; the operator runbook lives at ../how-to/mesh/inspect-the-event-bus.md. The reachability/ sub-package owns the per-Node liveness state machine: it accepts heartbeats from the canonical POST /v1/nodes/{id}/heartbeat surface, evaluates the healthy / stale / unreachable transitions, and serves the read-side projection at GET /v1/nodes/{id}/reachability consumed downstream by Observability, the Dashboard, and the Topology view, Impact Preview, and the future Node State / reports surface. The heartbeat endpoint is gated by the Node Signing Key authn middleware at ../../internal/identity/authn/middleware/nsk.go — the security-critical seam that proves the caller is the addressed Node before the evaluator updates state. The bounded-context reference at ../contexts/mesh/reachability.md carries the state-machine semantics, threshold knobs, and event-emission contract; the operator how-to lives at ../how-to/mesh/operate-reachability.md. |
internal/policy | Policy Engine | Network Policy Engine bounded context: owns the Policy aggregate, the append-only Revision history (one row per PATCH; partial-unique index policy_head_uq pins "exactly one live head per Policy" and surfaces concurrent races as ErrRevisionConflict), and the L3/L4 Rule value object whose five-tuple (SourceCIDR, DestinationCIDR, Protocol, PortRange, Action) is validated at the aggregate boundary (closed Action set {allow, deny, log}, closed Protocol set {tcp, udp, icmp, any}, max 1024 rules per revision, protocol/port coherence, CIDR family coherence). The module reaches its collaborators through narrow outbound ports in ../../internal/policy/ports.go: it consumes the labels SelectorPort (re-declared minimally so the policy module never imports internal/labels/*), a NodeLister bound to internal/mesh/peers.Provider.SnapshotForDomain through an anti-corruption adapter, a ReachabilityPort bound to internal/mesh/reachability.State, a LabelSnapshotPort bound to internal/labels through an anti-corruption adapter (reserved for the per-Node re-evaluation pass named in the CompileService.HandleEvent DECISION block), and the shared outbox + audit sinks; it exposes the policy.Repo + policy.RevisionRepo + policy.CompiledRulesetRepo persistence seams (pgx-backed adapter under ../../internal/policy/repo) and the PolicyService + DryRunService + CompileService application services consumed by the eight /v1/projects/{projectId}/policies/... HTTP operations under ../../internal/transport/http/v1/policies and by the outbox-driven compile arm at ../../internal/authz/sync/consumer.go. The closed outbox event set is exactly two literals — policy_revision_created and policy_deleted — pinned by the AST gate tests/workspace/policy_event_type_set_test.go; both literals drive both the SpiceDB tuple-write arm AND the CompileService per-Node compile arm inside the same outbox-consumer retry boundary. The per-Node compiled-ruleset projection lands in plexsphere.policy_compiled_ruleset (migration ../../internal/platform/db/migrations/0034_policy_compiled_rulesets.sql) and reaches the reconciliation-pull snapshot through the PolicySource port at ../../internal/mesh/state/policy_source.go; the wire-side policy_updated SSE event is emitted by the compile-service arm through the policy.WirePublisher port at ../../internal/policy/ports.go, bound in the composition root to *sse.Publisher via the adapter at ../../cmd/plexsphere/policy_wire_publisher_adapter.go — see ../contexts/policy/events.md for the publisher-side dispatch table and the producer-side fan-out algorithm. Cross-context imports are denied by no-cross-context-imports-policy with a negative-glob carve-out for internal/policy/repo/**. The bounded-context references at ../contexts/policy/model.md and ../contexts/policy/compiler.md carry the ubiquitous language, the schema reference, the rule grammar, the version-history state machine, the dry-run and diff contract, the ReBAC ownership matrix, the audit contract, the SelectorPort consumer carve-out, the per-Node projection algorithm, the SHA-256 fingerprint contract, the outbox-consumer wiring, and the PolicySource seam onto the reconciliation-pull snapshot. This is NOT request authorization — identity-and-relation authorization lives in internal/authz. |
internal/policy/hooks | Policy Engine | A Kubernetes-aware sub-module of the policy context holding the discovery-only projection of the upstream Kubernetes PlexdHook custom resource onto the five-field shape persisted in node_capability_manifest.plexd_hooks (name, image digest, parameters, timeout seconds, sandbox). It exposes PlexdHookGVK and ParsePlexdHook with fail-closed extraction. This is the only policy package aware of the Kubernetes client libraries — every other policy package stays Kubernetes-free, enforced by depguard. Discovery-only: plexsphere never writes PlexdHook objects back. |
internal/policy/hooks/push | Policy Engine | The managed-push domain sub-context — the opt-in authoritative-write counterpart to the discovery-only internal/policy/hooks projection above. Owns the per-Domain Target (the opt-in flag plus the sealed kubeconfig, with the fail-closed CanPush opt-in decision), the AES-256-GCM Sealer that binds the owning Domain UUID as GCM AAD so a ciphertext copied between Domain rows fails the tag check, ValidateKubeconfig (the attach-time rejection pipeline — the load-bearing check rejects an exec credential plugin or an authProvider as a remote-code-execution vector), NewRequest / spec validation, the push Record plus the one-shot rollback rule, and the outbound ports (TargetStore, RecordStore, ClusterClientFactory, AuditSink). Persists to plexsphere.domain_managed_push and plexsphere.domain_hook_push via migration 0051_domain_managed_push.sql. The bounded-context reference at ../contexts/hooks/managed-push.md carries the ubiquitous language, the sealing and SSA-authority contract, the rollback rule, and the threat model. |
internal/policy/hooks/push/kube | Policy Engine | The customer-cluster adapter implementing push.ClusterClientFactory over client-go. It builds a rest.Config plus a dynamic client per call for the PlexdHookGVK, applies RenderPlexdHook via Server-Side Apply (FieldManager=plexsphere-managed-push, force:false), captures the prior cluster state for rollback, and classifies apiserver field-manager conflicts to push.ErrClusterConflict and connection / TLS / timeout faults to push.ErrClusterUnreachable. |
internal/policy/hooks/push/repo | Policy Engine | The sqlc-backed persistence adapter implementing both push.TargetStore and push.RecordStore over plexsphere.domain_managed_push and plexsphere.domain_hook_push, classifying the domain_id foreign-key violation (SQLSTATE 23503) to push.ErrDomainNotFound. |
internal/bridge | Bridge Orchestrator | Bridge Orchestrator bounded context: owns the orchestrator configuration a Resource of kind bridge carries — the relay daemon configuration, the mesh-ingress providers end users dial in through, the SNI-routed public ingress rules, and the site-to-site tunnels to remote endpoints. The context is modelled as four independent aggregates rather than one mega-aggregate rooted on the bridge Resource: BridgeRelay (the singleton relay configuration, keyed on the Resource alone — no surrogate id), UserAccessProvider (a named WireGuard-family mesh-ingress provider, tailscale / netbird / wireguard), PublicIngressRule (an SNI-routed public ingress termination forwarding to a target Node + port), and SiteToSiteTunnel (a directional wireguard / ipsec / openvpn tunnel carrying an allowed-subnets list and a routing policy). Each carries its own invariant — the relay's one-per-bridge singleton port, a provider's per-Resource slug uniqueness and peer ceiling, an ingress rule's slug + SNI uniqueness and in-domain target Node, and a tunnel's non-empty allowed-subnets list — so the orchestrator modifies one aggregate per transaction and coordinates across them via domain events rather than burying four lifecycles under one transactional gate. Persistence lands in four Postgres tables — plexsphere.bridge_relay, plexsphere.bridge_user_access_provider, plexsphere.bridge_public_ingress_rule, and plexsphere.bridge_site_to_site_tunnel — created by migration 0042_bridge_orchestrator.sql, whose Down block refuses the downgrade because the rows are operator-authored source-of-truth not reconstructible from anywhere else. The module stays framework-free behind narrow ports, enforced by depguard rules in .golangci.yml: the no-direct-persistence-from-contexts rule is carved out at the four internal/bridge/{relay,useraccess,ingress,sitetosite}/repo/** subpackages so only those may import github.com/jackc/pgx and the generated sqlc bundle, while every other bridge package — the four aggregates, their value objects, the events children, the application services — reaches persistence through the repo's aggregate-shaped methods; cross-context imports are denied by the dedicated no-cross-context-imports-bridge rule, the bridge-specific variant of the generic cross-context rule that permits the context's own subpackages to import the internal/bridge root (home of the shared ResourceID / ID / Slug / SecretRef value types and the sentinel errors) and each other while denying every other owned context. The orchestrator emits a closed seven-event outbox set — relay configured, user-access provider configured / removed, public ingress rule configured / removed, and site-to-site tunnel configured / removed — pinned by the AST gate at tests/workspace/bridge_event_type_set_test.go, which fails the build if any event-type literal outside the closed set reaches an outbox append. The context holds no secret material: every auth secret a provider or tunnel consumes is stored as an opaque secret reference (validated for shape, never dereferenced), so a projection leak exposes a pointer rather than a credential, and resolving a reference to live material is deferred to a future Secret Store story. The ReBAC posture is split — write operations gate manage on the addressed resource:<id> while read operations gate observe on the parent project:<id> — and the resource-kind precondition runs before any aggregate write: every mutation reads the target Resource and refuses a non-bridge Resource with 409 resource_not_bridge. The HTTP surface lives under internal/transport/http/v1/bridge and is wired through the composition root at cmd/plexsphere/bridge_factory_prod.go, which keys its opt-in on a non-empty PLEXSPHERE_DSN and otherwise leaves the handlers on their 501 stub. The producer-side mesh wire fan-out has landed: the four bridge application services call the bridge.WirePublisher port at internal/bridge/ports.go after they commit, the composition root binds the real *sse.Publisher and the effective.EffectiveConfigBuilder behind it, and a committed bridge change fans out one signed bridge_config_updated envelope per Node the bridge Resource hosts — see ../contexts/bridge/events.md for the closed-outbox-vs-wire-literal split, the per-Node fan-out algorithm, and the byte-equality contract with the reconciliation-pull bridge block. The cross-aggregate validation has landed as a stateless pre-persist validator at internal/bridge/validator, which the four bridge services run after the ReBAC check and before the persist transaction; it refuses host-port conflicts, subnet overlaps (against the Domain mesh CIDR and sibling tunnels), and unreachable ACME directories — see ../contexts/bridge/validation.md for the refusals, the failure-precedence order, and the per-entry-point prefix. Only one downstream story remains deliberately out of scope here: materialising a secret reference into live auth material through the Secret Store. The consumer-side /v1/nodes/{id}/events HTTP plumbing also stays deferred to the Signed Event Bus epic. The bounded-context reference at ../contexts/bridge/model.md carries the ubiquitous language, the aggregate invariants, the event set, the secret-reference posture, and the resource-kind precondition; the HTTP wire shape lives at ../reference/api/bridge.md. |
internal/provisioning | Provisioning Broker | Translates Resource declarations into Crossplane XRs, manages per-Project namespaces, injects bootstrap tokens, and coordinates teardown. |
internal/provisioning/managementfleet | Provisioning Broker | Management Fleet sub-context: owns the durable inventory of which Kubernetes clusters host the provisioning substrate (Crossplane v2 and the External Secrets Operator) and which management cluster owns each Project. The ManagementCluster and ProjectClusterAssignment aggregates back a pure, total namespace-phase state machine; a boot-probe-plus-ticker reconcile closure converges a least-privilege per-Project namespace (Namespace, Role, RoleBinding, ServiceAccount, ResourceQuota) on the owning cluster, and a read-only verify gate confirms the cluster serves the Crossplane and External Secrets Operator API groups with Available controllers before a Project is placed. The module reaches its collaborators through narrow ports so the domain layer stays framework-free, enforced by depguard rules in .golangci.yml: the no-direct-persistence-from-contexts rule is carved out at internal/provisioning/managementfleet/repo/** so only that subpackage may import github.com/jackc/pgx, and the dedicated no-controller-runtime-outside-managementfleet-reconcile rule confines sigs.k8s.io/controller-runtime to the reconcile/ subpackage. Every other managementfleet package — the domain, the application service, the events subpackage — stays clear of both frameworks. The bounded-context reference at ../contexts/provisioning/management-fleet.md carries the ubiquitous language, the aggregate invariants, the namespace-phase state machine, the five ports, the seven error sentinels, the verify-only readiness gate, and the operator recovery runbook. |
internal/provisioning/blueprints | Provisioning Broker | Blueprint Catalog sub-context: owns the durable record of the curated provisioning recipes a Project can request — which blueprints exist, which versions each has published, and the contract every version exposes to the Provisioning Broker. The Blueprint aggregate carries a catalogued recipe's identity, kebab-case slug, optional Domain scope, human-facing display name, and mutable active/retired status; its immutable BlueprintVersion children each bundle the XRD and Composition manifest blobs, a typed ParameterSchema, the closed set of accepted ProviderKinds, and the InjectionStrategy that threads a request into the rendered Composite Resource — a published version exposes no mutator, so a correction is a new version rather than an edit. Five embedded platform seed blueprints are reconciled into the catalog at boot with byte-for-byte drift detection, gating /readyz under the blueprint-catalog-seeds probe. The module reaches its collaborators through narrow ports so the domain layer stays framework-free, enforced by depguard rules in .golangci.yml: the no-direct-persistence-from-contexts rule is carved out at internal/provisioning/blueprints/repo/** so only that subpackage may import github.com/jackc/pgx, and the dedicated no-k8s-outside-blueprints-manifest rule confines k8s.io and sigs.k8s.io to the manifest/ subpackage that decodes XRD/Composition YAML — every other blueprints package models the manifests as opaque blobs. Cross-context imports are denied by no-cross-context-imports-provisioning. The bounded-context reference at ../contexts/provisioning/blueprints.md carries the ubiquitous language, the aggregate invariants, the ProviderKind and InjectionStrategy enums, the parameter-schema model, the nine error sentinels, the platform seed catalog, the depguard layout, and the seed readiness probe. |
internal/provisioning/broker | Provisioning Broker | Provisioning Broker sub-context: the orchestration context that turns a Project operator's resource declaration — a node on a given cloud, rendered from a published blueprint — into running substrate that auto-enrols into the mesh. The ProvisionedResource aggregate carries the request's identity and the deterministic Composite Resource (XR) name; a pure, total Next transition drives its five-phase status state machine (Pending, Provisioning, Enrolling, Ready, and Failed as a sticky terminal sink) off live XR conditions observed every reconcile tick rather than cached desired state. The render/ subpackage renders a Crossplane v2 namespaced Composite Resource and its ProviderConfig and threads a short-TTL bootstrap token into the XR per the blueprint's InjectionStrategy; the reconcile/ subpackage Server-Side Applies both into the per-Project namespace once the Management Fleet reports that namespace Ready, and a boot-probe-plus-ticker reconcile closure gates /readyz under the provisioning-broker-reconcile probe. The module reaches its collaborators through narrow ports so the domain layer stays framework-free, enforced by depguard rules in .golangci.yml: the no-direct-persistence-from-contexts rule is carved out at internal/provisioning/broker/repo/** so only that subpackage may import github.com/jackc/pgx; the no-controller-runtime-outside-broker-reconcile rule confines sigs.k8s.io/controller-runtime to the reconcile/ subpackage; the no-k8s-outside-broker-render-reconcile rule confines k8s.io and sigs.k8s.io to the render/ and reconcile/ subpackages; and the no-tenancy-import-from-broker rule bars any import of internal/identity/tenancy, so the broker observes a Project only by id and owns its own ProvisionedResource aggregate. The bounded-context reference at ../contexts/provisioning/broker.md carries the ubiquitous language, the aggregate invariants, the status state machine, the ports, the error sentinels, the render and reconcile pipeline, and the operator recovery runbook. |
internal/artifacts | Artifact Registry | Catalog of upstream plexd releases verified before serving: per-version, per-architecture (amd64 / arm64) records carrying the SHA-256 known-good checksum, the Sigstore bundle (the Fulcio code-signing certificate plus the Rekor transparency-log inclusion proof) verified against the release-signing identity pinned in plexd at build time, and a support status (supported / deprecated / withdrawn). It serves the verified records over GET /v1/artifacts/plexd/{version} and the {version}/sigstore companion endpoint, and exposes the known-good checksum set the service.upgrade verification path and the Capability & Hook Registry consult. Release signing happens upstream in the plexd build pipeline — plexsphere never holds release-signing keys — so this context only verifies and serves releases; building and signing the binaries and the node-side execution of an upgrade are out of scope. |
internal/actions | Action Orchestrator | Action Orchestrator bounded context: owns one Execution aggregate per dispatched action — a builtin shipped with the Node agent or a user-declared hook — fanned out to one ActionInvocation entity per target Node. The Execution is the consistency boundary (it owns its invocations and the collected output ref, and one transaction modifies the aggregate as a whole) while the per-Node status state machine lives on each invocation, because one dispatch fans out to N Nodes that advance independently — one Node may have succeeded while another is still pending. Each invocation walks a closed status lifecycle (pending → ack → started → one of the terminal values succeeded / failed / cancelled / timeout, with the timeout edge reachable from every non-terminal status so a never-acked invocation always has a terminal status to settle on), enforced by Status.Transition; the callback path drives it server-side through a compare-and-set and a background reconciler stamps timeout on expired live invocations. Persistence lands in three Postgres tables — plexsphere.action_execution (the header, keyed on an app-minted UUIDv7 so the id can ride the outbox event in the same transaction), plexsphere.action_execution_target (the per-Node invocation, keyed on the composite (execution_id, node_id) natural key so "at most one invocation per Node per Execution" is structural), and plexsphere.action_execution_event (the append-only transition log) — created by migration 0043_actions.sql, whose Down block refuses the downgrade with SQLSTATE 0A000 because the rows are operator-authored execution evidence reconstructible from nowhere else; the header FKs its Domain / Project / Node parents ON DELETE RESTRICT while the target and event log CASCADE from the header. A dispatch resolves its cohort from a single node_id or an opaque label selector, then applies three per-node gates — domain isolation, ReBAC act, capability — DROPPING each rejected node on the selector path (a single-node dispatch hard-fails instead); when no node survives the selector fan-out, ErrActionNotDeclared takes precedence over ErrSelectorEmptyCohort only when a node reached the capability gate and was dropped solely for not declaring the action, so the message names the real remedy. A per-Domain live-execution cap (DefaultLiveExecutionsCap = 1000, operator-tunable) refuses an over-budget dispatch with ErrCapacityExceeded. The collected output rides a two-tier contract: bounded inline bytes (≤ MaxInlineOutputBytes = 16 KiB) stored in the control plane, or — for an over-ceiling body the Node declares — a presigned object-store PUT URL returned on the first callback, with the terminal settle persisting the object_store variant of the OutputRef tagged union. The module stays framework-free behind narrow ports declared in internal/actions/ports.go (Repository, Authorizer, Clock, LabelSelector, ResourceReader, NodeReader, CapabilityReader, OutputPresigner, AuditSink, WirePublisher) so the domain layer reaches persistence, the selector engine, the blobstore, and the SSE publisher only through interfaces the composition root binds; cross-context imports are denied by the dedicated no-cross-context-imports-actions rule in .golangci.yml, which permits the context's own subpackages (the events child, repo, services) to import the internal/actions root and each other while denying every other owned context, and the LabelSelector port is an actions-local mirror of the labels shapes precisely so the context never imports internal/labels. The orchestrator emits a closed single-member outbox set — exactly actions.ActionDispatched, one row per target Node a dispatch fans out to — pinned by the AST gate at tests/workspace/actions_event_type_set_test.go; the SSE publisher's wireTypeFor dispatch table maps that single outbox literal onto the single wire literal action_request (internal/mesh/sse.EventTypeActionRequest), and a plexd consumer branches on action_request to receive the per-Node payload (action name, kind, parameters, timeout, callback URL) — see ../contexts/actions/events.md for the closed-outbox-vs-wire-literal split and the post-commit best-effort fan-out. The ReBAC posture is split: the transport layer gates act on the owning project:<id> for the three operator-facing operations before any persistence write, the dispatch service additionally gates act on each cohort member resource:<node> during fan-out, and the Node callback is NSK-authenticated (not ReBAC-gated) behind an execution-target membership gate that refuses a leaked NSK from a non-targeted Node with 403 nsk_node_mismatch. The HTTP surface lives under internal/transport/http/v1/actions (Dispatch / List / Get) plus the NSK callback handler at internal/transport/http/v1/handlers/executions_callback.go, with the wire-contract status codes conforming to the acceptance criteria — an empty cohort is 422 (selector_empty_cohort), a capacity refusal is 429 (capacity_exceeded), and malformed_selector / action_not_declared are 400 — kept in lockstep with the OpenAPI spec and the transport mapping. Three concerns stay deliberately out of scope here: the plaintext callback-token delivery to the Node (dispatch persists only the sha256 hash), a typed timeout outbox event (deferred until the closed set and the SSE wire-type table widen in lockstep), and the consumer-side /v1/nodes/{id}/events SSE plumbing (owned by the Signed Event Bus epic, still on its 501 stub). The bounded-context reference at ../contexts/actions/model.md carries the ubiquitous language, the aggregate invariants, the invocation state machine, the two-tier output contract, the dispatch fan-out and survivor-precedence rules, the ReBAC act matrix, the audit contract, and the Problem-code catalogue; the events surface lives at ../contexts/actions/events.md. |
internal/access | Access Orchestrator | Access Orchestrator bounded context: owns the Session aggregate that mediates a short-lived ssh / k8s / tcp grant, modelled with a per-Kind discriminated SessionTarget value object so an ssh session carries a target Node + login while a k8s session carries a cluster + namespace, validated at the aggregate boundary against the session Kind. Issuance mints a session-scoped JWT through the access.SignerClient typed gRPC client (internal/access/signer_client.go) wrapping the Signing Service's SignerService — the per-Domain Ed25519 key the JWT is signed under stays in the internal/signing custodian, never in this context — and the JWT canonical-header / claims / verify helpers live under internal/access/jwt. Every issuance runs a ReBAC Check on the target resource:<id> before any persist, enforces the per-Domain SessionPolicy (the operator-authored hard-TTL, idle timeout, concurrency ceiling, and step-up requirement loaded from plexsphere.domain_session_policy, migration 0045_domain_session_policy.sql), and refuses an over-concurrency or step-up-required issuance before minting a token. Persistence lands in three Postgres tables — plexsphere.access_session (the issued-session header, keyed on an app-minted UUIDv7 so the id rides the outbox event in the same transaction), plexsphere.access_session_event (the append-only transition log), and plexsphere.access_revocation_list (the instant-revocation set every target plexd consults) — created by migration 0044_access_sessions.sql. The module stays framework-free behind narrow ports declared in internal/access/ports.go so the domain reaches persistence, the signer, and the clock only through interfaces the composition root binds; the no-direct-persistence-from-contexts rule is carved out at internal/access/repo/** so only that adapter imports github.com/jackc/pgx and the generated sqlc bundle, while cross-context imports are denied by the dedicated no-cross-context-imports-access rule in .golangci.yml that permits the context's own subpackages (events, jwt, repo, services) to import the internal/access root and each other — and omits internal/signing from its deny list because the SignerClient maps the signer's gRPC status codes onto internal/signing sentinels — while denying every other owned context. The orchestrator emits a closed two-event outbox set — access.SessionIssued and access.SessionRevoked — pinned by the AST gate at tests/workspace/access_event_type_set_test.go; the Signed Event Bus routes them onto the two closed wire literals (session_setup, session_revoked) so a target plexd learns a session envelope on issuance and drops the session immediately on revocation, which is instant: a revoke commits the row to access_revocation_list and fans out session_revoked rather than waiting for the JWT to expire. The application services live under internal/access/services (issue, revoke, the activity-callback consumer that resets the idle timer from the target plexd, and a background sweeper that settles expired sessions), and the HTTP surface lives under internal/transport/http/v1/access (issue / list / get / revoke) with the split ReBAC posture — issue gates the target resource:<id> while list / get / revoke gate the parent project:<id>, and revoke_reason is a closed operator-only set. The ubiquitous language, the aggregate invariants, the SessionTarget discriminator, the SessionPolicy knobs, the instant-revocation contract, the JWT claim set, and the ReBAC matrix live on the aggregate and events packages themselves (internal/access, internal/access/events); the operator-facing summary is the Access Orchestrator README section. |
internal/secrets | Secret Store | Secret Store bounded context: the real-time NSK-rewrap proxy that serves Project-scoped secret material to plexd Nodes over GET /v1/nodes/{id}/secrets/{name}?version=… as NSK-wrapped ciphertext, owning the Secret aggregate and its value objects (SecretName, SecretVersion, OpenBaoPath, NSKKid). Three structural invariants bound the context: plaintext never leaves the in-process rewrap pipeline — secret plaintext lives in OpenBao, the Node Secret Key (NSK) plaintext is reconstructed from the per-Domain wrap key and the persisted node_secret_key.wrapped_nsk row at request time, the AES-256-GCM rewrap runs in-process, and only NSK-wrapped ciphertext leaves over /v1 (plaintext is never persisted to Postgres, never logged, and never carried by the Signed Event Bus); every read is recorded in the hash-chained Platform Audit Log (internal/audit); and per-Domain and per-Node token-bucket rate limits guard the read hotpath. Persistence will land in the plexsphere.secret_metadata and plexsphere.node_secret_visibility tables plus the plexsphere.domain_secret_policy rate-limit knobs (migrations 0046_node_secrets.sql and 0047_domain_secret_policy.sql). The module stays framework-free behind narrow ports (the OpenBao-backed BackendReader, the NSKResolver that recovers the NSK plaintext, the Repo, and the RateLimiter) declared at the context root internal/secrets; the no-direct-persistence-from-contexts rule is carved out at internal/secrets/repo/** so only that adapter imports github.com/jackc/pgx and the generated sqlc bundle, while cross-context imports are denied by the dedicated no-cross-context-imports-secrets rule in .golangci.yml that permits the context's own subpackages (events, repo, services) to import the internal/secrets root and each other, allow-lists internal/identity/nodes/nsk (the NSK custody seam the rewrap pipeline depends on), and leaves internal/audit un-denied (every read is recorded) while denying every other owned context. Two further depguard rules trap the plaintext-isolation invariant at lint time: no-secret-values-on-sse-payload refuses any import of internal/platform/secretstore from internal/secrets/events and internal/mesh/sse so the names-and-versions-only node_secrets_updated delta can never carry a value, and no-plaintext-logging-in-secrets refuses a direct log/slog import from internal/secrets/services/fetch.go so the rewrap path logs only through an injected, body-free *slog.Logger. Downstream consumers of the Secret Store are the plexd Node secret-fetch client that calls the /v1 read endpoint, the Signed Event Bus node_secrets_updated channel a plexd Node's per-Node SSE stream consumes, the Hook Catalog & Integrity Gating context (when a hook needs an external credential), the plexctl secret CLI subtree (deferred to its own story), and Observability Ingestion (the per-read metrics). The OpenBao Credential Broker (internal/provisioning/credentials) is the upstream WRITE side that issues Project-scoped secrets onto the KV v2 paths this context reads. The ubiquitous language, the rewrap hot-path, the rate-limit policy, and the audit contract are anchored on the context's doc.go and the Secret Store README section. |
internal/observability | Observability Ingest | Observability bounded context, an implemented context in its own Go module split into five package families. Ingest (internal/observability/ingest + .../ingest/services) is the node-facing write-only front door: plexd pushes batched metrics / logs / audit telemetry through three NSK-authenticated /v1/nodes/{id} endpoints, and the front door admits, validates, byte-weight-quota-gates each batch (per-Node and per-Domain token buckets), and buffers it onto a per-signal JetStream stream (obs.<signal>.<domain> subjects). It emits the lag / bytes / records / rejects metrics and carries no node_id label by cardinality design; the bounded-context reference at ../contexts/observability/ingest.md carries the ubiquitous language, the quota model, and the lag-metrics surface. Routing (internal/observability/routing + .../routing/services) is the backend egress half: it drains the three per-Domain JetStream buffer streams through five durable consumers and fans each buffered batch to its downstream sink — Grafana Mimir (metrics, Prometheus remote-write), Grafana Loki (logs + audit, push API), and an audit SIEM (verbatim NDJSON) — transforming each batch into the destination wire shape, classifying export outcomes as retryable (transport error / 429 / 5xx, redelivered under a deterministic capped backoff) or terminal (non-429 4xx, dropped), and emitting the six observability_routing metrics. Routing is a pure backend consumer: it mounts no /v1 route and runs under an observability-routing reconcile probe plus a JetStream consume loop; the bounded-context reference at ../contexts/observability/routing.md carries the three routes and their wire formats, the five-consumer topology, the error classification, and the routing metrics. Alerts (internal/observability/alerts + .../alerts/services + .../alerts/repo) is the per-Domain alert-rule CRUD aggregate — it stores the (signal, comparator, threshold, severity, enabled) rule definitions an operator authors and serves them over the five /v1/domains/{domainId}/alert-rules operations, but never evaluates or fires them (evaluation lives downstream in Grafana Mimir / Grafana); the bounded-context reference at ../contexts/observability/alerts.md carries the aggregate shape, the validation rules, the name-uniqueness invariant, and the ReBAC / audit posture. Incidents (internal/observability/incidents + .../incidents/services + .../incidents/repo) is the per-Domain operational-incident aggregate — an open -> resolved lifecycle (no acknowledged state) with an append-only TimelineEvent trail, served over the five /v1/domains/{domainId}/incidents operations; the bounded-context reference at ../contexts/observability/incidents.md carries the lifecycle state machine, the append-only and single-resolve invariants, and the status / resolved-at XOR. Query (internal/observability/query) is the read-only bounded PromQL/Mimir and LogQL/Loki query proxy behind the two /v1/domains/{domainId}/{metrics,logs}/query operations — it enforces hard query bounds, classifies upstream errors (502 / 504, no body leak), and injects the X-Scope-OrgID tenant server-side, never forwarding a client tenant header; the bounded-context reference at ../contexts/observability/query.md carries the bounds, the upstream-error classification, and the tenant-injection security property. Node-side audit ingest stays structurally separate from the platform audit chain at internal/audit — the depguard rule no-node-audit-on-platform-chain refuses any import of internal/audit/repo from internal/observability/**, backed at go test time by tests/workspace/depguard_audit_isolation_test.go. |
internal/audit | Platform Audit Log | Tamper-evident operator-action log, hash-chained/signed, correlated with approval workflows. The bounded context is split into five sub-packages, each with a frozen consumption seam: chain/ owns the canonical-byte encoder (PXA1 magic), the sha256(prev_hash ‖ sha256(canonical_bytes)) chain, and the offline verifier that backs VerifyAuditChain; repo/ owns the per-Domain pg_advisory_xact_lock + INSERT … RETURNING append path against plexsphere.audit_log_entry, audit_log_chain_head, audit_subject_pii, and audit_tamper_quarantine; archiver/ pull-drains archived_at IS NULL rows to audit/<domain_id>/<seq:020d>.json.zst on the per-Domain object-store bucket and stamps archive_etag for idempotent re-runs; erasure/ purges audit_subject_pii and emits an audit.erase-identity self-audit row, idempotent on subject_pseudonym; query/ mints HMAC-signed cursors over (domain_id, seq) and serves the ReBAC-gated ListAuditEntries / GetAuditEntry reads. The four /v1/domains/{domainId}/audit/... endpoints (ListAuditEntries, GetAuditEntry, VerifyAuditChain, EraseIdentityFromAudit) are gated by the existing domain#auditor ReBAC relation — no schema change. The substrate's downstream consumers are the audit-emitting make e2e flow, the Dashboard Foundation typed query API, the Phase-1 Audit Log viewer, the plexctl audit CLI, the Approval Workflow correlation IDs, the Credential Broker, the Access Orchestrator, the Secret Store, the Observability Ingestion / node-side audit pipeline, and the Security Posture work (pepper rotation, sovereign-archive export, tenant-side erasure UX). The bounded-context reference at ../contexts/audit/index.md carries the ubiquitous language, the Postgres + object-store split, the canonical-byte encoder pin, the hash-chain state machine, the per-Domain residency rule, the retention matrix, the right-to-erasure flow, the threat model, and the explicit "what this context is NOT" boundary against node-side audit ingest into Loki and correlation-id issuance. The architectural callout lives in ../architecture/overview.md under the "Forensic substrate — Platform Audit Log" heading. Node-side audit ingest stays structurally separate from the platform chain: the depguard rule no-node-audit-on-platform-chain refuses any import of internal/audit/repo from internal/observability/**, backed at go test time by tests/workspace/depguard_audit_isolation_test.go. |
internal/signing | Signing Service | Custodian of the Ed25519 signing key under the deployment-scoping rules (per-Domain in SaaS and self-hosted multi-Domain installs; optionally a shared platform-wide key in self-hosted single-Domain installs). Consumers reach the signer through the access.SignerClient typed client in internal/access/signer_client.go, which wraps the gRPC surface defined in api/proto/signing/v1/signing.proto; the domain ports (signing.KeyProvider, signing.KeyRepo, signing.EventPublisher) in internal/signing/ports.go stay internal to the custodian process. Consumers cover Signed Event Bus envelope signing, Dashboard signing-key rotation UI, and Access Orchestrator JWT issuance. |
internal/labels | Label Registry | Label Definitions, Assignments, and the selector engine used by policy targeting, bulk actions, and observability scopes. The labels.SelectorPort seam is the sole public consumption surface for non-transport packages — its consumers are the Policy Engine, the Provisioning Broker, the Action Orchestrator, and Observability. Direct imports of internal/labels/* from any other bounded context are denied by the no-cross-context-imports depguard rule; see tests/workspace/labels_depguard_alignment_test.go. |
internal/transport/http | North-facing interfaces | HTTP adapter layer: REST handlers, middleware, serialisation. Composes application services from every bounded context; the only place where cross-context wiring is allowed. |
internal/platform | Subsystems (shared platform primitives) | Cross-cutting infrastructure shared by every bounded context: logging, telemetry, HTTP client factory (httpx), server lifecycle, health checks, persistence wrappers (db [pgx/v5 + goose + sqlc], messaging [NATS JetStream], secretstore [OpenBao], blobstore [S3-compatible]). Contains no domain logic. Bounded contexts reach every persistence backend through this layer — direct driver imports are denied by the no-direct-persistence-from-contexts depguard rule. |
internal/platform/capacity | Subsystems (shared platform primitives) | Capacity-and-scale collector: a platform background worker that samples each Domain's usage on a fixed interval (default 15s, overridable via PLEXSPHERE_CAPACITY_SAMPLE_INTERVAL) across the six catalogued dimensions (nodes, sse_fanout, secret_reads, mediated_sessions, observability_ingest, action_executions), computes a used / target ratio per dimension (level sources via COUNT(*) … GROUP BY domain_id, counter sources as a rate-from-delta fed by the SSE / Secret Store / Observability Ingest usage-observer seams), and fires an edge-triggered 80%-threshold crossing through a CrossingRecorder port the composition root binds onto the Platform Audit Log. The read side is the GET /v1/domains/{domainId}/capacity snapshot under internal/transport/http/v1/capacity; the operator runbook is ../operations/capacity.md and the HTTP wire shape ../reference/api/capacity.md. Holds no domain logic — shared platform infrastructure, not a bounded context. |
api/openapi/ | API Versioning & Compatibility | OpenAPI 3.1 spec — the source of truth for /v1. Every generated artefact in the next row derives from this single file. Edit here first; see openapi.md for the workflow. |
pkg/openapi/v1/ | API Versioning & Compatibility | Generated Go client (client/) and shared types (types/) produced by oapi-codegen from api/openapi/plexsphere-v1.yaml. Consumed by internal callers, plexd, and third-party Go consumers; never edited by hand. |
Additional README subsystems (Signing Service, Signed Event Bus, Cloud Registry, Credential Broker, Blueprint Catalog, Artifact Registry, Capability & Hook Registry, Domains/Projects/Identity Federation) are covered by combinations of the modules above (commonly
internal/identity,internal/provisioning[including itscredentialsandcloudcredentialssub-contexts], andinternal/platform). Introducing a new bounded-context directory underinternal/requires adding a row to this table and a corresponding entry tono-cross-context-imports.
Command-line binaries
Every cmd/<binary>/ directory under the repository root compiles to exactly one binary and owns its own go.mod. The binaries are the composition roots that wire bounded-context application services together — they hold no domain logic of their own. Each row below points at the binary's primary documentation surface; cross-cutting build instructions live in ./toolchain.md and ./ci.md.
| Binary | Purpose | Primary doc |
|---|---|---|
cmd/plexsphere | The plexsphere control-plane server. Mounts the /v1 HTTP surface, composes every bounded-context application service, and owns the production factory wiring (Postgres pool, NATS JetStream, OpenBao, S3, signer client). | ../../cmd/plexsphere/ |
cmd/plexsphere-signer | The offline/air-gapped signer server, the second control-plane runtime binary. Produces detached signatures over exported mesh configuration bundles so operators can approve rollouts without granting the online control plane access to signing keys. Defaults to --addr :8081 so it can run alongside cmd/plexsphere. | ../../cmd/plexsphere-signer/ |
cmd/plexsphere-bootstrap | One-shot cluster-bootstrap Job binary. Seeds the initial tenancy Domain aggregates, their per-Domain OIDC IdPBinding aggregates, and the domain-admin ReBAC tuples from a YAML manifest. Safe to re-run — slug and binding conflicts are reported and skipped. | ../../cmd/plexsphere-bootstrap/ |
cmd/plexsphere-backup | Operator- and CI-facing disaster-recovery utility CLI. Prints the typed backup catalogue and the ordered restore sequence, streams artefacts to and from the S3-compatible object store, takes and restores OpenBao Raft snapshots, and verifies that the newest backup artefact is fresher than a given max-age. Holds no domain logic — it composes the internal/platform blobstore and secretstore seams behind injected interfaces. | ../../cmd/plexsphere-backup/ |
cmd/plexctl | The operator-facing CLI for the plexsphere /v1 surface — ships login, whoami, bootstrap-tokens, groups, labels, audit, identity-tokens, domain-idp, domain, project, identity, ReBAC schema commands, support bundle, and shell completions. Only the resource lifecycle commands remain deferred and exit 64. | ../reference/cli/plexctl.md |
cmd/messaging-publisher | A small env- and flag-driven utility that publishes a fixed count of messages to a NATS JetStream subject. Used to exercise and demonstrate the internal/platform/messaging publish path. | ../../cmd/messaging-publisher/ |
cmd/messaging-replayer | A small env- and flag-driven utility that replays messages from a NATS JetStream stream starting at a given sequence, with optional message-count and idle-timeout limits. Exercises the internal/platform/messaging replay path. | ../../cmd/messaging-replayer/ |
cmd/sse-stub-plexd | A minimal plexd-shaped SSE consumer used in integration and e2e suites to exercise the Signed Event Bus contract end-to-end without bringing up a full Node. | ../../cmd/sse-stub-plexd/ |
cmd/identity-e2e-demo | A non-production e2e test fixture for the identity bounded context. Env-driven dispatcher that drives the identity repository, issuer, and invitation adapters (IdPBinding registration, JIT sign-in, scope switching, token lifecycle, group seeding, invitation lifecycle) so chainsaw and Playwright suites observe the production wiring. | ../../cmd/identity-e2e-demo/ |
cmd/tenancy-e2e-demo | A non-production e2e test fixture for the identity tenancy bounded context. Drives the tenancy repository adapters to create a Domain → Project → Resource → Node tree and perform same-Domain and rejected cross-Domain Resource moves for the chainsaw suites. | ../../cmd/tenancy-e2e-demo/ |
cmd/signer-e2e-demo | A non-production e2e test fixture for the plexsphere-signer bounded context. Env-driven dispatcher for the security chainsaw suites — sign-and-verify over mTLS, NetworkPolicy probe-denied checks, and a Go-only mint-certs PKI helper. | ../../cmd/signer-e2e-demo/ |
Adding a new cmd/<binary>/ directory requires a row in this table and, where the binary is operator-facing, a Diátaxis reference page under docs/cli/<binary>.md.
The no-cross-context-imports rule
.golangci.yml declares a named depguard rule, no-cross-context-imports, that denies every bounded-context import path from every other bounded context. The only shared imports allowed across contexts are internal/platform/* and internal/transport/*. Contexts that grew their own subpackages (identity, provisioning, mesh's sse/reachability/peers, policy, artifacts, audit, labels) each carry a dedicated no-cross-context-imports-<ctx> variant that omits the context's own import path so its subpackages compose freely while every other context stays denied.
These rules — and the companion no-direct-persistence-from-contexts and no-default-http-client rules — are enforced per module. golangci-lint run ./... is module-scoped, so running it once from the repo root lints only the root module; because each internal/<ctx> is its own Go module, the boundary rules only bite when golangci-lint runs inside each module. make lint does this through make depguard-all, which iterates go list -m and runs golangci-lint --enable-only depguard ./... in every workspace module. The example invocation below (golangci-lint run ./internal/mesh/...) is the per-module form; the sweep runs the equivalent for every module so a carve-out that drifts out of sync turns the gate red. The coverage wiring is pinned by tests/workspace/depguard_all_modules_coverage_test.go.
Don't — direct cross-context import
go
// File: internal/mesh/service.go
package mesh
// This is rejected by depguard (no-cross-context-imports).
// internal/mesh must not reach into internal/identity directly —
// the Identity aggregate owns its own invariants and must not be
// manipulated from outside its context.
import (
"github.com/plexsphere/plexsphere/internal/identity/foo"
)
func (s *Service) attachNode(ctx context.Context, tokenID string) error {
return foo.ConsumeBootstrapToken(ctx, tokenID) // forbidden
}Running golangci-lint run ./internal/mesh/... on the above emits:
text
internal/mesh/service.go:5:5: cross-context import forbidden
(REQ-006, PX-0001); route through internal/platform or
internal/transport (depguard)Do — publish a domain event, let the other context subscribe
go
// File: internal/mesh/events.go
package mesh
// NodeEnrolled is a domain event on the mesh side; the identity
// context subscribes to it via the event bus provided by
// internal/platform. No direct package import crosses the boundary.
type NodeEnrolled struct {
NodeID string
MeshIP string
EnrolledAt time.Time
}go
// File: internal/identity/subscriber.go
package identity
import (
"github.com/plexsphere/plexsphere/internal/platform/events"
)
func (s *Service) OnNodeEnrolled(ctx context.Context, e events.Envelope) error {
// React to the mesh-side event without importing internal/mesh.
return s.markTokenConsumed(ctx, e.CorrelationID)
}Do — use shared platform primitives
go
// File: internal/policy/service.go
package policy
// internal/platform/* is explicitly exempt from the rule: every
// bounded context uses it for logging, telemetry, and HTTP clients.
import (
"github.com/plexsphere/plexsphere/internal/platform/telemetry"
"github.com/plexsphere/plexsphere/internal/platform/httpx"
)
func (s *Service) fetch(ctx context.Context, path string) ([]byte, error) {
ctx, span := telemetry.StartSpan(ctx, "policy.fetch")
defer span.End()
return s.client.Get(ctx, path) // s.client built from httpx.NewClient(...)
}The no-direct-persistence-from-contexts rule
.golangci.yml declares a second named depguard rule, no-direct-persistence-from-contexts, that denies direct imports of persistence drivers (github.com/jackc/pgx/*, github.com/nats-io/nats.go, github.com/nats-io/nats.go/jetstream, github.com/openbao/openbao/api/*, github.com/aws/aws-sdk-go-v2/*) from any bounded-context directory under internal/. Contexts must reach every persistence backend through the wrappers in internal/platform/db, internal/platform/messaging, internal/platform/secretstore, and internal/platform/blobstore . The platform wrappers themselves are the only files allowed to import the driver packages.
This keeps the driver choice behind a seam — swapping Postgres clients or upgrading the NATS API becomes a single-file change in the platform wrapper instead of a fan-out across every context. It also gives every connection a uniform readiness probe surface (health.ProbeFunc), credential redaction, and telemetry span propagation.
Do — call the platform wrapper
go
// File: internal/identity/repository.go
package identity
import (
"github.com/plexsphere/plexsphere/internal/platform/db"
)
func (r *Repository) Load(ctx context.Context, id string) (*Identity, error) {
row := r.pool.QueryRow(ctx, "SELECT ... FROM identities WHERE id = $1", id)
// ...
}The context receives a *pgxpool.Pool that was constructed via db.NewPool; the driver import stays in internal/platform/db.
Do — compose services at the HTTP boundary
go
// File: internal/transport/http/router.go
package http
// The transport layer is the one place allowed to wire application
// services from multiple bounded contexts together, because it owns
// no domain logic — it only adapts REST requests to application
// commands and queries.
import (
"github.com/plexsphere/plexsphere/internal/access"
"github.com/plexsphere/plexsphere/internal/identity"
"github.com/plexsphere/plexsphere/internal/mesh"
)
func NewRouter(id *identity.Service, m *mesh.Service, ac *access.Service) *Router {
// ...
}Where new code goes
Follow this decision path when adding a new file, type, or package:
- Pick the bounded context whose ubiquitous language the concept belongs to. If the concept is about bootstrap tokens, Identities, or node registration, it belongs in
internal/identity. If it is about peers, PSKs, or endpoint freshness, it belongs ininternal/mesh. When in doubt, re-read the matching README subsystem: the heading is the name of the context, and the bullet points are its invariants. - Keep domain logic out of
internal/platform/andinternal/transport/. These two directories are intentionally thin.platformholds cross-cutting infrastructure that has no business rules of its own (telemetry, HTTP client factories, health checks, server lifecycle).transportis a translation layer — REST handlers that turn HTTP into application commands and back. Business rules that creep into either directory are a design smell: they belong inside a bounded context. - Cross-context integration goes via domain events or the
internal/transport/httpboundary. If context A needs to react to something in context B, publish a domain event from B and subscribe from A (using the bus ininternal/platform/events). If an external caller needs to coordinate both contexts in a single request, that coordination happens in an HTTP handler underinternal/transport/http, which calls both application services explicitly. - Cross-cutting-only concerns go under
internal/platform/*. Logging, metrics, tracing, HTTP client construction, health probes, graceful-shutdown helpers, standard middleware, and similar framework-shaped code live here. If a new concern is genuinely shared by multiple bounded contexts and has no domain meaning, add a subpackage underinternal/platform/. If there is any domain meaning at all, it belongs in a bounded context instead.
When in doubt, prefer the bounded context. It is easier to promote code into internal/platform/ later than to disentangle domain logic that has leaked into a shared package.
CLI reference pages
The plexctl CLI documents its surface area one page per surface under the Reference quadrant, grouped by the surface itself rather than by delivery phase:
docs/reference/cli/plexctl/<family>.mdis the single-surface contract for oneplexctlcommand family — its leaf commands, the shared exit-code taxonomy, the chainsaw fixture that drives it, and the bounded context it reaches. The family index at../reference/cli/plexctl.mdlinks every per-family page.
There is no separate per-phase index page. The cross-cutting "what did this delivery phase add" narrative lives in the changelog, while these single-surface pages remain the exhaustive contract for one view or one family.
Ownership
Every bounded context in the map above is mirrored by an entry in .github/CODEOWNERS. The drift gate in tests/workspace/codeowners_test.go (TestCodeownersCoversEveryBoundedContext) asserts the two stay in lockstep: a new context cannot land without a review contact, and the removal of a context must remove its owner line at the same time. When a team handle replaces the placeholder maintainer (see README §Governance), update both files in the same commit.
Cross-references
- Architecture Overview — planes, control flow, and trust model at a higher level.
- Toolchain pins — where Go, golangci-lint, and other build-time dependencies are pinned and how to move them.
- OpenAPI contract and code generation — how the
/v1spec inapi/openapi/drives the generated Go artefact tree listed in the map above. - Identity & Tenancy context reference — ERD, ubiquitous-language glossary, invariant-to-test matrix, and allocator rules for the Domain, Project, Resource, and Node aggregates under
internal/identity/tenancy. - Identity & IdP Bindings context reference — onboarding runbook, claim-mapping rules, acr/amr step-up examples, API-token format/rotation/revocation contract, and invariant-to-test matrix for the IdPBinding, User, UserSession, ServiceIdentity, and APIToken aggregates under
internal/identity/{idp,users,services,tokens,authn}. ../../README.md— the canonical subsystem specification; every bounded-context row above links into it.../../.golangci.yml— source of truth for theno-cross-context-importsandno-default-http-clientrules.../../.github/CODEOWNERS— ownership map that mirrors the bounded-context table above.../../CLAUDE.md— repository-wide engineering principles, with DDD as the primary ranking.