Skip to content

feat: the solution-go service agent — scaffold a solution backend, not a generic Go service - #2

Merged
antoinetoussaint-byte merged 6 commits into
mainfrom
feat/solution-go-agent
Oct 29, 2026
Merged

antoinetoussaint-byte merged 6 commits into
mainfrom
feat/solution-go-agent

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1.

The solution-go service agent: codefly add solution --agent solution-go now
scaffolds a solution backend — the solution-runtime-go pin, consumes.go
generated from the sibling manifest's api.consumes, the /health endpoint's
handler, and the ./Page / fe-remote npm package — instead of a plain Go
service that knows none of it.

It is a codefly:service agent and nothing else. services.Add drives Create,
so there is no core change and no new wire protocol, and no
codefly:solution executor: that option was weighed and declined in cli#1215.
The CLI half shipped as cli#1216 and needs no further change.

Classification: fix, not hack

Every change here is a fix. Nothing is worked around, no gate is weakened, and
no check was relaxed to make something pass.

Three further defects were found by running the real CLI (their own section
below) and all three are fixed at the cause: a README that reached for a value
its caller does not have, a replacement spelled in a field core cannot fill, and
a test that modelled an order the product does not run in. The last was fixed by
making the test state the truth, not by making the truth quieter.

The branch arrived with a build and vet that were clean and four failing
shape tests (the handover named one). Each was a real mismatch between a stated
rule and what the code did:

What was wrong Fix
rsbuild.config.ts.tmpl react was a bare key where every sibling share key was a quoted specifier, so the entry sealing the host's React instance read as absent spelled as the exact specifier it is
go.mod.tmpl named obin-ai/core-solutions in a comment, putting one consumer's private repository into every solution scaffolded for anyone states the pin floor (v0.0.12), which is the fact that matters
shape_test.go read a fenced YAML example in GETTING_STARTED.md as a declaration; nothing reads Markdown as a manifest, and that block is deliberately the manifest an author copies fenced blocks come out before matching; prose outside a fence and every line of a non-Markdown template still held to the rule
shape_test.go allowed only GETTING_STARTED to mention service-dependencies, which refused consumes_test.go — the template that reads the manifest in order to refuse an edge, i.e. this very rule enforced in the scaffolded solution's own suite both named, with what each says; an exemption that stops mentioning an edge now fails

The module-edge check was replaced rather than relaxed, and is now stronger.
It matched every - name: line in the file, so it read the example's
- name: grpc — an endpoint of the hop — as a module edge, and would have read
a real edge spelled as a flow mapping as nothing at all. The example is now
parsed and held to the host's door structurally, which is how
check_solution_mesh.py reads a real manifest and for the reason that script's
own docstring gives.

The two defects nothing was testing

Every test in the package read a template; none ran what the templates render.
So this adds the test that does — scaffold as codefly add solution scaffolds,
complete as the CLI completes, compile, and run the suite the tree ships with —
and it found two defects immediately. A solution that consumed anything was
born red, and could not boot.

  1. The runtime refuses a consumed module that declares no method
    (passthrough.go: consumed module %q declares no methods), and the
    scaffolded consumes.go declared one per api.consumes entry whether or not
    anyone had composed its methods. A solution scaffolded with --consumes
    could not render its interface document and could not start. The comment
    claiming an empty declaration means "nothing of it is reachable" described an
    intent the runtime does not share. consumed() now leaves an uncomposed
    module out, which says the same thing and starts.

  2. The scaffolded drift check asserted an invariant the reference solution
    does not hold.
    It required consumed() to equal api.consumes exactly,
    but api.consumes names every module a solution reaches anywhere — the
    page reaches modules at the authority's edge with their own clients, never
    through the backend — and solutions/wiki declares six consumed modules and
    relays two. Split into the two directions that are true: consumed_gen.go
    must equal api.consumes (that is the codefly sync drift check, and it
    is the generated file that must match), while the backend may relay any
    subset of it and nothing outside it.

Removing the filter in (1) turns the new test red, which is how it was checked.

What running the real CLI found

The agent was installed into a scratch Codefly home as solution-go__0.0.1, a
codefly was built from codefly-dev/cli main (f7bfa66), and

codefly add solution notes --package-id obin/notes --agent solution-go \
    --consumes connect:documents/documents/grpc

was run against a real workspace. It found three things no test in this
package could see, because every one of them rendered this agent's templates
with this agent's own context.
All three are fixed in 1f4b021.

1. The agent's README killed add solution outright

☠️ cannot execute command
☠️   ↳ rpc error: code = Internal desc = templates.ApplyTemplateFrom: cannot execute
     template: template:1:13: executing "template" at <.Service.Name.Title>:
     nil pointer evaluating *resources.ServiceWithCase.Name

