From 1494faeffdb4b44d9a3c75198155e319e3abb5cf Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:25:15 -0400 Subject: [PATCH 1/3] docs: state the agent contract for this repo (#30) An agent starting here had no statement of what this runtime owns, what it does not, or how to behave when something does not work. The failure that produced the fleet standard was measured against these runtimes: an agent hand-wrote the environment they exist to resolve, and a sibling runtime skipped registration silently. Commands are derived from the workflow and were run in this worktree. Refs obin-ai/handbook#68 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8332021 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,143 @@ +# Working in solution-runtime-go + +This repository owns the generic Go runtime for **codefly solutions** — modules +deployed independently of the host they extend, with no build-time coupling. It +owns what every solution needs identically: configuration resolution through the +codefly SDK, self-registration with the host frontend and the gateway (the +credential exchanges and their heartbeats), CORS, Module Federation asset +serving, the capability handshake, the manifest, and the gateway client a +handler uses to read composed modules on the viewer's behalf. + +It owns none of the counterparts it talks to. The registration surfaces and +their admission rules belong to the host (`codefly-dev/module-saas-starter`) and +the gateway; endpoint, port and secret resolution to `codefly-dev/sdk-go`; +workspace and manifest types to `codefly-dev/core`; running a composition and +provisioning its secrets to `codefly-dev/cli`. Everything here is a library — +one Go package at the repository root, consumed by solution repos as +`github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag. + +## Boundaries + +- Read [README.md](README.md) first. It is the contract this runtime offers its + consumers and it is kept current with the code, down to the env-override + table. A behaviour change the README describes is unfinished until the README + describes the new one. +- The exported surface (`New`, `Handle`, `HandleRequest`, `Serve`, `Unary`, + `Gateway`, `Scope`, `ClientError`, `GatewayError`) is pinned at a tag by repos + you cannot see from here. A change that breaks a consumer carries a `!` in the + commit type and says so in the PR body. +- Configuration is resolved in one place, `loadConfig`, and checked in one + place, `validate()`. New configuration follows both: resolved through the SDK, + with an explicit env override, refused loudly at boot when unresolved. Do not + read the environment from the interior. +- Validate at the boundary — boot, and the headers a request arrives with — not + between internal callers. `validate()` is the model: each refusal names the + variable or the provisioning path that fixes it. +- A registration that cannot attest which publisher it speaks for is not a + registration. There is no fallback to the shared cluster-internal token and no + downgrade path when an exchange answers `404`. +- Tests live beside the code in the same package. Every behaviour change brings + one, and the counterpart it exercises is an `httptest` server, never a real + host. + +## How to behave when something does not work + +Fleet standard, governed by the handbook's agent-context track +([obin-ai/handbook#68](https://github.com/obin-ai/handbook/issues/68)). These +are not style preferences; each is a rule an agent broke at real cost, and this +repository is the runtime that cost was measured against. + +1. **A gap in the tooling is a bug in the tooling** — never a reason to reach + around it. Not as a "workaround", not "just this once", not "until the verb + lands". The failure that produced this standard was exactly that: unable to + run three solutions under one `codefly run`, an agent hand-wrote the + environment these runtimes exist to resolve — internal token, endpoint + carriers, credential paths, pinned ports. It worked, and a sibling runtime + with a missing credential skipped registration *silently*, so a solution + booted, served, and was simply absent. The real cause was a missing CLI + capability (codefly-dev/cli#717). If the CLI cannot express a composition, + that is a CLI bug; if the SDK cannot resolve a value, that is an SDK bug. + Neither is a reason to add a default here. The env overrides in the README + exist for an operator with a deployment the resolver cannot see — they are + not a way to fill a gap in resolution. +2. **Never hack. Always provide the best fix, even when it spans repos.** + Almost nothing that breaks here is owned here. Resolving the host by service + role rather than by module name needed a change in `codefly-dev/core` + (core#382); loading a composed module's workspace path needed another + (core#365). Both were fixed at the owner and arrived here as a `go.mod` pin — + which is why this module carries no `replace` directive. The right fix living + in someone else's repo is not a reason to work around it in this one. If it + genuinely cannot be fixed now, the deliverable is a precise issue against that + owner plus an explicitly labelled stopgap, never an unlabelled one. +3. **Classify every change that makes something work**, in the PR body: a *fix* + at the place that owns the behaviour, or a *hack*. A hack does not become a + fix by working, by being small, by being local, or by the real fix belonging + to core, the SDK or the host. +4. **Never hardcode what the system resolves.** This package resolves every + address, port and secret through the SDK; the single `localhost` literal in + it is the public URL built from the *resolved* port. Both token-exchange URLs + are derived from their register URLs by swapping the path suffix, which is + why an override that drops the documented suffix cannot be paired and is + refused at boot rather than guessed at. If you are typing an address, a port + or a credential, you are encoding something true only on your machine for the + next ten minutes. +5. **Diagnose, do not pattern-match.** "It started working when I set X" is not + a diagnosis — set X back and confirm it breaks. Do not trust an error message + before checking it: in the session above, *"the provisioned secret does not + match its digest"* actually meant a missing internal token. `validate()` + already carries that lesson — it separates "the SDK resolved nothing" from + "loading the injected environment failed first" from "your own override + cannot be paired", because the single generic message sent an operator to + inspect endpoint resolution over a variable they had broken themselves. Keep + new refusals that specific. +6. **Say what you did not verify.** Unverified is not the same as working, and + this suite makes that easy to get wrong: it is hermetic. Every host, gateway + and accounts counterpart is an `httptest` server on `127.0.0.1`, so a green + run proves the wire shapes and refusals this package produces — never that a + real host admits a registration, that accounts mints a Work Context, or that + a composed module accepts one. If you did not run it against a real + composition, the PR says so. + +## Build and test + +Derived from [`.github/workflows/go.yml`](.github/workflows/go.yml), the only +gate. It is four commands, and they are the whole local loop: + +```sh +go mod download && go mod verify +go test -race ./... -count=1 -timeout=5m +go vet ./... +test -z "$(gofmt -l ./*.go)" +``` + +Go comes from `go.mod` (1.27). Nothing else is needed: no Docker, no +credentials, no running composition. + +- The suite takes ~20s, most of it two heartbeat tests that wait on real wall + time (10s and 5s). That is not a hang — hence the 5m timeout. +- The formatting gate globs `./*.go`, so it covers the root package and only + the root package. A package added in a subdirectory is vetted and tested by + the commands above but not format-checked; widen the glob in the same PR. +- Dependency pins are how fixes from `core` and `sdk-go` reach consumers: + `go get github.com/codefly-dev/core@vX.Y.Z && go mod tidy`, then the four + commands above. `go mod tidy` is a no-op on a clean tree, so a diff it + produces is part of your change. Say in the commit *why* the pin moves and + reference the owning issue — "bump" is not a reason. +- Releases are plain `vX.Y.Z` tags on `main`; there is no release workflow. The + tag is what consumers pin, so it is cut from a commit whose gate is green. + +## Where the depth is + +Keep this file short; add depth to the file that owns the subject. + +- [README.md](README.md) — the consumer-facing contract: handler errors, the + full configuration and env-override table, self-registration and its + provisioning, consumed-module federation, and reading a + Work-Context-authenticated module. +- The package doc comment and the comments in [`solution.go`](solution.go) — + why a refusal, a backoff cap or a cache lifetime is what it is. Several + record a failure mode that is not obvious from the code; read the one next to + what you are changing before you change it. +- `.claude/skills/` — procedures an agent repeats, loaded only when relevant. + None exist yet, because nothing here repeats beyond the four commands above. + Add one instead of growing this file. From 5ae07587d01961c2d828f739cde0ad7315e6be63 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:14:05 -0400 Subject: [PATCH 2/3] docs: correct two claims the code does not support (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the new file found it asserting a safety property this runtime does not have: the consumed-API projection is read at serve time, never reaches validate(), and a malformed one degrades to a log line while the solution serves. A file stating otherwise would send an agent toward the silent absence the rules exist to prevent, so the exception is now stated as the dangerous path it is. The exported surface was also enumerated partially, omitting Manifest, Handler, RequestHandler and the exported constants — read as exhaustive, it would let a breaking change ship without a bang. Also drops an invented rationale for CI's test timeout and records the stale v0.0.2 Release object rather than implying tags are the only artifact. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8332021..0fc6793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ their admission rules belong to the host (`codefly-dev/module-saas-starter`) and the gateway; endpoint, port and secret resolution to `codefly-dev/sdk-go`; workspace and manifest types to `codefly-dev/core`; running a composition and provisioning its secrets to `codefly-dev/cli`. Everything here is a library — -one Go package at the repository root, consumed by solution repos as +one Go package at the repository root, consumed as the Go module `github.com/codefly-dev/solution-runtime-go` at a `vX.Y.Z` tag. ## Boundaries @@ -22,14 +22,21 @@ one Go package at the repository root, consumed by solution repos as consumers and it is kept current with the code, down to the env-override table. A behaviour change the README describes is unfinished until the README describes the new one. -- The exported surface (`New`, `Handle`, `HandleRequest`, `Serve`, `Unary`, - `Gateway`, `Scope`, `ClientError`, `GatewayError`) is pinned at a tag by repos - you cannot see from here. A change that breaks a consumer carries a `!` in the - commit type and says so in the PR body. -- Configuration is resolved in one place, `loadConfig`, and checked in one - place, `validate()`. New configuration follows both: resolved through the SDK, - with an explicit env override, refused loudly at boot when unresolved. Do not - read the environment from the interior. +- Everything `go doc -short .` prints is pinned at a tag by repos you cannot see + from here — the types a solution author writes (`Manifest`, `Handler`, + `RequestHandler`) as much as the calls it makes. Read that list before you + change a signature, a struct field or an exported constant; a change that + breaks a consumer carries a `!` in the commit type and says so in the PR body. +- Boot configuration is resolved in one place, `loadConfig`, and checked in one + place, `validate()`: resolved through the SDK, with an explicit env override, + refused loudly when unresolved. New configuration follows both rather than + reading the environment where it is used. +- The exception is the consumed-API projection, read at serve time in + `registerConsumedAPIs` and never seen by `validate()`. A malformed one is not + refused: the runtime logs that the federation is disabled and serves on, so + every consumed facade 404s at the gateway while the solution looks healthy. + Anything you add on that path inherits that property — it degrades silently + unless you make it loud, and a log line is the only signal there is. - Validate at the boundary — boot, and the headers a request arrives with — not between internal callers. `validate()` is the model: each refusal names the variable or the provisioning path that fixes it. @@ -114,7 +121,7 @@ Go comes from `go.mod` (1.27). Nothing else is needed: no Docker, no credentials, no running composition. - The suite takes ~20s, most of it two heartbeat tests that wait on real wall - time (10s and 5s). That is not a hang — hence the 5m timeout. + time (10s and 5s). That is not a hang. - The formatting gate globs `./*.go`, so it covers the root package and only the root package. A package added in a subdirectory is vetted and tested by the commands above but not format-checked; widen the glob in the same PR. @@ -123,8 +130,10 @@ credentials, no running composition. commands above. `go mod tidy` is a no-op on a clean tree, so a diff it produces is part of your change. Say in the commit *why* the pin moves and reference the owning issue — "bump" is not a reason. -- Releases are plain `vX.Y.Z` tags on `main`; there is no release workflow. The - tag is what consumers pin, so it is cut from a commit whose gate is green. +- Releases are plain `vX.Y.Z` tags on `main`; there is no release workflow, and + the one GitHub Release object (v0.0.2) was not kept up — it names no current + version and nothing reads it. The tag is what consumers pin, so it is cut + from a commit whose gate is green. ## Where the depth is From bd58d23fc453db5f7b705fb0f285ba4322bd0f86 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:42:17 -0400 Subject: [PATCH 3/3] test: pin the configuration boundary AGENTS.md states (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file now tells an agent that boot configuration is resolved in loadConfig and refused by validate(), with one serve-time exception whose failure is a log line rather than a refusal. That claim was prose with nothing holding it: a second interior read would make the file quietly wrong again, which is the defect the file was just corrected for. The guard is structural rather than a list of function names — an enumeration rots the same way the exported-surface list did. It asserts every environment read sits in loadConfig's call tree, where validate() governs the result, or is the documented exception; that the exception still reads the environment and is still unreachable from loadConfig; and that this file still names it. Proven red three ways before landing: an os.Getenv added to a serve-path handler, the exception's name removed from AGENTS.md, and the exception's own read replaced by a constant. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +- environment_boundary_test.go | 177 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 environment_boundary_test.go diff --git a/AGENTS.md b/AGENTS.md index 0fc6793..81c8fc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,9 @@ one Go package at the repository root, consumed as the Go module refused: the runtime logs that the federation is disabled and serves on, so every consumed facade 404s at the gateway while the solution looks healthy. Anything you add on that path inherits that property — it degrades silently - unless you make it loud, and a log line is the only signal there is. + unless you make it loud, and a log line is the only signal there is. Both + halves of this bullet are pinned by `environment_boundary_test.go`, so a + second exception fails the suite until this file describes it. - Validate at the boundary — boot, and the headers a request arrives with — not between internal callers. `validate()` is the model: each refusal names the variable or the provisioning path that fixes it. diff --git a/environment_boundary_test.go b/environment_boundary_test.go new file mode 100644 index 0000000..1d56323 --- /dev/null +++ b/environment_boundary_test.go @@ -0,0 +1,177 @@ +package solution + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// serveTimeEnvironmentRead is the one function that reads the environment +// outside boot resolution: the consumed-API projection is parsed when the +// server starts, never reaches validate(), and a malformed value disables the +// federation with a log line instead of refusing the boot. +const serveTimeEnvironmentRead = "registerConsumedAPIs" + +// AGENTS.md tells an agent that configuration is resolved in one place and +// refused at boot, naming the single serve-time exception and the silent +// failure it produces. Prose cannot notice a second exception appearing +// underneath it, and an agent-context file is followed literally, so the claim +// is pinned here: every environment read must sit in loadConfig's call tree, +// where validate() governs the result, or be the documented exception. +func TestEnvironmentIsReadOnlyWhereAgentsFileSaysItIs(t *testing.T) { + pkg := parsePackage(t) + boot := pkg.reachableFrom("loadConfig") + + var undocumented []string + for _, fn := range pkg.environmentReaders() { + if !boot[fn] && fn != serveTimeEnvironmentRead { + undocumented = append(undocumented, fn) + } + } + if len(undocumented) > 0 { + t.Errorf("these functions read the environment outside loadConfig's call tree: %s\n"+ + "AGENTS.md states that boot configuration is resolved in loadConfig and refused by validate(), with %q as the only exception. "+ + "Either resolve the value in loadConfig, or document the new exception in AGENTS.md — a value read after boot is never refused, it degrades while the solution serves.", + strings.Join(undocumented, ", "), serveTimeEnvironmentRead) + } +} + +// The documented exception has to keep being one. If it stops reading the +// environment, or starts being called during boot resolution, AGENTS.md +// describes a hazard that no longer exists where it says it does. +func TestServeTimeEnvironmentReadIsStillTheException(t *testing.T) { + pkg := parsePackage(t) + + reads := false + for _, fn := range pkg.environmentReaders() { + if fn == serveTimeEnvironmentRead { + reads = true + } + } + if !reads { + t.Errorf("%s no longer reads the environment, so AGENTS.md documents an exception that does not exist: remove it there and here", + serveTimeEnvironmentRead) + } + if pkg.reachableFrom("loadConfig")[serveTimeEnvironmentRead] { + t.Errorf("%s is now reachable from loadConfig, so its value is resolved at boot: AGENTS.md describes it as read at serve time and unseen by validate()", + serveTimeEnvironmentRead) + } + + agents, err := os.ReadFile("AGENTS.md") + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + if !strings.Contains(string(agents), serveTimeEnvironmentRead) { + t.Errorf("AGENTS.md does not name %q, the one configuration read that is not refused at boot", serveTimeEnvironmentRead) + } +} + +// pkgFuncs is the package's functions, the calls between them, and which of +// them read the process environment. +type pkgFuncs struct { + calls map[string][]string + reads map[string]bool +} + +func (p pkgFuncs) environmentReaders() []string { + names := make([]string, 0, len(p.reads)) + for name := range p.reads { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (p pkgFuncs) reachableFrom(root string) map[string]bool { + seen := map[string]bool{root: true} + queue := []string{root} + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, callee := range p.calls[current] { + if !seen[callee] { + seen[callee] = true + queue = append(queue, callee) + } + } + } + return seen +} + +func parsePackage(t *testing.T) pkgFuncs { + t.Helper() + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package directory: %v", err) + } + + fset := token.NewFileSet() + decls := map[string]*ast.FuncDecl{} + for _, entry := range entries { + name := entry.Name() + if filepath.Ext(name) != ".go" || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Body != nil { + decls[fn.Name.Name] = fn + } + } + } + + pkg := pkgFuncs{calls: map[string][]string{}, reads: map[string]bool{}} + for name, fn := range decls { + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if readsEnvironment(call) { + pkg.reads[name] = true + } + if callee, ok := calleeName(call); ok && decls[callee] != nil { + pkg.calls[name] = append(pkg.calls[name], callee) + } + return true + }) + } + if len(pkg.reads) == 0 { + t.Fatal("no environment reads found at all: this guard is inert") + } + return pkg +} + +func readsEnvironment(call *ast.CallExpr) bool { + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + pkg, ok := selector.X.(*ast.Ident) + if !ok || pkg.Name != "os" { + return false + } + return selector.Sel.Name == "Getenv" || selector.Sel.Name == "LookupEnv" || selector.Sel.Name == "Environ" +} + +// calleeName is the called function's own name, whether it is called plainly +// or as a method on a receiver. Names are unique enough in this package to +// identify the declaration. +func calleeName(call *ast.CallExpr) (string, bool) { + switch fn := call.Fun.(type) { + case *ast.Ident: + return fn.Name, true + case *ast.SelectorExpr: + return fn.Sel.Name, true + } + return "", false +}