From d910a566b2abdd9d553b1e87b094050cb038d058 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 14:55:44 +0100 Subject: [PATCH 1/2] feat: make delivery control programmable --- .gitattributes | 1 + .github/tests/test_repository_contract.py | 17 +- .github/workflows/ci.yml | 29 + README.md | 21 +- boatstack/cmd/boatstack-helper/main.go | 33 +- boatstack/cmd/boatstack-helper/main_test.go | 4 +- boatstack/control/control.go | 948 +++ boatstack/control/control_test.go | 355 + boatstack/control/extension.go | 120 + boatstack/control/flow_runtime.go | 103 + boatstack/control/runtime_contract_test.go | 90 + boatstack/core/system.go | 48 + boatstack/core/system_test.go | 35 + boatstack/core/transitions.json | 4584 ++++++++++++ boatstack/distribution/standard.go | 153 + boatstack/distribution/standard_test.go | 198 + boatstack/examples/control_program_test.go | 38 + boatstack/extension/inprocess.go | 28 + .../extension/releasenote/releasenote.go | 198 + .../extension/releasenote/releasenote_test.go | 66 + boatstack/extension/subprocess/subprocess.go | 214 + .../extension/subprocess/subprocess_test.go | 231 + .../testdata/reference_extension.py | 44 + .../standard}/completeness_test.go | 89 +- .../standard}/historical_test.go | 23 +- boatstack/flow/standard/standard.go | 112 + boatstack/flow/standard/standard_test.go | 48 + .../standard/supervisor_parity_test.go} | 88 +- boatstack/flow/standard/transitions.json | 6192 +++++++++++++++++ boatstack/internal/effects/artifacts.go | 2 +- .../internal/effects/command_boundary.go | 2 +- .../internal/effects/command_boundary_test.go | 12 +- boatstack/internal/effects/driver.go | 47 +- boatstack/internal/effects/extensions.go | 116 + boatstack/internal/effects/extensions_test.go | 39 + .../internal/effects/integration_test.go | 268 +- boatstack/internal/effects/receipts.go | 3 +- boatstack/internal/effects/recovery_test.go | 24 +- .../internal/effects/standard_adapter.go | 18 + .../reducer.go => effects/state_reducer.go} | 31 +- .../state_reducer_test.go} | 27 +- boatstack/internal/kernel/catalog/default.go | 345 - .../internal/kernel/catalog/default_test.go | 96 - .../internal/kernel/catalog/goal_contract.go | 85 + .../internal/kernel/catalog/predicates.go | 281 - .../internal/kernel/catalog/transition.go | 329 +- boatstack/internal/kernel/durable/state.go | 4 + boatstack/internal/kernel/engine/engine.go | 64 +- .../internal/kernel/engine/engine_test.go | 122 +- boatstack/internal/kernel/model/facet.go | 27 +- boatstack/internal/kernel/model/state.go | 111 +- boatstack/internal/kernel/model/state_test.go | 1 + boatstack/internal/kernel/ports/ports.go | 7 +- .../internal/kernel/protocol/admission.go | 37 +- boatstack/internal/kernel/protocol/config.go | 69 +- .../internal/kernel/protocol/config_test.go | 62 +- .../kernel/protocol/parameters_test.go | 6 +- .../internal/kernel/protocol/policy_test.go | 7 +- boatstack/internal/kernel/protocol/receipt.go | 5 +- .../internal/kernel/supervisor/classify.go | 21 +- boatstack/internal/kernel/supervisor/guard.go | 14 +- .../internal/kernel/supervisor/guard_test.go | 60 + .../internal/kernel/supervisor/supervisor.go | 77 +- boatstack/internal/plant/observer.go | 41 +- boatstack/internal/plant/observer_test.go | 14 +- .../surfaces/artifacts_external_test.go | 50 + boatstack/internal/surfaces/catalog_render.go | 35 +- boatstack/internal/surfaces/locus_render.go | 10 +- boatstack/internal/surfaces/protocol.go | 27 +- boatstack/internal/surfaces/render_test.go | 50 +- boatstack/internal/testprogram/standard.go | 26 + boatstack/{v2_kernel.go => kernel.go} | 110 +- boatstack/kernel_test.go | 38 + boatstack/program_effects.go | 160 + boatstack/program_observer.go | 208 + boatstack/program_observer_test.go | 79 + boatstack/references/config-schema.md | 12 +- boatstack/sdk/sdk.go | 133 +- boatstack/sdk/sdk_test.go | 74 + docs/architecture/boatstack-standard-flow.mmd | 42 + .../boatstack-v2-closure-report.md | 4 + docs/architecture/boatstack-v2-kernel.md | 301 +- .../boatstack-v2-locus-liveness.json | 724 +- .../boatstack-v2-locus-safety.json | 724 +- .../boatstack-v2-transition-catalog.md | 135 +- .../boatstack-v2-transition-catalog.mmd | 107 +- docs/configuration.md | 33 +- docs/generated-files.md | 7 +- docs/public-claims.json | 26 +- project.example.json | 3 +- ...2026-08-11-programmable-control-program.md | 3 + 91 files changed, 18335 insertions(+), 1370 deletions(-) create mode 100644 boatstack/control/control.go create mode 100644 boatstack/control/control_test.go create mode 100644 boatstack/control/extension.go create mode 100644 boatstack/control/flow_runtime.go create mode 100644 boatstack/control/runtime_contract_test.go create mode 100644 boatstack/core/system.go create mode 100644 boatstack/core/system_test.go create mode 100644 boatstack/core/transitions.json create mode 100644 boatstack/distribution/standard.go create mode 100644 boatstack/distribution/standard_test.go create mode 100644 boatstack/examples/control_program_test.go create mode 100644 boatstack/extension/inprocess.go create mode 100644 boatstack/extension/releasenote/releasenote.go create mode 100644 boatstack/extension/releasenote/releasenote_test.go create mode 100644 boatstack/extension/subprocess/subprocess.go create mode 100644 boatstack/extension/subprocess/subprocess_test.go create mode 100755 boatstack/extension/subprocess/testdata/reference_extension.py rename boatstack/{internal/kernel/catalog => flow/standard}/completeness_test.go (66%) rename boatstack/{internal/kernel/catalog => flow/standard}/historical_test.go (93%) create mode 100644 boatstack/flow/standard/standard.go create mode 100644 boatstack/flow/standard/standard_test.go rename boatstack/{internal/kernel/supervisor/supervisor_test.go => flow/standard/supervisor_parity_test.go} (78%) create mode 100644 boatstack/flow/standard/transitions.json create mode 100644 boatstack/internal/effects/extensions.go create mode 100644 boatstack/internal/effects/extensions_test.go create mode 100644 boatstack/internal/effects/standard_adapter.go rename boatstack/internal/{kernel/reducer/reducer.go => effects/state_reducer.go} (93%) rename boatstack/internal/{kernel/reducer/reducer_test.go => effects/state_reducer_test.go} (85%) delete mode 100644 boatstack/internal/kernel/catalog/default.go delete mode 100644 boatstack/internal/kernel/catalog/default_test.go create mode 100644 boatstack/internal/kernel/catalog/goal_contract.go delete mode 100644 boatstack/internal/kernel/catalog/predicates.go create mode 100644 boatstack/internal/kernel/supervisor/guard_test.go create mode 100644 boatstack/internal/surfaces/artifacts_external_test.go create mode 100644 boatstack/internal/testprogram/standard.go rename boatstack/{v2_kernel.go => kernel.go} (55%) create mode 100644 boatstack/kernel_test.go create mode 100644 boatstack/program_effects.go create mode 100644 boatstack/program_observer.go create mode 100644 boatstack/program_observer_test.go create mode 100644 docs/architecture/boatstack-standard-flow.mmd create mode 100644 release-notes/2026-08-11-programmable-control-program.md diff --git a/.gitattributes b/.gitattributes index 16244fc..bb66a06 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ docs/architecture/boatstack-v2-*.md text eol=lf docs/architecture/boatstack-v2-*.mmd text eol=lf docs/architecture/boatstack-v2-*.json text eol=lf +docs/architecture/boatstack-standard-flow.mmd text eol=lf diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 4815923..6adfc2d 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -309,19 +309,23 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> "docs/architecture/boatstack-v2-*.md text eol=lf", "docs/architecture/boatstack-v2-*.mmd text eol=lf", "docs/architecture/boatstack-v2-*.json text eol=lf", + "docs/architecture/boatstack-standard-flow.mmd text eol=lf", ): self.assertIn(pattern, attributes) response = json.loads(self.run_helper("catalog").stdout) transitions = response["catalog"] - self.assertEqual(len(transitions), 61) - self.assertEqual(len({item["id"] for item in transitions}), 61) + self.assertEqual(len(transitions), 62) + self.assertEqual(len({item["id"] for item in transitions}), 62) self.assertEqual( {item["class"] for item in transitions}, {"authority", "owned-local", "owned-external", "recovery", "observed-external"}, ) markdown = self.run_helper("catalog", "--format", "markdown").stdout mermaid = self.run_helper("catalog", "--format", "mermaid").stdout + standard_flow = self.run_helper( + "catalog", "--format", "standard-flow-mermaid" + ).stdout locus_safety = self.run_helper( "catalog", "--format", "locus-safety" ).stdout @@ -336,6 +340,11 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> mermaid, (REPO / "docs" / "architecture" / "boatstack-v2-transition-catalog.mmd").read_text(), ) + self.assertEqual( + standard_flow, + (REPO / "docs" / "architecture" / "boatstack-standard-flow.mmd").read_text(), + ) + self.assertEqual(standard_flow.count("
"), 30) for name, rendered in ( ("boatstack-v2-locus-safety.json", locus_safety), ("boatstack-v2-locus-liveness.json", locus_liveness), @@ -343,7 +352,7 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> checked = (REPO / "docs" / "architecture" / name).read_text() self.assertEqual(rendered, checked) model = json.loads(checked) - self.assertEqual(len(model["events"]), 61) + self.assertEqual(len(model["events"]), 62) self.assertEqual( {event["id"] for event in model["events"]}, {item["id"] for item in transitions}, @@ -413,7 +422,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) self.assertTrue(doctor["doctor"]["healthy"]) - self.assertEqual(doctor["doctor"]["transition_count"], 61) + self.assertEqual(doctor["doctor"]["transition_count"], 62) self.assertEqual(doctor["snapshot"]["runtime"]["value"], "verified") goal = ( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b439d..16c9d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,35 @@ concurrency: cancel-in-progress: true jobs: + component: + name: component-${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: kernel-mechanism + packages: ./internal/kernel/model ./internal/kernel/catalog ./internal/kernel/supervisor ./internal/kernel/engine + - name: control-program-compiler + packages: ./control ./core + - name: standard-flow + packages: ./flow/standard ./internal/kernel/protocol + - name: extension-conformance + packages: ./extension/... ./distribution + - name: surface-parity + packages: ./internal/surfaces ./sdk ./cmd/boatstack-helper + - name: plant-integration + packages: ./internal/plant ./internal/effects + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: boatstack/go.mod + cache-dependency-path: boatstack/go.sum + - name: Test ${{ matrix.name }} + working-directory: boatstack + run: go test ${{ matrix.packages }} + # Unix runs the full suite serially (~1-2 min) and is the unsharded correctness # reference. Windows is sharded in `test-windows` (see below). test: diff --git a/README.md b/README.md index 9d4b37f..f1b8988 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # Boatstack -Boatstack V2 is one repository-delivery controller for humans and coding agents. -It observes a repository, resolves one legal transition, binds exact authority, -executes owned effects transactionally, verifies the result, and records a -receipt. +Boatstack is a programmable supervisory control runtime for software delivery, +with a first-party standard delivery flow. It compiles one CoreSystem, one +explicit primary flow, and optional conservative extensions into an immutable +ControlProgram before it observes a repository, resolves one legal transition, +binds exact authority, executes owned effects, verifies the result, and records +a receipt. Cursor, Codex, Claude Code, Gemini CLI, MCP, the CLI, and the Go SDK use the same versioned protocol. They do not keep separate workflow state machines. @@ -70,7 +72,7 @@ boatstack apply --repo . --transition --format json ``` - `status`, `next`, `doctor`, `catalog`, and `events` are read-only. -- `apply` and `recover` request stable transition IDs from the 61-event +- `apply` and `recover` request stable transition IDs from the 62-event executable catalog. - Friendly aliases such as `plan-create`, `plan-approve`, `workspace-cut`, `record-test`, and `publish-pr` map to those IDs. @@ -85,10 +87,17 @@ boatstack apply --repo . --transition --format json The generated [transition catalog](docs/architecture/boatstack-v2-transition-catalog.md) and [Mermaid inventory](docs/architecture/boatstack-v2-transition-catalog.mmd) -come directly from the runtime registry. +come directly from the runtime registry. The generated +[StandardFlow graph](docs/architecture/boatstack-standard-flow.mmd) filters the +same compiled registry by primary-flow origin; it is not a second graph. The [replacement closure report](docs/architecture/boatstack-v2-closure-report.md) records the deleted V1 authority and its V2 evidence. +The Go SDK keeps the standard distribution ergonomic with `sdk.New(...)`. +Custom applications use `sdk.NewKernel(..., sdk.WithFlow(flow), +sdk.WithExtension(extension))`; the lower-level constructor requires an +explicit trusted in-process primary flow and never inserts StandardFlow. + ## Coding-agent skills Boatstack exposes exactly three operation skills on every supported interactive diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index c0bccd7..9bb05e2 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -18,6 +18,7 @@ import ( boatstack "github.com/operatorstack/boatstack/boatstack" "github.com/operatorstack/boatstack/boatstack/analysis" + "github.com/operatorstack/boatstack/boatstack/distribution" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" @@ -84,7 +85,7 @@ func run(arguments []string) error { if err != nil { return err } - kernel, err := boatstack.NewV2Kernel("") + kernel, err := standardKernel(context.Background(), request) if err != nil { return err } @@ -116,7 +117,7 @@ func runRPC() error { if err := decoder.Decode(&trailing); err != io.EOF { return fmt.Errorf("V2 RPC request contains trailing JSON") } - kernel, err := boatstack.NewV2Kernel("") + kernel, err := standardKernel(context.Background(), request) if err != nil { return err } @@ -220,7 +221,7 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.Var(&options.parameters, "param", "transition parameter name=value (repeatable)") flags.Var(&options.authorityReceipts, "authority-receipt", "authority receipt JSON path (repeatable)") flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)") - flags.StringVar(&options.host, "host", options.host, "cli, cursor, codex, claude, gemini, or mcp") + flags.StringVar(&options.host, "host", options.host, "cli, sdk, cursor, codex, claude, gemini, or mcp") flags.StringVar(&options.command, "command", "", "raw command to classify at the guard boundary") if err := flags.Parse(arguments); err != nil { return commandOptions{}, err @@ -245,7 +246,22 @@ func parseOptions(command string, arguments []string, transition catalog.Transit return options, nil } -func followEvents(kernel boatstack.V2Kernel, request surfaces.Request) error { +func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.Kernel, error) { + programRequest := distribution.RepositoryProgramRequest{ + Repository: request.Repository, Host: request.Host, CorrelationID: request.CorrelationID, + } + if request.TransitionID == "installation.initialize" || request.TransitionID == "configuration.initialize" { + programRequest.ConfigurationPath, _ = request.Parameters.Get("config_path") + programRequest.ConfigurationFingerprint, _ = request.Parameters.Get("config_sha256") + } + program, err := distribution.StandardProgramForRepository(ctx, programRequest) + if err != nil { + return boatstack.Kernel{}, err + } + return boatstack.NewKernel("", program) +} + +func followEvents(kernel boatstack.Kernel, request surfaces.Request) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() encoder := json.NewEncoder(os.Stdout) @@ -480,6 +496,9 @@ func renderResponse(response surfaces.Response, format string) error { case "mermaid": fmt.Print(surfaces.RenderCatalogMermaid(response.Catalog)) return nil + case "standard-flow-mermaid": + fmt.Print(surfaces.RenderStandardFlowMermaid(response.Catalog)) + return nil case "locus-safety": value, err := surfaces.RenderCatalogLocusSafety(response.Catalog) if err != nil { @@ -518,7 +537,11 @@ func renderResponse(response surfaces.Response, format string) error { return nil } if response.Doctor != nil { - fmt.Printf("healthy=%t transitions=%d snapshot=%s\n%s\n", response.Doctor.Healthy, response.Doctor.TransitionCount, response.Doctor.Snapshot, response.Doctor.Detail) + fmt.Printf("healthy=%t kernel=%s core=%s@%s flow=%s@%s core_transitions=%d flow_transitions=%d extension_transitions=%d transitions=%d program=%s drift=%t snapshot=%s\n%s\n", + response.Doctor.Healthy, response.Doctor.KernelVersion, response.Doctor.CoreSystemID, response.Doctor.CoreSystemVersion, + response.Doctor.PrimaryFlowID, response.Doctor.PrimaryFlowVersion, response.Doctor.CoreTransitionCount, + response.Doctor.FlowTransitionCount, response.Doctor.ExtensionTransitionCount, response.Doctor.TransitionCount, + response.Doctor.ProgramFingerprint, response.Doctor.UnresolvedProgramDrift, response.Doctor.Snapshot, response.Doctor.Detail) return nil } if response.Decision != nil { diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 6f24aae..f5107da 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -6,13 +6,13 @@ import ( "testing" boatstack "github.com/operatorstack/boatstack/boatstack" - "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func TestEveryFriendlyMutationAliasMapsToOneRegistryTransition(t *testing.T) { // control-law: cli-verbs-are-adapters-not-transition-authority - registry := catalog.Default() + registry := testprogram.StandardRegistry() commands := []string{"init", "update", "attach", "detach", "hydrate-runtime", "configure", "goal-configure", "plan-create", "plan-validate", "plan-approve", "plan-activate", "plan-amend", "workspace-cut", "workspace-sync", "workspace-cleanup", "workspace-reap", "record-build", "record-test", "record-review", "record-change", "record-journey", "publication-preview", "publish-pr", "observe-pr", "correct-pr", "abandon"} for _, command := range commands { operation, transitionID, _, err := classifyCommand(command) diff --git a/boatstack/control/control.go b/boatstack/control/control.go new file mode 100644 index 0000000..6000f27 --- /dev/null +++ b/boatstack/control/control.go @@ -0,0 +1,948 @@ +// Package control defines the stable authoring and compilation contracts for +// Boatstack control programs. It contains no default flow or product surface. +package control + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +type Transition = catalog.Transition +type TransitionID = catalog.TransitionID +type EventClass = catalog.EventClass +type AuthorityClass = catalog.AuthorityClass +type FacetCondition = catalog.FacetCondition +type SelectionClass = catalog.SelectionClass +type GoalContract = catalog.GoalContract +type EffectID = catalog.EffectID +type Prescription = catalog.Prescription +type ParameterSpec = catalog.ParameterSpec +type InterruptionContract = catalog.InterruptionContract +type Reversibility = catalog.Reversibility +type GoalKind = model.GoalKind +type ProtocolPhase = model.ProtocolPhase +type FactStatus = model.FactStatus +type FacetName = model.FacetName + +const ( + EventOwnedLocal = catalog.EventOwnedLocal + EventOwnedExternal = catalog.EventOwnedExternal + EventAuthority = catalog.EventAuthority + EventObservedExternal = catalog.EventObservedExternal + EventRecovery = catalog.EventRecovery + + AuthorityNone = catalog.AuthorityNone + AuthorityRepository = catalog.AuthorityRepository + AuthorityHuman = catalog.AuthorityHuman + AuthorityAutonomy = catalog.AuthorityAutonomy + AuthorityProvider = catalog.AuthorityProvider + + SelectionSystemRecovery = catalog.SelectionSystemRecovery + SelectionFlowRecovery = catalog.SelectionFlowRecovery + SelectionExtensionRecovery = catalog.SelectionExtensionRecovery + SelectionGoalRequired = catalog.SelectionGoalRequired + SelectionFlowProgress = catalog.SelectionFlowProgress + SelectionExplicitOnly = catalog.SelectionExplicitOnly + SelectionObservedExternal = catalog.SelectionObservedExternal + + GoalApprovedPlan = model.GoalApprovedPlan + GoalVerified = model.GoalVerified + GoalOpenPR = model.GoalOpenPR + GoalMerged = model.GoalMerged + GoalAbandoned = model.GoalAbandoned + + PhaseDormant = model.PhaseDormant + PhaseObserved = model.PhaseObserved + PhaseActive = model.PhaseActive + PhaseRecovery = model.PhaseRecovery + PhaseFrontier = model.PhaseFrontier + PhaseTerminal = model.PhaseTerminal + PhaseUnresolved = model.PhaseUnresolved + PhaseAbandoned = model.PhaseAbandoned + + FactKnown = model.FactKnown + FactAbsent = model.FactAbsent + FactUnknown = model.FactUnknown + FactStale = model.FactStale + FactConflicting = model.FactConflicting + + Reversible = catalog.Reversible + Compensatable = catalog.Compensatable + Irreversible = catalog.Irreversible + ObservationOnly = catalog.ObservationOnly + + FacetPhase = model.FacetPhase + FacetProgram = model.FacetProgram + FacetTopology = model.FacetTopology + FacetEngagement = model.FacetEngagement + FacetDelivery = model.FacetDelivery + FacetWorkspace = model.FacetWorkspace + FacetPlan = model.FacetPlan + FacetConfiguration = model.FacetConfiguration + FacetConfigurationPolicy = model.FacetConfigurationPolicy + FacetRuntime = model.FacetRuntime + FacetPublication = model.FacetPublication + FacetVerification = model.FacetVerification + FacetRecovery = model.FacetRecovery + FacetTransaction = model.FacetTransaction + FacetRecoveryInfo = model.FacetRecoveryInfo + FacetTransactionInfo = model.FacetTransactionInfo + FacetTerminal = model.FacetTerminal + FacetGoal = model.FacetGoal +) + +const ProgramSchemaVersion = 1 + +func KnownCondition(facet FacetName, values ...string) FacetCondition { + return FacetCondition{Facet: facet, Statuses: []FactStatus{FactKnown}, Values: append([]string(nil), values...)} +} + +func StatusCondition(facet FacetName, statuses ...FactStatus) FacetCondition { + return FacetCondition{Facet: facet, Statuses: append([]FactStatus(nil), statuses...)} +} + +// CoreSystemDefinition supplies Boatstack's operational capabilities. Trust is +// assigned by the application calling Compile, not by the implementation. +type CoreSystemDefinition interface { + CoreManifest(context.Context) (CoreSystemManifest, error) +} + +// FlowDefinition supplies exactly one trusted in-process primary delivery law. +type FlowDefinition interface { + FlowManifest(context.Context) (PrimaryFlowManifest, error) +} + +// Extension supplies an additive manifest. Runtime behavior is optional for a +// declaration-only extension and is assigned by the assembling application. +type Extension interface { + ExtensionManifest(context.Context) (ExtensionManifest, error) +} + +type CoreSystemManifest struct { + ID string `json:"id"` + Version string `json:"version"` + Transitions []Transition `json:"transitions"` +} + +type PrimaryFlowManifest struct { + ID string `json:"id"` + Version string `json:"version"` + ProtocolVersion int `json:"protocol_version"` + RuntimeMode FlowRuntimeMode `json:"runtime_mode"` + SupportedGoals []GoalKind `json:"supported_goals"` + GoalContracts []GoalContract `json:"goal_contracts"` + Transitions []Transition `json:"transitions"` + Facts []string `json:"facts,omitempty"` + OwnedResources []string `json:"owned_resources"` + Effects []string `json:"effects"` + Verifiers []string `json:"verifiers"` + RecoveryTransitions []TransitionID `json:"recovery_transitions"` + Settings json.RawMessage `json:"settings,omitempty"` + ConfigurationSchema json.RawMessage `json:"configuration_schema,omitempty"` + PrivacyClassification string `json:"privacy_classification"` + TelemetryClassification string `json:"telemetry_classification"` +} + +type GoalConstraint struct { + GoalKind GoalKind `json:"goal_kind"` + Conditions []FacetCondition `json:"conditions"` +} + +type ExtensionManifest struct { + ID string `json:"id"` + Version string `json:"version"` + ProtocolVersion int `json:"protocol_version"` + ExecutableSHA256 string `json:"executable_sha256,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + SettingsSchema json.RawMessage `json:"settings_schema"` + Facts []string `json:"facts,omitempty"` + Transitions []Transition `json:"transitions,omitempty"` + GoalConstraints []GoalConstraint `json:"goal_constraints,omitempty"` + OwnedResources []string `json:"owned_resources,omitempty"` + Effects []string `json:"effects,omitempty"` + Verifiers []string `json:"verifiers,omitempty"` + RecoveryTransitions []TransitionID `json:"recovery_transitions,omitempty"` + PrivacyClassification string `json:"privacy_classification"` + TelemetryClassification string `json:"telemetry_classification"` + Dependencies []string `json:"dependencies,omitempty"` +} + +type ComponentIdentity struct { + ID string `json:"id"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint"` +} + +type ProgramSummary struct { + SchemaVersion int `json:"schema_version"` + KernelVersion string `json:"kernel_version"` + Core ComponentIdentity `json:"core"` + Flow ComponentIdentity `json:"flow"` + Extensions []ComponentIdentity `json:"extensions,omitempty"` + CoreTransitionCount int `json:"core_transition_count"` + FlowTransitionCount int `json:"flow_transition_count"` + ExtensionTransitionCount int `json:"extension_transition_count"` + TotalTransitionCount int `json:"total_transition_count"` + ProgramFingerprint string `json:"program_fingerprint"` +} + +// ControlProgram is immutable after Compile. Its accessors always return +// copies; the runtime registry remains the one executable graph. +type ControlProgram struct { + summary ProgramSummary + registry catalog.Registry + goalContracts catalog.GoalContracts + resourceOwnership map[string]string + settingsFingerprint string + extensions []compiledExtension + flow compiledFlow +} + +type compiledFlow struct { + manifest PrimaryFlowManifest + identity ComponentIdentity + runtime FlowRuntime +} + +type CompiledFlow struct { + Manifest PrimaryFlowManifest + Identity ComponentIdentity + Runtime FlowRuntime +} + +type compiledExtension struct { + manifest ExtensionManifest + identity ComponentIdentity + runtime ExtensionRuntime +} + +type CompiledExtension struct { + Manifest ExtensionManifest + Identity ComponentIdentity + Runtime ExtensionRuntime +} + +func (p ControlProgram) Summary() ProgramSummary { + result := p.summary + result.Extensions = append([]ComponentIdentity(nil), result.Extensions...) + return result +} + +func (p ControlProgram) Fingerprint() string { return p.summary.ProgramFingerprint } +func (p ControlProgram) TransitionCount() int { return p.registry.Len() } +func (p ControlProgram) Transitions() []Transition { return p.registry.All() } +func (p ControlProgram) ResourceOwnership() map[string]string { + result := make(map[string]string, len(p.resourceOwnership)) + for resource, owner := range p.resourceOwnership { + result[resource] = owner + } + return result +} + +func (p ControlProgram) Extensions() []CompiledExtension { + result := make([]CompiledExtension, 0, len(p.extensions)) + for _, extension := range p.extensions { + result = append(result, CompiledExtension{Manifest: cloneExtensionManifest(extension.manifest), Identity: extension.identity, Runtime: extension.runtime}) + } + sort.Slice(result, func(i, j int) bool { return result[i].Identity.ID < result[j].Identity.ID }) + return result +} + +func (p ControlProgram) ExtensionByID(id string) (CompiledExtension, bool) { + for _, extension := range p.extensions { + if extension.identity.ID == id { + return CompiledExtension{Manifest: cloneExtensionManifest(extension.manifest), Identity: extension.identity, Runtime: extension.runtime}, true + } + } + return CompiledExtension{}, false +} + +func (p ControlProgram) Flow() CompiledFlow { + return CompiledFlow{Manifest: cloneFlowManifest(p.flow.manifest), Identity: p.flow.identity, Runtime: p.flow.runtime} +} + +// RuntimeRegistry and RuntimeGoalContracts are for the Boatstack mechanism. +// External applications should use Transitions and Summary. +func (p ControlProgram) RuntimeRegistry() catalog.Registry { return p.registry } +func (p ControlProgram) RuntimeGoalContracts() catalog.GoalContracts { return p.goalContracts.Clone() } + +type CompileRequest struct { + KernelVersion string + Core CoreSystemDefinition + Flow FlowDefinition + Extensions []Extension + Settings any +} + +var componentID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$`) + +func Compile(ctx context.Context, request CompileRequest) (ControlProgram, error) { + if request.KernelVersion == "" || request.Core == nil || request.Flow == nil { + return ControlProgram{}, fmt.Errorf("control program requires kernel version, CoreSystem, and exactly one PrimaryFlow") + } + core, err := request.Core.CoreManifest(ctx) + if err != nil { + return ControlProgram{}, fmt.Errorf("load CoreSystem manifest: %w", err) + } + core = cloneCoreManifest(core) + flow, err := request.Flow.FlowManifest(ctx) + if err != nil { + return ControlProgram{}, fmt.Errorf("load PrimaryFlow manifest: %w", err) + } + flow = cloneFlowManifest(flow) + if err := validateCore(core); err != nil { + return ControlProgram{}, err + } + if err := validateFlow(flow); err != nil { + return ControlProgram{}, err + } + coreFingerprint, err := fingerprint(core) + if err != nil { + return ControlProgram{}, err + } + flowFingerprint, err := fingerprint(flow) + if err != nil { + return ControlProgram{}, err + } + settingsFingerprint, err := fingerprint(request.Settings) + if err != nil { + return ControlProgram{}, fmt.Errorf("fingerprint program settings: %w", err) + } + var flowRuntime FlowRuntime + if runtimeDefinition, ok := request.Flow.(RuntimeFlowDefinition); ok { + flowRuntime = runtimeDefinition.FlowRuntime() + } + if flow.RuntimeMode == FlowRuntimeProtocol && flowRuntime == nil { + return ControlProgram{}, fmt.Errorf("PrimaryFlow %q selects protocol runtime without a FlowRuntime", flow.ID) + } + + transitions := make([]Transition, 0, len(core.Transitions)+len(flow.Transitions)) + resources := map[string]string{} + appendComponent := func(items []Transition, origin catalog.TransitionOrigin) error { + for _, item := range items { + item = cloneTransition(item) + item.Origin = origin + item.Owner = origin.ID + if item.Controllable() && !item.Policy.ReconcilesProgram && !hasFacet(item.SourceConditions, model.FacetProgram) { + item.SourceConditions = append(item.SourceConditions, KnownCondition(model.FacetProgram, string(model.ProgramUnbound), string(model.ProgramCurrent))) + } + for _, resource := range item.OwnedResources { + if prior, exists := resources[resource]; exists && prior != origin.ID { + return fmt.Errorf("resource %q has overlapping owners %q and %q", resource, prior, origin.ID) + } + resources[resource] = origin.ID + } + transitions = append(transitions, item) + } + return nil + } + if err := appendComponent(core.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginCoreSystem, ID: core.ID, Version: core.Version, ManifestFingerprint: coreFingerprint}); err != nil { + return ControlProgram{}, err + } + if err := appendComponent(flow.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginPrimaryFlow, ID: flow.ID, Version: flow.Version, ManifestFingerprint: flowFingerprint}); err != nil { + return ControlProgram{}, err + } + + extensionConditions := map[model.GoalKind][]catalog.FacetCondition{} + compiledExtensions := make([]compiledExtension, 0, len(request.Extensions)) + extensionIdentities := make([]ComponentIdentity, 0, len(request.Extensions)) + extensionCount := 0 + seenExtensions := map[string]bool{} + reservedComponents := map[string]bool{core.ID: true, flow.ID: true} + claimedFacts := map[string]string{} + for _, fact := range flow.Facts { + claimedFacts[fact] = flow.ID + } + for _, definition := range request.Extensions { + if definition == nil { + return ControlProgram{}, fmt.Errorf("nil extension definition") + } + manifest, manifestErr := definition.ExtensionManifest(ctx) + if manifestErr != nil { + return ControlProgram{}, fmt.Errorf("load extension manifest: %w", manifestErr) + } + manifest = cloneExtensionManifest(manifest) + if err := validateExtension(manifest, seenExtensions, reservedComponents); err != nil { + return ControlProgram{}, err + } + seenExtensions[manifest.ID] = true + for _, fact := range manifest.Facts { + if prior := claimedFacts[fact]; prior != "" { + return ControlProgram{}, fmt.Errorf("fact %q is declared by both %q and %q", fact, prior, manifest.ID) + } + claimedFacts[fact] = manifest.ID + } + manifestFingerprint, manifestErr := fingerprint(manifest) + if manifestErr != nil { + return ControlProgram{}, manifestErr + } + identity := ComponentIdentity{ID: manifest.ID, Version: manifest.Version, Fingerprint: manifestFingerprint} + extensionIdentities = append(extensionIdentities, identity) + var runtime ExtensionRuntime + if runtimeDefinition, ok := definition.(RuntimeExtension); ok { + runtime = runtimeDefinition.Runtime() + } + if (len(manifest.Facts) != 0 || len(manifest.Transitions) != 0) && runtime == nil { + return ControlProgram{}, fmt.Errorf("extension %q declares runtime behavior without an ExtensionRuntime", manifest.ID) + } + compiledExtensions = append(compiledExtensions, compiledExtension{manifest: manifest, identity: identity, runtime: runtime}) + for _, constraint := range manifest.GoalConstraints { + extensionConditions[constraint.GoalKind] = append(extensionConditions[constraint.GoalKind], constraint.Conditions...) + } + for _, resource := range manifest.OwnedResources { + if !strings.HasPrefix(resource, manifest.ID+".") { + return ControlProgram{}, fmt.Errorf("extension %q resource %q is not namespaced", manifest.ID, resource) + } + if prior, exists := resources[resource]; exists && prior != manifest.ID { + return ControlProgram{}, fmt.Errorf("resource %q has overlapping owners %q and %q", resource, prior, manifest.ID) + } + resources[resource] = manifest.ID + } + for index := range manifest.Transitions { + item := cloneTransition(manifest.Transitions[index]) + if item.Controllable() && !item.Policy.ReconcilesProgram && !hasFacet(item.SourceConditions, model.FacetProgram) { + item.SourceConditions = append(item.SourceConditions, KnownCondition(model.FacetProgram, string(model.ProgramUnbound), string(model.ProgramCurrent))) + } + if !strings.HasPrefix(string(item.ID), manifest.ID+".") { + return ControlProgram{}, fmt.Errorf("extension %q transition %q is not namespaced", manifest.ID, item.ID) + } + if item.Priority != 0 { + return ControlProgram{}, fmt.Errorf("extension %q transition %q cannot declare raw priority", manifest.ID, item.ID) + } + if item.SelectionClass == "" { + if item.Class == catalog.EventRecovery { + item.SelectionClass = catalog.SelectionExtensionRecovery + } else { + item.SelectionClass = catalog.SelectionExplicitOnly + } + } + if item.SelectionClass != catalog.SelectionGoalRequired && item.SelectionClass != catalog.SelectionExtensionRecovery && + item.SelectionClass != catalog.SelectionExplicitOnly && item.SelectionClass != catalog.SelectionObservedExternal { + return ControlProgram{}, fmt.Errorf("extension %q transition %q uses forbidden selection class %q", manifest.ID, item.ID, item.SelectionClass) + } + if item.Class == catalog.EventRecovery && (item.SelectionClass != catalog.SelectionExtensionRecovery || !containsTransition(manifest.RecoveryTransitions, item.ID)) { + return ControlProgram{}, fmt.Errorf("extension recovery %q requires EXTENSION_RECOVERY selection and an explicit recovery declaration", item.ID) + } + if item.SelectionClass == catalog.SelectionExtensionRecovery && item.Class != catalog.EventRecovery { + return ControlProgram{}, fmt.Errorf("extension transition %q cannot use EXTENSION_RECOVERY selection outside a recovery event", item.ID) + } + item.Priority = 1 + item.Origin = catalog.TransitionOrigin{Kind: catalog.OriginExtension, ID: manifest.ID, Version: manifest.Version, ManifestFingerprint: manifestFingerprint} + item.Owner = manifest.ID + if !containsString(manifest.Effects, string(item.Effect)) { + return ControlProgram{}, fmt.Errorf("extension transition %q uses undeclared effect %q", item.ID, item.Effect) + } + if !containsString(manifest.Verifiers, item.Verifier) { + return ControlProgram{}, fmt.Errorf("extension transition %q uses undeclared verifier %q", item.ID, item.Verifier) + } + if item.Interruption.Recovery != "" && item.Interruption.Recovery != "recovery.escalate" && !containsTransition(manifest.RecoveryTransitions, item.Interruption.Recovery) { + return ControlProgram{}, fmt.Errorf("extension transition %q uses undeclared recovery %q", item.ID, item.Interruption.Recovery) + } + for _, resource := range item.OwnedResources { + if owner := resources[resource]; owner != manifest.ID { + return ControlProgram{}, fmt.Errorf("extension transition %q writes undeclared resource %q", item.ID, resource) + } + } + transitions = append(transitions, item) + extensionCount++ + } + } + if err := validateDependencies(compiledExtensions); err != nil { + return ControlProgram{}, err + } + for goal, conditions := range extensionConditions { + sort.SliceStable(conditions, func(i, j int) bool { + left, _ := json.Marshal(conditions[i]) + right, _ := json.Marshal(conditions[j]) + return string(left) < string(right) + }) + extensionConditions[goal] = conditions + } + registry, err := catalog.New(transitions) + if err != nil { + return ControlProgram{}, fmt.Errorf("compile transition registry: %w", err) + } + contracts, err := catalog.NewGoalContracts(flow.GoalContracts, extensionConditions) + if err != nil { + return ControlProgram{}, fmt.Errorf("compile goal contracts: %w", err) + } + sort.Slice(extensionIdentities, func(i, j int) bool { return extensionIdentities[i].ID < extensionIdentities[j].ID }) + programIdentity := struct { + SchemaVersion int + KernelVersion string + Core ComponentIdentity + Flow ComponentIdentity + Extensions []ComponentIdentity + SettingsFingerprint string + Transitions []Transition + GoalContracts []catalog.GoalContract + Resources map[string]string + }{ + ProgramSchemaVersion, request.KernelVersion, + ComponentIdentity{ID: core.ID, Version: core.Version, Fingerprint: coreFingerprint}, + ComponentIdentity{ID: flow.ID, Version: flow.Version, Fingerprint: flowFingerprint}, + extensionIdentities, settingsFingerprint, registry.All(), contracts.All(), resources, + } + programFingerprint, err := fingerprint(programIdentity) + if err != nil { + return ControlProgram{}, err + } + summary := ProgramSummary{ + SchemaVersion: ProgramSchemaVersion, KernelVersion: request.KernelVersion, + Core: programIdentity.Core, Flow: programIdentity.Flow, Extensions: extensionIdentities, + CoreTransitionCount: len(core.Transitions), FlowTransitionCount: len(flow.Transitions), + ExtensionTransitionCount: extensionCount, TotalTransitionCount: registry.Len(), ProgramFingerprint: programFingerprint, + } + return ControlProgram{ + summary: summary, registry: registry, goalContracts: contracts, resourceOwnership: resources, + settingsFingerprint: settingsFingerprint, extensions: compiledExtensions, + flow: compiledFlow{manifest: cloneFlowManifest(flow), identity: programIdentity.Flow, runtime: flowRuntime}, + }, nil +} + +func validateCore(manifest CoreSystemManifest) error { + if !componentID.MatchString(manifest.ID) || manifest.Version == "" || len(manifest.Transitions) == 0 { + return fmt.Errorf("CoreSystem requires semantic id, version, and transitions") + } + return nil +} + +func validateFlow(manifest PrimaryFlowManifest) error { + if !componentID.MatchString(manifest.ID) || manifest.Version == "" || manifest.ProtocolVersion != FlowProtocolVersion || + (manifest.RuntimeMode != FlowRuntimeNative && manifest.RuntimeMode != FlowRuntimeProtocol) || + len(manifest.Transitions) == 0 || len(manifest.SupportedGoals) == 0 || + !validJSONObject(manifest.ConfigurationSchema) || + manifest.PrivacyClassification == "" || manifest.TelemetryClassification == "" { + return fmt.Errorf("PrimaryFlow requires semantic id, version, configuration schema, goals, and transitions") + } + supported := map[GoalKind]bool{} + for _, goal := range manifest.SupportedGoals { + if !goal.Valid() || supported[goal] { + return fmt.Errorf("PrimaryFlow has invalid or duplicate goal %q", goal) + } + supported[goal] = true + } + for _, contract := range manifest.GoalContracts { + if !supported[contract.GoalKind] { + return fmt.Errorf("PrimaryFlow goal contract %q is not supported", contract.GoalKind) + } + delete(supported, contract.GoalKind) + } + if len(supported) != 0 { + return fmt.Errorf("PrimaryFlow does not define every supported goal contract") + } + for _, values := range [][]string{manifest.Facts, manifest.OwnedResources, manifest.Effects, manifest.Verifiers} { + if duplicate := duplicateString(values); duplicate != "" { + return fmt.Errorf("PrimaryFlow %q duplicates declaration %q", manifest.ID, duplicate) + } + } + if manifest.RuntimeMode == FlowRuntimeProtocol { + for _, value := range append(append(append([]string(nil), manifest.Facts...), manifest.OwnedResources...), append(manifest.Effects, manifest.Verifiers...)...) { + if !strings.HasPrefix(value, manifest.ID+".") { + return fmt.Errorf("protocol PrimaryFlow %q declaration %q is not namespaced", manifest.ID, value) + } + } + } + resources := stringSet(manifest.OwnedResources) + facts := stringSet(manifest.Facts) + effects := stringSet(manifest.Effects) + verifiers := stringSet(manifest.Verifiers) + recoveryDeclarations := transitionSet(manifest.RecoveryTransitions) + if len(recoveryDeclarations) != len(manifest.RecoveryTransitions) { + return fmt.Errorf("PrimaryFlow %q duplicates a recovery declaration", manifest.ID) + } + transitions := make(map[TransitionID]Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + transitions[transition.ID] = transition + if manifest.RuntimeMode == FlowRuntimeProtocol && !strings.HasPrefix(string(transition.ID), manifest.ID+".") { + return fmt.Errorf("protocol PrimaryFlow %q transition %q is not namespaced", manifest.ID, transition.ID) + } + if transition.Controllable() { + if !effects[string(transition.Effect)] || !verifiers[transition.Verifier] { + return fmt.Errorf("PrimaryFlow transition %q uses an undeclared effect or verifier", transition.ID) + } + for _, resource := range transition.OwnedResources { + if !resources[resource] { + return fmt.Errorf("PrimaryFlow transition %q writes undeclared resource %q", transition.ID, resource) + } + if manifest.RuntimeMode == FlowRuntimeProtocol && !strings.HasPrefix(resource, manifest.ID+".") { + return fmt.Errorf("protocol PrimaryFlow %q resource %q is not namespaced", manifest.ID, resource) + } + } + if manifest.RuntimeMode == FlowRuntimeProtocol { + for _, condition := range transition.TargetConditions { + if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") { + return fmt.Errorf("protocol PrimaryFlow transition %q targets non-owned fact %q", transition.ID, condition.Facet) + } + if !facts[string(condition.Facet)] { + return fmt.Errorf("protocol PrimaryFlow transition %q targets undeclared fact %q", transition.ID, condition.Facet) + } + } + } + } + } + for recovery := range recoveryDeclarations { + transition, ok := transitions[recovery] + if !ok || transition.Class != EventRecovery { + return fmt.Errorf("PrimaryFlow %q recovery %q is not a declared recovery transition", manifest.ID, recovery) + } + } + return nil +} + +func stringSet(values []string) map[string]bool { + result := make(map[string]bool, len(values)) + for _, value := range values { + if value != "" { + result[value] = true + } + } + return result +} + +func transitionSet(values []TransitionID) map[TransitionID]bool { + result := make(map[TransitionID]bool, len(values)) + for _, value := range values { + if value != "" { + result[value] = true + } + } + return result +} + +func validateExtension(manifest ExtensionManifest, seen, reserved map[string]bool) error { + if !componentID.MatchString(manifest.ID) || manifest.Version == "" || manifest.ProtocolVersion != ExtensionProtocolVersion || seen[manifest.ID] || reserved[manifest.ID] { + return fmt.Errorf("extension requires unique semantic id, version, and protocol version") + } + if manifest.PrivacyClassification == "" || manifest.TelemetryClassification == "" { + return fmt.Errorf("extension %q requires privacy and telemetry classifications", manifest.ID) + } + if !validJSONObject(manifest.SettingsSchema) { + return fmt.Errorf("extension %q requires a JSON-object settings schema", manifest.ID) + } + if manifest.ExecutableSHA256 != "" { + if len(manifest.ExecutableSHA256) != 64 { + return fmt.Errorf("extension %q executable SHA-256 is invalid", manifest.ID) + } + if _, err := hex.DecodeString(manifest.ExecutableSHA256); err != nil { + return fmt.Errorf("extension %q executable SHA-256 is invalid", manifest.ID) + } + } + for _, fact := range manifest.Facts { + if !strings.HasPrefix(fact, manifest.ID+".") { + return fmt.Errorf("extension %q fact %q is not namespaced", manifest.ID, fact) + } + } + declaredFacts := stringSet(manifest.Facts) + if duplicate := duplicateString(manifest.Facts); duplicate != "" { + return fmt.Errorf("extension %q duplicates fact %q", manifest.ID, duplicate) + } + for _, effect := range manifest.Effects { + if !strings.HasPrefix(effect, manifest.ID+".") { + return fmt.Errorf("extension %q effect %q is not namespaced", manifest.ID, effect) + } + } + if duplicate := duplicateString(manifest.Effects); duplicate != "" { + return fmt.Errorf("extension %q duplicates effect %q", manifest.ID, duplicate) + } + for _, verifier := range manifest.Verifiers { + if !strings.HasPrefix(verifier, manifest.ID+".") { + return fmt.Errorf("extension %q verifier %q is not namespaced", manifest.ID, verifier) + } + } + if duplicate := duplicateString(manifest.Verifiers); duplicate != "" { + return fmt.Errorf("extension %q duplicates verifier %q", manifest.ID, duplicate) + } + if duplicate := duplicateString(manifest.OwnedResources); duplicate != "" { + return fmt.Errorf("extension %q duplicates resource %q", manifest.ID, duplicate) + } + if duplicate := duplicateString(manifest.Dependencies); duplicate != "" { + return fmt.Errorf("extension %q duplicates dependency %q", manifest.ID, duplicate) + } + for _, dependency := range manifest.Dependencies { + if dependency == manifest.ID { + return fmt.Errorf("extension %q cannot depend on itself", manifest.ID) + } + } + constrainedFacets := map[GoalKind]map[FacetName][]FacetCondition{} + for _, constraint := range manifest.GoalConstraints { + if !constraint.GoalKind.Valid() || len(constraint.Conditions) == 0 { + return fmt.Errorf("extension %q has invalid goal constraint", manifest.ID) + } + for _, condition := range constraint.Conditions { + if !condition.Facet.Valid() || len(condition.Statuses) == 0 || condition.Facet == model.FacetTerminal { + return fmt.Errorf("extension %q has invalid or terminal-reporting goal condition", manifest.ID) + } + for _, status := range condition.Statuses { + if !status.Valid() { + return fmt.Errorf("extension %q has invalid goal-condition status %q", manifest.ID, status) + } + } + if constrainedFacets[constraint.GoalKind] == nil { + constrainedFacets[constraint.GoalKind] = map[FacetName][]FacetCondition{} + } + constrainedFacets[constraint.GoalKind][condition.Facet] = append(constrainedFacets[constraint.GoalKind][condition.Facet], condition) + } + } + declaredRecovery := map[TransitionID]bool{} + for _, recovery := range manifest.RecoveryTransitions { + if declaredRecovery[recovery] { + return fmt.Errorf("extension %q duplicates recovery %q", manifest.ID, recovery) + } + declaredRecovery[recovery] = true + } + seenTransitions := make(map[TransitionID]bool, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + seenTransitions[transition.ID] = true + for _, condition := range transition.TargetConditions { + if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") { + return fmt.Errorf("extension transition %q targets non-owned fact %q", transition.ID, condition.Facet) + } + if !declaredFacts[string(condition.Facet)] { + return fmt.Errorf("extension transition %q targets undeclared fact %q", transition.ID, condition.Facet) + } + } + if transition.SelectionClass == SelectionGoalRequired { + if len(transition.GoalKinds) == 0 { + return fmt.Errorf("extension transition %q is implicitly selectable without an explicit constrained goal", transition.ID) + } + for _, goal := range transition.GoalKinds { + discharges := false + for _, target := range transition.TargetConditions { + for _, obligation := range constrainedFacets[goal][target.Facet] { + discharges = discharges || conditionImplies(target, obligation) + } + } + if !discharges { + return fmt.Errorf("extension transition %q is implicitly selectable for goal %q without discharging an active obligation", transition.ID, goal) + } + } + } + if declaredRecovery[transition.ID] && transition.Class != EventRecovery { + return fmt.Errorf("extension recovery %q is not a recovery transition", transition.ID) + } + } + for recovery := range declaredRecovery { + if !seenTransitions[recovery] { + return fmt.Errorf("extension %q recovery %q has no declared transition", manifest.ID, recovery) + } + } + return nil +} + +func conditionImplies(target, obligation FacetCondition) bool { + if target.Facet != obligation.Facet { + return false + } + for _, status := range target.Statuses { + found := false + for _, allowed := range obligation.Statuses { + found = found || status == allowed + } + if !found { + return false + } + } + if len(obligation.Values) == 0 { + return true + } + if len(target.Values) == 0 { + return false + } + for _, value := range target.Values { + found := false + for _, allowed := range obligation.Values { + found = found || value == allowed + } + if !found { + return false + } + } + return true +} + +func duplicateString(values []string) string { + seen := map[string]bool{} + for _, value := range values { + if seen[value] { + return value + } + seen[value] = true + } + return "" +} + +func hasFacet(conditions []FacetCondition, facet FacetName) bool { + for _, condition := range conditions { + if condition.Facet == facet { + return true + } + } + return false +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func containsTransition(values []TransitionID, wanted TransitionID) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func validateDependencies(extensions []compiledExtension) error { + known := map[string]bool{} + for _, extension := range extensions { + known[extension.manifest.ID] = true + } + visiting, visited := map[string]bool{}, map[string]bool{} + byID := map[string]ExtensionManifest{} + for _, extension := range extensions { + byID[extension.manifest.ID] = extension.manifest + } + var visit func(string) error + visit = func(id string) error { + if visiting[id] { + return fmt.Errorf("extension dependency cycle includes %q", id) + } + if visited[id] { + return nil + } + visiting[id] = true + for _, dependency := range byID[id].Dependencies { + if !known[dependency] { + return fmt.Errorf("extension %q depends on unavailable extension %q", id, dependency) + } + if err := visit(dependency); err != nil { + return err + } + } + visiting[id], visited[id] = false, true + return nil + } + for id := range known { + if err := visit(id); err != nil { + return err + } + } + return nil +} + +func cloneTransition(value Transition) Transition { + value.SourcePhases = append([]model.ProtocolPhase(nil), value.SourcePhases...) + value.TargetPhases = append([]model.ProtocolPhase(nil), value.TargetPhases...) + value.GoalKinds = append([]model.GoalKind(nil), value.GoalKinds...) + value.RequiredIdentity = append([]string(nil), value.RequiredIdentity...) + value.Authority = append([]catalog.AuthorityClass(nil), value.Authority...) + value.AuthorityAll = append([]catalog.AuthorityClass(nil), value.AuthorityAll...) + value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...) + value.OwnedResources = append([]string(nil), value.OwnedResources...) + value.LocalEffects = append([]catalog.EffectID(nil), value.LocalEffects...) + value.ExternalEffects = append([]catalog.EffectID(nil), value.ExternalEffects...) + value.Parameters = append([]catalog.ParameterSpec(nil), value.Parameters...) + value.SourceConditions = cloneConditions(value.SourceConditions) + value.TargetConditions = cloneConditions(value.TargetConditions) + value.Interruption.Points = append([]string(nil), value.Interruption.Points...) + value.Interruption.PartialState = append([]string(nil), value.Interruption.PartialState...) + value.Policy.ManagedOperations = append([]string(nil), value.Policy.ManagedOperations...) + return value +} + +func cloneConditions(values []catalog.FacetCondition) []catalog.FacetCondition { + result := make([]catalog.FacetCondition, len(values)) + for index, value := range values { + value.Statuses = append([]model.FactStatus(nil), value.Statuses...) + value.Values = append([]string(nil), value.Values...) + result[index] = value + } + return result +} + +func cloneCoreManifest(value CoreSystemManifest) CoreSystemManifest { + value.Transitions = cloneTransitions(value.Transitions) + return value +} + +func cloneFlowManifest(value PrimaryFlowManifest) PrimaryFlowManifest { + value.SupportedGoals = append([]GoalKind(nil), value.SupportedGoals...) + value.GoalContracts = append([]GoalContract(nil), value.GoalContracts...) + for index := range value.GoalContracts { + value.GoalContracts[index].Conditions = cloneConditions(value.GoalContracts[index].Conditions) + } + value.Transitions = cloneTransitions(value.Transitions) + value.Facts = append([]string(nil), value.Facts...) + value.OwnedResources = append([]string(nil), value.OwnedResources...) + value.Effects = append([]string(nil), value.Effects...) + value.Verifiers = append([]string(nil), value.Verifiers...) + value.RecoveryTransitions = append([]TransitionID(nil), value.RecoveryTransitions...) + value.Settings = append(json.RawMessage(nil), value.Settings...) + value.ConfigurationSchema = append(json.RawMessage(nil), value.ConfigurationSchema...) + return value +} + +func cloneExtensionManifest(value ExtensionManifest) ExtensionManifest { + value.Settings = append(json.RawMessage(nil), value.Settings...) + value.SettingsSchema = append(json.RawMessage(nil), value.SettingsSchema...) + value.Facts = append([]string(nil), value.Facts...) + value.Transitions = cloneTransitions(value.Transitions) + value.GoalConstraints = append([]GoalConstraint(nil), value.GoalConstraints...) + for index := range value.GoalConstraints { + value.GoalConstraints[index].Conditions = cloneConditions(value.GoalConstraints[index].Conditions) + } + value.OwnedResources = append([]string(nil), value.OwnedResources...) + value.Effects = append([]string(nil), value.Effects...) + value.Verifiers = append([]string(nil), value.Verifiers...) + value.RecoveryTransitions = append([]TransitionID(nil), value.RecoveryTransitions...) + value.Dependencies = append([]string(nil), value.Dependencies...) + return value +} + +func validJSONObject(value json.RawMessage) bool { + if len(value) == 0 { + return false + } + var decoded map[string]any + decoder := json.NewDecoder(strings.NewReader(string(value))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil || decoded == nil { + return false + } + var trailing any + return decoder.Decode(&trailing) == io.EOF +} + +func cloneTransitions(values []Transition) []Transition { + result := make([]Transition, len(values)) + for index, value := range values { + result[index] = cloneTransition(value) + } + return result +} + +func fingerprint(value any) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode control identity: %w", err) + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} diff --git a/boatstack/control/control_test.go b/boatstack/control/control_test.go new file mode 100644 index 0000000..acade27 --- /dev/null +++ b/boatstack/control/control_test.go @@ -0,0 +1,355 @@ +package control_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/distribution" + "github.com/operatorstack/boatstack/boatstack/extension" + "github.com/operatorstack/boatstack/boatstack/extension/releasenote" + "github.com/operatorstack/boatstack/boatstack/flow/standard" +) + +func TestStandardProgramHasExplicitStableComposition(t *testing.T) { + // control-law: compile-produces-one-immutable-registry-with-explicit-origins + one, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + two, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + if one.Fingerprint() != two.Fingerprint() { + t.Fatalf("identical compilation drifted: %s != %s", one.Fingerprint(), two.Fingerprint()) + } + summary := one.Summary() + if summary.CoreTransitionCount != 32 || summary.FlowTransitionCount != 30 || summary.ExtensionTransitionCount != 0 || summary.TotalTransitionCount != 62 { + t.Fatalf("compiled counts = %+v", summary) + } + counts := map[string]int{} + for _, transition := range one.Transitions() { + counts[string(transition.Origin.Kind)]++ + if transition.Owner != transition.Origin.ID || len(transition.Origin.ManifestFingerprint) != 64 { + t.Fatalf("transition lost compiled ownership: %+v", transition) + } + } + if counts["core-system"] != 32 || counts["primary-flow"] != 30 || counts["extension"] != 0 { + t.Fatalf("origin counts = %#v", counts) + } +} + +func TestProgramFingerprintBindsCompositionAndPolicyInputs(t *testing.T) { + // control-law: any-control-law-input-change-changes-program-identity + base, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + reference := releasenote.Definition() + manifest, err := reference.ExtensionManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + variants := map[string]control.ExtensionManifest{} + version := cloneManifest(t, manifest) + version.Version = "1.0.1" + variants["extension-version"] = version + executable := cloneManifest(t, manifest) + executable.ExecutableSHA256 = strings.Repeat("a", 64) + variants["executable"] = executable + settings := cloneManifest(t, manifest) + settings.Settings = json.RawMessage(`{"required":true}`) + variants["extension-settings"] = settings + settingsSchema := cloneManifest(t, manifest) + settingsSchema.SettingsSchema = json.RawMessage(`{"type":"object","required":["mode"]}`) + variants["extension-settings-schema"] = settingsSchema + resource := cloneManifest(t, manifest) + resource.OwnedResources = []string{"boatstack.release-note.alternate-evidence"} + resource.Transitions[0].OwnedResources = append([]string(nil), resource.OwnedResources...) + variants["resource-ownership"] = resource + verifier := cloneManifest(t, manifest) + verifier.Verifiers = []string{"boatstack.release-note.alternate-verifier"} + verifier.Transitions[0].Verifier = verifier.Verifiers[0] + variants["verifier"] = verifier + recovery := cloneManifest(t, manifest) + recoveryTransition := manifest.Transitions[0] + recoveryTransition.ID = "boatstack.release-note.recover" + recoveryTransition.Class = control.EventRecovery + recoveryTransition.SelectionClass = control.SelectionExtensionRecovery + recoveryTransition.SourcePhases = []control.ProtocolPhase{control.PhaseRecovery} + recoveryTransition.TargetPhases = []control.ProtocolPhase{control.PhaseActive} + recoveryTransition.Effect = "boatstack.release-note.recover-effect" + recoveryTransition.LocalEffects = []control.EffectID{recoveryTransition.Effect} + recoveryTransition.Verifier = "boatstack.release-note.recover-verifier" + recoveryTransition.Interruption.Recovery = "recovery.escalate" + recoveryTransition.Priority = 0 + recovery.Transitions = append(recovery.Transitions, recoveryTransition) + recovery.Effects = append(recovery.Effects, string(recoveryTransition.Effect)) + recovery.Verifiers = append(recovery.Verifiers, recoveryTransition.Verifier) + recovery.RecoveryTransitions = []control.TransitionID{recoveryTransition.ID} + variants["recovery-declaration"] = recovery + + for name, variant := range variants { + t.Run(name, func(t *testing.T) { + definition, err := extension.NewInProcess(variant, reference.Runtime()) + if err != nil { + t.Fatal(err) + } + program, err := distribution.StandardProgram(context.Background(), definition) + if err != nil { + t.Fatal(err) + } + if program.Fingerprint() == base.Fingerprint() { + t.Fatalf("%s did not change program fingerprint", name) + } + }) + } + + policyOne, err := control.Compile(context.Background(), control.CompileRequest{KernelVersion: "kernel", Core: core.System(), Flow: standard.Definition(), Settings: map[string]any{"policy": "one"}}) + if err != nil { + t.Fatal(err) + } + policyTwo, err := control.Compile(context.Background(), control.CompileRequest{KernelVersion: "kernel", Core: core.System(), Flow: standard.Definition(), Settings: map[string]any{"policy": "two"}}) + if err != nil { + t.Fatal(err) + } + if policyOne.Fingerprint() == policyTwo.Fingerprint() { + t.Fatal("repository policy did not change program fingerprint") + } +} + +func TestComponentsMustDeclareTheirOwnSelectionSemantics(t *testing.T) { + // control-law: generic-compiler-never-infers-flow-order-from-transition-ids + flow, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + flow.Transitions[0].SelectionClass = "" + if _, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "kernel", Core: core.System(), Flow: staticFlow{manifest: flow}, + }); err == nil || !strings.Contains(err.Error(), "selection class") { + t.Fatalf("flow without an explicit selection class was accepted: %v", err) + } + + flow, err = standard.Definition().FlowManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + flow.Transitions[0].SelectionClass = control.SelectionSystemRecovery + if _, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "kernel", Core: core.System(), Flow: staticFlow{manifest: flow}, + }); err == nil || !strings.Contains(err.Error(), "SYSTEM_RECOVERY") { + t.Fatalf("flow claimed CoreSystem recovery precedence: %v", err) + } +} + +func TestPrimaryFlowCannotClaimCoreSystemResources(t *testing.T) { + // control-law: every-resource-has-exactly-one-component-owner + coreManifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + var coreResource string + for _, transition := range coreManifest.Transitions { + if len(transition.OwnedResources) != 0 { + coreResource = transition.OwnedResources[0] + break + } + } + if coreResource == "" { + t.Fatal("CoreSystem fixture declares no owned resource") + } + flow, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + flow.OwnedResources = append(flow.OwnedResources, coreResource) + flow.Transitions[0].OwnedResources = append(flow.Transitions[0].OwnedResources, coreResource) + if _, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "kernel", Core: core.System(), Flow: staticFlow{manifest: flow}, + }); err == nil || !strings.Contains(err.Error(), "overlapping owners") { + t.Fatalf("PrimaryFlow claimed CoreSystem resource %q: %v", coreResource, err) + } +} + +func TestExtensionCompilationRejectsBoundaryViolations(t *testing.T) { + // control-law: extensions-are-namespaced-additive-and-declarative + reference := releasenote.Definition() + manifest, err := reference.ExtensionManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + cases := map[string]func(*control.ExtensionManifest){ + "reserved-component-id": func(value *control.ExtensionManifest) { value.ID = standard.ID }, + "unnamespaced-fact": func(value *control.ExtensionManifest) { value.Facts[0] = "present" }, + "raw-priority": func(value *control.ExtensionManifest) { value.Transitions[0].Priority = 7 }, + "undeclared-effect": func(value *control.ExtensionManifest) { value.Effects = nil }, + "undeclared-verifier": func(value *control.ExtensionManifest) { value.Verifiers = nil }, + "phantom-recovery": func(value *control.ExtensionManifest) { + value.RecoveryTransitions = []control.TransitionID{"boatstack.release-note.missing-recovery"} + }, + "invalid-goal-status": func(value *control.ExtensionManifest) { + value.GoalConstraints[0].Conditions[0].Statuses = []control.FactStatus{"invented"} + }, + "goal-selection-without-matching-obligation": func(value *control.ExtensionManifest) { + value.Transitions[0].GoalKinds = []control.GoalKind{control.GoalVerified} + }, + "goal-selection-does-not-discharge-obligation": func(value *control.ExtensionManifest) { + value.Transitions[0].TargetConditions[0].Values = []string{"missing"} + }, + "recovery-selection-on-progress": func(value *control.ExtensionManifest) { + value.Transitions[0].SelectionClass = control.SelectionExtensionRecovery + }, + "program-reconciliation-claim": func(value *control.ExtensionManifest) { + value.Transitions[0].Policy.ReconcilesProgram = true + }, + "requested-goal-binding-claim": func(value *control.ExtensionManifest) { + value.Transitions[0].Policy.BindsRequestedGoal = true + }, + "foreign-target": func(value *control.ExtensionManifest) { + value.Transitions[0].TargetConditions = []control.FacetCondition{control.KnownCondition(control.FacetPlan, "approved")} + }, + "undeclared-owned-target": func(value *control.ExtensionManifest) { + value.Transitions[0].TargetConditions = []control.FacetCondition{control.KnownCondition(control.FacetName(value.ID+".phantom"), "verified")} + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + value := cloneManifest(t, manifest) + mutate(&value) + definition, err := extension.NewInProcess(value, reference.Runtime()) + if err != nil { + t.Fatal(err) + } + if _, err := distribution.StandardProgram(context.Background(), definition); err == nil { + t.Fatalf("%s extension violation was accepted", name) + } + }) + } + + left := declarationOnlyExtension{id: "example.left", dependencies: []string{"example.right"}} + right := declarationOnlyExtension{id: "example.right", dependencies: []string{"example.left"}} + if _, err := distribution.StandardProgram(context.Background(), left, right); err == nil || !strings.Contains(err.Error(), "cycle") { + t.Fatalf("dependency cycle error = %v", err) + } +} + +func TestControlProgramAccessorsCannotMutateCompiledBytes(t *testing.T) { + // control-law: compiled-control-program-is-immutable + program, err := distribution.StandardProgram(context.Background(), releasenote.Definition()) + if err != nil { + t.Fatal(err) + } + originalFingerprint := program.Fingerprint() + transitions := program.Transitions() + transitions[0].SourcePhases[0] = control.PhaseAbandoned + transitions[0].SourceConditions[0].Values = []string{"mutated"} + extensions := program.Extensions() + extensions[0].Manifest.Facts[0] = "mutated.fact.id" + extensions[0].Manifest.Settings = json.RawMessage(`{"mutated":true}`) + flow := program.Flow() + flow.Manifest.GoalContracts[0].Conditions[0].Values = []string{"mutated"} + + if program.Fingerprint() != originalFingerprint || program.Transitions()[0].SourcePhases[0] == control.PhaseAbandoned || + program.Extensions()[0].Manifest.Facts[0] == "mutated.fact.id" || program.Flow().Manifest.GoalContracts[0].Conditions[0].Values[0] == "mutated" { + t.Fatal("public accessor mutated the compiled ControlProgram") + } +} + +func TestExtensionGoalConditionsAreConjunctive(t *testing.T) { + // control-law: extension-terminal-set-is-a-subset-of-primary-flow-terminal-set + base, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + extended, err := distribution.StandardProgram(context.Background(), releasenote.Definition()) + if err != nil { + t.Fatal(err) + } + conditionCount := func(program control.ControlProgram) int { + for _, contract := range program.RuntimeGoalContracts().All() { + if contract.GoalKind == control.GoalVerified { + return len(contract.Conditions) + } + } + return 0 + } + if conditionCount(extended) != conditionCount(base) { + t.Fatalf("release-note extension unexpectedly changed the verified goal") + } + for _, goal := range []control.GoalKind{control.GoalOpenPR, control.GoalMerged} { + baseCount, extendedCount := 0, 0 + for _, contract := range base.RuntimeGoalContracts().All() { + if contract.GoalKind == goal { + baseCount = len(contract.Conditions) + } + } + for _, contract := range extended.RuntimeGoalContracts().All() { + if contract.GoalKind == goal { + extendedCount = len(contract.Conditions) + } + } + if extendedCount != baseCount+1 { + t.Fatalf("goal %s conditions: base=%d extended=%d", goal, baseCount, extendedCount) + } + } +} + +func TestExtensionOrderDoesNotChangeProgramIdentity(t *testing.T) { + // control-law: extension-observation-and-compilation-order-is-deterministic + left := declarationOnlyExtension{id: "example.left", goalConditions: []control.FacetCondition{control.KnownCondition(control.FacetPlan, "locked")}} + right := declarationOnlyExtension{id: "example.right", goalConditions: []control.FacetCondition{control.KnownCondition(control.FacetConfiguration, "verified")}} + one, err := distribution.StandardProgram(context.Background(), left, right) + if err != nil { + t.Fatal(err) + } + two, err := distribution.StandardProgram(context.Background(), right, left) + if err != nil { + t.Fatal(err) + } + if one.Fingerprint() != two.Fingerprint() { + t.Fatalf("extension order changed program identity: %s != %s", one.Fingerprint(), two.Fingerprint()) + } +} + +type declarationOnlyExtension struct { + id string + dependencies []string + goalConditions []control.FacetCondition +} + +type staticFlow struct{ manifest control.PrimaryFlowManifest } + +func (s staticFlow) FlowManifest(context.Context) (control.PrimaryFlowManifest, error) { + return s.manifest, nil +} + +func cloneManifest(t *testing.T, value control.ExtensionManifest) control.ExtensionManifest { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var result control.ExtensionManifest + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + return result +} + +func (e declarationOnlyExtension) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { + var constraints []control.GoalConstraint + if len(e.goalConditions) != 0 { + constraints = []control.GoalConstraint{{GoalKind: control.GoalOpenPR, Conditions: append([]control.FacetCondition(nil), e.goalConditions...)}} + } + return control.ExtensionManifest{ + ID: e.id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, + SettingsSchema: json.RawMessage(`{"type":"object"}`), + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + Dependencies: append([]string(nil), e.dependencies...), GoalConstraints: constraints, + }, nil +} diff --git a/boatstack/control/extension.go b/boatstack/control/extension.go new file mode 100644 index 0000000..cd8d9db --- /dev/null +++ b/boatstack/control/extension.go @@ -0,0 +1,120 @@ +package control + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +const ExtensionProtocolVersion = 1 + +type ExtensionOperation string + +const ( + ExtensionManifestOperation ExtensionOperation = "manifest" + ExtensionObserveOperation ExtensionOperation = "observe" + ExtensionPlanLocalEffectOperation ExtensionOperation = "plan-local-effect" + ExtensionExecuteExternalOperation ExtensionOperation = "execute-external" + ExtensionVerifyOperation ExtensionOperation = "verify" + ExtensionRecoverOperation ExtensionOperation = "recover" +) + +type ExtensionRequest struct { + ProtocolVersion int `json:"protocol_version"` + Operation ExtensionOperation `json:"operation"` + ExtensionID string `json:"extension_id"` + ExtensionVersion string `json:"extension_version"` + ProgramFingerprint string `json:"program_fingerprint"` + CorrelationID string `json:"correlation_id"` + RepositoryRoot string `json:"repository_root,omitempty"` + TransitionID TransitionID `json:"transition_id,omitempty"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` +} + +type ExtensionFact struct { + ID string `json:"id"` + Status FactStatus `json:"status"` + Value string `json:"value,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Detail string `json:"detail,omitempty"` +} + +type ResourceWrite struct { + Resource string `json:"resource"` + Path string `json:"path"` + Content []byte `json:"content,omitempty"` + SHA256 string `json:"sha256"` + Mode uint32 `json:"mode,omitempty"` + Delete bool `json:"delete,omitempty"` +} + +type ExtensionResponse struct { + ProtocolVersion int `json:"protocol_version"` + Operation ExtensionOperation `json:"operation"` + ExtensionID string `json:"extension_id"` + ExtensionVersion string `json:"extension_version"` + CorrelationID string `json:"correlation_id"` + Manifest *ExtensionManifest `json:"manifest,omitempty"` + Facts []ExtensionFact `json:"facts,omitempty"` + Writes []ResourceWrite `json:"writes,omitempty"` + ExternalResult json.RawMessage `json:"external_result,omitempty"` + Verified *bool `json:"verified,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + Error string `json:"error,omitempty"` +} + +// ValidateExtensionOperationResponse enforces the exact payload union for the +// language-neutral extension protocol. Identity and correlation are checked by +// the caller that owns the request boundary. +func ValidateExtensionOperationResponse(operation ExtensionOperation, response ExtensionResponse) error { + if (response.Error == "") != (response.ErrorClass == "") { + return fmt.Errorf("extension errors require an explicit classification and message") + } + hasPayload := response.Manifest != nil || len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + if response.Error != "" { + if hasPayload { + return fmt.Errorf("extension error response contains an operation payload") + } + return nil + } + invalid := false + switch operation { + case ExtensionManifestOperation: + invalid = response.Manifest == nil || len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + case ExtensionObserveOperation: + invalid = response.Manifest != nil || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + case ExtensionPlanLocalEffectOperation, ExtensionRecoverOperation: + invalid = response.Manifest != nil || len(response.Facts) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + case ExtensionExecuteExternalOperation: + invalid = response.Manifest != nil || len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) == 0 || response.Verified != nil + case ExtensionVerifyOperation: + invalid = response.Manifest != nil || len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified == nil + default: + return fmt.Errorf("unsupported extension operation %q", operation) + } + if invalid { + return fmt.Errorf("extension returned the wrong response type for %q", operation) + } + return nil +} + +// ExtensionRuntime is the language-neutral execution contract shared by +// trusted in-process and subprocess extensions. Kernel admission and resource +// ownership remain outside the implementation. +type ExtensionRuntime interface { + Invoke(context.Context, ExtensionRequest) (ExtensionResponse, error) +} + +type RuntimeExtension interface { + Extension + Runtime() ExtensionRuntime +} + +type SubprocessLimits struct { + Deadline time.Duration + StdoutBytes int64 + StderrBytes int64 +} diff --git a/boatstack/control/flow_runtime.go b/boatstack/control/flow_runtime.go new file mode 100644 index 0000000..6e893c8 --- /dev/null +++ b/boatstack/control/flow_runtime.go @@ -0,0 +1,103 @@ +package control + +import ( + "context" + "encoding/json" + "fmt" +) + +const FlowProtocolVersion = 1 + +type FlowRuntimeMode string + +const ( + // FlowRuntimeNative selects a trusted in-process first-party adapter. + FlowRuntimeNative FlowRuntimeMode = "native" + // FlowRuntimeProtocol selects the bounded public FlowRuntime contract. + FlowRuntimeProtocol FlowRuntimeMode = "protocol" +) + +type FlowOperation string + +const ( + FlowObserveOperation FlowOperation = "observe" + FlowPlanLocalEffectOperation FlowOperation = "plan-local-effect" + FlowExecuteExternalOperation FlowOperation = "execute-external" + FlowVerifyOperation FlowOperation = "verify" + FlowRecoverOperation FlowOperation = "recover" +) + +type FlowRequest struct { + ProtocolVersion int `json:"protocol_version"` + Operation FlowOperation `json:"operation"` + FlowID string `json:"flow_id"` + FlowVersion string `json:"flow_version"` + ProgramFingerprint string `json:"program_fingerprint"` + CorrelationID string `json:"correlation_id"` + RepositoryRoot string `json:"repository_root,omitempty"` + TransitionID TransitionID `json:"transition_id,omitempty"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` +} + +type FlowResponse struct { + ProtocolVersion int `json:"protocol_version"` + Operation FlowOperation `json:"operation"` + FlowID string `json:"flow_id"` + FlowVersion string `json:"flow_version"` + CorrelationID string `json:"correlation_id"` + Facts []ExtensionFact `json:"facts,omitempty"` + Writes []ResourceWrite `json:"writes,omitempty"` + ExternalResult json.RawMessage `json:"external_result,omitempty"` + Verified *bool `json:"verified,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + Error string `json:"error,omitempty"` +} + +// ValidateFlowOperationResponse enforces the exact payload union for a custom +// in-process PrimaryFlow runtime. Identity and correlation are checked by the +// Kernel boundary that owns the request. +func ValidateFlowOperationResponse(operation FlowOperation, response FlowResponse) error { + if (response.Error == "") != (response.ErrorClass == "") { + return fmt.Errorf("primary-flow errors require an explicit classification and message") + } + hasPayload := len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + if response.Error != "" { + if hasPayload { + return fmt.Errorf("primary-flow error response contains an operation payload") + } + return nil + } + invalid := false + switch operation { + case FlowObserveOperation: + invalid = len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + case FlowPlanLocalEffectOperation, FlowRecoverOperation: + invalid = len(response.Facts) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil + case FlowExecuteExternalOperation: + invalid = len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) == 0 || response.Verified != nil + case FlowVerifyOperation: + invalid = len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified == nil + default: + return fmt.Errorf("unsupported primary-flow operation %q", operation) + } + if invalid { + return fmt.Errorf("primary flow returned the wrong response type for %q", operation) + } + return nil +} + +// FlowRuntime is the bounded in-process runtime contract for a custom primary +// flow. It receives projections and returns declarations; it never receives a +// mutable Kernel object. +type FlowRuntime interface { + InvokeFlow(context.Context, FlowRequest) (FlowResponse, error) +} + +// RuntimeFlowDefinition supplies a public runtime together with its trusted +// primary-flow manifest. +type RuntimeFlowDefinition interface { + FlowDefinition + FlowRuntime() FlowRuntime +} diff --git a/boatstack/control/runtime_contract_test.go b/boatstack/control/runtime_contract_test.go new file mode 100644 index 0000000..5bed616 --- /dev/null +++ b/boatstack/control/runtime_contract_test.go @@ -0,0 +1,90 @@ +package control + +import ( + "encoding/json" + "testing" +) + +func TestFlowOperationResponsesAreAnExactTaggedUnion(t *testing.T) { + verified := true + valid := map[FlowOperation]FlowResponse{ + FlowObserveOperation: {Facts: []ExtensionFact{{ID: "flow.ready"}}}, + FlowPlanLocalEffectOperation: {Writes: []ResourceWrite{{Resource: "flow.plan"}}}, + FlowExecuteExternalOperation: {ExternalResult: json.RawMessage(`{"ok":true}`)}, + FlowVerifyOperation: {Verified: &verified}, + FlowRecoverOperation: {Writes: []ResourceWrite{{Resource: "flow.recovery"}}}, + } + for operation, response := range valid { + if err := ValidateFlowOperationResponse(operation, response); err != nil { + t.Fatalf("valid %q response: %v", operation, err) + } + } + + invalid := []struct { + name string + operation FlowOperation + response FlowResponse + }{ + {"observe-write", FlowObserveOperation, FlowResponse{Writes: []ResourceWrite{{Resource: "wrong"}}}}, + {"local-fact", FlowPlanLocalEffectOperation, FlowResponse{Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"external-empty", FlowExecuteExternalOperation, FlowResponse{}}, + {"verify-missing", FlowVerifyOperation, FlowResponse{}}, + {"recover-external", FlowRecoverOperation, FlowResponse{ExternalResult: json.RawMessage(`{}`)}}, + {"partial-error", FlowObserveOperation, FlowResponse{ErrorClass: "temporary"}}, + {"error-payload", FlowObserveOperation, FlowResponse{ErrorClass: "temporary", Error: "failed", Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"unknown-operation", FlowOperation("unknown"), FlowResponse{}}, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + if err := ValidateFlowOperationResponse(test.operation, test.response); err == nil { + t.Fatalf("invalid %q response was accepted", test.operation) + } + }) + } + if err := ValidateFlowOperationResponse(FlowObserveOperation, FlowResponse{ErrorClass: "temporary", Error: "failed"}); err != nil { + t.Fatalf("classified error response: %v", err) + } +} + +func TestExtensionOperationResponsesAreAnExactTaggedUnion(t *testing.T) { + verified := true + valid := map[ExtensionOperation]ExtensionResponse{ + ExtensionManifestOperation: {Manifest: &ExtensionManifest{ID: "example.extension"}}, + ExtensionObserveOperation: {Facts: []ExtensionFact{{ID: "example.ready"}}}, + ExtensionPlanLocalEffectOperation: {Writes: []ResourceWrite{{Resource: "example.plan"}}}, + ExtensionExecuteExternalOperation: {ExternalResult: json.RawMessage(`{"ok":true}`)}, + ExtensionVerifyOperation: {Verified: &verified}, + ExtensionRecoverOperation: {Writes: []ResourceWrite{{Resource: "example.recovery"}}}, + } + for operation, response := range valid { + if err := ValidateExtensionOperationResponse(operation, response); err != nil { + t.Fatalf("valid %q response: %v", operation, err) + } + } + + invalid := []struct { + name string + operation ExtensionOperation + response ExtensionResponse + }{ + {"manifest-missing", ExtensionManifestOperation, ExtensionResponse{}}, + {"observe-write", ExtensionObserveOperation, ExtensionResponse{Writes: []ResourceWrite{{Resource: "wrong"}}}}, + {"local-fact", ExtensionPlanLocalEffectOperation, ExtensionResponse{Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"external-empty", ExtensionExecuteExternalOperation, ExtensionResponse{}}, + {"verify-missing", ExtensionVerifyOperation, ExtensionResponse{}}, + {"recover-external", ExtensionRecoverOperation, ExtensionResponse{ExternalResult: json.RawMessage(`{}`)}}, + {"partial-error", ExtensionObserveOperation, ExtensionResponse{Error: "failed"}}, + {"error-payload", ExtensionObserveOperation, ExtensionResponse{ErrorClass: "temporary", Error: "failed", Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"unknown-operation", ExtensionOperation("unknown"), ExtensionResponse{}}, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + if err := ValidateExtensionOperationResponse(test.operation, test.response); err == nil { + t.Fatalf("invalid %q response was accepted", test.operation) + } + }) + } + if err := ValidateExtensionOperationResponse(ExtensionObserveOperation, ExtensionResponse{ErrorClass: "temporary", Error: "failed"}); err != nil { + t.Fatalf("classified error response: %v", err) + } +} diff --git a/boatstack/core/system.go b/boatstack/core/system.go new file mode 100644 index 0000000..36a72c2 --- /dev/null +++ b/boatstack/core/system.go @@ -0,0 +1,48 @@ +// Package core owns the first-party CoreSystem definition. It declares +// Boatstack operational capabilities but no software-delivery flow. +package core + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + + "github.com/operatorstack/boatstack/boatstack/control" +) + +const ( + ID = "boatstack.core" + Version = "1.0.0" +) + +type system struct{} + +//go:embed transitions.json +var transitionDeclarations []byte + +func System() control.CoreSystemDefinition { return system{} } + +func (system) CoreManifest(context.Context) (control.CoreSystemManifest, error) { + transitions, err := decodeTransitions() + if err != nil { + return control.CoreSystemManifest{}, err + } + return control.CoreSystemManifest{ID: ID, Version: Version, Transitions: transitions}, nil +} + +func decodeTransitions() ([]control.Transition, error) { + decoder := json.NewDecoder(bytes.NewReader(transitionDeclarations)) + decoder.DisallowUnknownFields() + var transitions []control.Transition + if err := decoder.Decode(&transitions); err != nil { + return nil, fmt.Errorf("decode CoreSystem transitions: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, fmt.Errorf("CoreSystem transition declarations contain trailing JSON") + } + return transitions, nil +} diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go new file mode 100644 index 0000000..8ffd57c --- /dev/null +++ b/boatstack/core/system_test.go @@ -0,0 +1,35 @@ +package core_test + +import ( + "context" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/core" +) + +func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { + // control-law: core-system-declarations-exclude-primary-flow-policy + manifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + if manifest.ID != core.ID || manifest.Version != core.Version || len(manifest.Transitions) != 32 { + t.Fatalf("CoreSystem identity/count = %s@%s/%d", manifest.ID, manifest.Version, len(manifest.Transitions)) + } + for _, transition := range manifest.Transitions { + id := string(transition.ID) + if !hasPrefix(id, "engagement.", "invocation.", "repository.", "runtime.", "configuration.", "installation.", "catalog.", "goal.", "recovery.", "external.") { + t.Errorf("CoreSystem owns delivery-flow transition %s", id) + } + } +} + +func hasPrefix(value string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json new file mode 100644 index 0000000..7eeb7f2 --- /dev/null +++ b/boatstack/core/transitions.json @@ -0,0 +1,4584 @@ +[ + { + "id": "engagement.begin", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "GOAL_REQUIRED", + "class": "authority", + "source_phases": [ + "DORMANT", + "OBSERVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:engagement", + "facet:goal", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "engagement" + ], + "effect": "engagement.begin", + "local_effects": [ + "engagement.begin" + ], + "idempotent": true, + "prescription": { + "operation": "engagement.begin", + "expected_postcondition": "predicate:target-phase:engagement.begin" + }, + "source_predicate": "predicate:source-phase:engagement.begin", + "source_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "dormant", + "command" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:engagement.begin", + "target_predicate": "predicate:target-phase:engagement.begin", + "target_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command" + ] + } + ], + "verifier": "verifier:fresh-observation:engagement.begin", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:engagement.begin" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 10 + }, + { + "id": "engagement.renew", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "authority", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:engagement", + "facet:goal", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "engagement" + ], + "effect": "engagement.renew", + "local_effects": [ + "engagement.renew" + ], + "idempotent": true, + "prescription": { + "operation": "engagement.renew", + "expected_postcondition": "predicate:target-phase:engagement.renew" + }, + "source_predicate": "predicate:source-phase:engagement.renew", + "source_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:engagement.renew", + "target_predicate": "predicate:target-phase:engagement.renew", + "target_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + } + ], + "verifier": "verifier:fresh-observation:engagement.renew", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:engagement.renew" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 70 + }, + { + "id": "engagement.release", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "authority", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "DORMANT" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:engagement", + "facet:goal", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "engagement" + ], + "effect": "engagement.release", + "local_effects": [ + "engagement.release" + ], + "idempotent": true, + "prescription": { + "operation": "engagement.release", + "expected_postcondition": "predicate:target-phase:engagement.release" + }, + "source_predicate": "predicate:source-phase:engagement.release", + "source_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "active", + "stale", + "command" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:engagement.release", + "target_predicate": "predicate:target-phase:engagement.release", + "target_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "dormant" + ] + } + ], + "verifier": "verifier:fresh-observation:engagement.release", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:engagement.release" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 95 + }, + { + "id": "invocation.rebind", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:topology", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "identity-binding" + ], + "effect": "invocation.rebind", + "local_effects": [ + "invocation.rebind" + ], + "idempotent": true, + "prescription": { + "operation": "invocation.rebind", + "expected_postcondition": "predicate:target-phase:invocation.rebind" + }, + "source_predicate": "predicate:source-phase:invocation.rebind", + "source_conditions": [ + { + "facet": "topology", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + } + ], + "admission_predicate": "predicate:exact-admission:invocation.rebind", + "target_predicate": "predicate:target-phase:invocation.rebind", + "target_conditions": [ + { + "facet": "topology", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:invocation.rebind", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:invocation.rebind" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 15, + "allows_identity_rebind": true + }, + { + "id": "repository.attach", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "DORMANT", + "OBSERVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:topology", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "repository-binding" + ], + "effect": "repository.attach", + "local_effects": [ + "repository.attach" + ], + "idempotent": true, + "parameters": [ + { + "name": "topology", + "required": true, + "secret": false + }, + { + "name": "config_authority", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "repository.attach", + "expected_postcondition": "predicate:target-phase:repository.attach" + }, + "source_predicate": "predicate:source-phase:repository.attach", + "source_conditions": [ + { + "facet": "topology", + "statuses": [ + "known" + ], + "values": [ + "embedded" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + } + ], + "admission_predicate": "predicate:exact-admission:repository.attach", + "target_predicate": "predicate:target-phase:repository.attach", + "target_conditions": [ + { + "facet": "topology", + "statuses": [ + "known" + ], + "values": [ + "detached", + "hybrid" + ] + } + ], + "verifier": "verifier:fresh-observation:repository.attach", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:repository.attach" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 12, + "allows_identity_rebind": true + }, + { + "id": "repository.detach", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "DORMANT", + "OBSERVED", + "FRONTIER" + ], + "target_phases": [ + "DORMANT" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:topology", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "repository-binding" + ], + "effect": "repository.detach", + "local_effects": [ + "repository.detach" + ], + "idempotent": true, + "prescription": { + "operation": "repository.detach", + "expected_postcondition": "predicate:target-phase:repository.detach" + }, + "source_predicate": "predicate:source-phase:repository.detach", + "source_conditions": [ + { + "facet": "topology", + "statuses": [ + "known" + ], + "values": [ + "detached", + "hybrid" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + } + ], + "admission_predicate": "predicate:exact-admission:repository.detach", + "target_predicate": "predicate:target-phase:repository.detach", + "target_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "dormant" + ] + } + ], + "verifier": "verifier:fresh-observation:repository.detach", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:repository.detach" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 96, + "allows_identity_rebind": true + }, + { + "id": "runtime.hydrate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "GOAL_REQUIRED", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "runtime" + ], + "effect": "runtime.hydrate", + "local_effects": [ + "runtime.hydrate" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "runtime_path", + "required": true, + "secret": false + }, + { + "name": "runtime_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "runtime.hydrate", + "expected_postcondition": "predicate:target-phase:runtime.hydrate" + }, + "source_predicate": "predicate:source-phase:runtime.hydrate", + "source_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "absent", + "stale", + "invalid", + "conflicting", + "wrong-source", + "partially-published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + } + ], + "admission_predicate": "predicate:exact-admission:runtime.hydrate", + "target_predicate": "predicate:target-phase:runtime.hydrate", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:runtime.hydrate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "runtime.reconcile", + "recovery_authority": "declared-by:runtime.reconcile", + "resumption_predicate": "recovery-contract-for:runtime.hydrate" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 20 + }, + { + "id": "runtime.replace", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "RECOVERY" + ], + "target_phases": [ + "OBSERVED", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "runtime" + ], + "effect": "runtime.replace", + "local_effects": [ + "runtime.replace" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "runtime_path", + "required": true, + "secret": false + }, + { + "name": "runtime_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "runtime.replace", + "expected_postcondition": "predicate:target-phase:runtime.replace" + }, + "source_predicate": "predicate:source-phase:runtime.replace", + "source_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified", + "stale", + "invalid", + "conflicting", + "wrong-source", + "partially-published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + } + ], + "admission_predicate": "predicate:exact-admission:runtime.replace", + "target_predicate": "predicate:target-phase:runtime.replace", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:runtime.replace", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "runtime.reconcile", + "recovery_authority": "declared-by:runtime.reconcile", + "resumption_predicate": "recovery-contract-for:runtime.replace" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 25 + }, + { + "id": "runtime.reconcile", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "SYSTEM_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "FRONTIER", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program" + ], + "owned_resources": [ + "runtime" + ], + "effect": "runtime.reconcile", + "local_effects": [ + "runtime.reconcile" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "runtime_path", + "required": true, + "secret": false + }, + { + "name": "runtime_sha256", + "required": true, + "secret": false + }, + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "runtime.reconcile", + "expected_postcondition": "predicate:target-phase:runtime.reconcile" + }, + "source_predicate": "predicate:source-phase:runtime.reconcile", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:runtime.reconcile", + "target_predicate": "predicate:target-phase:runtime.reconcile", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:runtime.reconcile", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:runtime.reconcile" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 4 + }, + { + "id": "configuration.initialize", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "GOAL_REQUIRED", + "class": "owned-local", + "source_phases": [ + "OBSERVED" + ], + "target_phases": [ + "OBSERVED", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:configuration", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "configuration" + ], + "effect": "configuration.initialize", + "local_effects": [ + "configuration.initialize" + ], + "idempotent": true, + "parameters": [ + { + "name": "config_path", + "required": true, + "secret": false + }, + { + "name": "config_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "configuration.initialize", + "expected_postcondition": "predicate:target-phase:configuration.initialize" + }, + "source_predicate": "predicate:source-phase:configuration.initialize", + "source_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "unsupported", + "stale", + "conflicting" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + } + ], + "admission_predicate": "predicate:exact-admission:configuration.initialize", + "target_predicate": "predicate:target-phase:configuration.initialize", + "target_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:configuration.initialize", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "configuration.reconcile", + "recovery_authority": "declared-by:configuration.reconcile", + "resumption_predicate": "recovery-contract-for:configuration.initialize" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 22 + }, + { + "id": "configuration.mutate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:configuration", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:runtime" + ], + "owned_resources": [ + "configuration" + ], + "effect": "configuration.mutate", + "local_effects": [ + "configuration.mutate" + ], + "idempotent": true, + "parameters": [ + { + "name": "config_path", + "required": true, + "secret": false + }, + { + "name": "config_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "configuration.mutate", + "expected_postcondition": "predicate:target-phase:configuration.mutate" + }, + "source_predicate": "predicate:source-phase:configuration.mutate", + "source_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified", + "stale", + "divergent", + "conflicting" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:configuration.mutate", + "target_predicate": "predicate:target-phase:configuration.mutate", + "target_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:configuration.mutate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "configuration.reconcile", + "recovery_authority": "declared-by:configuration.reconcile", + "resumption_predicate": "recovery-contract-for:configuration.mutate" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 60 + }, + { + "id": "configuration.reconcile", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "SYSTEM_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "FRONTIER", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program" + ], + "owned_resources": [ + "configuration" + ], + "effect": "configuration.reconcile", + "local_effects": [ + "configuration.reconcile" + ], + "idempotent": true, + "parameters": [ + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "configuration.reconcile", + "expected_postcondition": "predicate:target-phase:configuration.reconcile" + }, + "source_predicate": "predicate:source-phase:configuration.reconcile", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:configuration.reconcile", + "target_predicate": "predicate:target-phase:configuration.reconcile", + "target_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:configuration.reconcile", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:configuration.reconcile" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 3 + }, + { + "id": "installation.initialize", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "GOAL_REQUIRED", + "class": "owned-local", + "source_phases": [ + "DORMANT", + "OBSERVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "installation" + ], + "effect": "installation.initialize", + "local_effects": [ + "installation.initialize" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "runtime_path", + "required": true, + "secret": false + }, + { + "name": "runtime_sha256", + "required": true, + "secret": false + }, + { + "name": "config_path", + "required": true, + "secret": false + }, + { + "name": "config_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "installation.initialize", + "expected_postcondition": "predicate:target-phase:installation.initialize" + }, + "source_predicate": "predicate:source-phase:installation.initialize", + "source_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "absent", + "invalid", + "stale" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + } + ], + "admission_predicate": "predicate:exact-admission:installation.initialize", + "target_predicate": "predicate:target-phase:installation.initialize", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:installation.initialize", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "runtime.reconcile", + "recovery_authority": "declared-by:runtime.reconcile", + "resumption_predicate": "recovery-contract-for:installation.initialize" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 11 + }, + { + "id": "installation.update", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration" + ], + "owned_resources": [ + "installation" + ], + "effect": "installation.update", + "local_effects": [ + "installation.update" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "runtime_path", + "required": true, + "secret": false + }, + { + "name": "runtime_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "installation.update", + "expected_postcondition": "predicate:target-phase:installation.update" + }, + "source_predicate": "predicate:source-phase:installation.update", + "source_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified", + "stale", + "invalid", + "conflicting", + "wrong-source", + "partially-published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:installation.update", + "target_predicate": "predicate:target-phase:installation.update", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "verifier": "verifier:fresh-observation:installation.update", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "runtime.reconcile", + "recovery_authority": "declared-by:runtime.reconcile", + "resumption_predicate": "recovery-contract-for:installation.update" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 65 + }, + { + "id": "catalog.reconcile", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED", + "TERMINAL", + "ABANDONED" + ], + "target_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:program", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal" + ], + "owned_resources": [ + "catalog-identity" + ], + "effect": "catalog.reconcile", + "local_effects": [ + "catalog.reconcile" + ], + "idempotent": true, + "parameters": [ + { + "name": "prior_program_fingerprint", + "required": true, + "secret": false + }, + { + "name": "accept_obligation_change", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "catalog.reconcile", + "expected_postcondition": "predicate:target-phase:catalog.reconcile" + }, + "source_predicate": "predicate:source-phase:catalog.reconcile", + "source_conditions": [ + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "drift" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "drift" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale", + "established" + ] + } + ], + "admission_predicate": "predicate:exact-admission:catalog.reconcile", + "target_predicate": "predicate:target-phase:catalog.reconcile", + "target_conditions": [ + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:catalog.reconcile", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:catalog.reconcile" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "reconciles_program": true + }, + "priority": 1 + }, + { + "id": "goal.configure", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "GOAL_REQUIRED", + "class": "authority", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:goal", + "facet:program" + ], + "owned_resources": [ + "goal" + ], + "effect": "goal.configure", + "local_effects": [ + "goal.configure" + ], + "idempotent": true, + "parameters": [ + { + "name": "goal_kind", + "required": true, + "secret": false + }, + { + "name": "delivery_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "goal.configure", + "expected_postcondition": "predicate:target-phase:goal.configure" + }, + "source_predicate": "predicate:source-phase:goal.configure", + "source_conditions": [ + { + "facet": "goal", + "statuses": [ + "known", + "absent" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:goal.configure", + "target_predicate": "predicate:target-phase:goal.configure", + "target_conditions": [ + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:goal.configure", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:goal.configure" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "binds_requested_goal": true + }, + "priority": 30 + }, + { + "id": "recovery.resume", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "SYSTEM_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY" + ], + "target_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program" + ], + "owned_resources": [ + "recovery-journal" + ], + "effect": "recovery.resume", + "local_effects": [ + "recovery.resume" + ], + "idempotent": true, + "parameters": [ + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "recovery.resume", + "expected_postcondition": "predicate:target-phase:recovery.resume" + }, + "source_predicate": "predicate:source-phase:recovery.resume", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:recovery.resume", + "target_predicate": "predicate:target-phase:recovery.resume", + "target_conditions": [ + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:recovery.resume", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:recovery.resume" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 2 + }, + { + "id": "recovery.rollback", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "SYSTEM_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY" + ], + "target_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program" + ], + "owned_resources": [ + "recovery-journal" + ], + "effect": "recovery.rollback", + "local_effects": [ + "recovery.rollback" + ], + "idempotent": true, + "parameters": [ + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "recovery.rollback", + "expected_postcondition": "predicate:target-phase:recovery.rollback" + }, + "source_predicate": "predicate:source-phase:recovery.rollback", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:recovery.rollback", + "target_predicate": "predicate:target-phase:recovery.rollback", + "target_conditions": [ + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:recovery.rollback", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:recovery.rollback" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 3 + }, + { + "id": "recovery.escalate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "SYSTEM_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program" + ], + "owned_resources": [ + "recovery-journal" + ], + "effect": "recovery.escalate", + "local_effects": [ + "recovery.escalate" + ], + "idempotent": true, + "parameters": [ + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "recovery.escalate", + "expected_postcondition": "predicate:target-phase:recovery.escalate" + }, + "source_predicate": "predicate:source-phase:recovery.escalate", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + } + ], + "admission_predicate": "predicate:exact-admission:recovery.escalate", + "target_predicate": "predicate:target-phase:recovery.escalate", + "target_conditions": [ + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "escalated" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:recovery.escalate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:recovery.escalate" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 5 + }, + { + "id": "external.files-changed", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:verification" + ], + "idempotent": false, + "prescription": { + "operation": "external.files-changed", + "expected_postcondition": "predicate:target-phase:external.files-changed" + }, + "source_predicate": "predicate:source-phase:external.files-changed", + "source_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.files-changed", + "target_predicate": "predicate:target-phase:external.files-changed", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:external.files-changed", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.files-changed" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.head-changed", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:verification" + ], + "idempotent": false, + "prescription": { + "operation": "external.head-changed", + "expected_postcondition": "predicate:target-phase:external.head-changed" + }, + "source_predicate": "predicate:source-phase:external.head-changed", + "source_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.head-changed", + "target_predicate": "predicate:target-phase:external.head-changed", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:external.head-changed", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.head-changed" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.branch-changed", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace" + ], + "idempotent": false, + "prescription": { + "operation": "external.branch-changed", + "expected_postcondition": "predicate:target-phase:external.branch-changed" + }, + "source_predicate": "predicate:source-phase:external.branch-changed", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.branch-changed", + "target_predicate": "predicate:target-phase:external.branch-changed", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:external.branch-changed", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.branch-changed" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.runtime-disappeared", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "RECOVERY" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime" + ], + "idempotent": false, + "prescription": { + "operation": "external.runtime-disappeared", + "expected_postcondition": "predicate:target-phase:external.runtime-disappeared" + }, + "source_predicate": "predicate:source-phase:external.runtime-disappeared", + "source_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified", + "stale" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.runtime-disappeared", + "target_predicate": "predicate:target-phase:external.runtime-disappeared", + "target_conditions": [ + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "absent", + "stale" + ] + } + ], + "verifier": "verifier:fresh-observation:external.runtime-disappeared", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.runtime-disappeared" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.configuration-drifted", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "UNRESOLVED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:configuration" + ], + "idempotent": false, + "prescription": { + "operation": "external.configuration-drifted", + "expected_postcondition": "predicate:target-phase:external.configuration-drifted" + }, + "source_predicate": "predicate:source-phase:external.configuration-drifted", + "source_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.configuration-drifted", + "target_predicate": "predicate:target-phase:external.configuration-drifted", + "target_conditions": [ + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "stale", + "divergent", + "conflicting" + ] + } + ], + "verifier": "verifier:fresh-observation:external.configuration-drifted", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.configuration-drifted" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.lease-expired", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "DORMANT", + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:engagement" + ], + "idempotent": false, + "prescription": { + "operation": "external.lease-expired", + "expected_postcondition": "predicate:target-phase:external.lease-expired" + }, + "source_predicate": "predicate:source-phase:external.lease-expired", + "source_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "active", + "command" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.lease-expired", + "target_predicate": "predicate:target-phase:external.lease-expired", + "target_conditions": [ + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "dormant", + "stale" + ] + } + ], + "verifier": "verifier:fresh-observation:external.lease-expired", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.lease-expired" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.host-interrupted", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "RECOVERY" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:transaction", + "facet:transaction-info" + ], + "idempotent": false, + "prescription": { + "operation": "external.host-interrupted", + "expected_postcondition": "predicate:target-phase:external.host-interrupted" + }, + "source_predicate": "predicate:source-phase:external.host-interrupted", + "source_conditions": [ + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "staged", + "local-applied", + "external-uncertain", + "verifying" + ] + }, + { + "facet": "transaction-info", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.host-interrupted", + "target_predicate": "predicate:target-phase:external.host-interrupted", + "target_conditions": [ + { + "facet": "recovery", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:external.host-interrupted", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.host-interrupted" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.ci-completed", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:verification" + ], + "idempotent": false, + "prescription": { + "operation": "external.ci-completed", + "expected_postcondition": "predicate:target-phase:external.ci-completed" + }, + "source_predicate": "predicate:source-phase:external.ci-completed", + "source_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.ci-completed", + "target_predicate": "predicate:target-phase:external.ci-completed", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:external.ci-completed", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.ci-completed" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.pr-opened", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication" + ], + "idempotent": false, + "prescription": { + "operation": "external.pr-opened", + "expected_postcondition": "predicate:target-phase:external.pr-opened" + }, + "source_predicate": "predicate:source-phase:external.pr-opened", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.pr-opened", + "target_predicate": "predicate:target-phase:external.pr-opened", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "open" + ] + } + ], + "verifier": "verifier:fresh-observation:external.pr-opened", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.pr-opened" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.pr-updated", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication" + ], + "idempotent": false, + "prescription": { + "operation": "external.pr-updated", + "expected_postcondition": "predicate:target-phase:external.pr-updated" + }, + "source_predicate": "predicate:source-phase:external.pr-updated", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.pr-updated", + "target_predicate": "predicate:target-phase:external.pr-updated", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "open" + ] + } + ], + "verifier": "verifier:fresh-observation:external.pr-updated", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.pr-updated" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.pr-closed", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication" + ], + "idempotent": false, + "prescription": { + "operation": "external.pr-closed", + "expected_postcondition": "predicate:target-phase:external.pr-closed" + }, + "source_predicate": "predicate:source-phase:external.pr-closed", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.pr-closed", + "target_predicate": "predicate:target-phase:external.pr-closed", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "closed-unmerged", + "merged" + ] + } + ], + "verifier": "verifier:fresh-observation:external.pr-closed", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.pr-closed" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.pr-merged", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication" + ], + "idempotent": false, + "prescription": { + "operation": "external.pr-merged", + "expected_postcondition": "predicate:target-phase:external.pr-merged" + }, + "source_predicate": "predicate:source-phase:external.pr-merged", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.pr-merged", + "target_predicate": "predicate:target-phase:external.pr-merged", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "merged" + ] + } + ], + "verifier": "verifier:fresh-observation:external.pr-merged", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.pr-merged" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + }, + { + "id": "external.provider-unavailable", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "OBSERVED_EXTERNAL", + "class": "observed-external", + "source_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "RECOVERY", + "FRONTIER", + "UNRESOLVED" + ], + "target_phases": [ + "UNRESOLVED", + "RECOVERY" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "none" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication" + ], + "idempotent": false, + "prescription": { + "operation": "external.provider-unavailable", + "expected_postcondition": "predicate:target-phase:external.provider-unavailable" + }, + "source_predicate": "predicate:source-phase:external.provider-unavailable", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:external.provider-unavailable", + "target_predicate": "predicate:target-phase:external.provider-unavailable", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "unavailable" + ] + } + ], + "verifier": "verifier:fresh-observation:external.provider-unavailable", + "interruption": { + "points": [ + "after-observation" + ], + "partial_state": [ + "no-owned-partial-state" + ], + "detection": "fresh-canonical-observation", + "resume_contract": "not-applicable", + "rollback_contract": "not-applicable", + "compensation_contract": "not-applicable", + "recovery_authority": "not-applicable", + "resumption_predicate": "predicate:target-phase:external.provider-unavailable" + }, + "reversibility": "observation-only", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 100 + } +] diff --git a/boatstack/distribution/standard.go b/boatstack/distribution/standard.go new file mode 100644 index 0000000..3ff824f --- /dev/null +++ b/boatstack/distribution/standard.go @@ -0,0 +1,153 @@ +// Package distribution is the composition root for shipped Boatstack +// distributions. Kernel mechanism packages do not import it. +package distribution + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/extension/subprocess" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/plant" +) + +func StandardProgram(ctx context.Context, extensions ...control.Extension) (control.ControlProgram, error) { + return control.Compile(ctx, control.CompileRequest{ + KernelVersion: boatstack.Version, + Core: core.System(), Flow: standard.Definition(), Extensions: extensions, + Settings: programSettings{}, + }) +} + +// programSettings contains only repository policy that changes the compiled +// graph. Approval, host, visual, and risk policy remain controlling snapshot +// facts; the configured extension composition changes the ControlProgram. +type programSettings struct { + Extensions []protocol.SubprocessExtensionSettings `json:"extensions"` +} + +// RepositoryProgramRequest identifies one immutable repository-scoped +// composition. It contains no authority receipt and cannot replace +// StandardFlow. +type RepositoryProgramRequest struct { + Repository string + ExternalStateRoot string + Host string + CorrelationID string + Extensions []control.Extension + ConfigurationPath string + ConfigurationFingerprint string +} + +// StandardProgramForRepository compiles StandardFlow plus the exact +// checksum-verified subprocess extensions selected by the repository's strict +// project configuration. A new value is returned per call, so concurrent +// repositories never share mutable program state. +func StandardProgramForRepository(ctx context.Context, request RepositoryProgramRequest) (control.ControlProgram, error) { + configured, settings, err := ConfiguredExtensions(ctx, request) + if err != nil { + return control.ControlProgram{}, err + } + extensions := append([]control.Extension(nil), request.Extensions...) + extensions = append(extensions, configured...) + return control.Compile(ctx, control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: standard.Definition(), + Extensions: extensions, Settings: settings, + }) +} + +// ConfiguredExtensions resolves only additive subprocess extensions. The +// returned settings identity binds every repository policy byte that can +// affect the compiled program. +func ConfiguredExtensions(ctx context.Context, request RepositoryProgramRequest) ([]control.Extension, any, error) { + if request.Repository == "" { + return nil, programSettings{}, nil + } + host := request.Host + if host == "" { + host = "cli" + } + correlation := request.CorrelationID + if correlation == "" { + correlation = "program-assembly" + } + resolver, err := plant.NewResolver(request.ExternalStateRoot) + if err != nil { + return nil, nil, err + } + invocation, err := resolver.ResolveInvocation(ctx, request.Repository, host, correlation) + if err != nil { + return nil, nil, fmt.Errorf("resolve repository control program: %w", err) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return nil, nil, fmt.Errorf("resolve repository program layout: %w", err) + } + configurationPath := layout.ConfigPath + if request.ConfigurationPath != "" { + if !filepath.IsAbs(request.ConfigurationPath) || filepath.Clean(request.ConfigurationPath) != request.ConfigurationPath { + return nil, nil, fmt.Errorf("candidate repository program configuration path must be exact and absolute") + } + configurationPath = request.ConfigurationPath + } + raw, err := os.ReadFile(configurationPath) + if err != nil { + if os.IsNotExist(err) { + return nil, programSettings{}, nil + } + return nil, nil, fmt.Errorf("read repository program configuration: %w", err) + } + configuration, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return nil, nil, fmt.Errorf("verify repository program configuration: %w", err) + } + if request.ConfigurationFingerprint != "" && request.ConfigurationFingerprint != fingerprint { + return nil, nil, fmt.Errorf("candidate repository program configuration fingerprint mismatch") + } + configured := make([]control.Extension, 0, len(configuration.Extensions)) + for _, declaration := range configuration.Extensions { + extension, extensionErr := subprocess.New(subprocess.Config{ + ID: declaration.ID, Version: declaration.Version, Executable: declaration.Executable, SHA256: declaration.SHA256, + Settings: declaration.Settings, + Limits: control.SubprocessLimits{Deadline: time.Duration(declaration.DeadlineMillis) * time.Millisecond, StdoutBytes: declaration.StdoutBytes, StderrBytes: declaration.StderrBytes}, + }) + if extensionErr != nil { + return nil, nil, fmt.Errorf("verify configured subprocess extension %q: %w", declaration.ID, extensionErr) + } + configured = append(configured, extension) + } + settings, err := canonicalProgramSettings(configuration.Extensions) + if err != nil { + return nil, nil, err + } + return configured, settings, nil +} + +func canonicalProgramSettings(values []protocol.SubprocessExtensionSettings) (programSettings, error) { + extensions := append([]protocol.SubprocessExtensionSettings(nil), values...) + for index := range extensions { + if len(extensions[index].Settings) == 0 { + continue + } + var decoded any + if err := json.Unmarshal(extensions[index].Settings, &decoded); err != nil { + return programSettings{}, fmt.Errorf("canonicalize extension %q settings: %w", extensions[index].ID, err) + } + canonical, err := json.Marshal(decoded) + if err != nil { + return programSettings{}, fmt.Errorf("canonicalize extension %q settings: %w", extensions[index].ID, err) + } + extensions[index].Settings = canonical + } + sort.Slice(extensions, func(i, j int) bool { return extensions[i].ID < extensions[j].ID }) + return programSettings{Extensions: extensions}, nil +} diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go new file mode 100644 index 0000000..8e12159 --- /dev/null +++ b/boatstack/distribution/standard_test.go @@ -0,0 +1,198 @@ +package distribution + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" +) + +func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { + // control-law: repository-program-selection-has-no-mutable-global-state + if runtime.GOOS == "windows" { + t.Skip("the language-neutral Python fixture is exercised on POSIX") + } + if _, err := os.Stat("/usr/bin/python3"); err != nil { + t.Skip("/usr/bin/python3 is unavailable") + } + fixturePath, err := filepath.Abs(filepath.Join("..", "extension", "subprocess", "testdata", "reference_extension.py")) + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatal(err) + } + extensions := func(id string) protocol.SubprocessExtensionSettings { + path := filepath.Join(t.TempDir(), strings.ReplaceAll(id, ".", "-")+".py") + content := []byte(strings.ReplaceAll(string(source), "fixture.echo", id)) + if err := os.WriteFile(path, content, 0o700); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + return protocol.SubprocessExtensionSettings{ + ID: id, Version: "1.0.0", Executable: resolved, SHA256: hex.EncodeToString(digest[:]), DeadlineMillis: 30_000, + } + } + x, y := extensions("fixture.echo"), extensions("fixture.second") + candidateConfig := protocol.ProjectConfig{ + SchemaVersion: protocol.ConfigSchemaVersion, + Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, + Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, + Hosts: []string{"cli"}, Extensions: []protocol.SubprocessExtensionSettings{x}, + } + candidateBytes, err := json.Marshal(candidateConfig) + if err != nil { + t.Fatal(err) + } + _, candidateFingerprint, err := protocol.ProjectConfigFingerprint(candidateBytes) + if err != nil { + t.Fatal(err) + } + candidatePath := filepath.Join(t.TempDir(), "candidate.json") + if err := os.WriteFile(candidatePath, candidateBytes, 0o600); err != nil { + t.Fatal(err) + } + candidateProgram, err := StandardProgramForRepository(context.Background(), RepositoryProgramRequest{ + Repository: repositoryFixture(t, nil), ExternalStateRoot: t.TempDir(), Host: "cli", CorrelationID: "candidate-program", + ConfigurationPath: candidatePath, ConfigurationFingerprint: candidateFingerprint, + }) + if err != nil { + t.Fatal(err) + } + if len(candidateProgram.Summary().Extensions) != 1 { + t.Fatalf("candidate program extensions = %d, want 1", len(candidateProgram.Summary().Extensions)) + } + if _, err := StandardProgramForRepository(context.Background(), RepositoryProgramRequest{ + Repository: repositoryFixture(t, nil), ExternalStateRoot: t.TempDir(), Host: "cli", CorrelationID: "candidate-mismatch", + ConfigurationPath: candidatePath, ConfigurationFingerprint: strings.Repeat("0", 64), + }); err == nil { + t.Fatal("candidate program accepted a mismatched configuration fingerprint") + } + repositories := []string{repositoryFixture(t, nil), repositoryFixture(t, []protocol.SubprocessExtensionSettings{x}), repositoryFixture(t, []protocol.SubprocessExtensionSettings{x, y})} + expected := []int{0, 1, 2} + fingerprints := make([]string, len(repositories)) + externalRoots := []string{t.TempDir(), t.TempDir(), t.TempDir()} + var wait sync.WaitGroup + errors := make(chan error, len(repositories)) + for index, repository := range repositories { + wait.Add(1) + go func(index int, repository string) { + defer wait.Done() + program, err := StandardProgramForRepository(context.Background(), RepositoryProgramRequest{ + Repository: repository, ExternalStateRoot: externalRoots[index], Host: "cli", CorrelationID: "repository-program", + }) + if err != nil { + errors <- err + return + } + if len(program.Summary().Extensions) != expected[index] { + errors <- &countError{got: len(program.Summary().Extensions), want: expected[index]} + return + } + fingerprints[index] = program.Fingerprint() + }(index, repository) + } + wait.Wait() + close(errors) + for err := range errors { + t.Fatal(err) + } + if fingerprints[0] == fingerprints[1] || fingerprints[1] == fingerprints[2] || fingerprints[0] == fingerprints[2] { + t.Fatalf("repository program identities collided: %#v", fingerprints) + } +} + +func TestSnapshotPolicyDoesNotCreateCatalogDriftWithoutCompositionChange(t *testing.T) { + // control-law: only-repository-policy-that-changes-composition-enters-program-identity + left := repositoryFixture(t, []protocol.SubprocessExtensionSettings{}) + right := repositoryFixture(t, []protocol.SubprocessExtensionSettings{}) + path := filepath.Join(right, ".boatstack", "project.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var configuration protocol.ProjectConfig + if err := json.Unmarshal(raw, &configuration); err != nil { + t.Fatal(err) + } + configuration.Policy.PlanApproval = "human-or-autonomy" + configuration.Policy.VisualEvidence = "required" + configuration.Hosts = append(configuration.Hosts, "sdk") + raw, err = json.Marshal(configuration) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + + compile := func(repository string) string { + program, err := StandardProgramForRepository(context.Background(), RepositoryProgramRequest{ + Repository: repository, ExternalStateRoot: t.TempDir(), Host: "cli", CorrelationID: "policy-projection", + }) + if err != nil { + t.Fatal(err) + } + return program.Fingerprint() + } + if one, two := compile(left), compile(right); one != two { + t.Fatalf("snapshot-only policy changed compiled program identity: %s != %s", one, two) + } +} + +type countError struct{ got, want int } + +func (e *countError) Error() string { return "extension count mismatch" } + +func repositoryFixture(t *testing.T, extensions []protocol.SubprocessExtensionSettings) string { + t.Helper() + repository := t.TempDir() + command := func(arguments ...string) { + cmd := exec.Command("git", arguments...) + cmd.Dir = repository + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } + } + command("init", "-q") + command("config", "user.email", "boatstack@example.invalid") + command("config", "user.name", "Boatstack Test") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + command("add", "README.md") + command("commit", "-q", "-m", "fixture") + if extensions != nil { + configuration := protocol.ProjectConfig{ + SchemaVersion: protocol.ConfigSchemaVersion, + Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, + Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, + Hosts: []string{"cli"}, Extensions: extensions, + } + raw, err := json.Marshal(configuration) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repository, ".boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "project.json"), append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + } + return repository +} diff --git a/boatstack/examples/control_program_test.go b/boatstack/examples/control_program_test.go new file mode 100644 index 0000000..7ad9d59 --- /dev/null +++ b/boatstack/examples/control_program_test.go @@ -0,0 +1,38 @@ +package examples_test + +import ( + "context" + "fmt" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/extension/releasenote" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/sdk" +) + +func Example_standardFlowWithReleaseNoteExtension() { + program, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "example-kernel", + Core: core.System(), + Flow: standard.Definition(), + Extensions: []control.Extension{releasenote.Definition()}, + }) + if err != nil { + panic(err) + } + summary := program.Summary() + fmt.Printf("%s + %s + %s: %d transitions\n", summary.Core.ID, summary.Flow.ID, summary.Extensions[0].ID, summary.TotalTransitionCount) + // Output: + // boatstack.core + boatstack.standard + boatstack.release-note: 63 transitions +} + +func Example_sdkCustomKernel() { + _, err := sdk.NewKernel("", + sdk.WithFlow(standard.Definition()), + sdk.WithExtension(releasenote.Definition()), + ) + fmt.Println(err == nil) + // Output: + // true +} diff --git a/boatstack/extension/inprocess.go b/boatstack/extension/inprocess.go new file mode 100644 index 0000000..40eb13e --- /dev/null +++ b/boatstack/extension/inprocess.go @@ -0,0 +1,28 @@ +// Package extension contains public helpers for trusted in-process Boatstack +// extensions. Trust is assigned by the application compiling the program. +package extension + +import ( + "context" + "fmt" + + "github.com/operatorstack/boatstack/boatstack/control" +) + +type InProcess struct { + manifest control.ExtensionManifest + runtime control.ExtensionRuntime +} + +func NewInProcess(manifest control.ExtensionManifest, runtime control.ExtensionRuntime) (*InProcess, error) { + if runtime == nil { + return nil, fmt.Errorf("in-process extension requires a runtime") + } + return &InProcess{manifest: manifest, runtime: runtime}, nil +} + +func (e *InProcess) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { + return e.manifest, nil +} + +func (e *InProcess) Runtime() control.ExtensionRuntime { return e.runtime } diff --git a/boatstack/extension/releasenote/releasenote.go b/boatstack/extension/releasenote/releasenote.go new file mode 100644 index 0000000..61f751e --- /dev/null +++ b/boatstack/extension/releasenote/releasenote.go @@ -0,0 +1,198 @@ +// Package releasenote is a deterministic reference extension that adds a +// conservative release-note evidence obligation to PR and merged goals. +package releasenote + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +const ( + ID = "boatstack.release-note" + Version = "1.0.0" + FactID = "boatstack.release-note.present" + Transition = "boatstack.release-note.verify" + Resource = "boatstack.release-note.evidence" + Effect = "boatstack.release-note.write-evidence" + Verifier = "boatstack.release-note.verify-evidence" +) + +type Extension struct{} + +func Definition() control.RuntimeExtension { return Extension{} } + +func (Extension) Runtime() control.ExtensionRuntime { return Extension{} } + +func (Extension) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { + transition := catalog.Transition{ + ID: Transition, Version: 1, Class: catalog.EventOwnedLocal, SelectionClass: catalog.SelectionGoalRequired, + SourcePhases: []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, TargetPhases: []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, + GoalKinds: []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id", "controller-id", "topology", "host", "correlation-id"}, + Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot-fingerprint", "goal", "facet:" + FactID}, + OwnedResources: []string{Resource}, Effect: Effect, LocalEffects: []catalog.EffectID{Effect}, Idempotent: true, + Prescription: catalog.Prescription{Operation: Transition, ExpectedPostcondition: "release-note evidence is verified"}, + SourcePredicate: "reference-release-note-missing", AdmissionPredicate: "exact-extension-admission", TargetPredicate: "reference-release-note-verified", Verifier: Verifier, + SourceConditions: []catalog.FacetCondition{ + known(model.FacetProgram, string(model.ProgramUnbound), string(model.ProgramCurrent)), + known(model.FacetPlan, string(model.PlanLocked)), + known(model.FacetVerification, string(model.VerificationCurrent)), + known(model.FacetName(FactID), "missing"), + }, + TargetConditions: []catalog.FacetCondition{known(model.FacetName(FactID), "verified")}, + Interruption: catalog.InterruptionContract{ + Points: []string{"after-stage", "after-effect", "before-receipt"}, PartialState: []string{"namespaced evidence may be installed"}, + Detection: "fresh extension observation", ResumeContract: "re-observe exact namespaced evidence", RollbackContract: "restore prior namespaced bytes", + CompensationContract: "not-required", Recovery: "recovery.escalate", RecoveryAuthority: "repository-policy", ResumptionPredicate: "program and extension evidence are current", + }, + Reversibility: catalog.Reversible, TerminalEffect: "conjunctive-goal-obligation", + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local-verification", + } + constraint := func(goal model.GoalKind) control.GoalConstraint { + return control.GoalConstraint{GoalKind: goal, Conditions: []catalog.FacetCondition{known(model.FacetName(FactID), "verified", "not-required")}} + } + return control.ExtensionManifest{ + ID: ID, Version: Version, ProtocolVersion: control.ExtensionProtocolVersion, + SettingsSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`), + Facts: []string{FactID}, Transitions: []control.Transition{transition}, + GoalConstraints: []control.GoalConstraint{constraint(model.GoalOpenPR), constraint(model.GoalMerged)}, + OwnedResources: []string{Resource}, Effects: []string{Effect}, Verifiers: []string{Verifier}, + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }, nil +} + +func (Extension) Invoke(_ context.Context, request control.ExtensionRequest) (control.ExtensionResponse, error) { + response := control.ExtensionResponse{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: request.Operation, + ExtensionID: ID, ExtensionVersion: Version, CorrelationID: request.CorrelationID, + } + switch request.Operation { + case control.ExtensionObserveOperation: + fact, err := observe(request.RepositoryRoot, request.ProgramFingerprint) + if err != nil { + return control.ExtensionResponse{}, err + } + response.Facts = []control.ExtensionFact{fact} + case control.ExtensionPlanLocalEffectOperation: + if request.TransitionID != Transition { + return control.ExtensionResponse{}, fmt.Errorf("release-note extension received an unknown transition") + } + content, err := evidenceBytes(request.RepositoryRoot, request.ProgramFingerprint) + if err != nil { + return control.ExtensionResponse{}, err + } + response.Writes = []control.ResourceWrite{{ + Resource: Resource, Path: evidencePath(request.RepositoryRoot), Content: content, SHA256: digest(content), Mode: 0o600, + }} + case control.ExtensionVerifyOperation: + fact, err := observe(request.RepositoryRoot, request.ProgramFingerprint) + if err != nil { + return control.ExtensionResponse{}, err + } + verified := fact.Status == model.FactKnown && fact.Value == "verified" + response.Verified = &verified + default: + return control.ExtensionResponse{}, fmt.Errorf("release-note extension does not support %q", request.Operation) + } + return response, nil +} + +type evidence struct { + SchemaVersion int `json:"schema_version"` + ReleaseNotesSHA256 string `json:"release_notes_sha256"` + ProgramFingerprint string `json:"program_fingerprint"` +} + +func observe(repository, programFingerprint string) (control.ExtensionFact, error) { + releaseDigest, relevant, err := releaseNotesDigest(repository) + if err != nil { + return control.ExtensionFact{}, err + } + if !relevant { + return control.ExtensionFact{ID: FactID, Status: model.FactKnown, Value: "not-required", Fingerprint: digest([]byte("not-required"))}, nil + } + raw, err := os.ReadFile(evidencePath(repository)) + if err != nil { + if os.IsNotExist(err) { + return control.ExtensionFact{ID: FactID, Status: model.FactKnown, Value: "missing", Fingerprint: releaseDigest}, nil + } + return control.ExtensionFact{}, err + } + var record evidence + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil || record.SchemaVersion != 1 || record.ReleaseNotesSHA256 != releaseDigest || record.ProgramFingerprint != programFingerprint { + return control.ExtensionFact{ID: FactID, Status: model.FactStale, Detail: "release-note evidence is stale", Fingerprint: digest(raw)}, nil + } + return control.ExtensionFact{ID: FactID, Status: model.FactKnown, Value: "verified", Fingerprint: digest(raw)}, nil +} + +func evidenceBytes(repository, programFingerprint string) ([]byte, error) { + releaseDigest, relevant, err := releaseNotesDigest(repository) + if err != nil { + return nil, err + } + if !relevant || len(programFingerprint) != 64 { + return nil, fmt.Errorf("release-note evidence requires relevant notes and exact program identity") + } + raw, err := json.MarshalIndent(evidence{SchemaVersion: 1, ReleaseNotesSHA256: releaseDigest, ProgramFingerprint: programFingerprint}, "", " ") + if err != nil { + return nil, err + } + return append(raw, '\n'), nil +} + +func releaseNotesDigest(repository string) (string, bool, error) { + entries, err := os.ReadDir(filepath.Join(repository, "release-notes")) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, err + } + var names []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { + names = append(names, entry.Name()) + } + } + if len(names) == 0 { + return "", false, nil + } + sort.Strings(names) + hash := sha256.New() + for _, name := range names { + raw, err := os.ReadFile(filepath.Join(repository, "release-notes", name)) + if err != nil { + return "", false, err + } + _, _ = hash.Write([]byte(name)) + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write(raw) + } + return hex.EncodeToString(hash.Sum(nil)), true, nil +} + +func evidencePath(repository string) string { + return filepath.Join(repository, ".boatstack", "extensions", ID, "evidence.json") +} + +func digest(raw []byte) string { + value := sha256.Sum256(raw) + return hex.EncodeToString(value[:]) +} + +func known(facet model.FacetName, values ...string) catalog.FacetCondition { + return catalog.FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: values} +} diff --git a/boatstack/extension/releasenote/releasenote_test.go b/boatstack/extension/releasenote/releasenote_test.go new file mode 100644 index 0000000..001c642 --- /dev/null +++ b/boatstack/extension/releasenote/releasenote_test.go @@ -0,0 +1,66 @@ +package releasenote + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/effects" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +const testProgram = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestReferenceExtensionPlansVerifiesAndInvalidatesNamespacedEvidence(t *testing.T) { + // control-law: release-note-obligation-is-evidenced-by-reversible-namespaced-bytes + repository := t.TempDir() + if err := os.MkdirAll(filepath.Join(repository, "release-notes"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "release-notes", "change.md"), []byte("### Change\n\nUser-facing behavior changed.\n"), 0o644); err != nil { + t.Fatal(err) + } + runtime := Extension{} + request := control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, ExtensionID: ID, ExtensionVersion: Version, + ProgramFingerprint: testProgram, CorrelationID: "reference", RepositoryRoot: repository, + } + request.Operation = control.ExtensionObserveOperation + observed, err := runtime.Invoke(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if len(observed.Facts) != 1 || observed.Facts[0].Status != model.FactKnown || observed.Facts[0].Value != "missing" { + t.Fatalf("initial fact = %#v", observed.Facts) + } + request.Operation, request.TransitionID = control.ExtensionPlanLocalEffectOperation, Transition + planned, err := runtime.Invoke(context.Background(), request) + if err != nil { + t.Fatal(err) + } + prepared, err := effects.NewExtensionLocalPrepared(repository, ID, planned.Writes) + if err != nil { + t.Fatal(err) + } + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + request.Operation = control.ExtensionVerifyOperation + verified, err := runtime.Invoke(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if verified.Verified == nil || !*verified.Verified { + t.Fatal("fresh reference evidence did not verify") + } + request.ProgramFingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + stale, err := runtime.Invoke(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if stale.Verified == nil || *stale.Verified { + t.Fatal("evidence from another ControlProgram was accepted") + } +} diff --git a/boatstack/extension/subprocess/subprocess.go b/boatstack/extension/subprocess/subprocess.go new file mode 100644 index 0000000..6c6a8a0 --- /dev/null +++ b/boatstack/extension/subprocess/subprocess.go @@ -0,0 +1,214 @@ +// Package subprocess implements Boatstack's strict language-neutral extension +// protocol. A subprocess extension is a trusted executable boundary, not an OS +// sandbox. +package subprocess + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/operatorstack/boatstack/boatstack/control" +) + +const maxRequestBytes = 1 << 20 + +type Config struct { + ID string + Version string + Executable string + SHA256 string + Settings json.RawMessage + Limits control.SubprocessLimits +} + +type Extension struct { + config Config +} + +func New(config Config) (*Extension, error) { + if config.ID == "" || config.Version == "" || !filepath.IsAbs(config.Executable) || len(config.SHA256) != 64 { + return nil, fmt.Errorf("subprocess extension requires id, version, absolute executable path, and SHA-256") + } + clean := filepath.Clean(config.Executable) + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return nil, fmt.Errorf("resolve subprocess extension executable: %w", err) + } + if resolved != clean { + return nil, fmt.Errorf("subprocess extension executable path must be exact and symlink-free") + } + config.Executable = clean + if config.Limits.Deadline == 0 { + config.Limits.Deadline = 5 * time.Second + } + if config.Limits.StdoutBytes == 0 { + config.Limits.StdoutBytes = 1 << 20 + } + if config.Limits.StderrBytes == 0 { + config.Limits.StderrBytes = 64 << 10 + } + if config.Limits.Deadline < time.Millisecond || config.Limits.Deadline > 30*time.Second || + config.Limits.StdoutBytes < 1 || config.Limits.StdoutBytes > 4<<20 || + config.Limits.StderrBytes < 1 || config.Limits.StderrBytes > 1<<20 { + return nil, fmt.Errorf("subprocess extension limits are outside the supported bounds") + } + extension := &Extension{config: config} + if err := extension.verifyExecutable(); err != nil { + return nil, err + } + return extension, nil +} + +func (e *Extension) Runtime() control.ExtensionRuntime { return e } + +func (e *Extension) ExtensionManifest(ctx context.Context) (control.ExtensionManifest, error) { + correlation := "manifest-" + e.config.SHA256[:16] + response, err := e.Invoke(ctx, control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: control.ExtensionManifestOperation, + ExtensionID: e.config.ID, ExtensionVersion: e.config.Version, CorrelationID: correlation, + }) + if err != nil { + return control.ExtensionManifest{}, err + } + if response.Manifest == nil { + return control.ExtensionManifest{}, fmt.Errorf("subprocess extension returned no manifest") + } + manifest := *response.Manifest + if manifest.ID != e.config.ID || manifest.Version != e.config.Version || manifest.ProtocolVersion != control.ExtensionProtocolVersion { + return control.ExtensionManifest{}, fmt.Errorf("subprocess extension manifest identity mismatch") + } + if manifest.ExecutableSHA256 != "" && manifest.ExecutableSHA256 != e.config.SHA256 { + return control.ExtensionManifest{}, fmt.Errorf("subprocess extension manifest executable fingerprint mismatch") + } + manifest.ExecutableSHA256 = e.config.SHA256 + manifest.Settings = append(json.RawMessage(nil), e.config.Settings...) + return manifest, nil +} + +func (e *Extension) Invoke(ctx context.Context, request control.ExtensionRequest) (control.ExtensionResponse, error) { + if request.ProtocolVersion != control.ExtensionProtocolVersion || request.ExtensionID != e.config.ID || request.ExtensionVersion != e.config.Version || request.CorrelationID == "" { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension request identity mismatch") + } + switch request.Operation { + case control.ExtensionManifestOperation, control.ExtensionObserveOperation, control.ExtensionPlanLocalEffectOperation, + control.ExtensionExecuteExternalOperation, control.ExtensionVerifyOperation, control.ExtensionRecoverOperation: + default: + return control.ExtensionResponse{}, fmt.Errorf("unsupported subprocess extension operation %q", request.Operation) + } + if err := e.verifyExecutable(); err != nil { + return control.ExtensionResponse{}, err + } + raw, err := json.Marshal(request) + if err != nil { + return control.ExtensionResponse{}, err + } + if len(raw) > maxRequestBytes { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension request exceeds 1 MiB") + } + deadlineContext, cancel := context.WithTimeout(ctx, e.config.Limits.Deadline) + defer cancel() + command := exec.CommandContext(deadlineContext, e.config.Executable) + command.Env = []string{"LANG=C", "LC_ALL=C"} + command.Stdin = bytes.NewReader(raw) + stdout := &boundedBuffer{limit: e.config.Limits.StdoutBytes, cancel: cancel} + stderr := &boundedBuffer{limit: e.config.Limits.StderrBytes, cancel: cancel} + command.Stdout, command.Stderr = stdout, stderr + if err := command.Run(); err != nil { + if stdout.exceeded || stderr.exceeded { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension output exceeded its bound") + } + if errors.Is(deadlineContext.Err(), context.DeadlineExceeded) { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension deadline exceeded") + } + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension process failed") + } + if stdout.exceeded || stderr.exceeded { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension output exceeded its bound") + } + var response control.ExtensionResponse + decoder := json.NewDecoder(bytes.NewReader(stdout.Bytes())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&response); err != nil { + return control.ExtensionResponse{}, fmt.Errorf("decode subprocess extension response: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension response contains trailing JSON") + } + if response.ProtocolVersion != control.ExtensionProtocolVersion || response.Operation != request.Operation || + response.ExtensionID != e.config.ID || response.ExtensionVersion != e.config.Version || response.CorrelationID != request.CorrelationID { + return control.ExtensionResponse{}, fmt.Errorf("subprocess extension response identity mismatch") + } + if err := control.ValidateExtensionOperationResponse(request.Operation, response); err != nil { + return control.ExtensionResponse{}, err + } + return response, nil +} + +func (e *Extension) verifyExecutable() error { + info, err := os.Lstat(e.config.Executable) + if err != nil { + return fmt.Errorf("stat subprocess extension executable: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("subprocess extension executable path drifted to a symlink") + } + if !info.Mode().IsRegular() { + return fmt.Errorf("subprocess extension executable is not a regular file") + } + resolved, err := filepath.EvalSymlinks(e.config.Executable) + if err != nil { + return fmt.Errorf("resolve subprocess extension executable: %w", err) + } + if resolved != e.config.Executable { + return fmt.Errorf("subprocess extension executable path must remain exact and symlink-free") + } + raw, err := os.ReadFile(e.config.Executable) + if err != nil { + return fmt.Errorf("read subprocess extension executable: %w", err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != e.config.SHA256 { + return fmt.Errorf("subprocess extension executable fingerprint drifted") + } + return nil +} + +type boundedBuffer struct { + buffer bytes.Buffer + limit int64 + exceeded bool + cancel context.CancelFunc +} + +func (b *boundedBuffer) Write(value []byte) (int, error) { + if int64(b.buffer.Len()+len(value)) > b.limit { + if !b.exceeded { + b.exceeded = true + if b.cancel != nil { + b.cancel() + } + } + remaining := int(b.limit) - b.buffer.Len() + if remaining > 0 { + _, _ = b.buffer.Write(value[:remaining]) + } + // Report the write as consumed while cancellation terminates the child. + // This keeps the os/exec copy goroutine deterministic and prevents an + // output-flooding process from running until the independent deadline. + return len(value), nil + } + return b.buffer.Write(value) +} + +func (b *boundedBuffer) Bytes() []byte { return b.buffer.Bytes() } diff --git a/boatstack/extension/subprocess/subprocess_test.go b/boatstack/extension/subprocess/subprocess_test.go new file mode 100644 index 0000000..1728673 --- /dev/null +++ b/boatstack/extension/subprocess/subprocess_test.go @@ -0,0 +1,231 @@ +package subprocess + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/control" +) + +func fixtureExtension(t *testing.T) *Extension { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the language-neutral Python fixture is exercised on POSIX; the Go protocol implementation remains platform-tested") + } + if _, err := os.Stat("/usr/bin/python3"); err != nil { + t.Skip("/usr/bin/python3 is unavailable") + } + path, err := filepath.Abs(filepath.Join("testdata", "reference_extension.py")) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + extension, err := New(Config{ + ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), + Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, + }) + if err != nil { + t.Fatal(err) + } + return extension +} + +func pythonFixture(t *testing.T, mutate func(string) string) *Extension { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the language-neutral Python fixture is exercised on POSIX") + } + raw, err := os.ReadFile(filepath.Join("testdata", "reference_extension.py")) + if err != nil { + t.Fatal(err) + } + content := []byte(mutate(string(raw))) + path := filepath.Join(exactPath(t, t.TempDir()), "extension.py") + if err := os.WriteFile(path, content, 0o700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + extension, err := New(Config{ + ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), + Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, + }) + if err != nil { + t.Fatal(err) + } + return extension +} + +func exactPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func TestPythonFixtureImplementsStrictLanguageNeutralProtocol(t *testing.T) { + // control-law: subprocess-extension-is-exact-bounded-and-environment-clean + t.Setenv("BOATSTACK_TEST_SECRET", "must-not-cross") + extension := fixtureExtension(t) + manifest, err := extension.ExtensionManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + if manifest.ID != "fixture.echo" || manifest.ExecutableSHA256 == "" || len(manifest.Facts) != 1 { + t.Fatalf("manifest = %#v", manifest) + } + response, err := extension.Invoke(context.Background(), control.ExtensionRequest{ + ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: "observe-fixture", + }) + if err != nil { + t.Fatal(err) + } + if len(response.Facts) != 1 || response.Facts[0].Value != "clean" { + t.Fatalf("subprocess inherited arbitrary environment or lost fact: %#v", response) + } +} + +func TestExecutableDriftFailsBeforeInvocation(t *testing.T) { + source, err := os.ReadFile(filepath.Join("testdata", "reference_extension.py")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(exactPath(t, t.TempDir()), "extension.py") + if err := os.WriteFile(path, source, 0o700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(source) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:])}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(source, []byte("\n# drift\n")...), 0o700); err != nil { + t.Fatal(err) + } + _, err = extension.Invoke(context.Background(), control.ExtensionRequest{ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: "drift"}) + if err == nil || !strings.Contains(err.Error(), "fingerprint drifted") { + t.Fatalf("drift error = %v", err) + } +} + +func TestExecutableCannotBecomeASymlinkAfterConstruction(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink replacement semantics are exercised on POSIX") + } + source, err := os.ReadFile(filepath.Join("testdata", "reference_extension.py")) + if err != nil { + t.Fatal(err) + } + directory := exactPath(t, t.TempDir()) + path := filepath.Join(directory, "extension.py") + replacement := filepath.Join(directory, "replacement.py") + if err := os.WriteFile(path, source, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(replacement, source, 0o700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(source) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:])}) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(replacement, path); err != nil { + t.Fatal(err) + } + _, err = extension.Invoke(context.Background(), control.ExtensionRequest{ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: "symlink-drift"}) + if err == nil || !strings.Contains(err.Error(), "drifted to a symlink") { + t.Fatalf("symlink drift error = %v", err) + } +} + +func TestDeadlineAndOutputBoundsFailClosed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX script fixtures are not executable on Windows") + } + for _, fixture := range []struct { + name, body, want string + deadline time.Duration + stdout int64 + }{ + {"deadline", "#!/bin/sh\nsleep 1\n", "deadline exceeded", 10 * time.Millisecond, 1024}, + {"stdout", "#!/bin/sh\nprintf '%02048d' 1\n", "output exceeded", 30 * time.Second, 64}, + } { + t.Run(fixture.name, func(t *testing.T) { + path := filepath.Join(exactPath(t, t.TempDir()), "fixture.sh") + if err := os.WriteFile(path, []byte(fixture.body), 0o700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(fixture.body)) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Limits: control.SubprocessLimits{Deadline: fixture.deadline, StdoutBytes: fixture.stdout, StderrBytes: 64}}) + if err != nil { + t.Fatal(err) + } + _, err = extension.Invoke(context.Background(), control.ExtensionRequest{ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: fixture.name}) + if err == nil || !strings.Contains(err.Error(), fixture.want) { + t.Fatalf("error = %v, want %q", err, fixture.want) + } + }) + } +} + +func TestBoundedBufferCancelsAtTheFirstOverflow(t *testing.T) { + // control-law: output-bound-observation-terminates-the-subprocess + cancelled := 0 + buffer := &boundedBuffer{limit: 4, cancel: func() { cancelled++ }} + value := []byte("overflow") + written, err := buffer.Write(value) + if err != nil { + t.Fatal(err) + } + if written != len(value) || !buffer.exceeded || cancelled != 1 || string(buffer.Bytes()) != "over" { + t.Fatalf("write = %d, exceeded = %v, cancelled = %d, bytes = %q", written, buffer.exceeded, cancelled, buffer.Bytes()) + } + if _, err := buffer.Write(value); err != nil { + t.Fatal(err) + } + if cancelled != 1 { + t.Fatalf("cancel called %d times, want once", cancelled) + } +} + +func TestStrictJSONRejectsUnknownTrailingAndWrongOperationPayloads(t *testing.T) { + // control-law: subprocess-response-is-strict-correlated-and-operation-typed + cases := map[string]func(string) string{ + "unknown-field": func(source string) string { + return strings.Replace(source, "json.dump(response, sys.stdout, separators=(\",\", \":\"))", "response[\"unknown_field\"] = True\njson.dump(response, sys.stdout, separators=(\",\", \":\"))", 1) + }, + "trailing-json": func(source string) string { + return strings.Replace(source, "json.dump(response, sys.stdout, separators=(\",\", \":\"))", "json.dump(response, sys.stdout, separators=(\",\", \":\"))\nprint(\"{}\", end=\"\")", 1) + }, + "wrong-payload": func(source string) string { + return strings.Replace(source, "elif operation == \"observe\":", "elif operation == \"observe\":\n response[\"verified\"] = True", 1) + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + extension := pythonFixture(t, mutate) + _, err := extension.Invoke(context.Background(), control.ExtensionRequest{ + ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: name, + }) + if err == nil { + t.Fatalf("%s response was accepted", name) + } + }) + } +} diff --git a/boatstack/extension/subprocess/testdata/reference_extension.py b/boatstack/extension/subprocess/testdata/reference_extension.py new file mode 100755 index 0000000..41943bd --- /dev/null +++ b/boatstack/extension/subprocess/testdata/reference_extension.py @@ -0,0 +1,44 @@ +#!/usr/bin/python3 +import json +import os +import sys + +request = json.load(sys.stdin) +operation = request["operation"] +response = { + "protocol_version": 1, + "operation": operation, + "extension_id": request["extension_id"], + "extension_version": request["extension_version"], + "correlation_id": request["correlation_id"], +} + +if operation == "manifest": + response["manifest"] = { + "id": "fixture.echo", + "version": "1.0.0", + "protocol_version": 1, + "settings_schema": {"type": "object"}, + "facts": ["fixture.echo.present"], + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + } +elif operation == "observe": + value = "leaked" if "BOATSTACK_TEST_SECRET" in os.environ else "clean" + response["facts"] = [{ + "id": "fixture.echo.present", + "status": "known", + "value": value, + "fingerprint": "fixture-observation", + }] +elif operation == "verify": + response["verified"] = True +elif operation in ("plan-local-effect", "recover"): + response["writes"] = [] +elif operation == "execute-external": + response["external_result"] = {"settlement": "settled"} +else: + response["error_class"] = "unsupported-operation" + response["error"] = "unsupported" + +json.dump(response, sys.stdout, separators=(",", ":")) diff --git a/boatstack/internal/kernel/catalog/completeness_test.go b/boatstack/flow/standard/completeness_test.go similarity index 66% rename from boatstack/internal/kernel/catalog/completeness_test.go rename to boatstack/flow/standard/completeness_test.go index 6673487..85a1383 100644 --- a/boatstack/internal/kernel/catalog/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -1,4 +1,4 @@ -package catalog +package standard_test import ( "go/ast" @@ -12,7 +12,9 @@ import ( "strings" "testing" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func sourceRoot(t *testing.T) string { @@ -21,24 +23,24 @@ func sourceRoot(t *testing.T) string { if !ok { t.Fatal("cannot locate source inventory") } - return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing.T) { // control-law: runtime-catalog-is-the-complete-executable-model - registry := Default() + registry := testprogram.StandardRegistry() classified := map[model.FacetName]bool{model.FacetPhase: true} families := map[string]int{} for _, transition := range registry.All() { - for _, condition := range append(append([]FacetCondition(nil), transition.SourceConditions...), transition.TargetConditions...) { + for _, condition := range append(append([]catalog.FacetCondition(nil), transition.SourceConditions...), transition.TargetConditions...) { classified[condition.Facet] = true } family := familyFor(transition.ID) families[family]++ - if transition.Controllable() && transition.Effect != EffectID(transition.ID) { + if transition.Controllable() && transition.Effect != catalog.EffectID(transition.ID) { t.Errorf("transition %s effect=%s; effect identity must be exact", transition.ID, transition.Effect) } - if transition.Class == EventOwnedExternal && !containsAuthority(transition.AuthorityAll, AuthorityProvider) { + if transition.Class == catalog.EventOwnedExternal && !containsAuthority(transition.AuthorityAll, catalog.AuthorityProvider) { t.Errorf("external transition %s does not require provider authority", transition.ID) } } @@ -47,7 +49,7 @@ func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing t.Errorf("controlling facet %s is absent from executable predicates", facet) } } - want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 8, "goal-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} + want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 8, "catalog": 1, "goal-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} for family, count := range want { if families[family] != count { t.Errorf("family %s=%d, want %d", family, families[family], count) @@ -55,7 +57,7 @@ func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing } } -func containsAuthority(values []AuthorityClass, wanted AuthorityClass) bool { +func containsAuthority(values []catalog.AuthorityClass, wanted catalog.AuthorityClass) bool { for _, value := range values { if value == wanted { return true @@ -64,13 +66,15 @@ func containsAuthority(values []AuthorityClass, wanted AuthorityClass) bool { return false } -func familyFor(id TransitionID) string { +func familyFor(id catalog.TransitionID) string { value := string(id) switch { case strings.HasPrefix(value, "engagement."), strings.HasPrefix(value, "invocation."), strings.HasPrefix(value, "repository."): return "invocation-engagement" case strings.HasPrefix(value, "installation."), strings.HasPrefix(value, "runtime."), strings.HasPrefix(value, "configuration."): return "installation-runtime-configuration" + case strings.HasPrefix(value, "catalog."): + return "catalog" case strings.HasPrefix(value, "goal."), strings.HasPrefix(value, "plan."): return "goal-plan" case strings.HasPrefix(value, "workspace."): @@ -142,12 +146,13 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t t.Errorf("managed writer os.%s escaped effects package in %s", selector.Sel.Name, relative) } if importPath == "os/exec" && (selector.Sel.Name == "Command" || selector.Sel.Name == "CommandContext") { - if relative != "internal/effects/command_boundary.go" && relative != "internal/plant/resolver.go" { + if relative != "internal/effects/command_boundary.go" && relative != "internal/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" { t.Errorf("unclassified command boundary in %s", relative) } } if strings.HasSuffix(importPath, "/internal/kernel/model") && lifecycleSelector(selector.Sel.Name) && - !strings.HasPrefix(relative, "internal/kernel/") && !strings.HasPrefix(relative, "internal/effects/") && !strings.HasPrefix(relative, "internal/plant/") { + !strings.HasPrefix(relative, "internal/kernel/") && !strings.HasPrefix(relative, "internal/effects/") && !strings.HasPrefix(relative, "internal/plant/") && + !strings.HasPrefix(relative, "control/") && !strings.HasPrefix(relative, "flow/") && !strings.HasPrefix(relative, "extension/") { t.Errorf("lifecycle selector model.%s escaped kernel/plant/effects in %s", selector.Sel.Name, relative) } return true @@ -169,16 +174,16 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) { // control-law: registry-entry-cannot-exist-without-runtime-effect-reduction - path := filepath.Join(sourceRoot(t), "internal", "kernel", "reducer", "reducer.go") + path := filepath.Join(sourceRoot(t), "internal", "effects", "state_reducer.go") parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) if err != nil { t.Fatal(err) } - covered := map[TransitionID]bool{} - registry := Default() + covered := map[catalog.TransitionID]bool{} + registry := testprogram.StandardRegistry() for _, declaration := range parsed.Decls { function, ok := declaration.(*ast.FuncDecl) - if !ok || function.Name.Name != "Apply" { + if !ok || function.Name.Name != "applyStateTransition" { continue } ast.Inspect(function.Body, func(node ast.Node) bool { @@ -195,7 +200,7 @@ func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) if unquoteErr != nil { t.Fatal(unquoteErr) } - id := TransitionID(value) + id := catalog.TransitionID(value) if transition, exists := registry.Lookup(id); exists && transition.Controllable() { covered[id] = true } @@ -210,11 +215,59 @@ func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) } } +func TestPackageImportsPreserveControlProgramDependencyDirection(t *testing.T) { + // control-law: kernel-mechanism-cannot-depend-on-standard-flow-or-product-surfaces + root := sourceRoot(t) + forbiddenKernel := []string{"/flow/standard", "/distribution", "/sdk", "/cmd/boatstack-helper"} + forbiddenFlow := []string{"/distribution", "/sdk", "/cmd/boatstack-helper", "/internal/surfaces"} + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + kernelOwned := relative == "kernel.go" || relative == "program_effects.go" || relative == "program_observer.go" || strings.HasPrefix(relative, "internal/kernel/") + flowOwned := strings.HasPrefix(relative, "flow/") + if !kernelOwned && !flowOwned { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, item := range parsed.Imports { + value, _ := strconv.Unquote(item.Path.Value) + for _, forbidden := range forbiddenKernel { + if kernelOwned && strings.Contains(value, forbidden) { + t.Errorf("kernel mechanism %s imports forbidden layer %s", relative, value) + } + } + for _, forbidden := range forbiddenFlow { + if flowOwned && strings.Contains(value, forbidden) { + t.Errorf("primary flow %s imports forbidden surface %s", relative, value) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + func classifiedProductionFile(relative string) bool { - return relative == "v2_kernel.go" || strings.HasPrefix(relative, "cmd/boatstack-helper/") || + return relative == "kernel.go" || relative == "program_effects.go" || relative == "program_observer.go" || strings.HasPrefix(relative, "cmd/boatstack-helper/") || + strings.HasPrefix(relative, "control/") || strings.HasPrefix(relative, "core/") || + strings.HasPrefix(relative, "flow/") || strings.HasPrefix(relative, "distribution/") || strings.HasPrefix(relative, "extension/") || strings.HasPrefix(relative, "internal/kernel/") || strings.HasPrefix(relative, "internal/plant/") || strings.HasPrefix(relative, "internal/effects/") || strings.HasPrefix(relative, "internal/surfaces/") || - strings.HasPrefix(relative, "internal/retromine/") || strings.HasPrefix(relative, "sdk/") || + strings.HasPrefix(relative, "internal/retromine/") || strings.HasPrefix(relative, "internal/testprogram/") || strings.HasPrefix(relative, "sdk/") || strings.HasPrefix(relative, "analysis/") } diff --git a/boatstack/internal/kernel/catalog/historical_test.go b/boatstack/flow/standard/historical_test.go similarity index 93% rename from boatstack/internal/kernel/catalog/historical_test.go rename to boatstack/flow/standard/historical_test.go index ef5c557..f4c5b8b 100644 --- a/boatstack/internal/kernel/catalog/historical_test.go +++ b/boatstack/flow/standard/historical_test.go @@ -1,6 +1,7 @@ -package catalog_test +package standard_test import ( + "context" "encoding/json" "os" "path/filepath" @@ -11,11 +12,25 @@ import ( "testing" "time" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) +func historicalGoalContracts() catalog.GoalContracts { + manifest, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + panic(err) + } + contracts, err := catalog.NewGoalContracts(manifest.GoalContracts, nil) + if err != nil { + panic(err) + } + return contracts +} + type historicalCorpus struct { SchemaVersion int `json:"schema_version"` FixtureCount int `json:"fixture_count"` @@ -39,7 +54,7 @@ type historicalFixture struct { func loadHistoricalCorpus(t *testing.T) historicalCorpus { t.Helper() - raw, err := os.ReadFile("../../../testdata/v2-scenarios/historical.json") + raw, err := os.ReadFile("../../testdata/v2-scenarios/historical.json") if err != nil { t.Fatal(err) } @@ -100,8 +115,8 @@ func authoritySet(values []catalog.AuthorityClass) catalog.AuthoritySet { func TestHistoricalFailureCorpusUsesTheRuntimeControlLaw(t *testing.T) { // control-law: historical failures bind to executable catalog predicates corpus := loadHistoricalCorpus(t) - registry := catalog.Default() - control := supervisor.New(registry) + registry := testprogram.StandardRegistry() + control := supervisor.New(registry, historicalGoalContracts()) seenNames := map[string]bool{} for _, fixture := range corpus.Fixtures { fixture := fixture diff --git a/boatstack/flow/standard/standard.go b/boatstack/flow/standard/standard.go new file mode 100644 index 0000000..a2df7ca --- /dev/null +++ b/boatstack/flow/standard/standard.go @@ -0,0 +1,112 @@ +// Package standard owns Boatstack's first-party opinionated software-delivery +// flow. It has no CLI, SDK, host-rendering, journal, or receipt authority. +package standard + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +const ( + ID = "boatstack.standard" + Version = "1.0.0" +) + +type definition struct{} + +//go:embed transitions.json +var transitionDeclarations []byte + +func Definition() control.FlowDefinition { return definition{} } + +func (definition) FlowManifest(context.Context) (control.PrimaryFlowManifest, error) { + transitions, err := decodeTransitions() + if err != nil { + return control.PrimaryFlowManifest{}, err + } + resources, effects, verifiers, recoveries := declarations(transitions) + return control.PrimaryFlowManifest{ + ID: ID, Version: Version, ProtocolVersion: control.FlowProtocolVersion, RuntimeMode: control.FlowRuntimeNative, + SupportedGoals: []control.GoalKind{ + model.GoalApprovedPlan, model.GoalVerified, model.GoalOpenPR, + model.GoalMerged, model.GoalAbandoned, + }, + GoalContracts: []control.GoalContract{ + contract(model.GoalApprovedPlan, + known(model.FacetPlan, string(model.PlanApproved))), + contract(model.GoalVerified, + known(model.FacetVerification, string(model.VerificationCurrent)), + known(model.FacetConfiguration, string(model.ConfigurationVerified)), + known(model.FacetRuntime, string(model.RuntimeVerified)), + known(model.FacetDelivery, string(model.DeliveryTerminal))), + contract(model.GoalOpenPR, + known(model.FacetVerification, string(model.VerificationCurrent)), + known(model.FacetConfiguration, string(model.ConfigurationVerified)), + known(model.FacetRuntime, string(model.RuntimeVerified)), + known(model.FacetPublication, string(model.PublicationOpen))), + contract(model.GoalMerged, + known(model.FacetPublication, string(model.PublicationMerged)), + known(model.FacetDelivery, string(model.DeliveryTerminal)), + known(model.FacetWorkspace, string(model.WorkspaceLanded), string(model.WorkspaceAbsent))), + contract(model.GoalAbandoned, + known(model.FacetDelivery, string(model.DeliveryDiscarded)), + known(model.FacetWorkspace, string(model.WorkspaceAbandoned), string(model.WorkspaceAbsent))), + }, + Facts: []string{"plan", "workspace", "delivery", "verification", "publication"}, + Transitions: transitions, OwnedResources: resources, Effects: effects, Verifiers: verifiers, RecoveryTransitions: recoveries, + Settings: json.RawMessage(`{}`), ConfigurationSchema: json.RawMessage(`{"type":"object"}`), + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }, nil +} + +func decodeTransitions() ([]control.Transition, error) { + decoder := json.NewDecoder(bytes.NewReader(transitionDeclarations)) + decoder.DisallowUnknownFields() + var transitions []control.Transition + if err := decoder.Decode(&transitions); err != nil { + return nil, fmt.Errorf("decode StandardFlow transitions: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, fmt.Errorf("StandardFlow transition declarations contain trailing JSON") + } + return transitions, nil +} + +func declarations(transitions []control.Transition) ([]string, []string, []string, []control.TransitionID) { + var resources, effects, verifiers []string + var recoveries []control.TransitionID + seenResources, seenEffects, seenVerifiers, seenRecoveries := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[control.TransitionID]bool{} + for _, transition := range transitions { + for _, resource := range transition.OwnedResources { + if !seenResources[resource] { + seenResources[resource], resources = true, append(resources, resource) + } + } + if transition.Effect != "" && !seenEffects[string(transition.Effect)] { + seenEffects[string(transition.Effect)], effects = true, append(effects, string(transition.Effect)) + } + if transition.Verifier != "" && !seenVerifiers[transition.Verifier] { + seenVerifiers[transition.Verifier], verifiers = true, append(verifiers, transition.Verifier) + } + if transition.Class == control.EventRecovery && !seenRecoveries[transition.ID] { + seenRecoveries[transition.ID], recoveries = true, append(recoveries, transition.ID) + } + } + return resources, effects, verifiers, recoveries +} + +func contract(goal model.GoalKind, conditions ...control.FacetCondition) control.GoalContract { + return control.GoalContract{GoalKind: goal, Conditions: conditions} +} + +func known(facet model.FacetName, values ...string) control.FacetCondition { + return control.FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: values} +} diff --git a/boatstack/flow/standard/standard_test.go b/boatstack/flow/standard/standard_test.go new file mode 100644 index 0000000..42fa4d3 --- /dev/null +++ b/boatstack/flow/standard/standard_test.go @@ -0,0 +1,48 @@ +package standard_test + +import ( + "context" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/flow/standard" +) + +func TestManifestOwnsOnlyStandardDeliverySemantics(t *testing.T) { + // control-law: standard-flow-declarations-live-outside-kernel-mechanism + manifest, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + if manifest.ID != standard.ID || manifest.Version != standard.Version || len(manifest.Transitions) != 30 { + t.Fatalf("StandardFlow identity/count = %s@%s/%d", manifest.ID, manifest.Version, len(manifest.Transitions)) + } + for _, transition := range manifest.Transitions { + id := string(transition.ID) + if !hasPrefix(id, "plan.", "workspace.", "gate.", "evidence.", "delivery.", "publication.") { + t.Errorf("StandardFlow owns non-delivery transition %s", id) + } + } + if len(manifest.GoalContracts) != 5 { + t.Fatalf("goal contracts = %d, want 5", len(manifest.GoalContracts)) + } + for _, goal := range []control.GoalKind{control.GoalApprovedPlan, control.GoalVerified, control.GoalOpenPR, control.GoalMerged, control.GoalAbandoned} { + found := false + for _, contract := range manifest.GoalContracts { + found = found || contract.GoalKind == goal + } + if !found { + t.Errorf("missing goal contract %s", goal) + } + } +} + +func hasPrefix(value string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} diff --git a/boatstack/internal/kernel/supervisor/supervisor_test.go b/boatstack/flow/standard/supervisor_parity_test.go similarity index 78% rename from boatstack/internal/kernel/supervisor/supervisor_test.go rename to boatstack/flow/standard/supervisor_parity_test.go index 71dd1f6..4b0b3f0 100644 --- a/boatstack/internal/kernel/supervisor/supervisor_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -1,14 +1,30 @@ -package supervisor +package standard_test import ( + "context" "path/filepath" "testing" "time" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + . "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) +func testGoalContracts() catalog.GoalContracts { + manifest, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + panic(err) + } + contracts, err := catalog.NewGoalContracts(manifest.GoalContracts, nil) + if err != nil { + panic(err) + } + return contracts +} + func snapshotFor(t *testing.T, phase model.ProtocolPhase, terminal model.TerminalStatus) model.Snapshot { t.Helper() e := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()} @@ -73,7 +89,7 @@ func openPRSnapshot(t *testing.T, recordedGates ...string) (model.Snapshot, mode func TestTerminalGoalOutranksLocalTransitions(t *testing.T) { // control-law: configured-terminal-outranks-local-lifecycle - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") if decision.Kind != DecisionTerminal || decision.Transition != nil { t.Fatalf("decision = %#v, want terminal without transition", decision) @@ -89,7 +105,7 @@ func TestExplicitPostTerminalCleanupRemainsAdmissible(t *testing.T) { if err != nil { t.Fatal(err) } - decision := New(catalog.Default()).Resolve(canonical, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "workspace.cleanup") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(canonical, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "workspace.cleanup") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "workspace.cleanup" { t.Fatalf("decision = %#v, want explicit post-terminal cleanup", decision) } @@ -97,7 +113,7 @@ func TestExplicitPostTerminalCleanupRemainsAdmissible(t *testing.T) { func TestTerminalEvidenceForOldGoalDoesNotTerminateNewGoal(t *testing.T) { // control-law: terminal-evidence-is-bound-to-exact-goal-not-local-phase - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) newGoal := model.Goal{ID: "next-goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"} decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), newGoal, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "goal.configure") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "goal.configure" { @@ -111,14 +127,14 @@ func TestUntargetedResolutionReconfiguresDifferentGoalAndSkipsSatisfiedGoal(t *t authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} newGoal := model.Goal{ID: "new-goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"} - decision := New(catalog.Default()).Resolve(snapshot, newGoal, authority, "") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, newGoal, authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "goal.configure" { t.Fatalf("different-goal decision = %#v, want goal.configure", decision) } snapshot.Plan = model.Known(model.PlanValid, snapshot.Plan.Evidence[0]) snapshot = recanonicalize(t, snapshot) - decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "") + decision = New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "plan.approve" { t.Fatalf("exact-goal decision = %#v, want plan.approve without goal.configure stutter", decision) } @@ -132,20 +148,46 @@ func TestUntargetedResolutionExcludesExplicitControlTransitions(t *testing.T) { snapshot = recanonicalize(t, snapshot) authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} - decision := New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.build.record" { t.Fatalf("untargeted decision = %#v, want gate.build.record", decision) } - decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "plan.invalidate") + decision = New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), authority, "plan.invalidate") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "plan.invalidate" { t.Fatalf("explicit invalidation decision = %#v, want requested plan.invalidate", decision) } - decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "delivery.slice.advance") + decision = New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), authority, "delivery.slice.advance") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "delivery.slice.advance" { t.Fatalf("explicit slice-marker decision = %#v, want requested delivery.slice.advance", decision) } } +func TestSelectionClassOutranksComponentLocalPriority(t *testing.T) { + // control-law: bounded-selection-class-prevents-lower-layer-priority-inversion + transitions := testprogram.StandardRegistry().All() + for index := range transitions { + switch transitions[index].ID { + case "gate.build.record": + transitions[index].SelectionClass = catalog.SelectionGoalRequired + transitions[index].Priority = 999 + case "gate.test.record": + transitions[index].SelectionClass = catalog.SelectionFlowProgress + transitions[index].Priority = 1 + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) + snapshot.Plan = model.Known(model.PlanLocked, snapshot.Plan.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + decision := New(registry, testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.build.record" { + t.Fatalf("decision = %#v, want higher selection class despite lower-layer numeric priority", decision) + } +} + func TestUntargetedResolutionUsesCurrentGateEvidenceForProgress(t *testing.T) { // control-law: verified-gate-progress-is-derived-from-canonical-evidence authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} @@ -153,7 +195,7 @@ func TestUntargetedResolutionUsesCurrentGateEvidenceForProgress(t *testing.T) { snapshot.Publication = model.Known(model.PublicationNone, snapshot.Publication.Evidence[0]) snapshot = recanonicalize(t, snapshot) - decision := New(catalog.Default()).Resolve(snapshot, goal, authority, "") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goal, authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.test.record" { t.Fatalf("one-gate decision = %#v, want gate.test.record", decision) } @@ -161,7 +203,7 @@ func TestUntargetedResolutionUsesCurrentGateEvidenceForProgress(t *testing.T) { snapshot, goal = openPRSnapshot(t, "build", "test", "review") snapshot.Publication = model.Known(model.PublicationNone, snapshot.Publication.Evidence[0]) snapshot = recanonicalize(t, snapshot) - decision = New(catalog.Default()).Resolve(snapshot, goal, authority, "") + decision = New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goal, authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.preview" { t.Fatalf("complete-gates decision = %#v, want publication.preview", decision) } @@ -170,7 +212,7 @@ func TestUntargetedResolutionUsesCurrentGateEvidenceForProgress(t *testing.T) { func TestUntargetedResolutionStopsAtSelectedProviderBoundary(t *testing.T) { // control-law: unavailable-authority-cannot-be-skipped-for-a-lower-priority-effect snapshot, goal := openPRSnapshot(t, "build", "test", "review") - supervisor := New(catalog.Default()) + supervisor := New(testprogram.StandardRegistry(), testGoalContracts()) authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} decision := supervisor.Resolve(snapshot, goal, authority, "") @@ -186,7 +228,7 @@ func TestUntargetedResolutionStopsAtSelectedProviderBoundary(t *testing.T) { func TestRequestedTransitionRequiresExactAuthority(t *testing.T) { // control-law: useful-action-is-not-effect-authority - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) decision := s.Resolve(snapshotFor(t, model.PhaseActive, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "plan.approve") if decision.Kind != DecisionFrontier { t.Fatalf("decision = %s, want FRONTIER", decision.Kind) @@ -196,7 +238,7 @@ func TestRequestedTransitionRequiresExactAuthority(t *testing.T) { func TestPlanApprovalPolicyDistinguishesHumanFromAutonomyAuthority(t *testing.T) { snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) autonomy := catalog.AuthoritySet{catalog.AuthorityAutonomy: true} - decision := New(catalog.Default()).Resolve(snapshot, goalFor(), autonomy, "plan.approve") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), autonomy, "plan.approve") if decision.Kind != DecisionFrontier { t.Fatalf("human-only policy decision = %#v, want FRONTIER", decision) } @@ -205,7 +247,7 @@ func TestPlanApprovalPolicyDistinguishesHumanFromAutonomyAuthority(t *testing.T) if err != nil { t.Fatal(err) } - decision = New(catalog.Default()).Resolve(snapshot, goalFor(), autonomy, "plan.approve") + decision = New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), autonomy, "plan.approve") if decision.Kind != DecisionPrescribed { t.Fatalf("autonomy-enabled policy decision = %#v, want PRESCRIBED", decision) } @@ -218,7 +260,7 @@ func TestDisabledHostCannotRequestManagedTransition(t *testing.T) { if err != nil { t.Fatal(err) } - decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "plan.approve") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "plan.approve") if decision.Kind != DecisionRefused { t.Fatalf("disabled host decision = %#v, want REFUSED", decision) } @@ -233,7 +275,7 @@ func TestHighRiskReviewPolicyRequiresHumanAuthority(t *testing.T) { if err != nil { t.Fatal(err) } - supervisor := New(catalog.Default()) + supervisor := New(testprogram.StandardRegistry(), testGoalContracts()) decision := supervisor.Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "gate.review.record") if decision.Kind != DecisionFrontier { t.Fatalf("repository-only high-risk review = %#v, want FRONTIER", decision) @@ -251,7 +293,7 @@ func TestVisualEvidenceOffRefusesAttachment(t *testing.T) { if err != nil { t.Fatal(err) } - decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "evidence.visual.attach") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "evidence.visual.attach") if decision.Kind != DecisionRefused { t.Fatalf("visual-off decision = %#v, want REFUSED", decision) } @@ -259,7 +301,7 @@ func TestVisualEvidenceOffRefusesAttachment(t *testing.T) { func TestRecoveryModeOnlyPrescribesRecoveryTransition(t *testing.T) { // control-law: recovery-outranks-slice-position - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) decision := s.Resolve(snapshotFor(t, model.PhaseRecovery, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "recovery.resume") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.Class != catalog.EventRecovery { t.Fatalf("decision = %#v, want a recovery prescription", decision) @@ -268,7 +310,7 @@ func TestRecoveryModeOnlyPrescribesRecoveryTransition(t *testing.T) { func TestRecoveryModeRejectsARecoveryEventOutsideExactJournalContract(t *testing.T) { snapshot := snapshotFor(t, model.PhaseRecovery, model.TerminalNonterminal) - decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "recovery.rollback") + decision := New(testprogram.StandardRegistry(), testGoalContracts()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "recovery.rollback") if decision.Kind != DecisionRefused { t.Fatalf("unpermitted recovery decision = %#v, want REFUSED", decision) } @@ -276,7 +318,7 @@ func TestRecoveryModeRejectsARecoveryEventOutsideExactJournalContract(t *testing func TestUncontrollableEventCannotBeRequested(t *testing.T) { // control-law: surfaces-cannot-assert-external-facts - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) decision := s.Resolve(snapshotFor(t, model.PhaseActive, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "external.pr-merged") if decision.Kind != DecisionRefused { t.Fatalf("decision = %s, want REFUSED", decision.Kind) @@ -284,13 +326,13 @@ func TestUncontrollableEventCannotBeRequested(t *testing.T) { } func TestGuardDeniesDestructionAndRoutesManagedBypassThroughAdmission(t *testing.T) { - s := New(catalog.Default()) + s := New(testprogram.StandardRegistry(), testGoalContracts()) snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) destructive := s.Guard(snapshot, CommandIntent{Class: IntentDestructive, Operation: "git.reset-hard", Fingerprint: "fingerprint"}) if destructive.Allowed { t.Fatal("destructive command was allowed") } - managed := s.Guard(snapshot, CommandIntent{Class: IntentManagedBypass, Operation: "publication.push", Fingerprint: "fingerprint", Transition: "publication.execute"}) + managed := s.Guard(snapshot, CommandIntent{Class: IntentManagedBypass, Operation: "publication.push", Fingerprint: "fingerprint"}) if managed.Allowed || managed.RequiredTransition != "publication.execute" { t.Fatalf("managed bypass decision = %#v", managed) } diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json new file mode 100644 index 0000000..49be596 --- /dev/null +++ b/boatstack/flow/standard/transitions.json @@ -0,0 +1,6192 @@ +[ + { + "id": "plan.create", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "plan" + ], + "effect": "plan.create", + "local_effects": [ + "plan.create" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_path", + "required": true, + "secret": false + }, + { + "name": "delivery_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "plan.create", + "expected_postcondition": "predicate:target-phase:plan.create" + }, + "source_predicate": "predicate:source-phase:plan.create", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "absent", + "invalid", + "stale" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.create", + "target_predicate": "predicate:target-phase:plan.create", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "draft" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.create", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.create" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 35 + }, + { + "id": "plan.validate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "plan-evidence" + ], + "effect": "plan.validate", + "local_effects": [ + "plan.validate" + ], + "idempotent": true, + "prescription": { + "operation": "plan.validate", + "expected_postcondition": "predicate:target-phase:plan.validate" + }, + "source_predicate": "predicate:source-phase:plan.validate", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "draft" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.validate", + "target_predicate": "predicate:target-phase:plan.validate", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "valid" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.validate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.validate" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 40 + }, + { + "id": "plan.approve", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "authority", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime", + "facet:configuration-policy" + ], + "owned_resources": [ + "approval" + ], + "effect": "plan.approve", + "local_effects": [ + "plan.approve" + ], + "idempotent": true, + "parameters": [ + { + "name": "plan_fingerprint", + "required": true, + "secret": false + }, + { + "name": "actor", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "plan.approve", + "expected_postcondition": "predicate:target-phase:plan.approve" + }, + "source_predicate": "predicate:source-phase:plan.approve", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "valid" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "configuration-policy", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.approve", + "target_predicate": "predicate:target-phase:plan.approve", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "approved" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.approve", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.approve" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "authority_rule": "plan-approval" + }, + "priority": 45 + }, + { + "id": "plan.activate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "verified-implementation", + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "delivery-state" + ], + "effect": "plan.activate", + "local_effects": [ + "plan.activate" + ], + "idempotent": true, + "prescription": { + "operation": "plan.activate", + "expected_postcondition": "predicate:target-phase:plan.activate" + }, + "source_predicate": "predicate:source-phase:plan.activate", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "approved" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.activate", + "target_predicate": "predicate:target-phase:plan.activate", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.activate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.activate" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 50 + }, + { + "id": "plan.amend", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "plan" + ], + "effect": "plan.amend", + "local_effects": [ + "plan.amend" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_path", + "required": true, + "secret": false + }, + { + "name": "delivery_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "plan.amend", + "expected_postcondition": "predicate:target-phase:plan.amend" + }, + "source_predicate": "predicate:source-phase:plan.amend", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "approved", + "locked", + "stale", + "amendment-required" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.amend", + "target_predicate": "predicate:target-phase:plan.amend", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "amendment-required" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.amend", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.amend" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 42 + }, + { + "id": "plan.approve-amendment", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "authority", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime", + "facet:configuration-policy" + ], + "owned_resources": [ + "approval" + ], + "effect": "plan.approve-amendment", + "local_effects": [ + "plan.approve-amendment" + ], + "idempotent": true, + "parameters": [ + { + "name": "plan_fingerprint", + "required": true, + "secret": false + }, + { + "name": "actor", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "plan.approve-amendment", + "expected_postcondition": "predicate:target-phase:plan.approve-amendment" + }, + "source_predicate": "predicate:source-phase:plan.approve-amendment", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "amendment-required" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "configuration-policy", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.approve-amendment", + "target_predicate": "predicate:target-phase:plan.approve-amendment", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "approved" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.approve-amendment", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.approve-amendment" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "authority_rule": "plan-approval" + }, + "priority": 46 + }, + { + "id": "plan.invalidate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE", + "OBSERVED" + ], + "target_phases": [ + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "plan-evidence" + ], + "effect": "plan.invalidate", + "local_effects": [ + "plan.invalidate" + ], + "idempotent": true, + "prescription": { + "operation": "plan.invalidate", + "expected_postcondition": "predicate:target-phase:plan.invalidate" + }, + "source_predicate": "predicate:source-phase:plan.invalidate", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "draft", + "valid", + "approved", + "locked", + "stale" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.invalidate", + "target_predicate": "predicate:target-phase:plan.invalidate", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "invalid" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.invalidate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.invalidate" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 41 + }, + { + "id": "plan.abandon", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "authority", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ABANDONED" + ], + "goal_kinds": [ + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:delivery", + "facet:program", + "facet:terminal", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "plan" + ], + "effect": "plan.abandon", + "local_effects": [ + "plan.abandon" + ], + "idempotent": true, + "prescription": { + "operation": "plan.abandon", + "expected_postcondition": "predicate:target-phase:plan.abandon" + }, + "source_predicate": "predicate:source-phase:plan.abandon", + "source_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "uninitialized", + "planning", + "approved", + "active", + "gates-passed", + "published", + "amendment", + "invalid", + "recovery" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:plan.abandon", + "target_predicate": "predicate:target-phase:plan.abandon", + "target_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "discarded" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:plan.abandon", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:plan.abandon" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-safe-abandonment", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 90 + }, + { + "id": "workspace.cut", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.cut", + "local_effects": [ + "workspace.cut" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + }, + { + "name": "base_ref", + "required": true, + "secret": false + }, + { + "name": "destination", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.cut", + "expected_postcondition": "predicate:target-phase:workspace.cut" + }, + "source_predicate": "predicate:source-phase:workspace.cut", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "absent" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.cut", + "target_predicate": "predicate:target-phase:workspace.cut", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "cut" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.cut", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "workspace.reconcile", + "recovery_authority": "declared-by:workspace.reconcile", + "resumption_predicate": "recovery-contract-for:workspace.cut" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 52, + "allows_worktree_transfer": true + }, + { + "id": "workspace.sync", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.sync", + "local_effects": [ + "workspace.sync" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.sync", + "expected_postcondition": "predicate:target-phase:workspace.sync" + }, + "source_predicate": "predicate:source-phase:workspace.sync", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "cut", + "active" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.sync", + "target_predicate": "predicate:target-phase:workspace.sync", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.sync", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:workspace.sync" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 58 + }, + { + "id": "workspace.activate", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.activate", + "local_effects": [ + "workspace.activate" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.activate", + "expected_postcondition": "predicate:target-phase:workspace.activate" + }, + "source_predicate": "predicate:source-phase:workspace.activate", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "cut", + "active" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.activate", + "target_predicate": "predicate:target-phase:workspace.activate", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.activate", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:workspace.activate" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 53 + }, + { + "id": "workspace.publish", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace-state" + ], + "effect": "workspace.publish", + "local_effects": [ + "workspace.publish" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.publish", + "expected_postcondition": "predicate:target-phase:workspace.publish" + }, + "source_predicate": "predicate:source-phase:workspace.publish", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.publish", + "target_predicate": "predicate:target-phase:workspace.publish", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "published" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.publish", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:workspace.publish" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 75 + }, + { + "id": "workspace.cleanup", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL", + "ABANDONED" + ], + "target_phases": [ + "OBSERVED", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.cleanup", + "local_effects": [ + "workspace.cleanup" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.cleanup", + "expected_postcondition": "predicate:target-phase:workspace.cleanup" + }, + "source_predicate": "predicate:source-phase:workspace.cleanup", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "landed", + "abandoned" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "established" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.cleanup", + "target_predicate": "predicate:target-phase:workspace.cleanup", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "absent" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.cleanup", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "forbidden-after-destructive-git-effect", + "rollback_contract": "not-guaranteed-after-worktree-removal", + "compensation_contract": "no-generic-compensation", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:workspace.cleanup" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "managed_operations": [ + "workspace.remove" + ] + }, + "priority": 92, + "allows_worktree_transfer": true + }, + { + "id": "workspace.reap", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "TERMINAL", + "ABANDONED" + ], + "target_phases": [ + "OBSERVED", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.reap", + "local_effects": [ + "workspace.reap" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.reap", + "expected_postcondition": "predicate:target-phase:workspace.reap" + }, + "source_predicate": "predicate:source-phase:workspace.reap", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "landed", + "abandoned" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "established" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.reap", + "target_predicate": "predicate:target-phase:workspace.reap", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "absent" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.reap", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "forbidden-after-destructive-git-effect", + "rollback_contract": "not-guaranteed-after-worktree-removal", + "compensation_contract": "no-generic-compensation", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:workspace.reap" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 98, + "allows_worktree_transfer": true + }, + { + "id": "workspace.abandon", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ABANDONED" + ], + "goal_kinds": [ + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:workspace", + "facet:program", + "facet:terminal", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.abandon", + "local_effects": [ + "workspace.abandon" + ], + "idempotent": true, + "parameters": [ + { + "name": "branch", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.abandon", + "expected_postcondition": "predicate:target-phase:workspace.abandon" + }, + "source_predicate": "predicate:source-phase:workspace.abandon", + "source_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "cut", + "active", + "published", + "attention-required" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.abandon", + "target_predicate": "predicate:target-phase:workspace.abandon", + "target_conditions": [ + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "abandoned" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.abandon", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:workspace.abandon" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-safe-abandonment", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 91 + }, + { + "id": "workspace.reconcile", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "DORMANT", + "OBSERVED", + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:program", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "workspace" + ], + "effect": "workspace.reconcile", + "local_effects": [ + "workspace.reconcile" + ], + "idempotent": true, + "parameters": [ + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "workspace.reconcile", + "expected_postcondition": "predicate:target-phase:workspace.reconcile" + }, + "source_predicate": "predicate:source-phase:workspace.reconcile", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:workspace.reconcile", + "target_predicate": "predicate:target-phase:workspace.reconcile", + "target_conditions": [ + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:workspace.reconcile", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:workspace.reconcile" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 2, + "allows_worktree_transfer": true + }, + { + "id": "gate.build.record", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "gate-evidence" + ], + "effect": "gate.build.record", + "local_effects": [ + "gate.build.record" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "evidence_path", + "required": true, + "secret": false + }, + { + "name": "evidence_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "gate.build.record", + "expected_postcondition": "predicate:target-phase:gate.build.record" + }, + "source_predicate": "predicate:source-phase:gate.build.record", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:gate.build.record", + "target_predicate": "predicate:target-phase:gate.build.record", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:gate.build.record", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:gate.build.record" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "current_evidence_prefix": "gate-evidence:build:" + }, + "priority": 61, + "binds_source_revision": true + }, + { + "id": "gate.test.record", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "gate-evidence" + ], + "effect": "gate.test.record", + "local_effects": [ + "gate.test.record" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "evidence_path", + "required": true, + "secret": false + }, + { + "name": "evidence_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "gate.test.record", + "expected_postcondition": "predicate:target-phase:gate.test.record" + }, + "source_predicate": "predicate:source-phase:gate.test.record", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:gate.test.record", + "target_predicate": "predicate:target-phase:gate.test.record", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:gate.test.record", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:gate.test.record" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "current_evidence_prefix": "gate-evidence:test:" + }, + "priority": 62, + "binds_source_revision": true + }, + { + "id": "gate.review.record", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime", + "facet:configuration-policy" + ], + "owned_resources": [ + "gate-evidence" + ], + "effect": "gate.review.record", + "local_effects": [ + "gate.review.record" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "evidence_path", + "required": true, + "secret": false + }, + { + "name": "evidence_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "gate.review.record", + "expected_postcondition": "predicate:target-phase:gate.review.record" + }, + "source_predicate": "predicate:source-phase:gate.review.record", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "configuration-policy", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:gate.review.record", + "target_predicate": "predicate:target-phase:gate.review.record", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:gate.review.record", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:gate.review.record" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "authority_rule": "independent-high-risk-review", + "current_evidence_prefix": "gate-evidence:review:" + }, + "priority": 63, + "binds_source_revision": true + }, + { + "id": "gate.change.record", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "gate-evidence" + ], + "effect": "gate.change.record", + "local_effects": [ + "gate.change.record" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "evidence_path", + "required": true, + "secret": false + }, + { + "name": "evidence_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "gate.change.record", + "expected_postcondition": "predicate:target-phase:gate.change.record" + }, + "source_predicate": "predicate:source-phase:gate.change.record", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:gate.change.record", + "target_predicate": "predicate:target-phase:gate.change.record", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:gate.change.record", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:gate.change.record" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "current_evidence_prefix": "gate-evidence:change:" + }, + "priority": 64, + "binds_source_revision": true + }, + { + "id": "gate.journey.record", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "gate-evidence" + ], + "effect": "gate.journey.record", + "local_effects": [ + "gate.journey.record" + ], + "idempotent": true, + "parameters": [ + { + "name": "source_revision", + "required": true, + "secret": false + }, + { + "name": "evidence_path", + "required": true, + "secret": false + }, + { + "name": "evidence_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "gate.journey.record", + "expected_postcondition": "predicate:target-phase:gate.journey.record" + }, + "source_predicate": "predicate:source-phase:gate.journey.record", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:gate.journey.record", + "target_predicate": "predicate:target-phase:gate.journey.record", + "target_conditions": [ + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + } + ], + "verifier": "verifier:fresh-observation:gate.journey.record", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:gate.journey.record" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "current_evidence_prefix": "gate-evidence:journey:" + }, + "priority": 64, + "binds_source_revision": true + }, + { + "id": "evidence.visual.attach", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime", + "facet:configuration-policy" + ], + "owned_resources": [ + "evidence" + ], + "effect": "evidence.visual.attach", + "local_effects": [ + "evidence.visual.attach" + ], + "idempotent": true, + "parameters": [ + { + "name": "manifest_path", + "required": true, + "secret": false + }, + { + "name": "privacy_receipt", + "required": true, + "secret": false + }, + { + "name": "source_revision", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "evidence.visual.attach", + "expected_postcondition": "predicate:target-phase:evidence.visual.attach" + }, + "source_predicate": "predicate:source-phase:evidence.visual.attach", + "source_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active", + "gates-passed", + "published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "configuration-policy", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:evidence.visual.attach", + "target_predicate": "predicate:target-phase:evidence.visual.attach", + "target_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ] + } + ], + "verifier": "verifier:fresh-observation:evidence.visual.attach", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:evidence.visual.attach" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "required_when": "visual-evidence-required", + "availability_rule": "visual-evidence-enabled", + "current_evidence_prefix": "visual-evidence:" + }, + "priority": 66, + "binds_source_revision": true + }, + { + "id": "evidence.approval.revoke", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "authority", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "FRONTIER" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "approval" + ], + "effect": "evidence.approval.revoke", + "local_effects": [ + "evidence.approval.revoke" + ], + "idempotent": true, + "prescription": { + "operation": "evidence.approval.revoke", + "expected_postcondition": "predicate:target-phase:evidence.approval.revoke" + }, + "source_predicate": "predicate:source-phase:evidence.approval.revoke", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "approved", + "locked" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:evidence.approval.revoke", + "target_predicate": "predicate:target-phase:evidence.approval.revoke", + "target_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "valid" + ] + } + ], + "verifier": "verifier:fresh-observation:evidence.approval.revoke", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:evidence.approval.revoke" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 44 + }, + { + "id": "delivery.slice.advance", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL" + ], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:delivery", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "delivery-state" + ], + "effect": "delivery.slice.advance", + "local_effects": [ + "delivery.slice.advance" + ], + "idempotent": true, + "parameters": [ + { + "name": "slice_id", + "required": true, + "secret": false + }, + { + "name": "source_revision", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "delivery.slice.advance", + "expected_postcondition": "predicate:target-phase:delivery.slice.advance" + }, + "source_predicate": "predicate:source-phase:delivery.slice.advance", + "source_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:delivery.slice.advance", + "target_predicate": "predicate:target-phase:delivery.slice.advance", + "target_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "active" + ] + } + ], + "verifier": "verifier:fresh-observation:delivery.slice.advance", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:delivery.slice.advance" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 68, + "binds_source_revision": true + }, + { + "id": "publication.preview", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:plan", + "facet:verification", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "publication-preview" + ], + "effect": "publication.preview", + "local_effects": [ + "publication.preview" + ], + "idempotent": true, + "parameters": [ + { + "name": "base_ref", + "required": true, + "secret": false + }, + { + "name": "head_ref", + "required": true, + "secret": false + }, + { + "name": "body_path", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "publication.preview", + "expected_postcondition": "predicate:target-phase:publication.preview" + }, + "source_predicate": "predicate:source-phase:publication.preview", + "source_conditions": [ + { + "facet": "plan", + "statuses": [ + "known" + ], + "values": [ + "locked" + ] + }, + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + }, + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "active", + "published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.preview", + "target_predicate": "predicate:target-phase:publication.preview", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "candidate" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.preview", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:publication.preview" + }, + "reversibility": "reversible", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 72 + }, + { + "id": "publication.execute", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-external", + "source_phases": [ + "ACTIVE" + ], + "target_phases": [ + "ACTIVE", + "RECOVERY" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "authority_all": [ + "external-provider" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication", + "facet:verification", + "facet:workspace", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "publication" + ], + "effect": "publication.execute", + "external_effects": [ + "publication.execute" + ], + "idempotent": true, + "parameters": [ + { + "name": "preview_fingerprint", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "publication.execute", + "expected_postcondition": "predicate:target-phase:publication.execute" + }, + "source_predicate": "predicate:source-phase:publication.execute", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "candidate" + ] + }, + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + }, + { + "facet": "workspace", + "statuses": [ + "known" + ], + "values": [ + "active", + "published" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.execute", + "target_predicate": "predicate:target-phase:publication.execute", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "published-not-landed" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.execute", + "interruption": { + "points": [ + "before-request", + "after-request", + "before-settlement-observation", + "before-receipt" + ], + "partial_state": [ + "request-not-sent", + "request-possibly-accepted", + "provider-settlement-unobserved" + ], + "detection": "pending-journal-plus-fresh-provider-observation", + "resume_contract": "forbidden-without-provider-observation", + "rollback_contract": "not-provable-after-external-request", + "compensation_contract": "provider-reconciliation-only", + "recovery": "publication.reconcile", + "recovery_authority": "declared-by:publication.reconcile", + "resumption_predicate": "recovery-contract-for:publication.execute" + }, + "reversibility": "compensatable", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "managed_operations": [ + "publication.create", + "publication.push" + ] + }, + "priority": 76, + "authority_fingerprint_parameter": "preview_fingerprint" + }, + { + "id": "publication.observe", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_PROGRESS", + "class": "owned-local", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL", + "FRONTIER", + "UNRESOLVED" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "repository-policy" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "publication-evidence" + ], + "effect": "publication.observe", + "local_effects": [ + "publication.observe" + ], + "idempotent": true, + "parameters": [ + { + "name": "publication_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "publication.observe", + "expected_postcondition": "predicate:target-phase:publication.observe" + }, + "source_predicate": "predicate:source-phase:publication.observe", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "candidate", + "published-not-landed", + "open", + "closed-unmerged", + "unavailable", + "conflicting" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.observe", + "target_predicate": "predicate:target-phase:publication.observe", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "open", + "merged", + "closed-unmerged", + "unavailable", + "conflicting" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.observe", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:publication.observe" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 77 + }, + { + "id": "publication.reconcile", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "FLOW_RECOVERY", + "class": "recovery", + "source_phases": [ + "RECOVERY", + "UNRESOLVED" + ], + "target_phases": [ + "ACTIVE", + "TERMINAL", + "FRONTIER", + "UNRESOLVED" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "external-provider" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:recovery-info", + "facet:publication", + "facet:program", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "publication" + ], + "effect": "publication.reconcile", + "local_effects": [ + "publication.reconcile" + ], + "idempotent": true, + "parameters": [ + { + "name": "publication_id", + "required": true, + "secret": false + }, + { + "name": "transaction_id", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "publication.reconcile", + "expected_postcondition": "predicate:target-phase:publication.reconcile" + }, + "source_predicate": "predicate:source-phase:publication.reconcile", + "source_conditions": [ + { + "facet": "recovery-info", + "statuses": [ + "known" + ] + }, + { + "facet": "publication", + "statuses": [ + "known" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.reconcile", + "target_predicate": "predicate:target-phase:publication.reconcile", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "open", + "merged", + "closed-unmerged", + "unavailable", + "conflicting" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.reconcile", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-blindly-retry-interrupted-recovery", + "rollback_contract": "preserve-original-transaction-group", + "compensation_contract": "escalation-only-after-nested-interruption", + "recovery": "recovery.escalate", + "recovery_authority": "declared-by:recovery.escalate", + "resumption_predicate": "recovery-contract-for:publication.reconcile" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-configured-goal-after-fresh-observation", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 1, + "authority_fingerprint_parameter": "publication_id" + }, + { + "id": "publication.correct", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-external", + "source_phases": [ + "OBSERVED", + "ACTIVE", + "TERMINAL" + ], + "target_phases": [ + "ACTIVE", + "RECOVERY" + ], + "goal_kinds": [ + "open-or-updated-pr", + "merged-delivery" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human", + "autonomy" + ], + "authority_all": [ + "external-provider" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:publication", + "facet:verification", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:engagement", + "facet:goal", + "facet:configuration", + "facet:runtime" + ], + "owned_resources": [ + "publication" + ], + "effect": "publication.correct", + "external_effects": [ + "publication.correct" + ], + "idempotent": true, + "parameters": [ + { + "name": "publication_id", + "required": true, + "secret": false + }, + { + "name": "body_path", + "required": true, + "secret": false + }, + { + "name": "body_sha256", + "required": true, + "secret": false + } + ], + "prescription": { + "operation": "publication.correct", + "expected_postcondition": "predicate:target-phase:publication.correct" + }, + "source_predicate": "predicate:source-phase:publication.correct", + "source_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "open" + ] + }, + { + "facet": "verification", + "statuses": [ + "known" + ], + "values": [ + "current" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal", + "stale", + "established" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + }, + { + "facet": "configuration", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + }, + { + "facet": "runtime", + "statuses": [ + "known" + ], + "values": [ + "verified" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.correct", + "target_predicate": "predicate:target-phase:publication.correct", + "target_conditions": [ + { + "facet": "publication", + "statuses": [ + "known" + ], + "values": [ + "published-not-landed" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.correct", + "interruption": { + "points": [ + "before-request", + "after-request", + "before-settlement-observation", + "before-receipt" + ], + "partial_state": [ + "request-not-sent", + "request-possibly-accepted", + "provider-settlement-unobserved" + ], + "detection": "pending-journal-plus-fresh-provider-observation", + "resume_contract": "forbidden-without-provider-observation", + "rollback_contract": "not-provable-after-external-request", + "compensation_contract": "provider-reconciliation-only", + "recovery": "publication.reconcile", + "recovery_authority": "declared-by:publication.reconcile", + "resumption_predicate": "recovery-contract-for:publication.correct" + }, + "reversibility": "compensatable", + "terminal_effect": "none", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": { + "managed_operations": [ + "publication.edit", + "publication.ready", + "publication.api-write" + ] + }, + "priority": 80, + "authority_fingerprint_parameter": "body_sha256" + }, + { + "id": "publication.abandon", + "version": 1, + "origin": { + "kind": "", + "id": "", + "version": "", + "manifest_fingerprint": "" + }, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "authority", + "source_phases": [ + "ACTIVE", + "FRONTIER" + ], + "target_phases": [ + "ABANDONED" + ], + "goal_kinds": [ + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": [ + "human" + ], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:delivery", + "facet:program", + "facet:terminal", + "facet:engagement", + "facet:goal" + ], + "owned_resources": [ + "publication" + ], + "effect": "publication.abandon", + "local_effects": [ + "publication.abandon" + ], + "idempotent": true, + "prescription": { + "operation": "publication.abandon", + "expected_postcondition": "predicate:target-phase:publication.abandon" + }, + "source_predicate": "predicate:source-phase:publication.abandon", + "source_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "uninitialized", + "planning", + "approved", + "active", + "gates-passed", + "published", + "amendment", + "invalid", + "recovery" + ] + }, + { + "facet": "program", + "statuses": [ + "known" + ], + "values": [ + "unbound", + "current" + ] + }, + { + "facet": "terminal", + "statuses": [ + "known" + ], + "values": [ + "nonterminal" + ] + }, + { + "facet": "engagement", + "statuses": [ + "known" + ], + "values": [ + "command", + "active" + ] + }, + { + "facet": "goal", + "statuses": [ + "known" + ] + } + ], + "admission_predicate": "predicate:exact-admission:publication.abandon", + "target_predicate": "predicate:target-phase:publication.abandon", + "target_conditions": [ + { + "facet": "delivery", + "statuses": [ + "known" + ], + "values": [ + "discarded" + ] + }, + { + "facet": "recovery", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + }, + { + "facet": "transaction", + "statuses": [ + "known" + ], + "values": [ + "none" + ] + } + ], + "verifier": "verifier:fresh-observation:publication.abandon", + "interruption": { + "points": [ + "after-lock", + "after-stage", + "after-effect", + "before-receipt" + ], + "partial_state": [ + "journal-begun", + "effect-staged", + "effect-possibly-installed", + "postcondition-unreceipted" + ], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "journal-target-replay-when-permitted", + "rollback_contract": "exact-prior-byte-replay-when-permitted", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.resume", + "recovery_authority": "declared-by:recovery.resume", + "resumption_predicate": "recovery-contract-for:publication.abandon" + }, + "reversibility": "reversible", + "terminal_effect": "may-establish-safe-abandonment", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {}, + "priority": 93 + } +] diff --git a/boatstack/internal/effects/artifacts.go b/boatstack/internal/effects/artifacts.go index ae3c535..42fb056 100644 --- a/boatstack/internal/effects/artifacts.go +++ b/boatstack/internal/effects/artifacts.go @@ -183,7 +183,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if actual := sha256Bytes(evidenceRaw); actual != fingerprint { return nil, fmt.Errorf("gate evidence fingerprint mismatch: got %s", actual) } - gate, _ := catalog.GateName(transition.ID) + gate, _ := standardGateName(transition.ID) var input gateEvidenceInput if decodeErr := decodeStrictArtifact(evidenceRaw, &input); decodeErr != nil || input.SchemaVersion != 1 || input.Gate != gate || input.SourceRevision != revision || input.Outcome != "passed" || input.Producer == "" || input.CompletedAt.IsZero() { diff --git a/boatstack/internal/effects/command_boundary.go b/boatstack/internal/effects/command_boundary.go index 37bf86b..41db8d0 100644 --- a/boatstack/internal/effects/command_boundary.go +++ b/boatstack/internal/effects/command_boundary.go @@ -133,7 +133,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if err != nil { return settled, err } - gate, _ := catalog.GateName(transition.ID) + gate, _ := standardGateName(transition.ID) command := strings.TrimSpace(config.Project.Commands[gate]) if command == "" { return settled, fmt.Errorf("repository configuration has no %s command", gate) diff --git a/boatstack/internal/effects/command_boundary_test.go b/boatstack/internal/effects/command_boundary_test.go index db4864a..505146c 100644 --- a/boatstack/internal/effects/command_boundary_test.go +++ b/boatstack/internal/effects/command_boundary_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) type boundaryRunner struct { @@ -57,7 +57,7 @@ func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) { if err != nil { t.Fatal(err) } - transition, _ := catalog.Default().Lookup("gate.build.record") + transition, _ := testprogram.StandardRegistry().Lookup("gate.build.record") _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "go test ./..."), durable.State{}) if err == nil || runner.calls != 1 { t.Fatalf("failed configured command result: err=%v calls=%d", err, runner.calls) @@ -73,7 +73,7 @@ func TestConfiguredBuildCommandCannotCrossConstitutionalGuard(t *testing.T) { if err != nil { t.Fatal(err) } - transition, _ := catalog.Default().Lookup("gate.build.record") + transition, _ := testprogram.StandardRegistry().Lookup("gate.build.record") _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "git reset --hard HEAD~1"), durable.State{}) if err == nil || runner.calls != 0 { t.Fatalf("protected configured command result: err=%v calls=%d", err, runner.calls) @@ -86,7 +86,7 @@ func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) { if err != nil { t.Fatal(err) } - transition, _ := catalog.Default().Lookup("publication.observe") + transition, _ := testprogram.StandardRegistry().Lookup("publication.observe") admission := protocol.Admission{ Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", Parameters: protocol.Parameters{{Name: "publication_id", Value: "-dangerous"}}, @@ -108,7 +108,7 @@ func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) { func TestPublicationObservationRejectsUnrelatedProviderIdentity(t *testing.T) { runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/8","number":8,"baseRefName":"main","headRefName":"other","headRefOid":"revision","isCrossRepository":false}`)} boundary, _ := NewNativeBoundaryWithRunner(runner) - transition, _ := catalog.Default().Lookup("publication.observe") + transition, _ := testprogram.StandardRegistry().Lookup("publication.observe") admission := protocol.Admission{ Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", Parameters: protocol.Parameters{{Name: "publication_id", Value: "8"}}, @@ -156,7 +156,7 @@ func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) { runner := &boundaryRunner{} boundary, _ := NewNativeBoundaryWithRunner(runner) - transition, _ := catalog.Default().Lookup("publication.correct") + transition, _ := testprogram.StandardRegistry().Lookup("publication.correct") layout := writeBoundaryConfig(t, "go test ./...") bodyPath := filepath.Join(layout.RepositoryRoot, "body.md") if err := os.WriteFile(bodyPath, []byte("changed"), 0o600); err != nil { diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go index a77f813..82999d7 100644 --- a/boatstack/internal/effects/driver.go +++ b/boatstack/internal/effects/driver.go @@ -14,7 +14,6 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" - "github.com/operatorstack/boatstack/boatstack/internal/kernel/reducer" ) type CommandBoundary interface { @@ -23,9 +22,10 @@ type CommandBoundary interface { } type Driver struct { - resolver ports.InvocationResolver - clock ports.Clock - boundary CommandBoundary + resolver ports.InvocationResolver + clock ports.Clock + boundary CommandBoundary + resourceOwnership map[string]string } func NewDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary) (Driver, error) { @@ -35,7 +35,29 @@ func NewDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary Co return Driver{resolver: resolver, clock: clock, boundary: boundary}, nil } +func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary, ownership map[string]string) (Driver, error) { + driver, err := NewDriver(resolver, clock, boundary) + if err != nil { + return Driver{}, err + } + if len(ownership) == 0 { + return Driver{}, fmt.Errorf("effect driver requires compiled resource ownership") + } + driver.resourceOwnership = make(map[string]string, len(ownership)) + for resource, owner := range ownership { + driver.resourceOwnership[resource] = owner + } + return driver, nil +} + func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + if len(d.resourceOwnership) != 0 { + for _, resource := range transition.OwnedResources { + if owner := d.resourceOwnership[resource]; owner == "" || owner != transition.Owner { + return nil, fmt.Errorf("transition %q cannot write resource %q owned by %q", transition.ID, resource, owner) + } + } + } layout, currentInvocation, err := d.resolver.ResolveLayout(ctx, admission.Invocation) if err != nil { return nil, err @@ -53,6 +75,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if state.RepositoryID != admission.Invocation.RepositoryID || state.GitCommonID != admission.Invocation.GitCommonID || state.WorktreeID != admission.Invocation.WorktreeID { return nil, fmt.Errorf("durable state belongs to a different invocation") } + if state.ProgramFingerprint != "" && state.ProgramFingerprint != admission.ProgramFingerprint && !transition.Policy.ReconcilesProgram { + return nil, fmt.Errorf("compiled control program drifted; explicit program reconciliation is required") + } if err := verifyWorkspaceBranchParameter(state, admission, transition.ID); err != nil { return nil, err } @@ -60,10 +85,13 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, err } next := state + if next.ProgramFingerprint == "" { + next.ProgramFingerprint = admission.ProgramFingerprint + } if err := d.boundary.PrepareObservation(ctx, admission, transition, layout, &next); err != nil { return nil, err } - if err := reducer.Apply(&next, admission, transition); err != nil { + if err := applyStateTransition(&next, admission, transition); err != nil { return nil, err } next.Revision++ @@ -198,6 +226,13 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } mutations = append(mutations, recoveryMutations...) } + for index := range mutations { + if len(transition.OwnedResources) == 0 { + return nil, fmt.Errorf("transition %q produced an undeclared resource write", transition.ID) + } + mutations[index].Resource = transition.OwnedResources[0] + mutations[index].Owner = transition.Owner + } prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation} if requiresCommandBoundary(transition.ID) { prepared.boundary = func(boundaryContext context.Context) (ports.EffectResult, error) { @@ -298,7 +333,7 @@ func parkedSourceState(state durable.State, transition catalog.TransitionID, now state.PublicationURL = "" state.PreviewFingerprint = "" state.Gates = nil - reducer.ClearRecoveryContext(&state) + clearRecoveryContext(&state) state.LastTransition = transition state.UpdatedAt = now.UTC() return state diff --git a/boatstack/internal/effects/extensions.go b/boatstack/internal/effects/extensions.go new file mode 100644 index 0000000..30076a7 --- /dev/null +++ b/boatstack/internal/effects/extensions.go @@ -0,0 +1,116 @@ +package effects + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" +) + +// NewExtensionLocalPrepared turns a declarative extension write plan into the +// same reversible prepared-effect contract used by first-party effects. +func NewExtensionLocalPrepared(repositoryRoot, extensionID string, writes []control.ResourceWrite) (ports.PreparedEffect, error) { + return newNamespacedLocalPrepared(repositoryRoot, filepath.Join("extensions", extensionID), extensionID, "extension", writes) +} + +// NewFlowLocalPrepared constrains a protocol-backed primary flow to its own +// repository-local namespace while retaining the normal reversible effect +// contract. +func NewFlowLocalPrepared(repositoryRoot, flowID string, writes []control.ResourceWrite) (ports.PreparedEffect, error) { + return newNamespacedLocalPrepared(repositoryRoot, filepath.Join("flows", flowID), flowID, "primary flow", writes) +} + +func newNamespacedLocalPrepared(repositoryRoot, namespace, owner, kind string, writes []control.ResourceWrite) (ports.PreparedEffect, error) { + if len(writes) == 0 { + return nil, fmt.Errorf("%s %q planned no local writes", kind, owner) + } + canonicalRepository, err := filepath.EvalSymlinks(repositoryRoot) + if err != nil { + return nil, fmt.Errorf("resolve %s repository root: %w", kind, err) + } + lexicalRoot := filepath.Join(filepath.Clean(repositoryRoot), ".boatstack", namespace) + root := filepath.Join(canonicalRepository, ".boatstack", namespace) + mutations := make([]ports.ResourceMutation, 0, len(writes)) + seen := map[string]bool{} + totalBytes := 0 + for _, write := range writes { + if !strings.HasPrefix(write.Resource, owner+".") { + return nil, fmt.Errorf("%s %q planned undeclared resource %q", kind, owner, write.Resource) + } + if !filepath.IsAbs(write.Path) { + return nil, fmt.Errorf("%s %q planned a non-absolute path", kind, owner) + } + clean := filepath.Clean(write.Path) + relative, err := filepath.Rel(lexicalRoot, clean) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return nil, fmt.Errorf("%s %q write escapes its namespaced resource root", kind, owner) + } + clean = filepath.Join(root, relative) + if seen[clean] { + return nil, fmt.Errorf("%s %q planned duplicate path %q", kind, owner, clean) + } + seen[clean] = true + if write.Delete && len(write.Content) != 0 { + return nil, fmt.Errorf("%s %q delete contains target bytes", kind, owner) + } + if !write.Delete { + totalBytes += len(write.Content) + if totalBytes > 4<<20 { + return nil, fmt.Errorf("%s %q write plan exceeds 4 MiB", kind, owner) + } + digest := sha256.Sum256(write.Content) + if write.SHA256 == "" || hex.EncodeToString(digest[:]) != write.SHA256 { + return nil, fmt.Errorf("%s %q write digest mismatch", kind, owner) + } + } + mode := os.FileMode(write.Mode) + if mode == 0 { + mode = 0o600 + } + if err := rejectSymlinkComponents(canonicalRepository, clean); err != nil { + return nil, fmt.Errorf("%s %q write path is unsafe: %w", kind, owner, err) + } + mutation, err := mutationFor(clean, write.Content, mode, false, write.Delete) + if err != nil { + return nil, err + } + mutation.Resource, mutation.Owner = write.Resource, owner + mutations = append(mutations, mutation) + } + return &preparedEffect{mutations: mutations}, nil +} + +func rejectSymlinkComponents(repositoryRoot, target string) error { + relative, err := filepath.Rel(repositoryRoot, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return fmt.Errorf("target escapes the canonical repository") + } + current := repositoryRoot + for _, segment := range strings.Split(relative, string(filepath.Separator)) { + current = filepath.Join(current, segment) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + continue + } + if statErr != nil { + return statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink component %q", current) + } + } + return nil +} + +func NewExtensionExternalPrepared(execute func(context.Context) (ports.EffectResult, error)) (ports.PreparedEffect, error) { + if execute == nil { + return nil, fmt.Errorf("external extension effect requires an executor") + } + return &preparedEffect{boundary: execute}, nil +} diff --git a/boatstack/internal/effects/extensions_test.go b/boatstack/internal/effects/extensions_test.go new file mode 100644 index 0000000..2132b84 --- /dev/null +++ b/boatstack/internal/effects/extensions_test.go @@ -0,0 +1,39 @@ +package effects + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/operatorstack/boatstack/boatstack/control" +) + +func TestNamespacedExtensionWriteRejectsSymlinkEscapeWithoutMutation(t *testing.T) { + // control-law: declarative-extension-write-cannot-escape-its-owned-resource-root + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require elevated Windows privileges") + } + repository := t.TempDir() + outside := t.TempDir() + link := filepath.Join(repository, ".boatstack", "extensions", "example.guard", "link") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + content := []byte("must-not-escape") + digest := sha256.Sum256(content) + _, err := NewExtensionLocalPrepared(repository, "example.guard", []control.ResourceWrite{{ + Resource: "example.guard.evidence", Path: filepath.Join(link, "evidence.json"), Content: content, SHA256: hex.EncodeToString(digest[:]), + }}) + if err == nil { + t.Fatal("symlink escape was accepted") + } + if _, statErr := os.Stat(filepath.Join(outside, "evidence.json")); !os.IsNotExist(statErr) { + t.Fatalf("rejected write mutated outside path: %v", statErr) + } +} diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go index 35c5e4a..005cf6d 100644 --- a/boatstack/internal/effects/integration_test.go +++ b/boatstack/internal/effects/integration_test.go @@ -14,18 +14,48 @@ import ( "time" boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/extension/releasenote" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/effects" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/engine" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/plant" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) type fixedClock struct{ value time.Time } +const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func testGoalContracts() catalog.GoalContracts { + manifest, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + panic(err) + } + contracts, err := catalog.NewGoalContracts(manifest.GoalContracts, nil) + if err != nil { + panic(err) + } + return contracts +} + +func testProgram() control.ControlProgram { + program, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: standard.Definition(), + }) + if err != nil { + panic(err) + } + return program +} + func (c fixedClock) Now() time.Time { return c.value } func run(t *testing.T, directory, name string, arguments ...string) { @@ -84,7 +114,7 @@ func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { if err != nil { t.Fatal(err) } - kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } @@ -119,7 +149,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing ctx := context.Background() repository := testRepository(t) externalRoot := t.TempDir() - kernel, err := boatstack.NewV2Kernel(externalRoot) + kernel, err := boatstack.NewKernel(externalRoot, testProgram()) if err != nil { t.Fatal(err) } @@ -199,6 +229,236 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing } } +func TestProgramDriftRequiresExplicitCatalogReconciliation(t *testing.T) { + // control-law: frozen-program-cannot-change-without-exact-human-reconciliation + ctx := context.Background() + repository := testRepository(t) + externalRoot := t.TempDir() + oldProgram, err := control.Compile(ctx, control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: standard.Definition(), Extensions: []control.Extension{releasenote.Definition()}, + }) + if err != nil { + t.Fatal(err) + } + oldKernel, err := boatstack.NewKernel(externalRoot, oldProgram) + if err != nil { + t.Fatal(err) + } + goal := model.Goal{ID: "program-drift", Kind: model.GoalApprovedPlan, DeliveryID: "program-drift"} + now := time.Now().UTC() + human := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "program-drift-human", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "explicit-program-reconciliation", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + executable, _ := os.Executable() + executable, _ = filepath.Abs(executable) + executable, _ = filepath.EvalSymlinks(executable) + runtimeRaw, _ := os.ReadFile(executable) + configPath := filepath.Join(t.TempDir(), "project.json") + configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { + t.Fatal(err) + } + initialized, err := oldKernel.Handle(ctx, surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-old", + FlowID: "flow-program-drift", Goal: goal, TransitionID: "installation.initialize", Authority: human, + Parameters: protocol.Parameters{ + {Name: "source_revision", Value: "program-old"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, + }, + }) + if err != nil { + t.Fatal(err) + } + if initialized.Receipt == nil || initialized.Receipt.ProgramFingerprint != oldProgram.Fingerprint() { + t.Fatalf("initial receipt did not freeze old program: %#v", initialized.Receipt) + } + + newProgram := testProgram() + newKernel, err := boatstack.NewKernel(externalRoot, newProgram) + if err != nil { + t.Fatal(err) + } + resolved, err := newKernel.Handle(ctx, surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, Host: "cli", CorrelationID: "program-drift-resolve", Goal: goal, + }) + if err != nil { + t.Fatal(err) + } + if resolved.Decision == nil || resolved.Decision.Kind != supervisor.DecisionUnresolved { + t.Fatalf("program drift decision = %#v", resolved.Decision) + } + + resolver, err := plant.NewResolver(externalRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "program-drift-inspect") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + request := surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-drift-reconcile", + FlowID: "flow-program-drift", Goal: goal, TransitionID: "catalog.reconcile", + Parameters: protocol.Parameters{{Name: "prior_program_fingerprint", Value: oldProgram.Fingerprint()}, {Name: "accept_obligation_change", Value: "true"}}, + } + frontier, err := newKernel.Handle(ctx, request) + if err == nil || frontier.Decision == nil || frontier.Decision.Kind != supervisor.DecisionFrontier { + t.Fatalf("authority-free reconciliation = response %#v error %v", frontier, err) + } + afterRejected, _ := os.ReadFile(layout.StatePath) + if !bytes.Equal(before, afterRejected) { + t.Fatal("authority-free reconciliation mutated durable state") + } + request.Authority = human + request.Parameters[1].Value = "false" + if _, err := newKernel.Handle(ctx, request); err == nil { + t.Fatal("reconciliation without explicit obligation acceptance succeeded") + } + afterInvalid, _ := os.ReadFile(layout.StatePath) + if !bytes.Equal(before, afterInvalid) { + t.Fatal("invalid reconciliation mutated durable state") + } + request.Parameters[1].Value = "true" + reconciled, err := newKernel.Handle(ctx, request) + if err != nil { + t.Fatal(err) + } + if reconciled.Receipt == nil || reconciled.Receipt.ProgramFingerprint != newProgram.Fingerprint() || reconciled.Snapshot == nil || reconciled.Snapshot.Program.Value != model.ProgramCurrent { + t.Fatalf("reconciliation did not establish exact program identity: %#v", reconciled) + } +} + +func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) { + // control-law: extension-obligation-cannot-be-terminal-before-verified-kernel-receipt + ctx := context.Background() + repository := testRepository(t) + if err := os.MkdirAll(filepath.Join(repository, "release-notes"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "release-notes", "extension.md"), []byte("### Extension\n\nVerifiable user impact.\n"), 0o644); err != nil { + t.Fatal(err) + } + program, err := control.Compile(ctx, control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: standard.Definition(), Extensions: []control.Extension{releasenote.Definition()}, + }) + if err != nil { + t.Fatal(err) + } + kernel, err := boatstack.NewKernel(t.TempDir(), program) + if err != nil { + t.Fatal(err) + } + goal := model.Goal{ID: "extension-receipt", Kind: model.GoalOpenPR, DeliveryID: "extension-receipt"} + now := time.Now().UTC() + authority := func(class catalog.AuthorityClass) protocol.AuthorityBundle { + fingerprint, subject := "explicit-human", "integration" + if class == catalog.AuthorityRepository { + subject = filepath.Join(repository, ".boatstack", "project.json") + raw, readErr := os.ReadFile(subject) + if readErr != nil { + t.Fatal(readErr) + } + fingerprint = configFingerprint(t, raw) + } + return protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "extension-" + string(class), Class: class, Subject: subject, Fingerprint: fingerprint, + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + } + apply := func(id catalog.TransitionID, authorization protocol.AuthorityBundle, parameters protocol.Parameters) surfaces.Response { + t.Helper() + response, applyErr := kernel.Handle(ctx, surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", + CorrelationID: "extension-" + string(id), FlowID: "flow-extension-receipt", Goal: goal, TransitionID: id, + Authority: authorization, Parameters: parameters, + }) + if applyErr != nil { + t.Fatalf("apply %s: %v", id, applyErr) + } + return response + } + executable, _ := os.Executable() + executable, _ = filepath.Abs(executable) + executable, _ = filepath.EvalSymlinks(executable) + runtimeRaw, _ := os.ReadFile(executable) + configPath := filepath.Join(t.TempDir(), "project.json") + configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { + t.Fatal(err) + } + apply("installation.initialize", authority(catalog.AuthorityHuman), protocol.Parameters{ + {Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, + }) + apply("engagement.begin", authority(catalog.AuthorityRepository), nil) + planPath := filepath.Join(t.TempDir(), "plan.md") + planRaw := []byte("# Extension plan\n") + if err := os.WriteFile(planPath, planRaw, 0o600); err != nil { + t.Fatal(err) + } + apply("plan.create", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "source_path", Value: planPath}, {Name: "delivery_id", Value: goal.DeliveryID}}) + apply("plan.validate", authority(catalog.AuthorityRepository), nil) + apply("plan.approve", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "plan_fingerprint", Value: digestBytes(planRaw)}, {Name: "actor", Value: "integration"}}) + apply("plan.activate", authority(catalog.AuthorityHuman), nil) + head := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD")) + gate := func(name string) protocol.Parameters { + raw, marshalErr := json.Marshal(map[string]any{"schema_version": 1, "gate": name, "source_revision": head, "outcome": "passed", "producer": "integration", "completed_at": now}) + if marshalErr != nil { + t.Fatal(marshalErr) + } + path := filepath.Join(t.TempDir(), name+".json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return protocol.Parameters{{Name: "source_revision", Value: head}, {Name: "evidence_path", Value: path}, {Name: "evidence_fingerprint", Value: digestBytes(raw)}} + } + apply("gate.build.record", authority(catalog.AuthorityRepository), gate("build")) + apply("gate.test.record", authority(catalog.AuthorityRepository), gate("test")) + beforeExtension := apply("gate.review.record", authority(catalog.AuthorityRepository), gate("review")) + if beforeExtension.Snapshot == nil || beforeExtension.Snapshot.ExtensionFacts[releasenote.FactID].Value != "missing" { + t.Fatalf("extension obligation disappeared before receipt: %#v", beforeExtension.Snapshot) + } + next, err := kernel.Handle(ctx, surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, Host: "cli", + CorrelationID: "extension-next", Goal: goal, Authority: authority(catalog.AuthorityRepository), + }) + if err != nil { + t.Fatal(err) + } + if next.Decision == nil || next.Decision.Kind != supervisor.DecisionPrescribed || next.Decision.Transition == nil || next.Decision.Transition.ID != releasenote.Transition { + t.Fatalf("unmet extension obligation decision = %#v", next.Decision) + } + completed := apply(releasenote.Transition, authority(catalog.AuthorityRepository), nil) + if completed.Receipt == nil || completed.Receipt.TransitionID != releasenote.Transition || completed.Receipt.ProgramFingerprint != program.Fingerprint() || + completed.Snapshot == nil || completed.Snapshot.ExtensionFacts[releasenote.FactID].Value != "verified" { + t.Fatalf("extension did not traverse verified receipt path: %#v", completed) + } + after, err := kernel.Handle(ctx, surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, Host: "cli", + CorrelationID: "extension-after", Goal: goal, Authority: authority(catalog.AuthorityRepository), + }) + if err != nil || after.Decision == nil { + t.Fatalf("verified extension did not return control to PrimaryFlow: %#v error=%v", after.Decision, err) + } + if after.Decision.Transition != nil && after.Decision.Transition.Origin.Kind == catalog.OriginExtension { + t.Fatalf("verified extension remained selectable: %#v", after.Decision) + } + for _, candidate := range after.Decision.Candidates { + if candidate == releasenote.Transition { + t.Fatalf("verified extension remained a frontier candidate: %#v", after.Decision) + } + } +} + func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing.T) { // control-law: successful-writes-remain-independently-verifiable-and-goal-specific ctx := context.Background() @@ -217,7 +477,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing. journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) - kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } @@ -360,7 +620,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) - kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go index d7a9e6e..b07016c 100644 --- a/boatstack/internal/effects/receipts.go +++ b/boatstack/internal/effects/receipts.go @@ -137,6 +137,7 @@ type processEvent struct { Timestamp time.Time `json:"timestamp"` GoalID string `json:"goal_id"` TransitionID string `json:"transition_id"` + ProgramFingerprint string `json:"program_fingerprint"` SourceFingerprint string `json:"source_fingerprint"` TargetFingerprint string `json:"target_fingerprint"` Outcome string `json:"outcome"` @@ -180,7 +181,7 @@ func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionRe } event := processEvent{ SchemaVersion: 1, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, - TransitionID: string(receipt.TransitionID), SourceFingerprint: receipt.SourceFingerprint, TargetFingerprint: receipt.TargetFingerprint, + TransitionID: string(receipt.TransitionID), ProgramFingerprint: receipt.ProgramFingerprint, SourceFingerprint: receipt.SourceFingerprint, TargetFingerprint: receipt.TargetFingerprint, Outcome: string(receipt.Outcome), DurationNanoseconds: receipt.DurationNanoseconds, AuthorityClasses: append([]string(nil), receipt.AuthorityClasses...), Recovery: string(receipt.Recovery), Terminal: string(receipt.Terminal), FailureClass: receipt.FailureClass, } diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go index 4e3b62b..070bc4e 100644 --- a/boatstack/internal/effects/recovery_test.go +++ b/boatstack/internal/effects/recovery_test.go @@ -8,16 +8,32 @@ import ( "testing" "time" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/engine" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" "github.com/operatorstack/boatstack/boatstack/internal/plant" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) type recoveryClock struct{ value time.Time } +const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func testGoalContracts() catalog.GoalContracts { + manifest, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + panic(err) + } + contracts, err := catalog.NewGoalContracts(manifest.GoalContracts, nil) + if err != nil { + panic(err) + } + return contracts +} + func (c recoveryClock) Now() time.Time { return c.value } func recoveryRepository(t *testing.T) string { @@ -65,7 +81,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) if err != nil { t.Fatal(err) } - initial, err := model.Canonicalize(initialObservation) + initial, err := model.CanonicalizeForProgram(initialObservation, testProgramFingerprint) if err != nil { t.Fatal(err) } @@ -74,7 +90,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) ID: "recovery-human", Class: catalog.AuthorityHuman, Subject: "fixture", Fingerprint: "recovery-human-fingerprint", IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour), }}} - transition, _ := catalog.Default().Lookup("installation.initialize") + transition, _ := testprogram.StandardRegistry().Lookup("installation.initialize") executable, _ := os.Executable() executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) @@ -126,7 +142,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) if err != nil { t.Fatal(err) } - recoverySnapshot, err := model.Canonicalize(recoveryObservation) + recoverySnapshot, err := model.CanonicalizeForProgram(recoveryObservation, testProgramFingerprint) if err != nil { t.Fatal(err) } @@ -137,7 +153,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) locker, _ := NewLocker(resolver) journalAfterRestart, _ := NewJournal(resolver, clock) receipts, _ := NewReceiptStore(resolver, clock) - restartedEngine, err := engine.New(catalog.Default(), observer, clock, locker, journalAfterRestart, driver, receipts) + restartedEngine, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journalAfterRestart, driver, receipts) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/effects/standard_adapter.go b/boatstack/internal/effects/standard_adapter.go new file mode 100644 index 0000000..ee132cb --- /dev/null +++ b/boatstack/internal/effects/standard_adapter.go @@ -0,0 +1,18 @@ +package effects + +import ( + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" +) + +// standardGateName belongs to the trusted StandardFlow native adapter. The +// generic Kernel registry does not infer gate semantics from transition IDs. +func standardGateName(id catalog.TransitionID) (string, bool) { + value := string(id) + if !strings.HasPrefix(value, "gate.") || !strings.HasSuffix(value, ".record") { + return "", false + } + name := strings.TrimSuffix(strings.TrimPrefix(value, "gate."), ".record") + return name, name != "" +} diff --git a/boatstack/internal/kernel/reducer/reducer.go b/boatstack/internal/effects/state_reducer.go similarity index 93% rename from boatstack/internal/kernel/reducer/reducer.go rename to boatstack/internal/effects/state_reducer.go index 7e8a0cc..5e0d784 100644 --- a/boatstack/internal/kernel/reducer/reducer.go +++ b/boatstack/internal/effects/state_reducer.go @@ -1,4 +1,4 @@ -package reducer +package effects import ( "fmt" @@ -9,7 +9,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) -func Apply(state *durable.State, admission protocol.Admission, transition catalog.Transition) error { +func applyStateTransition(state *durable.State, admission protocol.Admission, transition catalog.Transition) error { configured := state.Goal.Validate() == nil switch transition.ID { case "installation.initialize", "goal.configure": @@ -46,7 +46,7 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") state.RuntimePath, _ = admission.Parameters.Get("runtime_path") state.RuntimeSource, _ = admission.Parameters.Get("source_revision") - ClearRecoveryContext(state) + clearRecoveryContext(state) state.Phase = settledPhase(*state) case "configuration.initialize", "configuration.mutate": state.Configuration = model.ConfigurationVerified @@ -54,8 +54,15 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.Phase = settledPhase(*state) case "configuration.reconcile": state.Configuration, state.Recovery, state.Transaction = model.ConfigurationVerified, model.RecoveryNone, model.TransactionNone - ClearRecoveryContext(state) + clearRecoveryContext(state) state.Phase = settledPhase(*state) + case "catalog.reconcile": + prior, _ := admission.Parameters.Get("prior_program_fingerprint") + accepted, _ := admission.Parameters.Get("accept_obligation_change") + if prior == "" || prior != state.ProgramFingerprint || accepted != "true" { + return fmt.Errorf("catalog reconciliation must bind the prior program and explicitly accept obligation changes") + } + state.ProgramFingerprint = admission.ProgramFingerprint case "installation.initialize": state.Runtime, state.Configuration = model.RuntimeVerified, model.ConfigurationVerified state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") @@ -99,12 +106,12 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.Workspace = model.WorkspaceAbandoned } state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone - ClearRecoveryContext(state) + clearRecoveryContext(state) establishTerminal(state, model.PhaseAbandoned) case "workspace.abandon": state.Delivery, state.Workspace = model.DeliveryDiscarded, model.WorkspaceAbandoned state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone - ClearRecoveryContext(state) + clearRecoveryContext(state) establishTerminal(state, model.PhaseAbandoned) case "workspace.cut": state.Workspace, state.Phase = model.WorkspaceCut, model.PhaseActive @@ -129,9 +136,9 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.Phase = terminalPhase(*state) case "workspace.reconcile": state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, engagedPhase(*state) - ClearRecoveryContext(state) + clearRecoveryContext(state) case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record": - gate, _ := catalog.GateName(transition.ID) + gate, _ := standardGateName(transition.ID) revision, _ := admission.Parameters.Get("source_revision") fingerprint, _ := admission.Parameters.Get("evidence_fingerprint") upsertGate(state, durable.GateEvidence{Gate: gate, Revision: revision, Fingerprint: fingerprint}) @@ -167,7 +174,7 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.Publication, state.Workspace, state.Delivery, state.Phase = model.PublicationPublishedNotLanded, model.WorkspacePublished, model.DeliveryPublished, model.PhaseActive case "publication.observe", "publication.reconcile": state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone - ClearRecoveryContext(state) + clearRecoveryContext(state) if state.Publication == model.PublicationUnavailable || state.Publication == model.PublicationConflicting { state.Phase = model.PhaseUnresolved } else if state.Publication == model.PublicationClosedUnmerged { @@ -185,10 +192,10 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo state.Terminal, state.Phase = model.TerminalNonterminal, model.PhaseActive case "recovery.resume": state.Recovery, state.Transaction, state.Delivery, state.Phase = model.RecoveryNone, model.TransactionNone, model.DeliveryActive, model.PhaseActive - ClearRecoveryContext(state) + clearRecoveryContext(state) case "recovery.rollback": state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, model.PhaseObserved - ClearRecoveryContext(state) + clearRecoveryContext(state) case "recovery.escalate": state.Recovery, state.Phase = model.RecoveryEscalated, model.PhaseFrontier state.Transaction = model.TransactionNone @@ -207,7 +214,7 @@ func Apply(state *durable.State, admission protocol.Admission, transition catalo return nil } -func ClearRecoveryContext(state *durable.State) { +func clearRecoveryContext(state *durable.State) { state.TransactionID = "" state.TransactionTransition = "" state.RecoveryCause = "" diff --git a/boatstack/internal/kernel/reducer/reducer_test.go b/boatstack/internal/effects/state_reducer_test.go similarity index 85% rename from boatstack/internal/kernel/reducer/reducer_test.go rename to boatstack/internal/effects/state_reducer_test.go index 4141105..c9f1ed4 100644 --- a/boatstack/internal/kernel/reducer/reducer_test.go +++ b/boatstack/internal/effects/state_reducer_test.go @@ -1,4 +1,4 @@ -package reducer +package effects import ( "testing" @@ -7,6 +7,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func TestRequiredVisualEvidenceParticipatesInVerifiedTerminal(t *testing.T) { @@ -22,11 +23,11 @@ func TestRequiredVisualEvidenceParticipatesInVerifiedTerminal(t *testing.T) { } apply := func(id catalog.TransitionID, parameters protocol.Parameters) { t.Helper() - transition, ok := catalog.Default().Lookup(id) + transition, ok := testprogram.StandardRegistry().Lookup(id) if !ok { t.Fatalf("missing transition %s", id) } - if err := Apply(&state, protocol.Admission{Goal: goal, Parameters: parameters, SourceRevision: "revision", WorktreeFingerprint: "worktree"}, transition); err != nil { + if err := applyStateTransition(&state, protocol.Admission{Goal: goal, Parameters: parameters, SourceRevision: "revision", WorktreeFingerprint: "worktree"}, transition); err != nil { t.Fatalf("apply %s: %v", id, err) } } @@ -56,17 +57,17 @@ func TestPublicationCorrectionRequiresIndependentObservationForTerminal(t *testi Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalEstablished, Goal: goal, } - correct, _ := catalog.Default().Lookup("publication.correct") + correct, _ := testprogram.StandardRegistry().Lookup("publication.correct") admission := protocol.Admission{Goal: goal, Parameters: protocol.Parameters{{Name: "publication_id", Value: "7"}, {Name: "body_path", Value: "/body"}}} - if err := Apply(&state, admission, correct); err != nil { + if err := applyStateTransition(&state, admission, correct); err != nil { t.Fatal(err) } if state.Terminal != model.TerminalNonterminal || state.Publication != model.PublicationPublishedNotLanded || state.Phase != model.PhaseActive { t.Fatalf("external correction self-certified terminal: %#v", state) } state.Publication = model.PublicationOpen // supplied only by PrepareObservation through gh pr view - observe, _ := catalog.Default().Lookup("publication.observe") - if err := Apply(&state, admission, observe); err != nil { + observe, _ := testprogram.StandardRegistry().Lookup("publication.observe") + if err := applyStateTransition(&state, admission, observe); err != nil { t.Fatal(err) } if state.Terminal != model.TerminalEstablished || state.Phase != model.PhaseTerminal { @@ -90,8 +91,8 @@ func TestWorkspaceReapPreservesEstablishedTerminalPhase(t *testing.T) { Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationMerged, Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalEstablished, Goal: goal, } - transition, _ := catalog.Default().Lookup("workspace.reap") - if err := Apply(&state, protocol.Admission{Goal: goal}, transition); err != nil { + transition, _ := testprogram.StandardRegistry().Lookup("workspace.reap") + if err := applyStateTransition(&state, protocol.Admission{Goal: goal}, transition); err != nil { t.Fatal(err) } if state.Workspace != model.WorkspaceAbsent || state.Phase != fixture.phase || state.Terminal != model.TerminalEstablished { @@ -111,18 +112,18 @@ func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *test Goal: original, TransactionID: "adm-interrupted", RecoveryCause: "provider unknown", RecoverySourcePhase: model.PhaseActive, RecoveryResumption: model.PhaseFrontier, RecoveryBudget: 0, } - configure, _ := catalog.Default().Lookup("goal.configure") + configure, _ := testprogram.StandardRegistry().Lookup("goal.configure") configureAdmission := protocol.Admission{Goal: abandoned, Parameters: protocol.Parameters{ {Name: "goal_kind", Value: string(abandoned.Kind)}, {Name: "delivery_id", Value: abandoned.DeliveryID}, }} - if err := Apply(&state, configureAdmission, configure); err != nil { + if err := applyStateTransition(&state, configureAdmission, configure); err != nil { t.Fatal(err) } if state.Phase != model.PhaseFrontier || state.Recovery != model.RecoveryEscalated { t.Fatalf("goal reconfiguration bypassed escalated recovery: %#v", state) } - abandon, _ := catalog.Default().Lookup("plan.abandon") - if err := Apply(&state, protocol.Admission{Goal: abandoned}, abandon); err != nil { + abandon, _ := testprogram.StandardRegistry().Lookup("plan.abandon") + if err := applyStateTransition(&state, protocol.Admission{Goal: abandoned}, abandon); err != nil { t.Fatal(err) } if state.Phase != model.PhaseAbandoned || state.Recovery != model.RecoveryNone || state.TransactionID != "" || state.Terminal != model.TerminalEstablished { diff --git a/boatstack/internal/kernel/catalog/default.go b/boatstack/internal/kernel/catalog/default.go deleted file mode 100644 index 424e609..0000000 --- a/boatstack/internal/kernel/catalog/default.go +++ /dev/null @@ -1,345 +0,0 @@ -package catalog - -import ( - "strings" - - "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" -) - -const DefaultTransitionCount = 61 - -type seed struct { - id TransitionID - class EventClass - source []model.ProtocolPhase - target []model.ProtocolPhase - authority []AuthorityClass - resource string - goals []model.GoalKind - priority int -} - -var activeSources = []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseUnresolved} -var managedSources = []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseRecovery, model.PhaseFrontier, model.PhaseUnresolved} - -func Default() Registry { - registry, err := New(defaultTransitions()) - if err != nil { - panic(err) - } - if registry.Len() != DefaultTransitionCount { - panic("boatstack V2 default transition count drifted") - } - return registry -} - -func defaultTransitions() []Transition { - allGoals := []model.GoalKind{model.GoalApprovedPlan, model.GoalVerified, model.GoalOpenPR, model.GoalMerged, model.GoalAbandoned} - seeds := []seed{ - {"engagement.begin", EventAuthority, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "engagement", allGoals, 10}, - {"engagement.renew", EventAuthority, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository, AuthorityAutonomy}, "engagement", allGoals, 70}, - {"engagement.release", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseDormant}, []AuthorityClass{AuthorityRepository}, "engagement", allGoals, 95}, - {"invocation.rebind", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityRepository}, "identity-binding", allGoals, 15}, - {"repository.attach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityHuman}, "repository-binding", allGoals, 12}, - {"repository.detach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseDormant}, []AuthorityClass{AuthorityHuman}, "repository-binding", allGoals, 96}, - - {"runtime.hydrate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "runtime", allGoals, 20}, - {"runtime.replace", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "runtime", allGoals, 25}, - {"runtime.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseFrontier, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "runtime", allGoals, 4}, - {"configuration.initialize", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "configuration", allGoals, 22}, - {"configuration.mutate", EventOwnedLocal, activeSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "configuration", allGoals, 60}, - {"configuration.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseFrontier, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "configuration", allGoals, 3}, - {"installation.initialize", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityHuman}, "installation", allGoals, 11}, - {"installation.update", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "installation", allGoals, 65}, - - {"goal.configure", EventAuthority, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "goal", allGoals, 30}, - {"plan.create", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "plan", allGoals, 35}, - {"plan.validate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "plan-evidence", allGoals, 40}, - {"plan.approve", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "approval", allGoals, 45}, - {"plan.activate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "delivery-state", []model.GoalKind{model.GoalVerified, model.GoalOpenPR, model.GoalMerged}, 50}, - {"plan.amend", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "plan", allGoals, 42}, - {"plan.approve-amendment", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "approval", allGoals, 46}, - {"plan.invalidate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "plan-evidence", allGoals, 41}, - {"plan.abandon", EventAuthority, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "plan", []model.GoalKind{model.GoalAbandoned}, 90}, - - {"workspace.cut", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 52}, - {"workspace.sync", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 58}, - {"workspace.activate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "workspace", allGoals, 53}, - {"workspace.publish", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "workspace-state", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 75}, - {"workspace.cleanup", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 92}, - {"workspace.reap", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "workspace", allGoals, 98}, - {"workspace.abandon", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "workspace", []model.GoalKind{model.GoalAbandoned}, 91}, - {"workspace.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "workspace", allGoals, 2}, - - {"gate.build.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 61}, - {"gate.test.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 62}, - {"gate.review.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "gate-evidence", allGoals, 63}, - {"gate.change.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 64}, - {"gate.journey.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 64}, - {"evidence.visual.attach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "evidence", allGoals, 66}, - {"evidence.approval.revoke", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityHuman}, "approval", allGoals, 44}, - {"delivery.slice.advance", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "delivery-state", allGoals, 68}, - - {"publication.preview", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "publication-preview", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 72}, - {"publication.execute", EventOwnedExternal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseRecovery}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 76}, - {"publication.observe", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal, model.PhaseFrontier, model.PhaseUnresolved}, []AuthorityClass{AuthorityRepository}, "publication-evidence", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 77}, - {"publication.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal, model.PhaseFrontier, model.PhaseUnresolved}, []AuthorityClass{AuthorityHuman, AuthorityProvider}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 1}, - {"publication.correct", EventOwnedExternal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []model.ProtocolPhase{model.PhaseActive, model.PhaseRecovery}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 80}, - {"publication.abandon", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "publication", []model.GoalKind{model.GoalAbandoned}, 93}, - - {"recovery.resume", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy, AuthorityRepository}, "recovery-journal", allGoals, 2}, - {"recovery.rollback", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "recovery-journal", allGoals, 3}, - {"recovery.escalate", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "recovery-journal", allGoals, 5}, - - {"external.files-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.head-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.branch-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.runtime-disappeared", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.configuration-drifted", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseUnresolved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.lease-expired", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseDormant, model.PhaseFrontier}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.host-interrupted", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.ci-completed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.pr-opened", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.pr-updated", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.pr-closed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.pr-merged", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - {"external.provider-unavailable", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseUnresolved, model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100}, - } - - transitions := make([]Transition, 0, len(seeds)) - for _, item := range seeds { - transitions = append(transitions, materialize(item)) - } - return transitions -} - -func materialize(item seed) Transition { - controllable := item.class.Controllable() - effect := EffectID("") - var localEffects, externalEffects []EffectID - resources := []string(nil) - reversibility := ObservationOnly - recovery := TransitionID("") - points := []string{"after-observation"} - if controllable { - effect = EffectID(item.id) - resources = []string{item.resource} - reversibility = Reversible - points = []string{"after-lock", "after-stage", "after-effect", "before-receipt"} - if item.class == EventOwnedExternal { - externalEffects = []EffectID{effect} - reversibility = Compensatable - points = []string{"before-request", "after-request", "before-settlement-observation", "before-receipt"} - } else { - localEffects = []EffectID{effect} - } - if item.class == EventRecovery { - recovery = "recovery.escalate" - } else { - recovery = interruptionRecovery(item) - } - } - transition := Transition{ - ID: item.id, Version: 1, Class: item.class, SourcePhases: item.source, TargetPhases: item.target, - GoalKinds: item.goals, RequiredIdentity: invocationIdentityRequirements(), Authority: item.authority, - RequiredEvidence: []string{"invocation-context", "snapshot-fingerprint", "goal"}, - OwnedResources: resources, Effect: effect, Idempotent: controllable, - LocalEffects: localEffects, ExternalEffects: externalEffects, - Prescription: Prescription{Operation: string(item.id), ExpectedPostcondition: "predicate:target-phase:" + string(item.id)}, - SourcePredicate: "predicate:source-phase:" + string(item.id), AdmissionPredicate: "predicate:exact-admission:" + string(item.id), TargetPredicate: "predicate:target-phase:" + string(item.id), - Verifier: "verifier:fresh-observation:" + string(item.id), - Interruption: interruptionContract(item, points, recovery), - Reversibility: reversibility, TerminalEffect: terminalEffect(item.id), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", - CostClass: "declared-neutral", Priority: item.priority, - } - transition.SourceConditions = sourceConditions(item.id) - if item.class.Controllable() && item.class != EventRecovery && !isSafeAbandonment(item.id) { - transition.SourceConditions = append(transition.SourceConditions, - known(model.FacetRecovery, string(model.RecoveryNone)), - known(model.FacetTransaction, string(model.TransactionNone)), - ) - } - if item.class.Controllable() && item.class != EventRecovery && item.id != "goal.configure" { - terminalValues := []string{string(model.TerminalNonterminal)} - if allowsStaleTerminalRepair(item.id) { - terminalValues = append(terminalValues, string(model.TerminalStale)) - } - if allowsPostTerminalMaintenance(item.id) { - terminalValues = append(terminalValues, string(model.TerminalEstablished)) - } - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetTerminal, terminalValues...)) - } - if requiresEngagement(item.id) { - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetEngagement, string(model.EngagementCommand), string(model.EngagementActive))) - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetGoal)) - } - if requiresHealthyConfiguration(item.id) { - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetConfiguration, string(model.ConfigurationVerified))) - } - if requiresHealthyRuntime(item.id) { - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetRuntime, string(model.RuntimeVerified))) - } - if usesConfigurationPolicy(item.id) { - transition.SourceConditions = append(transition.SourceConditions, known(model.FacetConfigurationPolicy)) - } - transition.TargetConditions = targetConditions(item.id) - transition.AuthorityAll = requiredAuthorities(item.id) - for _, condition := range transition.SourceConditions { - transition.RequiredEvidence = append(transition.RequiredEvidence, "facet:"+string(condition.Facet)) - } - transition.Parameters = parameterSpecs(item.id) - if item.id == "repository.attach" || item.id == "repository.detach" || item.id == "invocation.rebind" { - transition.AllowsIdentityRebind = true - } - if item.id == "workspace.cut" || item.id == "workspace.cleanup" || item.id == "workspace.reap" || item.id == "workspace.reconcile" { - transition.AllowsWorktreeTransfer = true - } - return transition -} - -func isSafeAbandonment(id TransitionID) bool { - return id == "goal.configure" || id == "plan.abandon" || id == "publication.abandon" || id == "workspace.abandon" -} - -func invocationIdentityRequirements() []string { - return []string{"repository-id", "git-common-id", "worktree-id", "ref", "controller-id", "invoking-path", "runtime-path", "runtime-fingerprint", "topology", "host", "correlation-id"} -} - -func interruptionContract(item seed, points []string, recovery TransitionID) InterruptionContract { - contract := InterruptionContract{ - Points: points, PartialState: []string{"no-owned-partial-state"}, Detection: "fresh-canonical-observation", - ResumeContract: "not-applicable", RollbackContract: "not-applicable", CompensationContract: "not-applicable", - Recovery: recovery, RecoveryAuthority: "not-applicable", ResumptionPredicate: "predicate:target-phase:" + string(item.id), - } - if !item.class.Controllable() { - return contract - } - contract.PartialState = []string{"journal-begun", "effect-staged", "effect-possibly-installed", "postcondition-unreceipted"} - contract.Detection = "pending-journal-plus-fresh-canonical-observation" - contract.ResumeContract = "journal-target-replay-when-permitted" - contract.RollbackContract = "exact-prior-byte-replay-when-permitted" - contract.CompensationContract = "not-required-for-owned-local-effects" - contract.RecoveryAuthority = "declared-by:" + string(recovery) - contract.ResumptionPredicate = "recovery-contract-for:" + string(item.id) - if item.class == EventOwnedExternal { - contract.PartialState = []string{"request-not-sent", "request-possibly-accepted", "provider-settlement-unobserved"} - contract.Detection = "pending-journal-plus-fresh-provider-observation" - contract.ResumeContract = "forbidden-without-provider-observation" - contract.RollbackContract = "not-provable-after-external-request" - contract.CompensationContract = "provider-reconciliation-only" - } - if item.id == "workspace.cleanup" || item.id == "workspace.reap" { - contract.ResumeContract = "forbidden-after-destructive-git-effect" - contract.RollbackContract = "not-guaranteed-after-worktree-removal" - contract.CompensationContract = "no-generic-compensation" - } - if item.class == EventRecovery { - contract.ResumeContract = "never-blindly-retry-interrupted-recovery" - contract.RollbackContract = "preserve-original-transaction-group" - contract.CompensationContract = "escalation-only-after-nested-interruption" - } - return contract -} - -func terminalEffect(id TransitionID) string { - value := string(id) - if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "plan.approve" || id == "plan.approve-amendment" || - id == "runtime.hydrate" || id == "runtime.replace" || id == "runtime.reconcile" || id == "configuration.initialize" || - id == "configuration.mutate" || id == "configuration.reconcile" || id == "installation.update" || - id == "publication.observe" || id == "publication.reconcile" || id == "workspace.cleanup" || id == "workspace.reap" { - return "may-establish-configured-goal-after-fresh-observation" - } - if id == "plan.abandon" || id == "publication.abandon" || id == "workspace.abandon" { - return "may-establish-safe-abandonment" - } - return "none" -} - -func interruptionRecovery(item seed) TransitionID { - if item.class == EventOwnedExternal { - return "publication.reconcile" - } - switch item.id { - case "runtime.hydrate", "runtime.replace", "installation.initialize", "installation.update": - return "runtime.reconcile" - case "configuration.initialize", "configuration.mutate": - return "configuration.reconcile" - case "workspace.cut": - return "workspace.reconcile" - case "workspace.cleanup", "workspace.reap": - return "recovery.escalate" - default: - return "recovery.resume" - } -} - -func allowsStaleTerminalRepair(id TransitionID) bool { - value := string(id) - return strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || - id == "plan.create" || id == "plan.validate" || id == "plan.amend" || id == "plan.invalidate" || - id == "runtime.hydrate" || id == "runtime.replace" || id == "installation.update" || - id == "configuration.initialize" || id == "configuration.mutate" || id == "publication.correct" -} - -func allowsPostTerminalMaintenance(id TransitionID) bool { - switch id { - case "workspace.cleanup", "workspace.reap", "publication.correct": - return true - default: - return false - } -} - -func parameterSpecs(id TransitionID) []ParameterSpec { - required := func(names ...string) []ParameterSpec { - result := make([]ParameterSpec, 0, len(names)) - for _, name := range names { - result = append(result, ParameterSpec{Name: name, Required: true}) - } - return result - } - switch id { - case "repository.attach": - return required("topology", "config_authority") - case "runtime.hydrate", "runtime.replace", "installation.update": - return required("source_revision", "runtime_path", "runtime_sha256") - case "runtime.reconcile": - return required("source_revision", "runtime_path", "runtime_sha256", "transaction_id") - case "installation.initialize": - return required("source_revision", "runtime_path", "runtime_sha256", "config_path", "config_sha256") - case "configuration.initialize", "configuration.mutate": - return required("config_path", "config_sha256") - case "configuration.reconcile", "workspace.reconcile": - return required("transaction_id") - case "goal.configure": - return required("goal_kind", "delivery_id") - case "plan.create", "plan.amend": - return required("source_path", "delivery_id") - case "plan.approve", "plan.approve-amendment": - return required("plan_fingerprint", "actor") - case "workspace.cut": - return required("branch", "base_ref", "destination") - case "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon": - return required("branch") - case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record": - return required("source_revision", "evidence_path", "evidence_fingerprint") - case "evidence.visual.attach": - return required("manifest_path", "privacy_receipt", "source_revision") - case "delivery.slice.advance": - return required("slice_id", "source_revision") - case "publication.preview": - return required("base_ref", "head_ref", "body_path") - case "publication.execute": - return required("preview_fingerprint") - case "publication.observe": - return required("publication_id") - case "publication.reconcile": - return required("publication_id", "transaction_id") - case "publication.correct": - return required("publication_id", "body_path", "body_sha256") - case "recovery.resume", "recovery.rollback", "recovery.escalate": - return required("transaction_id") - default: - return nil - } -} diff --git a/boatstack/internal/kernel/catalog/default_test.go b/boatstack/internal/kernel/catalog/default_test.go deleted file mode 100644 index 0725138..0000000 --- a/boatstack/internal/kernel/catalog/default_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package catalog - -import ( - "testing" - - "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" -) - -func TestDefaultRegistryIsTheCompleteRuntimeCatalog(t *testing.T) { - // control-law: one-runtime-registry-owns-every-managed-event - registry := Default() - if got := registry.Len(); got != DefaultTransitionCount { - t.Fatalf("registry contains %d transitions, want %d", got, DefaultTransitionCount) - } - counts := map[EventClass]int{} - for _, transition := range registry.All() { - counts[transition.Class]++ - if transition.Controllable() && transition.Effect == "" { - t.Fatalf("controllable transition %q has no effect", transition.ID) - } - if !transition.Controllable() && (transition.Effect != "" || len(transition.OwnedResources) != 0) { - t.Fatalf("observed event %q owns an effect", transition.ID) - } - } - if counts[EventObservedExternal] != 13 { - t.Fatalf("observed external events = %d, want 13", counts[EventObservedExternal]) - } -} - -func TestExternalEffectsRequireProviderAndHumanOrAutonomyAuthority(t *testing.T) { - transition, _ := Default().Lookup("publication.execute") - if (AuthoritySet{AuthorityHuman: true}).Satisfies(transition.Authority, transition.AuthorityAll) { - t.Fatal("human authority substituted for provider authority") - } - if (AuthoritySet{AuthorityProvider: true}).Satisfies(transition.Authority, transition.AuthorityAll) { - t.Fatal("provider authority substituted for human/autonomy authority") - } - if !(AuthoritySet{AuthorityHuman: true, AuthorityProvider: true}).Satisfies(transition.Authority, transition.AuthorityAll) { - t.Fatal("complete external authority set was rejected") - } -} - -func TestEveryControllableTransitionNamesAnExecutableRecoveryOperator(t *testing.T) { - registry := Default() - for _, transition := range registry.All() { - if !transition.Controllable() { - continue - } - recovery, ok := registry.Lookup(transition.Interruption.Recovery) - if !ok || recovery.Class != EventRecovery { - t.Fatalf("transition %s recovery=%s is not executable", transition.ID, transition.Interruption.Recovery) - } - if transition.Interruption.Recovery == "recovery.enter" { - t.Fatalf("transition %s references deleted synthetic recovery entry", transition.ID) - } - } -} - -func TestRegistryGraphEveryDeclaredPhaseCanReachMarkedOutcome(t *testing.T) { - // control-law: every-reachable-managed-state-is-coreachable - registry := Default() - edges := map[model.ProtocolPhase][]model.ProtocolPhase{} - states := map[model.ProtocolPhase]bool{} - for _, transition := range registry.All() { - for _, source := range transition.SourcePhases { - states[source] = true - for _, target := range transition.TargetPhases { - edges[source] = append(edges[source], target) - states[target] = true - } - } - } - marked := map[model.ProtocolPhase]bool{model.PhaseFrontier: true, model.PhaseTerminal: true, model.PhaseAbandoned: true} - for state := range states { - if !canReachMarked(state, edges, marked, map[model.ProtocolPhase]bool{}) { - t.Errorf("phase %s has no catalog path to a marked outcome", state) - } - } -} - -func canReachMarked(state model.ProtocolPhase, edges map[model.ProtocolPhase][]model.ProtocolPhase, marked, visiting map[model.ProtocolPhase]bool) bool { - if marked[state] { - return true - } - if visiting[state] { - return false - } - visiting[state] = true - defer delete(visiting, state) - for _, next := range edges[state] { - if canReachMarked(next, edges, marked, visiting) { - return true - } - } - return false -} diff --git a/boatstack/internal/kernel/catalog/goal_contract.go b/boatstack/internal/kernel/catalog/goal_contract.go new file mode 100644 index 0000000..6c413b4 --- /dev/null +++ b/boatstack/internal/kernel/catalog/goal_contract.go @@ -0,0 +1,85 @@ +package catalog + +import ( + "fmt" + "sort" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +// GoalContract is the compiled terminal law supplied by the primary flow. +// Extension conditions are conjunctive and therefore can only narrow the +// terminal set. +type GoalContract struct { + GoalKind model.GoalKind `json:"goal_kind"` + Conditions []FacetCondition `json:"conditions"` +} + +type GoalContracts map[model.GoalKind]GoalContract + +func (c GoalContracts) Clone() GoalContracts { + result := make(GoalContracts, len(c)) + for goal, contract := range c { + contract.Conditions = cloneConditions(contract.Conditions) + result[goal] = contract + } + return result +} + +func NewGoalContracts(base []GoalContract, extension map[model.GoalKind][]FacetCondition) (GoalContracts, error) { + contracts := make(GoalContracts, len(base)) + for _, contract := range base { + if !contract.GoalKind.Valid() || len(contract.Conditions) == 0 { + return nil, fmt.Errorf("goal contract requires a valid goal and conditions") + } + if _, exists := contracts[contract.GoalKind]; exists { + return nil, fmt.Errorf("duplicate goal contract %q", contract.GoalKind) + } + conditions := append([]FacetCondition(nil), contract.Conditions...) + conditions = append(conditions, extension[contract.GoalKind]...) + for _, condition := range conditions { + if !condition.Facet.Valid() || len(condition.Statuses) == 0 { + return nil, fmt.Errorf("goal %q has invalid terminal condition", contract.GoalKind) + } + for _, status := range condition.Statuses { + if !status.Valid() { + return nil, fmt.Errorf("goal %q has invalid terminal status %q", contract.GoalKind, status) + } + } + } + contract.Conditions = conditions + contracts[contract.GoalKind] = contract + } + for goal := range extension { + if _, exists := contracts[goal]; !exists { + return nil, fmt.Errorf("extension constrains unsupported goal %q", goal) + } + } + return contracts, nil +} + +func (c GoalContracts) Matches(snapshot model.Snapshot, goal model.Goal) bool { + if snapshot.Goal.Status != model.FactKnown || snapshot.Goal.Value != goal { + return false + } + contract, ok := c[goal.Kind] + if !ok { + return false + } + for _, condition := range contract.Conditions { + if !condition.Matches(snapshot) { + return false + } + } + return true +} + +func (c GoalContracts) All() []GoalContract { + result := make([]GoalContract, 0, len(c)) + for _, contract := range c { + contract.Conditions = append([]FacetCondition(nil), contract.Conditions...) + result = append(result, contract) + } + sort.Slice(result, func(i, j int) bool { return result[i].GoalKind < result[j].GoalKind }) + return result +} diff --git a/boatstack/internal/kernel/catalog/predicates.go b/boatstack/internal/kernel/catalog/predicates.go deleted file mode 100644 index e7af512..0000000 --- a/boatstack/internal/kernel/catalog/predicates.go +++ /dev/null @@ -1,281 +0,0 @@ -package catalog - -import ( - "strings" - - "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" -) - -func known(facet model.FacetName, values ...string) FacetCondition { - return FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: values} -} - -func statuses(facet model.FacetName, values ...model.FactStatus) FacetCondition { - return FacetCondition{Facet: facet, Statuses: values} -} - -func requiresEngagement(id TransitionID) bool { - value := string(id) - return strings.HasPrefix(value, "plan.") || strings.HasPrefix(value, "workspace.") || - strings.HasPrefix(value, "gate.") || strings.HasPrefix(value, "evidence.") || - strings.HasPrefix(value, "delivery.") || strings.HasPrefix(value, "publication.") || - id == "configuration.mutate" || id == "installation.update" -} - -func requiresHealthyConfiguration(id TransitionID) bool { - if id == "engagement.begin" || id == "engagement.renew" || id == "engagement.release" || id == "installation.update" { - return true - } - value := string(id) - if strings.HasPrefix(value, "plan.") { - return id != "plan.invalidate" && id != "plan.abandon" - } - if strings.HasPrefix(value, "workspace.") { - return id != "workspace.abandon" && id != "workspace.reconcile" - } - if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance" { - return true - } - return strings.HasPrefix(value, "publication.") && id != "publication.abandon" -} - -func requiresHealthyRuntime(id TransitionID) bool { - if id == "engagement.begin" || id == "engagement.renew" || id == "engagement.release" || id == "configuration.mutate" { - return true - } - value := string(id) - if strings.HasPrefix(value, "plan.") { - return id != "plan.invalidate" && id != "plan.abandon" - } - if strings.HasPrefix(value, "workspace.") { - return id != "workspace.abandon" && id != "workspace.reconcile" - } - if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance" { - return true - } - return strings.HasPrefix(value, "publication.") && id != "publication.abandon" -} - -func usesConfigurationPolicy(id TransitionID) bool { - switch id { - case "plan.approve", "plan.approve-amendment", "gate.review.record", "evidence.visual.attach": - return true - default: - return false - } -} - -func sourceConditions(id TransitionID) []FacetCondition { - switch id { - case "engagement.begin": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant), string(model.EngagementCommand)), known(model.FacetGoal)} - case "engagement.renew": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive)), known(model.FacetGoal)} - case "engagement.release": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive), string(model.EngagementStale), string(model.EngagementCommand)), known(model.FacetGoal)} - case "invocation.rebind": - return []FacetCondition{known(model.FacetTopology)} - case "repository.attach": - return []FacetCondition{known(model.FacetTopology, string(model.TopologyEmbedded))} - case "repository.detach": - return []FacetCondition{known(model.FacetTopology, string(model.TopologyDetached), string(model.TopologyHybrid))} - case "runtime.hydrate": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeStale), string(model.RuntimeInvalid), string(model.RuntimeConflicting), string(model.RuntimeWrongSource), string(model.RuntimePartiallyPublished))} - case "runtime.replace", "installation.update": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified), string(model.RuntimeStale), string(model.RuntimeInvalid), string(model.RuntimeConflicting), string(model.RuntimeWrongSource), string(model.RuntimePartiallyPublished))} - case "runtime.reconcile", "configuration.reconcile", "workspace.reconcile": - return []FacetCondition{known(model.FacetRecoveryInfo)} - case "configuration.initialize": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationUnsupported), string(model.ConfigurationStale), string(model.ConfigurationConflicting))} - case "configuration.mutate": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified), string(model.ConfigurationStale), string(model.ConfigurationDivergent), string(model.ConfigurationConflicting))} - case "installation.initialize": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeInvalid), string(model.RuntimeStale))} - case "goal.configure": - return []FacetCondition{statuses(model.FacetGoal, model.FactKnown, model.FactAbsent)} - case "plan.create": - return []FacetCondition{known(model.FacetPlan, string(model.PlanAbsent), string(model.PlanInvalid), string(model.PlanStale))} - case "plan.validate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft))} - case "plan.approve": - return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))} - case "plan.activate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved))} - case "plan.amend": - return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved), string(model.PlanLocked), string(model.PlanStale), string(model.PlanAmendmentRequired))} - case "plan.approve-amendment": - return []FacetCondition{known(model.FacetPlan, string(model.PlanAmendmentRequired))} - case "plan.invalidate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft), string(model.PlanValid), string(model.PlanApproved), string(model.PlanLocked), string(model.PlanStale))} - case "plan.abandon", "publication.abandon": - return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryUninitialized), string(model.DeliveryPlanning), string(model.DeliveryApproved), string(model.DeliveryActive), string(model.DeliveryGatesPassed), string(model.DeliveryPublished), string(model.DeliveryAmendment), string(model.DeliveryInvalid), string(model.DeliveryRecovery))} - case "workspace.cut": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceAbsent))} - case "workspace.sync", "workspace.activate": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut), string(model.WorkspaceActive))} - case "workspace.publish": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceActive))} - case "workspace.cleanup", "workspace.reap": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceLanded), string(model.WorkspaceAbandoned))} - case "workspace.abandon": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut), string(model.WorkspaceActive), string(model.WorkspacePublished), string(model.WorkspaceAttentionRequired))} - case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record": - return []FacetCondition{known(model.FacetPlan, string(model.PlanLocked)), known(model.FacetDelivery, string(model.DeliveryActive), string(model.DeliveryGatesPassed))} - case "evidence.visual.attach": - return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive), string(model.DeliveryGatesPassed), string(model.DeliveryPublished))} - case "evidence.approval.revoke": - return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved), string(model.PlanLocked))} - case "delivery.slice.advance": - return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive))} - case "publication.preview": - return []FacetCondition{ - known(model.FacetPlan, string(model.PlanLocked)), - known(model.FacetVerification, string(model.VerificationCurrent)), - known(model.FacetWorkspace, string(model.WorkspaceActive), string(model.WorkspacePublished)), - } - case "publication.execute": - return []FacetCondition{ - known(model.FacetPublication, string(model.PublicationCandidate)), - known(model.FacetVerification, string(model.VerificationCurrent)), - known(model.FacetWorkspace, string(model.WorkspaceActive), string(model.WorkspacePublished)), - } - case "publication.observe": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationCandidate), string(model.PublicationPublishedNotLanded), string(model.PublicationOpen), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting))} - case "publication.reconcile": - return []FacetCondition{known(model.FacetRecoveryInfo), known(model.FacetPublication)} - case "publication.correct": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen)), known(model.FacetVerification, string(model.VerificationCurrent))} - case "recovery.resume", "recovery.rollback", "recovery.escalate": - return []FacetCondition{known(model.FacetRecoveryInfo)} - case "external.files-changed", "external.head-changed": - return []FacetCondition{known(model.FacetVerification)} - case "external.branch-changed": - return []FacetCondition{known(model.FacetWorkspace)} - case "external.runtime-disappeared": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified), string(model.RuntimeStale))} - case "external.configuration-drifted": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified))} - case "external.lease-expired": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive), string(model.EngagementCommand))} - case "external.host-interrupted": - return []FacetCondition{known(model.FacetTransaction, string(model.TransactionStaged), string(model.TransactionLocalApplied), string(model.TransactionExternalUncertain), string(model.TransactionVerifying)), known(model.FacetTransactionInfo)} - case "external.ci-completed": - return []FacetCondition{known(model.FacetVerification)} - case "external.pr-opened", "external.pr-updated", "external.pr-closed", "external.pr-merged", "external.provider-unavailable": - return []FacetCondition{known(model.FacetPublication)} - default: - return nil - } -} - -func targetConditions(id TransitionID) []FacetCondition { - switch id { - case "engagement.begin": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementCommand))} - case "engagement.renew": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive))} - case "engagement.release", "repository.detach": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant))} - case "invocation.rebind": - return []FacetCondition{known(model.FacetTopology)} - case "repository.attach": - return []FacetCondition{known(model.FacetTopology, string(model.TopologyDetached), string(model.TopologyHybrid))} - case "runtime.hydrate", "runtime.replace", "installation.update": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified))} - case "runtime.reconcile": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified)), known(model.FacetTransaction, string(model.TransactionNone))} - case "configuration.initialize", "configuration.mutate": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified))} - case "configuration.reconcile": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified)), known(model.FacetTransaction, string(model.TransactionNone))} - case "installation.initialize": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified)), known(model.FacetConfiguration, string(model.ConfigurationVerified))} - case "goal.configure": - return []FacetCondition{known(model.FacetGoal)} - case "plan.create": - return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft))} - case "plan.validate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))} - case "plan.approve", "plan.approve-amendment": - return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved))} - case "plan.activate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanLocked))} - case "plan.amend": - return []FacetCondition{known(model.FacetPlan, string(model.PlanAmendmentRequired))} - case "plan.invalidate": - return []FacetCondition{known(model.FacetPlan, string(model.PlanInvalid))} - case "plan.abandon", "publication.abandon": - return []FacetCondition{ - known(model.FacetDelivery, string(model.DeliveryDiscarded)), - known(model.FacetRecovery, string(model.RecoveryNone)), - known(model.FacetTransaction, string(model.TransactionNone)), - } - case "workspace.cut": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut))} - case "workspace.sync", "workspace.activate": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceActive))} - case "workspace.publish": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspacePublished))} - case "workspace.cleanup", "workspace.reap": - return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceAbsent))} - case "workspace.abandon": - return []FacetCondition{ - known(model.FacetWorkspace, string(model.WorkspaceAbandoned)), - known(model.FacetRecovery, string(model.RecoveryNone)), - known(model.FacetTransaction, string(model.TransactionNone)), - } - case "workspace.reconcile", "recovery.resume", "recovery.rollback": - return []FacetCondition{known(model.FacetRecovery, string(model.RecoveryNone)), known(model.FacetTransaction, string(model.TransactionNone))} - case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record": - return []FacetCondition{known(model.FacetVerification, string(model.VerificationCurrent))} - case "evidence.visual.attach": - return []FacetCondition{known(model.FacetDelivery)} - case "evidence.approval.revoke": - return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))} - case "delivery.slice.advance": - return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive))} - case "publication.preview": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationCandidate))} - case "publication.execute": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationPublishedNotLanded))} - case "publication.observe": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen), string(model.PublicationMerged), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting))} - case "publication.reconcile": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen), string(model.PublicationMerged), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting)), known(model.FacetTransaction, string(model.TransactionNone))} - case "publication.correct": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationPublishedNotLanded))} - case "recovery.escalate": - return []FacetCondition{known(model.FacetRecovery, string(model.RecoveryEscalated)), known(model.FacetTransaction, string(model.TransactionNone))} - case "external.files-changed", "external.head-changed", "external.ci-completed": - return []FacetCondition{known(model.FacetVerification)} - case "external.branch-changed": - return []FacetCondition{known(model.FacetWorkspace)} - case "external.runtime-disappeared": - return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeStale))} - case "external.configuration-drifted": - return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationStale), string(model.ConfigurationDivergent), string(model.ConfigurationConflicting))} - case "external.lease-expired": - return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant), string(model.EngagementStale))} - case "external.host-interrupted": - return []FacetCondition{known(model.FacetRecovery)} - case "external.pr-opened", "external.pr-updated": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen))} - case "external.pr-closed": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationClosedUnmerged), string(model.PublicationMerged))} - case "external.pr-merged": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationMerged))} - case "external.provider-unavailable": - return []FacetCondition{known(model.FacetPublication, string(model.PublicationUnavailable))} - default: - return nil - } -} - -func requiredAuthorities(id TransitionID) []AuthorityClass { - switch id { - case "publication.execute", "publication.correct": - return []AuthorityClass{AuthorityProvider} - default: - return nil - } -} diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go index 990eccf..7485211 100644 --- a/boatstack/internal/kernel/catalog/transition.go +++ b/boatstack/internal/kernel/catalog/transition.go @@ -4,7 +4,6 @@ import ( "fmt" "regexp" "sort" - "strings" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) @@ -12,6 +11,73 @@ import ( type TransitionID string type EffectID string +type OriginKind string + +const ( + OriginCoreSystem OriginKind = "core-system" + OriginPrimaryFlow OriginKind = "primary-flow" + OriginExtension OriginKind = "extension" +) + +func (k OriginKind) Valid() bool { + switch k { + case OriginCoreSystem, OriginPrimaryFlow, OriginExtension: + return true + default: + return false + } +} + +type TransitionOrigin struct { + Kind OriginKind `json:"kind"` + ID string `json:"id"` + Version string `json:"version"` + ManifestFingerprint string `json:"manifest_fingerprint"` +} + +type SelectionClass string + +const ( + SelectionSystemRecovery SelectionClass = "SYSTEM_RECOVERY" + SelectionFlowRecovery SelectionClass = "FLOW_RECOVERY" + SelectionExtensionRecovery SelectionClass = "EXTENSION_RECOVERY" + SelectionGoalRequired SelectionClass = "GOAL_REQUIRED" + SelectionFlowProgress SelectionClass = "FLOW_PROGRESS" + SelectionExplicitOnly SelectionClass = "EXPLICIT_ONLY" + SelectionObservedExternal SelectionClass = "OBSERVED_EXTERNAL" +) + +func (c SelectionClass) Valid() bool { + switch c { + case SelectionSystemRecovery, SelectionFlowRecovery, SelectionExtensionRecovery, SelectionGoalRequired, + SelectionFlowProgress, SelectionExplicitOnly, SelectionObservedExternal: + return true + default: + return false + } +} + +func (c SelectionClass) rank() int { + switch c { + case SelectionSystemRecovery: + return 1 + case SelectionFlowRecovery: + return 2 + case SelectionExtensionRecovery: + return 3 + case SelectionGoalRequired: + return 4 + case SelectionFlowProgress: + return 5 + case SelectionExplicitOnly: + return 6 + case SelectionObservedExternal: + return 7 + default: + return 99 + } +} + type EventClass string const ( @@ -24,15 +90,6 @@ const ( func (c EventClass) Controllable() bool { return c != EventObservedExternal } -func GateName(id TransitionID) (string, bool) { - value := string(id) - if !strings.HasPrefix(value, "gate.") || !strings.HasSuffix(value, ".record") { - return "", false - } - name := strings.TrimSuffix(strings.TrimPrefix(value, "gate."), ".record") - return name, name != "" -} - func (c EventClass) Valid() bool { switch c { case EventOwnedLocal, EventOwnedExternal, EventAuthority, EventObservedExternal, EventRecovery: @@ -119,6 +176,16 @@ type InterruptionContract struct { ResumptionPredicate string `json:"resumption_predicate"` } +type PolicyContract struct { + RequiredWhen string `json:"required_when,omitempty"` + AuthorityRule string `json:"authority_rule,omitempty"` + AvailabilityRule string `json:"availability_rule,omitempty"` + CurrentEvidencePrefix string `json:"current_evidence_prefix,omitempty"` + ManagedOperations []string `json:"managed_operations,omitempty"` + BindsRequestedGoal bool `json:"binds_requested_goal,omitempty"` + ReconcilesProgram bool `json:"reconciles_program,omitempty"` +} + // FacetCondition is an executable, serializable predicate over one canonical // control facet. Values are ORed; conditions on a transition are ANDed. type FacetCondition struct { @@ -156,38 +223,44 @@ func (c FacetCondition) Matches(snapshot model.Snapshot) bool { // Transition is both the executable runtime declaration and the source for the // generated finite-state model. No second graph is maintained. type Transition struct { - ID TransitionID `json:"id"` - Version int `json:"version"` - Class EventClass `json:"class"` - SourcePhases []model.ProtocolPhase `json:"source_phases"` - TargetPhases []model.ProtocolPhase `json:"target_phases"` - GoalKinds []model.GoalKind `json:"goal_kinds,omitempty"` - RequiredIdentity []string `json:"required_identity"` - Authority []AuthorityClass `json:"authority"` - AuthorityAll []AuthorityClass `json:"authority_all,omitempty"` - RequiredEvidence []string `json:"required_evidence"` - OwnedResources []string `json:"owned_resources,omitempty"` - Effect EffectID `json:"effect,omitempty"` - LocalEffects []EffectID `json:"local_effects,omitempty"` - ExternalEffects []EffectID `json:"external_effects,omitempty"` - Idempotent bool `json:"idempotent"` - Parameters []ParameterSpec `json:"parameters,omitempty"` - Prescription Prescription `json:"prescription"` - SourcePredicate string `json:"source_predicate"` - SourceConditions []FacetCondition `json:"source_conditions"` - AdmissionPredicate string `json:"admission_predicate"` - TargetPredicate string `json:"target_predicate"` - TargetConditions []FacetCondition `json:"target_conditions"` - Verifier string `json:"verifier"` - Interruption InterruptionContract `json:"interruption"` - Reversibility Reversibility `json:"reversibility"` - TerminalEffect string `json:"terminal_effect,omitempty"` - PrivacyClassification string `json:"privacy_classification"` - TelemetryClassification string `json:"telemetry_classification"` - CostClass string `json:"cost_class"` - Priority int `json:"priority"` - AllowsIdentityRebind bool `json:"allows_identity_rebind,omitempty"` - AllowsWorktreeTransfer bool `json:"allows_worktree_transfer,omitempty"` + ID TransitionID `json:"id"` + Version int `json:"version"` + Origin TransitionOrigin `json:"origin"` + Owner string `json:"owner"` + SelectionClass SelectionClass `json:"selection_class"` + Class EventClass `json:"class"` + SourcePhases []model.ProtocolPhase `json:"source_phases"` + TargetPhases []model.ProtocolPhase `json:"target_phases"` + GoalKinds []model.GoalKind `json:"goal_kinds,omitempty"` + RequiredIdentity []string `json:"required_identity"` + Authority []AuthorityClass `json:"authority"` + AuthorityAll []AuthorityClass `json:"authority_all,omitempty"` + RequiredEvidence []string `json:"required_evidence"` + OwnedResources []string `json:"owned_resources,omitempty"` + Effect EffectID `json:"effect,omitempty"` + LocalEffects []EffectID `json:"local_effects,omitempty"` + ExternalEffects []EffectID `json:"external_effects,omitempty"` + Idempotent bool `json:"idempotent"` + Parameters []ParameterSpec `json:"parameters,omitempty"` + Prescription Prescription `json:"prescription"` + SourcePredicate string `json:"source_predicate"` + SourceConditions []FacetCondition `json:"source_conditions"` + AdmissionPredicate string `json:"admission_predicate"` + TargetPredicate string `json:"target_predicate"` + TargetConditions []FacetCondition `json:"target_conditions"` + Verifier string `json:"verifier"` + Interruption InterruptionContract `json:"interruption"` + Reversibility Reversibility `json:"reversibility"` + TerminalEffect string `json:"terminal_effect,omitempty"` + PrivacyClassification string `json:"privacy_classification"` + TelemetryClassification string `json:"telemetry_classification"` + CostClass string `json:"cost_class"` + Policy PolicyContract `json:"policy,omitempty"` + Priority int `json:"priority"` + AllowsIdentityRebind bool `json:"allows_identity_rebind,omitempty"` + AllowsWorktreeTransfer bool `json:"allows_worktree_transfer,omitempty"` + BindsSourceRevision bool `json:"binds_source_revision,omitempty"` + AuthorityFingerprintParameter string `json:"authority_fingerprint_parameter,omitempty"` } func (t Transition) Controllable() bool { return t.Class.Controllable() } @@ -198,18 +271,11 @@ func (t Transition) Controllable() bool { return t.Class.Controllable() } // transition, but cannot outrank the configured goal by merely being // admissible from the same snapshot. func (t Transition) ImplicitlySelectable() bool { - switch t.ID { - case "engagement.renew", "engagement.release", - "invocation.rebind", "repository.attach", "repository.detach", - "runtime.replace", "configuration.mutate", "installation.update", - "plan.amend", "plan.invalidate", "plan.abandon", - "workspace.sync", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon", - "gate.change.record", "gate.journey.record", "evidence.approval.revoke", "delivery.slice.advance", - "publication.correct", "publication.abandon": - return false - default: - return true - } + return t.SelectionClass == SelectionSystemRecovery || + t.SelectionClass == SelectionFlowRecovery || + t.SelectionClass == SelectionExtensionRecovery || + t.SelectionClass == SelectionGoalRequired || + t.SelectionClass == SelectionFlowProgress } func (t Transition) SupportsGoal(goal model.Goal) bool { @@ -262,14 +328,18 @@ func (t Transition) DeclaresTargetPhase(phase model.ProtocolPhase) bool { } type Registry struct { - ordered []Transition - byID map[TransitionID]Transition + ordered []Transition + byID map[TransitionID]Transition + managedOperation map[string]TransitionID } var semanticID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$`) func New(transitions []Transition) (Registry, error) { - registry := Registry{ordered: append([]Transition(nil), transitions...), byID: make(map[TransitionID]Transition, len(transitions))} + registry := Registry{ + ordered: cloneTransitions(transitions), byID: make(map[TransitionID]Transition, len(transitions)), + managedOperation: make(map[string]TransitionID), + } for index, transition := range registry.ordered { if err := validateTransition(transition); err != nil { return Registry{}, fmt.Errorf("transition %d: %w", index, err) @@ -277,7 +347,13 @@ func New(transitions []Transition) (Registry, error) { if _, exists := registry.byID[transition.ID]; exists { return Registry{}, fmt.Errorf("duplicate transition id %q", transition.ID) } - registry.byID[transition.ID] = transition + registry.byID[transition.ID] = cloneTransition(transition) + for _, operation := range transition.Policy.ManagedOperations { + if prior, exists := registry.managedOperation[operation]; exists { + return Registry{}, fmt.Errorf("managed operation %q is owned by both %q and %q", operation, prior, transition.ID) + } + registry.managedOperation[operation] = transition.ID + } } for _, transition := range registry.ordered { if recovery := transition.Interruption.Recovery; recovery != "" { @@ -288,6 +364,9 @@ func New(transitions []Transition) (Registry, error) { } } sort.SliceStable(registry.ordered, func(i, j int) bool { + if registry.ordered[i].SelectionClass.rank() != registry.ordered[j].SelectionClass.rank() { + return registry.ordered[i].SelectionClass.rank() < registry.ordered[j].SelectionClass.rank() + } if registry.ordered[i].Priority != registry.ordered[j].Priority { return registry.ordered[i].Priority < registry.ordered[j].Priority } @@ -303,6 +382,21 @@ func validateTransition(t Transition) error { if !t.Class.Valid() { return fmt.Errorf("%s: invalid event class %q", t.ID, t.Class) } + if !t.Origin.Kind.Valid() || t.Origin.ID == "" || t.Origin.Version == "" || t.Origin.ManifestFingerprint == "" || t.Owner == "" { + return fmt.Errorf("%s: transition origin, owner, version, and manifest fingerprint are required", t.ID) + } + if !t.SelectionClass.Valid() { + return fmt.Errorf("%s: invalid selection class %q", t.ID, t.SelectionClass) + } + if t.Class == EventObservedExternal && t.SelectionClass != SelectionObservedExternal { + return fmt.Errorf("%s: observed external transition must use OBSERVED_EXTERNAL selection", t.ID) + } + if t.Class != EventObservedExternal && t.SelectionClass == SelectionObservedExternal { + return fmt.Errorf("%s: controllable transition cannot use OBSERVED_EXTERNAL selection", t.ID) + } + if err := validateSelectionOwnership(t); err != nil { + return err + } if t.Version < 1 || len(t.SourcePhases) == 0 || len(t.TargetPhases) == 0 { return fmt.Errorf("%s: version, source phases, and target phases are required", t.ID) } @@ -311,6 +405,13 @@ func validateTransition(t Transition) error { return fmt.Errorf("%s: invalid phase %q", t.ID, phase) } } + goalKinds := map[model.GoalKind]bool{} + for _, goal := range t.GoalKinds { + if !goal.Valid() || goalKinds[goal] { + return fmt.Errorf("%s: goal kinds must be valid and unique", t.ID) + } + goalKinds[goal] = true + } if len(t.Authority) == 0 || len(t.RequiredEvidence) == 0 { return fmt.Errorf("%s: authority and evidence declarations are required", t.ID) } @@ -376,6 +477,23 @@ func validateTransition(t Transition) error { } parameterNames[parameter.Name] = true } + managedOperations := map[string]bool{} + for _, operation := range t.Policy.ManagedOperations { + if !semanticID.MatchString(operation) || managedOperations[operation] { + return fmt.Errorf("%s: managed operations must be semantic and unique", t.ID) + } + managedOperations[operation] = true + } + if t.Policy.BindsRequestedGoal && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetGoal)) { + return fmt.Errorf("%s: requested-goal binding requires a CoreSystem goal target", t.ID) + } + if t.Policy.ReconcilesProgram && (t.Origin.Kind != OriginCoreSystem || !conditionNamesFacet(t.TargetConditions, model.FacetProgram)) { + return fmt.Errorf("%s: program reconciliation requires a CoreSystem program target", t.ID) + } + providerDeclared := containsAuthorityClass(t.Authority, AuthorityProvider) || containsAuthorityClass(t.AuthorityAll, AuthorityProvider) + if t.AuthorityFingerprintParameter != "" && (!parameterNames[t.AuthorityFingerprintParameter] || !providerDeclared) { + return fmt.Errorf("%s: provider authority binding requires a declared parameter and mandatory provider authority", t.ID) + } if t.Reversibility == "" || t.PrivacyClassification == "" || t.TelemetryClassification == "" || t.CostClass == "" { return fmt.Errorf("%s: recovery/telemetry metadata are incomplete", t.ID) } @@ -385,21 +503,110 @@ func validateTransition(t Transition) error { return nil } +func conditionNamesFacet(conditions []FacetCondition, facet model.FacetName) bool { + for _, condition := range conditions { + if condition.Facet == facet { + return true + } + } + return false +} + +func validateSelectionOwnership(t Transition) error { + switch t.SelectionClass { + case SelectionSystemRecovery: + if t.Origin.Kind != OriginCoreSystem || t.Class != EventRecovery { + return fmt.Errorf("%s: SYSTEM_RECOVERY is reserved for CoreSystem recovery events", t.ID) + } + case SelectionFlowRecovery: + if t.Origin.Kind != OriginPrimaryFlow || t.Class != EventRecovery { + return fmt.Errorf("%s: FLOW_RECOVERY is reserved for PrimaryFlow recovery events", t.ID) + } + case SelectionExtensionRecovery: + if t.Origin.Kind != OriginExtension || t.Class != EventRecovery { + return fmt.Errorf("%s: EXTENSION_RECOVERY is reserved for extension recovery events", t.ID) + } + default: + if t.Class == EventRecovery { + return fmt.Errorf("%s: recovery event requires its origin-specific recovery selection class", t.ID) + } + } + return nil +} + +func containsAuthorityClass(values []AuthorityClass, wanted AuthorityClass) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + func (r Registry) Len() int { return len(r.ordered) } -func (r Registry) All() []Transition { return append([]Transition(nil), r.ordered...) } +func (r Registry) All() []Transition { return cloneTransitions(r.ordered) } func (r Registry) Lookup(id TransitionID) (Transition, bool) { transition, ok := r.byID[id] - return transition, ok + return cloneTransition(transition), ok +} + +// ManagedTransition resolves a host-classified operation through the compiled +// program. Command classifiers never select flow transition IDs themselves. +func (r Registry) ManagedTransition(operation string) (Transition, bool) { + id, ok := r.managedOperation[operation] + if !ok { + return Transition{}, false + } + return r.Lookup(id) } func (r Registry) Admissible(snapshot model.Snapshot, goal model.Goal) []Transition { var result []Transition for _, transition := range r.ordered { if transition.Controllable() && transition.SourceMatches(snapshot) && transition.SupportsGoal(goal) { - result = append(result, transition) + result = append(result, cloneTransition(transition)) } } return result } + +func cloneTransitions(values []Transition) []Transition { + result := make([]Transition, len(values)) + for index, value := range values { + result[index] = cloneTransition(value) + } + return result +} + +func cloneTransition(value Transition) Transition { + value.SourcePhases = append([]model.ProtocolPhase(nil), value.SourcePhases...) + value.TargetPhases = append([]model.ProtocolPhase(nil), value.TargetPhases...) + value.GoalKinds = append([]model.GoalKind(nil), value.GoalKinds...) + value.RequiredIdentity = append([]string(nil), value.RequiredIdentity...) + value.Authority = append([]AuthorityClass(nil), value.Authority...) + value.AuthorityAll = append([]AuthorityClass(nil), value.AuthorityAll...) + value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...) + value.OwnedResources = append([]string(nil), value.OwnedResources...) + value.LocalEffects = append([]EffectID(nil), value.LocalEffects...) + value.ExternalEffects = append([]EffectID(nil), value.ExternalEffects...) + value.Parameters = append([]ParameterSpec(nil), value.Parameters...) + value.Prescription.Arguments = append([]string(nil), value.Prescription.Arguments...) + value.SourceConditions = cloneConditions(value.SourceConditions) + value.TargetConditions = cloneConditions(value.TargetConditions) + value.Interruption.Points = append([]string(nil), value.Interruption.Points...) + value.Interruption.PartialState = append([]string(nil), value.Interruption.PartialState...) + value.Policy.ManagedOperations = append([]string(nil), value.Policy.ManagedOperations...) + return value +} + +func cloneConditions(values []FacetCondition) []FacetCondition { + result := make([]FacetCondition, len(values)) + for index, value := range values { + value.Statuses = append([]model.FactStatus(nil), value.Statuses...) + value.Values = append([]string(nil), value.Values...) + result[index] = value + } + return result +} diff --git a/boatstack/internal/kernel/durable/state.go b/boatstack/internal/kernel/durable/state.go index dcd8c4d..64e2624 100644 --- a/boatstack/internal/kernel/durable/state.go +++ b/boatstack/internal/kernel/durable/state.go @@ -25,6 +25,7 @@ type State struct { RepositoryID string `json:"repository_id"` GitCommonID string `json:"git_common_id"` WorktreeID string `json:"worktree_id"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` Revision uint64 `json:"revision"` Phase model.ProtocolPhase `json:"phase"` Engagement model.EngagementState `json:"engagement"` @@ -88,6 +89,9 @@ func (s State) Validate() error { if s.RepositoryID == "" || s.GitCommonID == "" || s.WorktreeID == "" || s.Revision == 0 || s.UpdatedAt.IsZero() { return fmt.Errorf("durable state identity, revision, and update time are required") } + if s.ProgramFingerprint != "" && len(s.ProgramFingerprint) != 64 { + return fmt.Errorf("durable state has invalid program fingerprint") + } if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.Plan.Valid() || !s.Configuration.Valid() || !s.Runtime.Valid() || !s.Publication.Valid() || !s.Verification.Valid() || !s.Recovery.Valid() || !s.Transaction.Valid() || !s.Terminal.Valid() { diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 19626d1..44eb0b1 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -14,21 +14,26 @@ import ( ) type Engine struct { - registry catalog.Registry - control supervisor.Supervisor - observer ports.Observer - clock ports.Clock - locker ports.Locker - journal ports.Journal - effects ports.EffectDriver - receipts ports.ReceiptStore + registry catalog.Registry + control supervisor.Supervisor + programFingerprint string + observer ports.Observer + clock ports.Clock + locker ports.Locker + journal ports.Journal + effects ports.EffectDriver + receipts ports.ReceiptStore } -func New(registry catalog.Registry, observer ports.Observer, clock ports.Clock, locker ports.Locker, journal ports.Journal, effects ports.EffectDriver, receipts ports.ReceiptStore) (Engine, error) { - if registry.Len() == 0 || observer == nil || clock == nil || locker == nil || journal == nil || effects == nil || receipts == nil { - return Engine{}, fmt.Errorf("kernel engine requires registry, observer, clock, locker, journal, effects, and receipt store") +func New(registry catalog.Registry, contracts catalog.GoalContracts, programFingerprint string, observer ports.Observer, clock ports.Clock, locker ports.Locker, journal ports.Journal, effects ports.EffectDriver, receipts ports.ReceiptStore) (Engine, error) { + if registry.Len() == 0 || len(contracts) == 0 || len(programFingerprint) != 64 || observer == nil || clock == nil || locker == nil || journal == nil || effects == nil || receipts == nil { + return Engine{}, fmt.Errorf("kernel engine requires registry, goal contracts, observer, clock, locker, journal, effects, and receipt store") } - return Engine{registry: registry, control: supervisor.New(registry), observer: observer, clock: clock, locker: locker, journal: journal, effects: effects, receipts: receipts}, nil + return Engine{registry: registry, control: supervisor.New(registry, contracts), programFingerprint: programFingerprint, observer: observer, clock: clock, locker: locker, journal: journal, effects: effects, receipts: receipts}, nil +} + +func (e Engine) canonicalize(observation model.Observation) (model.Snapshot, error) { + return model.CanonicalizeForProgram(observation, e.programFingerprint) } type ResolveRequest struct { @@ -46,15 +51,15 @@ type Resolution struct { func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution, error) { if err := request.Invocation.Validate(false); err != nil { - return Resolution{}, err + return unresolvedResolution(request.Goal, "invocation identity is invalid"), err } observation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) if err != nil { - return Resolution{}, fmt.Errorf("observe plant: %w", err) + return unresolvedResolution(request.Goal, "required observation failed"), fmt.Errorf("observe plant: %w", err) } - snapshot, err := model.Canonicalize(observation) + snapshot, err := e.canonicalize(observation) if err != nil { - return Resolution{}, fmt.Errorf("canonicalize observation: %w", err) + return unresolvedResolution(request.Goal, "canonical observation is invalid"), fmt.Errorf("canonicalize observation: %w", err) } if snapshot.Invocation != request.Invocation { return Resolution{}, fmt.Errorf("observer returned a different invocation identity") @@ -74,6 +79,10 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil } +func unresolvedResolution(goal model.Goal, reason string) Resolution { + return Resolution{Goal: goal, Decision: supervisor.Decision{Kind: supervisor.DecisionUnresolved, Reason: reason}} +} + type ApplyRequest struct { ResolveRequest FlowID string @@ -137,14 +146,14 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check supplied idempotency key: %w", err) } if ok { - if err := validateReplayRequest(prior, request); err != nil { + if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) if observeErr != nil { return result, observeErr } - snapshot, canonicalErr := model.Canonicalize(observation) + snapshot, canonicalErr := e.canonicalize(observation) if canonicalErr != nil { return result, canonicalErr } @@ -162,10 +171,10 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe request.AdmissionLifetime = 2 * time.Minute } resolution, err := e.Resolve(ctx, request.ResolveRequest) + result.Source, result.Goal, result.Decision = resolution.Snapshot, resolution.Goal, resolution.Decision if err != nil { return result, err } - result.Source, result.Goal, result.Decision = resolution.Snapshot, resolution.Goal, resolution.Decision request.Goal = resolution.Goal if resolution.Decision.Kind != supervisor.DecisionPrescribed || resolution.Decision.Transition == nil { return result, DecisionError{Decision: resolution.Decision} @@ -187,14 +196,14 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if prior, ok, err := e.receipts.FindByIdempotency(ctx, request.Invocation, admission.IdempotencyKey); err != nil { return result, fmt.Errorf("check idempotency: %w", err) } else if ok { - if err := validateReplayRequest(prior, request); err != nil { + if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) if observeErr != nil { return result, observeErr } - current, canonicalErr := model.Canonicalize(observation) + current, canonicalErr := e.canonicalize(observation) if canonicalErr != nil { return result, canonicalErr } @@ -219,14 +228,14 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err != nil { return result, err } - lockedSnapshot, err := model.Canonicalize(lockedObservation) + lockedSnapshot, err := e.canonicalize(lockedObservation) if err != nil { return result, err } if prior, ok, findErr := e.receipts.FindByIdempotency(ctx, request.Invocation, admission.IdempotencyKey); findErr != nil { return result, fmt.Errorf("check locked idempotency: %w", findErr) } else if ok { - if err := validateReplayRequest(prior, request); err != nil { + if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { return result, err } if !replayStateSettled(lockedSnapshot) { @@ -281,11 +290,11 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if transferred, ok := prepared.VerificationInvocation(); ok { verificationInvocation = transferred } - targetObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: verificationInvocation, IgnoreAdmissionID: admission.ID}) + targetObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: verificationInvocation, IgnoreAdmissionID: admission.ID, VerifyTransitionID: transition.ID}) if err != nil { return result, requireRecovery("target observation failed after effect", err) } - target, err := model.Canonicalize(targetObservation) + target, err := e.canonicalize(targetObservation) if err != nil { return result, requireRecovery("target canonicalization failed after effect", err) } @@ -324,7 +333,10 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, nil } -func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest) error { +func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string) error { + if prior.ProgramFingerprint != programFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different control program") + } if prior.FlowID != request.FlowID { return fmt.Errorf("idempotency receipt belongs to flow %q, not %q", prior.FlowID, request.FlowID) } diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index 568b25c..750cffe 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -13,10 +14,25 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" ) type fixedClock struct{ now time.Time } +const syntheticProgramFingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +func syntheticGoalContracts(t *testing.T) catalog.GoalContracts { + t.Helper() + contracts, err := catalog.NewGoalContracts([]catalog.GoalContract{{ + GoalKind: model.GoalVerified, + Conditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"terminal"}}}, + }}, nil) + if err != nil { + t.Fatal(err) + } + return contracts +} + func (c fixedClock) Now() time.Time { return c.now } func fixtureAbsolutePath(parts ...string) string { @@ -43,6 +59,12 @@ func (o *sequenceObserver) Observe(context.Context, ports.ObservationRequest) (m return item, nil } +type failingObserver struct{ err error } + +func (o failingObserver) Observe(context.Context, ports.ObservationRequest) (model.Observation, error) { + return model.Observation{}, o.err +} + type fakeLock struct{ released bool } func (l *fakeLock) Release() error { l.released = true; return nil } @@ -128,6 +150,12 @@ func (s *memoryReceipts) Append(_ context.Context, receipt protocol.TransitionRe func observation(phase model.ProtocolPhase, fingerprint string) model.Observation { e := model.Evidence{Source: "fixture", Fingerprint: fingerprint, ObservedAt: time.Unix(20, 0).UTC()} configurationEvidence := model.Evidence{Source: "configuration:/repo/.boatstack/project.json", Fingerprint: "config-fingerprint", ObservedAt: time.Unix(20, 0).UTC()} + stage := "start" + if phase == model.PhaseActive { + stage = "terminal" + } else if phase == model.PhaseRecovery { + stage = "verify" + } return model.Observation{ SchemaVersion: model.SnapshotSchemaVersion, Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: fixtureAbsolutePath("test-fixture", "repo"), RuntimePath: fixtureAbsolutePath("test-fixture", "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "corr"}, @@ -137,6 +165,7 @@ func observation(phase model.ProtocolPhase, fingerprint string) model.Observatio Publication: model.Known(model.PublicationNone, e), Verification: model.Known(model.VerificationUnverified, e), Recovery: model.Known(model.RecoveryNone, e), Transaction: model.Known(model.TransactionNone, e), RecoveryInfo: model.Absent[model.RecoveryContext]("none", e), TransactionInfo: model.Absent[model.TransactionContext]("none", e), Terminal: model.Known(model.TerminalNonterminal, e), Goal: model.Known(model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}, e), ObservedAt: time.Unix(20, 0).UTC(), + FlowFacts: map[string]model.Fact[string]{"test.synthetic.stage": model.Known(stage, e)}, } } @@ -166,15 +195,17 @@ func testRegistry(t *testing.T) catalog.Registry { } r, err := catalog.New([]catalog.Transition{{ ID: "test.advance", Version: 1, Class: catalog.EventOwnedLocal, + Origin: catalog.TransitionOrigin{Kind: catalog.OriginPrimaryFlow, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionFlowProgress, SourcePhases: []model.ProtocolPhase{model.PhaseObserved}, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: []catalog.EffectID{"test.advance"}, Idempotent: true, Prescription: catalog.Prescription{Operation: "test.advance", ExpectedPostcondition: "active"}, SourcePredicate: "observed", AdmissionPredicate: "exact-admission", TargetPredicate: "active", Verifier: "fresh-active", - SourceConditions: []catalog.FacetCondition{{Facet: model.FacetEngagement, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.EngagementActive)}}}, - TargetConditions: []catalog.FacetCondition{{Facet: model.FacetEngagement, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.EngagementActive)}}}, + SourceConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"start"}}}, + TargetConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"terminal"}}}, Interruption: interruption("test.recover"), Reversibility: catalog.Reversible, TerminalEffect: "none", PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "test", Priority: 1, }, { ID: "test.recover", Version: 1, Class: catalog.EventRecovery, + Origin: catalog.TransitionOrigin{Kind: catalog.OriginPrimaryFlow, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionFlowRecovery, SourcePhases: []model.ProtocolPhase{model.PhaseRecovery}, TargetPhases: []model.ProtocolPhase{model.PhaseFrontier}, RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.recover", LocalEffects: []catalog.EffectID{"test.recover"}, Idempotent: true, Prescription: catalog.Prescription{Operation: "test.recover", ExpectedPostcondition: "frontier"}, SourcePredicate: "recovery", AdmissionPredicate: "exact-recovery-admission", TargetPredicate: "frontier", Verifier: "fresh-frontier", @@ -197,12 +228,45 @@ func request(now time.Time) ApplyRequest { }, FlowID: "flow", AdmissionLifetime: time.Minute} } +func TestRequiredObserverFailureReturnsTypedUnresolvedDecision(t *testing.T) { + // control-law: required-observation-failure-is-a-typed-fail-closed-decision + now := time.Unix(30, 0).UTC() + journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} + kernel, err := New( + testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, + failingObserver{err: errors.New("observer unavailable")}, fixedClock{now}, + fakeLocker{lock}, journal, effects, receipts, + ) + if err != nil { + t.Fatal(err) + } + + resolved, resolveErr := kernel.Resolve(context.Background(), request(now).ResolveRequest) + if resolveErr == nil || !strings.Contains(resolveErr.Error(), "observer unavailable") { + t.Fatalf("resolve error = %v, want observer failure", resolveErr) + } + if resolved.Decision.Kind != supervisor.DecisionUnresolved || resolved.Decision.Reason != "required observation failed" { + t.Fatalf("resolve decision = %+v, want typed UNRESOLVED", resolved.Decision) + } + + applied, applyErr := kernel.Apply(context.Background(), request(now)) + if applyErr == nil || !strings.Contains(applyErr.Error(), "observer unavailable") { + t.Fatalf("apply error = %v, want observer failure", applyErr) + } + if applied.Decision.Kind != supervisor.DecisionUnresolved || applied.Decision.Reason != "required observation failed" { + t.Fatalf("apply decision = %+v, want typed UNRESOLVED", applied.Decision) + } + if effects.executions != 0 || journal.begun != 0 || len(receipts.values) != 0 { + t.Fatalf("observer failure crossed mutation boundary: effects=%d journal=%d receipts=%d", effects.executions, journal.begun, len(receipts.values)) + } +} + func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) { - // control-law: managed-effect-requires-exact-admission-and-postcondition + // control-law: synthetic-flow-crosses-exact-admission-and-postcondition-without-standard-flow now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target"), observation(model.PhaseActive, "target")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, err := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) if err != nil { t.Fatal(err) } @@ -224,6 +288,46 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) } } +func TestSyntheticStartVerifyTerminalContractNeedsNoStandardFlowFacet(t *testing.T) { + // control-law: kernel-terminal-is-defined-only-by-the-compiled-primary-flow-contract + goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"} + source, err := model.CanonicalizeForProgram(observation(model.PhaseObserved, "source"), syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + syntheticSupervisor := supervisor.New(testRegistry(t), syntheticGoalContracts(t)) + one := syntheticSupervisor.Resolve(source, goal, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + two := syntheticSupervisor.Resolve(source, goal, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + if !reflect.DeepEqual(one, two) || one.Kind != supervisor.DecisionPrescribed || one.Transition == nil || one.Transition.ID != "test.advance" { + t.Fatalf("synthetic resolution is not deterministic: one=%+v two=%+v", one, two) + } + outside := syntheticSupervisor.Resolve(source, goal, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "not.compiled") + if outside.Kind != supervisor.DecisionRefused || outside.Transition != nil { + t.Fatalf("transition outside compiled program was not refused: %+v", outside) + } + + observed := observation(model.PhaseActive, "target") + evidence := observed.Phase.Evidence[0] + observed.Plan = model.Known(model.PlanAbsent, evidence) + observed.Workspace = model.Known(model.WorkspaceAbsent, evidence) + observed.Delivery = model.Known(model.DeliveryPlanning, evidence) + target, err := model.CanonicalizeForProgram(observed, syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + decision := syntheticSupervisor.Resolve(target, goal, nil, "") + if decision.Kind != supervisor.DecisionTerminal { + t.Fatalf("synthetic terminal decision = %+v", decision) + } + withoutContract := supervisor.New(testRegistry(t), catalog.GoalContracts{}).Resolve(target, goal, nil, "") + if withoutContract.Kind == supervisor.DecisionTerminal { + t.Fatalf("synthetic state terminated without a compiled flow goal contract: %+v", withoutContract) + } + if target.Terminal.Value != model.TerminalNonterminal || target.Plan.Value != model.PlanAbsent || target.Publication.Value != model.PublicationNone { + t.Fatalf("fixture unexpectedly relied on StandardFlow terminal state: %+v", target) + } +} + func TestIdempotencyReceiptCannotHideUncommittedRecoveryJournal(t *testing.T) { // control-law: receipt-before-journal-commit-is-not-a-clean-replay now := time.Unix(30, 0).UTC() @@ -232,7 +336,7 @@ func TestIdempotencyReceiptCannotHideUncommittedRecoveryJournal(t *testing.T) { recoveryObservation("recovery"), }} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, err := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) if err != nil { t.Fatal(err) } @@ -257,7 +361,7 @@ func TestApplyRejectsSnapshotDriftBeforeEffect(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "drifted")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(now)) var stale StaleAdmissionError if !errors.As(err, &stale) { @@ -273,7 +377,7 @@ func TestApplyRollsBackFailedPostconditionAndDoesNotReceipt(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(now)) var postcondition PostconditionError if !errors.As(err, &postcondition) { @@ -290,7 +394,7 @@ func TestApplyRequiresRecoveryWhenJournalFailsAfterEffect(t *testing.T) { observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} journal := &fakeJournal{failMark: "verifying"} effects, receipts, lock := &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(now)) if err == nil || !strings.Contains(err.Error(), "injected journal mark failure") { t.Fatalf("error=%v, want injected post-effect journal failure", err) @@ -305,7 +409,7 @@ func TestApplyPreservesUnknownExternalOutcomeForReconciliation(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectUnknown}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(now)) var unknown ExternalOutcomeUnknownError if !errors.As(err, &unknown) { diff --git a/boatstack/internal/kernel/model/facet.go b/boatstack/internal/kernel/model/facet.go index 62a282b..cad16b1 100644 --- a/boatstack/internal/kernel/model/facet.go +++ b/boatstack/internal/kernel/model/facet.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "regexp" "sort" "strings" ) @@ -13,6 +14,7 @@ type FacetName string const ( FacetPhase FacetName = "phase" + FacetProgram FacetName = "program" FacetTopology FacetName = "topology" FacetEngagement FacetName = "engagement" FacetDelivery FacetName = "delivery" @@ -32,23 +34,29 @@ const ( ) var controllingFacets = []FacetName{ - FacetPhase, FacetTopology, FacetEngagement, FacetDelivery, FacetWorkspace, + FacetPhase, FacetProgram, FacetTopology, FacetEngagement, FacetDelivery, FacetWorkspace, FacetPlan, FacetConfiguration, FacetConfigurationPolicy, FacetRuntime, FacetPublication, FacetVerification, FacetRecovery, FacetTransaction, FacetRecoveryInfo, FacetTransactionInfo, FacetTerminal, FacetGoal, } -func ControllingFacets() []FacetName { return append([]FacetName(nil), controllingFacets...) } +var namespacedFacet = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+){2,}$`) -func (f FacetName) Valid() bool { +func controllingFacet(facet FacetName) bool { for _, candidate := range controllingFacets { - if f == candidate { + if facet == candidate { return true } } return false } +func ControllingFacets() []FacetName { return append([]FacetName(nil), controllingFacets...) } + +func (f FacetName) Valid() bool { + return controllingFacet(f) || namespacedFacet.MatchString(string(f)) +} + // Facet returns the status and canonical scalar value used by catalog // predicates. Composite contexts are represented by stable, sorted identity // fields; their evidence remains in the snapshot fingerprint. @@ -56,6 +64,8 @@ func (s Snapshot) Facet(name FacetName) (FactStatus, string, bool) { switch name { case FacetPhase: return s.Phase.Status, string(s.Phase.Value), true + case FacetProgram: + return s.Program.Status, string(s.Program.Value), true case FacetTopology: return FactKnown, string(s.Invocation.Topology), true case FacetEngagement: @@ -95,6 +105,13 @@ func (s Snapshot) Facet(name FacetName) (FactStatus, string, bool) { value := s.Goal.Value return s.Goal.Status, strings.Join([]string{value.ID, string(value.Kind), value.DeliveryID, value.EvidenceFingerprint, fmt.Sprint(value.FrontierIsStop)}, "|"), true default: - return "", "", false + if fact, ok := s.FlowFacts[string(name)]; ok { + return fact.Status, fact.Value, true + } + fact, ok := s.ExtensionFacts[string(name)] + if !ok { + return "", "", false + } + return fact.Status, fact.Value, true } } diff --git a/boatstack/internal/kernel/model/state.go b/boatstack/internal/kernel/model/state.go index d8178c4..24ecc01 100644 --- a/boatstack/internal/kernel/model/state.go +++ b/boatstack/internal/kernel/model/state.go @@ -368,28 +368,50 @@ func (s TerminalStatus) Valid() bool { } } +type ProgramState string + +const ( + ProgramUnbound ProgramState = "unbound" + ProgramCurrent ProgramState = "current" + ProgramDrift ProgramState = "drift" +) + +func (s ProgramState) Valid() bool { + switch s { + case ProgramUnbound, ProgramCurrent, ProgramDrift: + return true + default: + return false + } +} + // Observation is the read-only plant result before canonical validation and // fingerprinting. type Observation struct { - SchemaVersion int `json:"schema_version"` - Invocation InvocationContext `json:"invocation"` - Phase Fact[ProtocolPhase] `json:"phase"` - Engagement Fact[EngagementState] `json:"engagement"` - Delivery Fact[DeliveryState] `json:"delivery"` - Workspace Fact[WorkspaceState] `json:"workspace"` - Plan Fact[PlanState] `json:"plan"` - Configuration Fact[ConfigurationState] `json:"configuration"` - ConfigurationPolicy Fact[ConfigurationPolicy] `json:"configuration_policy"` - Runtime Fact[RuntimeState] `json:"runtime"` - Publication Fact[PublicationState] `json:"publication"` - Verification Fact[VerificationState] `json:"verification"` - Recovery Fact[RecoveryState] `json:"recovery"` - Transaction Fact[TransactionState] `json:"transaction"` - RecoveryInfo Fact[RecoveryContext] `json:"recovery_info"` - TransactionInfo Fact[TransactionContext] `json:"transaction_info"` - Terminal Fact[TerminalStatus] `json:"terminal"` - Goal Fact[Goal] `json:"goal"` - ObservedAt time.Time `json:"observed_at"` + SchemaVersion int `json:"schema_version"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + RecordedProgramFingerprint string `json:"recorded_program_fingerprint,omitempty"` + Invocation InvocationContext `json:"invocation"` + Program Fact[ProgramState] `json:"program"` + Phase Fact[ProtocolPhase] `json:"phase"` + Engagement Fact[EngagementState] `json:"engagement"` + Delivery Fact[DeliveryState] `json:"delivery"` + Workspace Fact[WorkspaceState] `json:"workspace"` + Plan Fact[PlanState] `json:"plan"` + Configuration Fact[ConfigurationState] `json:"configuration"` + ConfigurationPolicy Fact[ConfigurationPolicy] `json:"configuration_policy"` + Runtime Fact[RuntimeState] `json:"runtime"` + Publication Fact[PublicationState] `json:"publication"` + Verification Fact[VerificationState] `json:"verification"` + Recovery Fact[RecoveryState] `json:"recovery"` + Transaction Fact[TransactionState] `json:"transaction"` + RecoveryInfo Fact[RecoveryContext] `json:"recovery_info"` + TransactionInfo Fact[TransactionContext] `json:"transaction_info"` + Terminal Fact[TerminalStatus] `json:"terminal"` + Goal Fact[Goal] `json:"goal"` + FlowFacts map[string]Fact[string] `json:"flow_facts,omitempty"` + ExtensionFacts map[string]Fact[string] `json:"extension_facts,omitempty"` + ObservedAt time.Time `json:"observed_at"` } type Snapshot struct { @@ -397,10 +419,34 @@ type Snapshot struct { Fingerprint string `json:"fingerprint"` } +func CanonicalizeForProgram(observation Observation, programFingerprint string) (Snapshot, error) { + if len(programFingerprint) != 64 { + return Snapshot{}, fmt.Errorf("snapshot: invalid compiled program fingerprint") + } + observation.ProgramFingerprint = programFingerprint + state := ProgramUnbound + if observation.RecordedProgramFingerprint != "" { + state = ProgramDrift + if observation.RecordedProgramFingerprint == programFingerprint { + state = ProgramCurrent + } + } + observation.Program = Known(state, Evidence{ + Source: "compiled-control-program", Fingerprint: programFingerprint, ObservedAt: observation.ObservedAt, + }) + return Canonicalize(observation) +} + func Canonicalize(observation Observation) (Snapshot, error) { if observation.SchemaVersion != SnapshotSchemaVersion { return Snapshot{}, fmt.Errorf("snapshot: schema version %d, want %d", observation.SchemaVersion, SnapshotSchemaVersion) } + if observation.Program.Status == "" && observation.ProgramFingerprint == "" { + observation.Program = Known(ProgramUnbound, Evidence{Source: "control-program:unbound", Fingerprint: "unbound", ObservedAt: observation.ObservedAt}) + } + if observation.ProgramFingerprint != "" && len(observation.ProgramFingerprint) != 64 { + return Snapshot{}, fmt.Errorf("snapshot: invalid program fingerprint") + } if err := observation.Invocation.Validate(false); err != nil { return Snapshot{}, err } @@ -408,6 +454,7 @@ func Canonicalize(observation Observation) (Snapshot, error) { name string err error }{ + {"program", observation.Program.Validate("program")}, {"phase", observation.Phase.Validate("phase")}, {"engagement", observation.Engagement.Validate("engagement")}, {"delivery", observation.Delivery.Validate("delivery")}, @@ -438,6 +485,7 @@ func Canonicalize(observation Observation) (Snapshot, error) { known bool valid bool }{ + {"program", observation.Program.Status == FactKnown, observation.Program.Value.Valid()}, {"engagement", observation.Engagement.Status == FactKnown, observation.Engagement.Value.Valid()}, {"delivery", observation.Delivery.Status == FactKnown, observation.Delivery.Value.Valid()}, {"workspace", observation.Workspace.Status == FactKnown, observation.Workspace.Value.Valid()}, @@ -498,6 +546,22 @@ func Canonicalize(observation Observation) (Snapshot, error) { return Snapshot{}, fmt.Errorf("snapshot: invalid goal fact: %w", err) } } + for id, fact := range observation.FlowFacts { + if !FacetName(id).Valid() || controllingFacet(FacetName(id)) { + return Snapshot{}, fmt.Errorf("snapshot: invalid primary-flow fact id %q", id) + } + if err := fact.Validate("primary-flow fact " + id); err != nil { + return Snapshot{}, err + } + } + for id, fact := range observation.ExtensionFacts { + if !FacetName(id).Valid() || controllingFacet(FacetName(id)) { + return Snapshot{}, fmt.Errorf("snapshot: invalid extension fact id %q", id) + } + if err := fact.Validate("extension fact " + id); err != nil { + return Snapshot{}, err + } + } if observation.ConfigurationPolicy.Status == FactKnown { if err := observation.ConfigurationPolicy.Value.Validate(); err != nil { return Snapshot{}, fmt.Errorf("snapshot: invalid configuration policy: %w", err) @@ -517,6 +581,7 @@ func Canonicalize(observation Observation) (Snapshot, error) { snapshot := Snapshot{Observation: observation} projection := observation projection.ObservedAt = time.Time{} + zeroEvidenceTimes(&projection.Program) zeroEvidenceTimes(&projection.Phase) zeroEvidenceTimes(&projection.Engagement) zeroEvidenceTimes(&projection.Delivery) @@ -533,6 +598,14 @@ func Canonicalize(observation Observation) (Snapshot, error) { zeroEvidenceTimes(&projection.TransactionInfo) zeroEvidenceTimes(&projection.Terminal) zeroEvidenceTimes(&projection.Goal) + for id, fact := range projection.FlowFacts { + zeroEvidenceTimes(&fact) + projection.FlowFacts[id] = fact + } + for id, fact := range projection.ExtensionFacts { + zeroEvidenceTimes(&fact) + projection.ExtensionFacts[id] = fact + } raw, err := json.Marshal(Snapshot{Observation: projection}) if err != nil { return Snapshot{}, fmt.Errorf("snapshot: canonical encoding: %w", err) diff --git a/boatstack/internal/kernel/model/state_test.go b/boatstack/internal/kernel/model/state_test.go index 8d9d4d2..9607f0b 100644 --- a/boatstack/internal/kernel/model/state_test.go +++ b/boatstack/internal/kernel/model/state_test.go @@ -106,6 +106,7 @@ func TestEveryControllingFacetChangesCanonicalIdentity(t *testing.T) { evidence := testEvidence() mutations := map[FacetName]func(*Observation){ FacetPhase: func(o *Observation) { o.Phase = Known(PhaseActive, evidence) }, + FacetProgram: func(o *Observation) { o.Program = Known(ProgramCurrent, evidence) }, FacetTopology: func(o *Observation) { o.Invocation.Topology = TopologyDetached }, FacetEngagement: func(o *Observation) { o.Engagement = Known(EngagementCommand, evidence) }, FacetDelivery: func(o *Observation) { o.Delivery = Known(DeliveryApproved, evidence) }, diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index e3f6fa9..1af9c58 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -14,8 +14,9 @@ type Observer interface { } type ObservationRequest struct { - Invocation model.InvocationContext - IgnoreAdmissionID string + Invocation model.InvocationContext + IgnoreAdmissionID string + VerifyTransitionID catalog.TransitionID } type ControllerLayout struct { @@ -72,6 +73,8 @@ type EffectResult struct { } type ResourceMutation struct { + Resource string `json:"resource"` + Owner string `json:"owner"` Path string `json:"path"` Prior []byte `json:"prior,omitempty"` Target []byte `json:"target,omitempty"` diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go index 0750e64..028cc50 100644 --- a/boatstack/internal/kernel/protocol/admission.go +++ b/boatstack/internal/kernel/protocol/admission.go @@ -16,6 +16,7 @@ type Admission struct { ID string `json:"id"` TransitionID catalog.TransitionID `json:"transition_id"` TransitionVersion int `json:"transition_version"` + ProgramFingerprint string `json:"program_fingerprint"` SnapshotFingerprint string `json:"snapshot_fingerprint"` SourceRevision string `json:"source_revision,omitempty"` WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` @@ -40,7 +41,7 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T if err := goal.Validate(); err != nil { return Admission{}, err } - if snapshot.Fingerprint == "" || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) { + if snapshot.Fingerprint == "" || len(snapshot.ProgramFingerprint) != 64 || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) { return Admission{}, fmt.Errorf("transition %q is not admissible from snapshot %q", transition.ID, snapshot.Fingerprint) } if lifetime <= 0 { @@ -59,7 +60,7 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T return Admission{}, err } sourceRevision, worktreeFingerprint := gitBinding(snapshot) - if transitionBindsSourceRevision(transition.ID) { + if transition.BindsSourceRevision { declared, _ := parameters.Get("source_revision") if sourceRevision == "" || worktreeFingerprint == "" || declared != sourceRevision { return Admission{}, fmt.Errorf("transition %q must bind the current Git revision and worktree fingerprint", transition.ID) @@ -76,7 +77,7 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T } a := Admission{ SchemaVersion: AdmissionSchemaVersion, TransitionID: transition.ID, TransitionVersion: transition.Version, - SnapshotFingerprint: snapshot.Fingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint, + ProgramFingerprint: snapshot.ProgramFingerprint, SnapshotFingerprint: snapshot.Fingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint, SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, Authority: authority.canonical(), Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), } @@ -113,6 +114,9 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra if a.SnapshotFingerprint != snapshot.Fingerprint { return fmt.Errorf("admission %q is stale: snapshot changed", a.ID) } + if a.ProgramFingerprint != snapshot.ProgramFingerprint { + return fmt.Errorf("admission %q is bound to a different control program", a.ID) + } if snapshot.Phase.Status != model.FactKnown || a.SourcePhase != snapshot.Phase.Value { return fmt.Errorf("admission %q is bound to a different source phase", a.ID) } @@ -141,7 +145,7 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra if a.SourceRevision != sourceRevision || a.WorktreeFingerprint != worktreeFingerprint { return fmt.Errorf("admission %q Git binding changed", a.ID) } - if transitionBindsSourceRevision(transition.ID) { + if transition.BindsSourceRevision { declared, _ := a.Parameters.Get("source_revision") if declared != sourceRevision || sourceRevision == "" || worktreeFingerprint == "" { return fmt.Errorf("admission %q is not bound to the current Git revision", a.ID) @@ -167,11 +171,6 @@ func gitBinding(snapshot model.Snapshot) (string, string) { return "", "" } -func transitionBindsSourceRevision(id catalog.TransitionID) bool { - value := string(id) - return strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance" -} - func validateRecoveryPermission(snapshot model.Snapshot, transition catalog.Transition) error { if transition.Class != catalog.EventRecovery { return nil @@ -189,13 +188,8 @@ func validateRecoveryPermission(snapshot model.Snapshot, transition catalog.Tran func validateProviderAuthorityBinding(authority AuthorityBundle, transition catalog.Transition, parameters Parameters) error { var expected string - switch transition.ID { - case "publication.execute": - expected, _ = parameters.Get("preview_fingerprint") - case "publication.correct": - expected, _ = parameters.Get("body_sha256") - case "publication.reconcile": - expected, _ = parameters.Get("publication_id") + if transition.AuthorityFingerprintParameter != "" { + expected, _ = parameters.Get(transition.AuthorityFingerprintParameter) } for _, receipt := range authority.Receipts { if receipt.Class != catalog.AuthorityProvider { @@ -224,8 +218,7 @@ func validatePolicyAuthority(snapshot model.Snapshot, transition catalog.Transit return fmt.Errorf("transition %q is unavailable to disabled host %q", transition.ID, snapshot.Invocation.Host) } } - requiresPolicy := transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment" || - transition.ID == "gate.review.record" || transition.ID == "evidence.visual.attach" + requiresPolicy := transition.Policy.RequiredWhen != "" || transition.Policy.AuthorityRule != "" || transition.Policy.AvailabilityRule != "" if !requiresPolicy { return nil } @@ -233,13 +226,13 @@ func validatePolicyAuthority(snapshot model.Snapshot, transition catalog.Transit return fmt.Errorf("transition %q requires known configuration policy", transition.ID) } policy := snapshot.ConfigurationPolicy.Value - if transition.ID == "evidence.visual.attach" && policy.VisualEvidence == "off" { + if transition.Policy.AvailabilityRule == "visual-evidence-enabled" && policy.VisualEvidence == "off" { return fmt.Errorf("transition %q is disabled by repository policy", transition.ID) } - if (transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment") && policy.PlanApproval == "human" && !authority[catalog.AuthorityHuman] { + if transition.Policy.AuthorityRule == "plan-approval" && policy.PlanApproval == "human" && !authority[catalog.AuthorityHuman] { return fmt.Errorf("transition %q requires human approval under repository policy", transition.ID) } - if transition.ID == "gate.review.record" && policy.IndependentReviewForHighRisk && policy.HighRiskChange && !authority[catalog.AuthorityHuman] { + if transition.Policy.AuthorityRule == "independent-high-risk-review" && policy.IndependentReviewForHighRisk && policy.HighRiskChange && !authority[catalog.AuthorityHuman] { return fmt.Errorf("transition %q requires independent human review for a high-risk change", transition.ID) } return nil @@ -265,7 +258,7 @@ func validateAuthorityEvidence(snapshot model.Snapshot, authority AuthorityBundl } func (a Admission) ValidateIdentity() error { - if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.SnapshotFingerprint == "" || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { + if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || len(a.ProgramFingerprint) != 64 || a.SnapshotFingerprint == "" || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } if err := a.Invocation.Validate(true); err != nil { diff --git a/boatstack/internal/kernel/protocol/config.go b/boatstack/internal/kernel/protocol/config.go index 183283e..fdbd1fa 100644 --- a/boatstack/internal/kernel/protocol/config.go +++ b/boatstack/internal/kernel/protocol/config.go @@ -7,6 +7,8 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" + "regexp" "sort" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" @@ -29,14 +31,29 @@ type PolicySettings struct { ExternalEffectAuthority string `json:"external_effect_authority,omitempty"` } +// SubprocessExtensionSettings is a repository-selected, checksum-bound +// additive capability. It cannot select or replace the primary flow. +type SubprocessExtensionSettings struct { + ID string `json:"id"` + Version string `json:"version"` + Executable string `json:"executable"` + SHA256 string `json:"sha256"` + Settings json.RawMessage `json:"settings,omitempty"` + DeadlineMillis int `json:"deadline_millis,omitempty"` + StdoutBytes int64 `json:"stdout_bytes,omitempty"` + StderrBytes int64 `json:"stderr_bytes,omitempty"` +} + type ProjectConfig struct { - SchemaVersion int `json:"schema_version"` - Project ProjectSettings `json:"project"` - Policy PolicySettings `json:"policy"` - Hosts []string `json:"hosts"` + SchemaVersion int `json:"schema_version"` + Project ProjectSettings `json:"project"` + Policy PolicySettings `json:"policy"` + Hosts []string `json:"hosts"` + Extensions []SubprocessExtensionSettings `json:"extensions,omitempty"` } -var canonicalHosts = []string{"claude", "cli", "codex", "cursor", "gemini", "mcp"} +var canonicalHosts = []string{"claude", "cli", "codex", "cursor", "gemini", "mcp", "sdk"} +var extensionID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$`) func CanonicalHosts() []string { return append([]string(nil), canonicalHosts...) } @@ -80,6 +97,20 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { canonical := config canonical.Hosts = append([]string(nil), config.Hosts...) sort.Strings(canonical.Hosts) + canonical.Extensions = append([]SubprocessExtensionSettings(nil), config.Extensions...) + for index := range canonical.Extensions { + if len(canonical.Extensions[index].Settings) != 0 { + var settings any + if err := json.Unmarshal(canonical.Extensions[index].Settings, &settings); err != nil { + return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q settings: %w", canonical.Extensions[index].ID, err) + } + canonical.Extensions[index].Settings, err = json.Marshal(settings) + if err != nil { + return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q settings: %w", canonical.Extensions[index].ID, err) + } + } + } + sort.Slice(canonical.Extensions, func(i, j int) bool { return canonical.Extensions[i].ID < canonical.Extensions[j].ID }) if canonical.Policy.ExternalEffectAuthority == "" { canonical.Policy.ExternalEffectAuthority = "human-or-autonomy-plus-provider" } @@ -125,5 +156,33 @@ func (c ProjectConfig) Validate() error { if !seen["cli"] { return fmt.Errorf("V2 project configuration must enable the canonical CLI surface") } + seenExtensions := map[string]bool{} + for _, extension := range c.Extensions { + if !extensionID.MatchString(extension.ID) || extension.Version == "" || !filepath.IsAbs(extension.Executable) || filepath.Clean(extension.Executable) != extension.Executable || len(extension.SHA256) != 64 { + return fmt.Errorf("subprocess extension requires semantic id, version, exact absolute executable, and SHA-256") + } + if _, err := hex.DecodeString(extension.SHA256); err != nil { + return fmt.Errorf("subprocess extension %q has invalid SHA-256", extension.ID) + } + if seenExtensions[extension.ID] { + return fmt.Errorf("duplicated subprocess extension %q", extension.ID) + } + seenExtensions[extension.ID] = true + if extension.DeadlineMillis < 0 || extension.StdoutBytes < 0 || extension.StderrBytes < 0 { + return fmt.Errorf("subprocess extension %q has negative limits", extension.ID) + } + if len(extension.Settings) != 0 { + var settings any + decoder := json.NewDecoder(bytes.NewReader(extension.Settings)) + decoder.UseNumber() + if err := decoder.Decode(&settings); err != nil { + return fmt.Errorf("subprocess extension %q settings are invalid JSON", extension.ID) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("subprocess extension %q settings contain trailing JSON", extension.ID) + } + } + } return nil } diff --git a/boatstack/internal/kernel/protocol/config_test.go b/boatstack/internal/kernel/protocol/config_test.go index 8d1549d..e31c710 100644 --- a/boatstack/internal/kernel/protocol/config_test.go +++ b/boatstack/internal/kernel/protocol/config_test.go @@ -1,6 +1,11 @@ package protocol -import "testing" +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) { valid := []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`) @@ -20,6 +25,61 @@ func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) { } } +func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t *testing.T) { + // control-law: repository-extension-selection-binds-exact-executable-and-settings + executable := filepath.Join(t.TempDir(), "extension") + base := ProjectConfig{ + SchemaVersion: ConfigSchemaVersion, + Project: ProjectSettings{Name: "product", DefaultBranch: "main", Commands: map[string]string{}}, + Policy: PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, + Hosts: []string{"cli", "sdk"}, + Extensions: []SubprocessExtensionSettings{{ + ID: "example.guard", Version: "1.0.0", Executable: executable, SHA256: strings.Repeat("a", 64), + Settings: json.RawMessage(`{"level":"strict","enabled":true}`), DeadlineMillis: 1000, StdoutBytes: 2048, StderrBytes: 1024, + }}, + } + raw, err := json.Marshal(base) + if err != nil { + t.Fatal(err) + } + _, fingerprint, err := ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + reordered := base + reordered.Hosts = []string{"sdk", "cli"} + reordered.Extensions = append([]SubprocessExtensionSettings(nil), base.Extensions...) + reordered.Extensions[0].Settings = json.RawMessage(`{ "enabled" : true, "level" : "strict" }`) + raw, _ = json.Marshal(reordered) + _, reorderedFingerprint, err := ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + if reorderedFingerprint != fingerprint { + t.Fatalf("representation changed extension policy fingerprint: %s != %s", reorderedFingerprint, fingerprint) + } + + changed := base + changed.Extensions = append([]SubprocessExtensionSettings(nil), base.Extensions...) + changed.Extensions[0].SHA256 = strings.Repeat("b", 64) + raw, _ = json.Marshal(changed) + _, changedFingerprint, err := ProjectConfigFingerprint(raw) + if err != nil { + t.Fatal(err) + } + if changedFingerprint == fingerprint { + t.Fatal("executable identity change retained repository policy fingerprint") + } + + invalid := base + invalid.Extensions = append([]SubprocessExtensionSettings(nil), base.Extensions...) + invalid.Extensions[0].Executable = "relative/extension" + raw, _ = json.Marshal(invalid) + if _, err := DecodeProjectConfig(raw); err == nil { + t.Fatal("relative subprocess executable was accepted") + } +} + func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { one := []byte("{\n \"schema_version\": 2,\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"]\n}\n") two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":2\r\n}\r\n") diff --git a/boatstack/internal/kernel/protocol/parameters_test.go b/boatstack/internal/kernel/protocol/parameters_test.go index e6ccede..9878598 100644 --- a/boatstack/internal/kernel/protocol/parameters_test.go +++ b/boatstack/internal/kernel/protocol/parameters_test.go @@ -4,15 +4,15 @@ import ( "path/filepath" "testing" - "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func TestEffectPathsAndGitNamesAreValidatedAtAdmissionBoundary(t *testing.T) { - plan, _ := catalog.Default().Lookup("plan.create") + plan, _ := testprogram.StandardRegistry().Lookup("plan.create") if err := (Parameters{{Name: "source_path", Value: "relative.md"}, {Name: "delivery_id", Value: "delivery"}}).Validate(plan); err == nil { t.Fatal("relative source path was accepted") } - workspace, _ := catalog.Default().Lookup("workspace.cut") + workspace, _ := testprogram.StandardRegistry().Lookup("workspace.cut") if err := (Parameters{{Name: "branch", Value: "--force"}, {Name: "base_ref", Value: "HEAD~1"}, {Name: "destination", Value: filepath.Join(t.TempDir(), "worktree")}}).Validate(workspace); err == nil { t.Fatal("unsafe Git parameters were accepted") } diff --git a/boatstack/internal/kernel/protocol/policy_test.go b/boatstack/internal/kernel/protocol/policy_test.go index ebf6fc6..7abaa0b 100644 --- a/boatstack/internal/kernel/protocol/policy_test.go +++ b/boatstack/internal/kernel/protocol/policy_test.go @@ -6,6 +6,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func policySnapshot(policy model.ConfigurationPolicy) model.Snapshot { @@ -21,7 +22,7 @@ func TestProviderAuthorityBindsExactExternalRequest(t *testing.T) { ID: "provider", Class: catalog.AuthorityProvider, Subject: "github", Fingerprint: "different-preview", IssuedAt: now, ExpiresAt: now.Add(time.Minute), }}} - transition, _ := catalog.Default().Lookup("publication.execute") + transition, _ := testprogram.StandardRegistry().Lookup("publication.execute") parameters := Parameters{{Name: "preview_fingerprint", Value: "reviewed-preview"}} if err := validateProviderAuthorityBinding(authority, transition, parameters); err == nil { t.Fatal("provider authority for different preview was accepted") @@ -37,7 +38,7 @@ func TestAdmissionPolicyRejectsRepositoryOnlyHighRiskReview(t *testing.T) { PlanApproval: "human", IndependentReviewForHighRisk: true, HighRiskChange: true, VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}, }) - transition := catalog.Transition{ID: "gate.review.record"} + transition, _ := testprogram.StandardRegistry().Lookup("gate.review.record") if err := validatePolicyAuthority(snapshot, transition, catalog.AuthoritySet{catalog.AuthorityRepository: true}); err == nil { t.Fatal("repository-only authority admitted a high-risk review") } @@ -51,7 +52,7 @@ func TestAdmissionPolicyRejectsDisabledVisualEvidence(t *testing.T) { PlanApproval: "human", VisualEvidence: "off", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}, }) - transition := catalog.Transition{ID: "evidence.visual.attach"} + transition, _ := testprogram.StandardRegistry().Lookup("evidence.visual.attach") if err := validatePolicyAuthority(snapshot, transition, catalog.AuthoritySet{catalog.AuthorityHuman: true}); err == nil { t.Fatal("visual evidence attachment admitted while disabled") } diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index 056e0aa..6f41b57 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -26,6 +26,7 @@ type TransitionReceipt struct { Sequence uint64 `json:"sequence"` TransitionID catalog.TransitionID `json:"transition_id"` TransitionVersion int `json:"transition_version"` + ProgramFingerprint string `json:"program_fingerprint"` AdmissionID string `json:"admission_id"` GoalID string `json:"goal_id"` GoalKind model.GoalKind `json:"goal_kind"` @@ -61,7 +62,7 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition } receipt := TransitionReceipt{ SchemaVersion: ReceiptSchemaVersion, FlowID: flowID, Sequence: sequence, TransitionID: transition.ID, - TransitionVersion: transition.Version, AdmissionID: admission.ID, GoalID: admission.Goal.ID, GoalKind: admission.Goal.Kind, DeliveryID: admission.Goal.DeliveryID, + TransitionVersion: transition.Version, ProgramFingerprint: admission.ProgramFingerprint, AdmissionID: admission.ID, GoalID: admission.Goal.ID, GoalKind: admission.Goal.Kind, DeliveryID: admission.Goal.DeliveryID, SourceFingerprint: admission.SnapshotFingerprint, TargetFingerprint: target.Fingerprint, AuthorityClasses: classes, IdempotencyKey: admission.IdempotencyKey, Verifier: transition.Verifier, Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal, @@ -79,7 +80,7 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition } func (r TransitionReceipt) Validate() error { - if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.AdmissionID == "" || r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.IdempotencyKey == "" || r.Verifier == "" { + if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || len(r.ProgramFingerprint) != 64 || r.AdmissionID == "" || r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.IdempotencyKey == "" || r.Verifier == "" { return fmt.Errorf("receipt has incomplete identity or evidence") } if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || r.DurationNanoseconds != r.CompletedAt.Sub(r.StartedAt).Nanoseconds() { diff --git a/boatstack/internal/kernel/supervisor/classify.go b/boatstack/internal/kernel/supervisor/classify.go index caafc59..16f4fe9 100644 --- a/boatstack/internal/kernel/supervisor/classify.go +++ b/boatstack/internal/kernel/supervisor/classify.go @@ -5,8 +5,6 @@ import ( "encoding/hex" "regexp" "strings" - - "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" ) type commandPattern struct { @@ -43,16 +41,15 @@ var ( ) var managedCommandPatterns = []struct { - pattern *regexp.Regexp - operation string - transition catalog.TransitionID + pattern *regexp.Regexp + operation string }{ - {regexp.MustCompile(`(?i)\bgit\s+worktree\s+remove\b`), "workspace.remove", "workspace.cleanup"}, - {regexp.MustCompile(`(?i)\bgh\s+pr\s+create\b`), "publication.create", "publication.execute"}, - {regexp.MustCompile(`(?i)\bgh\s+pr\s+edit\b`), "publication.edit", "publication.correct"}, - {regexp.MustCompile(`(?i)\bgh\s+pr\s+ready\b`), "publication.ready", "publication.correct"}, - {regexp.MustCompile(`(?i)\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH))`), "publication.api-write", "publication.correct"}, - {regexp.MustCompile(`(?i)\bgit\s+push\b`), "publication.push", "publication.execute"}, + {regexp.MustCompile(`(?i)\bgit\s+worktree\s+remove\b`), "workspace.remove"}, + {regexp.MustCompile(`(?i)\bgh\s+pr\s+create\b`), "publication.create"}, + {regexp.MustCompile(`(?i)\bgh\s+pr\s+edit\b`), "publication.edit"}, + {regexp.MustCompile(`(?i)\bgh\s+pr\s+ready\b`), "publication.ready"}, + {regexp.MustCompile(`(?i)\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH))`), "publication.api-write"}, + {regexp.MustCompile(`(?i)\bgit\s+push\b`), "publication.push"}, } func ClassifyCommandIntent(command string) CommandIntent { @@ -78,7 +75,7 @@ func ClassifyCommandIntent(command string) CommandIntent { } for _, candidate := range managedCommandPatterns { if candidate.pattern.MatchString(normalized) { - intent.Class, intent.Operation, intent.Transition = IntentManagedBypass, candidate.operation, candidate.transition + intent.Class, intent.Operation = IntentManagedBypass, candidate.operation return intent } } diff --git a/boatstack/internal/kernel/supervisor/guard.go b/boatstack/internal/kernel/supervisor/guard.go index 6ba1002..6f99ac2 100644 --- a/boatstack/internal/kernel/supervisor/guard.go +++ b/boatstack/internal/kernel/supervisor/guard.go @@ -32,8 +32,8 @@ func (i CommandIntent) Validate() error { return fmt.Errorf("%s intent cannot name a managed transition", i.Class) } case IntentManagedBypass: - if i.Transition == "" { - return fmt.Errorf("managed-bypass intent requires a transition") + if i.Transition != "" { + return fmt.Errorf("managed-bypass transition must be resolved from the compiled control program") } default: return fmt.Errorf("invalid command intent class %q", i.Class) @@ -66,7 +66,15 @@ func (s Supervisor) Guard(snapshot model.Snapshot, intent CommandIntent) GuardDe decision.Reason = "ordinary repository operation is outside managed effect authority" return decision } - decision.RequiredTransition = intent.Transition + transition, managed := s.registry.ManagedTransition(intent.Operation) + if !managed { + decision.Intent.Class = IntentOrdinary + decision.Allowed = true + decision.Reason = "operation is not managed by the compiled control program" + return decision + } + decision.Intent.Transition = transition.ID + decision.RequiredTransition = transition.ID if snapshot.Engagement.Status != model.FactKnown { decision.Reason = "managed-effect engagement is unresolved" return decision diff --git a/boatstack/internal/kernel/supervisor/guard_test.go b/boatstack/internal/kernel/supervisor/guard_test.go new file mode 100644 index 0000000..d17e256 --- /dev/null +++ b/boatstack/internal/kernel/supervisor/guard_test.go @@ -0,0 +1,60 @@ +package supervisor + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func TestManagedCommandRoutingComesOnlyFromCompiledProgram(t *testing.T) { + registry, err := catalog.New([]catalog.Transition{ + syntheticManagedTransition("synthetic.publish", catalog.EventOwnedLocal, catalog.SelectionExplicitOnly, "synthetic.recover"), + syntheticManagedTransition("synthetic.recover", catalog.EventRecovery, catalog.SelectionFlowRecovery, "synthetic.recover"), + }) + if err != nil { + t.Fatal(err) + } + active := model.Snapshot{ + Fingerprint: "snapshot", + Observation: model.Observation{ + Phase: model.Fact[model.ProtocolPhase]{Status: model.FactKnown, Value: model.PhaseActive}, + Engagement: model.Fact[model.EngagementState]{Status: model.FactKnown, Value: model.EngagementActive}, + }, + } + supervisor := New(registry, catalog.GoalContracts{}) + managed := supervisor.Guard(active, CommandIntent{Class: IntentManagedBypass, Operation: "artifact.publish", Fingerprint: "command"}) + if managed.Allowed || managed.RequiredTransition != "synthetic.publish" || managed.Intent.Transition != "synthetic.publish" { + t.Fatalf("compiled managed operation was not routed through admission: %#v", managed) + } + unowned := supervisor.Guard(active, CommandIntent{Class: IntentManagedBypass, Operation: "publication.create", Fingerprint: "command"}) + if !unowned.Allowed || unowned.RequiredTransition != "" || unowned.Intent.Class != IntentOrdinary { + t.Fatalf("custom program inherited StandardFlow command semantics: %#v", unowned) + } +} + +func syntheticManagedTransition(id catalog.TransitionID, class catalog.EventClass, selection catalog.SelectionClass, recovery catalog.TransitionID) catalog.Transition { + policy := catalog.PolicyContract{} + if id == "synthetic.publish" { + policy.ManagedOperations = []string{"artifact.publish"} + } + return catalog.Transition{ + ID: id, Version: 1, + Origin: catalog.TransitionOrigin{Kind: catalog.OriginPrimaryFlow, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: "manifest"}, + Owner: "test.synthetic", SelectionClass: selection, Class: class, + SourcePhases: []model.ProtocolPhase{model.PhaseActive}, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, + GoalKinds: []model.GoalKind{model.GoalVerified}, RequiredIdentity: []string{"repository-id"}, + Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, + OwnedResources: []string{"test.synthetic.state"}, Effect: catalog.EffectID(id), LocalEffects: []catalog.EffectID{catalog.EffectID(id)}, Idempotent: true, + Prescription: catalog.Prescription{Operation: string(id), ExpectedPostcondition: "synthetic-target"}, + SourcePredicate: "synthetic-source", SourceConditions: []catalog.FacetCondition{{Facet: model.FacetProgram, Statuses: []model.FactStatus{model.FactKnown}}}, + AdmissionPredicate: "exact-admission", TargetPredicate: "synthetic-target", + TargetConditions: []catalog.FacetCondition{{Facet: model.FacetProgram, Statuses: []model.FactStatus{model.FactKnown}}}, Verifier: "synthetic-verifier", + Interruption: catalog.InterruptionContract{ + Points: []string{"after-effect"}, PartialState: []string{"state"}, Detection: "fresh-observation", ResumeContract: "resume", + RollbackContract: "rollback", CompensationContract: "none", Recovery: recovery, RecoveryAuthority: "repository-policy", ResumptionPredicate: "fresh-state", + }, + Reversibility: catalog.Reversible, TerminalEffect: "none", PrivacyClassification: "metadata-only", TelemetryClassification: "receipt", CostClass: "synthetic", + Policy: policy, Priority: 1, + } +} diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go index f78bcaf..f73f87e 100644 --- a/boatstack/internal/kernel/supervisor/supervisor.go +++ b/boatstack/internal/kernel/supervisor/supervisor.go @@ -27,9 +27,14 @@ type Decision struct { Reason string `json:"reason"` } -type Supervisor struct{ registry catalog.Registry } +type Supervisor struct { + registry catalog.Registry + contracts catalog.GoalContracts +} -func New(registry catalog.Registry) Supervisor { return Supervisor{registry: registry} } +func New(registry catalog.Registry, contracts catalog.GoalContracts) Supervisor { + return Supervisor{registry: registry, contracts: contracts} +} func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority catalog.AuthoritySet, requested catalog.TransitionID) Decision { base := Decision{SnapshotFingerprint: snapshot.Fingerprint} @@ -41,6 +46,17 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority base.Kind, base.Reason = DecisionUnresolved, "terminal or phase evidence is not known" return base } + if snapshot.Program.Status != model.FactKnown { + base.Kind, base.Reason = DecisionUnresolved, "compiled control program evidence is not known" + return base + } + if snapshot.Program.Value == model.ProgramDrift { + transition, ok := s.registry.Lookup(requested) + if !ok || !transition.Policy.ReconcilesProgram { + base.Kind, base.Reason = DecisionUnresolved, "compiled control program drift requires explicit reconciliation" + return base + } + } if requested != "" && snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && requested != "goal.configure" { base.Kind, base.Reason = DecisionRefused, "requested goal differs from configured goal; goal.configure is required" return base @@ -49,7 +65,7 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("host %q is not enabled by repository policy", snapshot.Invocation.Host) return base } - if requested == "" && snapshot.Terminal.Value == model.TerminalEstablished && terminalMatchesGoal(snapshot, goal) { + if requested == "" && s.contracts.Matches(snapshot, goal) { base.Kind, base.Reason = DecisionTerminal, "configured terminal is established by current evidence" return base } @@ -109,10 +125,11 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority base.Kind, base.Reason = DecisionUnresolved, "no goal-progressing transition is safely selectable from current evidence" return base } + topClass := selectable[0].SelectionClass topPriority := selectable[0].Priority var top []catalog.Transition for _, candidate := range selectable { - if candidate.Priority == topPriority { + if candidate.SelectionClass == topClass && candidate.Priority == topPriority { top = append(top, candidate) } } @@ -133,17 +150,15 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority } func targetAlreadySatisfied(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition) bool { - if transition.ID == "goal.configure" { + if transition.Policy.BindsRequestedGoal { return snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value == goal } - if gate, ok := catalog.GateName(transition.ID); ok { - return currentEvidenceRecorded(snapshot, "gate-evidence:"+gate+":") + if transition.Policy.RequiredWhen == "visual-evidence-required" && + (snapshot.ConfigurationPolicy.Status != model.FactKnown || snapshot.ConfigurationPolicy.Value.VisualEvidence != "required") { + return true } - if transition.ID == "evidence.visual.attach" { - if snapshot.ConfigurationPolicy.Status != model.FactKnown || snapshot.ConfigurationPolicy.Value.VisualEvidence != "required" { - return true - } - return currentEvidenceRecorded(snapshot, "visual-evidence:") + if transition.Policy.CurrentEvidencePrefix != "" { + return currentEvidenceRecorded(snapshot, transition.Policy.CurrentEvidencePrefix) } return transition.TargetMatches(snapshot) } @@ -173,7 +188,7 @@ func authoritySatisfies(snapshot model.Snapshot, transition catalog.Transition, if !authority.Satisfies(transition.Authority, transition.AuthorityAll) { return false } - if transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment" { + if transition.Policy.AuthorityRule == "plan-approval" { if snapshot.ConfigurationPolicy.Status != model.FactKnown { return false } @@ -181,7 +196,7 @@ func authoritySatisfies(snapshot model.Snapshot, transition catalog.Transition, return false } } - if transition.ID == "gate.review.record" { + if transition.Policy.AuthorityRule == "independent-high-risk-review" { if snapshot.ConfigurationPolicy.Status != model.FactKnown { return false } @@ -206,44 +221,14 @@ func policyAllows(snapshot model.Snapshot, transition catalog.Transition) (bool, return false, fmt.Sprintf("recovery transition %q is not permitted for transaction %q", transition.ID, snapshot.RecoveryInfo.Value.TransactionID) } } - if transition.ID != "evidence.visual.attach" { + if transition.Policy.AvailabilityRule == "" { return true, "" } if snapshot.ConfigurationPolicy.Status != model.FactKnown { return false, fmt.Sprintf("transition %q requires known configuration policy", transition.ID) } - if snapshot.ConfigurationPolicy.Value.VisualEvidence == "off" { + if transition.Policy.AvailabilityRule == "visual-evidence-enabled" && snapshot.ConfigurationPolicy.Value.VisualEvidence == "off" { return false, "visual evidence is disabled by repository policy" } return true, "" } - -func terminalMatchesGoal(snapshot model.Snapshot, goal model.Goal) bool { - if snapshot.Goal.Status != model.FactKnown || snapshot.Goal.Value != goal { - return false - } - switch goal.Kind { - case model.GoalApprovedPlan: - return snapshot.Plan.Status == model.FactKnown && snapshot.Plan.Value == model.PlanApproved - case model.GoalVerified: - return deliveryInputsCurrent(snapshot) && - snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryTerminal - case model.GoalOpenPR: - return deliveryInputsCurrent(snapshot) && snapshot.Publication.Status == model.FactKnown && snapshot.Publication.Value == model.PublicationOpen - case model.GoalMerged: - return snapshot.Publication.Status == model.FactKnown && snapshot.Publication.Value == model.PublicationMerged && - snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryTerminal && - snapshot.Workspace.Status == model.FactKnown && (snapshot.Workspace.Value == model.WorkspaceLanded || snapshot.Workspace.Value == model.WorkspaceAbsent) - case model.GoalAbandoned: - return snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryDiscarded && - snapshot.Workspace.Status == model.FactKnown && (snapshot.Workspace.Value == model.WorkspaceAbandoned || snapshot.Workspace.Value == model.WorkspaceAbsent) - default: - return false - } -} - -func deliveryInputsCurrent(snapshot model.Snapshot) bool { - return snapshot.Verification.Status == model.FactKnown && snapshot.Verification.Value == model.VerificationCurrent && - snapshot.Configuration.Status == model.FactKnown && snapshot.Configuration.Value == model.ConfigurationVerified && - snapshot.Runtime.Status == model.FactKnown && snapshot.Runtime.Value == model.RuntimeVerified -} diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index b1bba64..e930842 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -161,6 +161,13 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) if pendingErr != nil { return model.Observation{}, pendingErr } + recordedProgramFingerprint := state.ProgramFingerprint + if pending.ProgramFingerprint != "" { + if recordedProgramFingerprint != "" && recordedProgramFingerprint != pending.ProgramFingerprint { + return model.Observation{}, fmt.Errorf("durable state and pending transaction bind different control programs") + } + recordedProgramFingerprint = pending.ProgramFingerprint + } if pending.Conflicting { evidence := append(append([]model.Evidence(nil), stateEvidence...), pending.Evidence...) phase = model.PhaseUnresolved @@ -191,7 +198,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) goalFact = model.Fact[model.Goal]{Status: model.FactKnown, Value: state.Goal, Evidence: stateEvidence} } return model.Observation{ - SchemaVersion: model.SnapshotSchemaVersion, Invocation: current, + SchemaVersion: model.SnapshotSchemaVersion, RecordedProgramFingerprint: recordedProgramFingerprint, Invocation: current, Phase: model.Fact[model.ProtocolPhase]{Status: model.FactKnown, Value: phase, Evidence: stateEvidence}, Engagement: model.Fact[model.EngagementState]{Status: model.FactKnown, Value: state.Engagement, Evidence: stateEvidence}, Delivery: model.Fact[model.DeliveryState]{Status: model.FactKnown, Value: delivery, Evidence: deliveryEvidence}, @@ -545,10 +552,11 @@ type pendingJournalHeader struct { Status string `json:"status"` Reason string `json:"reason"` Admission struct { - ID string `json:"id"` - SourcePhase model.ProtocolPhase `json:"source_phase"` - Invocation model.InvocationContext `json:"invocation"` - Parameters protocol.Parameters `json:"parameters"` + ID string `json:"id"` + ProgramFingerprint string `json:"program_fingerprint"` + SourcePhase model.ProtocolPhase `json:"source_phase"` + Invocation model.InvocationContext `json:"invocation"` + Parameters protocol.Parameters `json:"parameters"` } `json:"admission"` Mutations []struct { Path string `json:"path"` @@ -558,12 +566,13 @@ type pendingJournalHeader struct { } type pendingJournalSet struct { - Found bool - Conflicting bool - Evidence []model.Evidence - Recovery model.RecoveryContext - Transaction model.TransactionContext - TransactionState model.TransactionState + Found bool + Conflicting bool + Evidence []model.Evidence + Recovery model.RecoveryContext + Transaction model.TransactionContext + TransactionState model.TransactionState + ProgramFingerprint string } type pendingJournalRecord struct { @@ -610,7 +619,7 @@ func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pend return pendingJournalSet{}, err } class := catalog.EventClass(header.TransitionClass) - if header.SchemaVersion != 2 || header.Admission.ID == "" || entry.Name() != header.Admission.ID+".pending" || header.TransitionID == "" || header.Status == "" || !class.Valid() || !class.Controllable() { + if header.SchemaVersion != 2 || header.Admission.ID == "" || len(header.Admission.ProgramFingerprint) != 64 || entry.Name() != header.Admission.ID+".pending" || header.TransitionID == "" || header.Status == "" || !class.Valid() || !class.Controllable() { return pendingJournalSet{}, fmt.Errorf("invalid pending transaction journal %s", path) } if header.Admission.ID == ignoreAdmissionID { @@ -644,8 +653,9 @@ func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pend } set := pendingJournalSet{ Found: true, Evidence: []model.Evidence{evidence}, TransactionState: transactionState, - Recovery: model.RecoveryContext{TransactionID: header.Admission.ID, Cause: cause, SourcePhase: header.Admission.SourcePhase, Permitted: permitted, BudgetRemaining: budget, Resumption: header.Admission.SourcePhase}, - Transaction: model.TransactionContext{ID: header.Admission.ID, TransitionID: header.TransitionID, Status: header.Status, ResourceDigests: resourceDigests, ExternalPossible: external}, + ProgramFingerprint: header.Admission.ProgramFingerprint, + Recovery: model.RecoveryContext{TransactionID: header.Admission.ID, Cause: cause, SourcePhase: header.Admission.SourcePhase, Permitted: permitted, BudgetRemaining: budget, Resumption: header.Admission.SourcePhase}, + Transaction: model.TransactionContext{ID: header.Admission.ID, TransitionID: header.TransitionID, Status: header.Status, ResourceDigests: resourceDigests, ExternalPossible: external}, } rootID := header.Admission.ID if class == catalog.EventRecovery { @@ -687,6 +697,9 @@ func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pend base.Evidence = nil base.Transaction.ResourceDigests = nil for _, record := range records { + if record.set.ProgramFingerprint != base.ProgramFingerprint { + return conflictingPending(records), nil + } base.Evidence = append(base.Evidence, record.set.Evidence...) base.Transaction.ResourceDigests = append(base.Transaction.ResourceDigests, record.set.Transaction.ResourceDigests...) } diff --git a/boatstack/internal/plant/observer_test.go b/boatstack/internal/plant/observer_test.go index a1b7230..54dbd8f 100644 --- a/boatstack/internal/plant/observer_test.go +++ b/boatstack/internal/plant/observer_test.go @@ -201,9 +201,10 @@ func TestRecoveryAttemptsExhaustToEscalationOnly(t *testing.T) { "status": "recovery-required", "reason": "simulated interruption", "admission": map[string]any{ - "id": originalID, - "source_phase": "ACTIVE", - "invocation": map[string]any{"correlation_id": "prior-process"}, + "id": originalID, + "program_fingerprint": strings.Repeat("a", 64), + "source_phase": "ACTIVE", + "invocation": map[string]any{"correlation_id": "prior-process"}, }, } writeJSON := func(name string, value any) { @@ -237,6 +238,9 @@ func TestRecoveryAttemptsExhaustToEscalationOnly(t *testing.T) { if observed.Recovery.BudgetRemaining != wantBudget { t.Fatalf("attempt %d budget=%d, want %d", attempt, observed.Recovery.BudgetRemaining, wantBudget) } + if observed.ProgramFingerprint != strings.Repeat("a", 64) { + t.Fatalf("pending program fingerprint=%q", observed.ProgramFingerprint) + } if attempt == 3 { if len(observed.Recovery.Permitted) != 1 || observed.Recovery.Permitted[0] != "recovery.escalate" { t.Fatalf("exhausted recovery permitted=%v, want escalation only", observed.Recovery.Permitted) @@ -261,12 +265,12 @@ func TestInterruptedRecoveryAttemptCollapsesToEscalatableTransactionGroup(t *tes originalID := "adm-original" write(originalID+".pending", map[string]any{ "schema_version": 2, "transition_id": "plan.create", "transition_class": "owned-local", "status": "recovery-required", - "admission": map[string]any{"id": originalID, "source_phase": "ACTIVE", "invocation": map[string]any{"correlation_id": "old-process"}}, + "admission": map[string]any{"id": originalID, "program_fingerprint": strings.Repeat("a", 64), "source_phase": "ACTIVE", "invocation": map[string]any{"correlation_id": "old-process"}}, }) write("adm-nested.pending", map[string]any{ "schema_version": 2, "transition_id": "recovery.rollback", "transition_class": "recovery", "status": "verifying", "admission": map[string]any{ - "id": "adm-nested", "source_phase": "RECOVERY", "invocation": map[string]any{"correlation_id": "old-process"}, + "id": "adm-nested", "program_fingerprint": strings.Repeat("a", 64), "source_phase": "RECOVERY", "invocation": map[string]any{"correlation_id": "old-process"}, "parameters": []map[string]string{{"name": "transaction_id", "value": originalID}}, }, }) diff --git a/boatstack/internal/surfaces/artifacts_external_test.go b/boatstack/internal/surfaces/artifacts_external_test.go new file mode 100644 index 0000000..52ac1cf --- /dev/null +++ b/boatstack/internal/surfaces/artifacts_external_test.go @@ -0,0 +1,50 @@ +package surfaces_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/operatorstack/boatstack/boatstack/distribution" + "github.com/operatorstack/boatstack/boatstack/internal/surfaces" +) + +func TestCheckedArchitectureArtifactsMatchCompiledStandardProgram(t *testing.T) { + program, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + transitions := program.Transitions() + checks := map[string]string{ + "boatstack-v2-transition-catalog.md": surfaces.RenderCatalogMarkdown(transitions), + "boatstack-v2-transition-catalog.mmd": surfaces.RenderCatalogMermaid(transitions), + "boatstack-standard-flow.mmd": surfaces.RenderStandardFlowMermaid(transitions), + } + safety, err := surfaces.RenderCatalogLocusSafety(transitions) + if err != nil { + t.Fatal(err) + } + liveness, err := surfaces.RenderCatalogLocusLiveness(transitions) + if err != nil { + t.Fatal(err) + } + checks["boatstack-v2-locus-safety.json"] = safety + checks["boatstack-v2-locus-liveness.json"] = liveness + + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate checked architecture artifacts") + } + repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + for name, expected := range checks { + actual, err := os.ReadFile(filepath.Join(repositoryRoot, "docs", "architecture", name)) + if err != nil { + t.Fatal(err) + } + if string(actual) != expected { + t.Errorf("%s drifted; regenerate it with boatstack catalog", name) + } + } +} diff --git a/boatstack/internal/surfaces/catalog_render.go b/boatstack/internal/surfaces/catalog_render.go index dde761a..75ef177 100644 --- a/boatstack/internal/surfaces/catalog_render.go +++ b/boatstack/internal/surfaces/catalog_render.go @@ -19,8 +19,8 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { counts[transition.Class]++ } var output strings.Builder - output.WriteString("\n") - output.WriteString("# Boatstack V2 executable transition catalog\n\n") + output.WriteString("\n") + output.WriteString("# Boatstack compiled transition catalog\n\n") fmt.Fprintf(&output, "Registry size: **%d** transitions. Event classes: authority %d; owned-local %d; owned-external %d; recovery %d; observed-external %d.\n\n", len(ordered), counts[catalog.EventAuthority], counts[catalog.EventOwnedLocal], counts[catalog.EventOwnedExternal], counts[catalog.EventRecovery], counts[catalog.EventObservedExternal]) output.WriteString("Controlling facets: `") @@ -32,8 +32,8 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { output.WriteString(string(facet)) } output.WriteString("`.\n\n") - output.WriteString("| Transition | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Recovery |\n") - output.WriteString("|---|---|---|---|---|---|---|---|\n") + output.WriteString("| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Verifier | Recovery | Cost |\n") + output.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|---|\n") for _, transition := range ordered { authority := joinAuthorities(transition.Authority) if len(transition.AuthorityAll) > 0 { @@ -51,9 +51,11 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { if recovery == "" { recovery = "-" } - fmt.Fprintf(&output, "| `%s` | %s | %s | %s | %s | %s | %s | `%s` |\n", - transition.ID, transition.Class, joinPhases(transition.SourcePhases), joinPhases(transition.TargetPhases), - authority, markdownList(parameters), markdownList(transition.OwnedResources), recovery) + origin := fmt.Sprintf("%s:`%s@%s`
`%s`", transition.Origin.Kind, transition.Origin.ID, transition.Origin.Version, transition.Origin.ManifestFingerprint) + fmt.Fprintf(&output, "| `%s` | %s | `%s` | %s | %s | %s | %s | %s | %s | %s | `%s` | `%s` | `%s` |\n", + transition.ID, origin, transition.Owner, transition.SelectionClass, transition.Class, + joinPhases(transition.SourcePhases), joinPhases(transition.TargetPhases), authority, + markdownList(parameters), markdownList(transition.OwnedResources), transition.Verifier, recovery, transition.CostClass) } output.WriteString("\n`*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`.\n") return output.String() @@ -63,6 +65,23 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { // grouped by event class. Phase sets are labels on the exact transition node, // avoiding a second hand-maintained graph. func RenderCatalogMermaid(transitions []catalog.Transition) string { + return renderCatalogMermaid(transitions, "%% Generated from the compiled ControlProgram registry by surfaces.RenderCatalogMermaid. Do not edit.\n") +} + +// RenderStandardFlowMermaid projects only the compiled primary-flow +// declarations. The owner filter is metadata from the same executable registry, +// not a second transition graph. +func RenderStandardFlowMermaid(transitions []catalog.Transition) string { + flow := make([]catalog.Transition, 0, len(transitions)) + for _, transition := range transitions { + if transition.Origin.Kind == catalog.OriginPrimaryFlow { + flow = append(flow, transition) + } + } + return renderCatalogMermaid(flow, "%% Generated from compiled PrimaryFlow declarations by surfaces.RenderStandardFlowMermaid. Do not edit.\n") +} + +func renderCatalogMermaid(transitions []catalog.Transition, header string) string { ordered := append([]catalog.Transition(nil), transitions...) sort.Slice(ordered, func(i, j int) bool { if ordered[i].Class != ordered[j].Class { @@ -72,7 +91,7 @@ func RenderCatalogMermaid(transitions []catalog.Transition) string { }) classes := []catalog.EventClass{catalog.EventAuthority, catalog.EventOwnedLocal, catalog.EventOwnedExternal, catalog.EventRecovery, catalog.EventObservedExternal} var output strings.Builder - output.WriteString("%% Generated from catalog.Default by surfaces.RenderCatalogMermaid. Do not edit.\n") + output.WriteString(header) output.WriteString("flowchart TB\n") index := 0 for _, class := range classes { diff --git a/boatstack/internal/surfaces/locus_render.go b/boatstack/internal/surfaces/locus_render.go index d78f6a8..58293ad 100644 --- a/boatstack/internal/surfaces/locus_render.go +++ b/boatstack/internal/surfaces/locus_render.go @@ -85,17 +85,17 @@ func renderCatalogLocus(transitions []catalog.Transition, safety bool) (string, result := locusModel{ SchemaVersion: 1, ID: "boatstack-v2-executable-catalog-liveness-v1", - Subject: "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", + Subject: "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", Evidence: []locusEvidence{ - {Path: "boatstack/internal/kernel/catalog/default.go", Note: "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization."}, + {Path: "boatstack/control/control.go", Note: "Compiler combines exact CoreSystem, PrimaryFlow, extension, contract, and ownership declarations into one immutable runtime registry."}, {Path: "docs/architecture/boatstack-v2-transition-catalog.md", Note: "Generated readable projection from the same runtime registry."}, {Path: "boatstack/internal/kernel/protocol/admission.go", Note: "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."}, {Path: "boatstack/internal/kernel/engine/engine.go", Note: "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."}, - {Path: "boatstack/internal/kernel/reducer/reducer.go", Note: "Single executable reducer for every controllable semantic transition."}, - {Path: "boatstack/internal/kernel/catalog/completeness_test.go", Note: "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests."}, + {Path: "boatstack/internal/effects/state_reducer.go", Note: "Admitted native effects reduce every controllable Standard distribution transition through one state adapter."}, + {Path: "boatstack/flow/standard/completeness_test.go", Note: "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests."}, {Path: "boatstack/internal/kernel/engine/engine_test.go", Note: "Exact-admission, stale-snapshot, postcondition, interruption, idempotency, and unknown-outcome tests."}, {Path: "boatstack/internal/effects/prepared.go", Note: "Staged effect ordering, atomic resource application, rollback, and external settlement boundary."}, - {Path: "boatstack/internal/kernel/catalog/historical_test.go", Note: "Historical incidents resolved through the executable runtime supervisor."}, + {Path: "boatstack/flow/standard/historical_test.go", Note: "Historical incidents resolved through the executable runtime supervisor."}, }, Spec: locusSpec{ Description: "Every reachable stable catalog phase retains a path to terminal, explicit frontier, or safe abandonment.", diff --git a/boatstack/internal/surfaces/protocol.go b/boatstack/internal/surfaces/protocol.go index 41106e7..36818a8 100644 --- a/boatstack/internal/surfaces/protocol.go +++ b/boatstack/internal/surfaces/protocol.go @@ -74,13 +74,6 @@ func (r Request) Validate(now time.Time) error { return fmt.Errorf("apply/recover request requires flow and transition identity") } } - if r.Operation == OperationRecover { - switch r.TransitionID { - case "recovery.resume", "recovery.rollback", "recovery.escalate", "runtime.reconcile", "configuration.reconcile", "workspace.reconcile", "publication.reconcile": - default: - return fmt.Errorf("recover operation requires a registered recovery transition") - } - } if r.Operation == OperationGuard && (strings.TrimSpace(r.Command) == "" || len(r.Command) > 1<<20) { return fmt.Errorf("guard operation requires a bounded command") } @@ -94,10 +87,22 @@ func (r Request) Validate(now time.Time) error { } type DoctorReport struct { - Healthy bool `json:"healthy"` - TransitionCount int `json:"transition_count"` - Snapshot string `json:"snapshot,omitempty"` - Detail string `json:"detail"` + Healthy bool `json:"healthy"` + KernelVersion string `json:"kernel_version"` + CoreSystemID string `json:"core_system_id"` + CoreSystemVersion string `json:"core_system_version"` + PrimaryFlowID string `json:"primary_flow_id"` + PrimaryFlowVersion string `json:"primary_flow_version"` + PrimaryFlowFingerprint string `json:"primary_flow_fingerprint"` + CoreTransitionCount int `json:"core_transition_count"` + FlowTransitionCount int `json:"flow_transition_count"` + ExtensionTransitionCount int `json:"extension_transition_count"` + TransitionCount int `json:"transition_count"` + EnabledExtensions []string `json:"enabled_extensions,omitempty"` + ProgramFingerprint string `json:"program_fingerprint"` + UnresolvedProgramDrift bool `json:"unresolved_program_drift"` + Snapshot string `json:"snapshot,omitempty"` + Detail string `json:"detail"` } type Response struct { diff --git a/boatstack/internal/surfaces/render_test.go b/boatstack/internal/surfaces/render_test.go index ad06b52..c5629e5 100644 --- a/boatstack/internal/surfaces/render_test.go +++ b/boatstack/internal/surfaces/render_test.go @@ -2,10 +2,7 @@ package surfaces import ( "encoding/json" - "os" - "path/filepath" "reflect" - "runtime" "strings" "testing" @@ -13,11 +10,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) func TestShellRenderersConsumeOneCommandAST(t *testing.T) { // control-law: shell-rendering-never-changes-transition-semantics - transition, ok := catalog.Default().Lookup("plan.create") + transition, ok := testprogram.StandardRegistry().Lookup("plan.create") if !ok { t.Fatal("missing plan.create") } @@ -49,11 +47,11 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { } func TestCatalogArtifactsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { - registry := catalog.Default() + registry := testprogram.StandardRegistry() markdown := RenderCatalogMarkdown(registry.All()) mermaid := RenderCatalogMermaid(registry.All()) for _, transition := range registry.All() { - rowPrefix := "\n| `" + string(transition.ID) + "` | " + string(transition.Class) + " |" + rowPrefix := "\n| `" + string(transition.ID) + "` | " + string(transition.Origin.Kind) + ":" if strings.Count(markdown, rowPrefix) != 1 { t.Errorf("markdown does not contain transition %s exactly once", transition.ID) } @@ -68,7 +66,7 @@ func TestCatalogArtifactsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { func TestLocusModelsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { // control-law: formal-model-alphabet-is-the-runtime-catalog - registry := catalog.Default() + registry := testprogram.StandardRegistry() for _, render := range []func([]catalog.Transition) (string, error){RenderCatalogLocusSafety, RenderCatalogLocusLiveness} { one, err := render(registry.All()) if err != nil { @@ -101,41 +99,9 @@ func TestLocusModelsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { } } -func TestCheckedArchitectureArtifactsMatchExecutableCatalog(t *testing.T) { - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("cannot locate checked architecture artifacts") - } - repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) - checks := map[string]string{ - "boatstack-v2-transition-catalog.md": RenderCatalogMarkdown(catalog.Default().All()), - "boatstack-v2-transition-catalog.mmd": RenderCatalogMermaid(catalog.Default().All()), - } - safety, err := RenderCatalogLocusSafety(catalog.Default().All()) - if err != nil { - t.Fatal(err) - } - liveness, err := RenderCatalogLocusLiveness(catalog.Default().All()) - if err != nil { - t.Fatal(err) - } - checks["boatstack-v2-locus-safety.json"] = safety - checks["boatstack-v2-locus-liveness.json"] = liveness - for name, expected := range checks { - path := filepath.Join(repositoryRoot, "docs", "architecture", name) - actual, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(actual) != expected { - t.Errorf("%s drifted; regenerate it with boatstack catalog", name) - } - } -} - func TestEveryHostConsumesOneSemanticPrescription(t *testing.T) { goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"} - for _, transition := range catalog.Default().All() { + for _, transition := range testprogram.StandardRegistry().All() { if !transition.Controllable() { continue } @@ -162,7 +128,7 @@ func TestEveryHostConsumesOneSemanticPrescription(t *testing.T) { } func TestCanonicalHostsAreDataNotControllers(t *testing.T) { - want := []string{"claude", "cli", "codex", "cursor", "gemini", "mcp"} + want := []string{"claude", "cli", "codex", "cursor", "gemini", "mcp", "sdk"} if got := CanonicalHostNames(); !reflect.DeepEqual(got, want) { t.Fatalf("hosts = %v, want %v", got, want) } @@ -177,7 +143,7 @@ func TestGuardClassifierIsShellNeutralAndPrivacyBounded(t *testing.T) { } } managed := ClassifyCommandIntent("gh pr create --base main --head feature") - if managed.Class != supervisor.IntentManagedBypass || managed.Transition != "publication.execute" { + if managed.Class != supervisor.IntentManagedBypass || managed.Operation != "publication.create" || managed.Transition != "" { t.Fatalf("managed intent = %#v", managed) } ordinary := ClassifyCommandIntent("go test ./...") diff --git a/boatstack/internal/testprogram/standard.go b/boatstack/internal/testprogram/standard.go new file mode 100644 index 0000000..4c248a5 --- /dev/null +++ b/boatstack/internal/testprogram/standard.go @@ -0,0 +1,26 @@ +// Package testprogram assembles first-party definitions for compatibility and +// parity tests. Product code must use distribution or an explicit compiler. +package testprogram + +import ( + "context" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" +) + +// StandardRegistry compiles the real CoreSystem and StandardFlow declaration +// bytes. It panics only in tests when a checked first-party manifest is invalid. +func StandardRegistry() catalog.Registry { + program, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "test-kernel", + Core: core.System(), + Flow: standard.Definition(), + }) + if err != nil { + panic(err) + } + return program.RuntimeRegistry() +} diff --git a/boatstack/v2_kernel.go b/boatstack/kernel.go similarity index 55% rename from boatstack/v2_kernel.go rename to boatstack/kernel.go index e2e0d47..ee15805 100644 --- a/boatstack/v2_kernel.go +++ b/boatstack/kernel.go @@ -8,6 +8,7 @@ import ( "os" "time" + "github.com/operatorstack/boatstack/boatstack/control" "github.com/operatorstack/boatstack/boatstack/internal/effects" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/engine" @@ -25,51 +26,58 @@ var ( ChecksumsSHA256 = "development" ) -// V2Kernel is a product facade over the authoritative engine and its concrete -// plant/effect ports. It owns no independent durable or lifecycle state. -type V2Kernel struct { +// Kernel is the deterministic mechanism facade over one immutable compiled +// ControlProgram and its concrete plant/effect ports. It owns no independent +// delivery-flow policy or durable lifecycle state. +type Kernel struct { + program control.ControlProgram registry catalog.Registry resolver plant.Resolver - observer plant.Observer + observer ports.Observer engine engine.Engine clock effects.Clock } -func NewV2Kernel(externalStateRoot string) (V2Kernel, error) { +func NewKernel(externalStateRoot string, program control.ControlProgram) (Kernel, error) { + if program.Fingerprint() == "" || program.TransitionCount() == 0 { + return Kernel{}, fmt.Errorf("Kernel requires an immutable compiled ControlProgram") + } clock := effects.Clock{} resolver, err := plant.NewResolver(externalStateRoot) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } - observer, err := plant.NewObserver(resolver, clock) + baseObserver, err := plant.NewObserver(resolver, clock) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } + observer := programObserver{base: baseObserver, program: program} locker, err := effects.NewLocker(resolver) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } journal, err := effects.NewJournal(resolver, clock) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } receipts, err := effects.NewReceiptStore(resolver, clock) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } - driver, err := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) + baseDriver, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership()) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } - registry := catalog.Default() - runtimeEngine, err := engine.New(registry, observer, clock, locker, journal, driver, receipts) + driver := programEffectDriver{base: baseDriver, program: program} + registry := program.RuntimeRegistry() + runtimeEngine, err := engine.New(registry, program.RuntimeGoalContracts(), program.Fingerprint(), observer, clock, locker, journal, driver, receipts) if err != nil { - return V2Kernel{}, err + return Kernel{}, err } - return V2Kernel{registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock}, nil + return Kernel{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock}, nil } -func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { +func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation} if err := request.Validate(k.clock.Now()); err != nil { response.Error = err.Error() @@ -79,6 +87,14 @@ func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surface response.Catalog = k.registry.All() return response, nil } + if request.Operation == surfaces.OperationRecover { + transition, ok := k.registry.Lookup(request.TransitionID) + if !ok || transition.Class != catalog.EventRecovery { + err := fmt.Errorf("recover operation requires a recovery transition from the compiled control program") + response.Error = err.Error() + return response, err + } + } invocation, err := k.resolver.ResolveInvocation(ctx, request.Repository, request.Host, request.CorrelationID) if err != nil { response.Error = err.Error() @@ -94,18 +110,26 @@ func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surface switch request.Operation { case surfaces.OperationResolve: resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Requested: request.TransitionID}) + response.Goal, response.Decision = resolution.Goal, &resolution.Decision + if resolution.Snapshot.Fingerprint != "" { + response.Snapshot = &resolution.Snapshot + } if resolveErr != nil { response.Error = resolveErr.Error() return response, resolveErr } - response.Goal, response.Snapshot, response.Decision = resolution.Goal, &resolution.Snapshot, &resolution.Decision return response, nil case surfaces.OperationApply, surfaces.OperationRecover: result, applyErr := k.engine.Apply(ctx, engine.ApplyRequest{ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Requested: request.TransitionID}, FlowID: request.FlowID, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute, }) - response.Goal, response.Snapshot = result.Goal, &result.Target + response.Goal = result.Goal + if result.Target.Fingerprint != "" { + response.Snapshot = &result.Target + } else if result.Source.Fingerprint != "" { + response.Snapshot = &result.Source + } if result.Decision.Kind != "" { response.Decision = &result.Decision } @@ -118,34 +142,52 @@ func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surface response.Replayed = result.Replayed if applyErr != nil { response.Error = applyErr.Error() - if result.Target.Fingerprint == "" { - response.Snapshot = &result.Source - } return response, applyErr } return response, nil case surfaces.OperationDoctor: + summary := k.program.Summary() + extensionIDs := make([]string, 0, len(summary.Extensions)) + for _, extension := range summary.Extensions { + extensionIDs = append(extensionIDs, extension.ID+"@"+extension.Version) + } + report := surfaces.DoctorReport{ + KernelVersion: summary.KernelVersion, CoreSystemID: summary.Core.ID, CoreSystemVersion: summary.Core.Version, + PrimaryFlowID: summary.Flow.ID, PrimaryFlowVersion: summary.Flow.Version, PrimaryFlowFingerprint: summary.Flow.Fingerprint, + CoreTransitionCount: summary.CoreTransitionCount, FlowTransitionCount: summary.FlowTransitionCount, + ExtensionTransitionCount: summary.ExtensionTransitionCount, TransitionCount: summary.TotalTransitionCount, + EnabledExtensions: extensionIDs, ProgramFingerprint: summary.ProgramFingerprint, + } observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation}) if observeErr != nil { - response.Doctor = &surfaces.DoctorReport{Healthy: false, TransitionCount: k.registry.Len(), Detail: observeErr.Error()} + report.Healthy, report.Detail = false, observeErr.Error() + response.Doctor = &report response.Error = observeErr.Error() return response, observeErr } - snapshot, canonicalErr := model.Canonicalize(observation) + snapshot, canonicalErr := model.CanonicalizeForProgram(observation, k.program.Fingerprint()) if canonicalErr != nil { - response.Doctor = &surfaces.DoctorReport{Healthy: false, TransitionCount: k.registry.Len(), Detail: canonicalErr.Error()} + report.Healthy, report.Detail = false, canonicalErr.Error() + response.Doctor = &report response.Error = canonicalErr.Error() return response, canonicalErr } response.Snapshot = &snapshot - response.Doctor = &surfaces.DoctorReport{Healthy: k.registry.Len() == catalog.DefaultTransitionCount, TransitionCount: k.registry.Len(), Snapshot: snapshot.Fingerprint, Detail: "V2 kernel, observation, and catalog are valid"} + report.UnresolvedProgramDrift = snapshot.Program.Status != model.FactKnown || snapshot.Program.Value == model.ProgramDrift + report.Healthy = k.registry.Len() == summary.TotalTransitionCount && !report.UnresolvedProgramDrift + report.Snapshot = snapshot.Fingerprint + report.Detail = "Kernel, observation, and compiled control program are valid" + if report.UnresolvedProgramDrift { + report.Detail = "compiled control program drift requires explicit reconciliation" + } + response.Doctor = &report return response, nil case surfaces.OperationEvents: layout, _, layoutErr := k.resolver.ResolveLayout(ctx, invocation) if layoutErr != nil { return response, layoutErr } - events, readErr := readV2Events(layout.EventPath) + events, readErr := readEvents(layout.EventPath) if readErr != nil { response.Error = readErr.Error() return response, readErr @@ -158,13 +200,13 @@ func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surface response.Error = observeErr.Error() return response, observeErr } - snapshot, canonicalErr := model.Canonicalize(observation) + snapshot, canonicalErr := model.CanonicalizeForProgram(observation, k.program.Fingerprint()) if canonicalErr != nil { response.Error = canonicalErr.Error() return response, canonicalErr } intent := surfaces.ClassifyCommandIntent(request.Command) - guard := supervisor.New(k.registry).Guard(snapshot, intent) + guard := supervisor.New(k.registry, k.program.RuntimeGoalContracts()).Guard(snapshot, intent) response.Snapshot, response.Guard = &snapshot, &guard return response, nil default: @@ -172,24 +214,24 @@ func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surface } } -func (k V2Kernel) deriveRepositoryAuthority(ctx context.Context, invocation model.InvocationContext, bundle protocol.AuthorityBundle) (protocol.AuthorityBundle, error) { +func (k Kernel) deriveRepositoryAuthority(ctx context.Context, invocation model.InvocationContext, bundle protocol.AuthorityBundle) (protocol.AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { - return protocol.AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by the V2 kernel") + return protocol.AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by Kernel") } } observation, err := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation}) if err != nil { return protocol.AuthorityBundle{}, err } - snapshot, err := model.Canonicalize(observation) + snapshot, err := model.CanonicalizeForProgram(observation, k.program.Fingerprint()) if err != nil { return protocol.AuthorityBundle{}, err } return protocol.DeriveRepositoryAuthority(snapshot, bundle, k.clock.Now()) } -func readV2Events(path string) ([]map[string]any, error) { +func readEvents(path string) ([]map[string]any, error) { file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { diff --git a/boatstack/kernel_test.go b/boatstack/kernel_test.go new file mode 100644 index 0000000..7aaf25f --- /dev/null +++ b/boatstack/kernel_test.go @@ -0,0 +1,38 @@ +package boatstack_test + +import ( + "context" + "strings" + "testing" + "time" + + boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/distribution" + "github.com/operatorstack/boatstack/boatstack/internal/surfaces" +) + +func TestRecoverSurfaceConsumesCompiledRegistryInsteadOfFixedFlowIDs(t *testing.T) { + // control-law: recover-is-classified-by-the-compiled-program-not-a-surface-shadow-list + program, err := distribution.StandardProgram(context.Background()) + if err != nil { + t.Fatal(err) + } + kernel, err := boatstack.NewKernel(t.TempDir(), program) + if err != nil { + t.Fatal(err) + } + request := surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationRecover, + Repository: "/repository-is-not-consulted", Host: "cli", CorrelationID: "compiled-recovery", + FlowID: "flow", TransitionID: "plan.create", + } + response, err := kernel.Handle(context.Background(), request) + if err == nil || !strings.Contains(err.Error(), "compiled control program") || response.Error == "" { + t.Fatalf("non-recovery transition crossed recover surface: response=%+v error=%v", response, err) + } + + request.TransitionID = "example.extension.recover" + if err := request.Validate(time.Now()); err != nil { + t.Fatalf("surface schema rejected a recovery ID before compiled-registry validation: %v", err) + } +} diff --git a/boatstack/program_effects.go b/boatstack/program_effects.go new file mode 100644 index 0000000..1203bbb --- /dev/null +++ b/boatstack/program_effects.go @@ -0,0 +1,160 @@ +package boatstack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/effects" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" +) + +type programEffectDriver struct { + base ports.EffectDriver + program control.ControlProgram +} + +func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + if transition.Origin.Kind == catalog.OriginCoreSystem { + return d.base.Prepare(ctx, admission, transition) + } + if transition.Origin.Kind == catalog.OriginPrimaryFlow { + flow := d.program.Flow() + if flow.Manifest.RuntimeMode == control.FlowRuntimeNative { + return d.base.Prepare(ctx, admission, transition) + } + if flow.Runtime == nil { + return nil, fmt.Errorf("primary-flow runtime %q is unavailable", flow.Identity.ID) + } + parameters, err := json.Marshal(admission.Parameters) + if err != nil { + return nil, err + } + request := control.FlowRequest{ + ProtocolVersion: control.FlowProtocolVersion, FlowID: flow.Identity.ID, FlowVersion: flow.Identity.Version, + ProgramFingerprint: admission.ProgramFingerprint, CorrelationID: admission.Invocation.Correlation, + RepositoryRoot: admission.Invocation.InvokingPath, TransitionID: transition.ID, Parameters: parameters, Settings: flow.Manifest.Settings, + } + if transition.Class == catalog.EventOwnedExternal { + return effects.NewExtensionExternalPrepared(func(executionContext context.Context) (ports.EffectResult, error) { + request.Operation = control.FlowExecuteExternalOperation + response, invokeErr := flow.Runtime.InvokeFlow(executionContext, request) + if invokeErr != nil { + return ports.EffectResult{}, invokeErr + } + if err := validateFlowResponse(flow, request.Operation, request.CorrelationID, response); err != nil { + return ports.EffectResult{}, err + } + return decodeExtensionSettlement(flow.Identity.ID, response.ExternalResult) + }) + } + operation := control.FlowPlanLocalEffectOperation + if transition.Class == catalog.EventRecovery { + operation = control.FlowRecoverOperation + } + request.Operation = operation + response, invokeErr := flow.Runtime.InvokeFlow(ctx, request) + if invokeErr != nil { + return nil, invokeErr + } + if err := validateFlowResponse(flow, operation, admission.Invocation.Correlation, response); err != nil { + return nil, err + } + if err := validateProgramWrites(d.program, transition, flow.Identity.ID, response.Writes); err != nil { + return nil, err + } + return effects.NewFlowLocalPrepared(admission.Invocation.InvokingPath, flow.Identity.ID, response.Writes) + } + extension, ok := d.program.ExtensionByID(transition.Origin.ID) + if !ok || extension.Runtime == nil { + return nil, fmt.Errorf("extension runtime %q is unavailable", transition.Origin.ID) + } + parameters, err := json.Marshal(admission.Parameters) + if err != nil { + return nil, err + } + baseRequest := control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, ExtensionID: extension.Identity.ID, ExtensionVersion: extension.Identity.Version, + ProgramFingerprint: admission.ProgramFingerprint, CorrelationID: admission.Invocation.Correlation, + RepositoryRoot: admission.Invocation.InvokingPath, TransitionID: transition.ID, Parameters: parameters, Settings: extension.Manifest.Settings, + } + if transition.Class == catalog.EventOwnedExternal { + return effects.NewExtensionExternalPrepared(func(executionContext context.Context) (ports.EffectResult, error) { + request := baseRequest + request.Operation = control.ExtensionExecuteExternalOperation + response, invokeErr := extension.Runtime.Invoke(executionContext, request) + if invokeErr != nil { + return ports.EffectResult{}, invokeErr + } + if err := validateExtensionResponse(extension, request.Operation, request.CorrelationID, response); err != nil { + return ports.EffectResult{}, err + } + return decodeExtensionSettlement(extension.Identity.ID, response.ExternalResult) + }) + } + operation := control.ExtensionPlanLocalEffectOperation + if transition.Class == catalog.EventRecovery { + operation = control.ExtensionRecoverOperation + } + baseRequest.Operation = operation + response, err := extension.Runtime.Invoke(ctx, baseRequest) + if err != nil { + return nil, err + } + if err := validateExtensionResponse(extension, operation, admission.Invocation.Correlation, response); err != nil { + return nil, err + } + if err := validateProgramWrites(d.program, transition, extension.Identity.ID, response.Writes); err != nil { + return nil, err + } + return effects.NewExtensionLocalPrepared(admission.Invocation.InvokingPath, extension.Identity.ID, response.Writes) +} + +func validateProgramWrites(program control.ControlProgram, transition catalog.Transition, owner string, writes []control.ResourceWrite) error { + allowed := map[string]bool{} + for _, resource := range transition.OwnedResources { + allowed[resource] = true + } + ownership := program.ResourceOwnership() + for _, write := range writes { + if !allowed[write.Resource] || ownership[write.Resource] != owner { + return fmt.Errorf("transition %q planned undeclared resource %q", transition.ID, write.Resource) + } + } + return nil +} + +func decodeExtensionSettlement(owner string, raw []byte) (ports.EffectResult, error) { + var result struct { + Settlement ports.EffectSettlement `json:"settlement"` + Detail string `json:"detail,omitempty"` + } + if err := decodeStrictExtensionJSON(raw, &result); err != nil { + return ports.EffectResult{}, fmt.Errorf("component %q returned invalid external settlement", owner) + } + if result.Settlement != ports.EffectSettled && result.Settlement != ports.EffectUnknown { + return ports.EffectResult{}, fmt.Errorf("component %q returned invalid external settlement", owner) + } + return ports.EffectResult{Settlement: result.Settlement, Detail: result.Detail}, nil +} + +func decodeStrictExtensionJSON(raw []byte, target any) error { + if len(raw) == 0 { + return fmt.Errorf("empty JSON") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("trailing JSON") + } + return nil +} diff --git a/boatstack/program_observer.go b/boatstack/program_observer.go new file mode 100644 index 0000000..99ffc7e --- /dev/null +++ b/boatstack/program_observer.go @@ -0,0 +1,208 @@ +package boatstack + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" +) + +type programObserver struct { + base ports.Observer + program control.ControlProgram +} + +func (o programObserver) Observe(ctx context.Context, request ports.ObservationRequest) (model.Observation, error) { + observation, err := o.base.Observe(ctx, request) + if err != nil { + return model.Observation{}, err + } + flow := o.program.Flow() + if flow.Manifest.RuntimeMode == control.FlowRuntimeProtocol { + if flow.Runtime == nil { + return model.Observation{}, fmt.Errorf("primary flow %q observer is unavailable", flow.Identity.ID) + } + projection := observation + projection.ProgramFingerprint = o.program.Fingerprint() + snapshot, encodeErr := json.Marshal(projection) + if encodeErr != nil { + return model.Observation{}, fmt.Errorf("encode bounded primary-flow observation: %w", encodeErr) + } + response, invokeErr := flow.Runtime.InvokeFlow(ctx, control.FlowRequest{ + ProtocolVersion: control.FlowProtocolVersion, Operation: control.FlowObserveOperation, + FlowID: flow.Identity.ID, FlowVersion: flow.Identity.Version, + ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, + RepositoryRoot: request.Invocation.InvokingPath, Snapshot: snapshot, Settings: flow.Manifest.Settings, + }) + if invokeErr != nil { + return model.Observation{}, fmt.Errorf("primary flow %q observation failed: %w", flow.Identity.ID, invokeErr) + } + if err := validateFlowResponse(flow, control.FlowObserveOperation, request.Invocation.Correlation, response); err != nil { + return model.Observation{}, err + } + declared := make(map[string]bool, len(flow.Manifest.Facts)) + for _, id := range flow.Manifest.Facts { + declared[id] = true + } + observation.FlowFacts = make(map[string]model.Fact[string], len(declared)) + for _, fact := range response.Facts { + if !declared[fact.ID] { + return model.Observation{}, fmt.Errorf("primary flow %q returned undeclared fact %q", flow.Identity.ID, fact.ID) + } + if _, exists := observation.FlowFacts[fact.ID]; exists { + return model.Observation{}, fmt.Errorf("primary-flow fact %q was returned more than once", fact.ID) + } + if !fact.Status.Valid() || fact.Fingerprint == "" { + return model.Observation{}, fmt.Errorf("primary flow %q returned invalid fact %q", flow.Identity.ID, fact.ID) + } + observation.FlowFacts[fact.ID] = model.Fact[string]{ + Status: fact.Status, Value: fact.Value, Detail: fact.Detail, + Evidence: []model.Evidence{{Source: "primary-flow:" + flow.Identity.ID, Fingerprint: fact.Fingerprint, ObservedAt: observation.ObservedAt}}, + } + delete(declared, fact.ID) + } + if len(declared) != 0 { + return model.Observation{}, fmt.Errorf("primary flow %q omitted required observed facts", flow.Identity.ID) + } + } + // Extension observers receive the same core-plus-flow projection. Facts + // returned by one extension are collected into the final snapshot but are + // never exposed to another observer based on invocation order. + observation.ExtensionFacts = nil + extensionProjection := observation + for _, extension := range o.program.Extensions() { + if len(extension.Manifest.Facts) == 0 { + continue + } + if extension.Runtime == nil { + return model.Observation{}, fmt.Errorf("extension %q observer is unavailable", extension.Identity.ID) + } + projection := extensionProjection + projection.ProgramFingerprint = o.program.Fingerprint() + snapshot, err := json.Marshal(projection) + if err != nil { + return model.Observation{}, fmt.Errorf("encode bounded extension observation: %w", err) + } + response, err := extension.Runtime.Invoke(ctx, control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: control.ExtensionObserveOperation, + ExtensionID: extension.Identity.ID, ExtensionVersion: extension.Identity.Version, + ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, + RepositoryRoot: request.Invocation.InvokingPath, Snapshot: snapshot, Settings: extension.Manifest.Settings, + }) + if err != nil { + return model.Observation{}, fmt.Errorf("extension %q observation failed: %w", extension.Identity.ID, err) + } + if err := validateExtensionResponse(extension, control.ExtensionObserveOperation, request.Invocation.Correlation, response); err != nil { + return model.Observation{}, err + } + declared := make(map[string]bool, len(extension.Manifest.Facts)) + for _, id := range extension.Manifest.Facts { + declared[id] = true + } + if observation.ExtensionFacts == nil { + observation.ExtensionFacts = map[string]model.Fact[string]{} + } + for _, fact := range response.Facts { + if !declared[fact.ID] { + return model.Observation{}, fmt.Errorf("extension %q returned undeclared fact %q", extension.Identity.ID, fact.ID) + } + if _, exists := observation.ExtensionFacts[fact.ID]; exists { + return model.Observation{}, fmt.Errorf("extension fact %q was returned more than once", fact.ID) + } + if !fact.Status.Valid() || fact.Fingerprint == "" { + return model.Observation{}, fmt.Errorf("extension %q returned invalid fact %q", extension.Identity.ID, fact.ID) + } + observation.ExtensionFacts[fact.ID] = model.Fact[string]{ + Status: fact.Status, Value: fact.Value, Detail: fact.Detail, + Evidence: []model.Evidence{{Source: "extension:" + extension.Identity.ID, Fingerprint: fact.Fingerprint, ObservedAt: observation.ObservedAt}}, + } + delete(declared, fact.ID) + } + if len(declared) != 0 { + return model.Observation{}, fmt.Errorf("extension %q omitted required observed facts", extension.Identity.ID) + } + } + if request.VerifyTransitionID != "" { + transition, ok := o.program.RuntimeRegistry().Lookup(request.VerifyTransitionID) + if ok && transition.Origin.Kind == catalog.OriginPrimaryFlow && flow.Manifest.RuntimeMode == control.FlowRuntimeProtocol { + snapshot, encodeErr := json.Marshal(observation) + if encodeErr != nil { + return model.Observation{}, encodeErr + } + response, invokeErr := flow.Runtime.InvokeFlow(ctx, control.FlowRequest{ + ProtocolVersion: control.FlowProtocolVersion, Operation: control.FlowVerifyOperation, + FlowID: flow.Identity.ID, FlowVersion: flow.Identity.Version, + ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, + RepositoryRoot: request.Invocation.InvokingPath, TransitionID: transition.ID, Snapshot: snapshot, Settings: flow.Manifest.Settings, + }) + if invokeErr != nil { + return model.Observation{}, invokeErr + } + if err := validateFlowResponse(flow, control.FlowVerifyOperation, request.Invocation.Correlation, response); err != nil { + return model.Observation{}, err + } + if response.Verified == nil || !*response.Verified { + return model.Observation{}, fmt.Errorf("primary-flow verifier %q rejected the postcondition", transition.Verifier) + } + } + if ok && transition.Origin.Kind == catalog.OriginExtension { + extension, exists := o.program.ExtensionByID(transition.Origin.ID) + if !exists || extension.Runtime == nil { + return model.Observation{}, fmt.Errorf("extension verifier %q is unavailable", transition.Verifier) + } + snapshot, err := json.Marshal(observation) + if err != nil { + return model.Observation{}, err + } + response, err := extension.Runtime.Invoke(ctx, control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: control.ExtensionVerifyOperation, + ExtensionID: extension.Identity.ID, ExtensionVersion: extension.Identity.Version, + ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, + RepositoryRoot: request.Invocation.InvokingPath, TransitionID: transition.ID, Snapshot: snapshot, Settings: extension.Manifest.Settings, + }) + if err != nil { + return model.Observation{}, err + } + if err := validateExtensionResponse(extension, control.ExtensionVerifyOperation, request.Invocation.Correlation, response); err != nil { + return model.Observation{}, err + } + if response.Verified == nil || !*response.Verified { + return model.Observation{}, fmt.Errorf("extension verifier %q rejected the postcondition", transition.Verifier) + } + } + } + return observation, nil +} + +func validateFlowResponse(flow control.CompiledFlow, operation control.FlowOperation, correlation string, response control.FlowResponse) error { + if response.ProtocolVersion != control.FlowProtocolVersion || response.Operation != operation || + response.FlowID != flow.Identity.ID || response.FlowVersion != flow.Identity.Version || response.CorrelationID != correlation { + return fmt.Errorf("primary flow %q returned a mismatched protocol response", flow.Identity.ID) + } + if err := control.ValidateFlowOperationResponse(operation, response); err != nil { + return fmt.Errorf("primary flow %q returned an invalid operation response: %w", flow.Identity.ID, err) + } + if response.ErrorClass != "" || response.Error != "" { + return fmt.Errorf("primary flow %q reported %s", flow.Identity.ID, response.ErrorClass) + } + return nil +} + +func validateExtensionResponse(extension control.CompiledExtension, operation control.ExtensionOperation, correlation string, response control.ExtensionResponse) error { + if response.ProtocolVersion != control.ExtensionProtocolVersion || response.Operation != operation || + response.ExtensionID != extension.Identity.ID || response.ExtensionVersion != extension.Identity.Version || + response.CorrelationID != correlation { + return fmt.Errorf("extension %q returned a mismatched protocol response", extension.Identity.ID) + } + if err := control.ValidateExtensionOperationResponse(operation, response); err != nil { + return fmt.Errorf("extension %q returned an invalid operation response: %w", extension.Identity.ID, err) + } + if response.ErrorClass != "" || response.Error != "" { + return fmt.Errorf("extension %q reported %s", extension.Identity.ID, response.ErrorClass) + } + return nil +} diff --git a/boatstack/program_observer_test.go b/boatstack/program_observer_test.go new file mode 100644 index 0000000..8215e19 --- /dev/null +++ b/boatstack/program_observer_test.go @@ -0,0 +1,79 @@ +package boatstack + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" +) + +type fixedObservation struct{ value model.Observation } + +func (o fixedObservation) Observe(context.Context, ports.ObservationRequest) (model.Observation, error) { + return o.value, nil +} + +type isolatedObservationExtension struct { + id string + forbid string + sawFact *bool +} + +func (e isolatedObservationExtension) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { + return control.ExtensionManifest{ + ID: e.id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, + SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{e.id + ".fact"}, + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }, nil +} + +func (e isolatedObservationExtension) Runtime() control.ExtensionRuntime { return e } + +func (e isolatedObservationExtension) Invoke(_ context.Context, request control.ExtensionRequest) (control.ExtensionResponse, error) { + var projection struct { + ExtensionFacts map[string]json.RawMessage `json:"extension_facts"` + } + if err := json.Unmarshal(request.Snapshot, &projection); err != nil { + return control.ExtensionResponse{}, err + } + _, *e.sawFact = projection.ExtensionFacts[e.forbid] + return control.ExtensionResponse{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: request.Operation, + ExtensionID: e.id, ExtensionVersion: "1.0.0", CorrelationID: request.CorrelationID, + Facts: []control.ExtensionFact{{ID: e.id + ".fact", Status: control.FactKnown, Value: "observed", Fingerprint: e.id + "-fingerprint"}}, + }, nil +} + +func TestExtensionObserversConsumeOneOrderIndependentProjection(t *testing.T) { + // control-law: extension-observation-order-cannot-create-cross-extension-facts + var alphaSawBeta, betaSawAlpha bool + alpha := isolatedObservationExtension{id: "example.alpha", forbid: "example.beta.fact", sawFact: &alphaSawBeta} + beta := isolatedObservationExtension{id: "example.beta", forbid: "example.alpha.fact", sawFact: &betaSawAlpha} + program, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "test-kernel", Core: core.System(), Flow: standard.Definition(), + Extensions: []control.Extension{beta, alpha}, + }) + if err != nil { + t.Fatal(err) + } + invocation := model.InvocationContext{Correlation: "observation-order"} + observed, err := (programObserver{ + base: fixedObservation{value: model.Observation{Invocation: invocation, ObservedAt: time.Unix(100, 0).UTC()}}, + program: program, + }).Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if alphaSawBeta || betaSawAlpha { + t.Fatalf("extension observer consumed another extension's invocation-order fact: alpha=%v beta=%v", alphaSawBeta, betaSawAlpha) + } + if len(observed.ExtensionFacts) != 2 || observed.ExtensionFacts["example.alpha.fact"].Value != "observed" || observed.ExtensionFacts["example.beta.fact"].Value != "observed" { + t.Fatalf("final layered observation lost extension facts: %#v", observed.ExtensionFacts) + } +} diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index 5d88cd6..a718442 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -5,9 +5,17 @@ normative Go decoder is `internal/kernel/protocol.DecodeProjectConfig`; the public example is `project.example.json`. -Top-level keys are `schema_version`, `project`, `policy`, and `hosts`. +Top-level keys are `schema_version`, `project`, `policy`, `hosts`, and optional +`extensions`. Unknown keys and trailing JSON fail. Hosts are selected from `claude`, `cli`, -`codex`, `cursor`, `gemini`, and `mcp`; `cli` is mandatory. +`codex`, `cursor`, `gemini`, `mcp`, and `sdk`; `cli` is mandatory. + +Each `extensions` item selects only an additive subprocess extension and +requires a semantic ID, exact version, symlink-free absolute executable path, +exact SHA-256, optional strict JSON settings, and optional bounded deadline, +stdout, and stderr limits. Repository configuration cannot replace the primary +flow. A subprocess extension is a trusted executable boundary, not an OS +sandbox. Configuration changes use `configuration.mutate` with `config_path` and `config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-2 diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index f4c8ad3..cbc2522 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -1,11 +1,15 @@ -// Package sdk exposes the versioned Boatstack V2 surface protocol without +// Package sdk exposes the versioned Boatstack surface protocol without // exposing or duplicating the internal controller implementation. package sdk import ( "context" + "fmt" boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/distribution" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" @@ -71,18 +75,131 @@ const ( DecisionUnresolved = supervisor.DecisionUnresolved ) -// Client is the only supported in-process V2 entry point. It delegates every -// decision and effect to the same kernel used by the CLI. -type Client struct{ kernel boatstack.V2Kernel } +const HostIdentity = "sdk" -func New(externalStateRoot string) (Client, error) { - kernel, err := boatstack.NewV2Kernel(externalStateRoot) +type options struct { + flow control.FlowDefinition + extensions []control.Extension +} + +type Option func(*options) error + +func WithFlow(flow control.FlowDefinition) Option { + return func(configuration *options) error { + if flow == nil { + return fmt.Errorf("SDK flow cannot be nil") + } + if configuration.flow != nil { + return fmt.Errorf("SDK accepts exactly one PrimaryFlow") + } + configuration.flow = flow + return nil + } +} + +func WithExtension(extension control.Extension) Option { + return func(configuration *options) error { + if extension == nil { + return fmt.Errorf("SDK extension cannot be nil") + } + configuration.extensions = append(configuration.extensions, extension) + return nil + } +} + +// Client is an immutable program factory plus the canonical request boundary. +// It compiles one repository-scoped ControlProgram per request, allowing a +// single Client to serve concurrent repositories with different extensions. +type Client struct { + externalStateRoot string + standard bool + flow control.FlowDefinition + extensions []control.Extension +} + +// New assembles the standard Boatstack distribution. Options may add +// extensions but cannot replace StandardFlow. +func New(externalStateRoot string, supplied ...Option) (Client, error) { + configuration, err := applyOptions(supplied) if err != nil { return Client{}, err } - return Client{kernel: kernel}, nil + if configuration.flow != nil { + return Client{}, fmt.Errorf("sdk.New always uses StandardFlow; use sdk.NewKernel for an explicit flow") + } + if _, err := distribution.StandardProgram(context.Background(), configuration.extensions...); err != nil { + return Client{}, err + } + return Client{externalStateRoot: externalStateRoot, standard: true, extensions: append([]control.Extension(nil), configuration.extensions...)}, nil +} + +// NewKernel is the low-level composition API. It never inserts StandardFlow; +// callers must supply exactly one WithFlow option. +func NewKernel(externalStateRoot string, supplied ...Option) (Client, error) { + configuration, err := applyOptions(supplied) + if err != nil { + return Client{}, err + } + if configuration.flow == nil { + return Client{}, fmt.Errorf("sdk.NewKernel requires an explicit PrimaryFlow") + } + if _, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: configuration.flow, + Extensions: configuration.extensions, + }); err != nil { + return Client{}, err + } + return Client{externalStateRoot: externalStateRoot, flow: configuration.flow, extensions: append([]control.Extension(nil), configuration.extensions...)}, nil +} + +func applyOptions(supplied []Option) (options, error) { + var configuration options + for _, option := range supplied { + if option == nil { + return options{}, fmt.Errorf("SDK option cannot be nil") + } + if err := option(&configuration); err != nil { + return options{}, err + } + } + return configuration, nil } func (c Client) Do(ctx context.Context, request Request) (Response, error) { - return c.kernel.Handle(ctx, request) + if request.Host == "" { + request.Host = HostIdentity + } + repositoryRequest := distribution.RepositoryProgramRequest{ + Repository: request.Repository, ExternalStateRoot: c.externalStateRoot, Host: request.Host, + CorrelationID: request.CorrelationID, Extensions: c.extensions, + } + if request.TransitionID == "installation.initialize" || request.TransitionID == "configuration.initialize" { + repositoryRequest.ConfigurationPath, _ = request.Parameters.Get("config_path") + repositoryRequest.ConfigurationFingerprint, _ = request.Parameters.Get("config_sha256") + } + var program control.ControlProgram + var err error + if c.standard { + program, err = distribution.StandardProgramForRepository(ctx, repositoryRequest) + } else { + var configured []control.Extension + var settings any + configured, settings, err = distribution.ConfiguredExtensions(ctx, repositoryRequest) + if err == nil { + extensions := append([]control.Extension(nil), c.extensions...) + extensions = append(extensions, configured...) + program, err = control.Compile(ctx, control.CompileRequest{ + KernelVersion: boatstack.Version, Core: core.System(), Flow: c.flow, + Extensions: extensions, Settings: settings, + }) + } + } + if err != nil { + return Response{}, err + } + kernel, err := boatstack.NewKernel(c.externalStateRoot, program) + if err != nil { + return Response{}, err + } + return kernel.Handle(ctx, request) } diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index 610acbf..15b41ca 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -1,8 +1,11 @@ package sdk_test import ( + "context" + "encoding/json" "testing" + "github.com/operatorstack/boatstack/boatstack/control" "github.com/operatorstack/boatstack/boatstack/sdk" ) @@ -19,3 +22,74 @@ func TestPublicProtocolCanBeConstructedWithoutInternalPackages(t *testing.T) { t.Fatalf("public V2 aliases lost protocol identity: %#v", request) } } + +func TestLowLevelSDKRequiresAndAcceptsExactlyOneNonStandardPrimaryFlow(t *testing.T) { + // control-law: low-level-sdk-never-inserts-or-multiplies-standard-flow + if _, err := sdk.NewKernel(""); err == nil { + t.Fatal("low-level SDK accepted a missing PrimaryFlow") + } + flow := syntheticFlow{} + if _, err := sdk.NewKernel("", sdk.WithFlow(flow)); err != nil { + t.Fatalf("synthetic PrimaryFlow was rejected: %v", err) + } + if _, err := sdk.NewKernel("", sdk.WithFlow(flow), sdk.WithFlow(flow)); err == nil { + t.Fatal("low-level SDK accepted two PrimaryFlows") + } + if _, err := sdk.New("", sdk.WithFlow(flow)); err == nil { + t.Fatal("standard SDK allowed StandardFlow replacement") + } +} + +type syntheticFlow struct{} + +func (syntheticFlow) FlowRuntime() control.FlowRuntime { return syntheticRuntime{} } + +func (syntheticFlow) FlowManifest(context.Context) (control.PrimaryFlowManifest, error) { + const ( + id = "synthetic.lifecycle" + fact = "synthetic.lifecycle.stage" + resource = "synthetic.lifecycle.state" + ) + transition := func(id control.TransitionID, source, target string, priority int) control.Transition { + effect := control.EffectID(string(id) + "-effect") + verifier := string(id) + "-verifier" + return control.Transition{ + ID: id, Version: 1, SelectionClass: control.SelectionFlowProgress, Class: control.EventOwnedLocal, + SourcePhases: []control.ProtocolPhase{control.PhaseObserved, control.PhaseActive}, TargetPhases: []control.ProtocolPhase{control.PhaseObserved, control.PhaseActive}, + GoalKinds: []control.GoalKind{control.GoalVerified}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id"}, + Authority: []control.AuthorityClass{control.AuthorityRepository}, RequiredEvidence: []string{"snapshot", "goal", "facet:" + fact}, + OwnedResources: []string{resource}, Effect: effect, LocalEffects: []control.EffectID{effect}, Idempotent: true, + Prescription: control.Prescription{Operation: string(id), ExpectedPostcondition: target}, + SourcePredicate: "synthetic-source", AdmissionPredicate: "exact-admission", TargetPredicate: "synthetic-target", + SourceConditions: []control.FacetCondition{control.KnownCondition(control.FacetName(fact), source)}, + TargetConditions: []control.FacetCondition{control.KnownCondition(control.FacetName(fact), target)}, Verifier: verifier, + Interruption: control.InterruptionContract{ + Points: []string{"after-effect"}, PartialState: []string{"namespaced-flow-state"}, Detection: "fresh-flow-observation", + ResumeContract: "re-observe", RollbackContract: "restore-prior-bytes", CompensationContract: "not-required", + Recovery: "recovery.escalate", RecoveryAuthority: "repository-policy", ResumptionPredicate: "fresh-flow-fact", + }, + Reversibility: control.Reversible, TerminalEffect: "compiled-goal-contract", PrivacyClassification: "metadata-only", + TelemetryClassification: "transition-receipt", CostClass: "synthetic", Priority: priority, + } + } + verify := transition("synthetic.lifecycle.verify", "start", "verify", 1) + finish := transition("synthetic.lifecycle.finish", "verify", "terminal", 2) + return control.PrimaryFlowManifest{ + ID: id, Version: "1.0.0", ProtocolVersion: control.FlowProtocolVersion, RuntimeMode: control.FlowRuntimeProtocol, + SupportedGoals: []control.GoalKind{control.GoalVerified}, + GoalContracts: []control.GoalContract{{GoalKind: control.GoalVerified, Conditions: []control.FacetCondition{control.KnownCondition(control.FacetName(fact), "terminal")}}}, + Transitions: []control.Transition{verify, finish}, Facts: []string{fact}, OwnedResources: []string{resource}, + Effects: []string{string(verify.Effect), string(finish.Effect)}, Verifiers: []string{verify.Verifier, finish.Verifier}, + ConfigurationSchema: json.RawMessage(`{"type":"object"}`), + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }, nil +} + +type syntheticRuntime struct{} + +func (syntheticRuntime) InvokeFlow(_ context.Context, request control.FlowRequest) (control.FlowResponse, error) { + return control.FlowResponse{ + ProtocolVersion: control.FlowProtocolVersion, Operation: request.Operation, + FlowID: request.FlowID, FlowVersion: request.FlowVersion, CorrelationID: request.CorrelationID, + }, nil +} diff --git a/docs/architecture/boatstack-standard-flow.mmd b/docs/architecture/boatstack-standard-flow.mmd new file mode 100644 index 0000000..c2a8d24 --- /dev/null +++ b/docs/architecture/boatstack-standard-flow.mmd @@ -0,0 +1,42 @@ +%% Generated from compiled PrimaryFlow declarations by surfaces.RenderStandardFlowMermaid. Do not edit. +flowchart TB + subgraph authority["authority"] + t00["evidence.approval.revoke
ACTIVE | FRONTIER → FRONTIER"] + t01["plan.abandon
OBSERVED | ACTIVE | FRONTIER → ABANDONED"] + t02["plan.approve
ACTIVE | FRONTIER → ACTIVE | TERMINAL"] + t03["plan.approve-amendment
ACTIVE | FRONTIER → ACTIVE"] + t04["publication.abandon
ACTIVE | FRONTIER → ABANDONED"] + end + subgraph owned_local["owned-local"] + t05["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"] + t06["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"] + t07["gate.build.record
ACTIVE → ACTIVE"] + t08["gate.change.record
ACTIVE → ACTIVE"] + t09["gate.journey.record
ACTIVE → ACTIVE"] + t10["gate.review.record
ACTIVE → ACTIVE | TERMINAL"] + t11["gate.test.record
ACTIVE → ACTIVE | TERMINAL"] + t12["plan.activate
OBSERVED | ACTIVE → ACTIVE"] + t13["plan.amend
ACTIVE | FRONTIER → ACTIVE"] + t14["plan.create
OBSERVED | ACTIVE → ACTIVE"] + t15["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"] + t16["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"] + t17["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] + t18["publication.preview
ACTIVE → ACTIVE"] + t19["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"] + t20["workspace.activate
OBSERVED | ACTIVE → ACTIVE"] + t21["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] + t22["workspace.cut
OBSERVED | ACTIVE → ACTIVE"] + t23["workspace.publish
ACTIVE → ACTIVE"] + t24["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] + t25["workspace.sync
ACTIVE → ACTIVE | FRONTIER"] + end + subgraph owned_external["owned-external"] + t26["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"] + t27["publication.execute
ACTIVE → ACTIVE | RECOVERY"] + end + subgraph recovery["recovery"] + t28["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] + t29["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + end + subgraph observed_external["observed-external"] + end diff --git a/docs/architecture/boatstack-v2-closure-report.md b/docs/architecture/boatstack-v2-closure-report.md index 851d8b0..51abb65 100644 --- a/docs/architecture/boatstack-v2-closure-report.md +++ b/docs/architecture/boatstack-v2-closure-report.md @@ -1,5 +1,9 @@ # Boatstack V2 replacement closure +> Historical replacement evidence for PR #186. The normative current +> architecture and executable counts are defined by +> [Boatstack programmable delivery control architecture](boatstack-v2-kernel.md). + Base revision: `c5b5e10cdcf4d97b645d705cb164e762acf93ff1` Replacement mode: flag day; no V1 compatibility or state migration diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md index e1b776d..ba775f1 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-v2-kernel.md @@ -1,14 +1,16 @@ -# Boatstack V2 authoritative delivery kernel +# Boatstack programmable delivery control architecture Status: normative implementation specification -Base revision: `c5b5e10cdcf4d97b645d705cb164e762acf93ff1` (`origin/main`, including PR #185) -Rewrite branch: `rewrite/v2-delivery-kernel` -Scope: one flag-day rewrite and one final pull request; no merge is authorized by this document +Base revision: `f7a5c9d1f2d15057f484371f348ee57311c0155e` (`origin/main`, after the V2 kernel replacement) +Implementation branch: `feat/control-program-and-standard-flow` +Scope: separate mechanism, system capabilities, primary delivery flow, optional +extensions, and product surfaces in one final pull request; no merge is +authorized by this document > Boatstack V2 is a flag-day replacement. Existing machine-local state may be > discarded and regenerated. No V1 runtime remains after cutover. -This document is the source of truth for the V2 implementation. If code and this +This document is the source of truth for the Boatstack implementation. If code and this document disagree, the discrepancy is a release blocker: either the code must be corrected or this document must be deliberately amended with matching tests. The [replacement closure report](boatstack-v2-closure-report.md) binds its frozen @@ -21,11 +23,11 @@ slices. They are logical ownership boundaries, not rollout phases. | Slice | Domain | Structure | Goal | Operator | Immediate value | | --- | --- | --- | --- | --- | --- | -| 1. Authoritative kernel | Repository-local delivery control | One evidence-backed composite snapshot and one transition catalog | Every managed state has a safe path to progress, recovery, authority frontier, or terminal | Observe, resolve, admit, execute, verify, record, recover | One executable delivery law replaces distributed state and authority reconstruction | -| 2. Product surfaces | Shipped CLI, hooks, SDK/MCP, hosts, and renderers | One adapter protocol projected from kernel decisions and prescriptions | Every consumer observes and requests the same semantics | Decode, invoke, render | Hosts stop acting as independent controllers while useful workflows remain available | +| 1. Compiled control law | Repository-local delivery control | CoreSystem plus one PrimaryFlow and zero or more conservative Extensions compiled into one immutable ControlProgram | Every managed state has a safe path to progress, recovery, authority frontier, or terminal | Compile, observe, resolve, admit, execute, verify, record, recover | Delivery policy can evolve without changing the mechanism that protects authority and effects | +| 2. Product surfaces | Shipped CLI, hooks, SDK/MCP, hosts, and renderers | One adapter protocol projected from Kernel decisions and prescriptions | Every consumer observes and requests the same compiled semantics | Assemble, decode, invoke, render | Hosts stop acting as independent controllers while useful workflows remain available | -Canonical form for slice 1: one domain, the `Snapshot` schema, the configured -`Goal`, and the `Engine.Apply` operator. Canonical form for slice 2: one domain, +Canonical form for slice 1: one domain, the `ControlProgram` and `Snapshot` +schemas, the configured `Goal`, and the `Kernel.Handle` operator. Canonical form for slice 2: one domain, the `SurfaceRequest`/`SurfaceResponse` schema, the same goal, and the adapter projection operator. @@ -44,7 +46,219 @@ which external outcomes can be proved, and which platform primitive provides atomic replacement. A user decision is required only if a new transition would change who may authorize an effect or what counts as a delivery terminal. -## 1. Product contract +Value emerges at the compilation boundary: the smallest valuable change is not +a second workflow engine, but one deterministic program that preserves the V2 +effect protocol while moving delivery policy out of the mechanism. The two +jointly shipped slices are therefore (1) program compilation and Kernel +execution, and (2) standard distribution and surface projection. + +## 1. Program architecture + +Boatstack is a programmable supervisory control runtime for software delivery, +with a first-party standard delivery flow. Its dependency direction is: + +```text +kernel contracts + ^ + |-- CoreSystem + |-- one PrimaryFlow + `-- zero or more Extensions + ^ + | + distribution assembly + ^ + | + SDK / CLI / hosts +``` + +The application assembles one immutable program before resolution: + +```text +CoreSystem + PrimaryFlow + Extensions + RepositoryPolicy + -> Compile + -> ControlProgram + -> Kernel + -> observe -> resolve -> admit -> execute -> verify -> receipt -> recover +``` + +### Ownership + +- **Kernel** is the stable deterministic mechanism. It accepts an explicit + compiled program and owns observation orchestration, canonicalization, + resolution, admission, effect routing, postcondition verification, + journaling, receipts, replay, recovery, and drift refusal. It imports no + primary flow, extension implementation, CLI, SDK wrapper, or host renderer. +- **CoreSystem** declares Boatstack operational capabilities: invocation and + repository identity, engagement, runtime, configuration, installation, + generic goal identity, transactions, recovery, process events, and external + observations. +- **PrimaryFlow** is exactly one trusted in-process delivery law. It declares + goal contracts, facts, transitions, resources, effects, verifiers, recovery, + policy projection, and telemetry. The application selects it; repository + configuration cannot select an arbitrary executable flow. +- **StandardFlow** is the first-party primary flow preserving the familiar + plan, approval, workspace, gate, evidence, publication, correction, and + abandonment behavior. +- **Extensions** are additive. In-process extensions are trusted compiled Go + capabilities constrained by the compiler. Subprocess extensions are trusted + executable boundaries using a strict bounded JSON protocol; they are not OS + sandboxes. Extensions may add namespaced facts, resources, transitions, + recovery, and conjunctive goal obligations, but may not replace the flow, + weaken a goal contract, or mutate another owner's state. +- **Surfaces** assemble or invoke a program and render typed results. They do + not decide lifecycle, terminal state, authority, or recovery. + +### ControlProgram + +`Compile` consumes an explicit CoreSystem definition, one PrimaryFlow manifest, +zero or more extension manifests, and canonical program-affecting settings. It +rejects missing or multiple flows, ID collisions, unnamespaced extension IDs, +overlapping mutable-resource ownership, undeclared effects or verifiers, +missing recovery contracts, dependency cycles, and goal constraints that are +not conservative. + +The result is immutable and contains one transition registry, one goal-contract +set, one resource-ownership map, compiled handlers, origin metadata, and one +content fingerprint. The registry is the only runtime graph. There is no core, +flow, extension, terminal, or verification shadow graph. + +The stable Go authoring and construction boundaries are: + +```go +type FlowDefinition interface { + FlowManifest(context.Context) (PrimaryFlowManifest, error) +} + +type Extension interface { + ExtensionManifest(context.Context) (ExtensionManifest, error) +} + +func Compile(context.Context, CompileRequest) (ControlProgram, error) +func NewKernel(externalStateRoot string, program control.ControlProgram) (Kernel, error) +``` + +Primary-flow runtime adapters are trusted in-process implementations of +`FlowRuntime`; this release has no subprocess primary-flow loader. The bounded +request/response contract gives custom flows immutable projections rather than +a mutable Kernel object. Every operation has an exact tagged response payload, +and identity, version, correlation, error classification, and operation type +are checked at the Kernel boundary. + +`sdk.New(...)` assembles CoreSystem plus StandardFlow and repository-scoped +extensions. `sdk.NewKernel(..., sdk.WithFlow(flow), sdk.WithExtension(...))` +requires exactly one explicit primary flow and never inserts StandardFlow. + +The fingerprint covers the Kernel version; CoreSystem ID, version, manifest, +and transitions; PrimaryFlow ID, version, manifest, goal contracts, and +transitions; extension manifests, versions, executable SHA-256 values, +settings, goal constraints, and transitions; the compiled transition registry; +resource ownership; verifier and recovery declarations; and canonical +program-affecting repository policy. In this version that repository projection +is exactly the checksum-bound extension composition; approval, host, visual, +and risk policy remain controlling snapshot facts rather than catalog identity. +The fingerprint is bound into snapshots, admissions, +flow records, transition receipts, recovery journals, and telemetry. Once a +flow admits its first transition, a different fingerprint is program drift and +must fail closed until explicit reconciliation. + +### Compiled transition ownership + +The current 62-event Standard distribution is classified from compiled +component declarations: + +| Owner | Families | Count | +| --- | --- | ---: | +| CoreSystem | `engagement.*`, `invocation.*`, `repository.*`, `runtime.*`, `configuration.*`, `installation.*`, `catalog.*`, `goal.*`, `recovery.*`, `external.*` | 32 | +| StandardFlow | `plan.*`, `workspace.*`, `gate.*`, `evidence.*`, `delivery.*`, `publication.*` | 30 | +| Extensions in the default distribution | none | 0 | +| **Compiled total** | one registry | **62** | + +The CoreSystem ownership of `external.*` declares the event vocabulary and +observation boundary; StandardFlow consumes the bounded publication and +verification facts without taking ownership of that boundary. + +### Selection and terminal contracts + +Every transition records its origin, owner, manifest fingerprint, and bounded +selection class: `SYSTEM_RECOVERY`, `FLOW_RECOVERY`, `EXTENSION_RECOVERY`, +`GOAL_REQUIRED`, `FLOW_PROGRESS`, `EXPLICIT_ONLY`, or `OBSERVED_EXTERNAL`. Third-party +extensions cannot supply raw numeric priority. An extension becomes implicitly +selectable only to discharge an active unmet extension obligation or its own +recovery contract. + +CoreSystem and PrimaryFlow declarations own their selection semantics; the +compiler never infers ordering from a transition ID or family name. An omitted +extension selection is bounded to `EXPLICIT_ONLY`, or to +`EXTENSION_RECOVERY` for an explicitly declared extension recovery. A +PrimaryFlow recovery manifest lists only recovery transitions owned by that +flow; cross-component interruption references are resolved only after the one +compiled registry exists. + +Command classification is similarly policy-neutral. A host classifier emits a +semantic managed operation, and the compiled registry maps that operation to a +transition through `PolicyContract.ManagedOperations`. A custom program that +does not claim an operation does not inherit StandardFlow transition IDs. + +The five software-delivery goal kinds remain closed. The PrimaryFlow supplies +the base terminal contract. Extension obligations are conjoined with that +contract, so for the same base state: + +```text +Terminal(StandardFlow + Extension) subseteq Terminal(StandardFlow) +``` + +Only the Kernel evaluates the compiled terminal contract. A flow or extension +cannot report terminal state directly. + +### Observation and effects + +Observation is layered in deterministic owner and ID order: core observation, +PrimaryFlow observation, then extension observations. Owners receive bounded +immutable projections. Required observer failure remains explicit unresolved, +blocked, or recovery evidence; it never disappears or becomes false. Snapshot +identity includes all controlling core, flow, and extension facts plus the +program fingerprint. + +PrimaryFlow and extension responses are validated as exact operation-specific +unions before their facts, writes, external settlement, or verifier result can +be interpreted. Classified errors cannot carry success payloads. Subprocess +extensions additionally use strict JSON with no unknown fields or trailing +data, and their exact symlink-free executable path and SHA-256 are revalidated +before every invocation. + +The compiled resource map assigns every mutable resource exactly one owner. +Effect routing rejects undeclared effects, handlers, and writes before any +mutation. StandardFlow and extension effects still pass through the same exact +admission, journal, verification, recovery, and Kernel-written receipt path. + +### Standard and custom distributions + +The default SDK and CLI explicitly assemble `CoreSystem + StandardFlow + +configured extensions`; users acquire no new configuration burden. A low-level +SDK constructor requires an explicit PrimaryFlow. A custom application can +assemble `CoreSystem + another trusted flow + selected extensions` without +forking Kernel and without parsing CLI output. + +```go +standardClient, err := sdk.New(stateRoot, sdk.WithExtension(extension)) +customClient, err := sdk.NewKernel( + stateRoot, + sdk.WithFlow(primaryFlow), + sdk.WithExtension(extension), +) +``` + +The first form always selects StandardFlow. The second form never inserts it. +Both clients compile the repository-scoped program and delegate every request +through `Client.Do`. + +The deterministic Kernel test target uses synthetic facts, flows, clocks, +effects, journals, receipts, and verifiers. One unrelated `START -> VERIFY -> +TERMINAL` flow proves that Kernel has no dependency on plan, workspace, PR, or +publication semantics. StandardFlow parity, extension conformance, surface +parity, and platform integration are separate test layers. + +## 2. Product contract Boatstack is a repository-local supervisory controller for software delivery by humans and coding agents. The agent writes software. Boatstack deterministically @@ -275,13 +489,15 @@ gain event authority merely because they are commands. ## 7. Transition registry -The initial V2 catalog contains **61 semantic events**. This count is generated -from code and must remain synchronized with this table. +The compiled Standard distribution contains **62 semantic events**. This count +is generated from CoreSystem and StandardFlow declaration bytes and must remain +synchronized with this table. | Family | Count | Required IDs | | --- | ---: | --- | | Invocation and engagement | 6 | `engagement.begin`, `engagement.renew`, `engagement.release`, `invocation.rebind`, `repository.attach`, `repository.detach` | | Installation, runtime, configuration | 8 | `runtime.hydrate`, `runtime.replace`, `runtime.reconcile`, `configuration.initialize`, `configuration.mutate`, `configuration.reconcile`, `installation.initialize`, `installation.update` | +| Catalog identity | 1 | `catalog.reconcile` | | Goal and plan | 9 | `goal.configure`, `plan.create`, `plan.validate`, `plan.approve`, `plan.activate`, `plan.amend`, `plan.approve-amendment`, `plan.invalidate`, `plan.abandon` | | Workspace | 8 | `workspace.cut`, `workspace.sync`, `workspace.activate`, `workspace.publish`, `workspace.cleanup`, `workspace.reap`, `workspace.abandon`, `workspace.reconcile` | | Delivery gates and evidence | 8 | `gate.build.record`, `gate.test.record`, `gate.review.record`, `gate.change.record`, `gate.journey.record`, `evidence.visual.attach`, `evidence.approval.revoke`, `delivery.slice.advance` | @@ -306,6 +522,9 @@ prescription. The registry is executable runtime authority, not a shadow model. The checked [catalog table](boatstack-v2-transition-catalog.md) and [Mermaid graph](boatstack-v2-transition-catalog.mmd) are deterministic projections of this registry. Golden tests reject either artifact when it drifts. +The checked [StandardFlow graph](boatstack-standard-flow.mmd) filters that same +compiled registry by primary-flow origin and contains exactly 30 transitions; +it is not an independently maintained graph. ## 8. Supervisory control law @@ -470,17 +689,21 @@ Dependencies point downward in this table and are acyclic. | Package | Owns | Public boundary and verifier | Allowed dependencies | Forbidden dependencies | | --- | --- | --- | --- | --- | | `internal/kernel/model` | typed facts, identity, snapshot, goal, fingerprints | constructors/canonical encoding; schema and invariant tests | standard library | plant, effects, surfaces, facade | -| `internal/kernel/catalog` | 61 transition declarations and catalog invariants | read-only registry; uniqueness/completeness/reachability verifier | model | effects implementations, surfaces | -| `internal/kernel/supervisor` | admissible-set and deterministic outcome law | pure `Resolve`; exhaustive reachable-state/property tests | model, catalog | I/O, effects, surfaces | +| `control` | stable CoreSystem, PrimaryFlow, Extension, and immutable ControlProgram compiler contracts | strict manifests, conservative extension compilation, fingerprints, ownership map | kernel contracts | concrete distribution or surfaces | +| `core` | 32 operational-capability transition declarations | embedded strict declaration bytes through `CoreManifest` | control contracts | StandardFlow, extensions, surfaces | +| `flow/standard` | 30 first-party delivery transitions and five base goal contracts | `standard.Definition()` plus default-flow parity, historical, ownership, and completeness tests | control contracts and model vocabulary | Kernel mechanism, CLI, host rendering, SDK | +| `extension/*` | additive in-process and checksum-bound subprocess capabilities | strict extension manifests and bounded runtime protocol | control contracts | Kernel state, admissions, receipts, foreign resources | +| `internal/kernel/catalog` | transition, registry, and goal-contract mechanism and invariants | read-only registry; uniqueness and recovery-reference validation | model | CoreSystem or StandardFlow declarations, effects, surfaces | +| `internal/kernel/supervisor` | admissible-set and deterministic outcome law | pure `Resolve`; synthetic mechanism tests through the engine, with StandardFlow parity outside Kernel packages | model, catalog | I/O, effects, surfaces | | `internal/kernel/protocol` | prescriptions, admission, receipts, recovery records | typed codecs and content identity verifier | model, catalog | concrete I/O and surfaces | | `internal/kernel/durable` | strict machine-state and detached-binding codecs | canonical encode/decode and invariant validation | model, catalog | observation, effects, surfaces | | `internal/kernel/ports` | observer, clock, lock, journal, local/external effect ports | compile-time narrow interfaces and fakes | model, protocol | concrete adapters | -| `internal/kernel/reducer` | the sole durable lifecycle reduction for admitted controllable events | `Apply` plus exhaustive catalog coverage tests | model, catalog, durable, protocol | I/O, plant, surfaces | | `internal/kernel/engine` | observe-resolve-admit-execute-reobserve-verify-record orchestration | `Resolve`, `Apply`, `Recover`; protocol/conformance tests | model, catalog, supervisor, protocol, ports | concrete surfaces and host logic | | `internal/plant` | Git/worktree identity, layout, configuration, runtime, durable-state and journal observation | one read-only composite observer; fact/fingerprint fixtures | model, protocol, ports, durable codecs | engine decisions, mutating effects, surfaces | -| `internal/effects` | transactions, local/external effect drivers and recovery | port implementations; fault-injection/postcondition tests | model, catalog, durable, protocol, ports, reducer, shared supervisor command classifier | surfaces and independent lifecycle reduction | +| `internal/effects` | transactions, local/external effect drivers, trusted StandardFlow native state adapters, and recovery | port implementations; exhaustive admitted-reducer coverage; fault-injection/postcondition tests | model, catalog, durable, protocol, ports, shared supervisor command classifier | surfaces and any decision graph independent of the compiled registry | | `internal/surfaces` | request decoding and decision/prescription rendering | CLI/hook/host/SDK/MCP adapter protocol; golden parity tests | model, protocol, engine facade interfaces | plant/effect implementations, lifecycle logic | -| top-level `boatstack` | product construction and stable V2 facade | dependency injection and public operations; end-to-end tests | engine, plant, effects, surfaces | independent durable state or alternate decisions | +| top-level `boatstack` | stable Kernel facade over one explicit ControlProgram | dependency injection and public operations; end-to-end tests | control, engine, plant, effects, surfaces | StandardFlow, distribution assembly, independent durable state or alternate decisions | +| `distribution` | Standard distribution composition and repository-scoped extension assembly | `StandardProgram` and `StandardProgramForRepository` | CoreSystem, StandardFlow, verified extensions, control | mutable global program state | | `cmd/boatstack-helper` | process startup and command parsing | parse -> facade request -> render; command tests | top-level facade/surfaces | direct plant writes or workflow decisions | | `sdk` | public Go aliases and client | schema-2 request/response and one facade delegate | top-level facade and public aliases | internal decision or effect implementations | | `analysis` | passive retrospective API | bounded deterministic report | `internal/retromine` | lifecycle decisions or managed writes | @@ -609,9 +832,9 @@ Capability analysis records three separate dispositions without modifying Locus: The executable registry now deterministically generates the checked [safety model](boatstack-v2-locus-safety.json) and -[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 61 +[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 62 runtime events. The liveness abstraction expands the declared phase predicates -to 363 inferred stable-phase edges over eight reachable phases; the safety +to 427 inferred stable-phase edges over eight reachable phases; the safety model adds one guarded counterfactual edge and `UNADMITTED_EFFECT` state. Repository and Go tests reject byte drift or an alphabet mismatch. @@ -619,33 +842,31 @@ Observed Locus runs over those generated artifacts produced: | Claim/operator | Postimplementation result | Disposition | | --- | --- | --- | -| `verification.safety-reachability` | `UNADMITTED_EFFECT` is unreachable; result `res-e90ff62f70169697c81e851f44fc4f423a0c26ca2d589f69b59b663200f4913e` | accepted finite-model result; advisory claim | -| `verification.guard-essentiality` | `exact-admission` is essential; removing it admits `DORMANT --publication.execute--> UNADMITTED_EFFECT`; result `res-b095833a4f708aedec42f62f3c10ab9634ae102e88f31522ca4aa97f1d6c2481` | accepted finite-model result; advisory claim | -| `control.nonblockingness` | all eight reachable stable phases are coreachable; no blocking states; result `res-f8ca50706925277190a6c0fba49a3ac44665d0a894203ea88c321434a7a4e549` | accepted finite-model result; advisory claim | -| event-completeness obligation | source inventory, sole reducer, generated-model parity, and repository-contract refusing tests were accepted as complete | closes the declared event alphabet obligation | - -Safety/guard derivation -`drv-bfc66012cb01e28ea3e1ef7407acf433d2340a166a14a4120a09a27fc77f1859` -and liveness derivation -`drv-a9534324fdc7e93fcfba3fd6919cbe9513a7dff9e97428f66f46b5489c39c9a4` -remain advisory because the applicable Locus operators are currently candidate -capabilities and because the model deliberately names real-system unknowns. -The verified liveness frontier returned `current_claim: advisory`, -`termination: blocked`, and no `EvidenceAction`; there is no honest additional -action to invent inside V2. - -This closes the finite stable-phase abstraction, not the whole live system. -The source-phase by target-phase expansion is conservative. Exact 17-facet -predicates, reducer branches, operating-system interruption behavior, and -external-provider truth are separately closed by executable unit, fault, -integration, historical, repository, and platform tests. +| `verification.trace-refinement` | the programmable ControlProgram protocol refines the preimplementation Kernel protocol with no distinguishing trace | accepted finite-model result; advisory claim | +| `verification.conservative-feature-extension` | the reference release-note extension is conservative across all six checks with no violation | accepted bounded-extension result; advisory claim | +| `verification.safety-reachability` | `UNADMITTED_EFFECT` is unreachable; result `res-6e8d6372eea4a7aaf2fcfa8ce5fcc91271b1f99a208f34006ba952d9825343a1` | accepted finite-model result; advisory claim | +| `verification.guard-essentiality` | `exact-admission` is essential; removing it admits `DORMANT --publication.execute--> UNADMITTED_EFFECT`; result `res-10a40fec10fa47086e1e5ef83ee3434cffb461e30166961e4cc18d08812f2caa` | accepted finite-model result; advisory claim | +| `control.nonblockingness` | all eight reachable stable phases are coreachable; no blocking states; result `res-a6a465d2e50cd830e0b95777f631ca9254d475757a6941c4d6922b2b7ff701f4` | accepted finite-model result; advisory claim | +| `practice.zca-projection` | both shipped slices cover all nine declared facets and all 14 bounded Go-module sites | accepted source-bound projection; no runtime authority granted | +| declared-slice completeness | every declared event-completeness obligation and the conservative-extension facet obligation were accepted as complete | closes the modeled source, writer, command, lifecycle, reducer, and generated-artifact inventories | + +The content-addressed Locus results and derivations are archived in Observatory. +They remain advisory because each model deliberately names facts outside its +bounded source slice rather than treating them as assumptions. + +This closes the finite stable-phase abstraction and the declared Go-module +event surface, not every possible host integration. The source-phase by +target-phase expansion is conservative. Exact 18-facet predicates, reducer +branches, arbitrary third-party extension executables, fresh coding-host +execution, operating-system interruption behavior, and external-provider truth +remain executable integration evidence rather than whole-host formal proof. ## 18. Complete V2 replacement work order This is one atomic branch and one final PR. The order controls build safety, not rollout compatibility. -1. Freeze this specification against base `c5b5e10...` and record historical +1. Freeze this specification against base `f7a5c9d1f2d15057f484371f348ee57311c0155e` and record historical fixtures. 2. Add slice 1 model, catalog, supervisor, protocol, ports, engine, generated graph, and formal/property tests. diff --git a/docs/architecture/boatstack-v2-locus-liveness.json b/docs/architecture/boatstack-v2-locus-liveness.json index 4ca0ab6..37e8ced 100644 --- a/docs/architecture/boatstack-v2-locus-liveness.json +++ b/docs/architecture/boatstack-v2-locus-liveness.json @@ -1,11 +1,11 @@ { "schema_version": 1, "id": "boatstack-v2-executable-catalog-liveness-v1", - "subject": "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", + "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { - "path": "boatstack/internal/kernel/catalog/default.go", - "note": "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization." + "path": "boatstack/control/control.go", + "note": "Compiler combines exact CoreSystem, PrimaryFlow, extension, contract, and ownership declarations into one immutable runtime registry." }, { "path": "docs/architecture/boatstack-v2-transition-catalog.md", @@ -20,11 +20,11 @@ "note": "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery." }, { - "path": "boatstack/internal/kernel/reducer/reducer.go", - "note": "Single executable reducer for every controllable semantic transition." + "path": "boatstack/internal/effects/state_reducer.go", + "note": "Admitted native effects reduce every controllable Standard distribution transition through one state adapter." }, { - "path": "boatstack/internal/kernel/catalog/completeness_test.go", + "path": "boatstack/flow/standard/completeness_test.go", "note": "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests." }, { @@ -36,7 +36,7 @@ "note": "Staged effect ordering, atomic resource application, rollback, and external settlement boundary." }, { - "path": "boatstack/internal/kernel/catalog/historical_test.go", + "path": "boatstack/flow/standard/historical_test.go", "note": "Historical incidents resolved through the executable runtime supervisor." } ], @@ -70,6 +70,12 @@ } ], "events": [ + { + "id": "catalog.reconcile", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "configuration.initialize", "controllable": true, @@ -438,6 +444,710 @@ } ], "transitions": [ + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "configuration.initialize", diff --git a/docs/architecture/boatstack-v2-locus-safety.json b/docs/architecture/boatstack-v2-locus-safety.json index 32d4c0f..17989d4 100644 --- a/docs/architecture/boatstack-v2-locus-safety.json +++ b/docs/architecture/boatstack-v2-locus-safety.json @@ -1,11 +1,11 @@ { "schema_version": 1, "id": "boatstack-v2-executable-catalog-safety-v1", - "subject": "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", + "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { - "path": "boatstack/internal/kernel/catalog/default.go", - "note": "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization." + "path": "boatstack/control/control.go", + "note": "Compiler combines exact CoreSystem, PrimaryFlow, extension, contract, and ownership declarations into one immutable runtime registry." }, { "path": "docs/architecture/boatstack-v2-transition-catalog.md", @@ -20,11 +20,11 @@ "note": "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery." }, { - "path": "boatstack/internal/kernel/reducer/reducer.go", - "note": "Single executable reducer for every controllable semantic transition." + "path": "boatstack/internal/effects/state_reducer.go", + "note": "Admitted native effects reduce every controllable Standard distribution transition through one state adapter." }, { - "path": "boatstack/internal/kernel/catalog/completeness_test.go", + "path": "boatstack/flow/standard/completeness_test.go", "note": "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests." }, { @@ -36,7 +36,7 @@ "note": "Staged effect ordering, atomic resource application, rollback, and external settlement boundary." }, { - "path": "boatstack/internal/kernel/catalog/historical_test.go", + "path": "boatstack/flow/standard/historical_test.go", "note": "Historical incidents resolved through the executable runtime supervisor." } ], @@ -73,6 +73,12 @@ } ], "events": [ + { + "id": "catalog.reconcile", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "configuration.initialize", "controllable": true, @@ -441,6 +447,710 @@ } ], "transitions": [ + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "RECOVERY", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "UNRESOLVED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "RECOVERY", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "UNRESOLVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "catalog.reconcile", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "configuration.initialize", diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index 505cd6c..6811882 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -1,72 +1,73 @@ - -# Boatstack V2 executable transition catalog + +# Boatstack compiled transition catalog -Registry size: **61** transitions. Event classes: authority 9; owned-local 30; owned-external 2; recovery 7; observed-external 13. +Registry size: **62** transitions. Event classes: authority 9; owned-local 31; owned-external 2; recovery 7; observed-external 13. -Controlling facets: `phase`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `goal`. +Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `goal`. -| Transition | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Recovery | -|---|---|---|---|---|---|---|---| -| `configuration.initialize` | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `configuration.reconcile` | -| `configuration.mutate` | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `configuration.reconcile` | -| `configuration.reconcile` | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `recovery.escalate` | -| `delivery.slice.advance` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `slice_id*`, `source_revision*` | `delivery-state` | `recovery.resume` | -| `engagement.begin` | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `recovery.resume` | -| `engagement.release` | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `recovery.resume` | -| `engagement.renew` | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `recovery.resume` | -| `evidence.approval.revoke` | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `recovery.resume` | -| `evidence.visual.attach` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `recovery.resume` | -| `external.branch-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` | -| `external.ci-completed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` | -| `external.configuration-drifted` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | `-` | -| `external.files-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` | -| `external.head-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` | -| `external.host-interrupted` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | `-` | -| `external.lease-expired` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | `-` | -| `external.pr-closed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | `-` | -| `external.pr-merged` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` | -| `external.pr-opened` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` | -| `external.pr-updated` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` | -| `external.provider-unavailable` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | `-` | -| `external.runtime-disappeared` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | `-` | -| `gate.build.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` | -| `gate.change.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` | -| `gate.journey.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` | -| `gate.review.record` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` | -| `gate.test.record` | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` | -| `goal.configure` | authority | OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `recovery.resume` | -| `installation.initialize` | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `runtime.reconcile` | -| `installation.update` | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `runtime.reconcile` | -| `invocation.rebind` | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `recovery.resume` | -| `plan.abandon` | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `recovery.resume` | -| `plan.activate` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `recovery.resume` | -| `plan.amend` | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `recovery.resume` | -| `plan.approve` | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `recovery.resume` | -| `plan.approve-amendment` | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `recovery.resume` | -| `plan.create` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `recovery.resume` | -| `plan.invalidate` | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `recovery.resume` | -| `plan.validate` | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `recovery.resume` | -| `publication.abandon` | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `recovery.resume` | -| `publication.correct` | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `publication.reconcile` | -| `publication.execute` | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `publication.reconcile` | -| `publication.observe` | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `publication_id*` | `publication-evidence` | `recovery.resume` | -| `publication.preview` | owned-local | ACTIVE | ACTIVE | repository-policy | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `recovery.resume` | -| `publication.reconcile` | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `publication_id*`, `transaction_id*` | `publication` | `recovery.escalate` | -| `recovery.escalate` | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` | -| `recovery.resume` | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` | -| `recovery.rollback` | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` | -| `repository.attach` | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `recovery.resume` | -| `repository.detach` | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `recovery.resume` | -| `runtime.hydrate` | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `runtime.reconcile` | -| `runtime.reconcile` | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `recovery.escalate` | -| `runtime.replace` | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `runtime.reconcile` | -| `workspace.abandon` | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `recovery.resume` | -| `workspace.activate` | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `recovery.resume` | -| `workspace.cleanup` | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `branch*` | `workspace` | `recovery.escalate` | -| `workspace.cut` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `workspace.reconcile` | -| `workspace.publish` | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `recovery.resume` | -| `workspace.reap` | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `recovery.escalate` | -| `workspace.reconcile` | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `recovery.escalate` | -| `workspace.sync` | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `recovery.resume` | +| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Verifier | Recovery | Cost | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `delivery.slice.advance` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `evidence.approval.revoke` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | +| `evidence.visual.attach` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | +| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | +| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | +| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | +| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `gate.build.record` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | +| `gate.change.record` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | +| `gate.journey.record` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | +| `gate.review.record` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | +| `gate.test.record` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | +| `goal.configure` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `plan.abandon` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | +| `plan.activate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | +| `plan.amend` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | +| `plan.approve` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `publication.observe` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | +| `publication.preview` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | +| `publication.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`ab8e145315d72ae5ab17f916775a40db0a8d12a0529ac3de8ed194d116bab9bc` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `workspace.abandon` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | +| `workspace.activate` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | +| `workspace.cleanup` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | +| `workspace.cut` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | primary-flow:`boatstack.standard@1.0.0`
`4ced330c8ca69159c2661e674c223f25077adb6eeec569e506d599d843d959de` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-v2-transition-catalog.mmd index 9fff145..ba07251 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.mmd +++ b/docs/architecture/boatstack-v2-transition-catalog.mmd @@ -1,4 +1,4 @@ -%% Generated from catalog.Default by surfaces.RenderCatalogMermaid. Do not edit. +%% Generated from the compiled ControlProgram registry by surfaces.RenderCatalogMermaid. Do not edit. flowchart TB subgraph authority["authority"] t00["engagement.begin
DORMANT | OBSERVED → OBSERVED | ACTIVE"] @@ -12,62 +12,63 @@ flowchart TB t08["publication.abandon
ACTIVE | FRONTIER → ABANDONED"] end subgraph owned_local["owned-local"] - t09["configuration.initialize
OBSERVED → OBSERVED | TERMINAL"] - t10["configuration.mutate
OBSERVED | ACTIVE | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t11["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"] - t12["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"] - t13["gate.build.record
ACTIVE → ACTIVE"] - t14["gate.change.record
ACTIVE → ACTIVE"] - t15["gate.journey.record
ACTIVE → ACTIVE"] - t16["gate.review.record
ACTIVE → ACTIVE | TERMINAL"] - t17["gate.test.record
ACTIVE → ACTIVE | TERMINAL"] - t18["installation.initialize
DORMANT | OBSERVED → OBSERVED"] - t19["installation.update
OBSERVED | ACTIVE → OBSERVED | ACTIVE | TERMINAL"] - t20["invocation.rebind
OBSERVED | UNRESOLVED → OBSERVED"] - t21["plan.activate
OBSERVED | ACTIVE → ACTIVE"] - t22["plan.amend
ACTIVE | FRONTIER → ACTIVE"] - t23["plan.create
OBSERVED | ACTIVE → ACTIVE"] - t24["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"] - t25["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"] - t26["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t27["publication.preview
ACTIVE → ACTIVE"] - t28["repository.attach
DORMANT | OBSERVED → OBSERVED"] - t29["repository.detach
DORMANT | OBSERVED | FRONTIER → DORMANT"] - t30["runtime.hydrate
OBSERVED | RECOVERY | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t31["runtime.replace
OBSERVED | RECOVERY → OBSERVED | TERMINAL"] - t32["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"] - t33["workspace.activate
OBSERVED | ACTIVE → ACTIVE"] - t34["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t35["workspace.cut
OBSERVED | ACTIVE → ACTIVE"] - t36["workspace.publish
ACTIVE → ACTIVE"] - t37["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t38["workspace.sync
ACTIVE → ACTIVE | FRONTIER"] + t09["catalog.reconcile
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED | TERMINAL | ABANDONED → DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED | TERMINAL | ABANDONED"] + t10["configuration.initialize
OBSERVED → OBSERVED | TERMINAL"] + t11["configuration.mutate
OBSERVED | ACTIVE | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t12["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"] + t13["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"] + t14["gate.build.record
ACTIVE → ACTIVE"] + t15["gate.change.record
ACTIVE → ACTIVE"] + t16["gate.journey.record
ACTIVE → ACTIVE"] + t17["gate.review.record
ACTIVE → ACTIVE | TERMINAL"] + t18["gate.test.record
ACTIVE → ACTIVE | TERMINAL"] + t19["installation.initialize
DORMANT | OBSERVED → OBSERVED"] + t20["installation.update
OBSERVED | ACTIVE → OBSERVED | ACTIVE | TERMINAL"] + t21["invocation.rebind
OBSERVED | UNRESOLVED → OBSERVED"] + t22["plan.activate
OBSERVED | ACTIVE → ACTIVE"] + t23["plan.amend
ACTIVE | FRONTIER → ACTIVE"] + t24["plan.create
OBSERVED | ACTIVE → ACTIVE"] + t25["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"] + t26["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"] + t27["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] + t28["publication.preview
ACTIVE → ACTIVE"] + t29["repository.attach
DORMANT | OBSERVED → OBSERVED"] + t30["repository.detach
DORMANT | OBSERVED | FRONTIER → DORMANT"] + t31["runtime.hydrate
OBSERVED | RECOVERY | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t32["runtime.replace
OBSERVED | RECOVERY → OBSERVED | TERMINAL"] + t33["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"] + t34["workspace.activate
OBSERVED | ACTIVE → ACTIVE"] + t35["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] + t36["workspace.cut
OBSERVED | ACTIVE → ACTIVE"] + t37["workspace.publish
ACTIVE → ACTIVE"] + t38["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] + t39["workspace.sync
ACTIVE → ACTIVE | FRONTIER"] end subgraph owned_external["owned-external"] - t39["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"] - t40["publication.execute
ACTIVE → ACTIVE | RECOVERY"] + t40["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"] + t41["publication.execute
ACTIVE → ACTIVE | RECOVERY"] end subgraph recovery["recovery"] - t41["configuration.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] - t42["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t43["recovery.escalate
RECOVERY | UNRESOLVED → FRONTIER"] - t44["recovery.resume
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] - t45["recovery.rollback
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] - t46["runtime.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] - t47["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + t42["configuration.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] + t43["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] + t44["recovery.escalate
RECOVERY | UNRESOLVED → FRONTIER"] + t45["recovery.resume
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + t46["recovery.rollback
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + t47["runtime.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] + t48["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] end subgraph observed_external["observed-external"] - t48["external.branch-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t49["external.ci-completed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t50["external.configuration-drifted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | UNRESOLVED"] - t51["external.files-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t52["external.head-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t53["external.host-interrupted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → RECOVERY"] - t54["external.lease-expired
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → DORMANT | FRONTIER"] - t55["external.pr-closed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | FRONTIER"] - t56["external.pr-merged
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t57["external.pr-opened
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t58["external.pr-updated
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t59["external.provider-unavailable
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → UNRESOLVED | RECOVERY"] - t60["external.runtime-disappeared
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | RECOVERY"] + t49["external.branch-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] + t50["external.ci-completed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t51["external.configuration-drifted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | UNRESOLVED"] + t52["external.files-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] + t53["external.head-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] + t54["external.host-interrupted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → RECOVERY"] + t55["external.lease-expired
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → DORMANT | FRONTIER"] + t56["external.pr-closed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | FRONTIER"] + t57["external.pr-merged
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t58["external.pr-opened
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t59["external.pr-updated
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] + t60["external.provider-unavailable
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → UNRESOLVED | RECOVERY"] + t61["external.runtime-disappeared
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | RECOVERY"] end diff --git a/docs/configuration.md b/docs/configuration.md index 0ef3d79..fce244b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,7 +23,7 @@ hosts, trailing JSON, and missing required fields fail closed. "visual_evidence": "optional", "external_effect_authority": "human-or-autonomy-plus-provider" }, - "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"] + "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp", "sdk"] } ``` @@ -49,6 +49,37 @@ refuses attachment. A host omitted from `hosts` cannot request managed transitions. If the configured default branch cannot be inspected, the high-risk derivation fails closed whenever that policy is active. +## Optional additive extensions + +Repository configuration may enable checksum-bound subprocess extensions, but +it cannot select or replace the trusted primary flow: + +```json +{ + "extensions": [ + { + "id": "example.security", + "version": "1.0.0", + "executable": "/absolute/symlink-free/path/security-extension", + "sha256": "<64 lowercase hexadecimal characters>", + "settings": {"profile": "strict"}, + "deadline_millis": 5000, + "stdout_bytes": 1048576, + "stderr_bytes": 65536 + } + ] +} +``` + +The executable is invoked directly without a shell, receives only the bounded +versioned JSON protocol and fixed locale variables, and is re-hashed before +every invocation. Crossing either output bound cancels the subprocess +immediately and fails the operation closed. It is a trusted executable +boundary, not an OS sandbox. +Changing its set, version, executable bytes, settings, or limits changes the +ControlProgram fingerprint and therefore fails closed as program drift for an +active flow. + `project.commands` names the repository's canonical product checks. Build and test gate transitions execute those exact repository-owned commands inside the effect boundary and install evidence only after a zero exit status. The command diff --git a/docs/generated-files.md b/docs/generated-files.md index b8be4e9..ba08f98 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -32,18 +32,21 @@ falls back to it. ## Generated architecture evidence -The executable transition registry deterministically generates four checked +The executable transition registry deterministically generates five checked architecture artifacts: | Artifact | Regeneration command | |---|---| | `docs/architecture/boatstack-v2-transition-catalog.md` | `boatstack-helper catalog --format markdown` | | `docs/architecture/boatstack-v2-transition-catalog.mmd` | `boatstack-helper catalog --format mermaid` | +| `docs/architecture/boatstack-standard-flow.mmd` | `boatstack-helper catalog --format standard-flow-mermaid` | | `docs/architecture/boatstack-v2-locus-safety.json` | `boatstack-helper catalog --format locus-safety` | | `docs/architecture/boatstack-v2-locus-liveness.json` | `boatstack-helper catalog --format locus-liveness` | Repository and Go tests compare every checked byte with a fresh render and -require both Locus alphabets to equal all 61 executable catalog transitions. +require both Locus alphabets to equal all 62 executable catalog transitions. +The StandardFlow graph contains exactly the 30 transitions whose compiled +origin is the primary flow. The Locus phase graph is intentionally conservative: it expands each declared source phase against each declared target phase. Facet predicates and reducer branches remain executable-test obligations. diff --git a/docs/public-claims.json b/docs/public-claims.json index 8b84c5b..4bb608c 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -13,11 +13,11 @@ "readable_evidence": "architecture/boatstack-v2-kernel.md#14-package-and-dependency-architecture", "implementation": [ "../boatstack/internal/kernel/engine/engine.go", - "../boatstack/internal/kernel/catalog/default.go", - "../boatstack/v2_kernel.go" + "../boatstack/control/control.go", + "../boatstack/kernel.go" ], "verification": [ - "../boatstack/internal/kernel/catalog/completeness_test.go", + "../boatstack/flow/standard/completeness_test.go", "../boatstack/internal/kernel/engine/engine_test.go" ], "last_verified_version": "v2.0.0" @@ -80,7 +80,7 @@ "../boatstack/internal/surfaces/guard.go" ], "verification": [ - "../boatstack/internal/kernel/supervisor/supervisor_test.go", + "../boatstack/flow/standard/supervisor_parity_test.go", "../boatstack/internal/surfaces/render_test.go" ], "last_verified_version": "v2.0.0" @@ -110,7 +110,7 @@ "readable_evidence": "architecture/boatstack-v2-kernel.md#16-process-telemetry-contract", "implementation": [ "../boatstack/internal/effects/receipts.go", - "../boatstack/v2_kernel.go" + "../boatstack/kernel.go" ], "verification": [ "../boatstack/internal/effects/integration_test.go" @@ -125,31 +125,33 @@ "implementation": [ "../boatstack/internal/kernel/model/state.go", "../boatstack/internal/kernel/supervisor/supervisor.go", - "../boatstack/internal/kernel/reducer/reducer.go" + "../boatstack/internal/effects/state_reducer.go" ], "verification": [ - "../boatstack/internal/kernel/supervisor/supervisor_test.go", + "../boatstack/flow/standard/supervisor_parity_test.go", "../boatstack/internal/kernel/protocol/policy_test.go", "../boatstack/internal/plant/observer_test.go", - "../boatstack/internal/kernel/reducer/reducer_test.go", + "../boatstack/internal/effects/state_reducer_test.go", "../boatstack/internal/effects/integration_test.go" ], "last_verified_version": "v2.0.0" }, { "id": "formal-live-system-closure", - "public_claim": "The generated 61-event stable-phase abstraction satisfies the checked safety and liveness properties; executable tests separately bind catalog completeness, facets, reducer branches, operating-system behavior, and provider outcomes.", + "public_claim": "The generated 62-event stable-phase abstraction satisfies the checked safety and liveness properties; executable tests separately bind catalog completeness, facets, reducer branches, operating-system behavior, and provider outcomes.", "status": "advisory", "readable_evidence": "architecture/boatstack-v2-kernel.md#17-test-and-formal-property-strategy", "implementation": [ "architecture/boatstack-v2-transition-catalog.md", "architecture/boatstack-v2-locus-safety.json", "architecture/boatstack-v2-locus-liveness.json", - "../boatstack/internal/kernel/catalog/default.go" + "../boatstack/control/control.go", + "../boatstack/core/transitions.json", + "../boatstack/flow/standard/transitions.json" ], "verification": [ - "../boatstack/internal/kernel/catalog/completeness_test.go", - "../boatstack/internal/kernel/catalog/historical_test.go", + "../boatstack/flow/standard/completeness_test.go", + "../boatstack/flow/standard/historical_test.go", "../boatstack/internal/surfaces/render_test.go", "../boatstack/internal/kernel/engine/engine_test.go", "../boatstack/internal/effects/integration_test.go" diff --git a/project.example.json b/project.example.json index 7bc7eb0..4e947f6 100644 --- a/project.example.json +++ b/project.example.json @@ -33,6 +33,7 @@ "codex", "claude", "gemini", - "mcp" + "mcp", + "sdk" ] } diff --git a/release-notes/2026-08-11-programmable-control-program.md b/release-notes/2026-08-11-programmable-control-program.md new file mode 100644 index 0000000..5cb41ca --- /dev/null +++ b/release-notes/2026-08-11-programmable-control-program.md @@ -0,0 +1,3 @@ +### Separate Kernel mechanism from delivery policy + +Boatstack now compiles one immutable CoreSystem, one explicit primary delivery flow, and optional conservative extensions into a fingerprinted ControlProgram before the Kernel resolves or applies any transition. The default distribution retains StandardFlow behavior, while public SDK contracts can supply another trusted flow and checksum-verified subprocess extensions without modifying Kernel mechanism code. From 79eebafb300643f2cee2be32f877c86f6d534bd9 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 16:22:59 +0100 Subject: [PATCH 2/2] fix: harden control program boundaries --- boatstack/control/control.go | 55 +++ boatstack/control/control_test.go | 49 +- boatstack/control/extension.go | 3 + boatstack/control/flow_runtime.go | 3 + boatstack/control/runtime_contract_test.go | 5 + boatstack/distribution/standard.go | 33 +- boatstack/distribution/standard_test.go | 11 +- boatstack/extension/subprocess/subprocess.go | 99 ++-- .../extension/subprocess/subprocess_test.go | 82 +++- boatstack/go.mod | 4 +- boatstack/go.sum | 10 +- boatstack/internal/effects/executable.go | 24 + boatstack/internal/kernel/engine/engine.go | 4 + .../internal/kernel/engine/engine_test.go | 41 +- boatstack/internal/kernel/model/state.go | 7 + boatstack/internal/kernel/protocol/config.go | 46 +- .../internal/kernel/protocol/config_test.go | 1 + boatstack/internal/surfaces/catalog_render.go | 41 +- boatstack/internal/surfaces/render_test.go | 5 +- boatstack/program_observer.go | 23 +- boatstack/program_observer_test.go | 71 ++- docs/architecture/boatstack-standard-flow.mmd | 180 +++++-- .../boatstack-v2-transition-catalog.mmd | 447 +++++++++++++++--- docs/configuration.md | 16 +- ...2026-08-11-programmable-control-program.md | 2 +- 25 files changed, 1075 insertions(+), 187 deletions(-) create mode 100644 boatstack/internal/effects/executable.go diff --git a/boatstack/control/control.go b/boatstack/control/control.go index 6000f27..0411137 100644 --- a/boatstack/control/control.go +++ b/boatstack/control/control.go @@ -15,6 +15,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/santhosh-tekuri/jsonschema/v6" ) type Transition = catalog.Transition @@ -525,6 +526,9 @@ func validateFlow(manifest PrimaryFlowManifest) error { manifest.PrivacyClassification == "" || manifest.TelemetryClassification == "" { return fmt.Errorf("PrimaryFlow requires semantic id, version, configuration schema, goals, and transitions") } + if err := validateDeclaredSchema(manifest.ConfigurationSchema, manifest.Settings, "PrimaryFlow "+manifest.ID+" configuration"); err != nil { + return err + } supported := map[GoalKind]bool{} for _, goal := range manifest.SupportedGoals { if !goal.Valid() || supported[goal] { @@ -630,6 +634,9 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo if !validJSONObject(manifest.SettingsSchema) { return fmt.Errorf("extension %q requires a JSON-object settings schema", manifest.ID) } + if err := validateDeclaredSchema(manifest.SettingsSchema, manifest.Settings, "extension "+manifest.ID+" settings"); err != nil { + return err + } if manifest.ExecutableSHA256 != "" { if len(manifest.ExecutableSHA256) != 64 { return fmt.Errorf("extension %q executable SHA-256 is invalid", manifest.ID) @@ -740,6 +747,54 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo return nil } +type rejectingSchemaLoader struct{} + +func (rejectingSchemaLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("external JSON Schema reference %q is not permitted", url) +} + +func validateDeclaredSchema(schemaRaw, instanceRaw json.RawMessage, label string) error { + decode := func(raw json.RawMessage, fallback string) (any, error) { + if len(raw) == 0 { + raw = json.RawMessage(fallback) + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, fmt.Errorf("contains trailing JSON") + } + return value, nil + } + schemaValue, err := decode(schemaRaw, `{}`) + if err != nil { + return fmt.Errorf("%s schema is invalid JSON: %w", label, err) + } + instance, err := decode(instanceRaw, `{}`) + if err != nil { + return fmt.Errorf("%s value is invalid JSON: %w", label, err) + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + compiler.UseLoader(rejectingSchemaLoader{}) + const location = "urn:boatstack:component-schema" + if err := compiler.AddResource(location, schemaValue); err != nil { + return fmt.Errorf("%s schema could not be loaded: %w", label, err) + } + schema, err := compiler.Compile(location) + if err != nil { + return fmt.Errorf("%s schema is invalid: %w", label, err) + } + if err := schema.Validate(instance); err != nil { + return fmt.Errorf("%s does not satisfy its declared schema: %w", label, err) + } + return nil +} + func conditionImplies(target, obligation FacetCondition) bool { if target.Facet != obligation.Facet { return false diff --git a/boatstack/control/control_test.go b/boatstack/control/control_test.go index acade27..3ce2af1 100644 --- a/boatstack/control/control_test.go +++ b/boatstack/control/control_test.go @@ -14,6 +14,12 @@ import ( "github.com/operatorstack/boatstack/boatstack/flow/standard" ) +type staticFlowDefinition struct{ manifest control.PrimaryFlowManifest } + +func (f staticFlowDefinition) FlowManifest(context.Context) (control.PrimaryFlowManifest, error) { + return f.manifest, nil +} + func TestStandardProgramHasExplicitStableComposition(t *testing.T) { // control-law: compile-produces-one-immutable-registry-with-explicit-origins one, err := distribution.StandardProgram(context.Background()) @@ -63,9 +69,10 @@ func TestProgramFingerprintBindsCompositionAndPolicyInputs(t *testing.T) { variants["executable"] = executable settings := cloneManifest(t, manifest) settings.Settings = json.RawMessage(`{"required":true}`) + settings.SettingsSchema = json.RawMessage(`{"type":"object","properties":{"required":{"type":"boolean"}},"additionalProperties":false}`) variants["extension-settings"] = settings settingsSchema := cloneManifest(t, manifest) - settingsSchema.SettingsSchema = json.RawMessage(`{"type":"object","required":["mode"]}`) + settingsSchema.SettingsSchema = json.RawMessage(`{"type":"object","description":"alternate valid schema","additionalProperties":false}`) variants["extension-settings-schema"] = settingsSchema resource := cloneManifest(t, manifest) resource.OwnedResources = []string{"boatstack.release-note.alternate-evidence"} @@ -122,6 +129,46 @@ func TestProgramFingerprintBindsCompositionAndPolicyInputs(t *testing.T) { } } +func TestCompileEnforcesDeclaredComponentSchemas(t *testing.T) { + // control-law: component-settings-cannot-cross-runtime-boundaries-unvalidated + reference := releasenote.Definition() + manifest, err := reference.ExtensionManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name, schema, settings string + }{ + {"missing-required", `{"type":"object","required":["mode"]}`, `{}`}, + {"wrong-type", `{"type":"object","properties":{"mode":{"type":"string"}}}`, `{"mode":1}`}, + {"additional-property", `{"type":"object","additionalProperties":false}`, `{"mode":"strict"}`}, + {"invalid-schema", `{"type":"object","required":"mode"}`, `{}`}, + } + for _, test := range cases { + t.Run("extension-"+test.name, func(t *testing.T) { + candidate := cloneManifest(t, manifest) + candidate.SettingsSchema = json.RawMessage(test.schema) + candidate.Settings = json.RawMessage(test.settings) + definition, err := extension.NewInProcess(candidate, reference.Runtime()) + if err != nil { + t.Fatal(err) + } + if _, err := distribution.StandardProgram(context.Background(), definition); err == nil { + t.Fatal("invalid extension settings or schema compiled") + } + }) + } + flow, err := standard.Definition().FlowManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + flow.ConfigurationSchema = json.RawMessage(`{"type":"object","required":["mode"],"additionalProperties":false}`) + flow.Settings = json.RawMessage(`{}`) + if _, err := control.Compile(context.Background(), control.CompileRequest{KernelVersion: "kernel", Core: core.System(), Flow: staticFlowDefinition{manifest: flow}}); err == nil { + t.Fatal("PrimaryFlow settings that violate ConfigurationSchema compiled") + } +} + func TestComponentsMustDeclareTheirOwnSelectionSemantics(t *testing.T) { // control-law: generic-compiler-never-infers-flow-order-from-transition-ids flow, err := standard.Definition().FlowManifest(context.Background()) diff --git a/boatstack/control/extension.go b/boatstack/control/extension.go index cd8d9db..de1cb4a 100644 --- a/boatstack/control/extension.go +++ b/boatstack/control/extension.go @@ -75,6 +75,9 @@ func ValidateExtensionOperationResponse(operation ExtensionOperation, response E } hasPayload := response.Manifest != nil || len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil if response.Error != "" { + if len(response.ErrorClass) > 128 || len(response.Error) > 4096 { + return fmt.Errorf("extension error classification or message exceeds its bound") + } if hasPayload { return fmt.Errorf("extension error response contains an operation payload") } diff --git a/boatstack/control/flow_runtime.go b/boatstack/control/flow_runtime.go index 6e893c8..329526a 100644 --- a/boatstack/control/flow_runtime.go +++ b/boatstack/control/flow_runtime.go @@ -64,6 +64,9 @@ func ValidateFlowOperationResponse(operation FlowOperation, response FlowRespons } hasPayload := len(response.Facts) != 0 || len(response.Writes) != 0 || len(response.ExternalResult) != 0 || response.Verified != nil if response.Error != "" { + if len(response.ErrorClass) > 128 || len(response.Error) > 4096 { + return fmt.Errorf("primary-flow error classification or message exceeds its bound") + } if hasPayload { return fmt.Errorf("primary-flow error response contains an operation payload") } diff --git a/boatstack/control/runtime_contract_test.go b/boatstack/control/runtime_contract_test.go index 5bed616..e8fcc58 100644 --- a/boatstack/control/runtime_contract_test.go +++ b/boatstack/control/runtime_contract_test.go @@ -2,6 +2,7 @@ package control import ( "encoding/json" + "strings" "testing" ) @@ -32,6 +33,8 @@ func TestFlowOperationResponsesAreAnExactTaggedUnion(t *testing.T) { {"recover-external", FlowRecoverOperation, FlowResponse{ExternalResult: json.RawMessage(`{}`)}}, {"partial-error", FlowObserveOperation, FlowResponse{ErrorClass: "temporary"}}, {"error-payload", FlowObserveOperation, FlowResponse{ErrorClass: "temporary", Error: "failed", Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"error-class-too-long", FlowObserveOperation, FlowResponse{ErrorClass: strings.Repeat("x", 129), Error: "failed"}}, + {"error-message-too-long", FlowObserveOperation, FlowResponse{ErrorClass: "temporary", Error: strings.Repeat("x", 4097)}}, {"unknown-operation", FlowOperation("unknown"), FlowResponse{}}, } for _, test := range invalid { @@ -75,6 +78,8 @@ func TestExtensionOperationResponsesAreAnExactTaggedUnion(t *testing.T) { {"recover-external", ExtensionRecoverOperation, ExtensionResponse{ExternalResult: json.RawMessage(`{}`)}}, {"partial-error", ExtensionObserveOperation, ExtensionResponse{Error: "failed"}}, {"error-payload", ExtensionObserveOperation, ExtensionResponse{ErrorClass: "temporary", Error: "failed", Facts: []ExtensionFact{{ID: "wrong"}}}}, + {"error-class-too-long", ExtensionObserveOperation, ExtensionResponse{ErrorClass: strings.Repeat("x", 129), Error: "failed"}}, + {"error-message-too-long", ExtensionObserveOperation, ExtensionResponse{ErrorClass: "temporary", Error: strings.Repeat("x", 4097)}}, {"unknown-operation", ExtensionOperation("unknown"), ExtensionResponse{}}, } for _, test := range invalid { diff --git a/boatstack/distribution/standard.go b/boatstack/distribution/standard.go index 3ff824f..6b09a5a 100644 --- a/boatstack/distribution/standard.go +++ b/boatstack/distribution/standard.go @@ -117,8 +117,8 @@ func ConfiguredExtensions(ctx context.Context, request RepositoryProgramRequest) for _, declaration := range configuration.Extensions { extension, extensionErr := subprocess.New(subprocess.Config{ ID: declaration.ID, Version: declaration.Version, Executable: declaration.Executable, SHA256: declaration.SHA256, - Settings: declaration.Settings, - Limits: control.SubprocessLimits{Deadline: time.Duration(declaration.DeadlineMillis) * time.Millisecond, StdoutBytes: declaration.StdoutBytes, StderrBytes: declaration.StderrBytes}, + Manifest: declaration.Manifest, Settings: declaration.Settings, + Limits: control.SubprocessLimits{Deadline: time.Duration(declaration.DeadlineMillis) * time.Millisecond, StdoutBytes: declaration.StdoutBytes, StderrBytes: declaration.StderrBytes}, }) if extensionErr != nil { return nil, nil, fmt.Errorf("verify configured subprocess extension %q: %w", declaration.ID, extensionErr) @@ -135,18 +135,25 @@ func ConfiguredExtensions(ctx context.Context, request RepositoryProgramRequest) func canonicalProgramSettings(values []protocol.SubprocessExtensionSettings) (programSettings, error) { extensions := append([]protocol.SubprocessExtensionSettings(nil), values...) for index := range extensions { - if len(extensions[index].Settings) == 0 { - continue + items := []struct { + name string + value *json.RawMessage + }{{"manifest", &extensions[index].Manifest}, {"settings", &extensions[index].Settings}} + for _, item := range items { + name, value := item.name, item.value + if len(*value) == 0 { + continue + } + var decoded any + if err := json.Unmarshal(*value, &decoded); err != nil { + return programSettings{}, fmt.Errorf("canonicalize extension %q %s: %w", extensions[index].ID, name, err) + } + canonical, err := json.Marshal(decoded) + if err != nil { + return programSettings{}, fmt.Errorf("canonicalize extension %q %s: %w", extensions[index].ID, name, err) + } + *value = canonical } - var decoded any - if err := json.Unmarshal(extensions[index].Settings, &decoded); err != nil { - return programSettings{}, fmt.Errorf("canonicalize extension %q settings: %w", extensions[index].ID, err) - } - canonical, err := json.Marshal(decoded) - if err != nil { - return programSettings{}, fmt.Errorf("canonicalize extension %q settings: %w", extensions[index].ID, err) - } - extensions[index].Settings = canonical } sort.Slice(extensions, func(i, j int) bool { return extensions[i].ID < extensions[j].ID }) return programSettings{Extensions: extensions}, nil diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go index 8e12159..bb11398 100644 --- a/boatstack/distribution/standard_test.go +++ b/boatstack/distribution/standard_test.go @@ -13,6 +13,7 @@ import ( "sync" "testing" + "github.com/operatorstack/boatstack/boatstack/control" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) @@ -43,8 +44,16 @@ func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { t.Fatal(err) } digest := sha256.Sum256(content) + manifest, err := json.Marshal(control.ExtensionManifest{ + ID: id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, + SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{id + ".present"}, + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }) + if err != nil { + t.Fatal(err) + } return protocol.SubprocessExtensionSettings{ - ID: id, Version: "1.0.0", Executable: resolved, SHA256: hex.EncodeToString(digest[:]), DeadlineMillis: 30_000, + ID: id, Version: "1.0.0", Executable: resolved, SHA256: hex.EncodeToString(digest[:]), Manifest: manifest, DeadlineMillis: 30_000, } } x, y := extensions("fixture.echo"), extensions("fixture.second") diff --git a/boatstack/extension/subprocess/subprocess.go b/boatstack/extension/subprocess/subprocess.go index 6c6a8a0..46fa0ca 100644 --- a/boatstack/extension/subprocess/subprocess.go +++ b/boatstack/extension/subprocess/subprocess.go @@ -18,6 +18,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/effects" ) const maxRequestBytes = 1 << 20 @@ -27,17 +28,20 @@ type Config struct { Version string Executable string SHA256 string + Manifest json.RawMessage Settings json.RawMessage Limits control.SubprocessLimits } type Extension struct { - config Config + config Config + manifestRaw json.RawMessage + beforeStart func() } func New(config Config) (*Extension, error) { - if config.ID == "" || config.Version == "" || !filepath.IsAbs(config.Executable) || len(config.SHA256) != 64 { - return nil, fmt.Errorf("subprocess extension requires id, version, absolute executable path, and SHA-256") + if config.ID == "" || config.Version == "" || !filepath.IsAbs(config.Executable) || len(config.SHA256) != 64 || len(config.Manifest) == 0 { + return nil, fmt.Errorf("subprocess extension requires id, version, absolute executable path, SHA-256, and declarative manifest") } clean := filepath.Clean(config.Executable) resolved, err := filepath.EvalSymlinks(clean) @@ -62,8 +66,24 @@ func New(config Config) (*Extension, error) { config.Limits.StderrBytes < 1 || config.Limits.StderrBytes > 1<<20 { return nil, fmt.Errorf("subprocess extension limits are outside the supported bounds") } - extension := &Extension{config: config} - if err := extension.verifyExecutable(); err != nil { + manifest, err := decodeManifest(config.Manifest) + if err != nil { + return nil, fmt.Errorf("decode declarative subprocess extension manifest: %w", err) + } + if manifest.ID != config.ID || manifest.Version != config.Version || manifest.ProtocolVersion != control.ExtensionProtocolVersion { + return nil, fmt.Errorf("subprocess extension manifest identity mismatch") + } + if manifest.ExecutableSHA256 != "" && manifest.ExecutableSHA256 != config.SHA256 { + return nil, fmt.Errorf("subprocess extension manifest executable fingerprint mismatch") + } + manifest.ExecutableSHA256 = config.SHA256 + manifest.Settings = append(json.RawMessage(nil), config.Settings...) + manifestRaw, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("encode declarative subprocess extension manifest: %w", err) + } + extension := &Extension{config: config, manifestRaw: manifestRaw} + if _, err := extension.verifiedExecutable(); err != nil { return nil, err } return extension, nil @@ -71,28 +91,8 @@ func New(config Config) (*Extension, error) { func (e *Extension) Runtime() control.ExtensionRuntime { return e } -func (e *Extension) ExtensionManifest(ctx context.Context) (control.ExtensionManifest, error) { - correlation := "manifest-" + e.config.SHA256[:16] - response, err := e.Invoke(ctx, control.ExtensionRequest{ - ProtocolVersion: control.ExtensionProtocolVersion, Operation: control.ExtensionManifestOperation, - ExtensionID: e.config.ID, ExtensionVersion: e.config.Version, CorrelationID: correlation, - }) - if err != nil { - return control.ExtensionManifest{}, err - } - if response.Manifest == nil { - return control.ExtensionManifest{}, fmt.Errorf("subprocess extension returned no manifest") - } - manifest := *response.Manifest - if manifest.ID != e.config.ID || manifest.Version != e.config.Version || manifest.ProtocolVersion != control.ExtensionProtocolVersion { - return control.ExtensionManifest{}, fmt.Errorf("subprocess extension manifest identity mismatch") - } - if manifest.ExecutableSHA256 != "" && manifest.ExecutableSHA256 != e.config.SHA256 { - return control.ExtensionManifest{}, fmt.Errorf("subprocess extension manifest executable fingerprint mismatch") - } - manifest.ExecutableSHA256 = e.config.SHA256 - manifest.Settings = append(json.RawMessage(nil), e.config.Settings...) - return manifest, nil +func (e *Extension) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { + return decodeManifest(e.manifestRaw) } func (e *Extension) Invoke(ctx context.Context, request control.ExtensionRequest) (control.ExtensionResponse, error) { @@ -105,9 +105,18 @@ func (e *Extension) Invoke(ctx context.Context, request control.ExtensionRequest default: return control.ExtensionResponse{}, fmt.Errorf("unsupported subprocess extension operation %q", request.Operation) } - if err := e.verifyExecutable(); err != nil { + executable, err := e.verifiedExecutable() + if err != nil { + return control.ExtensionResponse{}, err + } + stagedPath, cleanup, err := effects.StageVerifiedExecutable(e.config.Executable, executable) + if err != nil { return control.ExtensionResponse{}, err } + defer cleanup() + if e.beforeStart != nil { + e.beforeStart() + } raw, err := json.Marshal(request) if err != nil { return control.ExtensionResponse{}, err @@ -117,7 +126,7 @@ func (e *Extension) Invoke(ctx context.Context, request control.ExtensionRequest } deadlineContext, cancel := context.WithTimeout(ctx, e.config.Limits.Deadline) defer cancel() - command := exec.CommandContext(deadlineContext, e.config.Executable) + command := exec.CommandContext(deadlineContext, stagedPath) command.Env = []string{"LANG=C", "LC_ALL=C"} command.Stdin = bytes.NewReader(raw) stdout := &boundedBuffer{limit: e.config.Limits.StdoutBytes, cancel: cancel} @@ -155,33 +164,47 @@ func (e *Extension) Invoke(ctx context.Context, request control.ExtensionRequest return response, nil } -func (e *Extension) verifyExecutable() error { +func (e *Extension) verifiedExecutable() ([]byte, error) { info, err := os.Lstat(e.config.Executable) if err != nil { - return fmt.Errorf("stat subprocess extension executable: %w", err) + return nil, fmt.Errorf("stat subprocess extension executable: %w", err) } if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("subprocess extension executable path drifted to a symlink") + return nil, fmt.Errorf("subprocess extension executable path drifted to a symlink") } if !info.Mode().IsRegular() { - return fmt.Errorf("subprocess extension executable is not a regular file") + return nil, fmt.Errorf("subprocess extension executable is not a regular file") } resolved, err := filepath.EvalSymlinks(e.config.Executable) if err != nil { - return fmt.Errorf("resolve subprocess extension executable: %w", err) + return nil, fmt.Errorf("resolve subprocess extension executable: %w", err) } if resolved != e.config.Executable { - return fmt.Errorf("subprocess extension executable path must remain exact and symlink-free") + return nil, fmt.Errorf("subprocess extension executable path must remain exact and symlink-free") } raw, err := os.ReadFile(e.config.Executable) if err != nil { - return fmt.Errorf("read subprocess extension executable: %w", err) + return nil, fmt.Errorf("read subprocess extension executable: %w", err) } digest := sha256.Sum256(raw) if hex.EncodeToString(digest[:]) != e.config.SHA256 { - return fmt.Errorf("subprocess extension executable fingerprint drifted") + return nil, fmt.Errorf("subprocess extension executable fingerprint drifted") + } + return raw, nil +} + +func decodeManifest(raw json.RawMessage) (control.ExtensionManifest, error) { + var manifest control.ExtensionManifest + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return control.ExtensionManifest{}, err } - return nil + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return control.ExtensionManifest{}, fmt.Errorf("manifest contains trailing JSON") + } + return manifest, nil } type boundedBuffer struct { diff --git a/boatstack/extension/subprocess/subprocess_test.go b/boatstack/extension/subprocess/subprocess_test.go index 1728673..830f491 100644 --- a/boatstack/extension/subprocess/subprocess_test.go +++ b/boatstack/extension/subprocess/subprocess_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "os" "path/filepath" "runtime" @@ -12,8 +13,23 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/flow/standard" ) +func fixtureManifest(t *testing.T, id string) json.RawMessage { + t.Helper() + raw, err := json.Marshal(control.ExtensionManifest{ + ID: id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, + SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{id + ".present"}, + PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", + }) + if err != nil { + t.Fatal(err) + } + return raw +} + func fixtureExtension(t *testing.T) *Extension { t.Helper() if runtime.GOOS == "windows" { @@ -33,7 +49,8 @@ func fixtureExtension(t *testing.T) *Extension { digest := sha256.Sum256(raw) extension, err := New(Config{ ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), - Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, + Manifest: fixtureManifest(t, "fixture.echo"), + Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, }) if err != nil { t.Fatal(err) @@ -58,7 +75,8 @@ func pythonFixture(t *testing.T, mutate func(string) string) *Extension { digest := sha256.Sum256(content) extension, err := New(Config{ ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), - Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, + Manifest: fixtureManifest(t, "fixture.echo"), + Limits: control.SubprocessLimits{Deadline: 30 * time.Second}, }) if err != nil { t.Fatal(err) @@ -107,7 +125,7 @@ func TestExecutableDriftFailsBeforeInvocation(t *testing.T) { t.Fatal(err) } digest := sha256.Sum256(source) - extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:])}) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Manifest: fixtureManifest(t, "fixture.echo")}) if err != nil { t.Fatal(err) } @@ -120,6 +138,60 @@ func TestExecutableDriftFailsBeforeInvocation(t *testing.T) { } } +func TestInvocationExecutesTheExactVerifiedBytesAfterPathReplacement(t *testing.T) { + // control-law: executable-identity-is-bound-to-the-bytes-actually-executed + if runtime.GOOS == "windows" { + t.Skip("POSIX atomic replacement semantics are exercised here") + } + extension := pythonFixture(t, func(source string) string { return source }) + replacement := []byte("#!/bin/sh\nexit 42\n") + extension.beforeStart = func() { + temporary := extension.config.Executable + ".replacement" + if err := os.WriteFile(temporary, replacement, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(temporary, extension.config.Executable); err != nil { + t.Fatal(err) + } + } + response, err := extension.Invoke(context.Background(), control.ExtensionRequest{ + ProtocolVersion: 1, Operation: control.ExtensionObserveOperation, ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: "atomic-replacement", + }) + if err != nil { + t.Fatal(err) + } + if len(response.Facts) != 1 || response.Facts[0].Value != "clean" { + t.Fatalf("response = %#v", response) + } +} + +func TestDeclarativeManifestDoesNotStartExecutable(t *testing.T) { + // control-law: program-compilation-never-executes-repository-selected-code + if runtime.GOOS == "windows" { + t.Skip("POSIX script fixture") + } + directory := exactPath(t, t.TempDir()) + marker := filepath.Join(directory, "started") + content := []byte("#!/bin/sh\ntouch \"" + marker + "\"\n") + path := filepath.Join(directory, "extension.sh") + if err := os.WriteFile(path, content, 0o700); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Manifest: fixtureManifest(t, "fixture.echo")}) + if err != nil { + t.Fatal(err) + } + if _, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "test-kernel", Core: core.System(), Flow: standard.Definition(), Extensions: []control.Extension{extension}, + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("declarative manifest started executable: %v", err) + } +} + func TestExecutableCannotBecomeASymlinkAfterConstruction(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink replacement semantics are exercised on POSIX") @@ -138,7 +210,7 @@ func TestExecutableCannotBecomeASymlinkAfterConstruction(t *testing.T) { t.Fatal(err) } digest := sha256.Sum256(source) - extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:])}) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Manifest: fixtureManifest(t, "fixture.echo")}) if err != nil { t.Fatal(err) } @@ -172,7 +244,7 @@ func TestDeadlineAndOutputBoundsFailClosed(t *testing.T) { t.Fatal(err) } digest := sha256.Sum256([]byte(fixture.body)) - extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Limits: control.SubprocessLimits{Deadline: fixture.deadline, StdoutBytes: fixture.stdout, StderrBytes: 64}}) + extension, err := New(Config{ID: "fixture.echo", Version: "1.0.0", Executable: path, SHA256: hex.EncodeToString(digest[:]), Manifest: fixtureManifest(t, "fixture.echo"), Limits: control.SubprocessLimits{Deadline: fixture.deadline, StdoutBytes: fixture.stdout, StderrBytes: 64}}) if err != nil { t.Fatal(err) } diff --git a/boatstack/go.mod b/boatstack/go.mod index 8e4c22b..dc63655 100644 --- a/boatstack/go.mod +++ b/boatstack/go.mod @@ -2,4 +2,6 @@ module github.com/operatorstack/boatstack/boatstack go 1.26 -require go.yaml.in/yaml/v3 v3.0.4 +require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + +require golang.org/x/text v0.14.0 // indirect diff --git a/boatstack/go.sum b/boatstack/go.sum index 56a75c7..5b447c8 100644 --- a/boatstack/go.sum +++ b/boatstack/go.sum @@ -1,4 +1,6 @@ -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/boatstack/internal/effects/executable.go b/boatstack/internal/effects/executable.go new file mode 100644 index 0000000..37fdbf8 --- /dev/null +++ b/boatstack/internal/effects/executable.go @@ -0,0 +1,24 @@ +package effects + +import ( + "fmt" + "os" + "path/filepath" +) + +// StageVerifiedExecutable materializes already verified bytes in a private, +// single-use location. Callers execute the returned path instead of reopening +// a mutable repository path after verification. +func StageVerifiedExecutable(source string, raw []byte) (string, func(), error) { + directory, err := os.MkdirTemp("", "boatstack-extension-") + if err != nil { + return "", func() {}, fmt.Errorf("create private subprocess extension staging directory: %w", err) + } + cleanup := func() { _ = os.RemoveAll(directory) } + path := filepath.Join(directory, "extension"+filepath.Ext(source)) + if err := os.WriteFile(path, raw, 0o700); err != nil { + cleanup() + return "", func() {}, fmt.Errorf("stage verified subprocess extension bytes: %w", err) + } + return path, cleanup, nil +} diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 44eb0b1..e0d24f0 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -277,6 +277,10 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe startedAt := e.clock.Now() effectResult, effectErr := prepared.Execute(ctx) if effectErr != nil { + if transition.Class == catalog.EventOwnedExternal { + unknown := ExternalOutcomeUnknownError{Transition: transition.ID, Recovery: transition.Interruption.Recovery} + return result, requireRecovery("external effect returned without a provable outcome", errors.Join(unknown, effectErr)) + } rollbackErr := prepared.Rollback(ctx) if rollbackErr != nil { return result, requireRecovery("effect and rollback failed", errors.Join(effectErr, rollbackErr)) diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index 750cffe..94f22f2 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -104,6 +104,7 @@ func (j *fakeJournal) RequireRecovery(context.Context, string, string) error { type fakeEffects struct { executions, rollbacks int result ports.EffectResult + err error } func (e *fakeEffects) Prepare(context.Context, protocol.Admission, catalog.Transition) (ports.PreparedEffect, error) { @@ -115,7 +116,7 @@ func (e *fakeEffects) VerificationInvocation() (model.InvocationContext, bool) { } func (e *fakeEffects) Execute(context.Context) (ports.EffectResult, error) { e.executions++ - return e.result, nil + return e.result, e.err } func (e *fakeEffects) Rollback(context.Context) error { e.rollbacks++ @@ -184,6 +185,10 @@ func recoveryObservation(fingerprint string) model.Observation { } func testRegistry(t *testing.T) catalog.Registry { + return testRegistryWithAdvanceClass(t, catalog.EventOwnedLocal) +} + +func testRegistryWithAdvanceClass(t *testing.T, class catalog.EventClass) catalog.Registry { t.Helper() identity := []string{"repository-id", "git-common-id", "worktree-id"} interruption := func(recovery catalog.TransitionID) catalog.InterruptionContract { @@ -193,11 +198,19 @@ func testRegistry(t *testing.T) catalog.Registry { Recovery: recovery, RecoveryAuthority: "test-authority", ResumptionPredicate: "test-resumption", } } + authority := []catalog.AuthorityClass{catalog.AuthorityRepository} + localEffects := []catalog.EffectID{"test.advance"} + var externalEffects []catalog.EffectID + if class == catalog.EventOwnedExternal { + authority = []catalog.AuthorityClass{catalog.AuthorityHuman} + localEffects = nil + externalEffects = []catalog.EffectID{"test.advance"} + } r, err := catalog.New([]catalog.Transition{{ - ID: "test.advance", Version: 1, Class: catalog.EventOwnedLocal, + ID: "test.advance", Version: 1, Class: class, Origin: catalog.TransitionOrigin{Kind: catalog.OriginPrimaryFlow, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionFlowProgress, SourcePhases: []model.ProtocolPhase{model.PhaseObserved}, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, - RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: []catalog.EffectID{"test.advance"}, Idempotent: true, + RequiredIdentity: identity, Authority: authority, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: localEffects, ExternalEffects: externalEffects, Idempotent: true, Prescription: catalog.Prescription{Operation: "test.advance", ExpectedPostcondition: "active"}, SourcePredicate: "observed", AdmissionPredicate: "exact-admission", TargetPredicate: "active", Verifier: "fresh-active", SourceConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"start"}}}, TargetConditions: []catalog.FacetCondition{{Facet: model.FacetName("test.synthetic.stage"), Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"terminal"}}}, @@ -419,3 +432,25 @@ func TestApplyPreservesUnknownExternalOutcomeForReconciliation(t *testing.T) { t.Fatalf("external uncertainty was not preserved: effects=%+v journal=%+v receipts=%d", effects, journal, len(receipts.values)) } } + +func TestOwnedExternalExecutionErrorRequiresRecoveryWithoutRollback(t *testing.T) { + // control-law: a returned transport error cannot prove an external effect did not settle + now := time.Unix(30, 0).UTC() + registry := testRegistryWithAdvanceClass(t, catalog.EventOwnedExternal) + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} + journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{err: context.DeadlineExceeded}, &memoryReceipts{}, &fakeLock{} + kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + if err != nil { + t.Fatal(err) + } + apply := request(now) + apply.Authority.Receipts[0].Class = catalog.AuthorityHuman + _, err = kernel.Apply(context.Background(), apply) + var unknown ExternalOutcomeUnknownError + if !errors.As(err, &unknown) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error=%v, want unknown external outcome joined with deadline", err) + } + if effects.executions != 1 || effects.rollbacks != 0 || journal.recovery != 1 || journal.aborted != 0 || len(receipts.values) != 0 { + t.Fatalf("ambiguous external error was collapsed: effects=%+v journal=%+v receipts=%d", effects, journal, len(receipts.values)) + } +} diff --git a/boatstack/internal/kernel/model/state.go b/boatstack/internal/kernel/model/state.go index 24ecc01..75810e0 100644 --- a/boatstack/internal/kernel/model/state.go +++ b/boatstack/internal/kernel/model/state.go @@ -414,6 +414,13 @@ type Observation struct { ObservedAt time.Time `json:"observed_at"` } +// ExecutableRuntimeAdmitted reports whether repository-selected executable +// observers may run for this exact compiled program. +func (o Observation) ExecutableRuntimeAdmitted(programFingerprint string) bool { + return o.RecordedProgramFingerprint == programFingerprint && + o.Configuration.Status == FactKnown && o.Configuration.Value == ConfigurationVerified +} + type Snapshot struct { Observation Fingerprint string `json:"fingerprint"` diff --git a/boatstack/internal/kernel/protocol/config.go b/boatstack/internal/kernel/protocol/config.go index fdbd1fa..259fee6 100644 --- a/boatstack/internal/kernel/protocol/config.go +++ b/boatstack/internal/kernel/protocol/config.go @@ -38,6 +38,7 @@ type SubprocessExtensionSettings struct { Version string `json:"version"` Executable string `json:"executable"` SHA256 string `json:"sha256"` + Manifest json.RawMessage `json:"manifest"` Settings json.RawMessage `json:"settings,omitempty"` DeadlineMillis int `json:"deadline_millis,omitempty"` StdoutBytes int64 `json:"stdout_bytes,omitempty"` @@ -99,14 +100,22 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { sort.Strings(canonical.Hosts) canonical.Extensions = append([]SubprocessExtensionSettings(nil), config.Extensions...) for index := range canonical.Extensions { - if len(canonical.Extensions[index].Settings) != 0 { - var settings any - if err := json.Unmarshal(canonical.Extensions[index].Settings, &settings); err != nil { - return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q settings: %w", canonical.Extensions[index].ID, err) + values := []struct { + name string + value *json.RawMessage + }{{"manifest", &canonical.Extensions[index].Manifest}, {"settings", &canonical.Extensions[index].Settings}} + for _, item := range values { + name, value := item.name, item.value + if len(*value) == 0 { + continue + } + var decoded any + if err := json.Unmarshal(*value, &decoded); err != nil { + return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q %s: %w", canonical.Extensions[index].ID, name, err) } - canonical.Extensions[index].Settings, err = json.Marshal(settings) + *value, err = json.Marshal(decoded) if err != nil { - return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q settings: %w", canonical.Extensions[index].ID, err) + return ProjectConfig{}, "", fmt.Errorf("canonicalize extension %q %s: %w", canonical.Extensions[index].ID, name, err) } } } @@ -158,8 +167,8 @@ func (c ProjectConfig) Validate() error { } seenExtensions := map[string]bool{} for _, extension := range c.Extensions { - if !extensionID.MatchString(extension.ID) || extension.Version == "" || !filepath.IsAbs(extension.Executable) || filepath.Clean(extension.Executable) != extension.Executable || len(extension.SHA256) != 64 { - return fmt.Errorf("subprocess extension requires semantic id, version, exact absolute executable, and SHA-256") + if !extensionID.MatchString(extension.ID) || extension.Version == "" || !filepath.IsAbs(extension.Executable) || filepath.Clean(extension.Executable) != extension.Executable || len(extension.SHA256) != 64 || len(extension.Manifest) == 0 { + return fmt.Errorf("subprocess extension requires semantic id, version, exact absolute executable, SHA-256, and declarative manifest") } if _, err := hex.DecodeString(extension.SHA256); err != nil { return fmt.Errorf("subprocess extension %q has invalid SHA-256", extension.ID) @@ -171,16 +180,29 @@ func (c ProjectConfig) Validate() error { if extension.DeadlineMillis < 0 || extension.StdoutBytes < 0 || extension.StderrBytes < 0 { return fmt.Errorf("subprocess extension %q has negative limits", extension.ID) } - if len(extension.Settings) != 0 { + values := []struct { + name string + value json.RawMessage + }{{"manifest", extension.Manifest}, {"settings", extension.Settings}} + for _, item := range values { + name, value := item.name, item.value + if len(value) == 0 { + continue + } var settings any - decoder := json.NewDecoder(bytes.NewReader(extension.Settings)) + decoder := json.NewDecoder(bytes.NewReader(value)) decoder.UseNumber() if err := decoder.Decode(&settings); err != nil { - return fmt.Errorf("subprocess extension %q settings are invalid JSON", extension.ID) + return fmt.Errorf("subprocess extension %q %s is invalid JSON", extension.ID, name) } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("subprocess extension %q settings contain trailing JSON", extension.ID) + return fmt.Errorf("subprocess extension %q %s contains trailing JSON", extension.ID, name) + } + if name == "manifest" { + if _, ok := settings.(map[string]any); !ok { + return fmt.Errorf("subprocess extension %q manifest must be a JSON object", extension.ID) + } } } } diff --git a/boatstack/internal/kernel/protocol/config_test.go b/boatstack/internal/kernel/protocol/config_test.go index e31c710..e555307 100644 --- a/boatstack/internal/kernel/protocol/config_test.go +++ b/boatstack/internal/kernel/protocol/config_test.go @@ -35,6 +35,7 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * Hosts: []string{"cli", "sdk"}, Extensions: []SubprocessExtensionSettings{{ ID: "example.guard", Version: "1.0.0", Executable: executable, SHA256: strings.Repeat("a", 64), + Manifest: json.RawMessage(`{"id":"example.guard","version":"1.0.0","protocol_version":1,"settings_schema":{"type":"object"},"privacy_classification":"metadata-only","telemetry_classification":"transition-receipt"}`), Settings: json.RawMessage(`{"level":"strict","enabled":true}`), DeadlineMillis: 1000, StdoutBytes: 2048, StderrBytes: 1024, }}, } diff --git a/boatstack/internal/surfaces/catalog_render.go b/boatstack/internal/surfaces/catalog_render.go index 75ef177..3f86b2e 100644 --- a/boatstack/internal/surfaces/catalog_render.go +++ b/boatstack/internal/surfaces/catalog_render.go @@ -61,9 +61,8 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { return output.String() } -// RenderCatalogMermaid generates one inventory node per runtime transition, -// grouped by event class. Phase sets are labels on the exact transition node, -// avoiding a second hand-maintained graph. +// RenderCatalogMermaid generates one connected phase-transition graph from the +// runtime registry, avoiding a second hand-maintained graph. func RenderCatalogMermaid(transitions []catalog.Transition) string { return renderCatalogMermaid(transitions, "%% Generated from the compiled ControlProgram registry by surfaces.RenderCatalogMermaid. Do not edit.\n") } @@ -93,19 +92,51 @@ func renderCatalogMermaid(transitions []catalog.Transition, header string) strin var output strings.Builder output.WriteString(header) output.WriteString("flowchart TB\n") + phases := map[model.ProtocolPhase]bool{} + for _, transition := range ordered { + for _, phase := range append(append([]model.ProtocolPhase(nil), transition.SourcePhases...), transition.TargetPhases...) { + phases[phase] = true + } + } + orderedPhases := make([]model.ProtocolPhase, 0, len(phases)) + for phase := range phases { + orderedPhases = append(orderedPhases, phase) + } + sort.Slice(orderedPhases, func(i, j int) bool { return orderedPhases[i] < orderedPhases[j] }) + output.WriteString(" subgraph phases[\"protocol phases\"]\n") + for _, phase := range orderedPhases { + fmt.Fprintf(&output, " p_%s[\"%s\"]\n", strings.ReplaceAll(string(phase), "-", "_"), escapeMermaid(string(phase))) + } + output.WriteString(" end\n") index := 0 + type edgeSet struct { + node string + sources []model.ProtocolPhase + targets []model.ProtocolPhase + } + edges := make([]edgeSet, 0, len(ordered)) for _, class := range classes { fmt.Fprintf(&output, " subgraph %s[\"%s\"]\n", strings.ReplaceAll(string(class), "-", "_"), class) for _, transition := range ordered { if transition.Class != class { continue } - label := fmt.Sprintf("%s
%s → %s", transition.ID, strings.Join(phaseStrings(transition.SourcePhases), " | "), strings.Join(phaseStrings(transition.TargetPhases), " | ")) - fmt.Fprintf(&output, " t%02d[\"%s\"]\n", index, escapeMermaid(label)) + node := fmt.Sprintf("t%02d", index) + label := fmt.Sprintf("%s
%s", transition.ID, transition.Class) + fmt.Fprintf(&output, " %s[\"%s\"]\n", node, escapeMermaid(label)) + edges = append(edges, edgeSet{node: node, sources: transition.SourcePhases, targets: transition.TargetPhases}) index++ } output.WriteString(" end\n") } + for _, edge := range edges { + for _, phase := range edge.sources { + fmt.Fprintf(&output, " p_%s --> %s\n", strings.ReplaceAll(string(phase), "-", "_"), edge.node) + } + for _, phase := range edge.targets { + fmt.Fprintf(&output, " %s --> p_%s\n", edge.node, strings.ReplaceAll(string(phase), "-", "_")) + } + } return output.String() } diff --git a/boatstack/internal/surfaces/render_test.go b/boatstack/internal/surfaces/render_test.go index c5629e5..53fa722 100644 --- a/boatstack/internal/surfaces/render_test.go +++ b/boatstack/internal/surfaces/render_test.go @@ -55,10 +55,13 @@ func TestCatalogArtifactsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { if strings.Count(markdown, rowPrefix) != 1 { t.Errorf("markdown does not contain transition %s exactly once", transition.ID) } - if strings.Count(mermaid, string(transition.ID)+"
") != 1 { + if strings.Count(mermaid, `["`+string(transition.ID)+`
`) != 1 { t.Errorf("Mermaid does not contain transition %s exactly once", transition.ID) } } + if !strings.Contains(mermaid, " --> ") { + t.Fatal("Mermaid transition inventory is not connected to protocol phases") + } if markdown != RenderCatalogMarkdown(registry.All()) || mermaid != RenderCatalogMermaid(registry.All()) { t.Fatal("catalog artifact rendering is not deterministic") } diff --git a/boatstack/program_observer.go b/boatstack/program_observer.go index 99ffc7e..b7e62fc 100644 --- a/boatstack/program_observer.go +++ b/boatstack/program_observer.go @@ -16,6 +16,19 @@ type programObserver struct { program control.ControlProgram } +// ComponentRuntimeError preserves a bounded protocol error classification and +// message without granting a component control over recovery policy. +type ComponentRuntimeError struct { + Component string + Operation string + Class string + Message string +} + +func (e ComponentRuntimeError) Error() string { + return fmt.Sprintf("%s %s reported %s: %s", e.Component, e.Operation, e.Class, e.Message) +} + func (o programObserver) Observe(ctx context.Context, request ports.ObservationRequest) (model.Observation, error) { observation, err := o.base.Observe(ctx, request) if err != nil { @@ -78,6 +91,12 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR if len(extension.Manifest.Facts) == 0 { continue } + // Repository-selected executable extensions remain inert until the + // observed configuration and recorded program identity prove that this + // exact composition has already crossed the Kernel admission boundary. + if extension.Manifest.ExecutableSHA256 != "" && !observation.ExecutableRuntimeAdmitted(o.program.Fingerprint()) { + continue + } if extension.Runtime == nil { return model.Observation{}, fmt.Errorf("extension %q observer is unavailable", extension.Identity.ID) } @@ -187,7 +206,7 @@ func validateFlowResponse(flow control.CompiledFlow, operation control.FlowOpera return fmt.Errorf("primary flow %q returned an invalid operation response: %w", flow.Identity.ID, err) } if response.ErrorClass != "" || response.Error != "" { - return fmt.Errorf("primary flow %q reported %s", flow.Identity.ID, response.ErrorClass) + return ComponentRuntimeError{Component: fmt.Sprintf("primary flow %q", flow.Identity.ID), Operation: string(operation), Class: response.ErrorClass, Message: response.Error} } return nil } @@ -202,7 +221,7 @@ func validateExtensionResponse(extension control.CompiledExtension, operation co return fmt.Errorf("extension %q returned an invalid operation response: %w", extension.Identity.ID, err) } if response.ErrorClass != "" || response.Error != "" { - return fmt.Errorf("extension %q reported %s", extension.Identity.ID, response.ErrorClass) + return ComponentRuntimeError{Component: fmt.Sprintf("extension %q", extension.Identity.ID), Operation: string(operation), Class: response.ErrorClass, Message: response.Error} } return nil } diff --git a/boatstack/program_observer_test.go b/boatstack/program_observer_test.go index 8215e19..75e1461 100644 --- a/boatstack/program_observer_test.go +++ b/boatstack/program_observer_test.go @@ -3,6 +3,8 @@ package boatstack import ( "context" "encoding/json" + "errors" + "strings" "testing" "time" @@ -19,23 +21,47 @@ func (o fixedObservation) Observe(context.Context, ports.ObservationRequest) (mo return o.value, nil } +func TestRuntimeErrorPreservesBoundedClassAndMessage(t *testing.T) { + extension := control.CompiledExtension{ + Identity: control.ComponentIdentity{ID: "example.runtime", Version: "1.0.0"}, + } + err := validateExtensionResponse(extension, control.ExtensionObserveOperation, "correlation", control.ExtensionResponse{ + ProtocolVersion: control.ExtensionProtocolVersion, Operation: control.ExtensionObserveOperation, + ExtensionID: "example.runtime", ExtensionVersion: "1.0.0", CorrelationID: "correlation", + ErrorClass: "temporary", Error: "provider response was incomplete", + }) + var runtimeErr ComponentRuntimeError + if !errors.As(err, &runtimeErr) || runtimeErr.Class != "temporary" || runtimeErr.Message != "provider response was incomplete" { + t.Fatalf("runtime error detail was lost: %#v (%v)", runtimeErr, err) + } +} + type isolatedObservationExtension struct { - id string - forbid string - sawFact *bool + id string + forbid string + sawFact *bool + executable bool + calls *int } func (e isolatedObservationExtension) ExtensionManifest(context.Context) (control.ExtensionManifest, error) { - return control.ExtensionManifest{ + manifest := control.ExtensionManifest{ ID: e.id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{e.id + ".fact"}, PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", - }, nil + } + if e.executable { + manifest.ExecutableSHA256 = strings.Repeat("a", 64) + } + return manifest, nil } func (e isolatedObservationExtension) Runtime() control.ExtensionRuntime { return e } func (e isolatedObservationExtension) Invoke(_ context.Context, request control.ExtensionRequest) (control.ExtensionResponse, error) { + if e.calls != nil { + *e.calls++ + } var projection struct { ExtensionFacts map[string]json.RawMessage `json:"extension_facts"` } @@ -50,6 +76,41 @@ func (e isolatedObservationExtension) Invoke(_ context.Context, request control. }, nil } +func TestExecutableExtensionObservationWaitsForVerifiedProgramBinding(t *testing.T) { + // control-law: repository-selected-code-is-inert-before-exact-program-admission + var calls int + var sawFact bool + extension := isolatedObservationExtension{id: "example.external", sawFact: &sawFact, executable: true, calls: &calls} + program, err := control.Compile(context.Background(), control.CompileRequest{ + KernelVersion: "test-kernel", Core: core.System(), Flow: standard.Definition(), Extensions: []control.Extension{extension}, + }) + if err != nil { + t.Fatal(err) + } + invocation := model.InvocationContext{Correlation: "pre-admission-gate"} + base := model.Observation{ + Invocation: invocation, ObservedAt: time.Unix(100, 0).UTC(), + Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "test", Fingerprint: "configuration", ObservedAt: time.Unix(100, 0).UTC()}), + } + observer := programObserver{base: fixedObservation{value: base}, program: program} + observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if calls != 0 || len(observed.ExtensionFacts) != 0 { + t.Fatalf("unbound program executed extension: calls=%d facts=%#v", calls, observed.ExtensionFacts) + } + base.RecordedProgramFingerprint = program.Fingerprint() + observer.base = fixedObservation{value: base} + observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if calls != 1 || observed.ExtensionFacts["example.external.fact"].Value != "observed" { + t.Fatalf("verified binding did not execute extension once: calls=%d facts=%#v", calls, observed.ExtensionFacts) + } +} + func TestExtensionObserversConsumeOneOrderIndependentProjection(t *testing.T) { // control-law: extension-observation-order-cannot-create-cross-extension-facts var alphaSawBeta, betaSawAlpha bool diff --git a/docs/architecture/boatstack-standard-flow.mmd b/docs/architecture/boatstack-standard-flow.mmd index c2a8d24..bb0f5ee 100644 --- a/docs/architecture/boatstack-standard-flow.mmd +++ b/docs/architecture/boatstack-standard-flow.mmd @@ -1,42 +1,162 @@ %% Generated from compiled PrimaryFlow declarations by surfaces.RenderStandardFlowMermaid. Do not edit. flowchart TB + subgraph phases["protocol phases"] + p_ABANDONED["ABANDONED"] + p_ACTIVE["ACTIVE"] + p_DORMANT["DORMANT"] + p_FRONTIER["FRONTIER"] + p_OBSERVED["OBSERVED"] + p_RECOVERY["RECOVERY"] + p_TERMINAL["TERMINAL"] + p_UNRESOLVED["UNRESOLVED"] + end subgraph authority["authority"] - t00["evidence.approval.revoke
ACTIVE | FRONTIER → FRONTIER"] - t01["plan.abandon
OBSERVED | ACTIVE | FRONTIER → ABANDONED"] - t02["plan.approve
ACTIVE | FRONTIER → ACTIVE | TERMINAL"] - t03["plan.approve-amendment
ACTIVE | FRONTIER → ACTIVE"] - t04["publication.abandon
ACTIVE | FRONTIER → ABANDONED"] + t00["evidence.approval.revoke
authority"] + t01["plan.abandon
authority"] + t02["plan.approve
authority"] + t03["plan.approve-amendment
authority"] + t04["publication.abandon
authority"] end subgraph owned_local["owned-local"] - t05["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"] - t06["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"] - t07["gate.build.record
ACTIVE → ACTIVE"] - t08["gate.change.record
ACTIVE → ACTIVE"] - t09["gate.journey.record
ACTIVE → ACTIVE"] - t10["gate.review.record
ACTIVE → ACTIVE | TERMINAL"] - t11["gate.test.record
ACTIVE → ACTIVE | TERMINAL"] - t12["plan.activate
OBSERVED | ACTIVE → ACTIVE"] - t13["plan.amend
ACTIVE | FRONTIER → ACTIVE"] - t14["plan.create
OBSERVED | ACTIVE → ACTIVE"] - t15["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"] - t16["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"] - t17["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t18["publication.preview
ACTIVE → ACTIVE"] - t19["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"] - t20["workspace.activate
OBSERVED | ACTIVE → ACTIVE"] - t21["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t22["workspace.cut
OBSERVED | ACTIVE → ACTIVE"] - t23["workspace.publish
ACTIVE → ACTIVE"] - t24["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t25["workspace.sync
ACTIVE → ACTIVE | FRONTIER"] + t05["delivery.slice.advance
owned-local"] + t06["evidence.visual.attach
owned-local"] + t07["gate.build.record
owned-local"] + t08["gate.change.record
owned-local"] + t09["gate.journey.record
owned-local"] + t10["gate.review.record
owned-local"] + t11["gate.test.record
owned-local"] + t12["plan.activate
owned-local"] + t13["plan.amend
owned-local"] + t14["plan.create
owned-local"] + t15["plan.invalidate
owned-local"] + t16["plan.validate
owned-local"] + t17["publication.observe
owned-local"] + t18["publication.preview
owned-local"] + t19["workspace.abandon
owned-local"] + t20["workspace.activate
owned-local"] + t21["workspace.cleanup
owned-local"] + t22["workspace.cut
owned-local"] + t23["workspace.publish
owned-local"] + t24["workspace.reap
owned-local"] + t25["workspace.sync
owned-local"] end subgraph owned_external["owned-external"] - t26["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"] - t27["publication.execute
ACTIVE → ACTIVE | RECOVERY"] + t26["publication.correct
owned-external"] + t27["publication.execute
owned-external"] end subgraph recovery["recovery"] - t28["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t29["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + t28["publication.reconcile
recovery"] + t29["workspace.reconcile
recovery"] end subgraph observed_external["observed-external"] end + p_ACTIVE --> t00 + p_FRONTIER --> t00 + t00 --> p_FRONTIER + p_OBSERVED --> t01 + p_ACTIVE --> t01 + p_FRONTIER --> t01 + t01 --> p_ABANDONED + p_ACTIVE --> t02 + p_FRONTIER --> t02 + t02 --> p_ACTIVE + t02 --> p_TERMINAL + p_ACTIVE --> t03 + p_FRONTIER --> t03 + t03 --> p_ACTIVE + p_ACTIVE --> t04 + p_FRONTIER --> t04 + t04 --> p_ABANDONED + p_ACTIVE --> t05 + t05 --> p_ACTIVE + t05 --> p_TERMINAL + p_ACTIVE --> t06 + t06 --> p_ACTIVE + t06 --> p_TERMINAL + p_ACTIVE --> t07 + t07 --> p_ACTIVE + p_ACTIVE --> t08 + t08 --> p_ACTIVE + p_ACTIVE --> t09 + t09 --> p_ACTIVE + p_ACTIVE --> t10 + t10 --> p_ACTIVE + t10 --> p_TERMINAL + p_ACTIVE --> t11 + t11 --> p_ACTIVE + t11 --> p_TERMINAL + p_OBSERVED --> t12 + p_ACTIVE --> t12 + t12 --> p_ACTIVE + p_ACTIVE --> t13 + p_FRONTIER --> t13 + t13 --> p_ACTIVE + p_OBSERVED --> t14 + p_ACTIVE --> t14 + t14 --> p_ACTIVE + p_ACTIVE --> t15 + p_OBSERVED --> t15 + t15 --> p_FRONTIER + p_OBSERVED --> t16 + p_ACTIVE --> t16 + t16 --> p_ACTIVE + t16 --> p_FRONTIER + p_OBSERVED --> t17 + p_ACTIVE --> t17 + p_RECOVERY --> t17 + p_UNRESOLVED --> t17 + t17 --> p_ACTIVE + t17 --> p_TERMINAL + t17 --> p_FRONTIER + t17 --> p_UNRESOLVED + p_ACTIVE --> t18 + t18 --> p_ACTIVE + p_ACTIVE --> t19 + p_FRONTIER --> t19 + t19 --> p_ABANDONED + p_OBSERVED --> t20 + p_ACTIVE --> t20 + t20 --> p_ACTIVE + p_OBSERVED --> t21 + p_ACTIVE --> t21 + p_TERMINAL --> t21 + p_ABANDONED --> t21 + t21 --> p_OBSERVED + t21 --> p_TERMINAL + t21 --> p_ABANDONED + p_OBSERVED --> t22 + p_ACTIVE --> t22 + t22 --> p_ACTIVE + p_ACTIVE --> t23 + t23 --> p_ACTIVE + p_OBSERVED --> t24 + p_TERMINAL --> t24 + p_ABANDONED --> t24 + t24 --> p_OBSERVED + t24 --> p_TERMINAL + t24 --> p_ABANDONED + p_ACTIVE --> t25 + t25 --> p_ACTIVE + t25 --> p_FRONTIER + p_OBSERVED --> t26 + p_ACTIVE --> t26 + p_TERMINAL --> t26 + t26 --> p_ACTIVE + t26 --> p_RECOVERY + p_ACTIVE --> t27 + t27 --> p_ACTIVE + t27 --> p_RECOVERY + p_RECOVERY --> t28 + p_UNRESOLVED --> t28 + t28 --> p_ACTIVE + t28 --> p_TERMINAL + t28 --> p_FRONTIER + t28 --> p_UNRESOLVED + p_RECOVERY --> t29 + p_UNRESOLVED --> t29 + t29 --> p_DORMANT + t29 --> p_OBSERVED + t29 --> p_ACTIVE + t29 --> p_FRONTIER + t29 --> p_TERMINAL + t29 --> p_ABANDONED diff --git a/docs/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-v2-transition-catalog.mmd index ba07251..2dd67b2 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.mmd +++ b/docs/architecture/boatstack-v2-transition-catalog.mmd @@ -1,74 +1,397 @@ %% Generated from the compiled ControlProgram registry by surfaces.RenderCatalogMermaid. Do not edit. flowchart TB + subgraph phases["protocol phases"] + p_ABANDONED["ABANDONED"] + p_ACTIVE["ACTIVE"] + p_DORMANT["DORMANT"] + p_FRONTIER["FRONTIER"] + p_OBSERVED["OBSERVED"] + p_RECOVERY["RECOVERY"] + p_TERMINAL["TERMINAL"] + p_UNRESOLVED["UNRESOLVED"] + end subgraph authority["authority"] - t00["engagement.begin
DORMANT | OBSERVED → OBSERVED | ACTIVE"] - t01["engagement.release
ACTIVE | FRONTIER → DORMANT"] - t02["engagement.renew
ACTIVE → ACTIVE"] - t03["evidence.approval.revoke
ACTIVE | FRONTIER → FRONTIER"] - t04["goal.configure
OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED → OBSERVED | ACTIVE | FRONTIER"] - t05["plan.abandon
OBSERVED | ACTIVE | FRONTIER → ABANDONED"] - t06["plan.approve
ACTIVE | FRONTIER → ACTIVE | TERMINAL"] - t07["plan.approve-amendment
ACTIVE | FRONTIER → ACTIVE"] - t08["publication.abandon
ACTIVE | FRONTIER → ABANDONED"] + t00["engagement.begin
authority"] + t01["engagement.release
authority"] + t02["engagement.renew
authority"] + t03["evidence.approval.revoke
authority"] + t04["goal.configure
authority"] + t05["plan.abandon
authority"] + t06["plan.approve
authority"] + t07["plan.approve-amendment
authority"] + t08["publication.abandon
authority"] end subgraph owned_local["owned-local"] - t09["catalog.reconcile
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED | TERMINAL | ABANDONED → DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED | TERMINAL | ABANDONED"] - t10["configuration.initialize
OBSERVED → OBSERVED | TERMINAL"] - t11["configuration.mutate
OBSERVED | ACTIVE | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t12["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"] - t13["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"] - t14["gate.build.record
ACTIVE → ACTIVE"] - t15["gate.change.record
ACTIVE → ACTIVE"] - t16["gate.journey.record
ACTIVE → ACTIVE"] - t17["gate.review.record
ACTIVE → ACTIVE | TERMINAL"] - t18["gate.test.record
ACTIVE → ACTIVE | TERMINAL"] - t19["installation.initialize
DORMANT | OBSERVED → OBSERVED"] - t20["installation.update
OBSERVED | ACTIVE → OBSERVED | ACTIVE | TERMINAL"] - t21["invocation.rebind
OBSERVED | UNRESOLVED → OBSERVED"] - t22["plan.activate
OBSERVED | ACTIVE → ACTIVE"] - t23["plan.amend
ACTIVE | FRONTIER → ACTIVE"] - t24["plan.create
OBSERVED | ACTIVE → ACTIVE"] - t25["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"] - t26["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"] - t27["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t28["publication.preview
ACTIVE → ACTIVE"] - t29["repository.attach
DORMANT | OBSERVED → OBSERVED"] - t30["repository.detach
DORMANT | OBSERVED | FRONTIER → DORMANT"] - t31["runtime.hydrate
OBSERVED | RECOVERY | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t32["runtime.replace
OBSERVED | RECOVERY → OBSERVED | TERMINAL"] - t33["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"] - t34["workspace.activate
OBSERVED | ACTIVE → ACTIVE"] - t35["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t36["workspace.cut
OBSERVED | ACTIVE → ACTIVE"] - t37["workspace.publish
ACTIVE → ACTIVE"] - t38["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"] - t39["workspace.sync
ACTIVE → ACTIVE | FRONTIER"] + t09["catalog.reconcile
owned-local"] + t10["configuration.initialize
owned-local"] + t11["configuration.mutate
owned-local"] + t12["delivery.slice.advance
owned-local"] + t13["evidence.visual.attach
owned-local"] + t14["gate.build.record
owned-local"] + t15["gate.change.record
owned-local"] + t16["gate.journey.record
owned-local"] + t17["gate.review.record
owned-local"] + t18["gate.test.record
owned-local"] + t19["installation.initialize
owned-local"] + t20["installation.update
owned-local"] + t21["invocation.rebind
owned-local"] + t22["plan.activate
owned-local"] + t23["plan.amend
owned-local"] + t24["plan.create
owned-local"] + t25["plan.invalidate
owned-local"] + t26["plan.validate
owned-local"] + t27["publication.observe
owned-local"] + t28["publication.preview
owned-local"] + t29["repository.attach
owned-local"] + t30["repository.detach
owned-local"] + t31["runtime.hydrate
owned-local"] + t32["runtime.replace
owned-local"] + t33["workspace.abandon
owned-local"] + t34["workspace.activate
owned-local"] + t35["workspace.cleanup
owned-local"] + t36["workspace.cut
owned-local"] + t37["workspace.publish
owned-local"] + t38["workspace.reap
owned-local"] + t39["workspace.sync
owned-local"] end subgraph owned_external["owned-external"] - t40["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"] - t41["publication.execute
ACTIVE → ACTIVE | RECOVERY"] + t40["publication.correct
owned-external"] + t41["publication.execute
owned-external"] end subgraph recovery["recovery"] - t42["configuration.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] - t43["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"] - t44["recovery.escalate
RECOVERY | UNRESOLVED → FRONTIER"] - t45["recovery.resume
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] - t46["recovery.rollback
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] - t47["runtime.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"] - t48["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"] + t42["configuration.reconcile
recovery"] + t43["publication.reconcile
recovery"] + t44["recovery.escalate
recovery"] + t45["recovery.resume
recovery"] + t46["recovery.rollback
recovery"] + t47["runtime.reconcile
recovery"] + t48["workspace.reconcile
recovery"] end subgraph observed_external["observed-external"] - t49["external.branch-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t50["external.ci-completed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t51["external.configuration-drifted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | UNRESOLVED"] - t52["external.files-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t53["external.head-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"] - t54["external.host-interrupted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → RECOVERY"] - t55["external.lease-expired
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → DORMANT | FRONTIER"] - t56["external.pr-closed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | FRONTIER"] - t57["external.pr-merged
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t58["external.pr-opened
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t59["external.pr-updated
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"] - t60["external.provider-unavailable
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → UNRESOLVED | RECOVERY"] - t61["external.runtime-disappeared
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | RECOVERY"] + t49["external.branch-changed
observed-external"] + t50["external.ci-completed
observed-external"] + t51["external.configuration-drifted
observed-external"] + t52["external.files-changed
observed-external"] + t53["external.head-changed
observed-external"] + t54["external.host-interrupted
observed-external"] + t55["external.lease-expired
observed-external"] + t56["external.pr-closed
observed-external"] + t57["external.pr-merged
observed-external"] + t58["external.pr-opened
observed-external"] + t59["external.pr-updated
observed-external"] + t60["external.provider-unavailable
observed-external"] + t61["external.runtime-disappeared
observed-external"] end + p_DORMANT --> t00 + p_OBSERVED --> t00 + t00 --> p_OBSERVED + t00 --> p_ACTIVE + p_ACTIVE --> t01 + p_FRONTIER --> t01 + t01 --> p_DORMANT + p_ACTIVE --> t02 + t02 --> p_ACTIVE + p_ACTIVE --> t03 + p_FRONTIER --> t03 + t03 --> p_FRONTIER + p_OBSERVED --> t04 + p_ACTIVE --> t04 + p_FRONTIER --> t04 + p_TERMINAL --> t04 + p_ABANDONED --> t04 + t04 --> p_OBSERVED + t04 --> p_ACTIVE + t04 --> p_FRONTIER + p_OBSERVED --> t05 + p_ACTIVE --> t05 + p_FRONTIER --> t05 + t05 --> p_ABANDONED + p_ACTIVE --> t06 + p_FRONTIER --> t06 + t06 --> p_ACTIVE + t06 --> p_TERMINAL + p_ACTIVE --> t07 + p_FRONTIER --> t07 + t07 --> p_ACTIVE + p_ACTIVE --> t08 + p_FRONTIER --> t08 + t08 --> p_ABANDONED + p_DORMANT --> t09 + p_OBSERVED --> t09 + p_ACTIVE --> t09 + p_RECOVERY --> t09 + p_FRONTIER --> t09 + p_UNRESOLVED --> t09 + p_TERMINAL --> t09 + p_ABANDONED --> t09 + t09 --> p_DORMANT + t09 --> p_OBSERVED + t09 --> p_ACTIVE + t09 --> p_RECOVERY + t09 --> p_FRONTIER + t09 --> p_UNRESOLVED + t09 --> p_TERMINAL + t09 --> p_ABANDONED + p_OBSERVED --> t10 + t10 --> p_OBSERVED + t10 --> p_TERMINAL + p_OBSERVED --> t11 + p_ACTIVE --> t11 + p_FRONTIER --> t11 + p_UNRESOLVED --> t11 + t11 --> p_OBSERVED + t11 --> p_ACTIVE + t11 --> p_TERMINAL + p_ACTIVE --> t12 + t12 --> p_ACTIVE + t12 --> p_TERMINAL + p_ACTIVE --> t13 + t13 --> p_ACTIVE + t13 --> p_TERMINAL + p_ACTIVE --> t14 + t14 --> p_ACTIVE + p_ACTIVE --> t15 + t15 --> p_ACTIVE + p_ACTIVE --> t16 + t16 --> p_ACTIVE + p_ACTIVE --> t17 + t17 --> p_ACTIVE + t17 --> p_TERMINAL + p_ACTIVE --> t18 + t18 --> p_ACTIVE + t18 --> p_TERMINAL + p_DORMANT --> t19 + p_OBSERVED --> t19 + t19 --> p_OBSERVED + p_OBSERVED --> t20 + p_ACTIVE --> t20 + t20 --> p_OBSERVED + t20 --> p_ACTIVE + t20 --> p_TERMINAL + p_OBSERVED --> t21 + p_UNRESOLVED --> t21 + t21 --> p_OBSERVED + p_OBSERVED --> t22 + p_ACTIVE --> t22 + t22 --> p_ACTIVE + p_ACTIVE --> t23 + p_FRONTIER --> t23 + t23 --> p_ACTIVE + p_OBSERVED --> t24 + p_ACTIVE --> t24 + t24 --> p_ACTIVE + p_ACTIVE --> t25 + p_OBSERVED --> t25 + t25 --> p_FRONTIER + p_OBSERVED --> t26 + p_ACTIVE --> t26 + t26 --> p_ACTIVE + t26 --> p_FRONTIER + p_OBSERVED --> t27 + p_ACTIVE --> t27 + p_RECOVERY --> t27 + p_UNRESOLVED --> t27 + t27 --> p_ACTIVE + t27 --> p_TERMINAL + t27 --> p_FRONTIER + t27 --> p_UNRESOLVED + p_ACTIVE --> t28 + t28 --> p_ACTIVE + p_DORMANT --> t29 + p_OBSERVED --> t29 + t29 --> p_OBSERVED + p_DORMANT --> t30 + p_OBSERVED --> t30 + p_FRONTIER --> t30 + t30 --> p_DORMANT + p_OBSERVED --> t31 + p_RECOVERY --> t31 + p_UNRESOLVED --> t31 + t31 --> p_OBSERVED + t31 --> p_ACTIVE + t31 --> p_TERMINAL + p_OBSERVED --> t32 + p_RECOVERY --> t32 + t32 --> p_OBSERVED + t32 --> p_TERMINAL + p_ACTIVE --> t33 + p_FRONTIER --> t33 + t33 --> p_ABANDONED + p_OBSERVED --> t34 + p_ACTIVE --> t34 + t34 --> p_ACTIVE + p_OBSERVED --> t35 + p_ACTIVE --> t35 + p_TERMINAL --> t35 + p_ABANDONED --> t35 + t35 --> p_OBSERVED + t35 --> p_TERMINAL + t35 --> p_ABANDONED + p_OBSERVED --> t36 + p_ACTIVE --> t36 + t36 --> p_ACTIVE + p_ACTIVE --> t37 + t37 --> p_ACTIVE + p_OBSERVED --> t38 + p_TERMINAL --> t38 + p_ABANDONED --> t38 + t38 --> p_OBSERVED + t38 --> p_TERMINAL + t38 --> p_ABANDONED + p_ACTIVE --> t39 + t39 --> p_ACTIVE + t39 --> p_FRONTIER + p_OBSERVED --> t40 + p_ACTIVE --> t40 + p_TERMINAL --> t40 + t40 --> p_ACTIVE + t40 --> p_RECOVERY + p_ACTIVE --> t41 + t41 --> p_ACTIVE + t41 --> p_RECOVERY + p_RECOVERY --> t42 + p_UNRESOLVED --> t42 + t42 --> p_OBSERVED + t42 --> p_FRONTIER + t42 --> p_TERMINAL + p_RECOVERY --> t43 + p_UNRESOLVED --> t43 + t43 --> p_ACTIVE + t43 --> p_TERMINAL + t43 --> p_FRONTIER + t43 --> p_UNRESOLVED + p_RECOVERY --> t44 + p_UNRESOLVED --> t44 + t44 --> p_FRONTIER + p_RECOVERY --> t45 + t45 --> p_DORMANT + t45 --> p_OBSERVED + t45 --> p_ACTIVE + t45 --> p_FRONTIER + t45 --> p_TERMINAL + t45 --> p_ABANDONED + p_RECOVERY --> t46 + t46 --> p_DORMANT + t46 --> p_OBSERVED + t46 --> p_ACTIVE + t46 --> p_FRONTIER + t46 --> p_TERMINAL + t46 --> p_ABANDONED + p_RECOVERY --> t47 + p_UNRESOLVED --> t47 + t47 --> p_OBSERVED + t47 --> p_FRONTIER + t47 --> p_TERMINAL + p_RECOVERY --> t48 + p_UNRESOLVED --> t48 + t48 --> p_DORMANT + t48 --> p_OBSERVED + t48 --> p_ACTIVE + t48 --> p_FRONTIER + t48 --> p_TERMINAL + t48 --> p_ABANDONED + p_DORMANT --> t49 + p_OBSERVED --> t49 + p_ACTIVE --> t49 + p_RECOVERY --> t49 + p_FRONTIER --> t49 + p_UNRESOLVED --> t49 + t49 --> p_OBSERVED + p_DORMANT --> t50 + p_OBSERVED --> t50 + p_ACTIVE --> t50 + p_RECOVERY --> t50 + p_FRONTIER --> t50 + p_UNRESOLVED --> t50 + t50 --> p_OBSERVED + t50 --> p_ACTIVE + t50 --> p_TERMINAL + p_DORMANT --> t51 + p_OBSERVED --> t51 + p_ACTIVE --> t51 + p_RECOVERY --> t51 + p_FRONTIER --> t51 + p_UNRESOLVED --> t51 + t51 --> p_OBSERVED + t51 --> p_UNRESOLVED + p_DORMANT --> t52 + p_OBSERVED --> t52 + p_ACTIVE --> t52 + p_RECOVERY --> t52 + p_FRONTIER --> t52 + p_UNRESOLVED --> t52 + t52 --> p_OBSERVED + p_DORMANT --> t53 + p_OBSERVED --> t53 + p_ACTIVE --> t53 + p_RECOVERY --> t53 + p_FRONTIER --> t53 + p_UNRESOLVED --> t53 + t53 --> p_OBSERVED + p_DORMANT --> t54 + p_OBSERVED --> t54 + p_ACTIVE --> t54 + p_RECOVERY --> t54 + p_FRONTIER --> t54 + p_UNRESOLVED --> t54 + t54 --> p_RECOVERY + p_DORMANT --> t55 + p_OBSERVED --> t55 + p_ACTIVE --> t55 + p_RECOVERY --> t55 + p_FRONTIER --> t55 + p_UNRESOLVED --> t55 + t55 --> p_DORMANT + t55 --> p_FRONTIER + p_DORMANT --> t56 + p_OBSERVED --> t56 + p_ACTIVE --> t56 + p_RECOVERY --> t56 + p_FRONTIER --> t56 + p_UNRESOLVED --> t56 + t56 --> p_OBSERVED + t56 --> p_ACTIVE + t56 --> p_FRONTIER + p_DORMANT --> t57 + p_OBSERVED --> t57 + p_ACTIVE --> t57 + p_RECOVERY --> t57 + p_FRONTIER --> t57 + p_UNRESOLVED --> t57 + t57 --> p_OBSERVED + t57 --> p_ACTIVE + t57 --> p_TERMINAL + p_DORMANT --> t58 + p_OBSERVED --> t58 + p_ACTIVE --> t58 + p_RECOVERY --> t58 + p_FRONTIER --> t58 + p_UNRESOLVED --> t58 + t58 --> p_OBSERVED + t58 --> p_ACTIVE + t58 --> p_TERMINAL + p_DORMANT --> t59 + p_OBSERVED --> t59 + p_ACTIVE --> t59 + p_RECOVERY --> t59 + p_FRONTIER --> t59 + p_UNRESOLVED --> t59 + t59 --> p_OBSERVED + t59 --> p_ACTIVE + t59 --> p_TERMINAL + p_DORMANT --> t60 + p_OBSERVED --> t60 + p_ACTIVE --> t60 + p_RECOVERY --> t60 + p_FRONTIER --> t60 + p_UNRESOLVED --> t60 + t60 --> p_UNRESOLVED + t60 --> p_RECOVERY + p_DORMANT --> t61 + p_OBSERVED --> t61 + p_ACTIVE --> t61 + p_RECOVERY --> t61 + p_FRONTIER --> t61 + p_UNRESOLVED --> t61 + t61 --> p_OBSERVED + t61 --> p_RECOVERY diff --git a/docs/configuration.md b/docs/configuration.md index fce244b..bd372f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,14 @@ it cannot select or replace the trusted primary flow: "version": "1.0.0", "executable": "/absolute/symlink-free/path/security-extension", "sha256": "<64 lowercase hexadecimal characters>", + "manifest": { + "id": "example.security", + "version": "1.0.0", + "protocol_version": 1, + "settings_schema": {"type": "object", "additionalProperties": false}, + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt" + }, "settings": {"profile": "strict"}, "deadline_millis": 5000, "stdout_bytes": 1048576, @@ -71,9 +79,11 @@ it cannot select or replace the trusted primary flow: } ``` -The executable is invoked directly without a shell, receives only the bounded -versioned JSON protocol and fixed locale variables, and is re-hashed before -every invocation. Crossing either output bound cancels the subprocess +The declarative manifest is compiled without starting the executable. Once the +exact configuration and ControlProgram binding is current, the executable is +invoked directly without a shell from a private copy of the exact bytes hashed +for that invocation. It receives only the bounded versioned JSON protocol and +fixed locale variables. Crossing either output bound cancels the subprocess immediately and fails the operation closed. It is a trusted executable boundary, not an OS sandbox. Changing its set, version, executable bytes, settings, or limits changes the diff --git a/release-notes/2026-08-11-programmable-control-program.md b/release-notes/2026-08-11-programmable-control-program.md index 5cb41ca..3bfafc7 100644 --- a/release-notes/2026-08-11-programmable-control-program.md +++ b/release-notes/2026-08-11-programmable-control-program.md @@ -1,3 +1,3 @@ ### Separate Kernel mechanism from delivery policy -Boatstack now compiles one immutable CoreSystem, one explicit primary delivery flow, and optional conservative extensions into a fingerprinted ControlProgram before the Kernel resolves or applies any transition. The default distribution retains StandardFlow behavior, while public SDK contracts can supply another trusted flow and checksum-verified subprocess extensions without modifying Kernel mechanism code. +Boatstack now compiles one immutable CoreSystem, one explicit primary delivery flow, and optional conservative extensions into a fingerprinted ControlProgram before the Kernel resolves or applies any transition. The default distribution retains StandardFlow behavior, while public SDK contracts can supply another trusted flow and checksum-verified subprocess extensions without modifying Kernel mechanism code. Subprocess manifests are declarative, component settings are schema-validated, executable bytes remain identity-bound through invocation, and uncertain external outcomes enter recovery instead of being reported as rolled back.