templates/agent/README.md.tmpl opened with {{ .Service.Name.Title }}. But
GetAgentInformation is what codefly add service calls to print what the
agent scaffolds
, and at that moment nothing has been created: core has run
only the agent's Init, so s.Information carries the agent and Service and
Module are nil. The README describes the agent, so it now takes no values
at all — there is nothing left to be nil. (service-go-grpc guards with
{{if .Service}}; prose is simpler and says the same.)

2. GETTING_STARTED.md is rendered by CORE, with the same bare context

☠️   ↳ builder load failed: templates.ApplyTemplateFrom: cannot execute template:
     template:1:9: executing "template" at <.SolutionID>:
     can't evaluate field SolutionID in type *services.Information

core/agents/services/base_builder.go renders
templates/factory/GETTING_STARTED.md itself at builder Load in creation mode,
with s.Information. So that one template is constrained to the fields
Information carries — and the base/ restructure had replaced codefly-base
with {{ .SolutionID }}, which it does not. This was a regression the
restructure introduced
: the hand-written template had used
{{ .Module.Name.LowerCase }}, and collapsing every spelling of the solution
id onto one replacement lost the distinction.

The replacement is back to {{ .Module.Name.LowerCase }} — the same value, and
a field both contexts carry, since the scaffold context embeds
*services.Information. One spelling, renderable by either caller, no
per-file exception.

Both failures landed after add module had already written the module,
leaving the half-built tree that command's design goes out of its way to avoid.

advertisement_test.go now renders both core-rendered templates exactly the way
core renders them. Reverting either fix reproduces the exact error above in
go test.

3. The end-to-end test modelled the wrong order

It wrote solution.codefly.yaml before Create. codefly add solution
writes it after: it scaffolds both services through the agents' own Create
flows and only then writes the solution's documents. So the tree the test
proved was one no user ever has.

Run in the real order, a solution scaffolded with --consumes has an empty
consumed_gen.go
and one failing test:

--- FAIL: TestTheGeneratedConsumptionMatchesTheManifest
    consumes_test.go:38: consumed_gen.go names []; ../../solution.codefly.yaml declares [documents].
        consumed_gen.go is generated from api.consumes: run `codefly sync` and commit it.

That failure is true, and the agent cannot avoid it: api.consumes does not
exist when Create runs (builder.go says so), and until codefly sync has
run the backend really does relay nothing its solution declares. The check
saying so is the entire reason it exists.

So it is pinned rather than tolerated. The test now requires that exactly
one
test fails, that it is that one, and that its message carries the command
that fixes it — then runs the agent's own Sync and requires the tree green.
If add solution ever syncs before it returns, this fails too, which is the
moment to delete the branch.

codefly sync service notes/backend could not be run here for real: it resolves
the authority interface first, and a scratch workspace composes no
implementation of it. The agent's Sync entry point is exercised in go test.

Reuse

Nothing solution-specific is reimplemented:

  • Runtime, Code, Tooling and the Builder's Load/Init/Build/Deploy are
    codefly-dev/service-go's, imported wholesale. This agent adds only Create
    and Sync. That is why there is no runtime.go here, unlike
    service-go-grpc — a solution backend is an ordinary Go program to a
    compiler and a process manager.

  • The interface document is rendered by solution-runtime-go's own
    renderer, not a hand-rolled OpenAPI writer, so the bytes Create commits are
    the bytes the scaffolded go generate ./... writes.
    TestInterfaceArtifactIsCurrent passing on a tree no generator has run in is
    that, verified.

  • Consumption is read with core's own manifest.LoadConsumption — a
    manifest core refuses is one this agent refuses.

  • release_test.go pins the install path against core's own
    AgentKindRegistrationFor / GitHubAsset / ExecutableName rather than a
    repeated string, so a rename on either side fails in go test.

  • CI reuses codefly-dev/core's go-service-ci.yml, pinned at the core
    version go.mod requires, rather than restating its checks.

  • The release workflow is service-go-grpc's shape (suite on the tag first,
    then goreleaser-cross with Syft mounted for per-archive SBOMs).

  • The authoring model is service-go-grpc's: base/ is hand-written and
    templates/factory/ is generated from it by core's
    generator.GenerateServiceTemplate. The drift check calls that function
    directly rather than shelling out to the CLI.

Authoring model: base/, not hand-written templates

The templates were hand-written when this branch was handed over. That is the
wrong model for a service agent, and it is why the two defects above existed:
a template cannot be compiled, vetted or tested, so a scaffold that does not
build looks exactly like one that does.

base/ is now the hand-written artifact and templates/factory/ is generated
from it — codefly agent generate --service ., i.e. core's
generator.GenerateServiceTemplate.

