From 526233067b766a4bf621a4f353b0eda0f9b03abc Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:01:00 -0400 Subject: [PATCH 1/2] wip(base): the scaffolded backend serves one documented operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base/operations.go returned nil, so a scaffolded solution served nothing and its page could show nothing — correct-looking and inert. overviewOperation is one route with a required policy on this solution's own kind (solutionID + ".page", which is exactly the scope_ceiling and installer role `codefly add solution` writes, cli#1216), answering from the DECLARATIONS the runtime booted with rather than from constants, so a page that renders it has proven the whole path: host mounts the remote, edge routes /v1/, authority checks the viewer against the policy, runtime dispatches. INCOMPLETE — pushing the diagnosis with the change. Adding any operation to base/ breaks the agent's "a scaffolded tree's first interface document is already current" guarantee: writeInterfaceArtifact (scaffold.go:216) renders from solution.New(Manifest{...}) with NO operations registered, so the committed document has "paths": {} while the scaffolded code serves /v1//overview. Two tests catch it, both now red: TestBaseInterfaceDocumentIsTheRuntimesRender TestAScaffoldedSolutionBuildsAndPassesItsOwnGates -> notes/backend: TestInterfaceArtifactIsCurrent See the PR discussion for the two ways to close it. Co-Authored-By: Claude Opus 5 --- base/openapi/api.swagger.json | 83 +++++++++++++++++++++++++++- base/operations.go | 66 +++++++++++++++++++++- templates/factory/operations.go.tmpl | 66 +++++++++++++++++++++- 3 files changed, 210 insertions(+), 5 deletions(-) diff --git a/base/openapi/api.swagger.json b/base/openapi/api.swagger.json index cea613b..c554c0d 100644 --- a/base/openapi/api.swagger.json +++ b/base/openapi/api.swagger.json @@ -11,6 +11,21 @@ } }, "type": "object" + }, + "OverviewResponse": { + "additionalProperties": false, + "properties": { + "consumes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "solution": { + "type": "string" + } + }, + "type": "object" } }, "info": { @@ -18,7 +33,73 @@ "title": "codefly-base solution backend", "version": "0.0.0" }, - "paths": {}, + "paths": { + "/v1/codefly-base/overview": { + "get": { + "description": "This solution's own page, to show that it is installed and reachable as the viewer\n\nAnswers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + "operationId": "CodeflyBase_Get_V1_Codefly-base_Overview", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/OverviewResponse" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "report what this solution is and which modules it can reach", + "tags": [ + "codefly-base" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "codefly-base.page", + "actions": [ + "read" + ] + } + ] + }, + "x-codefly-operation": { + "attempt_timeout": "30s", + "audience": "codefly-base", + "backoff": "1s", + "completion": "COMPLETION_CALL", + "invoke_scopes": [ + { + "actions": [ + "read" + ], + "resource_kind": "codefly-base.page" + } + ], + "max_attempts": 3, + "recovery": "RECOVERY_RECOMPUTE", + "retryable_codes": [ + "503" + ], + "tool": { + "description": "report what this solution is and which modules it can reach", + "effect": "EFFECT_READ_ONLY", + "name": "CodeflyBase_Get_V1_Codefly-base_Overview" + }, + "total_timeout": "90s" + } + } + } + }, "produces": [ "application/json" ], diff --git a/base/operations.go b/base/operations.go index 123a5dd..60bac9b 100644 --- a/base/operations.go +++ b/base/operations.go @@ -1,6 +1,11 @@ package main -import solution "github.com/codefly-dev/solution-runtime-go" +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) // route is where the authority's edge routes this solution and forwards its // requests unchanged: /v1/, the solution's alias. EVERY operation carries @@ -36,5 +41,62 @@ const route = "/v1/" + solutionID // contract does not declare is a scope the authority never issues, so the // route would refuse every call. func operations() []solution.Operation { - return nil + return []solution.Operation{overviewOperation()} +} + +// overviewOperation is the one route a scaffolded solution serves, and the +// one its page reads. +// +// It exists so a freshly scaffolded solution is RUNNABLE AND OBSERVABLE +// rather than correct-looking and inert. Everything between a host and this +// backend has to work for its answer to appear on the page: the host mounts +// the remote, the edge routes /v1/, the authority checks the viewer's +// Work Context against the policy below, and the runtime dispatches. A page +// that renders this has proven that path; a page that renders a fixed string +// has proven nothing, which is why the answer is derived rather than typed. +// +// Replace it with the solution's own first route. Keep the shape: a policy, +// the three prose fields, and a typed Response. +func overviewOperation() solution.Operation { + return solution.Operation{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + // The viewer must hold a read on this solution's own page. The kind is + // the solution's OWN vocabulary, which module.contract.codefly.yaml + // declares under scope_ceilings and grants through the installer role + // — both written by `codefly add solution` for exactly this name + // (cli#1216). A kind the contract does not declare is a scope the + // authority never issues, so the route would refuse every call. + Policy: solution.Requires(solutionID+".page", "read"), + Handler: overview, + } +} + +// overviewResponse is what the page reads to show that this solution is +// reachable, and what it can reach. +type overviewResponse struct { + // Solution is this solution's id, as the host lists it and the authority + // issues to. + Solution string `json:"solution"` + // Consumes is the `as` route name of every module this backend relays, + // which is what consumes.go declares and the runtime opened a relay for. + // Empty on a freshly scaffolded solution, which composes none yet. + Consumes []string `json:"consumes"` +} + +// overview answers from the DECLARATIONS the runtime booted with, not from +// constants: the list is the one consumed() built, so a module that failed to +// declare a method is absent here too, and the page shows what is actually +// reachable. +func overview(_ context.Context, _ *solution.Gateway) (any, error) { + relayed := consumed() + names := make([]string, 0, len(relayed)) + for index := range relayed { + names = append(names, relayed[index].As) + } + return overviewResponse{Solution: solutionID, Consumes: names}, nil } diff --git a/templates/factory/operations.go.tmpl b/templates/factory/operations.go.tmpl index baafa3a..50ecd15 100644 --- a/templates/factory/operations.go.tmpl +++ b/templates/factory/operations.go.tmpl @@ -1,6 +1,11 @@ package main -import solution "github.com/codefly-dev/solution-runtime-go" +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) // route is where the authority's edge routes this solution and forwards its // requests unchanged: /v1/, the solution's alias. EVERY operation carries @@ -36,5 +41,62 @@ const route = "/v1/" + solutionID // contract does not declare is a scope the authority never issues, so the // route would refuse every call. func operations() []solution.Operation { - return nil + return []solution.Operation{overviewOperation()} +} + +// overviewOperation is the one route a scaffolded solution serves, and the +// one its page reads. +// +// It exists so a freshly scaffolded solution is RUNNABLE AND OBSERVABLE +// rather than correct-looking and inert. Everything between a host and this +// backend has to work for its answer to appear on the page: the host mounts +// the remote, the edge routes /v1/, the authority checks the viewer's +// Work Context against the policy below, and the runtime dispatches. A page +// that renders this has proven that path; a page that renders a fixed string +// has proven nothing, which is why the answer is derived rather than typed. +// +// Replace it with the solution's own first route. Keep the shape: a policy, +// the three prose fields, and a typed Response. +func overviewOperation() solution.Operation { + return solution.Operation{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + // The viewer must hold a read on this solution's own page. The kind is + // the solution's OWN vocabulary, which module.contract.codefly.yaml + // declares under scope_ceilings and grants through the installer role + // — both written by `codefly add solution` for exactly this name + // (cli#1216). A kind the contract does not declare is a scope the + // authority never issues, so the route would refuse every call. + Policy: solution.Requires(solutionID+".page", "read"), + Handler: overview, + } +} + +// overviewResponse is what the page reads to show that this solution is +// reachable, and what it can reach. +type overviewResponse struct { + // Solution is this solution's id, as the host lists it and the authority + // issues to. + Solution string `json:"solution"` + // Consumes is the `as` route name of every module this backend relays, + // which is what consumes.go declares and the runtime opened a relay for. + // Empty on a freshly scaffolded solution, which composes none yet. + Consumes []string `json:"consumes"` +} + +// overview answers from the DECLARATIONS the runtime booted with, not from +// constants: the list is the one consumed() built, so a module that failed to +// declare a method is absent here too, and the page shows what is actually +// reachable. +func overview(_ context.Context, _ *solution.Gateway) (any, error) { + relayed := consumed() + names := make([]string, 0, len(relayed)) + for index := range relayed { + names = append(names, relayed[index].As) + } + return overviewResponse{Solution: solutionID, Consumes: names}, nil } From a9d855e20c5a87be0941d0a15a849a1f5e3f58ec Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:19:00 -0400 Subject: [PATCH 2/2] fix: the document a scaffolded solution ships lists the route it serves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base/operations.go now serves one route, so the interface document the agent writes at Create had to list it — and did not. writeInterfaceArtifact rendered from a server with NO operations registered, so a scaffolded tree shipped a document saying it serves nothing while its code served /v1//overview. That is not cosmetic. The CLI publishes this document as the http endpoint's contract and the authority derives its route checks from it (cli#1104), so a served route missing from it is a route the authority was never told to check. The scaffolded tree's own TestInterfaceArtifactIsCurrent caught it, which is how it was found. scaffoldedOperations declares the routes in THIS module so they can be rendered without compiling the scaffolded tree — base/ is a different Go module and cannot be imported from here. The duplication is tolerable only because the drift is guarded: TestBaseInterfaceDocumentIsTheRuntimesRender renders this declaration and compares it byte for byte against base/openapi/api.swagger.json, which base's own `go generate` produces from base/operations.go. Change one and not the other and that test fails. The alternative was to delete writeInterfaceArtifact and template base's rendered document instead — one mechanism rather than two. It is the better shape and it touches builder.go, scaffold_test.go, base_test.go and the generation ignores, so it is not this change. Co-Authored-By: Claude Opus 5 --- base_test.go | 3 ++- scaffold.go | 3 ++- scaffolded_operations.go | 52 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 scaffolded_operations.go diff --git a/base_test.go b/base_test.go index 54cf0a3..81c74d2 100644 --- a/base_test.go +++ b/base_test.go @@ -141,7 +141,8 @@ func TestBaseConsumptionIsWhatThisAgentGenerates(t *testing.T) { // and it must come from the same render — otherwise base/'s suite passes on // bytes a scaffolded tree would never hold. func TestBaseInterfaceDocumentIsTheRuntimesRender(t *testing.T) { - server := solution.New(solution.Manifest{ID: baseSolutionID, Contract: baseSolutionID}) + server := solution.New(solution.Manifest{ID: baseSolutionID, Contract: baseSolutionID}). + Operations(scaffoldedOperations(baseSolutionID)...) document, err := server.InterfaceArtifact("", solution.InterfaceInfo{ Title: baseSolutionID + " solution backend", Version: "0.0.0", diff --git a/scaffold.go b/scaffold.go index 4dbf640..7101f52 100644 --- a/scaffold.go +++ b/scaffold.go @@ -214,7 +214,8 @@ func (s *Builder) writeGeneratedConsumption(ctx context.Context) error { // service.codefly.yaml, which `CreateResponse` has not written yet when this // runs. func (s *Builder) writeInterfaceArtifact(ctx context.Context, context scaffold, version string) error { - server := solution.New(solution.Manifest{ID: context.SolutionID, Contract: context.SolutionID}) + server := solution.New(solution.Manifest{ID: context.SolutionID, Contract: context.SolutionID}). + Operations(scaffoldedOperations(context.SolutionID)...) document, err := server.InterfaceArtifact("", solution.InterfaceInfo{ Title: context.SolutionID + " solution backend", Version: version, diff --git a/scaffolded_operations.go b/scaffolded_operations.go new file mode 100644 index 0000000..8829384 --- /dev/null +++ b/scaffolded_operations.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "net/http" + + solution "github.com/codefly-dev/solution-runtime-go" +) + +// scaffoldedOperations is what a solution this agent scaffolds SERVES, as a +// declaration this module can render without compiling the scaffolded tree. +// +// It mirrors base/operations.go, and it has to exist because the two live in +// different Go modules: base/ is the scaffolded backend's source and cannot +// be imported from here, but the interface document this agent writes at +// Create must list exactly the routes that source serves. Render it from an +// empty server and the scaffolded tree ships a document saying it serves +// nothing while its code serves a route — the authority then derives no check +// row for a route that is being served, and the scaffolded +// TestInterfaceArtifactIsCurrent fails on a tree nobody has touched. +// +// THE DRIFT IS GUARDED, which is the only reason a second declaration is +// tolerable here: TestBaseInterfaceDocumentIsTheRuntimesRender renders this +// and compares it byte for byte against base/openapi/api.swagger.json, which +// base's own `go generate` produces from base/operations.go. Change one +// without the other and that test fails. +func scaffoldedOperations(solutionID string) []solution.Operation { + route := "/v1/" + solutionID + return []solution.Operation{{ + Path: route + "/overview", + Method: http.MethodGet, + Summary: "report what this solution is and which modules it can reach", + Callers: "This solution's own page, to show that it is installed and reachable as the viewer", + Behavior: "Answers the solution's id and the `as` name of every module this backend relays, in the order they are declared. Takes no parameters and reads nothing outside this backend", + Response: overviewResponse{}, + Policy: solution.Requires(solutionID+".page", "read"), + Handler: func(context.Context, *solution.Gateway) (any, error) { + // Never called: this declaration exists to be RENDERED. The + // scaffolded tree's own handler is base/operations.go's. + return overviewResponse{}, nil + }, + }} +} + +// overviewResponse mirrors base/operations.go's, and must stay identical: the +// document's schema is derived from this type, and the comparison against +// base's committed render is what catches a field added to one and not the +// other. +type overviewResponse struct { + Solution string `json:"solution"` + Consumes []string `json:"consumes"` +}