Skip to content

Composing an API surface ​

An API surface is the set of /v1 operations one bounded context serves, together with the probes, workers, and dialled clients it needs. The cmd/plexsphere composition root composes the surfaces listed in ProductionSurfaces() through the surface registry: each surface implements a small contract, and Run walks the ordered list in fixed phases.

There is one way to add a surface, and this page describes it. It covers the contract, the phases, the registry order, the transport dependency struct, the opt-out rule, the backup surface as a worked example, and the gates that pin the result. The two checklists at the end are the short form. For where cmd/plexsphere sits in the repository, read the repository layout. For the test tiers the gates belong to, read the testing guide.

The surface contract ​

The contract lives in cmd/plexsphere/surface.go, in package main. It stays there because Runtime carries the unexported dbPools type, and no code outside the binary's own Go module uses the contract.

Runtime ​

Runtime is the set of shared dependencies Run hands every surface at build time. newRuntime builds it once from Config, and the registry passes it to Build by value. A surface publishes what a later surface needs through Bundle.Export, which writes onto the registry's working copy between two builds. Export is the only way one surface reaches another.

Every exported value is its own typed field rather than an entry in a type-keyed map, so a missing producer is a compile error while the registry is being written instead of a nil lookup at request time.

FieldTypeProducer
Logger*slog.LoggerThe boot logger of Run. It is never nil.
Pools*dbPoolsConfig.DBPools, the three-tier pool provider main.go assembles. Nil when no DSN is wired.
SpiceDB*authz.ClientConfig.SpiceDBClient, or the client Run dialled from SPICEDB_ENDPOINT when that field is nil. Nil without ReBAC wiring.
AuditSinkaudit.SinkThe audit surface's Export, seeded from Config.AuditSink. The surface writes it only when it built a sink, so a Config seed survives an opted-out surface.
AuthzCheckAuthzCheckFuncConfig.AuthzCheck. main.go sets it only when PLEXSPHERE_DSN is set.
Authorizer*authz.AuthorizerConfig.Authorizer, built over the SpiceDB client in main.go.
Pepperaudit.PepperConfig.AuditPepper, built once by buildAuditPepper in main.go.
Metricsprometheus.RegistererConfig.Metrics. main.go sets prometheus.DefaultRegisterer; a nil value leaves a surface on its own fallback.
PlexdDownloadPin*distribution.PinnedReleaseConfig.PlexdDownloadPin. The release the plexd-download surface serves; nil selects the pin embedded from /PLEXD_VERSION and the release's checksum file. A test sets a fixture pin.
Leader*leader.ElectorThe background-leader surface's Export, seeded from Config.BackgroundLeader.
CapacitySSEObserverfunc(domainID uuid.UUID, events int)The capacity surface's Export, seeded from Config.CapacitySSEObserver.
CapacitySecretReadsObserverfunc(domainID string, reads int)The capacity surface's Export, seeded from Config.CapacitySecretReadsObserver.
CapacityIngestObserverfunc(domainID string, wireBytes int)The capacity surface's Export, seeded from Config.CapacityIngestObserver.
SSEPublisher*sse.PublisherThe sse surface's Export, seeded from Config.SSEPublisher.
PolicyCompileServiceauthzsync.CompileServiceThe policy-compile surface's Export, seeded from Config.PolicyCompileService.
PeerAnchorerauthzsync.PeerAnchorerThe peers surface's Export, seeded from Config.PeerAnchorer. The surface writes it only while it is still nil.
NodeAuth*nodeAuthThe node-auth surface's Export.
Peers*PeersWiringThe peers surface's Export.
Labels*LabelsWiringThe labels surface's Export.
CredentialAssignments*caservices.CredentialAssignmentServiceThe credential-assignments surface's Export.
CloudAssignments*caaservices.CloudAssignmentServiceThe cloud-assignments surface's Export.
SinkEnablements*sinkenablementservices.SinkEnablementServiceThe sink-enablements surface's Export.
InvitationsConsumer*invconsumer.ConsumerThe invitations surface's Export.

