Major enhancements: - Added production-grade durability guarantees with fsync operations - Implemented BadgerDB WAL for crash recovery and persistence - Added comprehensive HTTP API (GET/POST /state, POST /command) - Exported ComputeStateHash for external use in genesis initialization - Enhanced snapshot system with atomic write-fsync-rename sequence - Added API integration documentation and durability guarantees docs New files: - api.go: HTTP server implementation with state and command endpoints - api_test.go: Comprehensive API test suite - badger_wal.go: BadgerDB-based write-ahead log - cmd/swoosh/main.go: CLI entry point with API server - API_INTEGRATION.md: API usage and integration guide - DURABILITY.md: Durability guarantees and recovery procedures - CHANGELOG.md: Version history and changes - RELEASE_NOTES.md: Release notes for v1.0.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
641 lines
17 KiB
Go
641 lines
17 KiB
Go
package swoosh
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// ErrGuardRejected is returned when guard outcomes prevent a transition from applying.
|
|
ErrGuardRejected = errors.New("guard rejected transition")
|
|
// ErrUnknownTransition is returned when Reduce encounters an unhandled transition name.
|
|
ErrUnknownTransition = errors.New("unknown transition")
|
|
// ErrQuarantined indicates transitions are blocked while policy quarantine is active.
|
|
ErrQuarantined = errors.New("transition blocked by quarantine")
|
|
// ErrInvalidPhase signals an unexpected phase for the requested transition.
|
|
ErrInvalidPhase = errors.New("invalid phase for transition")
|
|
// ErrPrecondition signals a precondition failure unrelated to phases or guards.
|
|
ErrPrecondition = errors.New("precondition failed")
|
|
)
|
|
|
|
// Reduce applies a validated transition to the given state and returns a new state.
|
|
// Reducer must remain deterministic and side-effect free.
|
|
func Reduce(oldState OrchestratorState, t TransitionProposal, g GuardOutcome) (OrchestratorState, error) {
|
|
newState := oldState
|
|
changed, err := applyTransition(&newState, t, g)
|
|
if err != nil {
|
|
return OrchestratorState{}, err
|
|
}
|
|
if !changed {
|
|
return oldState, nil
|
|
}
|
|
|
|
newState.HLCLast = t.HLC
|
|
hash, err := computeStateHash(newState)
|
|
if err != nil {
|
|
return OrchestratorState{}, fmt.Errorf("compute state hash: %w", err)
|
|
}
|
|
newState.StateHash = hash
|
|
|
|
return newState, nil
|
|
}
|
|
|
|
func applyTransition(state *OrchestratorState, t TransitionProposal, g GuardOutcome) (bool, error) {
|
|
name := t.TransitionName
|
|
switch name {
|
|
case "":
|
|
return false, fmt.Errorf("%w: empty transition name", ErrUnknownTransition)
|
|
}
|
|
|
|
if state.Policy.Quarantined && name != "TRANSITION_QUARANTINE_RELEASE" && name != "TRANSITION_QUARANTINE_CONFIRM_BLOCK" {
|
|
return false, fmt.Errorf("%w: %s", ErrQuarantined, name)
|
|
}
|
|
|
|
switch name {
|
|
case "BOOT_CONFIG_OK":
|
|
if state.Boot.Licensed {
|
|
return false, fmt.Errorf("%w: boot already licensed", ErrPrecondition)
|
|
}
|
|
return false, nil
|
|
|
|
case "LICENSE_GRANTED":
|
|
if state.Boot.Licensed {
|
|
return false, nil
|
|
}
|
|
if !g.LicenseOK {
|
|
return false, fmt.Errorf("%w: license check failed", ErrGuardRejected)
|
|
}
|
|
state.Boot.Licensed = true
|
|
return true, nil
|
|
|
|
case "INGESTION_SOURCES_RESOLVED":
|
|
if state.Ingestion.Phase == "FETCH" && state.Ingestion.LastError == "" {
|
|
return false, nil
|
|
}
|
|
if state.Ingestion.Phase != "" && state.Ingestion.Phase != "DISCOVER" {
|
|
return false, fmt.Errorf("%w: ingestion phase %q", ErrInvalidPhase, state.Ingestion.Phase)
|
|
}
|
|
changed := false
|
|
if state.Ingestion.Phase != "FETCH" {
|
|
state.Ingestion.Phase = "FETCH"
|
|
changed = true
|
|
}
|
|
if state.Ingestion.LastError != "" {
|
|
state.Ingestion.LastError = ""
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Ingestion.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "INGESTION_BYTES_OK":
|
|
if state.Ingestion.Phase == "VALIDATE" && state.Ingestion.LastError == "" {
|
|
return false, nil
|
|
}
|
|
if state.Ingestion.Phase != "FETCH" {
|
|
return false, fmt.Errorf("%w: ingestion phase %q", ErrInvalidPhase, state.Ingestion.Phase)
|
|
}
|
|
changed := false
|
|
if state.Ingestion.Phase != "VALIDATE" {
|
|
state.Ingestion.Phase = "VALIDATE"
|
|
changed = true
|
|
}
|
|
if state.Ingestion.LastError != "" {
|
|
state.Ingestion.LastError = ""
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Ingestion.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "INGESTION_SCHEMA_OK":
|
|
if state.Ingestion.Phase == "INDEX" && state.Ingestion.LastError == "" {
|
|
return false, nil
|
|
}
|
|
if state.Ingestion.Phase != "VALIDATE" {
|
|
return false, fmt.Errorf("%w: ingestion phase %q", ErrInvalidPhase, state.Ingestion.Phase)
|
|
}
|
|
if !g.PolicyOK || !g.BackbeatOK {
|
|
return false, fmt.Errorf("%w: policy=%t backbeat=%t", ErrGuardRejected, g.PolicyOK, g.BackbeatOK)
|
|
}
|
|
changed := false
|
|
if state.Ingestion.Phase != "INDEX" {
|
|
state.Ingestion.Phase = "INDEX"
|
|
changed = true
|
|
}
|
|
if state.Ingestion.LastError != "" {
|
|
state.Ingestion.LastError = ""
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Ingestion.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "INGESTION_CORPUS_BUILT":
|
|
if state.Ingestion.Phase == "READY" && state.Ingestion.LastError == "" {
|
|
return false, nil
|
|
}
|
|
if state.Ingestion.Phase != "INDEX" {
|
|
return false, fmt.Errorf("%w: ingestion phase %q", ErrInvalidPhase, state.Ingestion.Phase)
|
|
}
|
|
changed := false
|
|
if state.Ingestion.Phase != "READY" {
|
|
state.Ingestion.Phase = "READY"
|
|
changed = true
|
|
}
|
|
if state.Ingestion.LastError != "" {
|
|
state.Ingestion.LastError = ""
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Ingestion.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "INGESTION_POLICY_VIOLATION":
|
|
if g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard true", ErrGuardRejected)
|
|
}
|
|
if state.Ingestion.Phase != "VALIDATE" && state.Ingestion.Phase != "INDEX" {
|
|
return false, fmt.Errorf("%w: ingestion phase %q", ErrInvalidPhase, state.Ingestion.Phase)
|
|
}
|
|
rationale := joinRationale(g.Rationale)
|
|
changed := false
|
|
if !state.Policy.Quarantined {
|
|
state.Policy.Quarantined = true
|
|
changed = true
|
|
}
|
|
if state.Policy.Rationale != rationale {
|
|
state.Policy.Rationale = rationale
|
|
changed = true
|
|
}
|
|
if !state.Control.Degraded {
|
|
state.Control.Degraded = true
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Ingestion.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "COUNCIL_PROFILES_LOADED":
|
|
if state.Council.Phase == "ELECT" {
|
|
return false, nil
|
|
}
|
|
if state.Council.Phase != "" && state.Council.Phase != "PLAN_ROLES" {
|
|
return false, fmt.Errorf("%w: council phase %q", ErrInvalidPhase, state.Council.Phase)
|
|
}
|
|
state.Council.Phase = "ELECT"
|
|
state.Council.Epoch++
|
|
return true, nil
|
|
|
|
case "COUNCIL_QUORUM_CERT":
|
|
if state.Council.Phase == "TOOLING_SYNC" {
|
|
return false, nil
|
|
}
|
|
if state.Council.Phase != "ELECT" {
|
|
return false, fmt.Errorf("%w: council phase %q", ErrInvalidPhase, state.Council.Phase)
|
|
}
|
|
if !g.QuorumOK || !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: quorum=%t policy=%t", ErrGuardRejected, g.QuorumOK, g.PolicyOK)
|
|
}
|
|
state.Council.Phase = "TOOLING_SYNC"
|
|
state.Council.Epoch++
|
|
return true, nil
|
|
|
|
case "COUNCIL_MCP_GREEN":
|
|
if state.Council.Phase == "READY" && state.Council.MCPHealthGreen {
|
|
return false, nil
|
|
}
|
|
if state.Council.Phase != "TOOLING_SYNC" {
|
|
return false, fmt.Errorf("%w: council phase %q", ErrInvalidPhase, state.Council.Phase)
|
|
}
|
|
if !g.MCPHealthy {
|
|
return false, fmt.Errorf("%w: mcp unhealthy", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Council.Phase != "READY" {
|
|
state.Council.Phase = "READY"
|
|
changed = true
|
|
}
|
|
if !state.Council.MCPHealthGreen {
|
|
state.Council.MCPHealthGreen = true
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Council.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "ENV_CAPACITY_OK":
|
|
if state.Environment.Phase == "PROVISION" && state.Environment.CapacityOK && state.Environment.Health == "" {
|
|
return false, nil
|
|
}
|
|
if state.Environment.Phase != "" && state.Environment.Phase != "ALLOCATE" {
|
|
return false, fmt.Errorf("%w: environment phase %q", ErrInvalidPhase, state.Environment.Phase)
|
|
}
|
|
if !g.BackbeatOK {
|
|
return false, fmt.Errorf("%w: backbeat guard false", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Environment.Phase != "PROVISION" {
|
|
state.Environment.Phase = "PROVISION"
|
|
changed = true
|
|
}
|
|
if !state.Environment.CapacityOK {
|
|
state.Environment.CapacityOK = true
|
|
changed = true
|
|
}
|
|
if state.Environment.Health != "" {
|
|
state.Environment.Health = ""
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Environment.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "ENV_INSTALLED":
|
|
if state.Environment.Phase == "HEALTHCHECK" {
|
|
return false, nil
|
|
}
|
|
if state.Environment.Phase != "PROVISION" {
|
|
return false, fmt.Errorf("%w: environment phase %q", ErrInvalidPhase, state.Environment.Phase)
|
|
}
|
|
state.Environment.Phase = "HEALTHCHECK"
|
|
state.Environment.Epoch++
|
|
return true, nil
|
|
|
|
case "ENV_HEALTH_GREEN":
|
|
if state.Environment.Phase == "READY" && state.Environment.Health == "green" && !state.Control.Degraded {
|
|
return false, nil
|
|
}
|
|
if state.Environment.Phase != "HEALTHCHECK" {
|
|
return false, fmt.Errorf("%w: environment phase %q", ErrInvalidPhase, state.Environment.Phase)
|
|
}
|
|
changed := false
|
|
if state.Environment.Phase != "READY" {
|
|
state.Environment.Phase = "READY"
|
|
changed = true
|
|
}
|
|
if state.Environment.Health != "green" {
|
|
state.Environment.Health = "green"
|
|
changed = true
|
|
}
|
|
if state.Control.Degraded {
|
|
state.Control.Degraded = false
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Environment.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "ENV_HEALTH_AMBER":
|
|
if state.Environment.Phase == "DEGRADED" && state.Environment.Health == "amber" && state.Control.Degraded {
|
|
return false, nil
|
|
}
|
|
if state.Environment.Phase != "HEALTHCHECK" {
|
|
return false, fmt.Errorf("%w: environment phase %q", ErrInvalidPhase, state.Environment.Phase)
|
|
}
|
|
changed := false
|
|
if state.Environment.Phase != "DEGRADED" {
|
|
state.Environment.Phase = "DEGRADED"
|
|
changed = true
|
|
}
|
|
if state.Environment.Health != "amber" {
|
|
state.Environment.Health = "amber"
|
|
changed = true
|
|
}
|
|
if !state.Control.Degraded {
|
|
state.Control.Degraded = true
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Environment.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "ENV_RECOVERED_GREEN":
|
|
if state.Environment.Phase == "READY" && state.Environment.Health == "green" && !state.Control.Degraded {
|
|
return false, nil
|
|
}
|
|
if state.Environment.Phase != "DEGRADED" {
|
|
return false, fmt.Errorf("%w: environment phase %q", ErrInvalidPhase, state.Environment.Phase)
|
|
}
|
|
if !g.MCPHealthy {
|
|
return false, fmt.Errorf("%w: mcp unhealthy", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Environment.Phase != "READY" {
|
|
state.Environment.Phase = "READY"
|
|
changed = true
|
|
}
|
|
if state.Environment.Health != "green" {
|
|
state.Environment.Health = "green"
|
|
changed = true
|
|
}
|
|
if state.Control.Degraded {
|
|
state.Control.Degraded = false
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Environment.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "EXEC_PLAN_LOCKED":
|
|
if state.Execution.Phase == "WORK" && state.Execution.PlanLocked && state.Execution.ActiveWindowID == t.WindowID {
|
|
return false, nil
|
|
}
|
|
if state.Execution.Phase != "" && state.Execution.Phase != "PLAN" {
|
|
return false, fmt.Errorf("%w: execution phase %q", ErrInvalidPhase, state.Execution.Phase)
|
|
}
|
|
if !g.BackbeatOK {
|
|
return false, fmt.Errorf("%w: backbeat guard false", ErrGuardRejected)
|
|
}
|
|
if state.Ingestion.Phase != "READY" || state.Council.Phase != "READY" || state.Environment.Phase != "READY" {
|
|
return false, fmt.Errorf("%w: prerequisites not ready", ErrPrecondition)
|
|
}
|
|
changed := false
|
|
if state.Execution.Phase != "WORK" {
|
|
state.Execution.Phase = "WORK"
|
|
changed = true
|
|
}
|
|
if !state.Execution.PlanLocked {
|
|
state.Execution.PlanLocked = true
|
|
changed = true
|
|
}
|
|
if state.Execution.ActiveWindowID != t.WindowID {
|
|
state.Execution.ActiveWindowID = t.WindowID
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Execution.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "EXEC_BEAT_REVIEW_GATE":
|
|
if state.Execution.Phase == "REVIEW" {
|
|
return false, nil
|
|
}
|
|
if state.Execution.Phase != "WORK" {
|
|
return false, fmt.Errorf("%w: execution phase %q", ErrInvalidPhase, state.Execution.Phase)
|
|
}
|
|
if !g.BackbeatOK {
|
|
return false, fmt.Errorf("%w: backbeat guard false", ErrGuardRejected)
|
|
}
|
|
state.Execution.Phase = "REVIEW"
|
|
state.Execution.PlanLocked = true
|
|
state.Execution.Epoch++
|
|
return true, nil
|
|
|
|
case "EXEC_APPROVALS_THRESHOLD":
|
|
if state.Execution.Phase == "REVERB" {
|
|
return false, nil
|
|
}
|
|
if state.Execution.Phase != "REVIEW" {
|
|
return false, fmt.Errorf("%w: execution phase %q", ErrInvalidPhase, state.Execution.Phase)
|
|
}
|
|
if !g.QuorumOK || !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: quorum=%t policy=%t", ErrGuardRejected, g.QuorumOK, g.PolicyOK)
|
|
}
|
|
state.Execution.Phase = "REVERB"
|
|
state.Execution.Epoch++
|
|
return true, nil
|
|
|
|
case "EXEC_CHANGES_REQUESTED":
|
|
if state.Execution.Phase == "WORK" {
|
|
return false, nil
|
|
}
|
|
if state.Execution.Phase != "REVIEW" {
|
|
return false, fmt.Errorf("%w: execution phase %q", ErrInvalidPhase, state.Execution.Phase)
|
|
}
|
|
if !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard false", ErrGuardRejected)
|
|
}
|
|
state.Execution.Phase = "WORK"
|
|
state.Execution.PlanLocked = true
|
|
state.Execution.Epoch++
|
|
return true, nil
|
|
|
|
case "EXEC_NEXT_WINDOW":
|
|
if state.Execution.Phase == "PLAN" && !state.Execution.PlanLocked && state.Execution.ActiveWindowID == t.WindowID && state.Execution.Approvals == 0 {
|
|
return false, nil
|
|
}
|
|
if state.Execution.Phase != "REVERB" {
|
|
return false, fmt.Errorf("%w: execution phase %q", ErrInvalidPhase, state.Execution.Phase)
|
|
}
|
|
if !g.BackbeatOK {
|
|
return false, fmt.Errorf("%w: backbeat guard false", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Execution.Phase != "PLAN" {
|
|
state.Execution.Phase = "PLAN"
|
|
changed = true
|
|
}
|
|
if state.Execution.PlanLocked {
|
|
state.Execution.PlanLocked = false
|
|
changed = true
|
|
}
|
|
if state.Execution.ActiveWindowID != t.WindowID {
|
|
state.Execution.ActiveWindowID = t.WindowID
|
|
changed = true
|
|
}
|
|
if state.Execution.Approvals != 0 {
|
|
state.Execution.Approvals = 0
|
|
changed = true
|
|
}
|
|
if changed {
|
|
state.Execution.Epoch++
|
|
}
|
|
return changed, nil
|
|
|
|
case "CONTROL_PAUSE":
|
|
needPause := !state.Control.Paused
|
|
needRecoverReset := state.Control.Recovering
|
|
if !needPause && !needRecoverReset {
|
|
return false, nil
|
|
}
|
|
if !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard false", ErrGuardRejected)
|
|
}
|
|
if needPause {
|
|
state.Control.Paused = true
|
|
}
|
|
if needRecoverReset {
|
|
state.Control.Recovering = false
|
|
}
|
|
return true, nil
|
|
|
|
case "CONTROL_RESUME":
|
|
if !state.Control.Paused {
|
|
return false, fmt.Errorf("%w: system not paused", ErrPrecondition)
|
|
}
|
|
if !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard false", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Control.Paused {
|
|
state.Control.Paused = false
|
|
changed = true
|
|
}
|
|
if state.Control.Recovering {
|
|
state.Control.Recovering = false
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
case "CONTROL_RECOVERY_ENTER":
|
|
if state.Control.Recovering && state.Control.Degraded {
|
|
return false, nil
|
|
}
|
|
if g.QuorumOK && !state.Control.Degraded {
|
|
return false, fmt.Errorf("%w: no recovery trigger", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if !state.Control.Recovering {
|
|
state.Control.Recovering = true
|
|
changed = true
|
|
}
|
|
if !state.Control.Degraded {
|
|
state.Control.Degraded = true
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
case "CONTROL_RECOVERY_RESOLVED":
|
|
if !state.Control.Recovering {
|
|
return false, fmt.Errorf("%w: not in recovery", ErrPrecondition)
|
|
}
|
|
if !g.QuorumOK && state.Environment.Health != "green" {
|
|
return false, fmt.Errorf("%w: recovery not satisfied", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Control.Recovering {
|
|
state.Control.Recovering = false
|
|
changed = true
|
|
}
|
|
if state.Control.Degraded {
|
|
state.Control.Degraded = false
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
case "TRANSITION_QUARANTINE_ENTER":
|
|
if g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard true", ErrGuardRejected)
|
|
}
|
|
rationale := joinRationale(g.Rationale)
|
|
changed := false
|
|
if !state.Policy.Quarantined {
|
|
state.Policy.Quarantined = true
|
|
changed = true
|
|
}
|
|
if state.Policy.Rationale != rationale {
|
|
state.Policy.Rationale = rationale
|
|
changed = true
|
|
}
|
|
if !state.Control.Paused {
|
|
state.Control.Paused = true
|
|
changed = true
|
|
}
|
|
if state.Control.Recovering {
|
|
state.Control.Recovering = false
|
|
changed = true
|
|
}
|
|
if !state.Control.Degraded {
|
|
state.Control.Degraded = true
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
case "TRANSITION_QUARANTINE_RELEASE":
|
|
if !state.Policy.Quarantined {
|
|
return false, fmt.Errorf("%w: not quarantined", ErrPrecondition)
|
|
}
|
|
if !g.PolicyOK {
|
|
return false, fmt.Errorf("%w: policy guard false", ErrGuardRejected)
|
|
}
|
|
changed := false
|
|
if state.Policy.Quarantined {
|
|
state.Policy.Quarantined = false
|
|
changed = true
|
|
}
|
|
if state.Control.Paused {
|
|
state.Control.Paused = false
|
|
changed = true
|
|
}
|
|
if state.Control.Degraded {
|
|
state.Control.Degraded = false
|
|
changed = true
|
|
}
|
|
if state.Control.Recovering {
|
|
state.Control.Recovering = false
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
case "TRANSITION_QUARANTINE_CONFIRM_BLOCK":
|
|
if !state.Policy.Quarantined {
|
|
return false, fmt.Errorf("%w: not quarantined", ErrPrecondition)
|
|
}
|
|
changed := false
|
|
if !state.Control.Paused {
|
|
state.Control.Paused = true
|
|
changed = true
|
|
}
|
|
if !state.Control.Degraded {
|
|
state.Control.Degraded = true
|
|
changed = true
|
|
}
|
|
if state.Control.Recovering {
|
|
state.Control.Recovering = false
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
|
|
default:
|
|
return false, fmt.Errorf("%w: %s", ErrUnknownTransition, name)
|
|
}
|
|
}
|
|
|
|
func joinRationale(r []string) string {
|
|
if len(r) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(r, "; ")
|
|
}
|
|
|
|
// ComputeStateHash calculates the SHA256 hash of the canonical JSON representation.
|
|
// This is exported for use by main.go during genesis state initialization.
|
|
func ComputeStateHash(state OrchestratorState) (string, error) {
|
|
payload, err := canonicalJSON(state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sum := sha256.Sum256(payload)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
// computeStateHash is the internal wrapper (for backwards compatibility)
|
|
func computeStateHash(state OrchestratorState) (string, error) {
|
|
return ComputeStateHash(state)
|
|
}
|
|
|
|
func canonicalJSON(value any) ([]byte, error) {
|
|
buf, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf, nil
|
|
}
|