From 22723bc8d837a6a0a360e9ded02dc61ebf3564e8 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:39:08 -0400 Subject: [PATCH] feat: the interface artifact marks every read as a Runnable operation (x-codefly-operation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beside the authority policy of each operation whose policy requires only read, Server.InterfaceArtifact now writes core's x-codefly-operation marker, which core itself derives from that policy (runnable.RouteMarker, core#852). `codefly generate runnables` derives a Runnable package — and so a tool — from every marked route, as it does from every method a module serves behind the hop. - Operation.Exclude keeps a read from every agent, as codefly.runnable.v0.exclude keeps a module's method. The MCP route and every relayed route carry it. - InterfaceDocument marks nothing: the marker names the audience the serving runtime's hop admits, which only the Server knows. A solution must commit Server.InterfaceArtifact's output, and the README and CHANGELOG say so. - Every defined object closes additionalProperties, which core's bounded profile requires of a fixed shape. - Core moves to v0.19.7-0.20261026182446-518714d046af (core#852 merged). Rebased onto main after #78: main's authority-client and hop pins and its Go 1.27.3 bump are kept, core alone moves. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 20 ++++ README.md | 27 +++++ go.mod | 2 +- go.sum | 4 +- mcp.go | 5 +- operations.go | 102 ++++++++++++++++-- passthrough.go | 5 +- runnable_marker_test.go | 227 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 runnable_marker_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fd383f4..f86a029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased — every read a solution serves is a Runnable operation, and so a tool + +- **The artifact marks each read (`x-codefly-operation`).** An operation whose + policy requires only `read` is published with core's marker beside its + `x-codefly-authority-policy`, written by core from that policy + (`runnable.RouteMarker`, core#852, merged as `518714d0`): core's default bounds, the + `Summary` as the tool's description, the operationId as its name, and + `Manifest.ID` as the audience. `codefly generate runnables` derives a + Runnable from it (codefly-dev/cli#1184). Mutations, any-checked-caller + routes, streams, the MCP route and relayed routes are not marked. +- **`Operation.Exclude`** keeps a read from every agent. +- **Commit `Server.InterfaceArtifact`, never `InterfaceDocument`**, which marks + nothing (it does not know the audience): a solution committing its output + publishes no tool. `codefly generate runnables` names each such read. +- **Every defined object closes `additionalProperties`**, which core's bounded + profile requires of a fixed shape. Every consumer's committed artifact + changes on regeneration. +- **Core moves to `v0.19.7-0.20261026182446-518714d046af`**, the commit that + merged core#852, for `runnable.RouteMarker`. + ## Unreleased — a child names the owner its parent is selected to - **A child of a person's context at an installation names the diff --git a/README.md b/README.md index a5e8e87..1854c7c 100644 --- a/README.md +++ b/README.md @@ -655,6 +655,33 @@ ending in `/` serves its subtree and is written `/…/{path=**}`. The passthroug routes `InterfaceArtifact` publishes beside the solution's own are any checked caller's: see [why](#letting-the-page-call-a-consumed-module-the-passthrough). +**Every read is a Runnable operation, and so a tool.** Beside the policy of +each operation whose policy requires only `read`, `InterfaceArtifact` writes +core's `x-codefly-operation` marker — the proto3 JSON of +`codefly.runnable.v0.Operation` that core itself derives from that policy +(`runnable.RouteMarker`): recomputed on an uncertain outcome, core's default +bounds, invoked under exactly the scopes the policy requires, for the audience +this runtime's hop checks (`Manifest.ID`), presented as a read-only tool named +by the operationId and described by the `Summary`. `codefly generate runnables` +then derives a Runnable package from it, as it does from every method a module +serves behind the hop; a `GET`'s query parameters are its input. A mutation is +not marked: it is resolved by an effect receipt, read through a receipt route +this runtime does not serve, so it stays a route its own page calls. Neither is +a route any checked caller may call, a stream, the MCP route or a relayed route. +`Operation.Exclude` keeps a read from every agent, as +`codefly.runnable.v0.exclude` keeps a module's method. Every object the artifact +defines closes `additionalProperties` — a struct marshals exactly its fields +and protojson refuses an undeclared one — which is what lets a Runnable +contract describe it. + +> **Commit `Server.InterfaceArtifact`, never `InterfaceDocument`.** +> `InterfaceDocument` marks nothing — the marker names the audience the serving +> runtime's hop admits, which only the `Server` knows — so a solution that +> commits its output publishes no tool, silently, however many reads it serves. +> `InterfaceDocument` stays for documenting or validating a list of operations. +> `codefly generate runnables` names each read of a document that marks no route +> at all, which is this trap's signature (codefly-dev/cli#1185). + > **Dependency pins.** `go.mod` records the exact Core, SDK and authority > client revisions used by this runtime, including unreleased commit > pseudo-versions. No local `replace` directive is shipped. Core owns capability diff --git a/go.mod b/go.mod index f36cba1..bf2de59 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( connectrpc.com/connect v1.21.0 github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec - github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 + github.com/codefly-dev/core v0.19.7-0.20261026182446-518714d046af github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/modelcontextprotocol/go-sdk v1.8.0 diff --git a/go.sum b/go.sum index 23692ba..82f5f51 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-2026102701 github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec h1:QhncdaWaMERAGpZthlSXjKroXNl/L8llPtBMX+e5w/A= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:NqCbvIWbkbCzSpUUYQjP8Jv+LpFnDRbVhPdAj67DHhE= -github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= -github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026182446-518714d046af h1:JLTecMs4Oja+fl51chLyGw262S4yz7jzEEiFxoq7Viw= +github.com/codefly-dev/core v0.19.7-0.20261026182446-518714d046af/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= diff --git a/mcp.go b/mcp.go index 66ded7c..56e3dbb 100644 --- a/mcp.go +++ b/mcp.go @@ -117,7 +117,10 @@ func (s *Server) mcpOperation() Operation { Request: json.RawMessage{}, Response: json.RawMessage{}, Policy: s.mcp.policy, - Handler: func(context.Context, *Gateway) (any, error) { return nil, errors.New("served by the MCP transport") }, + // The body is a JSON-RPC message for any of the server's tools, not + // one operation's input: each tool is its own surface. + Exclude: true, + Handler: func(context.Context, *Gateway) (any, error) { return nil, errors.New("served by the MCP transport") }, } } diff --git a/operations.go b/operations.go index c8abe81..e767f26 100644 --- a/operations.go +++ b/operations.go @@ -11,6 +11,7 @@ import ( "strings" "unicode" + corerunnable "github.com/codefly-dev/core/runnable" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -67,6 +68,12 @@ type Operation struct { // Policy is what a call to the route must hold, as the authority applies // it: required on every operation the backend serves. Policy *Policy + // Exclude keeps the route from every agent, as + // codefly.runnable.v0.exclude keeps a module's method: the artifact marks + // it as no Runnable operation even when its policy is a read. The MCP + // route and every relayed route carry it, being a protocol and another + // module's call rather than this backend's own operation. + Exclude bool // Exactly one of Handler and RequestHandler answers the route. Handler Handler RequestHandler RequestHandler @@ -106,6 +113,64 @@ func AnyCheckedCaller() *Policy { return &Policy{AnyCheckedCaller: true} } // one the CLI reads route policies from. const PolicyExtension = "x-codefly-authority-policy" +// OperationExtension marks an operation as a Runnable one, and so a tool: +// core's x-codefly-operation, whose value is the proto3 JSON of +// codefly.runnable.v0.Operation. `codefly generate runnables` derives a +// Runnable package from every operation carrying it, as it does from every +// method a module serves behind the hop. +const OperationExtension = corerunnable.OpenAPIOperationMarker + +// operationMarker is the x-codefly-operation an operation publishes beside its +// policy, or nil when it is no Runnable operation. It is core's own default +// for the route, written by core from the policy this operation already +// publishes (corerunnable.RouteMarker), so the runtime invents no bound: the +// tool is named by the operationId and described by the Summary the operation +// is required to carry, and audience is the principal this runtime's hop +// checks a Work Context against. +// +// Only a read is marked. A mutation is resolved by its effect receipt, read +// through a receipt route this runtime does not serve, so marking one would +// publish a tool whose uncertain outcome nothing could resolve; it stays a +// route its own page calls. A policy any checked caller satisfies requires +// nothing to mint a call's capability from, a stream is not one finite call, +// and a relayed subtree is a path template — core derives none of them. An +// operation the owner excludes is never marked. A marked read whose payload +// core cannot carry is refused by `codefly generate runnables`, naming the +// route and why, rather than silently left unmarked here: the owner either +// fixes the shape or excludes the route. +func operationMarker(op Operation, method, route, id, audience string) (json.RawMessage, error) { + if audience == "" || op.Exclude || op.Streaming || !op.Policy.readOnly() { + return nil, nil + } + policy, err := json.Marshal(op.Policy) + if err != nil { + return nil, err + } + marker, err := corerunnable.RouteMarker(method, route, policy, audience, id, op.Summary) + if errors.Is(err, corerunnable.ErrNotDerivable) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("operation %s %s: %w", strings.ToUpper(method), route, err) + } + return marker, nil +} + +// readOnly reports a policy whose every requirement is the one read action, as +// core reads one (corerunnable.Authority.ReadOnly): a call holding nothing but +// read cannot change what it reads. +func (p *Policy) readOnly() bool { + if p == nil || p.AnyCheckedCaller || len(p.Requires) == 0 { + return false + } + for _, r := range p.Requires { + if len(r.Actions) != 1 || r.Actions[0] != corerunnable.ReadOnlyScopeAction { + return false + } + } + return true +} + // validate reports a policy that is absent, both things at once, or names a // kind without an action. The vocabulary itself is the authority's to check. func (p *Policy) validate() error { @@ -243,9 +308,20 @@ type InterfaceInfo struct { // InterfaceDocument renders ops as a Swagger 2.0 document — the dialect the // platform's proto-generated modules emit, so one reader handles every -// repository's artifact — indented, with a trailing newline. +// repository's artifact — indented, with a trailing newline. It marks no +// operation as a Runnable one: a marker names the audience the serving +// runtime's hop admits, which only the Server knows. A solution that commits +// this output therefore publishes no tool at all; the artifact a solution +// commits is Server.InterfaceArtifact's, and this is for documenting or +// validating a list of operations. func InterfaceDocument(info InterfaceInfo, ops []Operation) ([]byte, error) { - doc, err := swaggerDocument(info, ops) + return interfaceDocument(info, ops, "") +} + +// interfaceDocument is InterfaceDocument marking each Runnable operation for +// audience (operationMarker). +func interfaceDocument(info InterfaceInfo, ops []Operation, audience string) ([]byte, error) { + doc, err := swaggerDocument(info, ops, audience) if err != nil { return nil, err } @@ -256,11 +332,12 @@ func InterfaceDocument(info InterfaceInfo, ops []Operation) ([]byte, error) { return append(out, '\n'), nil } -func swaggerDocument(info InterfaceInfo, ops []Operation) (map[string]any, error) { +func swaggerDocument(info InterfaceInfo, ops []Operation, audience string) (map[string]any, error) { definitions := map[string]any{ "Error": map[string]any{ - "type": "object", - "properties": map[string]any{"error": map[string]any{"type": "string"}}, + "type": "object", + "properties": map[string]any{"error": map[string]any{"type": "string"}}, + "additionalProperties": false, }, } paths := map[string]map[string]any{} @@ -299,6 +376,13 @@ func swaggerDocument(info InterfaceInfo, ops []Operation) (map[string]any, error if op.Policy != nil { endpoint[PolicyExtension] = op.Policy } + marker, err := operationMarker(op, method, route, id, audience) + if err != nil { + return nil, err + } + if marker != nil { + endpoint[OperationExtension] = marker + } parameters := []any{} if op.Request != nil { parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": valueSchema(op.Request, definitions)}) @@ -510,7 +594,9 @@ func structSchema(t reflect.Type, definitions map[string]any) map[string]any { } properties[name] = schemaFor(field.Type, definitions) } - return map[string]any{"type": "object", "properties": properties} + // A struct marshals exactly its fields, so the object is closed: that is + // what makes it a fixed shape a Runnable contract can describe. + return map[string]any{"type": "object", "properties": properties, "additionalProperties": false} } // messageSchema describes a generated message as its protobuf JSON: lowerCamel @@ -530,7 +616,9 @@ func messageSchema(md protoreflect.MessageDescriptor, definitions map[string]any fd := fields.Get(i) properties[fd.JSONName()] = fieldSchema(fd, definitions) } - definitions[name] = map[string]any{"type": "object", "properties": properties} + // protojson refuses a field the message does not declare, so the + // object is closed, as a struct's is. + definitions[name] = map[string]any{"type": "object", "properties": properties, "additionalProperties": false} } return map[string]any{"$ref": "#/definitions/" + name} } diff --git a/passthrough.go b/passthrough.go index ba95e39..87b02f8 100644 --- a/passthrough.go +++ b/passthrough.go @@ -553,6 +553,9 @@ func PassthroughOperations(modules ...ConsumedModule) ([]Operation, error) { // Any checked caller: the child issued through the binding and the // module's own method policy gate the call (see above). Policy: AnyCheckedCaller(), + // Another module's call: it is a tool through that module's own + // derived operation, never twice through this relay. + Exclude: true, }) } return ops, nil @@ -608,7 +611,7 @@ func (s *Server) InterfaceArtifact(dir string, info InterfaceInfo) ([]byte, erro op.Path = s.authorityRoute() + op.Path ops = append(ops, op) } - return InterfaceDocument(info, ops) + return interfaceDocument(info, ops, s.manifest.ID) } // ServiceVersion is the version the service manifest (service.codefly.yaml) diff --git a/runnable_marker_test.go b/runnable_marker_test.go new file mode 100644 index 0000000..aa06e5c --- /dev/null +++ b/runnable_marker_test.go @@ -0,0 +1,227 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type recordedComponent struct { + Name string `json:"name"` + Pinned string `json:"pinned,omitempty"` +} + +type recordedBoard struct { + ObservedAt string `json:"observedAt"` + Components []recordedComponent `json:"components"` +} + +type recordResult struct { + Tags int `json:"tags"` +} + +// releasesServer serves the shape of the releases solution: two reads that +// require only records read, a write, a link that invokes a datasource, a +// status any checked caller may ask for, a stream, a relayed module and MCP. +func releasesServer() *Server { + answer := func(context.Context, *Gateway) (any, error) { return nil, nil } + reads := Requires("records.records", "read") + return New(Manifest{ID: "releases"}).Operations( + Operation{Path: "/v1/releases/recorded", Summary: "Read the board as records holds it, without asking GitHub", + Callers: "Any member who may read records", Behavior: "The newest recorded pin of every component", + Response: recordedBoard{}, Policy: reads, Handler: answer}, + Operation{Path: "/v1/releases/history", Summary: "Read the recorded tags of one repository and the recorded pins of one component", + Callers: "Any member who may read records", Behavior: "Newest first", + Query: []QueryParameter{{Name: "repo", Required: true}, {Name: "component"}}, + Response: recordedBoard{}, Policy: reads, Handler: answer}, + Operation{Path: "/v1/releases/record", Method: http.MethodPost, Summary: "Record every tag and pin the board shows", + Callers: "A member who may write records", Behavior: "How many events were recorded", + Response: recordResult{}, Policy: Requires("records.records", "read", "write"), Handler: answer}, + Operation{Path: "/v1/releases/link/begin", Method: http.MethodPost, Summary: "Connect the viewer's GitHub source", + Callers: "An organization administrator", Behavior: "Where to send the browser", + Response: recordResult{}, Policy: Requires("datasource", "invoke"), Handler: answer}, + Operation{Path: "/v1/releases/status", Summary: "Report whether the viewer's GitHub source is connected", + Callers: "Any member", Behavior: "One object", + Response: recordResult{}, Policy: AnyCheckedCaller(), Handler: answer}, + Operation{Path: "/v1/releases/watch", Summary: "Watch the board", Callers: "Any member who may read records", Behavior: "A stream", + Streaming: true, Response: recordedBoard{}, Policy: reads, Handler: answer}, + ).Consumes(passthroughModule()).ServeMCP("tools", "v1", reads, func(*mcp.Server) {}) +} + +func releasesArtifact(t *testing.T) []byte { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "service.codefly.yaml"), []byte("kind: service\nname: backend\nversion: 0.3.0\n"), 0o644); err != nil { + t.Fatal(err) + } + doc, err := releasesServer().InterfaceArtifact(dir, InterfaceInfo{Title: "releases", OperationPrefix: "Releases", Tag: "releases"}) + if err != nil { + t.Fatal(err) + } + return doc +} + +// TestTheArtifactMarksEveryReadAsARunnableOperation: every read route the +// backend serves carries x-codefly-operation beside its policy, and core +// derives each from the artifact exactly as `codefly generate runnables` will +// — a GET read whose query is its input, presented as a read-only tool named +// by its operationId and described by its Summary. Nothing else is marked: a +// write and a datasource invocation are mutations whose receipt this runtime +// does not serve, a status any checked caller may ask for requires nothing to +// mint from, and a stream, MCP and a relayed subtree are not one finite call +// on one fixed path. +func TestTheArtifactMarksEveryReadAsARunnableOperation(t *testing.T) { + doc := releasesArtifact(t) + var parsed struct { + Paths map[string]map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(doc, &parsed); err != nil { + t.Fatal(err) + } + marked := map[string]bool{} + for path, methods := range parsed.Paths { + for method, operation := range methods { + if _, ok := operation[OperationExtension]; ok { + marked[method+" "+path] = true + } + } + } + want := map[string]bool{"get /v1/releases/recorded": true, "get /v1/releases/history": true} + if len(marked) != len(want) || !marked["get /v1/releases/recorded"] || !marked["get /v1/releases/history"] { + t.Fatalf("marked %v, want exactly %v", marked, want) + } + + owner := corerunnable.ServiceOwner{Module: "releases", Service: "backend", Endpoint: "http", + Agent: &basev0.Agent{Kind: basev0.Agent_SERVICE, Name: "go", Version: "0.0.48", Publisher: "codefly.dev"}} + location := func(name string) *resources.RunnableLocation { + return &resources.RunnableLocation{ + Identity: &resources.RunnableIdentity{Name: name, Module: "releases", Workspace: "core-solutions", Version: "0.3.0"}, + WorkspacePath: "/workspaces/core-solutions", RelativeToWorkspace: "solutions/releases", + } + } + pkg, spec, err := corerunnable.PackageFromOpenAPIOperation(doc, location("backend-http-history"), owner, http.MethodGet, "/v1/releases/history") + if err != nil { + t.Fatalf("core does not derive the published read: %v", err) + } + if spec.Audience != "releases" || spec.Tool.GetEffect() != runnablev0.ToolExposure_EFFECT_READ_ONLY || + spec.Tool.GetName() != "Releases_Get_V1_Releases_History" || + spec.Tool.GetDescription() != "Read the recorded tags of one repository and the recorded pins of one component" { + t.Fatalf("the derived operation is %+v", spec) + } + if pkg.GetExecution().GetRecovery() != basev0.RunnableExecution_RECOVERY_RECOMPUTE { + t.Fatalf("a read is not recomputed: %v", pkg.GetExecution()) + } + fields := pkg.GetContract().GetInput().GetFields() + if len(fields) != 2 || fields[0].GetName() != "repo" || fields[0].GetOptional() || fields[1].GetName() != "component" || !fields[1].GetOptional() { + t.Fatalf("the query is not the input: %v", fields) + } + if _, _, err = corerunnable.PackageFromOpenAPIOperation(doc, location("backend-http-recorded"), owner, http.MethodGet, "/v1/releases/recorded"); err != nil { + t.Fatalf("core does not derive the published read: %v", err) + } + for _, route := range [][2]string{{http.MethodPost, "/v1/releases/record"}, {http.MethodPost, "/v1/releases/link/begin"}, {http.MethodGet, "/v1/releases/status"}} { + if _, _, err = corerunnable.PackageFromOpenAPIOperation(doc, location("backend-http-unmarked"), owner, route[0], route[1]); !errors.Is(err, corerunnable.ErrNotAnOperation) { + t.Errorf("%s %s: %v, want no operation", route[0], route[1], err) + } + } +} + +// TestInterfaceDocumentMarksNothing: the marker names the audience the serving +// runtime's hop admits, which only the Server knows, so the free function marks +// no operation rather than guess one. +func TestInterfaceDocumentMarksNothing(t *testing.T) { + doc, err := InterfaceDocument(InterfaceInfo{Title: "t", Version: "1", OperationPrefix: "Example", Tag: "example"}, documentedOperations()) + if err != nil { + t.Fatal(err) + } + if json.Valid(doc) && containsKey(t, doc, OperationExtension) { + t.Fatalf("InterfaceDocument marked an operation: %s", doc) + } +} + +// TestEveryDescribedObjectIsClosed: a struct marshals exactly its fields and +// protojson refuses an undeclared one, so each object the artifact defines +// says so — which is what lets a Runnable contract describe it at all. +func TestEveryDescribedObjectIsClosed(t *testing.T) { + var parsed struct { + Definitions map[string]map[string]any `json:"definitions"` + } + if err := json.Unmarshal(releasesArtifact(t), &parsed); err != nil { + t.Fatal(err) + } + raw, err := InterfaceDocument(InterfaceInfo{}, documentedOperations()) + if err != nil { + t.Fatal(err) + } + var generated struct { + Definitions map[string]map[string]any `json:"definitions"` + } + if err = json.Unmarshal(raw, &generated); err != nil { + t.Fatal(err) + } + for _, definitions := range []map[string]map[string]any{parsed.Definitions, generated.Definitions} { + for name, schema := range definitions { + if schema["additionalProperties"] != false { + t.Errorf("definition %s does not close additionalProperties: %v", name, schema) + } + } + } +} + +func containsKey(t *testing.T, doc []byte, key string) bool { + t.Helper() + var walk func(any) bool + walk = func(v any) bool { + switch v := v.(type) { + case map[string]any: + if _, ok := v[key]; ok { + return true + } + for _, child := range v { + if walk(child) { + return true + } + } + case []any: + for _, child := range v { + if walk(child) { + return true + } + } + } + return false + } + var parsed any + if err := json.Unmarshal(doc, &parsed); err != nil { + t.Fatal(err) + } + return walk(parsed) +} + +// TestAnExcludedReadIsNoOperation: an owner keeps a read from every agent the +// way a module keeps a method, and the artifact then marks it as nothing. +func TestAnExcludedReadIsNoOperation(t *testing.T) { + ops := []Operation{{Path: "/v1/releases/recorded", Summary: "Read the board", Callers: "c", Behavior: "b", + Response: recordedBoard{}, Policy: Requires("records.records", "read"), Exclude: true, + Handler: func(context.Context, *Gateway) (any, error) { return nil, nil }}} + doc, err := interfaceDocument(InterfaceInfo{OperationPrefix: "Releases"}, ops, "releases") + if err != nil { + t.Fatal(err) + } + if containsKey(t, doc, OperationExtension) { + t.Fatalf("an excluded read was marked: %s", doc) + } + ops[0].Exclude = false + if doc, err = interfaceDocument(InterfaceInfo{OperationPrefix: "Releases"}, ops, "releases"); err != nil || !containsKey(t, doc, OperationExtension) { + t.Fatalf("the same read, not excluded, was not marked (%v): %s", err, doc) + } +}