A field is nil when its producer opted out. What a consumer does with that nil is its own decision: it either refuses to build with its own sentinel, or opts out with the producer. The registry-order comment of the consuming entry names which of the two it is.

cmd/plexsphere/surface_runtime_integration_test.go runs under make test-integration and pins the SpiceDB row. It boots Run with SPICEDB_ENDPOINT set and Config.SpiceDBClient nil, and a surface must receive the client Run dialled.

Surface ​

Surface is one API surface the registry composes (doc comments omitted):

go
type Surface interface {
    Name() string
    LoadConfig(getenv func(string) string) error
    Build(ctx context.Context, rt Runtime) (*Bundle, error)
}
  • Name identifies the surface in logs, errors, and the shutdown closer stack. It is non-empty and unique across the registry.
  • LoadConfig reads the surface config through getenv. An error fails the boot.
  • Build constructs the surface from its loaded config and the Runtime. A nil *Bundle with a nil error means the surface opted out.

Bundle ​

Bundle is what a built surface contributes to Run.

FieldTypeMeaning
Probes[]ProbeThe readiness probes the surface registers.
Workers[]worker.WorkerThe background loops the surface registers with the worker supervisor.
Mountfunc(deps *v1.Deps)Installs the surface handlers on the /v1 router dependencies. It is nil for a surface without HTTP operations.
MountRootfunc(r chi.Router)Installs the surface handlers on the root router, outside /v1. It is nil for a surface without root routes. Run calls every MountRoot before it mounts /v1, in every build, including the e2e_no_v1 build whose /v1 mount is a no-op.
Closefunc() errorReleases what the surface dialled. It is nil when the surface dialled nothing.
Exportfunc(rt *Runtime)Publishes the values this surface produces onto the registry's working Runtime, so the surfaces built after it read them. It is nil for a surface that produces nothing for another surface.

build calls Export once, after the bundle is built and before the next surface builds.

Probe ​

Probe is one readiness probe a surface wants registered.

FieldTypeMeaning
NamestringIdentifies the probe in registration errors. It must not be empty.
Registerfunc(ctx context.Context, registry *health.Registry) errorAdds the probe to the boot registry. It receives the registry itself, so a surface can call any bootstrap.Register*Probe helper or health.Registry.Register directly. It must not be nil.

NewSurface ​

NewSurface binds a typed config loader and a builder into a Surface, so a surface is two functions and one NewSurface call:

go
func NewSurface[C any](
    name string,
    load func(getenv func(string) string) (C, error),
    build func(ctx context.Context, rt Runtime, cfg C) (*Bundle, error),
) Surface

C is the config type of the surface. The returned value keeps the config load returned and hands it to build. LoadConfig returns a loader error unchanged, so the registry adds the surface name exactly once. Build before a successful LoadConfig returns ErrSurfaceNotLoaded.

NewSurface panics on an empty name, a nil load, or a nil build. A registry entry is a compile-time literal, so a bad one is a programmer error that fails the boot.

Sentinels ​

Callers branch on the four registry sentinels with errors.Is.

SentinelReturned byCondition
ErrSurfaceNameEmptynewSurfaceRegistryA surface has an empty Name.
ErrSurfaceNameDuplicatenewSurfaceRegistryAn earlier surface already uses the Name.
ErrSurfaceNotLoadedBuild of a NewSurface valueLoadConfig has not succeeded.
ErrSurfaceProbeInvalidregisterSurfaceProbesA probe has an empty Name or a nil Register.

The registry phases ​

cmd/plexsphere/registry.go walks the ordered surface list in phases. Every phase keeps the list order, so boot logs and errors read in registry order.