base/ is a real solution backend called codefly-base — 15 files, of
which 10 generate templates:

source main.go, consumes.go, operations.go, openapi.go
its own suite consumes_test.go, openapi_test.go
module go.mod, go.sum (tidy as committed), .gitignore
docs GETTING_STARTED.md
ignored — the CLI's service.codefly.yaml, solution.frontend.codefly.yaml
ignored — the agent renders them consumed_gen.go, openapi/api.swagger.json
ignored — the config itself service.generation.codefly.yaml

It compiles with the default -mod=readonly, it vets, and its own suite is
the suite every scaffolded solution ships with
, so what this agent scaffolds
is known-good before anyone scaffolds it. It is staged into the layout a real
solution has (<root>/services/backend beside <root>/solution.codefly.yaml)
because that is where its consumes_test.go really finds the manifest, at
../../ — which from <repo>/base is outside the repository.

It deliberately consumes one module and composes no methods for it: that is
exactly the state that was broken, so base/ now sits permanently in the shape
whose regression would be caught.

The five ignored files are the ones base/ needs to be a real service but that
must never be scaffolded. A template for any would be a second mechanism for
one file. Note core's generator has a hardcoded exclusion for
service.codefly.yaml, but it compares a full path and so never fires — the
ignores list is what actually holds.

The three replacements reproduced the tree byte for byte

Except where the move was a deliberate simplification:

  • {{ .Module.Name.LowerCase }} kept as is. It was briefly {{ .SolutionID }}
    — the same value, and the wrong spelling; see What running the real CLI
    found
    below.
  • {{ .InterfaceDescription }} → the prose it rendered to, so the description
    lives once in base/openapi.go rather than in a Go function a template
    referred to.
  • {{ .Service.Name.LowerCase }} → backend. A solution's entry service is
    named backend by the shape's definition (the CLI's solutionEntryService
    and this agent's backendService are that constant), the scaffolded go.mod
    already hard-codes the /backend suffix the compiled binary is named after,
    and the word appears in prose on nearly every line — a whole-file
    substitution would corrupt it.

The regenerate-is-clean test passes

TestFactoryTemplatesAreGeneratedFromBase compares whole trees in both
directions
, because regeneration overwrites a file but never deletes one — a
template whose source left base/ would otherwise go on being scaffolded
forever. Both directions were checked by hand:

  • hand-editing a template → fails, naming the file and saying to edit
    base/;
  • editing base/ then regenerating → the change reaches the template.

TestGeneratedTemplatesCarryNoBaseSolutionName additionally fails if any of
the four spellings of codefly-base survives into a template, which is what
catches a new capitalisation introduced in base/ that no replacement covers.

go test -update is one flag for base/'s derived files and the templates
together, so the two cannot be refreshed one without the other.

AGENTS.md states all of this, because hand-editing a template is the mistake
a reader makes by default.

templates/page/ — the judgement call

It stays hand-written, and says so in AGENTS.md and at main.go's
pageFS. Two reasons, the first structural:

  1. GenerateServiceTemplate produces templates/factory from base/ and
    nothing else, and the page is written to a sibling of the service
    directory (services/fe-remote), not into it — so there is no base/
    subdirectory it could come from. Putting it under base/ would scaffold it
    inside the backend.
  2. The whole benefit of a base/ is that it compiles and is tested, and that
    needs npm install — the five @codefly-dev kits are on GitHub Packages
    behind a read:packages token this environment has none of. Until then a
    base-page/ would be a second generator for no verification.

The condition that reverses this, stated so the next reader does not have to
re-derive it: reason 2 goes away the moment CI holds a read:packages
token
. At that point the page would compile and be tested the way base/ is,
and a base-page/ becomes worth building — rendered by this agent into the
sibling directory rather than by GenerateServiceTemplate, because reason 1
(the structural one) never goes away. Reason 1 settles the question on its own
today; reason 2 is only what makes the cost not worth paying yet.

What holds the tree instead is TestThePageSharesEveryHostSingleton, which
pins every entry of the Module Federation share policy — the part whose failure
is invisible until a browser loads the remote inside a real host. That is
weaker than a compile, and it is listed under what I did not verify.

Release path: declared but absent

agent.codefly.yaml declared release.workflow: releaser.yml and
.goreleaser.yaml's comment cited a release_test.go. Neither existed — a
v* tag would have published nothing and the agent could not be installed at
all. Both added.

