ADR 0099: a public pkg/server builds the server, and components are replaced through options
Status: Accepted
Date: 2026-10-29
Accepted: 2026-10-29
Relates: ADR 0028 (one codebase, one license), ADR 0050 (MCP server), ADR 0068 (patch safety bar), ADR 0096 (task attempt usage events), ADR 0100 (admission hook), #2178 (pkg/mcp, closed by #2239)
Issues: #2176
Context
Every server package lives under internal/, and the server is wired in
cmd/dexaflow-server: about 4,400 lines in 22 production files, and 64
test files that test package main. An operator who wants the server with one
component replaced (a log sink for their storage, an authenticator for their
identity system, a policy on what a task may run) has to copy that wiring or
patch the source, and then carry the patch through every release.
The seams already exist and are injected: logs.Sink, xcom.Backend,
auth.Authenticator, api.TrustedIssuer, api.UIServer,
executor.Executor. None of them can be imported from another module,
because Go refuses an import of an internal/ package from outside the module.
Two public packages already exist and took different routes. pkg/client is
generated and imports nothing internal. pkg/dagspec and pkg/mcp (#2178)
import internal/ packages and re-export a few of their identifiers
(pkg/dagspec re-exports the ErrLimitExceeded variable, pkg/mcp aliases types). An
alias works for a caller, but the documentation of the type stays on the
internal package, which pkg.go.dev does not show, and any change to the
internal type changes the public one without anyone deciding it.
examples/ is not a place for Go programs: it is embedded into every binary
(//go:embed all:examples) as the DAG samples the IDE materializes.
Decision
pkg/serverruns the server. It exposesRun(ctx context.Context, opts ...Option) error. Without options it reads the configuration the way the binary does (the config file, then the environment) and behaves exactly asdexaflow-serverdoes today.WithConfigFilenames the config file to read in place of the oneDEXAFLOW_CONFIGnames; theDEXAFLOW_*environment still applies on top. (Amended 2026-11-02: the ADR first plannedWithConfigwith an already loaded configuration, which would have made the whole configuration type, about 300 keys, public API under the rule in 5.)Runis called once per process. It does whatmaindoes today before and aroundrun: the legacy environment mirroring (envcompat.MirrorProcess), the default logger, and shutdown, which it takes from the caller’sctxinstead of installing its own signal handler. The package exportsErrDatabaseReplacedandExitCode(err)so a caller keeps the binary’s exit codes, and the version comes fromdebug.ReadBuildInfowhen the build sets no ldflags.The wiring moves to
internal/serverapp, and the binary callspkg/server.cmd/dexaflow-server/main.gokeeps its flags and its exit codes and does nothing else. The move is its own change, with no edit beyond the package clause and the imports, so its review is a rename check and its tests move with it. The binary is the first caller of the public API, so an option that breaks is caught by the shipped build and its suites, not by an outside user.The first options are the seams that exist.
WithLogSink,WithXComBackend,WithAuthenticator,WithTrustedIssuer(repeatable),WithUIServer, andWithAdmissionfrom ADR 0100.WithUsageSinkwaits for ADR 0096’s events to merge, so the usage events change and this one do not edit the same wiring at once. An option replaces the component the configuration would have built; the configuration keys of the replaced component are ignored, and boot logs which component came from an option.WithAuthenticatorreplaces only the API authenticator (api.Dependencies.Authenticator). Agent and session tokens stay on the JWT authenticator, and theauth.jwtkeys keep applying.WithTrustedIssuertakes one issuer until multi-issuer support lands:Dependencies.TrustedIssueris a single value today, and bearer mode needs the concrete verifier, so making it repeatable is its own change.- Optional interfaces a seam type-asserts are part of its contract:
logs.MarkerSinkandlogs.LineWriterfor the log sink (andlogs.Event, which they name),xcom.BatchBackendfor the XCom backend, whoseListtakes a Redis-style glob.
The executor and the stores are not options:
executor.Requestchanges in most patches and the stores are the schema, and freezing either would stop that.Contract types are defined in public packages. The interfaces the options take, and every type their methods name (
logs.Ref,logs.LogWriter,xcom.Entry,auth.User,auth.Credentials,issuer.Identity, and those of ADR 0100), move topkg/packages named for them (pkg/logsink,pkg/xcomstore,pkg/authn,pkg/admission). The internal packages keep their names as aliases of the public ones, so no internal caller changes. Implementations stay internal.pkg/mcpandpkg/dagspecfollow the same rule when one of their aliases next needs a change.One compatibility rule for
pkg/. A patch release never breaks an exported identifier ofpkg/. A minor may, after the identifier was markedDeprecated:in the previous minor, and with aChangedorRemovedchangelog entry that says what to use instead. This mirrors how/api/v2is treated. Adding an option, a method to a struct, or a field to a struct is not a break; adding a method to an exported interface is, so an interface that may grow takes an optional second interface instead. ADR 0068 §2 item 4, which lists the public surfaces a patch may only add to, is amended to includepkg/.A custom authenticator cannot grant what only the server grants. The server clears
DeployAudience,ScopedandScopeson aUserthat a custom authenticator returns before using it: those fields are set only by the trusted issuer bearer and by scoped client tokens (ADR 0067).Examples are
Examplefunctions. A program that builds a server with a custom log sink is anExamplefunction ofpkg/server. It needs a database, so it has no// Output:line: it compiles with the package’s tests and is shown on pkg.go.dev. Examples of the contract packages that need no server run with an// Output:line, aspkg/mcp’sExampleAddTooldoes.examples/stays the DAG samples.
Consequences
- An operator replaces a component with a small Go program that imports
pkg/server, and upgrades by bumping the module version, with the compatibility rule telling them when to expect work. - The move in step 2 touches every file of
cmd/dexaflow-server, so it lands when no open change edits that directory: after ADR 0096’s usage events stack (#2215, #2216, #2218, #2219), which editsleader.go. - Each option is one more way to start the server; boot logging which components came from options keeps a support conversation short.
- The public contract types become reviewed API: a change to
logs.Refis now a change topkg/logsink.Ref, and the compatibility rule applies.
Alternatives considered
- Alias internal types from
pkg/server(whatpkg/mcpdoes). Smaller now, but the public documentation would be empty and an internal refactor would silently change the public API. - Plugins loaded at run time (Go
plugin, or a gRPC sidecar per seam). Go plugins need the exact toolchain and dependency versions of the host binary; sidecars add a network hop and a deployment to each seam. A small program built againstpkg/servergives the same result with neither. - Leave the wiring in
cmd/and export aBuildfunction from there. Amainpackage cannot be imported.
Plan
- This ADR, accepted.
- Move the wiring to
internal/serverapp(rename only), after the usage events stack merges. pkg/serverwithRun,WithConfigFileand the binary calling it.- The contract packages and the options, one PR per seam, each with its
Exampleand the GoDoc of every export. - The compatibility rule in
AGENTS.md, on the Go packages page and in ADR 0068 §2 item 4. - A CI API break check (
apidifforgorelease) onpkg/against the previous tag.