From 224725d2a0bd6ab5e9383ac90eb861460fbc8977 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 24 May 2026 13:13:14 -0400 Subject: [PATCH] feat: canonicalize product compatibility contracts --- ...-product-compatibility-contracts-design.md | 54 ++ internal/plugin_test.go | 15 +- protocol/types.go | 556 +++++++++++++++++- protocol/types_test.go | 103 ++++ 4 files changed, 698 insertions(+), 30 deletions(-) create mode 100644 docs/plans/2026-05-24-product-compatibility-contracts-design.md diff --git a/docs/plans/2026-05-24-product-compatibility-contracts-design.md b/docs/plans/2026-05-24-product-compatibility-contracts-design.md new file mode 100644 index 0000000..7403332 --- /dev/null +++ b/docs/plans/2026-05-24-product-compatibility-contracts-design.md @@ -0,0 +1,54 @@ +# Product Compatibility Contracts Design + +## Goal + +Make `workflow-plugin-compute-core` the canonical home for the provider/product +compatibility contract used by `workflow-compute` and provider plugins. This +lets `workflow-compute` become thinner without maintaining local copies of +public provider, product, session, proof, residue, access, and settlement +shapes. + +## Design + +Compute-core owns the public JSON contract and validation helpers for: + +- `NetworkProduct`, including proof policy, access policy, residue policy, + contribution policy, admission metadata, and creation timestamp; +- `ProviderConfig` and `SessionPolicy` validation; +- product proof policy validation; +- product placement, storage guidance, settlement target, crypto reward + routing, and contribution policy validation. + +`workflow-compute` still owns host-specific placement evaluation against live +worker capabilities and task network policy. That logic depends on scheduler +state, executor capability reports, and worker-side observations, so it should +stay in the application host until a separate runtime/placement plugin contract +exists. + +## Assumptions + +- Provider and product declarations are public plugin contracts, not private + server persistence models. +- Settlement and contribution fields remain part of product declarations for + now because existing product catalog entries already expose them. +- Moving validation into compute-core should preserve the current + `workflow-compute` behavior before aliases are introduced. +- Host runtime placement checks can be kept as host helpers without weakening + the public product contract. + +## Rollback + +Revert the compute-core product contract expansion and return +`workflow-compute` to its local product-contract copies. No data migration is +required because the JSON field names are unchanged. + +## Self-Challenge + +- Smaller option: only add the missing fields and no validation. Rejected + because aliases would silently weaken `workflow-compute` admission checks. +- Risk: compute-core may absorb too much marketplace/settlement policy. Kept + acceptable because these fields are already product contract data; runtime + settlement execution remains out of scope. +- Risk: `ProviderContract.SupportsProduct` has historically differed between + repos. The follow-up `workflow-compute` PR must either preserve the existing + behavior or explicitly update tests for the stricter core semantics. diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 71f57fd..4c1bd9f 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -109,17 +109,26 @@ func TestProtocolProviderContractSupportsProduct(t *testing.T) { PluginID: "workflow-plugin-example", ProviderID: "example", ContractID: "example.client.v1", + Version: "v1.0.0", + ConfigRef: "config://providers/example/client", }, NetworkModes: []protocol.NetworkMode{protocol.NetworkModeDirect}, PlacementConstraints: protocol.PlacementConstraints{ - Chain: "example", - Role: "client", - MinDiskBytes: 1, + Chain: "example", + Role: "client", + MinDiskBytes: 1, + MinMemoryBytes: 1, + MinBandwidthMbps: 1, + WalletRef: "wallet://example/primary", }, RewardPolicy: "points", AbusePolicy: "example", + SessionPolicy: protocol.SessionPolicy{ + WarmSeconds: 60, + }, SettlementTarget: protocol.SettlementTarget{ Kind: protocol.SettlementTargetTreasuryWallet, + AccountID: "example-account", Network: "example", WalletRef: "wallet://example/primary", }, diff --git a/protocol/types.go b/protocol/types.go index 992eb72..6678556 100644 --- a/protocol/types.go +++ b/protocol/types.go @@ -140,6 +140,13 @@ type PlacementRequirements struct { ExecutorProvider string `json:"executor_provider,omitempty"` ExecutionSecurityTier ExecutionSecurityTier `json:"execution_security_tier,omitempty"` ProofTier ProofTier `json:"proof_tier,omitempty"` + HardwareClass string `json:"hardware_class,omitempty"` + RequiredCapabilities []string `json:"required_capabilities,omitempty"` +} + +type ProofPolicy struct { + Quorum int `json:"quorum,omitempty"` + MaxAttempts int `json:"max_attempts,omitempty"` } type SessionPolicy struct { @@ -157,6 +164,77 @@ type ProviderConfig struct { ConfigDigest string `json:"config_digest,omitempty"` } +func (p ProviderConfig) Validate() error { + if p == (ProviderConfig{}) { + return nil + } + var errs []error + if err := validateIdentifier("plugin_id", p.PluginID); err != nil { + errs = append(errs, err) + } + if err := validateIdentifier("provider_id", p.ProviderID); err != nil { + errs = append(errs, err) + } + if err := validateIdentifier("contract_id", p.ContractID); err != nil { + errs = append(errs, err) + } + if strings.TrimSpace(p.Version) == "" { + errs = append(errs, errors.New("version is required")) + } else if strings.ContainsAny(p.Version, " \t\r\n\x00") { + errs = append(errs, errors.New("version must not contain whitespace")) + } + if strings.TrimSpace(p.ConfigRef) == "" { + errs = append(errs, errors.New("config_ref is required")) + } else if err := validateScopedRef("config_ref", p.ConfigRef, "config://"); err != nil { + errs = append(errs, err) + } + if p.ConfigDigest != "" && !validSHA256Ref(p.ConfigDigest) { + errs = append(errs, errors.New("config_digest must be sha256:<64 hex chars>")) + } + return errors.Join(errs...) +} + +func (p SessionPolicy) ValidateForOperatingMode(mode NetworkOperatingMode) error { + var errs []error + if p.WarmSeconds < 0 { + errs = append(errs, errors.New("warm_seconds must be non-negative")) + } + if p.MinRenewals < 0 { + errs = append(errs, errors.New("min_renewals must be non-negative")) + } + if p.MaxBatchRequests < 0 { + errs = append(errs, errors.New("max_batch_requests must be non-negative")) + } + switch mode { + case NetworkModeWarmService, NetworkModeNodeService, NetworkModeInferenceAPI: + if p.WarmSeconds <= 0 { + errs = append(errs, errors.New("warm_seconds is required for warm operating modes")) + } + case NetworkModeBatch: + if p.MinRenewals > 0 { + errs = append(errs, errors.New("min_renewals requires warm operating mode")) + } + } + return errors.Join(errs...) +} + +func ValidateProofPolicy(proofTier ProofTier, policy ProofPolicy) error { + switch proofTier { + case ProofReplicatedQuorum, ProofAttestedQuorum: + if policy.Quorum < 2 { + return errors.New("proof_policy.quorum must be >= 2") + } + if policy.MaxAttempts != 0 && policy.MaxAttempts < policy.Quorum { + return errors.New("proof_policy.max_attempts must be >= quorum") + } + default: + if policy.Quorum > 1 || policy.MaxAttempts > 0 { + return errors.New("proof_policy.quorum requires replicated or attested quorum proof tier") + } + } + return nil +} + type AccessVisibility string const ( @@ -827,6 +905,7 @@ type NetworkProduct struct { PoolID string `json:"pool_id"` WorkloadKinds []string `json:"workload_kinds"` SecurityFloor PlacementRequirements `json:"security_floor"` + ProofPolicy ProofPolicy `json:"proof_policy,omitzero"` SessionPolicy SessionPolicy `json:"session_policy,omitzero"` ProviderConfig ProviderConfig `json:"provider_config,omitzero"` NetworkModes []NetworkMode `json:"network_modes"` @@ -836,49 +915,151 @@ type NetworkProduct struct { SettlementAccountID string `json:"settlement_account_id,omitempty"` SettlementTarget SettlementTarget `json:"settlement_target,omitzero"` CryptoRewardRouting CryptoRewardRoutingPolicy `json:"crypto_reward_routing,omitzero"` + ContributionPolicy ContributionPolicy `json:"contribution_policy,omitzero"` + AccessPolicy AccessPolicy `json:"access_policy,omitzero"` + ResiduePolicy ResiduePolicy `json:"residue_policy,omitzero"` + AdmissionMode string `json:"admission_mode,omitempty"` + AllowPublic bool `json:"allow_public,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } func (p NetworkProduct) Validate() error { var errs []error - for _, field := range []struct { - name string - value string - }{ - {"protocol_version", p.ProtocolVersion}, - {"id", p.ID}, - {"org_id", p.OrgID}, - {"pool_id", p.PoolID}, - {"reward_policy", p.RewardPolicy}, - {"abuse_policy", p.AbusePolicy}, - } { - if strings.TrimSpace(field.value) == "" { - errs = append(errs, fmt.Errorf("%s is required", field.name)) - } - } if p.ProtocolVersion != Version { errs = append(errs, fmt.Errorf("protocol_version must be %q", Version)) } - if p.OperatingMode != NetworkModeNodeService { + if err := validateNetworkProductID(p.ID); err != nil { + errs = append(errs, fmt.Errorf("id: %w", err)) + } + if p.OrgID == "" { + errs = append(errs, errors.New("org_id is required")) + } + if p.PoolID == "" { + errs = append(errs, errors.New("pool_id is required")) + } + if p.OperatingMode == "" { + errs = append(errs, errors.New("operating_mode is required")) + } else if !validNetworkOperatingMode(p.OperatingMode) { errs = append(errs, fmt.Errorf("operating_mode %q is unsupported", p.OperatingMode)) } - if len(p.WorkloadKinds) == 0 || len(p.NetworkModes) == 0 { - errs = append(errs, errors.New("workload_kinds and network_modes are required")) + if len(p.WorkloadKinds) == 0 { + errs = append(errs, errors.New("workload_kinds is required")) } - if p.SecurityFloor.ExecutorProvider == "" || p.SecurityFloor.ExecutionSecurityTier == "" || p.SecurityFloor.ProofTier == "" { - errs = append(errs, errors.New("security_floor is required")) + for i, kind := range p.WorkloadKinds { + if !validWorkloadKind(WorkloadKind(strings.TrimSpace(kind))) { + errs = append(errs, fmt.Errorf("workload_kinds[%d] %q is unknown", i, kind)) + } } - if p.ProviderConfig.PluginID == "" || p.ProviderConfig.ProviderID == "" || p.ProviderConfig.ContractID == "" { - errs = append(errs, errors.New("provider_config identity is required")) + switch p.OperatingMode { + case NetworkModeBatch: + case NetworkModeWarmService, NetworkModeInferenceAPI: + if !contains(p.WorkloadKinds, string(WorkloadService)) { + errs = append(errs, fmt.Errorf("operating_mode %q requires service workload kind", p.OperatingMode)) + } + case NetworkModeNodeService: + if !contains(p.WorkloadKinds, string(WorkloadNodeService)) { + errs = append(errs, fmt.Errorf("operating_mode %q requires node-service workload kind", p.OperatingMode)) + } } - if p.PlacementConstraints.Chain == "" || p.PlacementConstraints.Role == "" || p.PlacementConstraints.MinDiskBytes <= 0 { - errs = append(errs, errors.New("placement_constraints chain, role, and min_disk_bytes are required")) + if p.SecurityFloor.ExecutorProvider == "" { + errs = append(errs, errors.New("security_floor.executor_provider is required")) + } + if p.SecurityFloor.ExecutionSecurityTier == "" { + errs = append(errs, errors.New("security_floor.execution_security_tier is required")) + } else if !validExecutionSecurityTier(p.SecurityFloor.ExecutionSecurityTier) { + errs = append(errs, fmt.Errorf("security_floor.execution_security_tier %q is unknown", p.SecurityFloor.ExecutionSecurityTier)) + } else if p.SecurityFloor.ExecutionSecurityTier == ExecutionTrustedNative { + errs = append(errs, errors.New("security_floor.execution_security_tier trusted-native is not allowed for network products")) + } + if p.SecurityFloor.ProofTier == "" { + errs = append(errs, errors.New("security_floor.proof_tier is required")) + } else if !validProofTier(p.SecurityFloor.ProofTier) { + errs = append(errs, fmt.Errorf("security_floor.proof_tier %q is unknown", p.SecurityFloor.ProofTier)) + } else if p.SecurityFloor.ProofTier == ProofReceiptOnly { + errs = append(errs, errors.New("security_floor.proof_tier receipt-only is not allowed for network products")) + } else if err := ValidateProofPolicy(p.SecurityFloor.ProofTier, p.ProofPolicy); err != nil { + errs = append(errs, fmt.Errorf("proof_policy: %w", err)) + } + if len(p.NetworkModes) == 0 { + errs = append(errs, errors.New("network_modes is required")) } - if p.SettlementTarget.Kind == "" || p.SettlementTarget.Network == "" || p.SettlementTarget.WalletRef == "" { - errs = append(errs, errors.New("settlement_target is required")) + for i, mode := range p.NetworkModes { + if !validNetworkMode(normalizeNetworkMode(mode)) { + errs = append(errs, fmt.Errorf("network_modes[%d] %q is unsupported", i, mode)) + } + } + if strings.TrimSpace(p.RewardPolicy) == "" { + errs = append(errs, errors.New("reward_policy is required")) + } + if strings.TrimSpace(p.AbusePolicy) == "" { + errs = append(errs, errors.New("abuse_policy is required")) + } + if p.SettlementAccountID != "" { + if err := validateIdentifier("settlement_account_id", p.SettlementAccountID); err != nil { + errs = append(errs, err) + } + } + if err := p.SettlementTarget.Validate(p.SettlementAccountID); err != nil { + errs = append(errs, fmt.Errorf("settlement_target: %w", err)) + } + if err := p.CryptoRewardRouting.Validate(p.SettlementTarget); err != nil { + errs = append(errs, fmt.Errorf("crypto_reward_routing: %w", err)) + } + if err := p.PlacementConstraints.Validate(p.SettlementTarget.Kind == SettlementTargetTreasuryWallet, p.SettlementTarget); err != nil { + errs = append(errs, fmt.Errorf("placement_constraints: %w", err)) + } + if err := p.ContributionPolicy.ValidateForRewardPolicy(p.RewardPolicy, p.SettlementTarget); err != nil { + errs = append(errs, fmt.Errorf("contribution_policy: %w", err)) + } + if err := p.AccessPolicy.Validate(); err != nil { + errs = append(errs, fmt.Errorf("access_policy: %w", err)) + } + if err := p.ResiduePolicy.Validate(ResiduePolicyValidation{}); err != nil { + errs = append(errs, fmt.Errorf("residue_policy: %w", err)) + } + if !p.ResiduePolicy.IsZero() && !networkProductAdmitsShortLivedResidue(p) { + errs = append(errs, errors.New("residue_policy is only for short-lived workload products; service products use session_policy and durable service storage")) + } + if strings.EqualFold(strings.TrimSpace(p.RewardPolicy), "upstream-credit") { + if p.OperatingMode != NetworkModeWarmService && p.OperatingMode != NetworkModeNodeService { + errs = append(errs, errors.New("upstream-credit products require headless warm-service or node-service operating mode")) + } + if !contains(p.WorkloadKinds, string(WorkloadService)) && !contains(p.WorkloadKinds, string(WorkloadNodeService)) { + errs = append(errs, errors.New("upstream-credit products require service or node-service workload kind")) + } + } + if p.OperatingMode == NetworkModeNodeService && strings.EqualFold(strings.TrimSpace(p.RewardPolicy), "profit-share") { + if p.SettlementTarget.Kind != SettlementTargetTreasuryWallet { + errs = append(errs, errors.New("node-service profit-share products require settlement_target.kind treasury_wallet")) + } + if strings.TrimSpace(p.SettlementTarget.Network) == "" { + errs = append(errs, errors.New("node-service profit-share products require settlement_target.network")) + } + if strings.TrimSpace(p.SettlementTarget.WalletRef) == "" { + errs = append(errs, errors.New("node-service profit-share products require settlement_target.wallet_ref")) + } + } + if err := p.SessionPolicy.ValidateForOperatingMode(p.OperatingMode); err != nil { + errs = append(errs, fmt.Errorf("session_policy: %w", err)) + } + if err := p.ProviderConfig.Validate(); err != nil { + errs = append(errs, fmt.Errorf("provider_config: %w", err)) } return errors.Join(errs...) } +func networkProductAdmitsShortLivedResidue(p NetworkProduct) bool { + for _, kind := range p.WorkloadKinds { + switch WorkloadKind(strings.TrimSpace(kind)) { + case WorkloadService, WorkloadNodeService: + continue + default: + return true + } + } + return false +} + type PlacementConstraints struct { Chain string `json:"chain,omitempty"` Role string `json:"role,omitempty"` @@ -891,6 +1072,93 @@ type PlacementConstraints struct { StorageGuidance StorageGuidance `json:"storage_guidance,omitzero"` } +func (c PlacementConstraints) Validate(required bool, target SettlementTarget) error { + if c.IsZero() { + if required { + return errors.New("placement constraints are required") + } + return nil + } + var errs []error + if c.Chain == "" { + if required { + errs = append(errs, errors.New("chain is required")) + } + } else if err := validateIdentifier("chain", c.Chain); err != nil { + errs = append(errs, err) + } + if c.Role == "" { + if required { + errs = append(errs, errors.New("role is required")) + } + } else if err := validateIdentifier("role", c.Role); err != nil { + errs = append(errs, err) + } + if c.MinDiskBytes <= 0 { + if required { + errs = append(errs, errors.New("min_disk_bytes must be positive")) + } else if c.MinDiskBytes < 0 { + errs = append(errs, errors.New("min_disk_bytes must not be negative")) + } + } + if c.MinMemoryBytes <= 0 { + if required { + errs = append(errs, errors.New("min_memory_bytes must be positive")) + } else if c.MinMemoryBytes < 0 { + errs = append(errs, errors.New("min_memory_bytes must not be negative")) + } + } + if c.MinBandwidthMbps <= 0 { + if required { + errs = append(errs, errors.New("min_bandwidth_mbps must be positive")) + } else if c.MinBandwidthMbps < 0 { + errs = append(errs, errors.New("min_bandwidth_mbps must not be negative")) + } + } + seen := map[string]struct{}{} + for i, tag := range c.RequiredCapabilities { + if err := validateIdentifier(fmt.Sprintf("required_capabilities[%d]", i), tag); err != nil { + errs = append(errs, err) + continue + } + if _, ok := seen[tag]; ok { + errs = append(errs, fmt.Errorf("required_capabilities[%d] %q is duplicated", i, tag)) + } + seen[tag] = struct{}{} + } + if c.WalletRef == "" { + if required { + errs = append(errs, errors.New("wallet_ref is required")) + } + } else { + if !strings.HasPrefix(c.WalletRef, "wallet://") { + errs = append(errs, errors.New("wallet_ref must use wallet:// scope")) + } + if strings.ContainsAny(c.WalletRef, " \t\r\n?&#") { + errs = append(errs, errors.New("wallet_ref must not contain whitespace, query, fragment, or parameter delimiters")) + } + if target.WalletRef != "" && c.WalletRef != target.WalletRef { + errs = append(errs, errors.New("wallet_ref must match settlement_target.wallet_ref")) + } + } + if err := c.StorageGuidance.Validate(); err != nil { + errs = append(errs, fmt.Errorf("storage_guidance: %w", err)) + } + return errors.Join(errs...) +} + +func (c PlacementConstraints) IsZero() bool { + return c.Chain == "" && + c.Role == "" && + c.MinDiskBytes == 0 && + c.MinMemoryBytes == 0 && + c.MinBandwidthMbps == 0 && + !c.RequiresIngress && + len(c.RequiredCapabilities) == 0 && + c.WalletRef == "" && + c.StorageGuidance == (StorageGuidance{}) +} + type StorageGuidance struct { Mode string `json:"mode,omitempty"` MinDiskBytes int64 `json:"min_disk_bytes,omitempty"` @@ -906,9 +1174,119 @@ type StorageGuidance struct { SnapshotVerificationRequired bool `json:"snapshot_verification_required,omitempty"` } +func (g StorageGuidance) Validate() error { + if g == (StorageGuidance{}) { + return nil + } + var errs []error + if g.Mode != "" { + if err := validateIdentifier("mode", g.Mode); err != nil { + errs = append(errs, err) + } + } + if g.MinDiskBytes < 0 { + errs = append(errs, errors.New("min_disk_bytes must not be negative")) + } + if g.RecommendedDiskBytes < 0 { + errs = append(errs, errors.New("recommended_disk_bytes must not be negative")) + } + if g.GrowthMarginBytes < 0 { + errs = append(errs, errors.New("growth_margin_bytes must not be negative")) + } + if g.MinDiskBytes > 0 && g.MinDiskDisplay == "" { + errs = append(errs, errors.New("min_disk_display is required when min_disk_bytes is set")) + } + if g.RecommendedDiskBytes > 0 && g.RecommendedDiskDisplay == "" { + errs = append(errs, errors.New("recommended_disk_display is required when recommended_disk_bytes is set")) + } + if g.GrowthMarginBytes > 0 && g.GrowthMarginDisplay == "" { + errs = append(errs, errors.New("growth_margin_display is required when growth_margin_bytes is set")) + } + if g.MinDiskBytes > 0 && g.RecommendedDiskBytes > 0 && g.RecommendedDiskBytes < g.MinDiskBytes { + errs = append(errs, errors.New("recommended_disk_bytes must be at least min_disk_bytes")) + } + for _, field := range []struct { + name string + value string + }{ + {"min_disk_display", g.MinDiskDisplay}, + {"recommended_disk_display", g.RecommendedDiskDisplay}, + {"growth_margin_display", g.GrowthMarginDisplay}, + } { + if strings.TrimSpace(field.value) != field.value || strings.ContainsAny(field.value, "\t\r\n") { + errs = append(errs, fmt.Errorf("%s must not contain surrounding whitespace or control whitespace", field.name)) + } + } + return errors.Join(errs...) +} + +type ContributionAuthority string + +const ( + ContributionAuthorityWFCompute ContributionAuthority = "wfcompute" + ContributionAuthorityUpstream ContributionAuthority = "upstream" +) + +type ContributionPolicy struct { + ValidationAuthority ContributionAuthority `json:"validation_authority,omitempty"` + CreditAuthority ContributionAuthority `json:"credit_authority,omitempty"` + MonetaryPayouts bool `json:"monetary_payouts,omitempty"` +} + +func (p ContributionPolicy) ValidateForRewardPolicy(rewardPolicy string, target SettlementTarget) error { + if p == (ContributionPolicy{}) && !strings.EqualFold(strings.TrimSpace(rewardPolicy), "upstream-credit") { + return nil + } + var errs []error + if p.ValidationAuthority == "" { + errs = append(errs, errors.New("validation_authority is required")) + } else if !validContributionAuthority(p.ValidationAuthority) { + errs = append(errs, fmt.Errorf("validation_authority %q is unsupported", p.ValidationAuthority)) + } + if p.CreditAuthority == "" { + errs = append(errs, errors.New("credit_authority is required")) + } else if !validContributionAuthority(p.CreditAuthority) { + errs = append(errs, fmt.Errorf("credit_authority %q is unsupported", p.CreditAuthority)) + } + if strings.EqualFold(strings.TrimSpace(rewardPolicy), "upstream-credit") { + if p.ValidationAuthority != ContributionAuthorityUpstream { + errs = append(errs, errors.New("validation_authority must be upstream for upstream-credit")) + } + if p.CreditAuthority != ContributionAuthorityUpstream { + errs = append(errs, errors.New("credit_authority must be upstream for upstream-credit")) + } + if p.MonetaryPayouts { + errs = append(errs, errors.New("monetary_payouts must be false for upstream-credit")) + } + switch target.Kind { + case "", SettlementTargetBadgeLedger: + default: + errs = append(errs, fmt.Errorf("settlement_target.kind %q is not allowed for upstream-credit", target.Kind)) + } + } + return errors.Join(errs...) +} + +func validContributionAuthority(authority ContributionAuthority) bool { + switch authority { + case ContributionAuthorityWFCompute, ContributionAuthorityUpstream: + return true + default: + return false + } +} + type SettlementTargetKind string -const SettlementTargetTreasuryWallet SettlementTargetKind = "treasury_wallet" +const ( + SettlementTargetPointsLedger SettlementTargetKind = "points_ledger" + SettlementTargetPayrollAccount SettlementTargetKind = "payroll_account" + SettlementTargetBadgeLedger SettlementTargetKind = "badge_ledger" + SettlementTargetFiatTokenTreasury SettlementTargetKind = "fiat_token_treasury" + SettlementTargetTreasuryWallet SettlementTargetKind = "treasury_wallet" + SettlementTargetParticipantWallet SettlementTargetKind = "participant_wallet" + SettlementTargetExternalDestination SettlementTargetKind = "external_destination" +) type SettlementTarget struct { Kind SettlementTargetKind `json:"kind,omitempty"` @@ -917,6 +1295,58 @@ type SettlementTarget struct { WalletRef string `json:"wallet_ref,omitempty"` } +func (t SettlementTarget) Validate(settlementAccountID string) error { + if t == (SettlementTarget{}) { + return nil + } + var errs []error + switch t.Kind { + case SettlementTargetPointsLedger, SettlementTargetPayrollAccount, SettlementTargetBadgeLedger, SettlementTargetFiatTokenTreasury, SettlementTargetTreasuryWallet, SettlementTargetParticipantWallet, SettlementTargetExternalDestination: + case "": + errs = append(errs, errors.New("kind is required")) + default: + errs = append(errs, fmt.Errorf("kind %q is unsupported", t.Kind)) + } + if t.AccountID != "" { + if err := validateIdentifier("account_id", t.AccountID); err != nil { + errs = append(errs, err) + } + } + if settlementAccountID != "" && t.AccountID != "" && t.AccountID != settlementAccountID { + errs = append(errs, fmt.Errorf("account_id %q must match settlement_account_id %q", t.AccountID, settlementAccountID)) + } + if t.Network != "" { + if err := validateIdentifier("network", t.Network); err != nil { + errs = append(errs, err) + } + } + if t.WalletRef != "" { + if strings.ContainsAny(t.WalletRef, " \t\r\n") { + errs = append(errs, errors.New("wallet_ref must not contain whitespace")) + } + if strings.ContainsAny(t.WalletRef, "?&#") { + errs = append(errs, errors.New("wallet_ref must not contain query, fragment, or parameter delimiters")) + } + } + if t.Kind == SettlementTargetTreasuryWallet { + if strings.TrimSpace(t.AccountID) == "" { + errs = append(errs, errors.New("account_id is required for treasury_wallet")) + } + if strings.TrimSpace(t.Network) == "" { + errs = append(errs, errors.New("network is required for treasury_wallet")) + } + if strings.TrimSpace(t.WalletRef) == "" { + errs = append(errs, errors.New("wallet_ref is required for treasury_wallet")) + } + } + if t.Kind == SettlementTargetExternalDestination { + if strings.TrimSpace(t.AccountID) == "" { + errs = append(errs, errors.New("account_id is required for external_destination")) + } + } + return errors.Join(errs...) +} + type CryptoRewardCustodyMode string const CryptoRewardCustodyTreasuryThenDistribute CryptoRewardCustodyMode = "treasury_then_distribute" @@ -939,6 +1369,67 @@ type CryptoRewardRoutingPolicy struct { ManagementFeeBps int `json:"management_fee_bps,omitempty"` } +func (p CryptoRewardRoutingPolicy) Validate(target SettlementTarget) error { + if p == (CryptoRewardRoutingPolicy{}) { + return nil + } + var errs []error + if strings.TrimSpace(p.Network) == "" { + errs = append(errs, errors.New("network is required")) + } else if err := validateIdentifier("network", p.Network); err != nil { + errs = append(errs, err) + } + if strings.TrimSpace(p.TreasuryAccountID) == "" { + errs = append(errs, errors.New("treasury_account_id is required")) + } else if err := validateIdentifier("treasury_account_id", p.TreasuryAccountID); err != nil { + errs = append(errs, err) + } + if strings.TrimSpace(p.TreasuryWalletRef) == "" { + errs = append(errs, errors.New("treasury_wallet_ref is required")) + } else if err := validateScopedRef("treasury_wallet_ref", p.TreasuryWalletRef, "wallet://"); err != nil { + errs = append(errs, err) + } else if strings.ContainsAny(p.TreasuryWalletRef, " \t\r\n?&#") { + errs = append(errs, errors.New("treasury_wallet_ref must not contain whitespace, query, fragment, or ampersand")) + } + switch p.CustodyMode { + case CryptoRewardCustodyTreasuryThenDistribute: + case "": + errs = append(errs, errors.New("custody_mode is required")) + default: + errs = append(errs, fmt.Errorf("custody_mode %q is unsupported", p.CustodyMode)) + } + switch p.DistributionMode { + case CryptoRewardDistributionContributionShare: + case "": + errs = append(errs, errors.New("distribution_mode is required")) + default: + errs = append(errs, fmt.Errorf("distribution_mode %q is unsupported", p.DistributionMode)) + } + switch p.ParticipantWalletSource { + case CryptoRewardParticipantAccountWallet: + case "": + errs = append(errs, errors.New("participant_wallet_source is required")) + default: + errs = append(errs, fmt.Errorf("participant_wallet_source %q is unsupported", p.ParticipantWalletSource)) + } + if p.ManagementFeeBps < 0 || p.ManagementFeeBps > 10_000 { + errs = append(errs, errors.New("management_fee_bps must be between 0 and 10000")) + } + if target.Kind != SettlementTargetTreasuryWallet { + errs = append(errs, errors.New("settlement_target.kind must be treasury_wallet")) + } + if target.Network != "" && p.Network != target.Network { + errs = append(errs, fmt.Errorf("network %q must match settlement_target.network %q", p.Network, target.Network)) + } + if target.AccountID != "" && p.TreasuryAccountID != target.AccountID { + errs = append(errs, fmt.Errorf("treasury_account_id %q must match settlement_target.account_id %q", p.TreasuryAccountID, target.AccountID)) + } + if target.WalletRef != "" && p.TreasuryWalletRef != target.WalletRef { + errs = append(errs, errors.New("treasury_wallet_ref must match settlement_target.wallet_ref")) + } + return errors.Join(errs...) +} + type ProviderConformanceEvidence struct { ProtocolVersion string `json:"protocol_version"` ID string `json:"id"` @@ -1191,6 +1682,17 @@ func validNetworkMode(mode NetworkMode) bool { } } +func validateNetworkProductID(id string) error { + id = strings.TrimSpace(id) + if id == "" { + return errors.New("product_id is required") + } + if strings.ContainsAny(id, " \t\r\n/:?&#") { + return errors.New("product_id must not contain whitespace, scheme, path, query, or fragment") + } + return nil +} + func validContainerRuntimeTool(tool ContainerRuntimeTool) bool { switch tool { case ContainerRuntimePodman, ContainerRuntimeDocker, ContainerRuntimeNerdctl, ContainerRuntimeAppleContainer: diff --git a/protocol/types_test.go b/protocol/types_test.go index df12bf6..0e3686b 100644 --- a/protocol/types_test.go +++ b/protocol/types_test.go @@ -319,6 +319,66 @@ func TestProviderContractRejectsMismatchedProductVersionWhenPresent(t *testing.T } } +func TestNetworkProductAcceptsWorkflowComputeProductCompatibilityShape(t *testing.T) { + product := validBatchNetworkProduct() + + if err := product.Validate(); err != nil { + t.Fatalf("product invalid: %v", err) + } + + contract := validBatchProviderContract() + if err := contract.SupportsProduct(product); err != nil { + t.Fatalf("contract should support product: %v", err) + } +} + +func TestNetworkProductRejectsServiceResiduePolicy(t *testing.T) { + product := validBatchNetworkProduct() + product.OperatingMode = protocol.NetworkModeWarmService + product.WorkloadKinds = []string{string(protocol.WorkloadService)} + product.ResiduePolicy = protocol.ResiduePolicy{ + Mode: protocol.ResidueModeProviderBound, + AllowedModes: []protocol.ResidueMode{protocol.ResidueModeProviderBound}, + } + + err := product.Validate() + if err == nil { + t.Fatal("expected service product residue policy to fail") + } + if !strings.Contains(err.Error(), "residue_policy") { + t.Fatalf("expected residue_policy error, got %v", err) + } +} + +func TestProviderConfigValidatesScopedConfigRef(t *testing.T) { + config := protocol.ProviderConfig{ + PluginID: "workflow-plugin-example", + ProviderID: "example", + ContractID: "example.batch.v1", + Version: "v1.0.0", + ConfigRef: "config://providers/example/batch", + ConfigDigest: protocol.CanonicalHash("config"), + } + + if err := config.Validate(); err != nil { + t.Fatalf("config invalid: %v", err) + } + + config.ConfigRef = "secret://wrong-scope" + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "config_ref") { + t.Fatalf("expected config_ref error, got %v", err) + } +} + +func TestValidateProofPolicyRequiresQuorumOnlyForQuorumTiers(t *testing.T) { + if err := protocol.ValidateProofPolicy(protocol.ProofReplicatedQuorum, protocol.ProofPolicy{Quorum: 2, MaxAttempts: 3}); err != nil { + t.Fatalf("quorum proof policy invalid: %v", err) + } + if err := protocol.ValidateProofPolicy(protocol.ProofArtifactHash, protocol.ProofPolicy{Quorum: 2}); err == nil || !strings.Contains(err.Error(), "quorum") { + t.Fatalf("expected non-quorum tier to reject quorum policy, got %v", err) + } +} + func TestProviderContractAcceptsWorkflowComputeNetworkModes(t *testing.T) { for _, mode := range []protocol.NetworkMode{ protocol.NetworkModeDirect, @@ -337,6 +397,49 @@ func TestProviderContractAcceptsWorkflowComputeNetworkModes(t *testing.T) { } } +func validBatchNetworkProduct() protocol.NetworkProduct { + return protocol.NetworkProduct{ + ProtocolVersion: protocol.Version, + ID: "example-batch", + DisplayName: "Example Batch", + Purpose: "ci", + OperatingMode: protocol.NetworkModeBatch, + OrgID: "public", + PoolID: "ci", + WorkloadKinds: []string{string(protocol.WorkloadCommand)}, + SecurityFloor: protocol.PlacementRequirements{ + ExecutorProvider: "sandboxed-container", + ExecutionSecurityTier: protocol.ExecutionSandboxedContainer, + ProofTier: protocol.ProofArtifactHash, + RequiredCapabilities: []string{"linux"}, + }, + ProviderConfig: protocol.ProviderConfig{ + PluginID: "workflow-plugin-example", + ProviderID: "example", + ContractID: "example.batch.v1", + Version: "v1.0.0", + ConfigRef: "config://providers/example/batch", + }, + NetworkModes: []protocol.NetworkMode{protocol.NetworkModeRelay}, + PlacementConstraints: protocol.PlacementConstraints{ + RequiredCapabilities: []string{"linux"}, + }, + RewardPolicy: "points", + AbusePolicy: "ban", + AccessPolicy: protocol.AccessPolicy{ + ProviderUsageVisibility: protocol.AccessVisibilityPublic, + }, + ResiduePolicy: protocol.ResiduePolicy{ + Mode: protocol.ResidueModeProviderBound, + AllowedModes: []protocol.ResidueMode{protocol.ResidueModeIsolated, protocol.ResidueModeProviderBound}, + PolicyHash: protocol.CanonicalHash("residue-policy"), + }, + AdmissionMode: "open", + AllowPublic: true, + CreatedAt: time.Now().UTC(), + } +} + func TestProviderConformanceEvidenceRequiresArtifactDigestAndObservation(t *testing.T) { evidence := protocol.ProviderConformanceEvidence{ ProtocolVersion: protocol.Version,