PhaseFunctionWhat it doesFailure
NewnewSurfaceRegistry(surfaces)Validates the names. A nil or empty slice yields a registry that loads, builds, and registers nothing.ErrSurfaceNameEmpty or ErrSurfaceNameDuplicate.
Load(*surfaceRegistry).load(getenv)Calls LoadConfig on every surface.The first error stops the walk and is returned wrapped with the surface name.
Build and export(*surfaceRegistry).build(ctx, rt, closers)Calls Build on every surface. A nil bundle logs api surface disabled and leaves the surface out. A bundle pushes its Close onto the closer stack as surface:<name> at once, logs api surface enabled, and then runs its Export against the working Runtime. Both log lines are INFO with a surface attribute. The second return value is that Runtime, returned on every path including the error path.The first error stops the walk and is returned wrapped with the surface name.
ProbesregisterSurfaceProbes(ctx, registry, built)Registers every probe of every built surface on the boot health registry.ErrSurfaceProbeInvalid, or the Register error (such as a probe name another surface already registered) wrapped with the surface and probe names.
WorkersregisterSurfaceWorkers(sup, built)Registers every worker of every built surface with the worker supervisor.The supervisor error wrapped with the surface and worker names. The worker sentinels (ErrEmptyName, ErrNilRun, ErrDuplicateName, ErrAlreadyStarted) still match with errors.Is.
MountmountSurfaces(&deps, built)Calls every non-nil Mount hook with the v1.Deps value.Returns no error. A nil deps is a composition-root defect and panics.
Close(*closerStack).closeAllCalls every pushed Close at shutdown, newest first.A failing Close logs shared resource close failed at WARN with a resource attribute, and the teardown continues.

Where the phases sit in the boot ​

Run in cmd/plexsphere/app.go carries the authoritative boot order in one DECISION block, and every other ordering comment in the composition root points at it. The sequence is:

  1. bootstrap.Bootstrap builds the boot logger, the health registry, and the router, and runs the migrations. The teardown defer and the closer stack are in place before anything is pushed onto them. Right after them Run registers the boot readiness probe, builds the rate-limit middleware, binds the API's address, and serves the boot gate, a bootGate over health.NewBootHandler, behind the rate-limit middleware and the correlation middleware outside it: /livez answers 200, /readyz answers 503 with boot failing, and every other request answers 503 until step 9.
  2. The audit-pepper closer and its /readyz probe.
  3. The SpiceDB dial, the readiness probe over *authz.Client.Ping, and the synchronous schema apply, which waits while SpiceDB is unreachable.
  4. The shared pool closer and the boot-latched db-primary and jetstream probes.
  5. The registry: newSurfaceRegistry, load, and build with newRuntime(cfg, boot.Logger, spicedbClient), then the probes phase and the workers phase. build hands back the built surfaces and the final Runtime, which carries every export.
  6. The deprecation table from the embedded OpenAPI document, the workers probe, and sup.Start.
  7. Run composes the base v1.Deps it owns (Registry, Build, Authn, CSRF, Deprecation) and hands it to mountV1 together with the built surfaces. mountV1 calls mountSurfaces(&deps, surfaces), checks the result with deps.Validate(), and then calls v1.Mount(r, deps). The transport dependency struct lists what Validate rejects.
  8. The NSK wrappers around boot.Router, built from the NodeAuth value on the runtime build returned. A nil value leaves the router unwrapped.
  9. The gate switches to the NSK-wrapped router, so the rate-limit and correlation middlewares around the gate front it, and Run writes the addr file.
  10. At shutdown the server-stop defer stops the server and releases the address. The teardown defer then cancels and joins the workers, and closes the shared resources, surface Close functions included, newest first.

Five constraints fix that order:

  • The address is bound right after bootstrap, so /livez answers for as long as the boot takes, and the boot gate serves no other request until it switches to the API routes.

  • Migrations run inside bootstrap.Bootstrap, so a missing table surfaces as a /readyz probe failure rather than a per-request 500.

  • Every probe a surface owns is registered before the supervisor starts, because a loop such as the reachability evaluator has to observe a fully wired ledger on its first tick.

  • Every worker a surface owns is registered before the supervisor starts, because worker.Supervisor.Register refuses a registration once Start ran.

  • The registry builds before the mount, because the mount consumes the built surfaces, and the NSK wrapper runs after the build, because it reads the runtime the build returns.

A failing phase returns an error that already names the surface, and Run returns it as is. The boot fails before the API serves its routes, and the server stops before Run returns, so a failed boot leaves nothing listening. The teardown defer is registered before anything is pushed onto the closer stack, so the bundles that built before the failure are closed on the way out.

