Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/plans/2026-05-24-runtime-executor-contracts-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Runtime Executor Contracts Design

## Goal

Move the smallest runtime execution boundary that provider plugins need into
`workflow-plugin-compute-core`: executor identity, proof-relevant runtime
metadata, and resource usage/limit evidence. This supports future runtime
plugins without forcing `workflow-compute` host internals such as leases,
workspace paths, network bindings, or process supervision into the public core
contract.

## Design

`RuntimeDescriptor` is the public descriptor shape a runtime provider can
advertise. It carries provider name, version, execution security tier, proof
tier, and optional image/rootfs digests. It can produce an `ExecutorRef`, which
is the proof-facing executor identity already used by `workflow-compute`.

`ExecutorRef` moves with its `ValidateForProof` and attestation requirement
helpers so proof validation and runtime advertisement share one contract.

`ResourceUsage` and `ResourceLimits` become compute-core contracts because
runtime plugins need to report measured usage and receive hard limits without
depending on `workflow-compute` task execution structs.

## Assumptions

- Runtime plugins need shared metadata and evidence contracts before they need
shared process execution APIs.
- `workflow-compute` remains responsible for host-only execution context:
workspace preparation, lease authorization, network binding, and supervisor
integration.
- Existing runtime providers can alias these contracts without changing
behavior.

## Rollback

Rollback is reverting this contract addition and restoring local
`workflow-compute` type ownership. No stored data format changes are introduced
because the JSON field names intentionally match the existing
`workflow-compute` structs.

## Self-Challenge

- Laziest solution: leave these structs local until the runtime plugin repos
exist. Rejected because provider plugins already need a stable advertised
runtime and proof evidence shape.
- Fragile assumption: `RuntimeDescriptor` may need workload-kind or network
mode declarations later. Those are intentionally left in
`ProviderRuntimeProfile` and provider contracts for now to avoid duplicating
catalog policy.
- Partial failure risk: moving execution request structs too early would expose
host workspace and network internals as public API. This slice explicitly
avoids that.
118 changes: 118 additions & 0 deletions protocol/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,124 @@ const (
ProofZKReplay ProofTier = "zk-replay"
)

type RuntimeDescriptor struct {
Name string `json:"name"`
Version string `json:"version"`
ExecutionSecurityTier ExecutionSecurityTier `json:"execution_security_tier,omitempty"`
ProofTier ProofTier `json:"proof_tier,omitempty"`
ImageDigest string `json:"image_digest,omitempty"`
RootFSDigest string `json:"rootfs_digest,omitempty"`
}

func (d RuntimeDescriptor) ExecutorRef(defaultProvider string) ExecutorRef {
provider := d.Name
if provider == "" {
provider = defaultProvider
}
version := d.Version
if version == "" {
version = "dev"
}
return ExecutorRef{
Provider: provider,
Version: version,
ExecutionSecurityTier: d.ExecutionSecurityTier,
ProofTier: d.ProofTier,
ImageDigest: d.ImageDigest,
RootFSDigest: d.RootFSDigest,
}
}

type ExecutorRef struct {
Provider string `json:"provider"`
Version string `json:"version"`
ExecutionSecurityTier ExecutionSecurityTier `json:"execution_security_tier,omitempty"`
ProofTier ProofTier `json:"proof_tier,omitempty"`
ImageDigest string `json:"image_digest,omitempty"`
RootFSDigest string `json:"rootfs_digest,omitempty"`
}

func (e ExecutorRef) ValidateForProof() error {
var errs []error
if e.Provider == "" {
errs = append(errs, errors.New("executor.provider is required"))
}
if e.Version == "" {
errs = append(errs, errors.New("executor.version is required"))
}
if e.ExecutionSecurityTier == "" {
errs = append(errs, errors.New("executor.execution_security_tier is required"))
}
if e.ProofTier == "" {
errs = append(errs, errors.New("executor.proof_tier is required"))
}
if e.ExecutionSecurityTier != "" && e.ExecutionSecurityTier != ExecutionTrustedNative && e.ExecutionSecurityTier != ExecutionWASMCapability {
if e.ImageDigest == "" {
errs = append(errs, errors.New("executor.image_digest is required for non-native executor"))
}
if e.RootFSDigest == "" {
errs = append(errs, errors.New("executor.rootfs_digest is required for non-native executor"))
}
}
return errors.Join(errs...)
}

func (e ExecutorRef) RequiresAttestation() bool {
switch e.ExecutionSecurityTier {
case ExecutionConfidentialCPU, ExecutionConfidentialGPU:
return true
}
switch e.ProofTier {
case ProofAttestedReceipt, ProofAttestedQuorum:
return true
default:
return false
}
}

