From 4a1f08420925a305ec617d6e8a7c0373bd0c1a60 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:11:27 -0400 Subject: [PATCH 1/6] feat(generate): runnables derives every method behind the hop, and every runnable is a tool `codefly generate runnables` now derives an operation for every served unary method that declares authority.hop.v1.policy, with nothing added to the proto (core feat/tools-from-endpoints). A read is written with recovery: RECOVERY_RECOMPUTE and no lookup; a mutation with RECOVERY_RECEIPT and the generic receipt lookup. Every operation.json carries the tool's presentation. A method a default derivation cannot turn into an operation (a stream, any_checked_caller, Any, recursion) is listed as "not an operation" and never fails the command; a marked method is refused exactly as before. operation.json gains `recovery` (omitted when unstated, so existing documents are byte-identical), carried through runnable-bindings into the prepared policy. Moves core to 45383f9f, which also needs ProtobufProcedures' new endpoint argument (core #836). Co-Authored-By: Claude Opus 5.5 --- cmd/generate/contract_surface.go | 2 +- cmd/generate/runnable_bindings.go | 15 +++- cmd/generate/runnable_scopes_test.go | 16 ++-- cmd/generate/runnables.go | 40 ++++++--- cmd/generate/runnables_default_test.go | 112 +++++++++++++++++++++++++ cmd/generate/runnables_fixture_test.go | 15 ++-- cmd/generate/runnables_output.go | 11 +++ cmd/generate/runnables_test.go | 6 +- docs/commands.md | 36 +++++--- go.mod | 2 +- go.sum | 2 + pkg/runnables/derived.go | 4 + 12 files changed, 215 insertions(+), 46 deletions(-) create mode 100644 cmd/generate/runnables_default_test.go diff --git a/cmd/generate/contract_surface.go b/cmd/generate/contract_surface.go index 8ec795e24..3c888ff55 100644 --- a/cmd/generate/contract_surface.go +++ b/cmd/generate/contract_surface.go @@ -115,7 +115,7 @@ func loadContractSurfaces(service *resources.Service, endpoints []*basev0.Endpoi // descriptor. A descriptor supplies schemas and options, including transitive // imports; the catalog says which of those methods this listener serves. func endpointProcedures(set *descriptorpb.FileDescriptorSet, endpoint *composition.APIContractEndpoint) ([]string, error) { - methods, err := composition.ProtobufProcedures(set, endpoint.Services) + methods, err := composition.ProtobufProcedures(set, *endpoint) if err != nil { return nil, fmt.Errorf("endpoint %s/%s: %w", endpoint.Service, endpoint.Endpoint, err) } diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index b3655ff41..9759b8d4b 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -102,8 +102,9 @@ owner must still serve it. In workspace.codefly.yaml, environments[].runnable-scope-selections maps each binding key (MODULE__OPERATION) to Core ScopeSelection messages: slot, invoke, and lookup. Every required slot needs exact resource kinds, actions and ids; -missing selections, wildcards and overlapping kinds are refused. Tool exposure -is carried from the owner. See docs/runnable.md for the selection YAML. +missing selections, wildcards and overlapping kinds are refused. Every +operation is a tool; its presentation and effect are carried from the derived +policy. See docs/runnable.md for the selection YAML. An environment that installs an operation for no resource answers its key with an explicitly empty list, KEY: []. That operation is declined: its key is absent @@ -528,6 +529,15 @@ func operationSpec(declared *runnablespkg.Operation, spelling string, codes core return nil, fmt.Errorf("completion %q is not a completion mode; %s and %s are the two", declared.Completion, basev0.RunnableExecution_COMPLETION_CALL, basev0.RunnableExecution_COMPLETION_SUBMIT) } + var recovery basev0.RunnableExecution_Recovery + if declared.Recovery != "" { + value, known := basev0.RunnableExecution_Recovery_value[declared.Recovery] + if !known || basev0.RunnableExecution_Recovery(value) == basev0.RunnableExecution_RECOVERY_UNKNOWN { + return nil, fmt.Errorf("recovery %q is not a recovery; %s and %s are the two", + declared.Recovery, basev0.RunnableExecution_RECOVERY_RECOMPUTE, basev0.RunnableExecution_RECOVERY_RECEIPT) + } + recovery = basev0.RunnableExecution_Recovery(value) + } return &corerunnable.OperationSpec{ Method: spelling, Codes: codes, @@ -545,6 +555,7 @@ func operationSpec(declared *runnablespkg.Operation, spelling string, codes core Completion: basev0.RunnableExecution_Completion(completion), Tool: declared.Tool, RequiredScopeSlots: declared.RequiredScopeSlots, + Recovery: recovery, }, nil } diff --git a/cmd/generate/runnable_scopes_test.go b/cmd/generate/runnable_scopes_test.go index 0e28ba1a3..301630ad4 100644 --- a/cmd/generate/runnable_scopes_test.go +++ b/cmd/generate/runnable_scopes_test.go @@ -27,6 +27,7 @@ func slotOperation(fixed bool) *runnablev0.Operation { } declared.RequiredScopeSlots = []*runnablev0.ScopeSlot{{Name: "source", RequiredActions: []string{"invoke", "read"}, Lookup: true}} declared.Tool = &runnablev0.ToolExposure{Name: "invoke_source", Description: "Invoke a selected source.", Effect: runnablev0.ToolExposure_EFFECT_MUTATION} + declared.Recovery = basev0.RunnableExecution_RECOVERY_RECEIPT return declared } @@ -147,11 +148,11 @@ func verifyResolvedBinding(t *testing.T, value string, tool *runnablev0.ToolExpo require.NoError(t, err) require.NoError(t, corerunnable.VerifyPrepared(prepared)) require.Empty(t, prepared.Policy.RequiredScopeSlots) + // Every runnable is a tool: one whose policy names none is presented + // from its operation. exposure, err := corerunnable.ToolFromPrepared(prepared) - if tool == nil { - require.ErrorIs(t, err, corerunnable.ErrNotATool) - } else { - require.NoError(t, err) + require.NoError(t, err) + if tool != nil { require.True(t, proto.Equal(tool, exposure)) } // The host receipt and the caller binding consume the SAME resolution. @@ -200,9 +201,12 @@ func TestGenerateRunnableBindingsResolvesThreeOperationFixtures(t *testing.T) { switch { case strings.HasSuffix(operation.Entry.Method, "/FixedOnly"): // The old path wrote the declared policy directly. Its canonical - // output stays byte-identical, not just semantically equivalent. + // output stays byte-identical, not just semantically equivalent, + // beside the tool presentation every runnable now carries. + require.NotNil(t, prepared.Policy.Tool) baseline := proto.CloneOf(prepared) - baseline.Policy = fixed + baseline.Policy = proto.CloneOf(fixed) + baseline.Policy.Tool = prepared.Policy.Tool before, err := corerunnable.EncodePrepared(baseline) require.NoError(t, err) require.Equal(t, string(before), values[key]) diff --git a/cmd/generate/runnables.go b/cmd/generate/runnables.go index 07ace16fc..c6961e4f4 100644 --- a/cmd/generate/runnables.go +++ b/cmd/generate/runnables.go @@ -35,19 +35,23 @@ var ( // published contracts mark as an operation. var RunnablesCmd = &cobra.Command{ Use: "runnables [module]", - Short: "Derive SERVICE-facility Runnable packages from the methods a module's contracts mark", + Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods", Long: `Derive a Runnable package for every gRPC method a module's published contracts -mark with the codefly.runnable.v0.operation option. +serve behind the authority hop. Every Runnable is a tool. -A unary, idempotent method an owner service already publishes becomes a -Runnable by derivation, not by authoring: the method option says which methods -are operations and under what execution policy, and the message descriptors say -what the contract is. Nothing is written twice. +A method that declares its authority policy (authority.hop.v1.policy) is an +operation with nothing added: a policy requiring only "read" derives a +read-only tool that is safe to re-run and carries no receipt; anything else +derives a mutation resolved by its effect receipt through the SDK's generic +lookup. The codefly.runnable.v0.operation option overrides those defaults, and +codefly.runnable.v0.exclude keeps a method from every agent. The message +descriptors say what the contract is, by their proto3 JSON. Nothing is written +twice. The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's served procedures in the API catalog and its serialized FileDescriptorSet. -Run that first. Only served methods carrying the option are derived; their -paired Lookup must be served on the same endpoint. No flag names a method. +Run that first. Only served methods are derived; a declared Lookup must be +served on the same endpoint. No flag names a method. For each marked method this writes, under contracts/runnables: @@ -55,12 +59,13 @@ For each marked method this writes, under contracts/runnables: ///operation.json its execution policy and authority index.json one row per operation -The tree is fully owned: a method that no longer carries the option loses its -directory. A module with no marked method writes an empty index, not an error. +The tree is fully owned: a method that is no longer an operation loses its +directory. A module with no operation writes an empty index, not an error. -A streaming method, a payload outside the bounded schema profile, or an option -core refuses is named and skipped, and the command exits non-zero after -reporting every one of them. +A method derived by default that cannot be one — a stream, a policy requiring +nothing, a payload proto3 JSON cannot carry — is listed and left a plain call. +A method carrying the option that core refuses is named and skipped, and the +command exits non-zero after reporting every one of them. --check is the CI drift gate: it regenerates into a temporary directory and prints a unified diff of what differs, without writing anything. @@ -121,6 +126,9 @@ Examples: for _, entry := range derived.index.Operations { cli.Info("runnables: %s %s %s", entry.Name, entry.Method, entry.Digest) } + for _, notice := range derived.notices { + cli.Info("runnables: not an operation: %s", notice) + } cli.Header(1, "Derived %d runnable operation(s)", len(derived.index.Operations)) return nil }, @@ -144,6 +152,8 @@ type derivation struct { // give one release a different digest per machine. workspaceDir string refusals []string + // notices are methods a default derivation left plain calls, and why. + notices []string } // refusal reports every method that could not be derived. They are collected @@ -311,6 +321,10 @@ func deriveMethod(module *resources.Module, endpoint *composition.APIContractEnd if errors.Is(err, corerunnable.ErrNotAnOperation) { return nil } + if errors.Is(err, corerunnable.ErrNotDerivable) { + derived.notices = append(derived.notices, fmt.Sprintf("%s: %v", fullMethod, err)) + return nil + } derived.refusals = append(derived.refusals, fmt.Sprintf("%s: %v", fullMethod, err)) return nil } diff --git a/cmd/generate/runnables_default_test.go b/cmd/generate/runnables_default_test.go new file mode 100644 index 000000000..9cd391c6c --- /dev/null +++ b/cmd/generate/runnables_default_test.go @@ -0,0 +1,112 @@ +package generate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + runnablespkg "github.com/codefly-dev/cli/pkg/runnables" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" + googleproto "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +// hopPolicyProto is authority.hop.v1's policy.proto as a module's contract +// ships it beside the methods it marks. +func hopPolicyProto() *descriptorpb.FileDescriptorProto { + repeatedString := stringField("actions", 2) + repeatedString.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + requires := selfField("requires", 1, ".authority.hop.v1.Requirement") + requires.Label = descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + return &descriptorpb.FileDescriptorProto{ + Name: googleproto.String("authority/hop/v1/policy.proto"), + Package: googleproto.String("authority.hop.v1"), + Syntax: googleproto.String("proto3"), + Dependency: []string{"google/protobuf/descriptor.proto"}, + MessageType: []*descriptorpb.DescriptorProto{ + descriptorMessage("Policy", requires, boolField("any_checked_caller", 2)), + descriptorMessage("Requirement", stringField("resource_kind", 1), repeatedString), + }, + Extension: []*descriptorpb.FieldDescriptorProto{{ + Name: googleproto.String("policy"), Number: googleproto.Int32(51100), + Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: googleproto.String(".authority.hop.v1.Policy"), + Extendee: googleproto.String(".google.protobuf.MethodOptions"), JsonName: googleproto.String("policy"), + }}, + } +} + +// hopPolicyMethod carries only `requires: [{resource_kind: kind, actions}]`, +// as the unknown bytes a reader without the Go type holds. +func hopPolicyMethod(name, kind string, actions ...string) *descriptorpb.MethodDescriptorProto { + var requirement []byte + requirement = protowire.AppendTag(requirement, 1, protowire.BytesType) + requirement = protowire.AppendString(requirement, kind) + for _, action := range actions { + requirement = protowire.AppendTag(requirement, 2, protowire.BytesType) + requirement = protowire.AppendString(requirement, action) + } + var policy []byte + policy = protowire.AppendTag(policy, 1, protowire.BytesType) + policy = protowire.AppendBytes(policy, requirement) + method := markedMethod(name, ".documents.ingest.v1.ApplyTextRequest", ".documents.ingest.v1.ApplyTextResponse", nil) + method.Options = &descriptorpb.MethodOptions{} + var raw []byte + raw = protowire.AppendTag(raw, 51100, protowire.BytesType) + raw = protowire.AppendBytes(raw, policy) + method.Options.ProtoReflect().SetUnknown(raw) + return method +} + +// withHopPolicy adds the policy declaration to a published descriptor set. +func withHopPolicy(t *testing.T, contract []byte) []byte { + t.Helper() + var set descriptorpb.FileDescriptorSet + require.NoError(t, googleproto.Unmarshal(contract, &set)) + set.File = append(set.File, hopPolicyProto()) + out, err := googleproto.Marshal(&set) + require.NoError(t, err) + return out +} + +// A method declaring only its authority policy is an operation, and a tool: +// a read is recomputed with no receipt; a stream is listed, not refused. +func TestGenerateRunnablesDerivesEveryMethodBehindTheHop(t *testing.T) { + ctx := context.Background() + streaming := hopPolicyMethod("StreamText", "documents", "read") + streaming.ServerStreaming = googleproto.Bool(true) + file := ingestionFile(conformingOperation(), hopPolicyMethod("CountText", "documents", "read"), + hopPolicyMethod("WriteText", "documents", "write"), streaming) + root, moduleDir := saveRunnableFixture(t, ctx, withHopPolicy(t, descriptorSet(t, file)), "0.1.0") + t.Chdir(root) + resetRunnablesFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + + read := func(method string) map[string]any { + data, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(runnablespkg.DerivedDir), "runtime-worker", "grpc", method, runnablespkg.OperationFileName)) + require.NoError(t, err) + var document map[string]any + require.NoError(t, json.Unmarshal(data, &document)) + return document + } + count := read("CountText") + require.Equal(t, "RECOVERY_RECOMPUTE", count["recovery"]) + require.Empty(t, count["lookup_scopes"]) + require.Equal(t, "EFFECT_READ_ONLY", count["tool"].(map[string]any)["effect"]) + require.Equal(t, "documents", count["audience"]) + + write := read("WriteText") + require.Equal(t, "RECOVERY_RECEIPT", write["recovery"]) + require.Equal(t, "EFFECT_MUTATION", write["tool"].(map[string]any)["effect"]) + require.EqualValues(t, 1, write["max_attempts"]) + + // The explicit option keeps its document, beside its presentation. + apply := read("ApplyText") + require.Nil(t, apply["recovery"]) + require.Equal(t, "ApplyText", apply["tool"].(map[string]any)["name"]) + + index := readIndex(t, moduleDir) + require.Len(t, index.Operations, 3, "the stream is a call, not an operation") +} diff --git a/cmd/generate/runnables_fixture_test.go b/cmd/generate/runnables_fixture_test.go index 1e9a1b1c0..68ba726f4 100644 --- a/cmd/generate/runnables_fixture_test.go +++ b/cmd/generate/runnables_fixture_test.go @@ -50,11 +50,12 @@ func int64Field(name string, number int32) *descriptorpb.FieldDescriptorProto { return field } -// uint64Field is outside the bounded profile: its range exceeds the signed -// 64-bit integer the profile carries. -func uint64Field(name string, number int32) *descriptorpb.FieldDescriptorProto { +// selfField is outside the bounded profile: a message reaching itself has no +// bounded shape, whatever proto3 JSON would spell its scalars as. +func selfField(name string, number int32, message string) *descriptorpb.FieldDescriptorProto { field := stringField(name, number) - field.Type = descriptorpb.FieldDescriptorProto_TYPE_UINT64.Enum() + field.Type = descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum() + field.TypeName = googleproto.String(message) return field } @@ -130,7 +131,7 @@ func ingestionFile(declared *runnablev0.Operation, extra ...*descriptorpb.Method ), descriptorMessage("LookupTextRequest", stringField("effect_digest", 1)), // CountRequest is deliberately out of the bounded profile. - descriptorMessage("CountRequest", uint64Field("total", 1)), + descriptorMessage("CountRequest", selfField("total", 1, ".documents.ingest.v1.CountRequest")), }, Service: []*descriptorpb.ServiceDescriptorProto{{ Name: googleproto.String("IngestionService"), @@ -139,8 +140,8 @@ func ingestionFile(declared *runnablev0.Operation, extra ...*descriptorpb.Method } } -// outOfProfileMethod takes a uint64 the bounded profile has no type for. It is -// marked, so it is refused rather than skipped. +// outOfProfileMethod takes a recursive message the bounded profile has no +// shape for. It is marked, so it is refused rather than skipped. func outOfProfileMethod() *descriptorpb.MethodDescriptorProto { return markedMethod("CountText", ".documents.ingest.v1.CountRequest", ".documents.ingest.v1.ApplyTextResponse", conformingOperation()) } diff --git a/cmd/generate/runnables_output.go b/cmd/generate/runnables_output.go index 4f68299e1..a16097195 100644 --- a/cmd/generate/runnables_output.go +++ b/cmd/generate/runnables_output.go @@ -37,9 +37,20 @@ func newOperationDocument(spec *corerunnable.OperationSpec) *runnablespkg.Operat Completion: spec.Completion.String(), Tool: spec.Tool, RequiredScopeSlots: spec.RequiredScopeSlots, + Recovery: recoveryName(spec.Recovery), } } +// recoveryName spells a declared recovery, empty when the owner stated none: +// an operation derived before recovery could be stated keeps its document +// byte for byte, and reads back as the receipt it always was. +func recoveryName(recovery basev0.RunnableExecution_Recovery) string { + if recovery == basev0.RunnableExecution_RECOVERY_UNKNOWN { + return "" + } + return recovery.String() +} + func scopeDocuments(scopes []*basev0.WorkScopeV1) []runnablespkg.Scope { documents := make([]runnablespkg.Scope, 0, len(scopes)) for _, scope := range scopes { diff --git a/cmd/generate/runnables_test.go b/cmd/generate/runnables_test.go index a007bf0df..864a912b6 100644 --- a/cmd/generate/runnables_test.go +++ b/cmd/generate/runnables_test.go @@ -341,10 +341,10 @@ func TestGenerateRunnablesRefusalLeavesTheCommittedTreeAlone(t *testing.T) { t.Fatalf("expected the generated tree to hold 3 files, got %d", len(committed)) } - // The owner breaks ApplyText's payload with a uint64, outside the bounded - // profile. The method still carries the option. + // The owner breaks ApplyText's payload with a recursive field, outside the + // bounded profile. The method still carries the option. broken := ingestionFile(conformingOperation()) - broken.MessageType[0].Field = append(broken.MessageType[0].Field, uint64Field("total", 99)) + broken.MessageType[0].Field = append(broken.MessageType[0].Field, selfField("total", 99, ".documents.ingest.v1."+broken.MessageType[0].GetName())) publishContract(t, moduleDir, descriptorSet(t, broken)) err := RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}) diff --git a/docs/commands.md b/docs/commands.md index e6188cae4..187cc6639 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3247,7 +3247,7 @@ codefly generate client --from contracts:module-saas-starter/contracts/api --lan codefly generate proto --proto ../proto --output ./generated # Generate code from local proto files, in the proto companion codefly generate contracts saas-starter # Export a module's interface endpoints as API contracts codefly generate contracts saas-starter --check # CI drift gate: fail if the on-disk catalog is stale -codefly generate runnables documents # Derive a Runnable package per method carrying the operation option +codefly generate runnables documents # Derive a Runnable package, and so a tool, per method served behind the hop codefly generate runnables documents --check # CI drift gate: fail if the derived packages are stale codefly generate runnable-bindings --env staging # Bind every derived operation to its owner endpoint as the environment resolves it ``` @@ -3579,17 +3579,24 @@ is the CI drift gate. #### generate runnables `codefly generate runnables [module]` derives a **SERVICE-facility Runnable -package** for every gRPC method a module's published contracts mark with the -`codefly.runnable.v0.operation` method option. A unary, idempotent method an -owner service already publishes becomes a Runnable by derivation, not by -authoring: the option says which methods are operations and under what -execution policy, and the message descriptors say what the contract is. +package** for every unary gRPC method a module's published contracts serve +behind the authority hop, and every Runnable is a tool. A method that declares +its authority policy (`authority.hop.v1.policy`) needs nothing else: a policy +requiring only `read` derives a read-only tool that is safe to re-run and +carries no receipt (`recovery: RECOVERY_RECOMPUTE`); anything else derives a +mutation resolved by its effect receipt through the SDK's generic lookup, +attempted once. The tool's name and description are the method's name and +leading doc comment. The `codefly.runnable.v0.operation` option overrides +those defaults, and `codefly.runnable.v0.exclude` keeps a method from every +agent. The message descriptors say what the contract is, projected by their +proto3 JSON (core docs/runnable.md). The input is what `generate contracts` already wrote — each gRPC/connect endpoint's served `services[].procedures` in the API catalog and its `contract.binpb` — so run that first. A method is derived only when that endpoint -serves it and its descriptor carries the option. Its paired Lookup must also be -served there; descriptor availability alone is insufficient. Catalog procedures +serves it and its descriptor declares an authority policy or carries the option. +A declared Lookup must also be served there; descriptor availability alone is +insufficient. Catalog procedures absent from the endpoint's descriptor package are refused. No flag names a method. ``` @@ -3603,13 +3610,13 @@ contracts/runnables/ digest over, so what a composition reads and what was hashed are the same bytes. `operation.json` is kept beside it rather than inside it: policy and authority are installed with a binding, and two installations of one contract -may differ in both. Required scope slots and optional tool exposure are preserved -in `operation.json`; generation does not select resource IDs. `index.json` is +may differ in both. Required scope slots, the tool presentation and the recovery +are preserved in `operation.json`; generation does not select resource IDs. `index.json` is what a composition reads to prepare bindings — identity, digest, full method, input and output message names and endpoint coordinates per row. -The tree is fully owned: a method that no longer carries the option or is no +The tree is fully owned: a method that is no longer an operation or is no longer served on an endpoint loses that endpoint's directory. Only the three file names a generation produces are removed, and only the directories that empties, so pointing `--output` at a directory this @@ -3619,8 +3626,11 @@ module that declares no `interface:` and so exports no endpoint at all, both write an empty index rather than an error; a module that *does* declare an interface but has no catalog is told to run `generate contracts` first. -A streaming method, a payload outside the bounded schema profile, or an option -core refuses is named with its field path; the walk continues so an owner +A method derived by default that cannot be one — a stream, a policy requiring +nothing (`any_checked_caller`), a payload proto3 JSON cannot carry (`Any`, a +recursive message) — is listed as "not an operation" and stays a plain call; it +never fails the command. A method carrying the option that is a stream, has a +payload outside the profile, or that core refuses is named with its field path; the walk continues so an owner fixing a contract sees the whole list, and the command then exits non-zero **having written nothing**. A failing run leaves the tree exactly as it found it: writing the methods that did derive would delete the refused one's diff --git a/go.mod b/go.mod index 77892c892..ad78ed97a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 + github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 56e7b3aab..7becbf81c 100644 --- a/go.sum +++ b/go.sum @@ -176,6 +176,8 @@ github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7 h1:sFkQTBSvAjz github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8 h1:4xDq2A1P/1CAhTHy7PsHIlbbvAo1ywDF5vxtD2eQiJ8= +github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/runnables/derived.go b/pkg/runnables/derived.go index 1d6120abc..2ea281717 100644 --- a/pkg/runnables/derived.go +++ b/pkg/runnables/derived.go @@ -167,6 +167,10 @@ type Operation struct { // policy vocabulary. Slots remain unresolved until the composition binds. Tool *runnablev0.ToolExposure `json:"tool,omitempty"` RequiredScopeSlots []*runnablev0.ScopeSlot `json:"required_scope_slots,omitempty"` + // Recovery is how an uncertain outcome is resolved, spelled as the + // codefly.base.v0.RunnableExecution.Recovery enum name. RECOVERY_RECOMPUTE + // is a read, which carries no receipt; empty is RECOVERY_RECEIPT. + Recovery string `json:"recovery,omitempty"` } // operationJSON preserves the existing document's scalar spellings and order, From 1d19fc49f9d48d4e0f6dd62116b7097324851511 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:17:59 -0400 Subject: [PATCH 2/6] fix(generate): a derived mutation reads its receipt under its invoke scopes Moves core to 87497b44 (codefly-dev/core#843): a derived mutation is written with exactly the scopes its authority policy requires and no lookup scopes, its receipt read under those invoke scopes. The default-derivation test now holds both. Co-Authored-By: Claude Opus 5.5 --- cmd/generate/runnables_default_test.go | 4 ++++ go.mod | 2 +- go.sum | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/generate/runnables_default_test.go b/cmd/generate/runnables_default_test.go index 9cd391c6c..8b03304e0 100644 --- a/cmd/generate/runnables_default_test.go +++ b/cmd/generate/runnables_default_test.go @@ -101,6 +101,10 @@ func TestGenerateRunnablesDerivesEveryMethodBehindTheHop(t *testing.T) { require.Equal(t, "RECOVERY_RECEIPT", write["recovery"]) require.Equal(t, "EFFECT_MUTATION", write["tool"].(map[string]any)["effect"]) require.EqualValues(t, 1, write["max_attempts"]) + // The receipt is read under the scopes that made the effect: no read is + // added on the written kind, and no lookup scopes are declared. + require.Equal(t, []any{map[string]any{"resource_kind": "documents", "actions": []any{"write"}}}, write["invoke_scopes"]) + require.Empty(t, write["lookup_scopes"]) // The explicit option keeps its document, beside its presentation. apply := read("ApplyText") diff --git a/go.mod b/go.mod index ad78ed97a..367703c1c 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8 + github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 7becbf81c..9647061bb 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsU github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8 h1:4xDq2A1P/1CAhTHy7PsHIlbbvAo1ywDF5vxtD2eQiJ8= github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1 h1:rlFnRxJkYiwKfCl2oZPoB5mP+H31BZgmw36jScG2yB8= +github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 20d15277e2d3ab6e6fde002bfe8e1fd5b3efafcd Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:48:24 -0400 Subject: [PATCH 3/6] fix(generate): bounded release names, an honest refusal header, and a report of what became a tool (r87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r87 B1 — a derived release name over 50 characters is shortened deterministically to a stable prefix and an eight-digit digest of the whole name (core BoundedName), so a long method name never fails generation (module-document-store's GetDocumentTransformationReceipt and friends now derive). The refusal header no longer claims the methods carry the operation option: "N method(s) cannot be derived as runnable operations". r87 M2 / decision 6 — the run reports each operation's origin (option or default) and effect, the methods left plain calls and why, and, when any operation was derived by default, their count, the mutations among them, the grants rule and the codefly.runnable.v0.exclude opt-out. r87 m1 — the default-derivation test covers exclude carried as unknown option bytes (51004) and a release name past 50 characters. r87 B2 — moves core to 483255ec (core#843 with r86 fixed). The source-operation and document-store oracle tests follow the wire spelling (JSON names, int64 as strings): the producer's committed package is held to the derived one modulo that re-spelling until module-saas-starter regenerates. Co-Authored-By: Claude Opus 5.5 --- .../runnable_source_operation_test.go | 33 ++++++- cmd/generate/runnables.go | 86 +++++++++++++++++-- cmd/generate/runnables_default_test.go | 21 ++++- cmd/generate/runnables_test.go | 14 +-- docs/commands.md | 21 ++++- go.mod | 2 +- go.sum | 2 + 7 files changed, 156 insertions(+), 23 deletions(-) diff --git a/cmd/generate/runnable_source_operation_test.go b/cmd/generate/runnable_source_operation_test.go index 3fb999f44..7d8d55575 100644 --- a/cmd/generate/runnable_source_operation_test.go +++ b/cmd/generate/runnable_source_operation_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "github.com/codefly-dev/cli/pkg/environments" @@ -104,7 +105,13 @@ func TestGenerateRealSourceOperations(t *testing.T) { key := RunnableBindingKey("saas-starter", operation.Entry.Name) value := values[key] prepared := verifyResolvedBinding(t, value, nil) - require.True(t, proto.Equal(published[operation.Entry.Method].Contract, prepared.Contract), "prepared contract matches the producer's committed runnable package") + // The producer's committed package predates the wire spelling (core + // #843: JSON names, 64-bit integers as strings); module-saas-starter + // regenerates to it. Until then the two agree field for field once the + // committed one is re-spelled, which is what this holds. + require.True(t, proto.Equal(operation.Package.GetContract(), prepared.Contract), "prepared contract is the derived one") + requireSameModuloWireSpelling(t, published[operation.Entry.Method].GetContract().GetInput().GetFields(), prepared.Contract.GetInput().GetFields()) + requireSameModuloWireSpelling(t, published[operation.Entry.Method].GetContract().GetOutput().GetFields(), prepared.Contract.GetOutput().GetFields()) require.Equal(t, "connect", prepared.Operation.Endpoint) require.Len(t, prepared.Policy.InvokeScopes, 1) require.True(t, proto.Equal(sourceSelection().Invoke[0], prepared.Policy.InvokeScopes[0])) @@ -115,3 +122,27 @@ func TestGenerateRealSourceOperations(t *testing.T) { require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"saas-starter"})) require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) } + +// requireSameModuloWireSpelling holds a contract committed before the wire +// spelling to the one derived now: the same members in the same order, each +// named by its JSON name, a 64-bit integer as a STRING of format int64. +func requireSameModuloWireSpelling(t *testing.T, committed, derived []*basev0.RunnableField) { + t.Helper() + require.Len(t, derived, len(committed)) + for i, before := range committed { + after := derived[i] + parts := strings.Split(before.GetName(), "_") + for j := 1; j < len(parts); j++ { + if parts[j] != "" { + parts[j] = strings.ToUpper(parts[j][:1]) + parts[j][1:] + } + } + require.Equal(t, strings.Join(parts, ""), after.GetName()) + if before.GetType() != after.GetType() { + require.Equal(t, basev0.RunnableField_INTEGER, before.GetType(), "%s", before.GetName()) + require.Equal(t, basev0.RunnableField_STRING, after.GetType(), "%s", before.GetName()) + } + require.Equal(t, before.GetOptional(), after.GetOptional(), "%s", before.GetName()) + requireSameModuloWireSpelling(t, before.GetFields(), after.GetFields()) + } +} diff --git a/cmd/generate/runnables.go b/cmd/generate/runnables.go index c6961e4f4..c3c44d359 100644 --- a/cmd/generate/runnables.go +++ b/cmd/generate/runnables.go @@ -16,12 +16,14 @@ import ( runnablespkg "github.com/codefly-dev/cli/pkg/runnables" "github.com/codefly-dev/core/composition" 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/codefly-dev/core/shared" "github.com/spf13/cobra" googleproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/descriptorpb" ) @@ -123,12 +125,7 @@ Examples: if err = writeDerivedTree(ctx, derived.files, output); err != nil { return err } - for _, entry := range derived.index.Operations { - cli.Info("runnables: %s %s %s", entry.Name, entry.Method, entry.Digest) - } - for _, notice := range derived.notices { - cli.Info("runnables: not an operation: %s", notice) - } + derived.report() cli.Header(1, "Derived %d runnable operation(s)", len(derived.index.Operations)) return nil }, @@ -154,6 +151,9 @@ type derivation struct { refusals []string // notices are methods a default derivation left plain calls, and why. notices []string + // origins says, per operation name, whether the owner declared it and + // what its tool's effect is. + origins map[string]derivedOperation } // refusal reports every method that could not be derived. They are collected @@ -163,10 +163,48 @@ func (d *derivation) refusal() error { if len(d.refusals) == 0 { return nil } - return fmt.Errorf("%d method(s) carry the operation option but cannot be derived:\n %s", + return fmt.Errorf("%d method(s) cannot be derived as runnable operations:\n %s", len(d.refusals), strings.Join(d.refusals, "\n ")) } +// derivedOperation is what the report says of one operation: where it came +// from and what it does. +type derivedOperation struct { + explicit bool + effect string +} + +// report says what the run made of the module, so an owner learns which of +// its methods became tools without reading the tree: each operation with its +// origin and effect, the methods left plain calls and why, and — when any +// operation was derived by default — how to keep a method from every agent. +func (d *derivation) report() { + var defaults, mutations []string + for i := range d.index.Operations { + entry := &d.index.Operations[i] + operation := d.origins[entry.Name] + origin := "option" + if !operation.explicit { + origin = "default" + defaults = append(defaults, entry.Method) + if operation.effect == runnablev0.ToolExposure_EFFECT_MUTATION.String() { + mutations = append(mutations, entry.Method) + } + } + cli.Info("runnables: %s %s %s %s %s", entry.Name, entry.Method, origin, strings.TrimPrefix(operation.effect, "EFFECT_"), entry.Digest) + } + for _, notice := range d.notices { + cli.Info("runnables: not an operation: %s", notice) + } + if len(defaults) == 0 { + return + } + cli.Info("runnables: %d operation(s) derived by default from their authority policy, %d of them mutations: %s", + len(defaults), len(mutations), strings.Join(mutations, ", ")) + cli.Info("runnables: every operation is a tool, offered only to callers whose grants cover its binding; " + + "a method no agent may ever call declares `option (codefly.runnable.v0.exclude) = true;`") +} + // deriveRunnables walks every protobuf contract the module published and // derives the operations its methods declare. func deriveRunnables(ctx context.Context, workspace *resources.Workspace, module *resources.Module) (*derivation, error) { @@ -196,7 +234,8 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string, Module: module.Name, Operations: []runnablespkg.IndexEntry{}, }, - files: map[string][]byte{}, + files: map[string][]byte{}, + origins: map[string]derivedOperation{}, } var endpoints []composition.APIContractEndpoint @@ -347,6 +386,7 @@ func deriveMethod(module *resources.Module, endpoint *composition.APIContractEnd derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes operation := pkg.GetServiceOperations()[0] + derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: declaresOperation(files, fullMethod), effect: spec.Tool.GetEffect().String()} derived.index.Operations = append(derived.index.Operations, runnablespkg.IndexEntry{ Name: pkg.GetIdentity().GetName(), Version: pkg.GetIdentity().GetVersion(), @@ -441,8 +481,36 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s // because one proto service may be published on both a grpc and a connect // endpoint: those are two operations reached two ways, and a name that dropped // the endpoint would give them one identity and two digests. +// +// A release name is at most maxDerivedName characters. A longer one is +// shortened deterministically — a stable prefix and a short digest of the +// whole name — so a long method name never fails generation and two long +// names never collapse into one. func derivedName(service, endpoint, method string) string { - return strings.Join([]string{service, endpoint, kebab(method)}, "-") + return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(method)}, "-"), maxDerivedName, "-") +} + +// maxDerivedName is the longest release name a RunnableIdentity admits. +const maxDerivedName = 50 + +// declaresOperation reports a method carrying the codefly.runnable.v0.operation +// option: an operation its owner declared, rather than one derived by default. +func declaresOperation(files *protoregistry.Files, fullMethod string) bool { + service, name, _ := strings.Cut(strings.TrimPrefix(fullMethod, "/"), "/") + descriptor, err := files.FindDescriptorByName(protoreflect.FullName(service)) + if err != nil { + return false + } + published, isService := descriptor.(protoreflect.ServiceDescriptor) + if !isService { + return false + } + method := published.Methods().ByName(protoreflect.Name(name)) + if method == nil { + return false + } + options, isOptions := method.Options().(*descriptorpb.MethodOptions) + return isOptions && googleproto.HasExtension(options, runnablev0.E_Operation) } // kebab spells a method name the way a resource name is spelled. An acronym diff --git a/cmd/generate/runnables_default_test.go b/cmd/generate/runnables_default_test.go index 8b03304e0..0fd8ecea4 100644 --- a/cmd/generate/runnables_default_test.go +++ b/cmd/generate/runnables_default_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" @@ -77,8 +78,16 @@ func TestGenerateRunnablesDerivesEveryMethodBehindTheHop(t *testing.T) { ctx := context.Background() streaming := hopPolicyMethod("StreamText", "documents", "read") streaming.ServerStreaming = googleproto.Bool(true) + // exclude carried as the unknown bytes a contract on disk holds (51004 = true). + excluded := hopPolicyMethod("PurgeText", "documents", "write") + raw := excluded.Options.ProtoReflect().GetUnknown() + raw = protowire.AppendTag(raw, 51004, protowire.VarintType) + raw = protowire.AppendVarint(raw, 1) + excluded.Options.ProtoReflect().SetUnknown(raw) + // A method whose derived release name exceeds 50 characters. + long := hopPolicyMethod("GetDocumentTransformationReceipt", "documents", "read") file := ingestionFile(conformingOperation(), hopPolicyMethod("CountText", "documents", "read"), - hopPolicyMethod("WriteText", "documents", "write"), streaming) + hopPolicyMethod("WriteText", "documents", "write"), streaming, excluded, long) root, moduleDir := saveRunnableFixture(t, ctx, withHopPolicy(t, descriptorSet(t, file)), "0.1.0") t.Chdir(root) resetRunnablesFlags(t) @@ -112,5 +121,13 @@ func TestGenerateRunnablesDerivesEveryMethodBehindTheHop(t *testing.T) { require.Equal(t, "ApplyText", apply["tool"].(map[string]any)["name"]) index := readIndex(t, moduleDir) - require.Len(t, index.Operations, 3, "the stream is a call, not an operation") + require.Len(t, index.Operations, 4, "the stream and the excluded method are calls, not operations") + for _, entry := range index.Operations { + require.LessOrEqual(t, len(entry.Name), 50, entry.Name) + require.NotContains(t, entry.Method, "PurgeText") + if strings.HasSuffix(entry.Method, "/GetDocumentTransformationReceipt") { + require.Equal(t, derivedName("runtime-worker", "grpc", "GetDocumentTransformationReceipt"), entry.Name) + require.Regexp(t, `^runtime-worker-grpc-get-document-transf.*-[0-9a-f]{8}$`, entry.Name) + } + } } diff --git a/cmd/generate/runnables_test.go b/cmd/generate/runnables_test.go index 864a912b6..4709cf3c7 100644 --- a/cmd/generate/runnables_test.go +++ b/cmd/generate/runnables_test.go @@ -517,14 +517,14 @@ func TestGenerateRunnablesReproducesTheDocumentStoreOracle(t *testing.T) { {Name: "path", Type: basev0.RunnableField_STRING}, {Name: "revision", Type: basev0.RunnableField_STRING}, {Name: "commit", Type: basev0.RunnableField_STRING}, - {Name: "cursor", Type: basev0.RunnableField_INTEGER}, - {Name: "content_type", Type: basev0.RunnableField_STRING}, + {Name: "cursor", Type: basev0.RunnableField_STRING, Format: corerunnable.FormatInt64}, + {Name: "contentType", Type: basev0.RunnableField_STRING}, {Name: "text", Type: basev0.RunnableField_STRING}, }}, Output: &basev0.RunnableSchema{Fields: []*basev0.RunnableField{ {Name: "reference", Type: basev0.RunnableField_STRING}, - {Name: "content_hash", Type: basev0.RunnableField_STRING}, - {Name: "effect_digest", Type: basev0.RunnableField_STRING}, + {Name: "contentHash", Type: basev0.RunnableField_STRING}, + {Name: "effectDigest", Type: basev0.RunnableField_STRING}, {Name: "minted", Type: basev0.RunnableField_BOOLEAN}, {Name: "skipped", Type: basev0.RunnableField_BOOLEAN}, {Name: "quarantined", Type: basev0.RunnableField_BOOLEAN}, @@ -564,10 +564,12 @@ func TestGenerateRunnablesReproducesTheDocumentStoreOracle(t *testing.T) { // The digest is over the canonical package, so a field added to // RunnableExecution moves it for every package in the fleet. This one moved // with the completion mode (codefly-dev/core#678); it had been - // 4c26fb95a0236b94107b30f34783cd893f2be36e0d157340c247013fd93a9a1f. The + // 4c26fb95a0236b94107b30f34783cd893f2be36e0d157340c247013fd93a9a1f, then + // ee460290f91e2cad2d9e7efe6d740ba35ff5ec89b4395bbde1e8bd19bd8291a0 until the + // contract was spelled as the Connect wire carries it (core#843). The // equality check above is what proves the derivation still matches what // document-store publishes — this line only pins the number. - if asOracle.GetDigest() != "ee460290f91e2cad2d9e7efe6d740ba35ff5ec89b4395bbde1e8bd19bd8291a0" { + if asOracle.GetDigest() != "fdfc6ac25ae0eb138d4f51dc255343de093ce3048c3262f8d5e0e61035027b19" { t.Fatalf("digest = %s, want the published oracle's", asOracle.GetDigest()) } } diff --git a/docs/commands.md b/docs/commands.md index 187cc6639..249f7b9ce 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3584,12 +3584,25 @@ behind the authority hop, and every Runnable is a tool. A method that declares its authority policy (`authority.hop.v1.policy`) needs nothing else: a policy requiring only `read` derives a read-only tool that is safe to re-run and carries no receipt (`recovery: RECOVERY_RECOMPUTE`); anything else derives a -mutation resolved by its effect receipt through the SDK's generic lookup, -attempted once. The tool's name and description are the method's name and +mutation with `RECOVERY_RECEIPT`, attempted once — at-most-once until the SDK's +receipt guard follows the derivation, an uncertain outcome surfaced as +unknown. A receipt lookup (another operation's `lookup_method`, or +`Lookup` beside ``) is never derived. The tool's name and description are the method's name and leading doc comment. The `codefly.runnable.v0.operation` option overrides those defaults, and `codefly.runnable.v0.exclude` keeps a method from every -agent. The message descriptors say what the contract is, projected by their -proto3 JSON (core docs/runnable.md). +agent. Every operation is a tool, and who reaches it is a matter of grants: a +caller offers a person only the tools whose binding the person's grants cover, +so a runtime-only or administrative operation derives and never reaches a +person without the grant. The message descriptors say what the contract is, +spelled as the Connect wire carries it — JSON member names, 64-bit integers as +strings (core docs/runnable.md). + +The run reports each operation with its origin (`option` or `default`) and +effect, lists the methods left plain calls and why, and — when any operation +was derived by default — counts them, names the mutations, and points at +`codefly.runnable.v0.exclude`. A release name longer than 50 characters is +shortened deterministically to a stable prefix and an eight-digit digest, so +a long method name never fails generation. The input is what `generate contracts` already wrote — each gRPC/connect endpoint's served `services[].procedures` in the API catalog and its diff --git a/go.mod b/go.mod index 367703c1c..c667dfb79 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1 + github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 9647061bb..860649141 100644 --- a/go.sum +++ b/go.sum @@ -180,6 +180,8 @@ github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8 h1:4xDq2A1P/1C github.com/codefly-dev/core v0.19.7-0.20261026010229-45383f9ff8c8/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1 h1:rlFnRxJkYiwKfCl2oZPoB5mP+H31BZgmw36jScG2yB8= github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45 h1:2pKa3A044y3W/eKNX2iIuaNMUmd5Kk06w0SrGkoejFM= +github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 11f8cdb1002ed2e3fd749b53f9821e8c8693e6de Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:49:13 -0400 Subject: [PATCH 4/6] chore(deps): core at b6c00122, core#843's head (its Python bindings; no Go change) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c667dfb79..d826b1899 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45 + github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 860649141..90231a4f0 100644 --- a/go.sum +++ b/go.sum @@ -182,6 +182,8 @@ github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1 h1:rlFnRxJkYiw github.com/codefly-dev/core v0.19.7-0.20261026011658-87497b44fac1/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45 h1:2pKa3A044y3W/eKNX2iIuaNMUmd5Kk06w0SrGkoejFM= github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c h1:9oKvfr/IBPSN/YpzmPskG87O2mwBcjrjnxtdE7AQhSA= +github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 6e895fcc00d74294db924df5b420314f78dda15f Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:20:05 -0400 Subject: [PATCH 5/6] fix(generate): one report line per method, the origin from core, a tighter bridge (r102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r102 m1 — the summary counts and names methods, each once, and a notice prints once per method, though one method is an operation per endpoint. r102 m2 — the origin is core's OperationSpec.Declared; declaresOperation, a second mechanism for the same fact, is gone. r102 m3 — requireSameModuloWireSpelling requires format int64/uint64 on an INTEGER that became a STRING, every non-required key optional, and recurses into array items. r102 m4 — core at 0c02ef0f (core#843 with r100's majors fixed): every non-required key optional on the wire, so the document-store oracle pin moves. Co-Authored-By: Claude Opus 5.5 --- .../runnable_source_operation_test.go | 31 +++++++++---- cmd/generate/runnables.go | 45 +++++++++---------- cmd/generate/runnables_test.go | 35 ++++++++------- go.mod | 2 +- go.sum | 2 + 5 files changed, 64 insertions(+), 51 deletions(-) diff --git a/cmd/generate/runnable_source_operation_test.go b/cmd/generate/runnable_source_operation_test.go index 7d8d55575..56543c457 100644 --- a/cmd/generate/runnable_source_operation_test.go +++ b/cmd/generate/runnable_source_operation_test.go @@ -125,12 +125,20 @@ func TestGenerateRealSourceOperations(t *testing.T) { // requireSameModuloWireSpelling holds a contract committed before the wire // spelling to the one derived now: the same members in the same order, each -// named by its JSON name, a 64-bit integer as a STRING of format int64. +// named by its JSON name, a 64-bit integer as a STRING of format int64 or +// uint64 and nothing else, every non-required key optional, array items held +// the same way. A stopgap: it goes when module-saas-starter regenerates. func requireSameModuloWireSpelling(t *testing.T, committed, derived []*basev0.RunnableField) { t.Helper() require.Len(t, derived, len(committed)) for i, before := range committed { - after := derived[i] + requireFieldModuloWireSpelling(t, before, derived[i], true) + } +} + +func requireFieldModuloWireSpelling(t *testing.T, before, after *basev0.RunnableField, member bool) { + t.Helper() + if member { parts := strings.Split(before.GetName(), "_") for j := 1; j < len(parts); j++ { if parts[j] != "" { @@ -138,11 +146,18 @@ func requireSameModuloWireSpelling(t *testing.T, committed, derived []*basev0.Ru } } require.Equal(t, strings.Join(parts, ""), after.GetName()) - if before.GetType() != after.GetType() { - require.Equal(t, basev0.RunnableField_INTEGER, before.GetType(), "%s", before.GetName()) - require.Equal(t, basev0.RunnableField_STRING, after.GetType(), "%s", before.GetName()) - } - require.Equal(t, before.GetOptional(), after.GetOptional(), "%s", before.GetName()) - requireSameModuloWireSpelling(t, before.GetFields(), after.GetFields()) + require.True(t, after.GetOptional(), "%s: proto3 JSON may omit every non-required key", after.GetName()) + } + if before.GetType() == after.GetType() { + require.Equal(t, before.GetFormat(), after.GetFormat(), "%s", after.GetName()) + } else { + require.Equal(t, basev0.RunnableField_INTEGER, before.GetType(), "%s", before.GetName()) + require.Equal(t, basev0.RunnableField_STRING, after.GetType(), "%s", before.GetName()) + require.Contains(t, []string{corerunnable.FormatInt64, corerunnable.FormatUint64}, after.GetFormat(), "%s", before.GetName()) + } + requireSameModuloWireSpelling(t, before.GetFields(), after.GetFields()) + require.Equal(t, before.GetItems() == nil, after.GetItems() == nil, "%s items", after.GetName()) + if before.GetItems() != nil { + requireFieldModuloWireSpelling(t, before.GetItems(), after.GetItems(), false) } } diff --git a/cmd/generate/runnables.go b/cmd/generate/runnables.go index c3c44d359..6eeb40c55 100644 --- a/cmd/generate/runnables.go +++ b/cmd/generate/runnables.go @@ -23,7 +23,6 @@ import ( "github.com/spf13/cobra" googleproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protodesc" - "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/descriptorpb" ) @@ -178,6 +177,10 @@ type derivedOperation struct { // its methods became tools without reading the tree: each operation with its // origin and effect, the methods left plain calls and why, and — when any // operation was derived by default — how to keep a method from every agent. +// +// One method published on several endpoints (gRPC and Connect) is several +// operations but one method, so the summary counts and names methods, each +// once, and a notice is printed once per method. func (d *derivation) report() { var defaults, mutations []string for i := range d.index.Operations { @@ -186,20 +189,24 @@ func (d *derivation) report() { origin := "option" if !operation.explicit { origin = "default" - defaults = append(defaults, entry.Method) + defaults = appendOnce(defaults, entry.Method) if operation.effect == runnablev0.ToolExposure_EFFECT_MUTATION.String() { - mutations = append(mutations, entry.Method) + mutations = appendOnce(mutations, entry.Method) } } cli.Info("runnables: %s %s %s %s %s", entry.Name, entry.Method, origin, strings.TrimPrefix(operation.effect, "EFFECT_"), entry.Digest) } + var notices []string for _, notice := range d.notices { + notices = appendOnce(notices, notice) + } + for _, notice := range notices { cli.Info("runnables: not an operation: %s", notice) } if len(defaults) == 0 { return } - cli.Info("runnables: %d operation(s) derived by default from their authority policy, %d of them mutations: %s", + cli.Info("runnables: %d method(s) derived by default from their authority policy, %d of them mutations: %s", len(defaults), len(mutations), strings.Join(mutations, ", ")) cli.Info("runnables: every operation is a tool, offered only to callers whose grants cover its binding; " + "a method no agent may ever call declares `option (codefly.runnable.v0.exclude) = true;`") @@ -386,7 +393,7 @@ func deriveMethod(module *resources.Module, endpoint *composition.APIContractEnd derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes operation := pkg.GetServiceOperations()[0] - derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: declaresOperation(files, fullMethod), effect: spec.Tool.GetEffect().String()} + derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()} derived.index.Operations = append(derived.index.Operations, runnablespkg.IndexEntry{ Name: pkg.GetIdentity().GetName(), Version: pkg.GetIdentity().GetVersion(), @@ -490,29 +497,17 @@ func derivedName(service, endpoint, method string) string { return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(method)}, "-"), maxDerivedName, "-") } -// maxDerivedName is the longest release name a RunnableIdentity admits. -const maxDerivedName = 50 - -// declaresOperation reports a method carrying the codefly.runnable.v0.operation -// option: an operation its owner declared, rather than one derived by default. -func declaresOperation(files *protoregistry.Files, fullMethod string) bool { - service, name, _ := strings.Cut(strings.TrimPrefix(fullMethod, "/"), "/") - descriptor, err := files.FindDescriptorByName(protoreflect.FullName(service)) - if err != nil { - return false +// appendOnce appends value unless the list already holds it. +func appendOnce(list []string, value string) []string { + if slices.Contains(list, value) { + return list } - published, isService := descriptor.(protoreflect.ServiceDescriptor) - if !isService { - return false - } - method := published.Methods().ByName(protoreflect.Name(name)) - if method == nil { - return false - } - options, isOptions := method.Options().(*descriptorpb.MethodOptions) - return isOptions && googleproto.HasExtension(options, runnablev0.E_Operation) + return append(list, value) } +// maxDerivedName is the longest release name a RunnableIdentity admits. +const maxDerivedName = 50 + // kebab spells a method name the way a resource name is spelled. An acronym // stays one word: ApplyHTTPText is apply-http-text, not apply-h-t-t-p-text. func kebab(name string) string { diff --git a/cmd/generate/runnables_test.go b/cmd/generate/runnables_test.go index 4709cf3c7..5fbf29b5b 100644 --- a/cmd/generate/runnables_test.go +++ b/cmd/generate/runnables_test.go @@ -511,23 +511,23 @@ func TestGenerateRunnablesReproducesTheDocumentStoreOracle(t *testing.T) { Contract: &basev0.RunnableContract{ Protocol: resources.RunnableServiceProtocolV1, Input: &basev0.RunnableSchema{Fields: []*basev0.RunnableField{ - {Name: "origin", Type: basev0.RunnableField_STRING}, - {Name: "container", Type: basev0.RunnableField_STRING}, - {Name: "ref", Type: basev0.RunnableField_STRING}, - {Name: "path", Type: basev0.RunnableField_STRING}, - {Name: "revision", Type: basev0.RunnableField_STRING}, - {Name: "commit", Type: basev0.RunnableField_STRING}, - {Name: "cursor", Type: basev0.RunnableField_STRING, Format: corerunnable.FormatInt64}, - {Name: "contentType", Type: basev0.RunnableField_STRING}, - {Name: "text", Type: basev0.RunnableField_STRING}, + {Name: "origin", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "container", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "ref", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "path", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "revision", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "commit", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "cursor", Type: basev0.RunnableField_STRING, Format: corerunnable.FormatInt64, Optional: true}, + {Name: "contentType", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "text", Type: basev0.RunnableField_STRING, Optional: true}, }}, Output: &basev0.RunnableSchema{Fields: []*basev0.RunnableField{ - {Name: "reference", Type: basev0.RunnableField_STRING}, - {Name: "contentHash", Type: basev0.RunnableField_STRING}, - {Name: "effectDigest", Type: basev0.RunnableField_STRING}, - {Name: "minted", Type: basev0.RunnableField_BOOLEAN}, - {Name: "skipped", Type: basev0.RunnableField_BOOLEAN}, - {Name: "quarantined", Type: basev0.RunnableField_BOOLEAN}, + {Name: "reference", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "contentHash", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "effectDigest", Type: basev0.RunnableField_STRING, Optional: true}, + {Name: "minted", Type: basev0.RunnableField_BOOLEAN, Optional: true}, + {Name: "skipped", Type: basev0.RunnableField_BOOLEAN, Optional: true}, + {Name: "quarantined", Type: basev0.RunnableField_BOOLEAN, Optional: true}, }}, }, Execution: &basev0.RunnableExecution{ @@ -566,10 +566,11 @@ func TestGenerateRunnablesReproducesTheDocumentStoreOracle(t *testing.T) { // with the completion mode (codefly-dev/core#678); it had been // 4c26fb95a0236b94107b30f34783cd893f2be36e0d157340c247013fd93a9a1f, then // ee460290f91e2cad2d9e7efe6d740ba35ff5ec89b4395bbde1e8bd19bd8291a0 until the - // contract was spelled as the Connect wire carries it (core#843). The + // contract was spelled as the Connect wire carries it — JSON names, 64-bit + // integers as strings, every non-required key optional (core#843). The // equality check above is what proves the derivation still matches what // document-store publishes — this line only pins the number. - if asOracle.GetDigest() != "fdfc6ac25ae0eb138d4f51dc255343de093ce3048c3262f8d5e0e61035027b19" { + if asOracle.GetDigest() != "4b72d393b3fb25284036185968438f3c24be98780660c7f994de896358d4878c" { t.Fatalf("digest = %s, want the published oracle's", asOracle.GetDigest()) } } diff --git a/go.mod b/go.mod index d826b1899..3325ef861 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c + github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 90231a4f0..44d064ac0 100644 --- a/go.sum +++ b/go.sum @@ -184,6 +184,8 @@ github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45 h1:2pKa3A044y3 github.com/codefly-dev/core v0.19.7-0.20261026014202-483255ecca45/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c h1:9oKvfr/IBPSN/YpzmPskG87O2mwBcjrjnxtdE7AQhSA= github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533 h1:J2mxIa2i/wRekIWqhzRrTrbgp5rNPHU0FSsQGz4vKX4= +github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= From 487d7320555fbe3e7c9ca3bb385650bfdedd4bc8 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:50:08 -0400 Subject: [PATCH 6/6] chore(deps): core at 8b5ddef6, core#843's merge commit; the CLI reference regenerated Re-pins core to v0.19.7-0.20261026023455-8b5ddef6d55a, #843 as merged. That commit also carries core#845 (WorkScopeV1.resource_owner_id) and core#835's module-contract kit fixture roles-grant-bindings. - docs/cli-reference.md: regenerated with `go test ./cmd -run TestCLIReferenceIsCurrent -update-reference` for this branch's new `generate runnables` help text. - The round-trip test exempts WorkScopeV1.resource_owner_id by name: core refuses an operation that declares an owner, so the document has nothing to carry. TestRunnablesRefusesADeclaredResourceOwner proves the refusal reaches `codefly generate runnables` with core's reason. - The module-contract kit's staging composition supplies the audiences roles-grant-bindings resolves (the same line as #1167, so the two merge cleanly). The document-store oracle digest is unchanged (4b72d393...). Co-Authored-By: Claude Opus 5.5 --- cmd/generate/runnable_scopes_test.go | 28 ++++++++++++++++++++++ docs/cli-reference.md | 36 ++++++++++++++++------------ go.mod | 2 +- go.sum | 2 ++ pkg/gitops/wire_kits_test.go | 2 +- 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/cmd/generate/runnable_scopes_test.go b/cmd/generate/runnable_scopes_test.go index 301630ad4..f1ed17b6c 100644 --- a/cmd/generate/runnable_scopes_test.go +++ b/cmd/generate/runnable_scopes_test.go @@ -76,6 +76,16 @@ func TestOperationDocumentRoundTripsEveryCoreField(t *testing.T) { require.Equal(t, coreFields.Len()+1, reflect.TypeFor[runnablespkg.Operation]().NumField()) } +// undeclarableFields are Core fields an operation may never declare: Core +// refuses the policy that sets one, so the document has nothing to carry and +// the fixture cannot populate it. Each is earned by a test proving the refusal +// reaches this command, never by being hard to populate. +var undeclarableFields = map[protoreflect.FullName]string{ + // An owner is the authority's to select for an installation (core#845); + // TestRunnablesRefusesADeclaredResourceOwner proves the refusal. + "codefly.base.v0.WorkScopeV1.resource_owner_id": "never declared", +} + // Descend into every message, including repeated scopes and slots. A future // nested field must be populated before proto.Equal can prove it survived. func requireEveryMessageFieldPopulated(t *testing.T, message protoreflect.Message) { @@ -83,6 +93,10 @@ func requireEveryMessageFieldPopulated(t *testing.T, message protoreflect.Messag fields := message.Descriptor().Fields() for i := 0; i < fields.Len(); i++ { field := fields.Get(i) + if _, undeclarable := undeclarableFields[field.FullName()]; undeclarable { + require.Falsef(t, message.Has(field), "%s is never declared; leave it out of the fixture", field.FullName()) + continue + } require.Truef(t, message.Has(field), "populate %s in the round-trip fixture", field.FullName()) if field.Message() == nil { continue @@ -103,6 +117,20 @@ func requireEveryMessageFieldPopulated(t *testing.T, message protoreflect.Messag } } +// A policy that declares a field Core never lets an operation declare is +// refused by the derivation with Core's reason, so the document's silence on +// that field is the contract rather than a loss. +func TestRunnablesRefusesADeclaredResourceOwner(t *testing.T) { + declared := slotOperation(true) + declared.InvokeScopes[0].ResourceOwnerId = "principal-u" + root, _ := saveConnectRunnableFixture(t, context.Background(), descriptorSet(t, ingestionFile(declared)), "0.1.0") + t.Chdir(root) + resetRunnablesFlags(t) + err := RunnablesCmd.RunE(RunnablesCmd, []string{"documents"}) + require.ErrorContains(t, err, `selects resource owner "principal-u"`) + require.ErrorContains(t, err, undeclarableFields["codefly.base.v0.WorkScopeV1.resource_owner_id"]) +} + func TestOperationDocumentRefusesUnknownNestedCoreFields(t *testing.T) { for _, data := range []string{ `{"tool":{"name":"invoke_source","new_authority":true}}`, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 78a2e5ebd..6f9dcfd14 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2950,8 +2950,9 @@ owner must still serve it. In workspace.codefly.yaml, environments[].runnable-scope-selections maps each binding key (MODULE__OPERATION) to Core ScopeSelection messages: slot, invoke, and lookup. Every required slot needs exact resource kinds, actions and ids; -missing selections, wildcards and overlapping kinds are refused. Tool exposure -is carried from the owner. See docs/runnable.md for the selection YAML. +missing selections, wildcards and overlapping kinds are refused. Every +operation is a tool; its presentation and effect are carried from the derived +policy. See docs/runnable.md for the selection YAML. An environment that installs an operation for no resource answers its key with an explicitly empty list, KEY: []. That operation is declined: its key is absent @@ -2995,21 +2996,25 @@ Flags: ## `codefly generate runnables` -Derive SERVICE-facility Runnable packages from the methods a module's contracts mark +Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods ``` Derive a Runnable package for every gRPC method a module's published contracts -mark with the codefly.runnable.v0.operation option. +serve behind the authority hop. Every Runnable is a tool. -A unary, idempotent method an owner service already publishes becomes a -Runnable by derivation, not by authoring: the method option says which methods -are operations and under what execution policy, and the message descriptors say -what the contract is. Nothing is written twice. +A method that declares its authority policy (authority.hop.v1.policy) is an +operation with nothing added: a policy requiring only "read" derives a +read-only tool that is safe to re-run and carries no receipt; anything else +derives a mutation resolved by its effect receipt through the SDK's generic +lookup. The codefly.runnable.v0.operation option overrides those defaults, and +codefly.runnable.v0.exclude keeps a method from every agent. The message +descriptors say what the contract is, by their proto3 JSON. Nothing is written +twice. The input is what `codefly generate contracts` already wrote — each endpoint's served procedures in the API catalog and its serialized FileDescriptorSet. -Run that first. Only served methods carrying the option are derived; their -paired Lookup must be served on the same endpoint. No flag names a method. +Run that first. Only served methods are derived; a declared Lookup must be +served on the same endpoint. No flag names a method. For each marked method this writes, under contracts/runnables: @@ -3017,12 +3022,13 @@ For each marked method this writes, under contracts/runnables: ///operation.json its execution policy and authority index.json one row per operation -The tree is fully owned: a method that no longer carries the option loses its -directory. A module with no marked method writes an empty index, not an error. +The tree is fully owned: a method that is no longer an operation loses its +directory. A module with no operation writes an empty index, not an error. -A streaming method, a payload outside the bounded schema profile, or an option -core refuses is named and skipped, and the command exits non-zero after -reporting every one of them. +A method derived by default that cannot be one — a stream, a policy requiring +nothing, a payload proto3 JSON cannot carry — is listed and left a plain call. +A method carrying the option that core refuses is named and skipped, and the +command exits non-zero after reporting every one of them. --check is the CI drift gate: it regenerates into a temporary directory and prints a unified diff of what differs, without writing anything. diff --git a/go.mod b/go.mod index 3325ef861..f7477df9a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 - github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533 + github.com/codefly-dev/core v0.19.7-0.20261026023455-8b5ddef6d55a github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 44d064ac0..81123e136 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,8 @@ github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c h1:9oKvfr/IBPS github.com/codefly-dev/core v0.19.7-0.20261026014844-b6c00122c09c/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533 h1:J2mxIa2i/wRekIWqhzRrTrbgp5rNPHU0FSsQGz4vKX4= github.com/codefly-dev/core v0.19.7-0.20261026021736-0c02ef0fd533/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/codefly-dev/core v0.19.7-0.20261026023455-8b5ddef6d55a h1:YnMwvoi6hpp1YBj+trwGavrhCBoFNKxo++Z73hr/b44= +github.com/codefly-dev/core v0.19.7-0.20261026023455-8b5ddef6d55a/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/gitops/wire_kits_test.go b/pkg/gitops/wire_kits_test.go index 77ac525f4..ad0c03d1e 100644 --- a/pkg/gitops/wire_kits_test.go +++ b/pkg/gitops/wire_kits_test.go @@ -55,7 +55,7 @@ func TestTheModuleContractKitRunsThroughTheRender(t *testing.T) { filepath.Join("modules", "assistant", resources.ModuleConfigurationName): "kind: module\nname: assistant\nservices:\n - name: api\n", filepath.Join("modules", "assistant", "services", "api", resources.ServiceConfigurationName): hostedServiceYAML("api", "assistant") + "module-identity: true\n", filepath.Join("modules", "assistant", "module.package.codefly.yaml"): "schema: codefly/module-package/v1\nid: acme/assistant\nversion: 1.0.0\n", - filepath.Join("configurations", "staging", "assistant.env"): "MODEL_AUDIENCE=model-gateway\nMODEL_RESOURCE_KIND=modelservice.profiles\nMODEL_BINDING=model\nEVIDENCE_AUDIENCE=documents\nEVIDENCE_RESOURCE_KIND=documents.passages\nEVIDENCE_BINDING=evidence\nANNOTATIONS_PREFIX=annotations\n", + filepath.Join("configurations", "staging", "assistant.env"): "MODEL_AUDIENCE=model-gateway\nMODEL_RESOURCE_KIND=modelservice.profiles\nMODEL_BINDING=model\nEVIDENCE_AUDIENCE=documents\nEVIDENCE_RESOURCE_KIND=documents.passages\nEVIDENCE_BINDING=evidence\nANNOTATIONS_PREFIX=annotations\nDOCUMENTS_AUDIENCE=documents\nREPLICA_AUDIENCE=documents\nCOLLECTIONS_AUDIENCE=documents\n", } { full := filepath.Join(workspace.Dir(), rel) require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))