The registry order ​

cmd/plexsphere/surfaces_prod.go holds the list. Its slice order is the build order, the export order, the probe-registration order, and the mount order at once. An entry whose position is load-bearing carries a comment naming the constraint that fixes it; the rest say that the position preserves the order the composition root already had.

Three kinds of constraint appear:

  • A data dependency the builder refuses to go without. The domains surface returns ErrDomainsAuditSinkRequired on a runtime whose AuditSink is nil, so it is listed after audit. A wrong order here fails the boot with the sentinel.
  • An export a later surface reads to decide whether it runs at all. The four NSK-gated surfaces opt out on a nil Runtime.NodeAuth, so a node-auth entry listed after them would disable all four with no error at all.
  • A v1.Deps field a Mount hook writes only while it is still nil. The state, node-auth, and reachability surfaces each carry a NodeRepo adapter and each writes it into a nil field only, so the list order alone decides which one supplies the port. The auth surface writes Authn and CSRF the same way, so a Config.AuthnMiddleware or Config.CSRFMiddleware a test stamps survives the mount.

TestProductionSurfaces_Order in cmd/plexsphere/surfaces_prod_test.go pins the list. It asserts every dependency as a position comparison against a named reason rather than against a frozen name list, so an insertion that violates no dependency stays green, and one that does names both surfaces and the value at stake. The surface count is pinned separately, because a list that lost most of its entries would satisfy every comparison.

The node-auth surface ​

cmd/plexsphere/node_auth_surface.go builds the per-Node NSK authentication once per boot: the resolver over plexsphere.node_secret_key, the middleware composed from it, the Node read port, and the audit sink the NSK-gated handlers record outcomes through. Its Export publishes the four on Runtime.NodeAuth. Its Mount hook writes NSKResolver and AuditSink onto v1.Deps, and NodeRepo when the state surface supplied none.

heartbeat, capabilities, integrity-violations, and observability-ingest read the wiring off the runtime and opt out when it is nil. Run reads it too: it wraps boot.Router with the shared per-Node middleware, then with the no-path-id authenticator for POST /v1/keys/rotate and the two passthrough authenticators the dual-credential reads need. The NSK path grammar and those three wrappers live in cmd/plexsphere/node_auth_routing.go.

An empty PLEXSPHERE_DSN opts the surface out. The four gated surfaces opt out with it, and the router stays unwrapped, so every /v1 request runs through the cookie and JWT chain alone.

TestProductionSurfaces_NodeAuthOrder pins that the four gated surfaces are registered after node-auth.

The transport dependency struct ​

v1.Deps is the struct v1.Mount hands to the /v1 handlers. It is an alias of handlers.Deps, declared in internal/transport/http/v1/handlers/deps.go. The struct lives in the handlers package because the port interfaces live there, and the alias keeps v1 the only package a composition root imports. It has one field per port or per-context bundle the handlers read, plus the Authn, Authz, CSRF, and Deprecation middleware fields that only v1.Mount reads.

A surface adds one field to the struct, and its dispatch file reads the field as h.deps.<Field>. A nil field keeps the operations on their 501 stub:

go
if h.deps.Backup == nil {
    backupNotProvisioned(w, r, "GetBackupCatalog")
    return
}

The field needs no setter and no forwarding code. v1.Mount passes the struct to handlers.New. A surface writes the field from its Mount hook on the *v1.Deps it receives.

Validation ​

Deps.Validate reports every same-origin port group that is only partly wired. Both fields of such a group are written by one surface Mount hook, so a set with one member and not the other is always a composition defect.

Set fieldRequired fieldOrigin surface
AuditQueryServiceAuditAuthorizeraudit (cmd/plexsphere/audit_factory_prod.go)
AuditChainStoreAuditAuthorizeraudit
AuditEraseServiceAuditAuthorizeraudit
AuditEraseServiceAuditPepperaudit
NodeListReaderNodeListAuthzCheckernodes-list (cmd/plexsphere/nodes_list_prod.go)
EventStreamSignatureVerifiersse (cmd/plexsphere/sse_factory_prod.go)
EndpointRecorderPeerLookuppeers (cmd/plexsphere/peers_factory_prod.go)

