Running systems have formal APIs: REST endpoints, gRPC services, GraphQL schemas. But development operations -- "run tests", "start dependencies", "build", "deploy", "scaffold a service" -- have always been implicit. They live in Makefiles, shell scripts, READMEs, and tribal knowledge.
Codefly formalizes development operations as typed gRPC APIs.
RuntimeService.Load() → Load the service agent
RuntimeService.Init() → Compile, configure, allocate ports
RuntimeService.Start() → Run the service process
RuntimeService.Test() → Execute tests
RuntimeService.Stop() → Graceful shutdown
RuntimeService.Destroy() → Full cleanup
BuilderService.Load() → Load the build agent
BuilderService.Create() → Scaffold a new service
BuilderService.Build() → Compile/package
BuilderService.Deploy() → Ship to target environment
Same API surface regardless of whether the service is Go, Python, Rust, or a managed database. The agent plugin handles language-specific implementation. This makes development operations composable, discoverable, and automatable by both humans and AI agents.
Development operations are local-first plugin contracts. Runtime Lint,
Build (native compile/typecheck), and Test, plus Builder Sync, Audit,
SBOM, and deployable Build, are invoked unchanged by local CLI commands,
Mind/editor integrations, and CI. CI may select affected resources, expand
dependencies, schedule, cache, enforce policy, and persist a report; it must
not introduce a parallel execution service or own language/schema commands.
Core owns shared runtime and agent contracts. The CLI-owned coordinate document and deployment YAML are an explicit exception: their existing wire format and strict parser live with the deployment consumer, not under Core's proto tree. See runtime configuration and deployment ownership.
Protobuf is the default source of truth for Codefly contracts. This applies not only to gRPC methods, but also to public reports, manifests exchanged between components, persisted evidence, events, policy envelopes, and any state that crosses a package, process, language, or repository boundary.
-
define the schema under
core/protobefore writing consumer code; -
generate bindings with Codefly itself:
codefly generate proto --proto ./proto --output ./generated --local -
import the generated binding from
core/generated; do not mirror it with a handwritten Go/TypeScript/Python DTO; -
evolve contracts additively with stable field numbers and an explicit schema or protocol version where the artifact has an independent lifecycle;
-
dispatch schema lint, compatibility, generation, and drift through the owning Codefly schema plugin, then run the affected generated consumers. Provider workflows and language plugins must not invoke
buf,protoc, or generator binaries directly.
Handwritten structs are reserved for private, in-process implementation state that has no serialization or compatibility meaning. An exception at a public boundary must be explicit and documented; convenience alone is not an exception.
The local operator container-stop receipt has a separate, narrowly scoped Docker evidence exception, including its fixed shape, fresh-comparison lifecycle and refusal on incompatible evidence. It does not extend the process-group registry exception to other state.
Per-consumer module update evidence and verdicts use codefly.update.v0.
See module updates for the offline comparison, authenticated
release artifacts, usage completeness requirements, and delivery ownership.
A module package carries the descriptor set and OpenAPI document of every
interface endpoint under contracts/api/, catalogued in
contracts/api/catalog.codefly.json (codefly/module-api-contracts/v1).
Consumers generate clients from the package, never from a checkout of the
producing repository. The module interface block is the authoritative export
boundary: only its endpoints are exported and graphed across modules.
A module that declares one therefore states each export once. An entry's
visibility is the reach that endpoint carries across module lines — public,
or internal (the default): reachable by whatever composes the module, naming
nobody — and an endpoint no entry names does not cross them at all, however the
service declares it; visibility: on the endpoint governs the module's own
inside. Neither names a consumer: which modules reach an endpoint is derived
from their declared dependencies (resources.Workspace.DeriveAllowModules), an authored
allow-modules is refused, and the derived list is reachability for mesh
policy, never per-call authorization (that is the Work Context) — see
network-model.md. The boundary
is applied where a module hands out its services, so every reader observes the
same value: the static passes, the module graph, the run and deploy resolution,
and the protos the module publishes. A service loaded by directory rather than
through its module — an agent loading the service it serves — applies it with
resources.ApplyModuleInterface.
Where an endpoint lives is its location and whether it is addressed from
outside the workspace is its exposure, neither of which the boundary touches:
an endpoint declaring location: external is exported or kept like any other
and keeps resolving from DNS either way, and visibility: public allocates no
address — only exposure: public does.
A published client pairs the generated bindings with a small facade that binds
them to a gateway seam and hides the transport envelope. Those facades are
themselves generated, not hand-written: the proto companion ships
protoc-gen-codefly-facade-{python,go,ts}, and proto.GenerateClient drives
them from either a descriptor set (what a module package carries) or proto
sources. Per proto package they emit one client class per service and a
<module> entry point (accounts(gw) / accounts.New(gw)), each RPC taking
the request message and returning the response message. The hand-written
facades still living in consumer repos (saas-sdk-go, saas-sdk-python,
@codefly-dev/saas-sdk) are to be regenerated through these plugins, not
maintained by hand.
Plugin boundaries use codefly.base.v0.Failure as the universal structured
error detail. gRPC failures carry it inside standard google.rpc.Status;
domain responses may embed the same message when partial output or failure
evidence must still be returned. Callers must branch on FailureCode, not parse
human-readable messages.
The contract separates transport behavior (google.rpc.Code) from the stable
Codefly reason, and includes retryability, operation/resource identity, field
violations, normalized diagnostics, bounded process evidence, typed Any
details, retry delay, and causal failures. Operation-specific enums remain only
for meaningful domain outcomes such as audit CLEAN/FINDINGS or SBOM
COMPLETE; repeated string error channels are removed and their protobuf
names/numbers reserved rather than maintained as a second failure model.
All Builder and Runtime lifecycle statuses expose an optional Failure field.
Core's shared response wrappers populate it automatically, and
failures.Wrap lets plugin implementations classify native errors once while
preserving the original Go cause. When Core turns an error status into a host
error, it retains the typed detail; CLI, Mind, editor, and CI consumers can
therefore apply the same retry, policy, and remediation behavior.
The Code service has one RPC, Execute, and one failure location,
CodeResponse.failure; operation-specific result messages never carry their
own error channel. Every Tooling RPC response carries Failure directly
because each response is its own RPC envelope. Unsuccessful build, test, and
lint outcomes must include a failure even when they also retain structured
diagnostics, counts, or process output.
This is a design rule, not a feature, and it is written down because it has been violated three times in one contract — each time by a careful change, each time found by a consumer rather than by a test.
The shape: a check requires an input the caller must supply, and the only source the caller can actually reach is the thing being checked. The check then compares a value against itself. It passes for every caller, including the one it exists to refuse, and it type-checks, so nothing here can detect it.
The three instances, so the shape is recognisable rather than abstract:
| input | the independent source it needs | what the only reachable source was |
|---|---|---|
| the attested execution digest a mint once took (removed) | the orchestrator's record of what is RUNNING | the issuer's record of what is APPROVED — so approved is compared against approved, and the superseded pod the field exists to refuse passes |
| the same field, in the conformance kit (removed) | a pod | Seals.Seal, read and attested straight back, because a fixture has no pod |
| the envelope of a renderer's activation request (removed with the authority document) | the platform's ceiling | the document under check — so containment tested a document's own approved build against a list built from that build |
The one place this was recognised first, and therefore the mitigation: the authority document's activation took the build as an explicit parameter rather than reading it out of the document, and said why — "reading the build out of the document that approves it would make the question answer itself". The same sentence applies to all three rows above. It was written before any of them.
So the test for a new required input is not "is it checked". It is: name the caller, and name where that caller gets this value from. If the answer is "from the thing being validated", the input is wrong, or the entrypoint is wrong, or the check belongs to a different party — which is how the envelope ended up back with the host and the renderer ended up taking a revision number. If no caller can answer, say so in the doc rather than requiring it in the indicative. The first two rows were resolved by moving the check to the party that holds both sources: core no longer seals a build into a capability. All three then ended when build approval left the runtime path altogether: a module manifest carries no digest, and "only approved images run" is deploy-time admission (module-manifest.md).
The pattern was named by a client consumer after the third instance, which is itself the finding: three single bugs looked like three bugs, and only someone integrating against all of them saw one.
Workspace → Module → Service → Endpoint
│ │ │ │
│ │ │ └── API type (gRPC, REST, HTTP, TCP)
│ │ └── Managed by an Agent (go-grpc, postgres, etc.)
│ └── Logical grouping of services
└── Root container (one per project)
Every resource is a YAML file:
# workspace.codefly.yaml — root of the project
name: my-platform
layout: modules # or "flat"
# module.codefly.yaml — inside each module directory; decoded STRICTLY: a key
# the model does not declare fails the load, naming it, and so do a merge key,
# an explicit null, a repeated key, a fractional number and a key that is not
# a name — those five by resources.ErrInvalidManifestWireForm. Two keys an earlier
# layout wrote and nothing read, `project` and `domain`, are refused with their
# remedy (delete them) — a manifest carrying them loaded before core v0.13.0
kind: module # a `composed-module` here is a composition descriptor, read by composition.LoadDescriptor
name: backend
vendored: # path prefixes this module vendors rather than authors (client kits);
- web/src/clients # declared, strictly loaded; core itself no longer counts a build's size
# service.codefly.yaml — inside each service directory
name: api-server
agent: go-grpc
version: 0.1.0
endpoints:
- name: grpc
api: grpc
visibility: internal # reachable by whatever composes this module; names nobody
service-dependencies:
- name: store/postgresThe CLI-agent contract versions protocol compatibility and required capabilities independently of the Core library release.
Core and the CLI must not carry concrete-agent compatibility tables or infer compatibility from release pins. They check the running process's advertised protocol and operation requirements over the generic boundary before work. An unchanged protocol requires no fleet rebuild or repinning when Core or the CLI releases. Agent names and artifact versions are opaque selection metadata, not compatibility policy. Agent-specific selection, configuration, framework paths and cleanup stay with the owning plugin; see the ownership and review rules in the compatibility contract.
An agent is a plugin binary that implements the development API for a specific service type. When the CLI needs to start a Go gRPC service, it spawns the go-grpc agent process, connects over gRPC, and calls Runtime.Load() → Init() → Start().
CLI ──gRPC──→ Agent Process ──→ Service Process
│
├── Load(): read service.codefly.yaml, parse settings
├── Init(): compile, resolve deps, allocate ports
├── Start(): exec the service binary
├── Test(): run `go test ./...`
└── Stop()/Destroy(): kill process, cleanup
Agents are downloaded binaries managed by the agent manager (agents/manager/). Each agent declares its identity in agent.codefly.yaml.
Key agents:
go-grpc— Go services with gRPC endpointspostgres— Managed Postgres (Docker for local, cloud for prod)external-temporal— Temporal workflow enginenextjs— Next.js frontend services
Connection strings change across environments. localhost:5432 locally, host.docker.internal:5432 from Docker, db.prod.internal:5432 in k8s.
Codefly solves this with NetworkMapping: each endpoint gets multiple NetworkInstances, one per access type.
| Access Type | Hostname | Use Case |
|---|---|---|
native |
localhost |
Local dev, tools like pgAdmin |
container |
host.docker.internal |
Docker-to-host communication |
public |
configurable | Production/public endpoints |
Port allocation is deterministic: SHA256(workspace + module + service + endpoint + api [+ mode]) → port. The same service always gets the same port. This means pgAdmin configs, DataGrip connections, and browser bookmarks survive restarts.
mode := network.PortModeFor(runtimeContext) // "" keeps the legacy hash
port := network.ToNamedPort(ctx, "myworkspace", "backend", "api", "grpc", "grpc", mode)
// Always returns the same port for these inputsmode is the seventh argument and this example omitted it — a call that does
not compile, in a document that read as current. It is not cosmetic: the mode
segment is appended to the hashed string only when non-empty, which is what
keeps host-bound (native and nix) ports on the legacy value while shifting
container-published ports onto a disjoint one. A reader copying the
six-argument form concluded there is no such distinction. Take it from
network.PortModeFor(runtimeContext) rather than writing the string.
For tests and ephemeral environments, RuntimeManager.WithTemporaryPorts() uses random free ports instead.
In-cluster (Kubernetes) ports are a separate, pure allocation:
network.DeployedEndpointPorts(ctx, module, service, endpoints). The
conventional endpoint of each API (named after it, or the only one of it) takes
standards.Port(api), earlier APIs in standards.APIS() winning a shared port;
every other endpoint takes ToNamedPort with an empty workspace, so its port
depends on the name the module is composed under. The CLI's GitOps render
calls it, and any other tool that must agree with the render calls the same
function rather than declaring ports by hand.
Endpoints reach a process as environment variables, one per endpoint:
| Carrier | Value | Use |
|---|---|---|
CODEFLY__ENDPOINT__<MODULE>__<SERVICE>__<NAME>__<API> |
a dependency's address; for the service's own endpoint, the address it listens on (a Kubernetes render localizes it to localhost:<port>) |
dial a dependency; bind a listener |
CODEFLY__SELF_ENDPOINT__<MODULE>__<SERVICE>__<NAME>__<API> |
the service's own endpoint as its peers reach it | advertise yourself — e.g. register an upstream with a gateway |
Both keys share one normalization (upper case, - → _), and the self
carrier deliberately does not start with CODEFLY__ENDPOINT__, so a reader
collecting endpoint carriers never mistakes one for the other. Values have the
NetworkInstance.Address shape: host:port, or a scheme-qualified URL for
HTTP-based APIs (http://backend.<namespace>.svc.cluster.local:8080).
Where the self value comes from:
- Kubernetes render (
DeployKustomizewithOwnEndpoints): the container-access instance of the service's own mapping — the in-cluster Service DNS name. Never the public instance, never localhost. - Local run: the agent calls
RuntimeWrapper.AddSelfEndpoints(ctx)in Init, which selects the instance under the runtime's ownNetworkAccess()(host-native for native/nix,host.docker.internalfor a container).
Read it with resources.FindSelfNetworkInstanceInEnvironmentVariables. A
missing self carrier is an error, never a fallback to the listen address:
advertising localhost to a peer is the defect this carrier exists to prevent.
Both lookups compare the key they build against the entries exactly, and say
absence in a typed way: resources.ErrEndpointCarrierAbsent. A carrier that is
present and does not hold an address is a different error, never that one, and
no instance travels with either — so a consumer entitled to fall back on
absence (a local run resolving a native address) tests the sentinel rather than
any error, and never falls back on a value it could not read.
Exact comparison means a carrier whose identity is wrong — the API of
another declaration, a near-miss spelling — sits under no key a lookup builds
and reads as absent. So a consumer judges the carriers it was handed before it
reads any, with resources.ValidateEndpointCarriers(envs, declared): every
CODEFLY__ENDPOINT__* entry must be, exactly, the canonical key of one
declared endpoint — spelled as above, with the API the declaration serves —
delivered once and with a non-empty value, or it is refused by name. The
prefix is matched without regard to case, so codefly__endpoint__… is judged
as the non-canonical carrier it is rather than passed over as "not a carrier"
and then read as absent. The key is parsed
only to say what it fell short of: …__RECORDS__REST__GRPC for a declaration
that serves rest is refused naming that declaration and its carrier, and a
trailing underscore is refused as the key it is; no typo is enumerated, and
every refusal is reported. An SDK calls it at each path that ingests an
environment, before publishing a snapshot, and a fallback consumes a validated
absence. The self and route carriers are other contracts, read by their own
keys, and are not judged by it.
- Service A produces configuration (e.g., postgres connection string)
- Service B declares dependency on A in
service.codefly.yaml - CLI resolves the dependency graph and injects configs as environment variables
- Pattern:
CODEFLY__SERVICE_CONFIGURATION__MODULE__SERVICE__NAME__KEY=value - Secrets get a separate prefix:
CODEFLY__SERVICE_SECRET_CONFIGURATION__...
Configuration values carry a Secret flag. The system never logs secret values and uses the separate env var prefix for them.
resources.IsSensitiveKey is the name-based safety net for a value whose
Secret flag was forgotten: diagnostics redact it, restricted Kubernetes
renders refuse it inline, and the CLI promotes it to a secret reference. Its
markers (PASSWORD, SECRET, TOKEN, CREDENTIAL, API_KEY, …) match as
substrings. AUTH is matched per word instead: any word containing AUTH
is sensitive (AUTH, BASIC_AUTH, OAUTH, AUTHKEY, AUTHORIZATION, …)
except a closed list of public words — AUTHOR(S), AUTHORITY/AUTHORITIES,
AUTHORIZE — so AUTHORITY_ISSUER, AUTHORITY_AUDIENCE and
IDENTITY_AUTHORIZE_URL stay public, while a credential noun beside a public
word (CERTIFICATE_AUTHORITY_KEY) keeps the key sensitive. A new public word
is added with a test, never inferred.
An environment variable NAME is classified by
resources.IsSensitiveEnvironmentVariable, which reads a codefly carrier by
what it carries rather than by its whole spelling: a secret carrier
(CODEFLY__*_SECRET_CONFIGURATION__…) is always sensitive; an endpoint or
route carrier is an address; a service or workspace configuration carrier is
classified by its configuration KEY alone — the module, service and
configuration-group segments are structure, so a public group named
datasource-oauth does not make its PROVIDERS or CLIENT_ID a credential,
while a CLIENT_SECRET key in that group, and any value that rides a secret
carrier, stays sensitive. The OUTERMOST carrier is read first, by its own
grammar: a configuration key that happens to spell an address carrier
(…__SETTINGS__CODEFLY__ENDPOINT__APP__SVC__REST__ACCESS_TOKEN) is still a
key, classified whole, so a token cannot hide behind an address spelling; an
address carrier is one at the start of the name or behind an operator prefix
that is a plain prefix (no double underscore). resources.ConfigurationCarrierKey
is the one parser of the key segment, for every reader that scans a carrier
for credential words — the rendered-manifest validator and the CLI's render
scan included — so no reader runs a word list over the whole name. The parser
is unambiguous because no structural segment spells a double underscore: a
module name, a service name and a configuration file name that carry
consecutive separators (public--auth, policy__auth.env) are refused where
they are loaded, since both spellings become …__AUTH and shift the first
segment of the key into the structure or the group into the key. A renderer
refusing a public value by its group name is a defect here, never a reason to
rename the group.
The same code runs in four contexts:
| Context | Network Access | Description |
|---|---|---|
native |
native |
Process runs directly on host |
nix |
native |
Process runs in Nix shell on host |
container |
container |
Process runs inside Docker |
free |
varies | No assumptions (testing) |
kubernetes |
container |
Workload rendered for Kubernetes (deployed only) |
CODEFLY__RUNTIME_CONTEXT environment variable controls which context is active. NetworkAccessFromRuntimeContext() maps context to the correct network access type.
A requested context is a preference, not an assertion every service of a run
can honour. Each plugin declares which execution backends it can use on this
host in AgentInformation.supported_backends, and
services.PlanRuntimeContexts reconciles the request against that declaration
before anything boots: a service that can only run as a container runs as one
under native, and a service left with no usable backend is named up front
rather than failing deep in its own Init. Each decision says whose fact caused
it — the plugin's declaration or the host's own report that a backend is
unusable — and flags the one substitution that is more than a preference, giving
up a container image whose engine version wrote the service's state. See
the runtime contexts a plugin can run in.
kubernetes is written by the builder into every Kubernetes render's
ConfigMap (all output profiles), so a deployed service can tell it is deployed
without comparing environment names. It is not a run context: it is absent from
RuntimeContexts(), so ValidateRuntimeContext refuses it for a local run.
For configuration matching it folds onto container, as nix folds onto
native. The values a process can observe are therefore native, nix,
container, free (local runs, unchanged) and kubernetes (renders).
Each agent defines what "ready" means for its service. The orchestrator does not guess -- it calls the agent and the agent performs real health checks.
For a postgres agent, ready means: a real SQL query succeeds. For a gRPC service, ready means: the gRPC health check endpoint responds. For Temporal, ready means: the Temporal health RPC returns OK.
Never use raw TCP connects for readiness. A port being open does not mean the service is ready to accept requests.
Services declare dependencies in service.codefly.yaml. The architecture/ package computes a DAG across services and modules. The CLI starts services in dependency order: infrastructure first (postgres, temporal), then application services.
workspace.codefly.yaml
├── module: backend/
│ ├── api-server (depends on: store/postgres)
│ └── worker (depends on: store/postgres, temporal)
└── module: store/
└── postgres
Start order: postgres → api-server, worker (parallel where possible).
A dependency can declare a kind saying which stage it constrains — a build
input, a consumed runtime endpoint, a one-shot completion prerequisite, a schema
contribution or an external capability. See dependency-kinds.md.
A dependency can also require an interface — a named, versioned contract a module implements — instead of naming a service; the workspace binds it to the one provider in scope. See interfaces.md.
Composition namespaces expose generic cache, build and runtime roots. Generators
receive the build root as CODEFLY_COMPOSITION_BUILD and namespace.buildDir in
the composition input. Each agent or generator owns its subdirectories; Core
does not choose framework build paths or create framework-specific directories.
The namespace, module and locked composition identity isolate these roots.
Projection activation retains immutable revisions for existing readers. On
Darwin it also retains superseded .projection-link-* symlinks: unlinking a
symlink during pathname resolution can fail a concurrent read with EINVAL.
These links and revisions have the same lifetime as the composition namespace;
do not sweep them while its consumers are running.
workspace.codefly.yaml's modules: answers where a module named X comes from —
source, version, checkout location. It does not say which modules take part in a
given run: that is derived by Workspace.ResolveModuleClosure, which starts from
the modules being run and follows the dependencies services already declare.
For a run, callers also pass WithModuleConfigurationReferences with the same
invocation-selected endpoint references used to build the service graph. A
service's declared workspace configuration groups pull in their pinned producer
modules, including producers reached transitively through newly loaded modules.
This happens before narrowing the workspace; adding reference edges afterwards
cannot recover a producer whose module was already discarded. Root-only groups
do not expand the run, and configuration references do not expand a build.
Module participation grants no endpoint access: configuration preflight still
rejects invalid, absent, ambiguous, private or excluded producers, and the
service graph still applies exclusions and records endpoint readiness.
The closure is per-Stage, because a dependency only pulls a module in for the
stages its kind constrains: building a service needs its codegen inputs and not
the endpoints it will later consume, and running it needs the reverse. A
kind: external dependency constrains no stage and so pulls in nothing — unless
the composition root binds the pair with an ${endpoint:…} reference, which makes
it a run-stage edge (dependency-kinds.md).
A pinned module nothing reaches is simply not in the closure, so the pin set is a
superset of any one stage. A module a declaration reaches but the pin set does not
cover is an error naming the declaring service, rather than a run that comes up
with no endpoints. ModuleClosure.ValidateServiceDependencies applies the
workspace-wide visibility rules to exactly that set — and to exactly the
dependencies that put them there, so an edge is judged by the stage that
traverses it rather than by whichever modules happen to be loaded beside it. It
shares its implementation with Workspace.ValidateServiceDependencies, which
has no stage to scope to and so judges every declared edge.
The same scoping rule governs the graph in architecture. A view restricted
with ServiceDependencies.ForStage judges the edges that constrain that stage
and an unrestricted one judges them all, and Closure.Verify takes the phase
the plan is for — the closure walk follows every declared edge, so judging them
all would make a build input's verdict depend on whether the walk happened to
reach its consumer.
An owner persists a detached process's birth (PID, BootID, StartID) from
runners/base.InspectProcess. Before signaling, it re-inspects and compares that
birth, then supplies the current full ProcessIdentity to SignalProcess.
Process group and executable describe execution state that can change within a
birth. Core binds
kernel delivery to that birth (Linux pidfd, Darwin audit token); a missing or
replaced process returns ErrProcessNotFound. A PID-only record is refused.
The consumer still owns its shutdown policy and process lifecycle. Registered
process groups continue to use TrackedProcessGroup.Signal for group delivery.
Inspection failure is not proof of absence. On Linux, /proc/PID/exe can
disappear after the thread-group leader exits while other threads still run.
Core rechecks process stat before classifying a missing executable as an absent
process. If stat remains present but the full identity is unavailable, inspection
returns an ordinary error; consumers must preserve that uncertainty. The Linux
regression uses a real pthread worker after leader exit and requires cc on
the test host; it also verifies absence after the worker exits and is reaped.