Self-hosting is kept, and now verified rather than assumed. source.agent: self is the CLI's exact sentinel (cmd/agents/build.go
isSelfHostedSourcePackager), and it imposes four preconditions before publish:
a declared source.bootstrap, a semver version, and a publisher and name that
are each a single path component. All four are now checked in go test rather
than at a publish that has already tagged, and bootstrap-agent is held to the
three variables the publisher states the target with
(CODEFLY_AGENT_OUTPUT/_OS/_ARCH) and to CGO, without which core's
tree-sitter source inspection cannot ship. Naming codefly.dev/go:<v> instead
would make every release of this agent wait on a bump of that one.

Verified

GOWORK=off go build ./..., go vet ./..., gofmt -l clean; 32 tests pass
(34 with subtests), 0 skipped
.

base/ builds, vets and passes its own suite
(TestBaseCompilesAndPassesItsOwnSuite), and is held to the agent's own
generator (consumed_gen.go), the runtime's own renderer
(openapi/api.swagger.json), the runtime pin scaffold.go declares, and the
host's-door-only manifest shape.

A scaffolded solution, in both states of codefly add solution (with
--consumes and without):

  • builds with the default -mod=readonly — the generated go.mod/go.sum are
    tidy as generated;
  • passes the suite it ships with, on a tree no generator has run in — with the
    one true exception above: with --consumes, the consumption drift check
    fails until codefly sync runs, and passes once the agent's Sync has;
  • the compiled binary is already covered by the scaffolded .gitignore.

The solution the real CLI scaffolded was then copied into a clone of
obin-ai/core-solutions at solutions/notes/, beside the eight that live
there. All three gates pass at exit 0, and each was confirmed to have teeth
on that exact tree rather than to be passing vacuously:

Gate Result Teeth
check-boundaries.sh clean outside the baseline baseline unchanged at 14
check-consumed-apis.sh solutions/notes: 1 delivered binding(s); no direct module address required pin moved to v0.0.11 → "backend must pin the native projected-token runtime (f9392decc414 or a later release)"
check-solution-mesh.sh no module edge, no routing credential a documents edge added → FAILED on documents/documents/grpc (SP-SOL-04)

The runtime-pin check is live, since the services/backend/go.mod it needs is
one this agent creates. The same edge also fails locally in the scaffolded
go test (TestNoModuleEdgeIsDeclared), which is the point — the author is
standing there, not at the consumer repository's gate.

Also checked against the sources rather than assumed: the GETTING_STARTED
example matches what cmd/add/solution.go actually writes
(solutionEntryDependencies, solutionEntryEndpoint, authorityInterfaceRange
— identical interface range, hop-as-external, and /health probe), and the
end-to-end test lifts the service manifest from that rendered example, so a
documented manifest that drifts from the CLI's fails the test.

codefly doctor solution — the checker that judges the shape