Each violated row yields one error that wraps v1.ErrDepsIncomplete and names both fields, such as EventStream is set but SignatureVerifier is nil. Validate joins them with errors.Join, so errors.Is(err, v1.ErrDepsIncomplete) matches the result. A nil result means the struct is mountable. An unwired group is valid and keeps its operations on the 501 stub. A new port group whose two fields come from one surface adds its row to the depsPortGroups table in deps.go.

Companions from different surfaces are not checked. RelationChecker comes from mesh, NSKResolver from node-auth, NodeRepo from whichever of state, node-auth, and reachability supplies it first, and PeerLookup also guards the rotate operations. A deployment without the mesh or the per-Node authentication is a supported posture, so those operations keep their fail-closed 501 gates and the boot goes on. Validate does not look inside bundle pointers such as Backup: a non-nil bundle with nil inner ports is that bundle's own concern.

v1.Mount does not call Validate, because tests mount partial sets on purpose to pin the 501 gates. mountV1 validates once, after mountSurfaces ran, so a field a surface Mount hook writes is checked too.

Where the deps are pinned ​

Every port reaches v1.Deps through a surface Mount hook, and the per-surface Test…Bundle_MountWritesDeps unit tests pin those writes. TestRun_IncompleteDeps_FailsBoot in cmd/plexsphere/mount_v1_test.go pins the other half at the Run level: a bundle that writes one member of a port group fails the boot with the Validate error, and the address file is never written.

Opting out ​

A surface opts out when Build returns a nil bundle and a nil error. The registry logs api surface disabled with the surface name and leaves the surface out of every later phase: no probe, no worker, no mount hook, no Export, and no Close. The operations of the surface keep dispatching to their 501 stub, and the not-provisioned problem code they answer stays unchanged. An opt-out is not an error. Only a non-nil error from Build fails the boot.

Two Config fields decide what the registry sees:

  • Config.Surfaces is the ordered list Run composes. Nil selects ProductionSurfaces(). A non-nil empty slice registers nothing, which is what a test passes to isolate a boot from every registered surface. Nil selects the production list because the alternative, main.go assigning the list, fails silently: one dropped assignment would leave every registered surface answering its stub.
  • Config.AuthzCheck reaches the surfaces as Runtime.AuthzCheck. Nil makes the ReBAC-gated surfaces opt out. main.go sets it only when PLEXSPHERE_DSN is set, behind the same DSN check the surface loaders apply.

Worked example: the backup surface ​

cmd/plexsphere/backup_surface.go serves GET /v1/platform/backup/catalog and GET /v1/platform/backup/restore-plan. Both reads are platform-scoped projections of the build-time catalog in internal/platform/backup, gated by the ReBAC manage check on the platform anchor. The surface has no database, no worker, and no environment knob.

Load the config ​

The backup surface has no knob, so its config type is empty and its loader never fails (doc comments omitted):

go
type backupSurfaceConfig struct{}

func loadBackupSurfaceConfig(_ func(string) string) (backupSurfaceConfig, error) {
    return backupSurfaceConfig{}, nil
}

A surface with knobs reads each one through getenv and returns an error for a malformed value, which fails the boot.

Build the bundle ​

buildBackupSurface ends in one of three ways (excerpt, the audit sink adapter and the transport dependencies elided):

go
if rt.AuthzCheck == nil {
    return nil, nil
}
cat := backupCatalog()
if err := cat.Validate(); err != nil {
    return nil, fmt.Errorf("validate backup catalog: %w", err)
}
return &Bundle{Mount: func(d *v1.Deps) { d.Backup = &deps }}, nil

These are the three outcomes a surface has:

  • A nil rt.AuthzCheck opts the surface out. Both reads keep answering 501 with the backup_not_provisioned problem code.
  • A catalog that fails validation returns an error. The registry wraps it with the surface name, and the boot fails.
  • Otherwise the bundle carries only a Mount hook, which sets v1.Deps.Backup. The surface dials nothing, so Close stays nil, and it produces nothing for another surface, so Export stays nil too.