type ResourceUsage struct {
CPUMillis int64 `json:"cpu_millis,omitempty"`
GPUMillis int64 `json:"gpu_millis,omitempty"`
MaxMemoryBytes int64 `json:"max_memory_bytes,omitempty"`
NetworkRxBytes int64 `json:"network_rx_bytes,omitempty"`
NetworkTxBytes int64 `json:"network_tx_bytes,omitempty"`
WorkspaceBytes int64 `json:"workspace_bytes,omitempty"`
OutputBytes int64 `json:"output_bytes,omitempty"`
LimitHit string `json:"limit_hit,omitempty"`
}

type ResourceLimits struct {
CPUPercent int `json:"cpu_percent,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
WorkspaceBytes int64 `json:"workspace_bytes,omitempty"`
RuntimeSeconds int `json:"runtime_seconds,omitempty"`
NetworkEgressBytes int64 `json:"network_egress_bytes,omitempty"`
OutputBytes int64 `json:"output_bytes,omitempty"`
}

func (l ResourceLimits) Validate() error {
var errs []error
if l.CPUPercent < 0 {
errs = append(errs, errors.New("cpu_percent cannot be negative"))
}
if l.MemoryBytes < 0 {
errs = append(errs, errors.New("memory_bytes cannot be negative"))
}
if l.WorkspaceBytes < 0 {
errs = append(errs, errors.New("workspace_bytes cannot be negative"))
}
if l.RuntimeSeconds < 0 {
errs = append(errs, errors.New("runtime_seconds cannot be negative"))
}
if l.NetworkEgressBytes < 0 {
errs = append(errs, errors.New("network_egress_bytes cannot be negative"))
}
if l.OutputBytes < 0 {
errs = append(errs, errors.New("output_bytes cannot be negative"))
}
return errors.Join(errs...)
}

type NetworkMode string

const (
Expand Down
72 changes: 72 additions & 0 deletions protocol/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,78 @@ func TestProviderContractAcceptsBatchSandboxedOCI(t *testing.T) {
}
}

func TestRuntimeDescriptorProducesExecutorRef(t *testing.T) {
descriptor := protocol.RuntimeDescriptor{
Name: "sandboxed-command",
Version: "v1.2.3",
ExecutionSecurityTier: protocol.ExecutionSandboxedContainer,
ProofTier: protocol.ProofArtifactHash,
ImageDigest: "sha256:image",
RootFSDigest: "sha256:rootfs",
}

ref := descriptor.ExecutorRef("fallback")

if ref.Provider != "sandboxed-command" || ref.Version != "v1.2.3" {
t.Fatalf("executor identity = %+v", ref)
}
if ref.ExecutionSecurityTier != protocol.ExecutionSandboxedContainer || ref.ProofTier != protocol.ProofArtifactHash {
t.Fatalf("executor proof metadata = %+v", ref)
}
if ref.ImageDigest != "sha256:image" || ref.RootFSDigest != "sha256:rootfs" {
t.Fatalf("executor digests = %+v", ref)
}
}

func TestRuntimeDescriptorFallsBackToProviderNameAndDevVersion(t *testing.T) {
ref := (protocol.RuntimeDescriptor{}).ExecutorRef("command")

if ref.Provider != "command" || ref.Version != "dev" {
t.Fatalf("fallback executor ref = %+v", ref)
}
}

func TestExecutorRefValidateForProofRequiresDigestsForNonNativeExecutors(t *testing.T) {
ref := protocol.ExecutorRef{
Provider: "sandboxed-command",
Version: "dev",
ExecutionSecurityTier: protocol.ExecutionSandboxedContainer,
ProofTier: protocol.ProofArtifactHash,
}

err := ref.ValidateForProof()
if err == nil {
t.Fatal("expected non-native executor without image digests to fail")
}
if !strings.Contains(err.Error(), "image_digest") || !strings.Contains(err.Error(), "rootfs_digest") {
t.Fatalf("expected digest errors, got %v", err)
}

ref.ImageDigest = "sha256:image"
ref.RootFSDigest = "sha256:rootfs"
if err := ref.ValidateForProof(); err != nil {
t.Fatalf("executor ref invalid with digests: %v", err)
}
}

func TestResourceLimitsRejectNegativeValues(t *testing.T) {
limits := protocol.ResourceLimits{
CPUPercent: -1,
RuntimeSeconds: -1,
OutputBytes: -1,
}

err := limits.Validate()
if err == nil {
t.Fatal("expected negative resource limits to fail")
}
if !strings.Contains(err.Error(), "cpu_percent") ||
!strings.Contains(err.Error(), "runtime_seconds") ||
!strings.Contains(err.Error(), "output_bytes") {
t.Fatalf("expected resource limit errors, got %v", err)
}
}

func TestProviderContractRejectsMalformedConfigSchemaDigest(t *testing.T) {
contract := validBatchProviderContract()
contract.ConfigSchemaDigest = "sha256:not-hex"
Expand Down
Loading