The solution the CLI scaffolded above, judged by the verb that owns the shape
(cli#1218, plus #1221's permission rules), on a codefly built from cli main:

$ codefly doctor solution --dir modules/notes
OK    notes
  WARN page_shell_has_no_app: the "web" shell is declared with no app behind it
      `codefly run service web` has nothing to start. Write the shell, or drop the service
      from module.codefly.yaml — a declaration with no source is the one state neither
      reading the manifests nor running the gates reveals
  WARN consumed_module_without_binding: consumed with no binding in the contract: documents
      correct when the module is reached with the VIEWER'S own credential or on nobody's
      behalf; a bug when the solution calls it on a person's behalf, and then the call is
      refused at runtime with nothing failing earlier. Confirm each one
  conforms to the solution shape

$ echo $?
0

Conforms, exit 0. Both warnings are about the CLI's half of the scaffold,
not the backend:

  • consumed_module_without_binding is module.contract.codefly.yaml, which
    add solution writes with no bindings — correctly, since whether a call is
    made on a person's behalf is the author's to say. It is also, usefully, the
    contract half of the gap recorded below.
  • page_shell_has_no_app looks like a false positive in the checker, not a
    defect in the tree: it stats services/web/package.json, and the nextjs
    agent puts the app one level down, at services/web/code/package.json
    (next.config.ts, package-lock.json and src/ are all there). Every
    solution scaffolded by add solution will carry this warning. Reported here
    rather than filed — no issues opened.

Each of the backend's own rules passed: module_identity_missing,
module_edge_declared, entry_endpoint_missing, entry_endpoint_not_exported,
frontend_declaration_missing, owner_aware_missing,
module_package_publishes_shell and the rest.

What I did NOT verify

Stated plainly, because none of it is covered above:

  • The ./Page remote is never built or run, and is the one tree with no
    base/ behind it (see the judgement call above). npm install needs a
    read:packages token for the five @codefly-dev/* packages on GitHub
    Packages, which this environment has none. Every public pin was confirmed to
    resolve to a published version, but rsbuild build, vitest run and
    tsc --noEmit were not executed, and the Module Federation share policy is
    pinned only by TestThePageSharesEveryHostSingleton reading the config — a
    wrong value in a correctly-named entry would still pass.
  • No scaffolded backend was ever booted or run. It was built, vetted and
    tested, and judged by doctor solution and the three gates — but ./backend
    was never started, nothing listened on /health, and no page ever called it.
    Everything about the running process is read from solution-runtime-go's
    source, not observed: the boot-time refusals (an empty relay slot, a
    composition naming no authority), the readiness answer, the relay to the hop.
  • codefly sync was not run end to end. It resolves the authority
    interface before it dispatches, and a scratch workspace composes no
    implementation of one. The agent's Sync is exercised in go test, with the
    same call sync service makes.
  • The add solution run needed a one-line local patch to the CLI to get
    past a stale-workspace bug on cli main (below). The patch is in a throwaway
    clone, touches nothing in this repository, and is not part of this PR —
    but it does mean the command as published does not yet complete.
  • Nothing was deployed, and no backend was booted. templates/builder and
    templates/deployment (Dockerfile, kustomize base and overlays) are
    unexercised: no image was built and no manifest applied. The boot-time
    refusals described in comments — an empty relay slot, a composition naming no
    authority — were read in solution-runtime-go, not observed.
  • A composed module is untested. Every scaffolded solution checked here
    composes no methods, because a real method name is a constant of a module's
    generated Go client and no module client is available here. That the filter in
    defect (1) does not hide a genuine composition is reasoned, not run.
  • endtoend_test.go is not hermetic — it fetches the scaffolded module's
    dependencies. go.sum was confirmed complete (the -mod=readonly build
    passes), so CI needs only its normal network access.

Known gap, recorded rather than fixed here

A module in api.consumes that nobody composes methods for reaches nothing
silently
— the backend starts, the page's call is simply not relayed, and no
test fails. That is what keeps generation green with --consumes, which
core-solutions' own recipe requires ("green on every gate at generation"); the
alternative is a freshly scaffolded tree that is red until the author writes Go.

It does not belong in this agent, and no second checker was built for it.
Whether a module is composed is the workspace's fact, and the solution
manifest and the composition are different documents in different places —
neither this agent nor the solution's own directory can see both. It belongs in
codefly doctor solution (cli#1218), which already runs against a workspace
when invoked without --dir, and which already warns on the adjacent
contract-side case (consumed_module_without_binding, above). Noted by the
owner; no issue opened.

Two CLI findings, reported not filed

Both were hit while running the verification above, both are in
codefly-dev/cli main rather than here, and no issue was opened for either:

  1. add solution cannot complete on cli main — cannot find module <notes> in <notes-ws>, present: []. addSolution captures the workspace before
    addModule writes the module and reuses that snapshot at step 3;
    common.LoadWorkspace reads from disk each call, so the snapshot is stale.
    Reproduced identically with the stock go agent, so it is not this agent.
    To finish the verification I reloaded the workspace in a local scratch
    build only
    — one common.LoadWorkspace call, never committed, in a
    throwaway clone.
  2. doctor solution's page_shell_has_no_app, described above.

No issues were opened.

🤖 Generated with Claude Code

Antoine Toussaint and others added 5 commits October 28, 2026 16:12
Scaffolds, builds and runs a solution backend, so `codefly add solution`
writes code and not only the five YAML documents.

Builds and vets clean; one test fails —
TestThePageSharesEveryHostSingleton, "the shared policy does not seal
react to the host's instance" — which is unfinished work, not a
regression. Pushed to keep it off a scratchpad that can vanish.

Refs #1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four shape tests that were failing each caught a real mismatch between
what the rule says and what the agent or the test did.

Two were the templates' fault:

  - the ./Page share policy spelled `react` as a bare key while every sibling
    was a quoted specifier, so the entry that seals the host's React instance
    read as absent to anything scanning for one. A share key is an exact
    module specifier; it is now spelled as one.
  - the scaffolded go.mod named obin-ai/core-solutions in a comment, putting
    one consumer's private repository into every solution this agent
    scaffolds for anyone. The pin floor (v0.0.12, the native projected-token
    runtime) is the fact that matters and is all the comment now states.

Two were the tests' own:

  - the manifest-declaration rule read a fenced YAML example in
    GETTING_STARTED.md as a declaration. Nothing reads Markdown as a
    manifest, and that block is deliberately the manifest an author copies
    when the service was added outside `add solution`. Fenced blocks now come
    out before matching; prose outside a fence, and every line of a template
    that is not Markdown, are still held to the rule.
  - the module-edge rule allowed only GETTING_STARTED to mention
    service-dependencies, which refused consumes_test.go — the template that
    READS the manifest in order to refuse an edge, i.e. this very rule
    enforced inside the scaffolded solution's own suite. Both are now named,
    with what each says, and an exemption that stops mentioning an edge
    fails.

The edge check itself was replaced rather than relaxed. It matched every
`- name:` line in the file, so it read the example's `- name: grpc` — an
endpoint of the hop — as a module edge, and would have read a real edge
spelled as a flow mapping as nothing at all. The example is now PARSED and
held to the host's door structurally, the way check_solution_mesh.py reads a
real manifest and for the reason that script gives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every test in this package read a template; none ran what the templates
render. So this adds the test that does — scaffold a solution the way
`codefly add solution` scaffolds one, complete it the way the CLI completes
it, compile it, and run the suite it ships with — and fixes the two defects
it found immediately.

The first stopped the boot. The solution runtime refuses a consumed module
that declares no method ("consumed module %q declares no methods",
passthrough.go), and the scaffolded consumes.go declared one per api.consumes
entry whether or not anyone had composed its methods yet. A solution
scaffolded with `--consumes` therefore could not render its interface
document and could not start: the comment claiming an empty declaration means
"nothing of it is reachable" described an intent the runtime does not share.
consumed() now leaves an uncomposed module out, which says the same thing and
starts.

The second was an invariant the reference solution does not hold. The
scaffolded drift check asserted consumed() equals api.consumes exactly, but
api.consumes names every module a solution reaches ANYWHERE — the page
reaches modules at the authority's edge with their own clients, never through
the backend — and obin-ai/core-solutions' wiki declares six consumed modules
and relays two. The check is split into the two directions that are actually
true: consumed_gen.go must equal api.consumes (that IS the `codefly sync`
drift check, and it is the generated file that must match), while this
backend may relay any subset of it and nothing outside it.

Both states of `codefly add solution` are now covered, with and without
`--consumes`, and both build with the default -mod=readonly and pass their
own gates on a tree no generator has run in. Removing the filter above turns
the new test red, which is how it was checked.

The service manifest the end-to-end test writes is lifted from the scaffolded
GETTING_STARTED.md's own fenced example rather than written in the test, so
what it proves is that the DOCUMENTED manifest is the one that works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
agent.codefly.yaml declared `release.workflow: releaser.yml` and
.goreleaser.yaml's comment cited a release_test.go. Neither existed: a `v*`
tag would have published nothing, so the agent could not be installed at all.

The workflow is core's shape, from codefly-dev/service-go-grpc: the suite
runs on the tag first and a release waits on it, then goreleaser-cross
publishes with Syft mounted for the per-archive SBOMs. CI reuses core's own
go-service-ci reusable workflow pinned at the core version go.mod requires,
rather than restating its checks here.

release_test.go pins the install path against CORE'S OWN registration rather
than against a repeated string: resources.AgentKindRegistrationFor gives the
executable name every build must produce and GitHubAsset gives the archive
core fetches, and the GoReleaser template is resolved for a concrete platform
and compared to it. A rename on either side now fails here instead of
publishing an agent that downloads and cannot be found.

It also pins the self-hosting choice, which is kept. `source.agent: self` is
the CLI's exact sentinel (cmd/agents/build.go isSelfHostedSourcePackager) and
it imposes four preconditions before publish — a declared source.bootstrap, a
semver version, and a publisher and name that are each one path component —
all four now checked in `go test` rather than at a publish that has already
tagged. bootstrap-agent is held to the three variables the publisher states
the target with (CODEFLY_AGENT_OUTPUT, _OS, _ARCH) and to CGO, which core's
tree-sitter source inspection cannot ship without.

Verified against the CLI at HEAD rather than assumed: self-hosting is
supported, and naming `codefly.dev/go:<v>` instead would make every release
of this agent wait on a bump of that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The templates were hand-written. That is the wrong authoring model for a
service agent and it is why the two defects in the previous commit existed at
all: a template cannot be compiled, vetted or tested, so a scaffold that does
not build looks exactly like one that does.

Every service agent holds a REAL service in base/ and generates
templates/factory from it with core's generator.GenerateServiceTemplate
(`codefly agent generate --service .`), which copies base/, adds a .tmpl
suffix and applies the literal replacements in
base/service.generation.codefly.yaml. codefly-dev/service-go-grpc is the
proof. This repository now does the same.

base/ is a real solution backend called `codefly-base`: it compiles with the
default -mod=readonly, it vets, and ITS OWN SUITE IS THE SUITE EVERY
SCAFFOLDED SOLUTION SHIPS WITH. It is staged into the layout a real solution
has — <root>/services/backend beside <root>/solution.codefly.yaml — because
that is where its consumes_test.go really finds the manifest, at ../../, which
from <repo>/base would be outside this repository.

It deliberately consumes one module and composes no methods for it. That is
the state that was broken: the runtime refuses a consumed module declaring no
method, so base/ is permanently in the shape whose regression would be caught.

templates/factory is now generated, and the three replacements reproduced the
hand-written tree byte for byte except where the move was a deliberate
simplification:

  - {{ .Module.Name.LowerCase }} became {{ .SolutionID }}, the same value.
  - {{ .InterfaceDescription }} became the prose it rendered to, so the
    description lives once, in base/openapi.go, instead of in a Go function
    the template referred to.
  - {{ .Service.Name.LowerCase }} became `backend`. A solution's entry service
    is named `backend` by the shape's definition (the CLI's
    solutionEntryService and this agent's backendService are that constant),
    the scaffolded go.mod already hard-codes the `/backend` module suffix the
    compiled binary is named after, and the word appears in prose on nearly
    every line, which a whole-file substitution would corrupt.

The five files base/ needs to be a real service but that must never be
scaffolded are listed under `ignores`: the three documents the CLI owns
(service.codefly.yaml, solution.frontend.codefly.yaml) plus the two the agent
renders itself (consumed_gen.go, openapi/api.swagger.json). A template for any
of them would be a second mechanism for one file. Core's own generator has a
hardcoded exclusion for service.codefly.yaml, but it compares a full path and
never fires, so the list is what actually holds.

The drift check calls core's generator directly rather than shelling out to
the CLI, so it needs no second binary and holds the templates to the exact
code `codefly agent generate` runs. It compares whole trees in both
directions, because regeneration overwrites a file but never deletes one — a
template whose source left base/ would otherwise be scaffolded forever. Both
directions were checked: hand-editing a template fails it, and editing base/
then regenerating carries the change through.

`go test -update` is one flag for base/'s derived files and the templates
together, so the two cannot be refreshed one without the other.

AGENTS.md states the model, because the mistake this commit undoes is one a
reader makes by default.

templates/page stays hand-written and says why, in AGENTS.md and at main.go's
pageFS: GenerateServiceTemplate produces templates/factory from base/ and
nothing else, and the page is written to a SIBLING of the service directory,
so there is no base/ subdirectory it could come from.

29 tests pass, 0 skipped. A scaffolded solution still passes core-solutions'
check-boundaries, check-consumed-apis and check-solution-mesh at exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Re-authored on base/ as you asked — 2c2f241. The PR body now describes the corrected model; this is what changed and what it cost.

You were right about the cause, not just the convention. The two defects in the previous commit (a solution that consumed anything could not boot) existed because the templates were hand-written. A template cannot be compiled, so a scaffold that does not build looks exactly like one that does. base/ closes that.

What base/ ended up containing — 15 files, 10 of which generate templates

source main.go, consumes.go, operations.go, openapi.go
its own suite consumes_test.go, openapi_test.go
module go.mod, go.sum (tidy as committed), .gitignore
docs GETTING_STARTED.md
ignored — the CLI's service.codefly.yaml, solution.frontend.codefly.yaml
ignored — the agent renders them consumed_gen.go, openapi/api.swagger.json
ignored — the config service.generation.codefly.yaml

It is a real solution called codefly-base: builds with default -mod=readonly, vets, and its suite is the suite every scaffolded solution ships with. It deliberately consumes one module and composes no methods for it — precisely the state that was broken — so base/ now sits permanently in the shape whose regression gets caught.

Two things worth flagging because they were not obvious from the brief:

  • base/ cannot be tested where it sits. consumes_test.go reads the solution manifest at ../../solution.codefly.yaml, which from <repo>/base is outside the repository. It is staged into <root>/services/backend beside <root>/solution.codefly.yaml — the layout a real solution has. Not a workaround; ../../ is the shipped path and a test that reached elsewhere would not be checking it.
  • Core's hardcoded service.codefly.yaml exclusion never fires. visitor.Keep compares file == "service.codefly.yaml" against a full path. The ignores list is what actually holds, which is why all five excluded files are listed explicitly. Worth knowing for the other agents.

Regenerate-is-clean: passes, and has teeth both ways

TestFactoryTemplatesAreGeneratedFromBase calls generator.GenerateServiceTemplate directly rather than shelling out to the CLI, so the check needs no second binary and holds the templates to the exact code codefly agent generate runs. It compares whole trees in both directions, since regeneration overwrites but never deletes — a template whose source left base/ would otherwise be scaffolded forever. Checked by hand:

  • hand-edit a template → fails, naming the file and saying to edit base/;
  • edit base/, regenerate → change reaches the template.

The three replacements reproduced the hand-written tree byte for byte except three deliberate simplifications: {{ .Module.Name.LowerCase }} → {{ .SolutionID }} (same value), {{ .InterfaceDescription }} → the prose it rendered to (so the description lives once), and {{ .Service.Name.LowerCase }} → backend — the entry service is backend by the shape's definition, the scaffolded go.mod already hard-codes the /backend suffix the binary is named after, and the word is in prose on nearly every line where a whole-file substitution would corrupt it.

go test -update is one flag for base/'s derived files and the templates, so they cannot be refreshed one without the other. AGENTS.md states the model, since hand-editing a template is the default mistake.

templates/page/ — kept hand-written, and here is why

Structural first: GenerateServiceTemplate produces templates/factory from base/ and nothing else, and the page is written to a sibling of the service directory (services/fe-remote), not into it. Under base/ it would be scaffolded inside the backend.

Second: the whole benefit of a base/ is that it compiles and is tested, and that needs npm install — the five @codefly-dev kits are on GitHub Packages behind a read:packages token this environment lacks. A base-page/ generated the same way is the right shape once CI holds that token and npm test would prove something; until then it is a second generator for no verification. Say the word if you want it anyway.

What holds that tree instead is TestThePageSharesEveryHostSingleton pinning every share-policy entry. That is weaker than a compile and is listed under what I did not verify.

State

29 tests, 0 skipped. go build / go vet / gofmt -l clean. A scaffolded solution still passes check-boundaries, check-consumed-apis and check-solution-mesh at exit 0 against a clone of obin-ai/core-solutions, re-run after regeneration.

Everything else in the body stands, including the one tradeoff I would still like a second opinion on: a module in api.consumes that nobody composes reaches nothing silently.

Running `codefly add solution --agent solution-go` against a real CLI built
from codefly-dev/cli main found three things no test here could see, because
every test rendered this agent's templates with this agent's own context.

1. THE AGENT README KILLED `add solution`. templates/agent/README.md.tmpl
   opened with `{{ .Service.Name.Title }}`, and `GetAgentInformation` is
   called BEFORE any service exists — core has run only the agent's Init, so
   Information carries the agent and nothing else. The call failed with "nil
   pointer evaluating *resources.ServiceWithCase.Name" and the command died
   with the module already written. The README describes the AGENT, so it now
   takes no values at all.

2. GETTING_STARTED IS RENDERED BY CORE, with the same bare Information
   (base_builder.go, creation mode). The base/ restructure had replaced
   `codefly-base` with `{{ .SolutionID }}`, a field Information does not
   carry: "can't evaluate field SolutionID in type *services.Information",
   again after the module was written. The replacement is now
   `{{ .Module.Name.LowerCase }}` — the same value, and a field BOTH contexts
   carry, since the scaffold context embeds Information.

3. THE END-TO-END TEST MODELLED THE WRONG ORDER. It wrote
   solution.codefly.yaml before Create; `add solution` writes it after. With
   `--consumes`, a freshly scaffolded solution therefore has an empty
   consumed_gen.go and its drift check fails until `codefly sync` runs. That
   failure is TRUE — the backend really does relay nothing its manifest
   declares — so the test now pins it: exactly one test may fail, it must be
   that one, and it must name the command that fixes it. Then it runs the
   agent's Sync and requires the tree green.

advertisement_test.go renders both core-rendered templates the way core
renders them. TestGeneratedTemplatesRenderWithTheScaffoldContext now EXECUTES
each replacement against the scaffold context instead of comparing it to a
list of fields that happen to exist today.

Verified end to end: `codefly add solution notes --agent solution-go
--consumes connect:documents/documents/grpc` scaffolds, `codefly doctor
solution --dir` reports "conforms to the solution shape" at exit 0, and all
three core-solutions gates pass at exit 0 on that tree, with the runtime-pin
gate confirmed to still have teeth on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

A lazybox agent is working on this. claude (Opus 5) started at 2026-10-29 02:01 UTC, last renewed the claim at 2026-10-29 02:01 UTC, and the claim lapses at 2026-10-29 03:01 UTC unless it is renewed again. A claim is an assertion with an expiry, not proof that a process is alive.

claim detail
Lazybox-Claim-Device: effacd542b611010656e
Lazybox-Claim-Session: 2760a4a982
Lazybox-Claim-Workspace: github-codefly-dev-service-solution-go-2
Lazybox-Claim-Agent: claude
Lazybox-Claim-Model: Opus 5
Lazybox-Claim-Started: 2026-10-29T02:01:40Z
Lazybox-Claim-Heartbeat: 2026-10-29T02:01:40Z
Lazybox-Claim-Expires: 2026-10-29T03:01:40Z

@antoinetoussaint-byte
antoinetoussaint-byte merged commit 86c64ca into main Oct 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build the solution-go service agent: scaffold a solution backend, not a generic Go service

1 participant