backupCatalog is a package variable that points at platformbackup.Default. A unit test swaps it to inject an invalid catalog.

Register the surface ​

backupSurface binds the loader and the builder under the name backup with NewSurface. Each call returns a fresh value, so two registries never share a loaded config:

go
func backupSurface() Surface {
    return NewSurface("backup", loadBackupSurfaceConfig, buildBackupSurface)
}

ProductionSurfaces() lists that constructor. A new surface adds its constructor to the slice, after every surface whose exports it reads and before every surface that reads its own.

The unit tests in cmd/plexsphere/backup_surface_test.go cover the opt-out on a nil AuthzCheck, the Mount hook, the audit forward of a denial, the wrapped catalog validation error, and the empty config load. A new surface carries the same set for its own outcomes.

The builder behind a surface ​

The backup surface builds its bundle inline because it dials nothing. A surface backed by Postgres or another external dependency keeps four symbols in its own <ctx>_factory_prod.go:

  • production<Ctx>ConfigFromEnv(getenv) is the loader. It reads the surface knobs and returns the typed config, or an error that fails the boot.
  • BuildProduction<Ctx>Factory(cfg) validates the config once and returns the builder closure, or a nil closure when the config opts the surface out.
  • func (w *<Ctx>Wiring) bundle(rt Runtime) *Bundle turns the assembled graph into the Bundle: the probes, the workers, the Mount hook, the Close, and the Export.
  • func <ctx>Surface() Surface binds the loader and the build function with NewSurface under the surface name. This is the symbol ProductionSurfaces() lists.

The surface's build function stamps the shared values off the Runtime onto its config (cfg.Logger = rt.Logger, cfg.AuthzCheck = rt.AuthzCheck, cfg.Pepper = rt.Pepper), asks for the factory, invokes it, and returns w.bundle(rt). A nil factory or a nil wiring opts the surface out. Nothing reaches a builder off Config directly: the pool provider, the chained audit sink, the elector, the capacity observers, and the SSE publisher all come off rt, which is what makes registry order the only thing that decides what a builder sees.

The gates ​

Unit tier ​

cmd/plexsphere/surface_registry_gate_test.go is the compiled gate for the registered surfaces. Four tables carry it: surfaceOperations lists the operations each surface mounts, surfaceNotProvisionedCodes the problem code those operations answer while the surface is opted out, infraFreeSurfaces the surfaces that build in a boot with no external infrastructure, and alwaysMounted the operations the router serves outside every surface.

  • TestProductionSurfaces_EveryOperationDispatches fails when a registered surface has no surfaceOperations entry or when an entry names no registered surface. It then boots Run with an AuthzCheck that admits every check and fails for every listed operation of an infrastructure-free surface that still answers 501.
  • TestProductionSurfaces_OptOutKeepsNotProvisioned boots Run with an empty Config, so every gated surface opts out. Every listed operation must answer 501 with an application/problem+json body carrying the problem code from surfaceNotProvisionedCodes. A surface without an entry in that table fails.
  • TestSurfaceTableDrift_DetectsMissingAndStaleEntries pins the drift check itself: a table in step, a registered surface missing from the table, a stale entry, and both at once.
  • TestSurfaceOperations_CoverTheSpec parses the embedded /v1 OpenAPI document and requires every operation it declares to be listed under exactly one surface, or under alwaysMounted. An entry that matches no path of the document fails the same test.

cmd/plexsphere/surfaces_prod_test.go pins the list itself: TestProductionSurfaces_Order for the count, the uniqueness, the first entry, and every producer-before-consumer comparison, TestProductionSurfaces_NodeAuthOrder for the four NSK-gated surfaces, and TestProductionSurfaces_ReturnsFreshSurfaces for the per-call freshness a loaded config depends on.

The per-surface Test…Bundle_MountWritesDeps tests pin which v1.Deps fields each Mount hook writes.

Integration tier ​

