diff --git a/docs/plans/2026-05-24-runtime-adapter-contract-design.md b/docs/plans/2026-05-24-runtime-adapter-contract-design.md new file mode 100644 index 0000000..16c6d13 --- /dev/null +++ b/docs/plans/2026-05-24-runtime-adapter-contract-design.md @@ -0,0 +1,55 @@ +# Runtime Adapter Contract Design + +## Goal + +Define the public, host-independent contract a runtime execution plugin can +advertise before `workflow-compute` moves concrete executors into plugin repos. +The contract should let hosts compare adapter capability, workspace policy, +runtime descriptor identity, workload kinds, and conformance evidence without +leaking server-owned task, lease, mount, network binding, or verifier state. + +## Design + +`RuntimeAdapterContract` is a declaration, not an execution API. It includes +the compute protocol version, adapter ID, runtime descriptor, adapter kinds, +supported workload kinds, optional runtime profiles, workspace policy, +conformance profiles, residue policy, optional provider config, and metadata. + +`RuntimeAdapterKind` separates short-lived execution, one-shot service run, and +long-lived service session support. `RuntimeWorkspacePolicy` is explicit because +some workloads, especially WASM-style adapters, may not have a workspace. +Service adapter kinds must declare `service` or `node-service` workload support +so a plugin cannot advertise long-lived service capability for only command-like +workloads. + +`RuntimeDescriptor.Validate` is added so adapter contracts can validate runtime +identity, security tier, proof tier, and non-native image/rootfs digest +requirements before a host accepts a plugin declaration. + +## Assumptions + +- A declaration contract is the right next step before moving executor process + code out of `workflow-compute`. +- Runtime plugins can describe capability with workload kinds and adapter kinds + while the host still decides task authorization, lease state, workspace + mounts, network capability, and proof verification. +- Residue policy may describe reusable non-workspace state, so workspace policy + must not blindly reject all residue for workspace-less adapters. + +## Rollback + +Rollback is reverting the additive adapter contract types and keeping +`workflow-compute` executor capability discovery local. No persisted state or +wire format migration is introduced by this slice. + +## Self-Challenge + +- Laziest solution: rely on `ProviderRuntimeContract` only. Rejected because it + describes provider runtime profiles, not what an executor plugin adapter can + register or whether it supports service sessions. +- Fragile assumption: adapter kinds are enough for the first plugin boundary. + The mitigation is that execution payloads remain in `RuntimeExecutionRequest` + and host-only capability handles stay out of compute-core. +- Security risk: plugin metadata could be mistaken for authorization. The + contract is declarative only; hosts must keep authorization and lease checks + local. diff --git a/protocol/types.go b/protocol/types.go index a96b719..b3fe46b 100644 --- a/protocol/types.go +++ b/protocol/types.go @@ -125,6 +125,22 @@ const ( ProofZKReplay ProofTier = "zk-replay" ) +type RuntimeAdapterKind string + +const ( + RuntimeAdapterExecution RuntimeAdapterKind = "execution" + RuntimeAdapterServiceRun RuntimeAdapterKind = "service-run" + RuntimeAdapterServiceSession RuntimeAdapterKind = "service-session" +) + +type RuntimeWorkspacePolicy string + +const ( + RuntimeWorkspaceUnavailable RuntimeWorkspacePolicy = "unavailable" + RuntimeWorkspaceOptional RuntimeWorkspacePolicy = "optional" + RuntimeWorkspaceRequired RuntimeWorkspacePolicy = "required" +) + type RuntimeDescriptor struct { Name string `json:"name"` Version string `json:"version"` @@ -134,6 +150,37 @@ type RuntimeDescriptor struct { RootFSDigest string `json:"rootfs_digest,omitempty"` } +func (d RuntimeDescriptor) Validate() error { + var errs []error + if err := validateIdentifier("name", d.Name); err != nil { + errs = append(errs, err) + } + if strings.TrimSpace(d.Version) == "" { + errs = append(errs, errors.New("version is required")) + } else if strings.ContainsAny(d.Version, " \t\r\n\x00") { + errs = append(errs, errors.New("version must not contain whitespace")) + } + if !validExecutionSecurityTier(d.ExecutionSecurityTier) { + errs = append(errs, fmt.Errorf("execution_security_tier %q is unsupported", d.ExecutionSecurityTier)) + } + if !validProofTier(d.ProofTier) { + errs = append(errs, fmt.Errorf("proof_tier %q is unsupported", d.ProofTier)) + } + if d.ExecutionSecurityTier != ExecutionTrustedNative && d.ExecutionSecurityTier != ExecutionWASMCapability { + if d.ImageDigest == "" { + errs = append(errs, errors.New("image_digest is required for non-native runtime")) + } else if !validSHA256Ref(d.ImageDigest) { + errs = append(errs, errors.New("image_digest must be sha256:<64 hex chars>")) + } + if d.RootFSDigest == "" { + errs = append(errs, errors.New("rootfs_digest is required for non-native runtime")) + } else if !validSHA256Ref(d.RootFSDigest) { + errs = append(errs, errors.New("rootfs_digest must be sha256:<64 hex chars>")) + } + } + return errors.Join(errs...) +} + func (d RuntimeDescriptor) ExecutorRef(defaultProvider string) ExecutorRef { provider := d.Name if provider == "" { @@ -286,6 +333,118 @@ func (r RuntimeExecutionRequest) Validate() error { return errors.Join(errs...) } +type RuntimeAdapterContract struct { + ProtocolVersion string `json:"protocol_version"` + AdapterID string `json:"adapter_id"` + Descriptor RuntimeDescriptor `json:"descriptor,omitzero"` + Kinds []RuntimeAdapterKind `json:"kinds"` + WorkloadKinds []WorkloadKind `json:"workload_kinds"` + RuntimeProfiles []RuntimeProfile `json:"runtime_profiles,omitempty"` + WorkspacePolicy RuntimeWorkspacePolicy `json:"workspace_policy"` + ConformanceProfiles []string `json:"conformance_profiles"` + ResiduePolicy ResiduePolicy `json:"residue_policy,omitzero"` + ProviderConfig ProviderConfig `json:"provider_config,omitzero"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func (c RuntimeAdapterContract) Validate() error { + var errs []error + if c.ProtocolVersion != Version { + errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) + } + if err := validateIdentifier("adapter_id", c.AdapterID); err != nil { + errs = append(errs, err) + } + if err := c.Descriptor.Validate(); err != nil { + errs = append(errs, fmt.Errorf("descriptor: %w", err)) + } + if len(c.Kinds) == 0 { + errs = append(errs, errors.New("kinds is required")) + } + seenKinds := map[RuntimeAdapterKind]struct{}{} + for i, kind := range c.Kinds { + if !validRuntimeAdapterKind(kind) { + errs = append(errs, fmt.Errorf("kinds[%d] %q is unsupported", i, kind)) + continue + } + if _, exists := seenKinds[kind]; exists { + errs = append(errs, fmt.Errorf("kinds[%d] %q is duplicated", i, kind)) + } + seenKinds[kind] = struct{}{} + } + if len(c.WorkloadKinds) == 0 { + errs = append(errs, errors.New("workload_kinds is required")) + } + seenWorkloads := map[WorkloadKind]struct{}{} + for i, kind := range c.WorkloadKinds { + if !validWorkloadKind(kind) { + errs = append(errs, fmt.Errorf("workload_kinds[%d] %q is unsupported", i, kind)) + continue + } + if _, exists := seenWorkloads[kind]; exists { + errs = append(errs, fmt.Errorf("workload_kinds[%d] %q is duplicated", i, kind)) + } + seenWorkloads[kind] = struct{}{} + } + if c.SupportsAdapterKind(RuntimeAdapterServiceRun) || c.SupportsAdapterKind(RuntimeAdapterServiceSession) { + if !c.Supports(WorkloadService) && !c.Supports(WorkloadNodeService) { + errs = append(errs, errors.New("service adapter kinds require service workload kind")) + } + } + for i, profile := range c.RuntimeProfiles { + if !validRuntimeProfile(profile) { + errs = append(errs, fmt.Errorf("runtime_profiles[%d] %q is unsupported", i, profile)) + } + } + if !validRuntimeWorkspacePolicy(c.WorkspacePolicy) { + errs = append(errs, fmt.Errorf("workspace_policy %q is unsupported", c.WorkspacePolicy)) + } + if len(c.ConformanceProfiles) == 0 { + errs = append(errs, errors.New("conformance_profiles is required")) + } + seenProfiles := map[string]struct{}{} + for i, profile := range c.ConformanceProfiles { + if err := validateIdentifier(fmt.Sprintf("conformance_profiles[%d]", i), profile); err != nil { + errs = append(errs, err) + continue + } + if _, exists := seenProfiles[profile]; exists { + errs = append(errs, fmt.Errorf("conformance_profiles[%d] %q is duplicated", i, profile)) + } + seenProfiles[profile] = struct{}{} + } + if err := c.ResiduePolicy.Validate(ResiduePolicyValidation{RequirePolicyHash: true}); err != nil { + errs = append(errs, fmt.Errorf("residue_policy: %w", err)) + } + if err := c.ProviderConfig.Validate(); err != nil { + errs = append(errs, fmt.Errorf("provider_config: %w", err)) + } + for key := range c.Metadata { + if err := validateIdentifier("metadata key", key); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (c RuntimeAdapterContract) Supports(kind WorkloadKind) bool { + for _, supported := range c.WorkloadKinds { + if supported == kind { + return true + } + } + return false +} + +func (c RuntimeAdapterContract) SupportsAdapterKind(kind RuntimeAdapterKind) bool { + for _, supported := range c.Kinds { + if supported == kind { + return true + } + } + return false +} + const MaxRuntimeResultPreviewBytes = 16 * 1024 type RuntimeExecutionResult struct { @@ -1938,6 +2097,24 @@ func validProofTier(tier ProofTier) bool { } } +func validRuntimeAdapterKind(kind RuntimeAdapterKind) bool { + switch kind { + case RuntimeAdapterExecution, RuntimeAdapterServiceRun, RuntimeAdapterServiceSession: + return true + default: + return false + } +} + +func validRuntimeWorkspacePolicy(policy RuntimeWorkspacePolicy) bool { + switch policy { + case RuntimeWorkspaceUnavailable, RuntimeWorkspaceOptional, RuntimeWorkspaceRequired: + return true + default: + return false + } +} + func normalizeNetworkMode(mode NetworkMode) NetworkMode { mode = NetworkMode(strings.TrimSpace(string(mode))) if mode == "" { diff --git a/protocol/types_test.go b/protocol/types_test.go index 89a7048..ec214e1 100644 --- a/protocol/types_test.go +++ b/protocol/types_test.go @@ -144,6 +144,89 @@ func TestRuntimeServiceResultValidatesStatusEvidenceHashes(t *testing.T) { } } +func TestRuntimeAdapterContractValidatesHostIndependentBoundary(t *testing.T) { + contract := protocol.RuntimeAdapterContract{ + ProtocolVersion: protocol.Version, + AdapterID: "sandboxed-command", + Descriptor: protocol.RuntimeDescriptor{ + Name: "sandboxed-command", + Version: "v1.2.3", + ExecutionSecurityTier: protocol.ExecutionSandboxedContainer, + ProofTier: protocol.ProofArtifactHash, + ImageDigest: protocol.CanonicalHash("image"), + RootFSDigest: protocol.CanonicalHash("rootfs"), + }, + Kinds: []protocol.RuntimeAdapterKind{protocol.RuntimeAdapterExecution}, + WorkloadKinds: []protocol.WorkloadKind{protocol.WorkloadCommand, protocol.WorkloadContainerBuild}, + RuntimeProfiles: []protocol.RuntimeProfile{protocol.RuntimeProfileSandboxedOCI}, + WorkspacePolicy: protocol.RuntimeWorkspaceRequired, + ConformanceProfiles: []string{"protected-command-v1"}, + ResiduePolicy: protocol.ResiduePolicy{ + Mode: protocol.ResidueModeProviderBound, + PolicyHash: protocol.CanonicalHash("residue"), + MaxReuseCount: 2, + }, + } + + if err := contract.Validate(); err != nil { + t.Fatalf("adapter contract invalid: %v", err) + } +} + +func TestRuntimeAdapterContractRejectsAmbiguousRuntimeBoundary(t *testing.T) { + contract := protocol.RuntimeAdapterContract{ + ProtocolVersion: "wrong", + AdapterID: "bad adapter", + Descriptor: protocol.RuntimeDescriptor{ + Name: "adapter", + Version: "v1.0.0", + ExecutionSecurityTier: protocol.ExecutionSandboxedContainer, + ProofTier: protocol.ProofArtifactHash, + }, + Kinds: []protocol.RuntimeAdapterKind{protocol.RuntimeAdapterExecution, protocol.RuntimeAdapterExecution}, + WorkloadKinds: []protocol.WorkloadKind{protocol.WorkloadKind("unknown")}, + WorkspacePolicy: protocol.RuntimeWorkspacePolicy("maybe"), + ResiduePolicy: protocol.ResiduePolicy{ + Mode: protocol.ResidueModeSessionBound, + SessionKey: "session", + }, + } + + err := contract.Validate() + if err == nil { + t.Fatal("expected malformed adapter contract to fail") + } + for _, want := range []string{"protocol_version", "adapter_id", "descriptor", "kinds", "workload_kinds", "workspace_policy", "conformance_profiles", "residue_policy"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q in error, got %v", want, err) + } + } +} + +func TestRuntimeAdapterContractRequiresServiceWorkloadForServiceAdapters(t *testing.T) { + contract := protocol.RuntimeAdapterContract{ + ProtocolVersion: protocol.Version, + AdapterID: "service-adapter", + Descriptor: protocol.RuntimeDescriptor{ + Name: "service-adapter", + Version: "v1.0.0", + ExecutionSecurityTier: protocol.ExecutionSandboxedContainer, + ProofTier: protocol.ProofArtifactHash, + ImageDigest: protocol.CanonicalHash("image"), + RootFSDigest: protocol.CanonicalHash("rootfs"), + }, + Kinds: []protocol.RuntimeAdapterKind{protocol.RuntimeAdapterServiceSession}, + WorkloadKinds: []protocol.WorkloadKind{protocol.WorkloadCommand}, + WorkspacePolicy: protocol.RuntimeWorkspaceOptional, + ConformanceProfiles: []string{"service-session-v1"}, + } + + err := contract.Validate() + if err == nil || !strings.Contains(err.Error(), "service adapter kinds require service workload kind") { + t.Fatalf("expected service workload error, got %v", err) + } +} + func TestRuntimeDescriptorFallsBackToProviderNameAndDevVersion(t *testing.T) { ref := (protocol.RuntimeDescriptor{}).ExecutorRef("command")