cmd/plexsphere/surfaces_prod_postgres_integration_test.go boots Run with the production registry over a real Postgres and a real SpiceDB, assembled the way main.go assembles it. TestRun_ProductionSurfaces_DispatchOverPostgres proves what the unit tier cannot: that a listed surface builds. Every surface the boot keeps must log api surface enabled, and every operation of the embedded OpenAPI document must answer something other than 501. Three lists carve out what the boot does not cover, and each entry names the infrastructure it lacks or the port the composition root leaves unassigned. A baseline boot with no configuration runs first, so an operation the generated wrapper rejects before dispatch cannot pass the sweep while asserting nothing.

cmd/plexsphere/surfaces_prod_integration_test.go boots the same registry with an AuthzCheck over a real SpiceDB that carries the canonical schema and no relationship tuples, and TestRun_ProductionSurfaces_BackupDeniesOverRealSpiceDB requires the catalog read to answer 403.

End to end ​

The assert-surfaces-mounted step of the bootstrap-seed scenario in tests/e2e/bootstrap-seed/chainsaw-test.yaml runs against the deployed binary. An anonymous GET /v1/platform/backup/catalog must answer 401 rather than 501, and the container's boot log must put every registered surface in the state that manifest's environment configures, matched line by line against the api surface enabled and api surface disabled records.

Add a surface ​

  1. Declare the surface config type and its loader production<Ctx>ConfigFromEnv(getenv) in cmd/plexsphere/<ctx>_factory_prod.go. Return an error for a malformed value and the opt-out for an unset one.
  2. Declare <Ctx>Wiring with the ports and loops the surface produces, and BuildProduction<Ctx>Factory(cfg) returning the builder closure.
  3. Write func (w *<Ctx>Wiring) bundle(rt Runtime) *Bundle. Fill Probes, Workers, Mount, MountRoot, Close, and Export with what the surface actually contributes, and leave the rest nil. MountRoot is for the rare route outside the versioned /v1 contract, such as the /downloads/plexd path a booting machine fetches its agent from; such a route also needs a surfaceRootOperations entry and a PathPrefix on the API HTTPRoute of the chart and the dev stack.
  4. Write the build function that stamps the shared values off rt, invokes the factory, and returns w.bundle(rt) or the opt-out.
  5. Add func <ctx>Surface() Surface with the NewSurface call and the surface name.
  6. Add the field the handlers read to handlers.Deps in internal/transport/http/v1/handlers/deps.go, and write it from the Mount hook. Add a depsPortGroups row when the surface writes a same-origin port pair.
  7. Append the constructor to ProductionSurfaces() in cmd/plexsphere/surfaces_prod.go. Place it after every surface whose exports it reads, and add the comment naming that constraint.
  8. When the surface exports a value, add the typed field to Runtime in cmd/plexsphere/surface.go and its row to the table above.
  9. Add the surfaceOperations entry and, when the surface mounts an operation, the surfaceNotProvisionedCodes entry. Add the order constraint to productionSurfaceOrderConstraints and raise productionSurfaceCount in cmd/plexsphere/surfaces_prod_test.go.
  10. Write the unit tests for the loader, the opt-out, each build failure, and the Mount hook, plus the integration and e2e coverage the feature needs.
  11. Update this page and the context documentation.

Add an operation to a surface ​

  1. Add the path and the operationId to api/openapi/plexsphere-v1.yaml, then run make generate.
  2. Implement func (h *Handlers) <Op> in the matching internal/transport/http/v1/handlers/<ctx>_dispatch.go, gated on the Deps field the surface writes, with the not-provisioned fallthrough.
  3. Put the request decoding, the authz check, and the error mapping in internal/transport/http/v1/<ctx>/.
  4. Extend the surface's wiring and its Mount hook when the operation needs a port the surface does not carry yet.
  5. Add the operation to the surface's surfaceOperations entry in cmd/plexsphere/surface_registry_gate_test.go. TestSurfaceOperations_CoverTheSpec fails until you do.
  6. Update the per-tag spec gate, the API reference page, and the tests for the three tiers.