diff --git a/.github/tests/boatstack_test_support.py b/.github/tests/boatstack_test_support.py new file mode 100644 index 0000000..3e3ed0b --- /dev/null +++ b/.github/tests/boatstack_test_support.py @@ -0,0 +1,30 @@ +"""Shared strict projections for Boatstack repository tests.""" + +from __future__ import annotations + +from collections.abc import Mapping + + +def prescription_cli_arguments( + prescription: Mapping[str, object], correlation: str +) -> tuple[str, ...]: + """Project one complete current prescription into raw apply CLI arguments.""" + arguments = [ + "--correlation", + correlation, + "--prescription-id", + str(prescription["id"]), + "--expected-state-revision", + str(prescription["expected_state_revision"]), + "--expected-program-fingerprint", + str(prescription["expected_program_fingerprint"]), + "--expected-snapshot-fingerprint", + str(prescription["expected_snapshot_fingerprint"]), + "--authority-fingerprint", + str(prescription["authority_fingerprint"]), + ] + for capability in prescription["required_capabilities"]: + arguments.extend(("--required-capability", str(capability))) + for capability in prescription["effective_capabilities"]: + arguments.extend(("--effective-capability", str(capability))) + return tuple(arguments) diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 15ca6f9..256a869 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -11,6 +11,8 @@ import unittest from pathlib import Path +from boatstack_test_support import prescription_cli_arguments + REPO = Path(__file__).resolve().parents[2] RUNTIME = REPO / "boatstack" @@ -100,11 +102,7 @@ def apply_prescribed( correlation = resolved["snapshot"]["invocation"]["correlation_id"] return self.helper_json( "apply", "--transition", transition, *args, - "--correlation", correlation, - "--prescription-id", prescription["id"], - "--expected-state-revision", prescription["expected_state_revision"], - "--expected-program-fingerprint", prescription["expected_program_fingerprint"], - "--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"], + *prescription_cli_arguments(prescription, correlation), cwd=cwd, ) diff --git a/.github/tests/test_prescription_projection.py b/.github/tests/test_prescription_projection.py new file mode 100644 index 0000000..b23846b --- /dev/null +++ b/.github/tests/test_prescription_projection.py @@ -0,0 +1,46 @@ +"""Contract tests for the shared raw-CLI prescription projection.""" + +from __future__ import annotations + +import unittest + +from boatstack_test_support import prescription_cli_arguments + + +class PrescriptionProjectionContract(unittest.TestCase): + def setUp(self) -> None: + self.prescription = { + "id": "prx-example", + "expected_state_revision": 41, + "expected_program_fingerprint": "a" * 64, + "expected_snapshot_fingerprint": "snapshot-example", + "authority_fingerprint": "auth-example", + "required_capabilities": ["command.execute", "repository.write"], + "effective_capabilities": ["command.execute", "repository.write"], + } + + def test_projection_preserves_complete_current_identity(self) -> None: + self.assertEqual( + prescription_cli_arguments(self.prescription, "correlation-example"), + ( + "--correlation", "correlation-example", + "--prescription-id", "prx-example", + "--expected-state-revision", "41", + "--expected-program-fingerprint", "a" * 64, + "--expected-snapshot-fingerprint", "snapshot-example", + "--authority-fingerprint", "auth-example", + "--required-capability", "command.execute", + "--required-capability", "repository.write", + "--effective-capability", "command.execute", + "--effective-capability", "repository.write", + ), + ) + + def test_missing_current_identity_fails_closed(self) -> None: + del self.prescription["authority_fingerprint"] + with self.assertRaises(KeyError): + prescription_cli_arguments(self.prescription, "correlation-example") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index b62077c..d264bac 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -10,6 +10,8 @@ import xml.etree.ElementTree as ET from pathlib import Path +from boatstack_test_support import prescription_cli_arguments + REPO = Path(__file__).resolve().parents[2] RUNTIME = REPO / "boatstack" @@ -97,11 +99,7 @@ def apply_prescribed( correlation = resolved["snapshot"]["invocation"]["correlation_id"] applied = self.run_command( binary, "apply", "--transition", transition, *args, - "--correlation", correlation, - "--prescription-id", prescription["id"], - "--expected-state-revision", prescription["expected_state_revision"], - "--expected-program-fingerprint", prescription["expected_program_fingerprint"], - "--expected-snapshot-fingerprint", prescription["expected_snapshot_fingerprint"], + *prescription_cli_arguments(prescription, correlation), cwd=cwd, env=env, ) return json.loads(applied.stdout) @@ -566,11 +564,16 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.assertEqual(pin["version"], "v0.7.contract-new") self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest()) self.assertNotIn("path", pin) - events = self.run_command( + events = [json.loads(line) for line in self.run_command( launcher, "events", "--repo", repository, "--format", "jsonl", env=env - ).stdout.splitlines() - transitions = {json.loads(line)["transition_id"] for line in events} + ).stdout.splitlines()] + transitions = {event["transition_id"] for event in events} self.assertTrue({"installation.initialize", "engagement.begin", "installation.reconcile-update"}.issubset(transitions)) + for event in events: + self.assertTrue(event["authority_fingerprint"]) + self.assertTrue(event["required_capabilities"]) + self.assertTrue(event["granted_capabilities"]) + self.assertTrue(event["exercised_capabilities"]) def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None: # control-law: accepted-program-delta-atomically-pins-runtime-and-program diff --git a/README.md b/README.md index f8ee895..3c090fa 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ program-qualified transition identity used by complete user-facing Flows. The [prescription transaction boundary](docs/architecture/prescription-transactions.md) defines the exact durable-state and executable-program compare-and-swap contract between resolution and effects. +The [capability and authority boundary](docs/architecture/capability-authority-boundary.md) +defines how programs narrow effect surfaces without granting themselves +authority, including the explicit arbitrary-command frontier. ## Install diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 60bc00b..190024f 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -48,6 +48,9 @@ type commandOptions struct { expectedStateRevision uint64 expectedProgramFingerprint string expectedSnapshotFingerprint string + authorityFingerprint string + requiredCapabilities stringList + effectiveCapabilities stringList idempotencyKey string humanActor string repositoryPolicy bool @@ -252,6 +255,9 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.Uint64Var(&options.expectedStateRevision, "expected-state-revision", 0, "exact durable state revision observed during resolution") flags.StringVar(&options.expectedProgramFingerprint, "expected-program-fingerprint", "", "exact executable control-program fingerprint observed during resolution") flags.StringVar(&options.expectedSnapshotFingerprint, "expected-snapshot-fingerprint", "", "exact admission-relevant snapshot fingerprint observed during resolution") + flags.StringVar(&options.authorityFingerprint, "authority-fingerprint", "", "exact authority projection fingerprint from resolution") + flags.Var(&options.requiredCapabilities, "required-capability", "required capability from resolution (repeatable)") + flags.Var(&options.effectiveCapabilities, "effective-capability", "effective capability from resolution (repeatable)") flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay") flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor") flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration") @@ -448,6 +454,14 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } + requiredCapabilities, err := parseCapabilities("--required-capability", options.requiredCapabilities) + if err != nil { + return surfaces.Request{}, err + } + effectiveCapabilities, err := parseCapabilities("--effective-capability", options.effectiveCapabilities) + if err != nil { + return surfaces.Request{}, err + } flowID := options.flowID if flowID == "" && goal.ID != "" { flowID = "flow-" + goal.ID @@ -460,11 +474,24 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface FlowID: flowID, Goal: goal, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters, Prescription: protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: options.prescriptionID, TransitionID: catalog.TransitionID(options.transitionID), ExpectedStateRevision: options.expectedStateRevision, - ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint}, + ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint, + AuthorityFingerprint: options.authorityFingerprint, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities}, RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, }, nil } +func parseCapabilities(field string, values []string) ([]catalog.Capability, error) { + capabilities := make([]catalog.Capability, len(values)) + for index, value := range values { + capabilities[index] = catalog.Capability(value) + } + normalized, err := catalog.NormalizeCapabilities(field, capabilities) + if err != nil { + return nil, err + } + return normalized, nil +} + func parseParameters(values []string) (protocol.Parameters, error) { parameters := make(protocol.Parameters, 0, len(values)) for _, value := range values { diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index dbaba56..2188c2d 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -4,8 +4,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) @@ -73,3 +76,32 @@ func TestBuildRevisionPrefersReleaseEmbeddedSourceCommit(t *testing.T) { t.Fatalf("build revision = %q", got) } } + +func TestTransitionReceiptCannotBeLoadedAsAuthority(t *testing.T) { + // control-law: historical execution evidence is not a reusable authority token + path := filepath.Join(t.TempDir(), "transition-receipt.json") + if err := os.WriteFile(path, []byte(`{"schema_version":5,"id":"trc-old"}`), 0o600); err != nil { + t.Fatal(err) + } + _, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Goal{}, time.Now().UTC()) + if err == nil { + t.Fatal("transition receipt was accepted as authority") + } +} + +func TestRawCLIReconstructsExactCapabilityPrescription(t *testing.T) { + options := commandOptions{ + transitionID: "installation.update", host: "cli", repository: ".", prescriptionID: "prx-test", + expectedStateRevision: 7, expectedProgramFingerprint: hash([]byte("program")), expectedSnapshotFingerprint: hash([]byte("snapshot")), + authorityFingerprint: "auth-test", + requiredCapabilities: stringList{string(catalog.CapabilityRepositoryWrite), string(catalog.CapabilityCommandExecute)}, + effectiveCapabilities: stringList{string(catalog.CapabilityRepositoryWrite), string(catalog.CapabilityCommandExecute)}, + } + request, err := buildRequest(surfaces.OperationApply, options) + if err != nil { + t.Fatal(err) + } + if request.Prescription.AuthorityFingerprint != options.authorityFingerprint || len(request.Prescription.RequiredCapabilities) != 2 || len(request.Prescription.EffectiveCapabilities) != 2 { + t.Fatalf("CLI lost capability prescription: %#v", request.Prescription) + } +} diff --git a/boatstack/control/control.go b/boatstack/control/control.go index 58c90c8..04dddda 100644 --- a/boatstack/control/control.go +++ b/boatstack/control/control.go @@ -22,6 +22,7 @@ type Transition = catalog.Transition type TransitionID = catalog.TransitionID type EventClass = catalog.EventClass type AuthorityClass = catalog.AuthorityClass +type Capability = catalog.Capability type FacetCondition = catalog.FacetCondition type SelectionClass = catalog.SelectionClass type GoalContract = catalog.GoalContract @@ -50,6 +51,13 @@ const ( AuthorityAutonomy = catalog.AuthorityAutonomy AuthorityProvider = catalog.AuthorityProvider + CapabilityRepositoryWrite = catalog.CapabilityRepositoryWrite + CapabilityCommandExecute = catalog.CapabilityCommandExecute + CapabilityProductMutate = catalog.CapabilityProductMutate + CapabilityPublicationPrepare = catalog.CapabilityPublicationPrepare + CapabilityPublicationPublish = catalog.CapabilityPublicationPublish + CapabilityHumanApprove = catalog.CapabilityHumanApprove + SelectionSystemRecovery = catalog.SelectionSystemRecovery SelectionProgramRecovery = catalog.SelectionProgramRecovery SelectionExtensionRecovery = catalog.SelectionExtensionRecovery @@ -106,7 +114,15 @@ const ( FacetGoal = model.FacetGoal ) -const ProgramSchemaVersion = 1 +const ProgramSchemaVersion = 2 + +func KernelEffectCapabilities(transition Transition) []Capability { + return catalog.KernelEffectCapabilities(transition) +} + +func UnionCapabilities(groups ...[]Capability) []Capability { + return catalog.UnionCapabilities(groups...) +} func KnownCondition(facet FacetName, values ...string) FacetCondition { return FacetCondition{Facet: facet, Statuses: []FactStatus{FactKnown}, Values: append([]string(nil), values...)} @@ -134,9 +150,10 @@ type Extension interface { } type CoreSystemManifest struct { - ID string `json:"id"` - Version string `json:"version"` - Transitions []Transition `json:"transitions"` + ID string `json:"id"` + Version string `json:"version"` + Capabilities []Capability `json:"capabilities"` + Transitions []Transition `json:"transitions"` } type ProgramRuntimeManifest struct { @@ -151,6 +168,7 @@ type ProgramRuntimeManifest struct { OwnedResources []string `json:"owned_resources"` Effects []string `json:"effects"` Verifiers []string `json:"verifiers"` + Capabilities []Capability `json:"capabilities"` RecoveryTransitions []TransitionID `json:"recovery_transitions"` Settings json.RawMessage `json:"settings,omitempty"` ConfigurationSchema json.RawMessage `json:"configuration_schema,omitempty"` @@ -176,6 +194,7 @@ type ExtensionManifest struct { OwnedResources []string `json:"owned_resources,omitempty"` Effects []string `json:"effects,omitempty"` Verifiers []string `json:"verifiers,omitempty"` + Capabilities []Capability `json:"capabilities"` RecoveryTransitions []TransitionID `json:"recovery_transitions,omitempty"` PrivacyClassification string `json:"privacy_classification"` TelemetryClassification string `json:"telemetry_classification"` @@ -336,11 +355,20 @@ func Compile(ctx context.Context, request CompileRequest) (ControlProgram, error transitions := make([]Transition, 0, len(core.Transitions)+len(flow.Transitions)) resources := map[string]string{} - appendComponent := func(items []Transition, origin catalog.TransitionOrigin) error { + appendComponent := func(items []Transition, origin catalog.TransitionOrigin, declared []Capability, runtimeExecution bool) error { + capabilities, capabilityErr := catalog.NormalizeCapabilities(origin.ID+".capabilities", declared) + if capabilityErr != nil || len(capabilities) == 0 { + if capabilityErr != nil { + return capabilityErr + } + return fmt.Errorf("component %q requires a non-empty capability surface", origin.ID) + } for _, item := range items { item = cloneTransition(item) item.Origin = origin item.Owner = origin.ID + item.RuntimeExecution = runtimeExecution + item.DeclaredCapabilities = append([]Capability(nil), capabilities...) 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))) } @@ -354,10 +382,10 @@ func Compile(ctx context.Context, request CompileRequest) (ControlProgram, error } return nil } - if err := appendComponent(core.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginCoreSystem, ID: core.ID, Version: core.Version, ManifestFingerprint: coreFingerprint}); err != nil { + if err := appendComponent(core.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginCoreSystem, ID: core.ID, Version: core.Version, ManifestFingerprint: coreFingerprint}, core.Capabilities, false); err != nil { return ControlProgram{}, err } - if err := appendComponent(flow.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: flow.ID, Version: flow.Version, ManifestFingerprint: flowFingerprint}); err != nil { + if err := appendComponent(flow.Transitions, catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: flow.ID, Version: flow.Version, ManifestFingerprint: flowFingerprint}, flow.Capabilities, flow.RuntimeMode == ProgramRuntimeProtocol); err != nil { return ControlProgram{}, err } @@ -418,6 +446,8 @@ func Compile(ctx context.Context, request CompileRequest) (ControlProgram, error } for index := range manifest.Transitions { item := cloneTransition(manifest.Transitions[index]) + item.RuntimeExecution = true + item.DeclaredCapabilities = append([]Capability(nil), manifest.Capabilities...) 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))) } @@ -520,9 +550,12 @@ func Compile(ctx context.Context, request CompileRequest) (ControlProgram, error } func validateCore(manifest CoreSystemManifest) error { - if !componentID.MatchString(manifest.ID) || manifest.Version == "" || len(manifest.Transitions) == 0 { + if !componentID.MatchString(manifest.ID) || manifest.Version == "" || len(manifest.Transitions) == 0 || len(manifest.Capabilities) == 0 { return fmt.Errorf("CoreSystem requires semantic id, version, and transitions") } + if _, err := catalog.NormalizeCapabilities("CoreSystem "+manifest.ID+" capabilities", manifest.Capabilities); err != nil { + return err + } return nil } @@ -530,10 +563,15 @@ func validateProgramRuntime(manifest ProgramRuntimeManifest) error { if !componentID.MatchString(manifest.ID) || manifest.Version == "" || manifest.ProtocolVersion != ProgramRuntimeProtocolVersion || (manifest.RuntimeMode != ProgramRuntimeNative && manifest.RuntimeMode != ProgramRuntimeProtocol) || len(manifest.Transitions) == 0 || len(manifest.SupportedGoals) == 0 || + len(manifest.Capabilities) == 0 || !validJSONObject(manifest.ConfigurationSchema) || manifest.PrivacyClassification == "" || manifest.TelemetryClassification == "" { return fmt.Errorf("ProgramRuntime requires semantic id, version, configuration schema, goals, and transitions") } + declaredCapabilities, err := catalog.NormalizeCapabilities("ProgramRuntime "+manifest.ID+" capabilities", manifest.Capabilities) + if err != nil { + return err + } if err := validateDeclaredSchema(manifest.ConfigurationSchema, manifest.Settings, "ProgramRuntime "+manifest.ID+" configuration"); err != nil { return err } @@ -575,6 +613,14 @@ func validateProgramRuntime(manifest ProgramRuntimeManifest) error { } transitions := make(map[TransitionID]Transition, len(manifest.Transitions)) for _, transition := range manifest.Transitions { + transition.RuntimeExecution = manifest.RuntimeMode == ProgramRuntimeProtocol + transition.DeclaredCapabilities = declaredCapabilities + if transition.Controllable() && len(transition.RequiredCapabilities) == 0 { + return fmt.Errorf("ProgramRuntime transition %q requires explicit capabilities", transition.ID) + } + if missing := catalog.MissingCapability(catalog.RequiredCapabilities(transition), catalog.NewCapabilitySet(declaredCapabilities...)); missing != "" { + return fmt.Errorf("ProgramRuntime transition %q: CAPABILITY_NOT_DECLARED %q", transition.ID, missing) + } transitions[transition.ID] = transition if manifest.RuntimeMode == ProgramRuntimeProtocol && !strings.HasPrefix(string(transition.ID), manifest.ID+".") { return fmt.Errorf("protocol ProgramRuntime %q transition %q is not namespaced", manifest.ID, transition.ID) @@ -639,6 +685,16 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo if manifest.PrivacyClassification == "" || manifest.TelemetryClassification == "" { return fmt.Errorf("extension %q requires privacy and telemetry classifications", manifest.ID) } + declaredCapabilities, err := catalog.NormalizeCapabilities("extension "+manifest.ID+" capabilities", manifest.Capabilities) + if err != nil || ((len(manifest.Facts) != 0 || len(manifest.Transitions) != 0) && len(declaredCapabilities) == 0) { + if err != nil { + return err + } + return fmt.Errorf("extension %q runtime behavior requires a non-empty capability surface", manifest.ID) + } + if (len(manifest.Facts) != 0 || len(manifest.Transitions) != 0) && !catalog.NewCapabilitySet(declaredCapabilities...)[catalog.CapabilityCommandExecute] { + return fmt.Errorf("extension %q runtime behavior requires command.execute", manifest.ID) + } if !validJSONObject(manifest.SettingsSchema) { return fmt.Errorf("extension %q requires a JSON-object settings schema", manifest.ID) } @@ -718,6 +774,14 @@ func validateExtension(manifest ExtensionManifest, seen, reserved map[string]boo } seenTransitions := make(map[TransitionID]bool, len(manifest.Transitions)) for _, transition := range manifest.Transitions { + transition.RuntimeExecution = true + transition.DeclaredCapabilities = declaredCapabilities + if transition.Controllable() && len(transition.RequiredCapabilities) == 0 { + return fmt.Errorf("extension transition %q requires explicit capabilities", transition.ID) + } + if missing := catalog.MissingCapability(catalog.RequiredCapabilities(transition), catalog.NewCapabilitySet(declaredCapabilities...)); missing != "" { + return fmt.Errorf("extension transition %q: CAPABILITY_NOT_DECLARED %q", transition.ID, missing) + } seenTransitions[transition.ID] = true for _, condition := range transition.TargetConditions { if !strings.HasPrefix(string(condition.Facet), manifest.ID+".") { @@ -917,6 +981,8 @@ func cloneTransition(value Transition) Transition { value.RequiredIdentity = append([]string(nil), value.RequiredIdentity...) value.Authority = append([]catalog.AuthorityClass(nil), value.Authority...) value.AuthorityAll = append([]catalog.AuthorityClass(nil), value.AuthorityAll...) + value.RequiredCapabilities = append([]Capability(nil), value.RequiredCapabilities...) + value.DeclaredCapabilities = append([]Capability(nil), value.DeclaredCapabilities...) value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...) value.OwnedResources = append([]string(nil), value.OwnedResources...) value.LocalEffects = append([]catalog.EffectID(nil), value.LocalEffects...) @@ -941,6 +1007,7 @@ func cloneConditions(values []catalog.FacetCondition) []catalog.FacetCondition { } func cloneCoreManifest(value CoreSystemManifest) CoreSystemManifest { + value.Capabilities = append([]Capability(nil), value.Capabilities...) value.Transitions = cloneTransitions(value.Transitions) return value } @@ -956,6 +1023,7 @@ func cloneRuntimeManifest(value ProgramRuntimeManifest) ProgramRuntimeManifest { value.OwnedResources = append([]string(nil), value.OwnedResources...) value.Effects = append([]string(nil), value.Effects...) value.Verifiers = append([]string(nil), value.Verifiers...) + value.Capabilities = append([]Capability(nil), value.Capabilities...) value.RecoveryTransitions = append([]TransitionID(nil), value.RecoveryTransitions...) value.Settings = append(json.RawMessage(nil), value.Settings...) value.ConfigurationSchema = append(json.RawMessage(nil), value.ConfigurationSchema...) @@ -974,6 +1042,7 @@ func cloneExtensionManifest(value ExtensionManifest) ExtensionManifest { value.OwnedResources = append([]string(nil), value.OwnedResources...) value.Effects = append([]string(nil), value.Effects...) value.Verifiers = append([]string(nil), value.Verifiers...) + value.Capabilities = append([]Capability(nil), value.Capabilities...) value.RecoveryTransitions = append([]TransitionID(nil), value.RecoveryTransitions...) value.Dependencies = append([]string(nil), value.Dependencies...) return value diff --git a/boatstack/control/extension.go b/boatstack/control/extension.go index de1cb4a..1112ab2 100644 --- a/boatstack/control/extension.go +++ b/boatstack/control/extension.go @@ -7,7 +7,7 @@ import ( "time" ) -const ExtensionProtocolVersion = 1 +const ExtensionProtocolVersion = 2 type ExtensionOperation string @@ -29,6 +29,7 @@ type ExtensionRequest struct { CorrelationID string `json:"correlation_id"` RepositoryRoot string `json:"repository_root,omitempty"` TransitionID TransitionID `json:"transition_id,omitempty"` + Capabilities []Capability `json:"capabilities,omitempty"` Snapshot json.RawMessage `json:"snapshot,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` Settings json.RawMessage `json:"settings,omitempty"` diff --git a/boatstack/control/program_manifest.go b/boatstack/control/program_manifest.go index f652ec0..68d8618 100644 --- a/boatstack/control/program_manifest.go +++ b/boatstack/control/program_manifest.go @@ -35,8 +35,9 @@ func (e ProgramError) Error() string { } type ProgramCapabilities struct { - Effects []string `json:"effects"` - Verifiers []string `json:"verifiers"` + Effects []string `json:"effects"` + Verifiers []string `json:"verifiers"` + CapabilitySurface []Capability `json:"capability_surface"` } // ProgramTransition contains only author-controlled executable semantics. @@ -52,6 +53,7 @@ type ProgramTransition struct { RequiredIdentity []string `json:"required_identity"` Authority []AuthorityClass `json:"authority"` AuthorityAll []AuthorityClass `json:"authority_all,omitempty"` + RequiredCapabilities []Capability `json:"required_capabilities"` RequiredEvidence []string `json:"required_evidence"` OwnedResources []string `json:"owned_resources,omitempty"` Effect EffectID `json:"effect,omitempty"` @@ -96,9 +98,10 @@ type ProgramManifest struct { // RuntimeCompatibility is verified runtime evidence. Declaring a capability // does not grant transition authority. type RuntimeCompatibility struct { - Version string - Effects []string - Verifiers []string + Version string + Effects []string + Verifiers []string + Capabilities []Capability } var ( @@ -179,6 +182,20 @@ func ValidateProgram(manifest ProgramManifest, runtime RuntimeCompatibility) (Co if missing := missingCapability(verifiers, runtime.Verifiers); missing != "" { return ControlProgram{}, invalidProgram("capabilities.verifiers", fmt.Sprintf("runtime does not provide %q", missing)) } + declaredCapabilities, err := catalog.NormalizeCapabilities("capabilities.capability_surface", manifest.Capabilities.CapabilitySurface) + if err != nil || len(declaredCapabilities) == 0 { + if err != nil { + return ControlProgram{}, invalidProgram("capabilities.capability_surface", err.Error()) + } + return ControlProgram{}, invalidProgram("capabilities.capability_surface", "must declare a non-empty capability surface") + } + runtimeCapabilities, err := catalog.NormalizeCapabilities("runtime.capabilities", runtime.Capabilities) + if err != nil { + return ControlProgram{}, invalidProgram("runtime.capabilities", err.Error()) + } + if missing := catalog.MissingCapability(declaredCapabilities, catalog.NewCapabilitySet(runtimeCapabilities...)); missing != "" { + return ControlProgram{}, invalidProgram("capabilities.capability_surface", fmt.Sprintf("runtime does not provide %q", missing)) + } normalized := make([]Transition, len(manifest.Transitions)) seen := map[TransitionID]bool{} @@ -187,6 +204,13 @@ func ValidateProgram(manifest ProgramManifest, runtime RuntimeCompatibility) (Co usedResources := map[string]bool{} for index, declared := range manifest.Transitions { source := declared.runtimeTransition() + // Repository-authored programs execute through the protocol runtime. + // This is compiler-owned and cannot be disabled by program input. + source.RuntimeExecution = true + source.DeclaredCapabilities = append([]Capability(nil), declaredCapabilities...) + if source.Controllable() && len(source.RequiredCapabilities) == 0 { + return ControlProgram{}, invalidProgram(fmt.Sprintf("transitions[%d].required_capabilities", index), "must not be empty") + } local := source.ID if !programSemanticID.MatchString(string(local)) || strings.Contains(string(local), "/") { return ControlProgram{}, invalidProgram(fmt.Sprintf("transitions[%d].id", index), "must be a local semantic identifier without '/'") @@ -210,6 +234,9 @@ func ValidateProgram(manifest ProgramManifest, runtime RuntimeCompatibility) (Co if source.Controllable() && !containsDeclaration(effects, string(source.Effect)) { return ControlProgram{}, invalidProgram(fmt.Sprintf("transitions[%d].effect", index), "effect is not declared by the program") } + if missing := catalog.MissingCapability(catalog.RequiredCapabilities(source), catalog.NewCapabilitySet(declaredCapabilities...)); missing != "" { + return ControlProgram{}, invalidProgram(fmt.Sprintf("transitions[%d].required_capabilities", index), fmt.Sprintf("CAPABILITY_NOT_DECLARED %q", missing)) + } if source.Controllable() { usedEffects[string(source.Effect)] = true } @@ -263,7 +290,7 @@ func ValidateProgram(manifest ProgramManifest, runtime RuntimeCompatibility) (Co OwnedResources []string GoalContracts []GoalContract Transitions []Transition - }{manifest.ProgramID, ProgramCapabilities{Effects: effects, Verifiers: verifiers}, resources, goalContracts.All(), canonicalTransitions} + }{manifest.ProgramID, ProgramCapabilities{Effects: effects, Verifiers: verifiers, CapabilitySurface: declaredCapabilities}, resources, goalContracts.All(), canonicalTransitions} fingerprint, err := fingerprint(canonical) if err != nil { return ControlProgram{}, invalidProgram("", "fingerprint canonical program: "+err.Error()) @@ -296,7 +323,8 @@ func (value ProgramTransition) runtimeTransition() Transition { ID: value.ID, Version: value.Version, SelectionClass: value.SelectionClass, Class: value.Class, SourcePhases: value.SourcePhases, TargetPhases: value.TargetPhases, GoalKinds: value.GoalKinds, RequiredIdentity: value.RequiredIdentity, Authority: value.Authority, AuthorityAll: value.AuthorityAll, - RequiredEvidence: value.RequiredEvidence, OwnedResources: value.OwnedResources, Effect: value.Effect, + RequiredCapabilities: value.RequiredCapabilities, + RequiredEvidence: value.RequiredEvidence, OwnedResources: value.OwnedResources, Effect: value.Effect, LocalEffects: value.LocalEffects, ExternalEffects: value.ExternalEffects, Idempotent: value.Idempotent, Parameters: value.Parameters, Prescription: value.Prescription, SourcePredicate: value.SourcePredicate, SourceConditions: value.SourceConditions, AdmissionPredicate: value.AdmissionPredicate, diff --git a/boatstack/control/program_manifest_test.go b/boatstack/control/program_manifest_test.go index dcb6f23..9c8bfcc 100644 --- a/boatstack/control/program_manifest_test.go +++ b/boatstack/control/program_manifest_test.go @@ -23,6 +23,7 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) { equivalent.RequiresRuntime = ">=0.9.0" reverse(equivalent.Capabilities.Effects) reverse(equivalent.Capabilities.Verifiers) + reverse(equivalent.Capabilities.CapabilitySurface) reverse(equivalent.OwnedResources) reverse(equivalent.Transitions) reverse(equivalent.Transitions[0].RequiredIdentity) @@ -63,6 +64,9 @@ func TestProgramManifestCanonicalFingerprintContract(t *testing.T) { "authority": func(value *control.ProgramManifest) { value.Transitions[0].Authority = []control.AuthorityClass{control.AuthorityHuman} }, + "capability-surface": func(value *control.ProgramManifest) { + value.Capabilities.CapabilitySurface = append(value.Capabilities.CapabilitySurface, control.CapabilityHumanApprove) + }, "effect": func(value *control.ProgramManifest) { value.Capabilities.Effects[0] = "alternate.effect" value.Transitions[0].Effect = "alternate.effect" @@ -111,6 +115,9 @@ func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) { if !strings.HasPrefix(string(transition.ID), "first-program/") { t.Fatalf("transition is not program-qualified: %s", transition.ID) } + if !transition.RuntimeExecution { + t.Fatalf("repository-authored transition %s escaped protocol-runtime classification", transition.ID) + } } cases := []struct { @@ -119,7 +126,7 @@ func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) { runtime control.RuntimeCompatibility code control.ProgramErrorCode }{ - {"unsupported-schema", func(value *control.ProgramManifest) { value.SchemaVersion = 2 }, runtimeFixture(), control.ProgramSchemaUnsupported}, + {"unsupported-schema", func(value *control.ProgramManifest) { value.SchemaVersion = control.ProgramSchemaVersion + 1 }, runtimeFixture(), control.ProgramSchemaUnsupported}, {"invalid-schema", func(value *control.ProgramManifest) { value.SchemaVersion = 0 }, runtimeFixture(), control.ProgramInvalid}, {"runtime-too-old", func(value *control.ProgramManifest) { value.RequiresRuntime = ">=2.0.0" }, runtimeFixture(), control.RuntimeTooOld}, {"malformed-runtime", func(value *control.ProgramManifest) { value.RequiresRuntime = "^1" }, runtimeFixture(), control.ProgramInvalid}, @@ -131,6 +138,12 @@ func TestProgramManifestNamespaceAndCompatibilityBoundary(t *testing.T) { {"duplicate-capability", func(value *control.ProgramManifest) { value.Capabilities.Effects = append(value.Capabilities.Effects, value.Capabilities.Effects[0]) }, runtimeFixture(), control.ProgramInvalid}, + {"unknown-authority-capability", func(value *control.ProgramManifest) { + value.Capabilities.CapabilitySurface = []control.Capability{"production.nuke"} + }, runtimeFixture(), control.ProgramInvalid}, + {"under-declared-kernel-effect", func(value *control.ProgramManifest) { + value.Capabilities.CapabilitySurface = []control.Capability{control.CapabilityRepositoryWrite} + }, runtimeFixture(), control.ProgramInvalid}, {"duplicate-condition", func(value *control.ProgramManifest) { value.Transitions[0].SourceConditions = append(value.Transitions[0].SourceConditions, value.Transitions[0].SourceConditions[0]) }, runtimeFixture(), control.ProgramInvalid}, @@ -216,7 +229,7 @@ func programFixture() control.ProgramManifest { recovery := control.ProgramTransition{ ID: "recover", Version: 1, SelectionClass: control.SelectionProgramRecovery, Class: control.EventRecovery, SourcePhases: []control.ProtocolPhase{control.PhaseRecovery}, TargetPhases: []control.ProtocolPhase{control.PhaseActive}, - RequiredIdentity: []string{"repository-id"}, Authority: []control.AuthorityClass{control.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, + RequiredIdentity: []string{"repository-id"}, Authority: []control.AuthorityClass{control.AuthorityRepository}, RequiredCapabilities: []control.Capability{control.CapabilityRepositoryWrite}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"program.state"}, Effect: "program.recover", LocalEffects: []control.EffectID{"program.recover"}, Idempotent: true, Prescription: control.Prescription{Operation: "recover", ExpectedPostcondition: "active"}, SourcePredicate: "recovery-required", SourceConditions: []control.FacetCondition{control.KnownCondition(control.FacetRecovery, "required")}, AdmissionPredicate: "exact-admission", @@ -242,14 +255,17 @@ func programFixture() control.ProgramManifest { advance.Verifier = "program.terminal" return control.ProgramManifest{ SchemaVersion: control.ProgramSchemaVersion, ProgramID: "test-program", ProgramVersion: "1", RequiresRuntime: ">=1.0.0", - Capabilities: control.ProgramCapabilities{Effects: []string{"program.advance", "program.recover"}, Verifiers: []string{"program.current", "program.terminal"}}, + Capabilities: control.ProgramCapabilities{ + Effects: []string{"program.advance", "program.recover"}, Verifiers: []string{"program.current", "program.terminal"}, + CapabilitySurface: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute}, + }, OwnedResources: []string{"program.state"}, GoalContracts: []control.GoalContract{{GoalKind: control.GoalVerified, Conditions: []control.FacetCondition{control.KnownCondition(control.FacetDelivery, "terminal")}}}, Transitions: []control.ProgramTransition{advance, recovery}, } } func runtimeFixture() control.RuntimeCompatibility { - return control.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}} + return control.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}, Capabilities: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute, control.CapabilityHumanApprove}} } func loadManifest(t *testing.T, manifest control.ProgramManifest) control.ControlProgram { diff --git a/boatstack/control/program_runtime.go b/boatstack/control/program_runtime.go index 34398b6..5d5aa34 100644 --- a/boatstack/control/program_runtime.go +++ b/boatstack/control/program_runtime.go @@ -6,7 +6,7 @@ import ( "fmt" ) -const ProgramRuntimeProtocolVersion = 1 +const ProgramRuntimeProtocolVersion = 2 type ProgramRuntimeMode string @@ -36,6 +36,7 @@ type ProgramRuntimeRequest struct { CorrelationID string `json:"correlation_id"` RepositoryRoot string `json:"repository_root,omitempty"` TransitionID TransitionID `json:"transition_id,omitempty"` + Capabilities []Capability `json:"capabilities,omitempty"` Snapshot json.RawMessage `json:"snapshot,omitempty"` Parameters json.RawMessage `json:"parameters,omitempty"` Settings json.RawMessage `json:"settings,omitempty"` @@ -93,7 +94,8 @@ func ValidateProgramRuntimeOperationResponse(operation ProgramRuntimeOperation, // ProgramRuntime is the bounded in-process execution contract for a Control // Program. It receives projections and returns declarations; it never receives a -// mutable Kernel object. +// mutable Kernel object. Capabilities are the exact admitted upper bound; the +// runtime cannot widen them. type ProgramRuntime interface { InvokeProgram(context.Context, ProgramRuntimeRequest) (ProgramRuntimeResponse, error) } diff --git a/boatstack/core/system.go b/boatstack/core/system.go index 36a72c2..1db38c7 100644 --- a/boatstack/core/system.go +++ b/boatstack/core/system.go @@ -30,7 +30,12 @@ func (system) CoreManifest(context.Context) (control.CoreSystemManifest, error) if err != nil { return control.CoreSystemManifest{}, err } - return control.CoreSystemManifest{ID: ID, Version: Version, Transitions: transitions}, nil + var capabilities []control.Capability + for index := range transitions { + transitions[index].RequiredCapabilities = control.KernelEffectCapabilities(transitions[index]) + capabilities = control.UnionCapabilities(capabilities, transitions[index].RequiredCapabilities) + } + return control.CoreSystemManifest{ID: ID, Version: Version, Capabilities: capabilities, Transitions: transitions}, nil } func decodeTransitions() ([]control.Transition, error) { diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go index bb11398..7b5de3f 100644 --- a/boatstack/distribution/standard_test.go +++ b/boatstack/distribution/standard_test.go @@ -47,6 +47,7 @@ func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { manifest, err := json.Marshal(control.ExtensionManifest{ ID: id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{id + ".present"}, + Capabilities: []control.Capability{control.CapabilityCommandExecute}, PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", }) if err != nil { diff --git a/boatstack/extension/releasenote/releasenote.go b/boatstack/extension/releasenote/releasenote.go index 61f751e..bac4be0 100644 --- a/boatstack/extension/releasenote/releasenote.go +++ b/boatstack/extension/releasenote/releasenote.go @@ -59,6 +59,7 @@ func (Extension) ExtensionManifest(context.Context) (control.ExtensionManifest, Reversibility: catalog.Reversible, TerminalEffect: "conjunctive-goal-obligation", PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "local-verification", } + transition.RequiredCapabilities = []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute} constraint := func(goal model.GoalKind) control.GoalConstraint { return control.GoalConstraint{GoalKind: goal, Conditions: []catalog.FacetCondition{known(model.FacetName(FactID), "verified", "not-required")}} } @@ -66,6 +67,7 @@ func (Extension) ExtensionManifest(context.Context) (control.ExtensionManifest, ID: ID, Version: Version, ProtocolVersion: control.ExtensionProtocolVersion, SettingsSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`), Facts: []string{FactID}, Transitions: []control.Transition{transition}, + Capabilities: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute}, GoalConstraints: []control.GoalConstraint{constraint(model.GoalOpenPR), constraint(model.GoalMerged)}, OwnedResources: []string{Resource}, Effects: []string{Effect}, Verifiers: []string{Verifier}, PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", diff --git a/boatstack/extension/releasenote/releasenote_test.go b/boatstack/extension/releasenote/releasenote_test.go index 001c642..126934e 100644 --- a/boatstack/extension/releasenote/releasenote_test.go +++ b/boatstack/extension/releasenote/releasenote_test.go @@ -9,6 +9,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/control" "github.com/operatorstack/boatstack/boatstack/internal/effects" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) const testProgram = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -26,6 +27,7 @@ func TestReferenceExtensionPlansVerifiesAndInvalidatesNamespacedEvidence(t *test request := control.ExtensionRequest{ ProtocolVersion: control.ExtensionProtocolVersion, ExtensionID: ID, ExtensionVersion: Version, ProgramFingerprint: testProgram, CorrelationID: "reference", RepositoryRoot: repository, + Capabilities: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute}, } request.Operation = control.ExtensionObserveOperation observed, err := runtime.Invoke(context.Background(), request) @@ -40,7 +42,12 @@ func TestReferenceExtensionPlansVerifiesAndInvalidatesNamespacedEvidence(t *test if err != nil { t.Fatal(err) } - prepared, err := effects.NewExtensionLocalPrepared(repository, ID, planned.Writes) + manifest, err := runtime.ExtensionManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + admission := protocol.Admission{EffectiveCapabilities: append([]control.Capability(nil), request.Capabilities...)} + prepared, err := effects.NewExtensionLocalPrepared(repository, ID, planned.Writes, admission, manifest.Transitions[0]) if err != nil { t.Fatal(err) } diff --git a/boatstack/extension/subprocess/subprocess_test.go b/boatstack/extension/subprocess/subprocess_test.go index ecbd84c..23195c8 100644 --- a/boatstack/extension/subprocess/subprocess_test.go +++ b/boatstack/extension/subprocess/subprocess_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -22,6 +23,7 @@ func fixtureManifest(t *testing.T, id string) json.RawMessage { raw, err := json.Marshal(control.ExtensionManifest{ ID: id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{id + ".present"}, + Capabilities: []control.Capability{control.CapabilityCommandExecute}, PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", }) if err != nil { @@ -58,6 +60,15 @@ func fixtureExtension(t *testing.T) *Extension { return extension } +func fixtureObserveRequest(correlationID string) control.ExtensionRequest { + return control.ExtensionRequest{ + ProtocolVersion: control.ExtensionProtocolVersion, + Operation: control.ExtensionObserveOperation, + ExtensionID: "fixture.echo", ExtensionVersion: "1.0.0", CorrelationID: correlationID, + Capabilities: []control.Capability{control.CapabilityCommandExecute}, + } +} + func pythonFixture(t *testing.T, mutate func(string) string) *Extension { t.Helper() if runtime.GOOS == "windows" { @@ -104,9 +115,7 @@ func TestPythonFixtureImplementsStrictLanguageNeutralProtocol(t *testing.T) { 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", - }) + response, err := extension.Invoke(context.Background(), fixtureObserveRequest("observe-fixture")) if err != nil { t.Fatal(err) } @@ -132,7 +141,7 @@ func TestExecutableDriftFailsBeforeInvocation(t *testing.T) { 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"}) + _, err = extension.Invoke(context.Background(), fixtureObserveRequest("drift")) if err == nil || !strings.Contains(err.Error(), "fingerprint drifted") { t.Fatalf("drift error = %v", err) } @@ -154,9 +163,7 @@ func TestInvocationExecutesTheExactVerifiedBytesAfterPathReplacement(t *testing. 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", - }) + response, err := extension.Invoke(context.Background(), fixtureObserveRequest("atomic-replacement")) if err != nil { t.Fatal(err) } @@ -220,7 +227,7 @@ func TestExecutableCannotBecomeASymlinkAfterConstruction(t *testing.T) { 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"}) + _, err = extension.Invoke(context.Background(), fixtureObserveRequest("symlink-drift")) if err == nil || !strings.Contains(err.Error(), "drifted to a symlink") { t.Fatalf("symlink drift error = %v", err) } @@ -248,7 +255,7 @@ func TestDeadlineAndOutputBoundsFailClosed(t *testing.T) { 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}) + _, err = extension.Invoke(context.Background(), fixtureObserveRequest(fixture.name)) if err == nil || !strings.Contains(err.Error(), fixture.want) { t.Fatalf("error = %v, want %q", err, fixture.want) } @@ -292,12 +299,25 @@ func TestStrictJSONRejectsUnknownTrailingAndWrongOperationPayloads(t *testing.T) 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, - }) + _, err := extension.Invoke(context.Background(), fixtureObserveRequest(name)) if err == nil { t.Fatalf("%s response was accepted", name) } }) } } + +func TestCommandExecuteDocumentsArbitraryProcessFrontier(t *testing.T) { + // frontier: command.execute permits arbitrary host effects; finer kernel + // capabilities govern only brokered Boatstack effects, not subprocess code. + marker := filepath.Join(exactPath(t, t.TempDir()), "ambient-write") + extension := pythonFixture(t, func(source string) string { + return strings.Replace(source, "request = json.load(sys.stdin)", "open("+strconv.Quote(marker)+", \"w\").write(\"ambient\")\nrequest = json.load(sys.stdin)", 1) + }) + if _, err := extension.Invoke(context.Background(), fixtureObserveRequest("command-frontier")); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(marker); err != nil || string(raw) != "ambient" { + t.Fatalf("arbitrary process frontier was not exercised: value=%q error=%v", raw, err) + } +} diff --git a/boatstack/extension/subprocess/testdata/reference_extension.py b/boatstack/extension/subprocess/testdata/reference_extension.py index 41943bd..dc37558 100755 --- a/boatstack/extension/subprocess/testdata/reference_extension.py +++ b/boatstack/extension/subprocess/testdata/reference_extension.py @@ -6,7 +6,7 @@ request = json.load(sys.stdin) operation = request["operation"] response = { - "protocol_version": 1, + "protocol_version": 2, "operation": operation, "extension_id": request["extension_id"], "extension_version": request["extension_version"], @@ -17,9 +17,10 @@ response["manifest"] = { "id": "fixture.echo", "version": "1.0.0", - "protocol_version": 1, + "protocol_version": 2, "settings_schema": {"type": "object"}, "facts": ["fixture.echo.present"], + "capabilities": ["command.execute"], "privacy_classification": "metadata-only", "telemetry_classification": "transition-receipt", } diff --git a/boatstack/flow/standard/standard.go b/boatstack/flow/standard/standard.go index 5a0489c..f566ef8 100644 --- a/boatstack/flow/standard/standard.go +++ b/boatstack/flow/standard/standard.go @@ -32,6 +32,11 @@ func (definition) RuntimeManifest(context.Context) (control.ProgramRuntimeManife return control.ProgramRuntimeManifest{}, err } resources, effects, verifiers, recoveries := declarations(transitions) + capabilities := []control.Capability{control.CapabilityHumanApprove} + for index := range transitions { + transitions[index].RequiredCapabilities = control.KernelEffectCapabilities(transitions[index]) + capabilities = control.UnionCapabilities(capabilities, transitions[index].RequiredCapabilities) + } return control.ProgramRuntimeManifest{ ID: ID, Version: Version, ProtocolVersion: control.ProgramRuntimeProtocolVersion, RuntimeMode: control.ProgramRuntimeNative, SupportedGoals: []control.GoalKind{ @@ -60,7 +65,7 @@ func (definition) RuntimeManifest(context.Context) (control.ProgramRuntimeManife 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, + Transitions: transitions, OwnedResources: resources, Effects: effects, Verifiers: verifiers, Capabilities: capabilities, RecoveryTransitions: recoveries, Settings: json.RawMessage(`{}`), ConfigurationSchema: json.RawMessage(`{"type":"object"}`), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", }, nil diff --git a/boatstack/internal/effects/command_boundary.go b/boatstack/internal/effects/command_boundary.go index fa0bfd8..bdd8581 100644 --- a/boatstack/internal/effects/command_boundary.go +++ b/boatstack/internal/effects/command_boundary.go @@ -54,6 +54,9 @@ type pullRequestObservation struct { } func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state *durable.State) error { + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return err + } switch transition.ID { case "publication.observe", "publication.reconcile": publicationID, _ := admission.Parameters.Get("publication_id") @@ -124,6 +127,9 @@ func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protoc func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state durable.State) (ports.EffectResult, error) { settled := ports.EffectResult{Settlement: ports.EffectSettled} + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return settled, err + } switch transition.ID { case "gate.build.record", "gate.test.record": raw, err := os.ReadFile(layout.ConfigPath) diff --git a/boatstack/internal/effects/command_boundary_test.go b/boatstack/internal/effects/command_boundary_test.go index 505146c..e41f3d3 100644 --- a/boatstack/internal/effects/command_boundary_test.go +++ b/boatstack/internal/effects/command_boundary_test.go @@ -10,6 +10,7 @@ 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" @@ -51,6 +52,11 @@ func writeBoundaryConfig(t *testing.T, command string) ports.ControllerLayout { return ports.ControllerLayout{RepositoryRoot: repository, ConfigPath: path} } +func boundaryAdmission(transition catalog.Transition) protocol.Admission { + required := catalog.RequiredCapabilities(transition) + return protocol.Admission{RequiredCapabilities: required, EffectiveCapabilities: required} +} + func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) { runner := &boundaryRunner{err: errors.New("exit status 1")} boundary, err := NewNativeBoundaryWithRunner(runner) @@ -58,7 +64,7 @@ func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) { t.Fatal(err) } transition, _ := testprogram.StandardRegistry().Lookup("gate.build.record") - _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "go test ./..."), durable.State{}) + _, err = boundary.Execute(context.Background(), boundaryAdmission(transition), 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) } @@ -74,7 +80,7 @@ func TestConfiguredBuildCommandCannotCrossConstitutionalGuard(t *testing.T) { t.Fatal(err) } transition, _ := testprogram.StandardRegistry().Lookup("gate.build.record") - _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "git reset --hard HEAD~1"), durable.State{}) + _, err = boundary.Execute(context.Background(), boundaryAdmission(transition), 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) } @@ -91,6 +97,8 @@ func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) { Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", Parameters: protocol.Parameters{{Name: "publication_id", Value: "-dangerous"}}, } + admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) + admission.EffectiveCapabilities = admission.RequiredCapabilities state := durable.State{} layout := writeBoundaryConfig(t, "go test ./...") if err := boundary.PrepareObservation(context.Background(), admission, transition, layout, &state); err != nil { @@ -113,6 +121,8 @@ func TestPublicationObservationRejectsUnrelatedProviderIdentity(t *testing.T) { Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", Parameters: protocol.Parameters{{Name: "publication_id", Value: "8"}}, } + admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) + admission.EffectiveCapabilities = admission.RequiredCapabilities state := durable.State{} if err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &state); err != nil { t.Fatal(err) @@ -169,6 +179,8 @@ func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) { {Name: "body_sha256", Value: sha256Bytes([]byte("reviewed"))}, }, } + admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) + admission.EffectiveCapabilities = admission.RequiredCapabilities state := durable.State{PublicationID: "7"} if _, err := boundary.Execute(context.Background(), admission, transition, layout, state); err == nil { t.Fatal("publication correction accepted drifted body bytes") diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go index 3c6ee76..5782f65 100644 --- a/boatstack/internal/effects/driver.go +++ b/boatstack/internal/effects/driver.go @@ -52,6 +52,9 @@ func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boun } func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return nil, err + } if len(d.resourceOwnership) != 0 { for _, resource := range transition.OwnedResources { if owner := d.resourceOwnership[resource]; owner == "" || owner != transition.Owner { @@ -67,7 +70,18 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, fmt.Errorf("effect invocation identity changed before preparation") } if transition.ID == "recovery.resume" || transition.ID == "recovery.rollback" || transition.ID == "workspace.reconcile" { - return d.prepareRecoveryReplay(ctx, layout, admission, transition) + prepared, prepareErr := d.prepareRecoveryReplay(ctx, layout, admission, transition) + if prepareErr != nil { + return nil, prepareErr + } + effect, ok := prepared.(*preparedEffect) + if !ok { + return nil, fmt.Errorf("recovery did not return a Boatstack prepared effect") + } + if err := bindPreparedCapabilities(effect, admission, transition); err != nil { + return nil, err + } + return effect, nil } state, err := loadDurableState(layout.StatePath, admission.Invocation, d.clock.Now()) if err != nil { @@ -258,6 +272,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans mutations[index].Owner = transition.Owner } prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation} + if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { + return nil, err + } if requiresCommandBoundary(transition.ID) { prepared.boundary = func(boundaryContext context.Context) (ports.EffectResult, error) { return d.boundary.Execute(boundaryContext, admission, transition, layout, state) diff --git a/boatstack/internal/effects/extensions.go b/boatstack/internal/effects/extensions.go index 30c6dab..4c06411 100644 --- a/boatstack/internal/effects/extensions.go +++ b/boatstack/internal/effects/extensions.go @@ -10,23 +10,25 @@ import ( "strings" "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) // 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) +func NewExtensionLocalPrepared(repositoryRoot, extensionID string, writes []control.ResourceWrite, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + return newNamespacedLocalPrepared(repositoryRoot, filepath.Join("extensions", extensionID), extensionID, "extension", writes, admission, transition) } // NewFlowLocalPrepared constrains a protocol-backed program runtime 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, "program runtime", writes) +func NewFlowLocalPrepared(repositoryRoot, flowID string, writes []control.ResourceWrite, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + return newNamespacedLocalPrepared(repositoryRoot, filepath.Join("flows", flowID), flowID, "program runtime", writes, admission, transition) } -func newNamespacedLocalPrepared(repositoryRoot, namespace, owner, kind string, writes []control.ResourceWrite) (ports.PreparedEffect, error) { +func newNamespacedLocalPrepared(repositoryRoot, namespace, owner, kind string, writes []control.ResourceWrite, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { if len(writes) == 0 { return nil, fmt.Errorf("%s %q planned no local writes", kind, owner) } @@ -83,7 +85,11 @@ func newNamespacedLocalPrepared(repositoryRoot, namespace, owner, kind string, w mutation.Resource, mutation.Owner = write.Resource, owner mutations = append(mutations, mutation) } - return &preparedEffect{mutations: mutations}, nil + prepared := &preparedEffect{mutations: mutations} + if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { + return nil, err + } + return prepared, nil } func rejectSymlinkComponents(repositoryRoot, target string) error { @@ -108,9 +114,13 @@ func rejectSymlinkComponents(repositoryRoot, target string) error { return nil } -func NewExtensionExternalPrepared(execute func(context.Context) (ports.EffectResult, error)) (ports.PreparedEffect, error) { +func NewExtensionExternalPrepared(execute func(context.Context) (ports.EffectResult, error), admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { if execute == nil { return nil, fmt.Errorf("external extension effect requires an executor") } - return &preparedEffect{boundary: execute}, nil + prepared := &preparedEffect{boundary: execute} + if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { + return nil, err + } + return prepared, nil } diff --git a/boatstack/internal/effects/extensions_test.go b/boatstack/internal/effects/extensions_test.go index 2132b84..78bceaa 100644 --- a/boatstack/internal/effects/extensions_test.go +++ b/boatstack/internal/effects/extensions_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) func TestNamespacedExtensionWriteRejectsSymlinkEscapeWithoutMutation(t *testing.T) { @@ -27,9 +29,11 @@ func TestNamespacedExtensionWriteRejectsSymlinkEscapeWithoutMutation(t *testing. } content := []byte("must-not-escape") digest := sha256.Sum256(content) + transition := catalog.Transition{ID: "example.guard/write", Effect: "example.guard.write", Class: catalog.EventOwnedLocal, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}} + admission := protocol.Admission{EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}} _, err := NewExtensionLocalPrepared(repository, "example.guard", []control.ResourceWrite{{ Resource: "example.guard.evidence", Path: filepath.Join(link, "evidence.json"), Content: content, SHA256: hex.EncodeToString(digest[:]), - }}) + }}, admission, transition) if err == nil { t.Fatal("symlink escape was accepted") } diff --git a/boatstack/internal/effects/host_skills_test.go b/boatstack/internal/effects/host_skills_test.go index 5890a46..ca3ca4f 100644 --- a/boatstack/internal/effects/host_skills_test.go +++ b/boatstack/internal/effects/host_skills_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" ) @@ -190,7 +191,7 @@ func TestHostSkillProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { func applyMutationsForTest(t *testing.T, mutations []ports.ResourceMutation) { t.Helper() - prepared := &preparedEffect{mutations: mutations} + prepared := &preparedEffect{requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, mutations: mutations} if _, err := prepared.Execute(context.Background()); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go index 92d56f6..230a7b4 100644 --- a/boatstack/internal/effects/integration_test.go +++ b/boatstack/internal/effects/integration_test.go @@ -502,10 +502,13 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } - apply("installation.initialize", authority(catalog.AuthorityHuman), protocol.Parameters{ + initialized := apply("installation.initialize", authority(catalog.AuthorityHuman), protocol.Parameters{ {Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) + if initialized.Receipt == nil || initialized.Receipt.AuthorityFingerprint == "" || len(initialized.Receipt.AuthoritySources) != 1 || len(initialized.Receipt.RequiredCapabilities) == 0 || len(initialized.Receipt.GrantedCapabilities) == 0 || len(initialized.Receipt.ExercisedCapabilities) == 0 { + t.Fatalf("receipt lost capability or authority provenance: %#v", initialized.Receipt) + } apply("goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}}) apply("engagement.begin", authority(catalog.AuthorityRepository), nil) planPath := filepath.Join(t.TempDir(), "plan.md") diff --git a/boatstack/internal/effects/prepared.go b/boatstack/internal/effects/prepared.go index 93bca00..8f426f5 100644 --- a/boatstack/internal/effects/prepared.go +++ b/boatstack/internal/effects/prepared.go @@ -7,18 +7,22 @@ import ( "os" "sort" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) type boundaryCall func(context.Context) (ports.EffectResult, error) type preparedEffect struct { - mutations []ports.ResourceMutation - boundary boundaryCall - verifyInvocation *model.InvocationContext - applied []ports.ResourceMutation - externalSettled bool + mutations []ports.ResourceMutation + boundary boundaryCall + verifyInvocation *model.InvocationContext + applied []ports.ResourceMutation + externalSettled bool + requiredCapabilities []catalog.Capability + effectiveCapabilities []catalog.Capability } func (p *preparedEffect) Manifest() []ports.ResourceMutation { @@ -36,6 +40,12 @@ func (p *preparedEffect) VerificationInvocation() (model.InvocationContext, bool func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error) { result := ports.EffectResult{Settlement: ports.EffectSettled} + if len(p.requiredCapabilities) == 0 { + return result, fmt.Errorf("effect boundary has no kernel capability classification") + } + if missing := catalog.MissingCapability(p.requiredCapabilities, catalog.NewCapabilitySet(p.effectiveCapabilities...)); missing != "" { + return result, fmt.Errorf("EFFECT_CAPABILITY_DENIED %q at prepared effect boundary", missing) + } if p.boundary != nil { var err error result, err = p.boundary(ctx) @@ -71,6 +81,15 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error return result, nil } +func bindPreparedCapabilities(effect *preparedEffect, admission protocol.Admission, transition catalog.Transition) error { + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return err + } + effect.requiredCapabilities = catalog.RequiredCapabilities(transition) + effect.effectiveCapabilities = append([]catalog.Capability(nil), admission.EffectiveCapabilities...) + return nil +} + func (p *preparedEffect) Rollback(context.Context) error { var rollbackErrors []error for index := len(p.applied) - 1; index >= 0; index-- { diff --git a/boatstack/internal/effects/prepared_test.go b/boatstack/internal/effects/prepared_test.go index 90a1efd..2a0401c 100644 --- a/boatstack/internal/effects/prepared_test.go +++ b/boatstack/internal/effects/prepared_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" ) @@ -24,7 +25,7 @@ func TestPreparedEffectNeverAcceptsMixedEpochAtWriteBoundary(t *testing.T) { if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil { t.Fatal(err) } - prepared := &preparedEffect{mutations: []ports.ResourceMutation{ + prepared := &preparedEffect{requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, mutations: []ports.ResourceMutation{ {Path: first, Prior: []byte("prior-first"), PriorExists: true, Target: []byte("target-first"), Mode: 0o600}, {Path: filepath.Join(blocker, "second.json"), Target: []byte("target-second"), Mode: 0o600}, {Path: authoritative, Prior: []byte("prior-state"), PriorExists: true, Target: []byte("target-state"), Mode: 0o600, InstallLast: true}, @@ -53,7 +54,7 @@ func TestPreparedEffectRollsBackManagedLauncherSymlink(t *testing.T) { if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil { t.Fatal(err) } - prepared := &preparedEffect{mutations: []ports.ResourceMutation{ + prepared := &preparedEffect{requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, mutations: []ports.ResourceMutation{ {Path: launcher, PriorExists: true, PriorLink: "boatstack-old", TargetLink: "boatstack-new", Mode: 0o700}, {Path: filepath.Join(blocker, "state.json"), Target: []byte("new-state"), Mode: 0o600, InstallLast: true}, }} @@ -71,3 +72,28 @@ func TestPreparedEffectRollsBackManagedLauncherSymlink(t *testing.T) { t.Fatalf("launcher rollback target = %q, want boatstack-old", target) } } + +func TestPreparedEffectRefusesMissingKernelCapabilityBeforeAnyEffect(t *testing.T) { + // control-law: a trusted helper cannot use ambient authority for its caller + root := t.TempDir() + target := filepath.Join(root, "must-not-exist") + boundaryCalls := 0 + prepared := &preparedEffect{ + requiredCapabilities: []catalog.Capability{catalog.CapabilityPublicationPublish}, + effectiveCapabilities: []catalog.Capability{catalog.CapabilityPublicationPrepare}, + boundary: func(context.Context) (ports.EffectResult, error) { + boundaryCalls++ + return ports.EffectResult{Settlement: ports.EffectSettled}, nil + }, + mutations: []ports.ResourceMutation{{Path: target, Target: []byte("changed"), Mode: 0o600}}, + } + if _, err := prepared.Execute(context.Background()); err == nil { + t.Fatal("publication.prepare crossed a publication.publish effect boundary") + } + if boundaryCalls != 0 { + t.Fatalf("trusted helper ran %d privileged calls", boundaryCalls) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("denied effect mutated repository: %v", err) + } +} diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go index a9dbdd9..66fab8e 100644 --- a/boatstack/internal/effects/receipts.go +++ b/boatstack/internal/effects/receipts.go @@ -9,6 +9,7 @@ import ( "path/filepath" "time" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" @@ -131,26 +132,30 @@ func (s *ReceiptStore) FindByIdempotency(ctx context.Context, invocation model.I } type processEvent struct { - SchemaVersion int `json:"schema_version"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - Timestamp time.Time `json:"timestamp"` - GoalID string `json:"goal_id"` - GoalScope string `json:"goal_scope,omitempty"` - GoalStatus string `json:"goal_status,omitempty"` - TransitionID string `json:"transition_id"` - ProgramFingerprint string `json:"program_fingerprint"` - PrescriptionID string `json:"prescription_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultingStateRevision uint64 `json:"resulting_state_revision"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - Outcome string `json:"outcome"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - AuthorityClasses []string `json:"authority_classes,omitempty"` - Recovery string `json:"recovery,omitempty"` - Terminal string `json:"terminal"` - FailureClass string `json:"failure_class,omitempty"` + SchemaVersion int `json:"schema_version"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + Timestamp time.Time `json:"timestamp"` + GoalID string `json:"goal_id"` + GoalScope string `json:"goal_scope,omitempty"` + GoalStatus string `json:"goal_status,omitempty"` + TransitionID string `json:"transition_id"` + ProgramFingerprint string `json:"program_fingerprint"` + PrescriptionID string `json:"prescription_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultingStateRevision uint64 `json:"resulting_state_revision"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + Outcome string `json:"outcome"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + AuthorityClasses []string `json:"authority_classes,omitempty"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities"` + Recovery string `json:"recovery,omitempty"` + Terminal string `json:"terminal"` + FailureClass string `json:"failure_class,omitempty"` } func appendLine(path string, value any, mode os.FileMode) error { @@ -185,13 +190,17 @@ func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionRe return err } event := processEvent{ - SchemaVersion: 2, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, + SchemaVersion: 3, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, GoalScope: string(receipt.GoalScope), GoalStatus: string(receipt.GoalStatus), TransitionID: string(receipt.TransitionID), ProgramFingerprint: receipt.ProgramFingerprint, PrescriptionID: receipt.PrescriptionID, PriorStateRevision: receipt.PriorStateRevision, ResultingStateRevision: receipt.ResultingStateRevision, 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, + AuthorityFingerprint: receipt.AuthorityFingerprint, + RequiredCapabilities: append([]catalog.Capability(nil), receipt.RequiredCapabilities...), + GrantedCapabilities: append([]catalog.Capability(nil), receipt.GrantedCapabilities...), + ExercisedCapabilities: append([]catalog.Capability(nil), receipt.ExercisedCapabilities...), } // Telemetry is passive and never changes transition success. _ = appendLine(layout.EventPath, event, 0o600) diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go index 7ae92a2..a58714d 100644 --- a/boatstack/internal/effects/recovery_test.go +++ b/boatstack/internal/effects/recovery_test.go @@ -117,7 +117,11 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_version", Value: runtimeIdentity.Version}, {Name: "runtime_sha256", Value: sha256Bytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint}, } - prescription, err := protocol.NewPrescription(initial, transition) + capabilities, err := protocol.ProjectCapabilities(initial, transition, authority, clock.Now()) + if err != nil { + t.Fatal(err) + } + prescription, err := protocol.NewPrescription(initial, transition, capabilities) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/effects/revision.go b/boatstack/internal/effects/revision.go index c802e56..9184888 100644 --- a/boatstack/internal/effects/revision.go +++ b/boatstack/internal/effects/revision.go @@ -23,6 +23,9 @@ func BindStateRevision(ctx context.Context, prepared ports.PreparedEffect, resol if !ok { return nil, fmt.Errorf("state revision binding requires a Boatstack prepared effect") } + if err := bindPreparedCapabilities(effect, admission, transition); err != nil { + return nil, err + } layout, invocation, err := resolver.ResolveLayout(ctx, admission.Invocation) if err != nil { return nil, err diff --git a/boatstack/internal/kernel/catalog/capability.go b/boatstack/internal/kernel/catalog/capability.go new file mode 100644 index 0000000..b05fb9a --- /dev/null +++ b/boatstack/internal/kernel/catalog/capability.go @@ -0,0 +1,156 @@ +package catalog + +import ( + "fmt" + "sort" + "strings" +) + +// Capability names a kernel-enforced class of effect. A declaration narrows +// what a program may exercise; it never grants authority. +type Capability string + +const ( + CapabilityRepositoryWrite Capability = "repository.write" + CapabilityCommandExecute Capability = "command.execute" + CapabilityProductMutate Capability = "product.mutate" + CapabilityPublicationPrepare Capability = "publication.prepare" + CapabilityPublicationPublish Capability = "publication.publish" + CapabilityHumanApprove Capability = "human.approve" +) + +func (c Capability) Valid() bool { + switch c { + case CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, + CapabilityPublicationPrepare, CapabilityPublicationPublish, CapabilityHumanApprove: + return true + default: + return false + } +} + +type CapabilitySet map[Capability]bool + +func NewCapabilitySet(values ...Capability) CapabilitySet { + set := CapabilitySet{} + for _, value := range values { + if value.Valid() { + set[value] = true + } + } + return set +} + +func (s CapabilitySet) ContainsAll(values []Capability) bool { + for _, value := range values { + if !s[value] { + return false + } + } + return true +} + +func (s CapabilitySet) Sorted() []Capability { + result := make([]Capability, 0, len(s)) + for value, present := range s { + if present { + result = append(result, value) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func NormalizeCapabilities(field string, values []Capability) ([]Capability, error) { + seen := CapabilitySet{} + for _, value := range values { + if !value.Valid() { + return nil, fmt.Errorf("%s contains unknown capability %q", field, value) + } + if seen[value] { + return nil, fmt.Errorf("%s duplicates capability %q", field, value) + } + seen[value] = true + } + return seen.Sorted(), nil +} + +func UnionCapabilities(groups ...[]Capability) []Capability { + set := CapabilitySet{} + for _, group := range groups { + for _, value := range group { + if value.Valid() { + set[value] = true + } + } + } + return set.Sorted() +} + +func MissingCapability(required []Capability, available CapabilitySet) Capability { + for _, value := range required { + if !available[value] { + return value + } + } + return "" +} + +// AuthorityCapabilities is kernel-owned. Receipt classes are evidence sources; +// repository programs cannot alter this mapping. +func AuthorityCapabilities(authority AuthoritySet) CapabilitySet { + granted := CapabilitySet{} + grant := func(values ...Capability) { + for _, value := range values { + granted[value] = true + } + } + if authority[AuthorityRepository] { + grant(CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPrepare) + } + if authority[AuthorityHuman] { + grant(CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPrepare, CapabilityHumanApprove) + } + if authority[AuthorityAutonomy] { + grant(CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPrepare) + } + if authority[AuthorityProvider] { + grant(CapabilityPublicationPublish) + } + return granted +} + +// KernelEffectCapabilities independently classifies the minimum capabilities +// of a compiled effect. It never trusts RequiredCapabilities to weaken this +// result. RuntimeExecution is compiler-derived, not repository-authored. +func KernelEffectCapabilities(transition Transition) []Capability { + if !transition.Controllable() { + return nil + } + required := NewCapabilitySet(CapabilityRepositoryWrite) + if transition.RuntimeExecution { + required[CapabilityCommandExecute] = true + } + id := string(transition.Effect) + switch id { + case "gate.build.record", "gate.test.record", "workspace.cut", "workspace.sync", "workspace.cleanup", "workspace.reap", + "publication.observe", "publication.reconcile", "publication.execute", "publication.correct": + required[CapabilityCommandExecute] = true + } + if strings.HasPrefix(id, "goal.") || strings.HasPrefix(id, "plan.") || strings.HasPrefix(id, "workspace.") || + strings.HasPrefix(id, "gate.") || strings.HasPrefix(id, "evidence.") || strings.HasPrefix(id, "delivery.") || + strings.HasPrefix(id, "publication.") { + required[CapabilityProductMutate] = true + } + if id == "publication.preview" { + required[CapabilityPublicationPrepare] = true + } + if id == "publication.execute" || id == "publication.correct" { + required[CapabilityPublicationPublish] = true + } + return required.Sorted() +} + +func RequiredCapabilities(transition Transition) []Capability { + return UnionCapabilities(transition.RequiredCapabilities, KernelEffectCapabilities(transition)) +} diff --git a/boatstack/internal/kernel/catalog/capability_test.go b/boatstack/internal/kernel/catalog/capability_test.go new file mode 100644 index 0000000..a2eecac --- /dev/null +++ b/boatstack/internal/kernel/catalog/capability_test.go @@ -0,0 +1,38 @@ +package catalog + +import "testing" + +func TestCapabilityVocabularyFailsClosed(t *testing.T) { + if _, err := NormalizeCapabilities("test", []Capability{"production.nuke"}); err == nil { + t.Fatal("unknown capability was ignored") + } + if _, err := NormalizeCapabilities("test", []Capability{CapabilityRepositoryWrite, CapabilityRepositoryWrite}); err == nil { + t.Fatal("duplicate capability was accepted") + } +} + +func TestKernelEffectClassificationCannotBeWeakenedByTransitionDeclaration(t *testing.T) { + // control-law: repository-authored requirements cannot under-classify a kernel effect + transition := Transition{ + ID: "program/publish", Class: EventOwnedExternal, Effect: "publication.execute", + RequiredCapabilities: []Capability{CapabilityRepositoryWrite}, + DeclaredCapabilities: []Capability{CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPublish}, + } + required := NewCapabilitySet(RequiredCapabilities(transition)...) + for _, capability := range []Capability{CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPublish} { + if !required[capability] { + t.Errorf("publication.execute omitted kernel capability %q", capability) + } + } +} + +func TestCapabilityClassesHaveNoImplicitHierarchy(t *testing.T) { + granted := AuthorityCapabilities(AuthoritySet{AuthorityProvider: true}) + if !granted[CapabilityPublicationPublish] || granted[CapabilityPublicationPrepare] || granted[CapabilityRepositoryWrite] { + t.Fatalf("provider capability mapping widened implicitly: %v", granted.Sorted()) + } + granted = AuthorityCapabilities(AuthoritySet{AuthorityRepository: true}) + if granted[CapabilityPublicationPublish] || granted[CapabilityHumanApprove] { + t.Fatalf("repository authority gained publication or approval: %v", granted.Sorted()) + } +} diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go index 31559b6..0727b8b 100644 --- a/boatstack/internal/kernel/catalog/transition.go +++ b/boatstack/internal/kernel/catalog/transition.go @@ -243,6 +243,9 @@ type Transition struct { RequiredIdentity []string `json:"required_identity"` Authority []AuthorityClass `json:"authority"` AuthorityAll []AuthorityClass `json:"authority_all,omitempty"` + RequiredCapabilities []Capability `json:"required_capabilities,omitempty"` + DeclaredCapabilities []Capability `json:"-"` + RuntimeExecution bool `json:"-"` RequiredEvidence []string `json:"required_evidence"` OwnedResources []string `json:"owned_resources,omitempty"` Effect EffectID `json:"effect,omitempty"` @@ -434,6 +437,20 @@ func validateTransition(t Transition) error { return fmt.Errorf("%s: invalid authority class %q", t.ID, authority) } } + required, err := NormalizeCapabilities(string(t.ID)+".required_capabilities", t.RequiredCapabilities) + if err != nil { + return err + } + declared, err := NormalizeCapabilities(string(t.ID)+".declared_capabilities", t.DeclaredCapabilities) + if err != nil { + return err + } + if t.Controllable() { + allRequired := UnionCapabilities(required, KernelEffectCapabilities(t)) + if missing := MissingCapability(allRequired, NewCapabilitySet(declared...)); missing != "" { + return fmt.Errorf("%s: CAPABILITY_NOT_DECLARED %q", t.ID, missing) + } + } if t.Controllable() && t.Effect == "" { return fmt.Errorf("%s: controllable transition has no owned effect", t.ID) } @@ -607,6 +624,8 @@ func cloneTransition(value Transition) Transition { value.RequiredIdentity = append([]string(nil), value.RequiredIdentity...) value.Authority = append([]AuthorityClass(nil), value.Authority...) value.AuthorityAll = append([]AuthorityClass(nil), value.AuthorityAll...) + value.RequiredCapabilities = append([]Capability(nil), value.RequiredCapabilities...) + value.DeclaredCapabilities = append([]Capability(nil), value.DeclaredCapabilities...) value.RequiredEvidence = append([]string(nil), value.RequiredEvidence...) value.OwnedResources = append([]string(nil), value.OwnedResources...) value.LocalEffects = append([]EffectID(nil), value.LocalEffects...) diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 5f5b5fb..35d1321 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -57,7 +57,11 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution if err := request.Invocation.Validate(false); err != nil { return unresolvedResolution(request.Goal, "invocation identity is invalid"), err } - observation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) + now := e.clock.Now() + if err := request.Authority.Validate(now); err != nil { + return Resolution{}, err + } + observation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: request.Authority.GrantedCapabilities(now)}) if err != nil { return unresolvedResolution(request.Goal, "required observation failed"), fmt.Errorf("observe plant: %w", err) } @@ -84,10 +88,6 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{}, fmt.Errorf("no valid requested or configured goal: %w", err) } } - now := e.clock.Now() - if err := request.Authority.Validate(now); err != nil { - return Resolution{}, err - } decision := e.control.Resolve(snapshot, goal, request.Authority.Set(now), request.Requested) if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil { goal, err = protocol.GoalForTransition(snapshot, goal, *decision.Transition) @@ -108,7 +108,14 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Transition = nil } } else { - prescription, prescriptionErr := protocol.NewPrescription(snapshot, *decision.Transition) + capabilities, capabilityErr := protocol.ProjectCapabilities(snapshot, *decision.Transition, request.Authority, now) + if capabilityErr != nil { + decision.Kind = supervisor.DecisionRefused + decision.Reason = capabilityErr.Error() + decision.Transition = nil + return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil + } + prescription, prescriptionErr := protocol.NewPrescription(snapshot, *decision.Transition, capabilities) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() @@ -173,6 +180,7 @@ type StalePrescriptionError struct { ExpectedProgramFingerprint string ObservedProgramFingerprint string SnapshotChanged bool + AuthorityChanged bool } func (e StalePrescriptionError) Error() string { @@ -186,6 +194,9 @@ func (e StalePrescriptionError) Error() string { if e.SnapshotChanged { facets = append(facets, "admission-relevant snapshot changed") } + if e.AuthorityChanged { + facets = append(facets, "authority or capability context changed") + } return fmt.Sprintf("STALE_PRESCRIPTION %q: %s; re-resolve before apply", e.PrescriptionID, strings.Join(facets, ", ")) } @@ -232,7 +243,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { return result, err } - observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) + observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: request.Authority.GrantedCapabilities(e.clock.Now())}) if observeErr != nil { return result, observeErr } @@ -265,6 +276,14 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := validatePrescriptionCurrent(request.Prescription, resolution.Snapshot); err != nil { return result, err } + if resolution.Prescription.ID != request.Prescription.ID { + return result, StalePrescriptionError{ + PrescriptionID: request.Prescription.ID, ExpectedStateRevision: request.Prescription.ExpectedStateRevision, + ObservedStateRevision: resolution.Snapshot.StateRevision, ExpectedProgramFingerprint: request.Prescription.ExpectedProgramFingerprint, + ObservedProgramFingerprint: resolution.Snapshot.ProgramFingerprint, SnapshotChanged: request.Prescription.ExpectedSnapshotFingerprint != resolution.Snapshot.Fingerprint, + AuthorityChanged: true, + } + } request.Goal = resolution.Goal if resolution.Decision.Kind != supervisor.DecisionPrescribed || resolution.Decision.Transition == nil { return result, DecisionError{Decision: resolution.Decision} @@ -288,7 +307,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { return result, err } - observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) + observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: admission.GrantedCapabilities}) if observeErr != nil { return result, observeErr } @@ -317,7 +336,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe } }() - lockedObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation}) + lockedObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: admission.GrantedCapabilities}) if err != nil { return result, err } @@ -350,6 +369,9 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err := admission.ValidateCurrent(lockedSnapshot, request.Goal, transition, e.clock.Now()); err != nil { return result, StaleAdmissionError{Err: err} } + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return result, err + } if err := e.receipts.Bind(ctx, request.FlowID, admission); err != nil { return result, err } @@ -378,6 +400,9 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe if err != nil { return result, abort("effect preparation failed", err) } + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return result, abort("effect capability check failed", err) + } if err := e.journal.Stage(ctx, admission.ID, prepared.Manifest()); err != nil { return result, abort("effect staging journal failed", err) } @@ -401,7 +426,7 @@ 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, VerifyTransitionID: transition.ID}) + targetObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: verificationInvocation, Capabilities: admission.GrantedCapabilities, IgnoreAdmissionID: admission.ID, VerifyTransitionID: transition.ID}) if err != nil { return result, requireRecovery("target observation failed after effect", err) } diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index a028163..c081278 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -217,7 +217,7 @@ func testRegistryWithAdvanceClass(t *testing.T, class catalog.EventClass) catalo ID: "test.advance", Version: 1, Class: class, Origin: catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionProgramProgress, SourcePhases: []model.ProtocolPhase{model.PhaseObserved}, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, - RequiredIdentity: identity, Authority: authority, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: localEffects, ExternalEffects: externalEffects, Idempotent: true, + RequiredIdentity: identity, Authority: authority, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, 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"}}}, @@ -227,7 +227,7 @@ func testRegistryWithAdvanceClass(t *testing.T, class catalog.EventClass) catalo ID: "test.recover", Version: 1, Class: catalog.EventRecovery, Origin: catalog.TransitionOrigin{Kind: catalog.OriginControlProgram, ID: "test.synthetic", Version: "1.0.0", ManifestFingerprint: syntheticProgramFingerprint}, Owner: "test.synthetic", SelectionClass: catalog.SelectionProgramRecovery, 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, + RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, 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", SourceConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryReconcile)}}}, TargetConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryEscalated)}}}, @@ -248,13 +248,18 @@ func request(t *testing.T, now time.Time) ApplyRequest { t.Fatal(err) } transition, _ := testRegistry(t).Lookup("test.advance") - prescription, err := protocol.NewPrescription(snapshot, transition) + authorityBundle := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityRepository, Subject: "repo", Fingerprint: "config-fingerprint", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}} + capabilities, err := protocol.ProjectCapabilities(snapshot, transition, authorityBundle, now) + if err != nil { + t.Fatal(err) + } + prescription, err := protocol.NewPrescription(snapshot, transition, capabilities) if err != nil { t.Fatal(err) } return ApplyRequest{ResolveRequest: ResolveRequest{ Invocation: invocation, Goal: model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}, Requested: "test.advance", - Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityRepository, Subject: "repo", Fingerprint: "config-fingerprint", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}}, + Authority: authorityBundle, }, FlowID: "flow", Prescription: prescription, AdmissionLifetime: time.Minute} } @@ -572,6 +577,19 @@ func TestOwnedExternalExecutionErrorRequiresRecoveryWithoutRollback(t *testing.T } apply := request(t, now) apply.Authority.Receipts[0].Class = catalog.AuthorityHuman + snapshot, snapshotErr := model.CanonicalizeForProgram(observation(model.PhaseObserved, "source"), syntheticProgramFingerprint) + if snapshotErr != nil { + t.Fatal(snapshotErr) + } + transition, _ := registry.Lookup("test.advance") + capabilities, capabilityErr := protocol.ProjectCapabilities(snapshot, transition, apply.Authority, now) + if capabilityErr != nil { + t.Fatal(capabilityErr) + } + apply.Prescription, err = protocol.NewPrescription(snapshot, transition, capabilities) + if err != nil { + t.Fatal(err) + } _, err = kernel.Apply(context.Background(), apply) var unknown ExternalOutcomeUnknownError if !errors.As(err, &unknown) || !errors.Is(err, context.DeadlineExceeded) { diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index f9653ef..35380ea 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -15,6 +15,7 @@ type Observer interface { type ObservationRequest struct { Invocation model.InvocationContext + Capabilities []catalog.Capability IgnoreAdmissionID string VerifyTransitionID catalog.TransitionID } diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go index ecd777b..49db778 100644 --- a/boatstack/internal/kernel/protocol/admission.go +++ b/boatstack/internal/kernel/protocol/admission.go @@ -9,7 +9,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const AdmissionSchemaVersion = 3 +const AdmissionSchemaVersion = 4 type Admission struct { SchemaVersion int `json:"schema_version"` @@ -30,6 +30,10 @@ type Admission struct { GoalScope catalog.GoalScope `json:"goal_scope,omitempty"` GoalStatus model.FactStatus `json:"goal_status,omitempty"` Authority AuthorityBundle `json:"authority"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` Parameters Parameters `json:"parameters,omitempty"` Evidence []string `json:"evidence"` IdempotencyKey string `json:"idempotency_key"` @@ -46,10 +50,14 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T if err := ValidateApplicability(snapshot, goal, transition, authority, parameters, now); err != nil { return Admission{}, err } + capabilities, err := ProjectCapabilities(snapshot, transition, authority, now) + if err != nil { + return Admission{}, err + } if lifetime <= 0 { return Admission{}, fmt.Errorf("admission lifetime must be positive") } - if err := prescription.ValidateCurrent(snapshot, transition); err != nil { + if err := prescription.ValidateCurrent(snapshot, transition, capabilities); err != nil { return Admission{}, err } sourceRevision, worktreeFingerprint := gitBinding(snapshot) @@ -58,6 +66,8 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T ExpectedStateRevision: prescription.ExpectedStateRevision, ExpectedProgramFingerprint: prescription.ExpectedProgramFingerprint, ExpectedSnapshotFingerprint: prescription.ExpectedSnapshotFingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint, SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, GoalScope: transition.Policy.GoalScope, Authority: authority.canonical(), + AuthorityFingerprint: capabilities.AuthorityFingerprint, RequiredCapabilities: capabilities.Required, + GrantedCapabilities: capabilities.Granted, EffectiveCapabilities: capabilities.Effective, Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), } if transition.Policy.GoalScope == catalog.GoalScopeOptionalPreserve { @@ -215,6 +225,16 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra if err := a.Authority.Validate(now); err != nil { return err } + capabilities, err := ProjectCapabilities(snapshot, transition, a.Authority, now) + if err != nil { + return err + } + if a.AuthorityFingerprint != capabilities.AuthorityFingerprint || + !sameCapabilities(a.RequiredCapabilities, capabilities.Required) || + !sameCapabilities(a.GrantedCapabilities, capabilities.Granted) || + !sameCapabilities(a.EffectiveCapabilities, capabilities.Effective) { + return fmt.Errorf("admission %q authority or capability binding changed", a.ID) + } if err := validateAuthorityEvidence(snapshot, a.Authority); err != nil { return err } @@ -344,9 +364,28 @@ func validateAuthorityEvidence(snapshot model.Snapshot, authority AuthorityBundl } func (a Admission) ValidateIdentity() error { - if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { + if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || a.AuthorityFingerprint == "" || len(a.RequiredCapabilities) == 0 || len(a.EffectiveCapabilities) == 0 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } + fingerprint, err := a.Authority.Fingerprint() + if err != nil || fingerprint != a.AuthorityFingerprint { + return fmt.Errorf("admission has invalid authority identity") + } + for field, values := range map[string][]catalog.Capability{ + "required_capabilities": a.RequiredCapabilities, + "granted_capabilities": a.GrantedCapabilities, + "effective_capabilities": a.EffectiveCapabilities, + } { + if _, err := catalog.NormalizeCapabilities("admission."+field, values); err != nil { + return err + } + } + if !sameCapabilities(a.RequiredCapabilities, a.EffectiveCapabilities) { + return fmt.Errorf("admission effective capabilities do not equal exact requirements") + } + if missing := catalog.MissingCapability(a.EffectiveCapabilities, catalog.NewCapabilitySet(a.GrantedCapabilities...)); missing != "" { + return fmt.Errorf("admission effective capability %q was not granted", missing) + } if (a.PriorProgramFingerprint == "") != (a.ProgramDeltaFingerprint == "") { return fmt.Errorf("admission has incomplete program delta identity") } @@ -396,3 +435,15 @@ func (a Admission) ValidateIdentity() error { } return nil } + +func sameCapabilities(left, right []catalog.Capability) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/boatstack/internal/kernel/protocol/authority.go b/boatstack/internal/kernel/protocol/authority.go index f8ebbbe..522f2c4 100644 --- a/boatstack/internal/kernel/protocol/authority.go +++ b/boatstack/internal/kernel/protocol/authority.go @@ -68,6 +68,21 @@ func (b AuthorityBundle) canonical() AuthorityBundle { return result } +func (b AuthorityBundle) Fingerprint() (string, error) { + canonical := b.canonical() + sources := make([]AuthoritySource, 0, len(canonical.Receipts)) + for _, receipt := range canonical.Receipts { + sources = append(sources, AuthoritySource{ + ID: receipt.ID, Class: receipt.Class, Subject: receipt.Subject, Fingerprint: receipt.Fingerprint, + }) + } + return contentID("auth-", sources) +} + +func (b AuthorityBundle) GrantedCapabilities(now time.Time) []catalog.Capability { + return catalog.AuthorityCapabilities(b.Set(now)).Sorted() +} + func DeriveRepositoryAuthority(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { diff --git a/boatstack/internal/kernel/protocol/capability.go b/boatstack/internal/kernel/protocol/capability.go new file mode 100644 index 0000000..3490dc9 --- /dev/null +++ b/boatstack/internal/kernel/protocol/capability.go @@ -0,0 +1,71 @@ +package protocol + +import ( + "fmt" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +type CapabilityProjection struct { + AuthorityFingerprint string + Required []catalog.Capability + Granted []catalog.Capability + Effective []catalog.Capability +} + +func ProjectCapabilities(snapshot model.Snapshot, transition catalog.Transition, authority AuthorityBundle, now time.Time) (CapabilityProjection, error) { + if err := authority.Validate(now); err != nil { + return CapabilityProjection{}, err + } + required := catalog.RequiredCapabilities(transition) + if requiresHumanApprovalCapability(snapshot, transition) { + required = catalog.UnionCapabilities(required, []catalog.Capability{catalog.CapabilityHumanApprove}) + } + declared := catalog.NewCapabilitySet(transition.DeclaredCapabilities...) + if missing := catalog.MissingCapability(required, declared); missing != "" { + return CapabilityProjection{}, fmt.Errorf("CAPABILITY_NOT_DECLARED %q for transition %q", missing, transition.ID) + } + grantedSet := catalog.AuthorityCapabilities(authority.Set(now)) + if missing := catalog.MissingCapability(required, grantedSet); missing != "" { + return CapabilityProjection{}, fmt.Errorf("CAPABILITY_DENIED %q for transition %q", missing, transition.ID) + } + fingerprint, err := authority.Fingerprint() + if err != nil { + return CapabilityProjection{}, err + } + return CapabilityProjection{ + AuthorityFingerprint: fingerprint, + Required: append([]catalog.Capability(nil), required...), + Granted: grantedSet.Sorted(), + Effective: append([]catalog.Capability(nil), required...), + }, nil +} + +func requiresHumanApprovalCapability(snapshot model.Snapshot, transition catalog.Transition) bool { + if transition.Policy.AuthorityRule == "plan-approval" && snapshot.ConfigurationPolicy.Status == model.FactKnown { + return snapshot.ConfigurationPolicy.Value.PlanApproval == "human" + } + if transition.Policy.AuthorityRule == "independent-high-risk-review" && snapshot.ConfigurationPolicy.Status == model.FactKnown { + policy := snapshot.ConfigurationPolicy.Value + return policy.IndependentReviewForHighRisk && policy.HighRiskChange + } + return false +} + +func ValidateEffectCapabilities(admission Admission, transition catalog.Transition) error { + required := catalog.RequiredCapabilities(transition) + effective := catalog.NewCapabilitySet(admission.EffectiveCapabilities...) + if missing := catalog.MissingCapability(required, effective); missing != "" { + return fmt.Errorf("EFFECT_CAPABILITY_DENIED %q for transition %q", missing, transition.ID) + } + return nil +} + +func RequireCapability(values []catalog.Capability, required catalog.Capability, component string) error { + if !catalog.NewCapabilitySet(values...)[required] { + return fmt.Errorf("EFFECT_CAPABILITY_DENIED %q for %s", required, component) + } + return nil +} diff --git a/boatstack/internal/kernel/protocol/capability_test.go b/boatstack/internal/kernel/protocol/capability_test.go new file mode 100644 index 0000000..c392834 --- /dev/null +++ b/boatstack/internal/kernel/protocol/capability_test.go @@ -0,0 +1,135 @@ +package protocol + +import ( + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func capabilityAuthority(now time.Time, class catalog.AuthorityClass, id string) AuthorityBundle { + return AuthorityBundle{Receipts: []AuthorityReceipt{{ + ID: id, Class: class, Subject: "test-source", Fingerprint: "test-fingerprint", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} +} + +func capabilityTransition(required, declared []catalog.Capability) catalog.Transition { + return catalog.Transition{ + ID: "program/write", Class: catalog.EventOwnedLocal, Effect: "program.write", + RequiredCapabilities: required, DeclaredCapabilities: declared, + } +} + +func TestCapabilityProjectionRequiresDeclarationAndExternalGrant(t *testing.T) { + // control-law: declaration narrows authority but never creates it + now := time.Unix(100, 0).UTC() + snapshot := model.Snapshot{} + write := capabilityTransition( + []catalog.Capability{catalog.CapabilityRepositoryWrite}, + []catalog.Capability{catalog.CapabilityRepositoryWrite}, + ) + if _, err := ProjectCapabilities(snapshot, write, AuthorityBundle{}, now); err == nil || !strings.Contains(err.Error(), "CAPABILITY_DENIED") { + t.Fatalf("declaration without grant = %v", err) + } + projection, err := ProjectCapabilities(snapshot, write, capabilityAuthority(now, catalog.AuthorityRepository, "repository"), now) + if err != nil { + t.Fatal(err) + } + if len(projection.Effective) != 1 || projection.Effective[0] != catalog.CapabilityRepositoryWrite { + t.Fatalf("effective capabilities = %v", projection.Effective) + } + + undeclared := write + undeclared.DeclaredCapabilities = []catalog.Capability{catalog.CapabilityCommandExecute} + if _, err := ProjectCapabilities(snapshot, undeclared, capabilityAuthority(now, catalog.AuthorityHuman, "human"), now); err == nil || !strings.Contains(err.Error(), "CAPABILITY_NOT_DECLARED") { + t.Fatalf("broad host grant escaped program surface: %v", err) + } +} + +func TestCapabilityProjectionRejectsPartialGrant(t *testing.T) { + // control-law: intersection never silently drops a required capability + now := time.Unix(100, 0).UTC() + transition := capabilityTransition( + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityPublicationPublish}, + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityPublicationPublish}, + ) + _, err := ProjectCapabilities(model.Snapshot{}, transition, capabilityAuthority(now, catalog.AuthorityRepository, "repository"), now) + if err == nil || !strings.Contains(err.Error(), `CAPABILITY_DENIED "publication.publish"`) { + t.Fatalf("partial grant = %v", err) + } +} + +func TestHumanApprovalCannotBeSynthesizedByProgramDeclaration(t *testing.T) { + // control-law: an approval declaration is not human authority + now := time.Unix(100, 0).UTC() + transition := capabilityTransition( + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityHumanApprove}, + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityHumanApprove}, + ) + if _, err := ProjectCapabilities(model.Snapshot{}, transition, capabilityAuthority(now, catalog.AuthorityRepository, "repository"), now); err == nil || !strings.Contains(err.Error(), `CAPABILITY_DENIED "human.approve"`) { + t.Fatalf("repository source synthesized approval: %v", err) + } + if _, err := ProjectCapabilities(model.Snapshot{}, transition, capabilityAuthority(now, catalog.AuthorityHuman, "human"), now); err != nil { + t.Fatalf("human approval authority was rejected: %v", err) + } +} + +func TestRecoveryRequiresFreshMatchingAuthority(t *testing.T) { + // control-law: a recovery transition cannot amplify the failed admission + now := time.Unix(100, 0).UTC() + recovery := capabilityTransition( + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityPublicationPublish}, + []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityPublicationPublish}, + ) + recovery.ID = "program/recover" + recovery.Class = catalog.EventRecovery + if _, err := ProjectCapabilities(model.Snapshot{}, recovery, capabilityAuthority(now, catalog.AuthorityRepository, "original"), now); err == nil || !strings.Contains(err.Error(), `CAPABILITY_DENIED "publication.publish"`) { + t.Fatalf("recovery amplified authority: %v", err) + } +} + +func TestPublicationPrepareCannotCrossPublishEffectBoundary(t *testing.T) { + transition := catalog.Transition{ID: "program/publish", Class: catalog.EventOwnedExternal, Effect: "publication.execute"} + admission := Admission{EffectiveCapabilities: []catalog.Capability{ + catalog.CapabilityRepositoryWrite, + catalog.CapabilityCommandExecute, + catalog.CapabilityProductMutate, + catalog.CapabilityPublicationPrepare, + }} + err := ValidateEffectCapabilities(admission, transition) + if err == nil || !strings.Contains(err.Error(), `EFFECT_CAPABILITY_DENIED "publication.publish"`) { + t.Fatalf("publication.prepare crossed publish boundary: %v", err) + } +} + +func TestAuthorityFingerprintBindsSourceIdentityNotRematerializationTime(t *testing.T) { + // control-law: one stable authority source survives bounded re-materialization + now := time.Unix(100, 0).UTC() + one := capabilityAuthority(now, catalog.AuthorityRepository, "policy") + two := one + two.Receipts = append([]AuthorityReceipt(nil), one.Receipts...) + two.Receipts[0].IssuedAt = now + two.Receipts[0].ExpiresAt = now.Add(2 * time.Hour) + oneFingerprint, err := one.Fingerprint() + if err != nil { + t.Fatal(err) + } + twoFingerprint, err := two.Fingerprint() + if err != nil { + t.Fatal(err) + } + if oneFingerprint != twoFingerprint { + t.Fatal("re-materializing the same authority source changed its identity") + } + two.Receipts[0].Fingerprint = "different-source" + changed, err := two.Fingerprint() + if err != nil { + t.Fatal(err) + } + if changed == oneFingerprint { + t.Fatal("authority source substitution preserved identity") + } +} diff --git a/boatstack/internal/kernel/protocol/journal.go b/boatstack/internal/kernel/protocol/journal.go index e0ed121..2ee6520 100644 --- a/boatstack/internal/kernel/protocol/journal.go +++ b/boatstack/internal/kernel/protocol/journal.go @@ -2,4 +2,4 @@ package protocol // JournalSchemaVersion identifies the transaction record that embeds an exact // prescription-bound admission. -const JournalSchemaVersion = 3 +const JournalSchemaVersion = 4 diff --git a/boatstack/internal/kernel/protocol/prescription.go b/boatstack/internal/kernel/protocol/prescription.go index e40953c..6208f37 100644 --- a/boatstack/internal/kernel/protocol/prescription.go +++ b/boatstack/internal/kernel/protocol/prescription.go @@ -7,10 +7,11 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const PrescriptionSchemaVersion = 1 +const PrescriptionSchemaVersion = 2 // Prescription is the immutable compare-and-swap binding emitted by -// resolution and required by apply. It carries no authority. +// resolution and required by apply. It carries no reusable authority or +// credential material, only the content identity of the admitted projection. type Prescription struct { SchemaVersion int `json:"schema_version"` ID string `json:"id"` @@ -18,15 +19,21 @@ type Prescription struct { ExpectedStateRevision uint64 `json:"expected_state_revision"` ExpectedProgramFingerprint string `json:"expected_program_fingerprint"` ExpectedSnapshotFingerprint string `json:"expected_snapshot_fingerprint"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` } -func NewPrescription(snapshot model.Snapshot, transition catalog.Transition) (Prescription, error) { +func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) (Prescription, error) { prescription := Prescription{ SchemaVersion: PrescriptionSchemaVersion, TransitionID: transition.ID, ExpectedStateRevision: snapshot.StateRevision, ExpectedProgramFingerprint: snapshot.ProgramFingerprint, ExpectedSnapshotFingerprint: snapshot.Fingerprint, + AuthorityFingerprint: capabilities.AuthorityFingerprint, + RequiredCapabilities: append([]catalog.Capability(nil), capabilities.Required...), + EffectiveCapabilities: append([]catalog.Capability(nil), capabilities.Effective...), } if err := prescription.validateFields(); err != nil { return Prescription{}, err @@ -59,14 +66,15 @@ func (p Prescription) Validate() error { } func (p Prescription) validateFields() error { - if p.SchemaVersion != PrescriptionSchemaVersion || p.TransitionID == "" || p.ExpectedStateRevision == 0 || + if p.SchemaVersion != PrescriptionSchemaVersion || p.TransitionID == "" || p.ExpectedStateRevision == 0 || p.AuthorityFingerprint == "" || + len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 || len(p.ExpectedProgramFingerprint) != 64 || len(p.ExpectedSnapshotFingerprint) != 64 { return fmt.Errorf("prescription has invalid schema, transition, state revision, program, or snapshot identity") } return nil } -func (p Prescription) ValidateCurrent(snapshot model.Snapshot, transition catalog.Transition) error { +func (p Prescription) ValidateCurrent(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) error { if err := p.Validate(); err != nil { return err } @@ -82,5 +90,10 @@ func (p Prescription) ValidateCurrent(snapshot model.Snapshot, transition catalo if p.ExpectedSnapshotFingerprint != snapshot.Fingerprint { return fmt.Errorf("prescription %q expected snapshot %s, observed %s", p.ID, p.ExpectedSnapshotFingerprint, snapshot.Fingerprint) } + if p.AuthorityFingerprint != capabilities.AuthorityFingerprint || + !sameCapabilities(p.RequiredCapabilities, capabilities.Required) || + !sameCapabilities(p.EffectiveCapabilities, capabilities.Effective) { + return fmt.Errorf("prescription %q is bound to a different authority or capability context", p.ID) + } return nil } diff --git a/boatstack/internal/kernel/protocol/prescription_test.go b/boatstack/internal/kernel/protocol/prescription_test.go index 2de5bfc..47139c6 100644 --- a/boatstack/internal/kernel/protocol/prescription_test.go +++ b/boatstack/internal/kernel/protocol/prescription_test.go @@ -15,11 +15,12 @@ func TestPrescriptionContentIdentityBindsTransitionStateProgramAndSnapshot(t *te Fingerprint: strings.Repeat("b", 64), } transition := catalog.Transition{ID: "program/advance"} - one, err := NewPrescription(base, transition) + capabilities := CapabilityProjection{AuthorityFingerprint: "auth-test", Required: []catalog.Capability{catalog.CapabilityRepositoryWrite}, Effective: []catalog.Capability{catalog.CapabilityRepositoryWrite}} + one, err := NewPrescription(base, transition, capabilities) if err != nil { t.Fatal(err) } - if err := one.ValidateCurrent(base, transition); err != nil { + if err := one.ValidateCurrent(base, transition, capabilities); err != nil { t.Fatal(err) } mutations := []struct { @@ -34,7 +35,7 @@ func TestPrescriptionContentIdentityBindsTransitionStateProgramAndSnapshot(t *te } for _, mutation := range mutations { t.Run(mutation.name, func(t *testing.T) { - other, err := NewPrescription(mutation.snapshot, mutation.transition) + other, err := NewPrescription(mutation.snapshot, mutation.transition, capabilities) if err != nil { t.Fatal(err) } @@ -48,4 +49,16 @@ func TestPrescriptionContentIdentityBindsTransitionStateProgramAndSnapshot(t *te if err := tampered.Validate(); err == nil { t.Fatal("tampered prescription retained validity") } + + for name, changed := range map[string]CapabilityProjection{ + "authority": {AuthorityFingerprint: "auth-other", Required: capabilities.Required, Effective: capabilities.Effective}, + "required-capability": {AuthorityFingerprint: capabilities.AuthorityFingerprint, Required: []catalog.Capability{catalog.CapabilityCommandExecute}, Effective: []catalog.Capability{catalog.CapabilityCommandExecute}}, + "effective-capability": {AuthorityFingerprint: capabilities.AuthorityFingerprint, Required: capabilities.Required, Effective: []catalog.Capability{catalog.CapabilityCommandExecute}}, + } { + t.Run(name, func(t *testing.T) { + if err := one.ValidateCurrent(base, transition, changed); err == nil { + t.Fatal("old prescription accepted changed authority or capability context") + } + }) + } } diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index 26a1ac6..b383447 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -2,13 +2,14 @@ package protocol import ( "fmt" + "sort" "time" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const ReceiptSchemaVersion = 4 +const ReceiptSchemaVersion = 5 type Outcome string @@ -45,6 +46,11 @@ type TransitionReceipt struct { SourceFingerprint string `json:"source_fingerprint"` TargetFingerprint string `json:"target_fingerprint"` AuthorityClasses []string `json:"authority_classes"` + AuthorityFingerprint string `json:"authority_fingerprint"` + AuthoritySources []AuthoritySource `json:"authority_sources"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities"` IdempotencyKey string `json:"idempotency_key"` Verifier string `json:"verifier"` Outcome Outcome `json:"outcome"` @@ -56,6 +62,13 @@ type TransitionReceipt struct { FailureClass string `json:"failure_class,omitempty"` } +type AuthoritySource struct { + ID string `json:"id"` + Class catalog.AuthorityClass `json:"class"` + Subject string `json:"subject"` + Fingerprint string `json:"fingerprint"` +} + func NewReceipt(flowID string, sequence uint64, admission Admission, transition catalog.Transition, target model.Snapshot, startedAt, completedAt time.Time, outcome Outcome, failureClass string) (TransitionReceipt, error) { if flowID == "" || sequence == 0 || admission.ID == "" || target.Fingerprint == "" { return TransitionReceipt{}, fmt.Errorf("receipt requires flow, sequence, admission, and target identity") @@ -67,8 +80,10 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition return TransitionReceipt{}, fmt.Errorf("receipt target revision must advance exactly once from the prescribed revision") } classes := make([]string, 0, len(admission.Authority.Receipts)) + sources := make([]AuthoritySource, 0, len(admission.Authority.Receipts)) for _, authority := range admission.Authority.Receipts { classes = append(classes, string(authority.Class)) + sources = append(sources, AuthoritySource{ID: authority.ID, Class: authority.Class, Subject: authority.Subject, Fingerprint: authority.Fingerprint}) } terminal := model.TerminalUnknown if target.Terminal.Status == model.FactKnown { @@ -83,7 +98,11 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition GoalScope: admission.GoalScope, GoalStatus: admission.GoalStatus, SourceFingerprint: admission.ExpectedSnapshotFingerprint, TargetFingerprint: target.Fingerprint, AuthorityClasses: classes, IdempotencyKey: admission.IdempotencyKey, Verifier: transition.Verifier, - Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal, + AuthorityFingerprint: admission.AuthorityFingerprint, AuthoritySources: sources, + RequiredCapabilities: append([]catalog.Capability(nil), admission.RequiredCapabilities...), + GrantedCapabilities: append([]catalog.Capability(nil), admission.GrantedCapabilities...), + ExercisedCapabilities: append([]catalog.Capability(nil), admission.EffectiveCapabilities...), + Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal, StartedAt: startedAt.UTC(), CompletedAt: completedAt.UTC(), DurationNanoseconds: completedAt.Sub(startedAt).Nanoseconds(), FailureClass: failureClass, } @@ -111,9 +130,49 @@ 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 || len(r.ProgramFingerprint) != 64 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision == 0 || r.ResultingStateRevision != r.PriorStateRevision+1 || 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.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision == 0 || r.ResultingStateRevision != r.PriorStateRevision+1 || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || len(r.ExercisedCapabilities) == 0 || r.IdempotencyKey == "" || r.Verifier == "" { return fmt.Errorf("receipt has incomplete identity or evidence") } + if _, err := catalog.NormalizeCapabilities("receipt.required_capabilities", r.RequiredCapabilities); err != nil { + return err + } + if _, err := catalog.NormalizeCapabilities("receipt.granted_capabilities", r.GrantedCapabilities); err != nil { + return err + } + if _, err := catalog.NormalizeCapabilities("receipt.exercised_capabilities", r.ExercisedCapabilities); err != nil { + return err + } + if missing := catalog.MissingCapability(r.ExercisedCapabilities, catalog.NewCapabilitySet(r.GrantedCapabilities...)); missing != "" { + return fmt.Errorf("receipt exercised ungranted capability %q", missing) + } + if !sameCapabilities(r.RequiredCapabilities, r.ExercisedCapabilities) { + return fmt.Errorf("receipt exercised capabilities differ from exact admitted requirements") + } + if len(r.AuthoritySources) != len(r.AuthorityClasses) { + return fmt.Errorf("receipt authority provenance and class counts differ") + } + authoritySet := catalog.AuthoritySet{} + sources := append([]AuthoritySource(nil), r.AuthoritySources...) + sort.Slice(sources, func(i, j int) bool { return sources[i].ID < sources[j].ID }) + for index, source := range sources { + if source.ID == "" || !source.Class.Valid() || source.Class == catalog.AuthorityNone || source.Subject == "" || source.Fingerprint == "" { + return fmt.Errorf("receipt has invalid authority provenance") + } + if index > 0 && sources[index-1].ID == source.ID { + return fmt.Errorf("receipt duplicates authority source %q", source.ID) + } + if source != r.AuthoritySources[index] || r.AuthorityClasses[index] != string(source.Class) { + return fmt.Errorf("receipt authority provenance is not canonical") + } + authoritySet[source.Class] = true + } + fingerprint, err := contentID("auth-", sources) + if err != nil || fingerprint != r.AuthorityFingerprint { + return fmt.Errorf("receipt authority fingerprint does not match its provenance") + } + if !sameCapabilities(r.GrantedCapabilities, catalog.AuthorityCapabilities(authoritySet).Sorted()) { + return fmt.Errorf("receipt granted capabilities do not match authority provenance") + } if !r.GoalScope.Valid() { return fmt.Errorf("receipt has invalid product-goal scope %q", r.GoalScope) } diff --git a/boatstack/internal/kernel/protocol/receipt_capability_test.go b/boatstack/internal/kernel/protocol/receipt_capability_test.go new file mode 100644 index 0000000..9677391 --- /dev/null +++ b/boatstack/internal/kernel/protocol/receipt_capability_test.go @@ -0,0 +1,47 @@ +package protocol + +import ( + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { + now := time.Unix(100, 0).UTC() + authority := capabilityAuthority(now, catalog.AuthorityRepository, "policy") + authorityFingerprint, err := authority.Fingerprint() + if err != nil { + t.Fatal(err) + } + admission := Admission{ + ID: "admission", PrescriptionID: "prescription", ExpectedStateRevision: 1, + ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: "source", + Goal: model.Goal{ID: "goal", Kind: model.GoalApprovedPlan, DeliveryID: "delivery"}, + Authority: authority, AuthorityFingerprint: authorityFingerprint, + RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + GrantedCapabilities: authority.GrantedCapabilities(now), + EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + IdempotencyKey: "idempotency", + } + transition := catalog.Transition{ID: "program/write", Version: 1, Verifier: "program.written"} + receipt, err := NewReceipt("flow", 1, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: "target"}, now, now.Add(time.Second), OutcomeSucceeded, "") + if err != nil { + t.Fatal(err) + } + if err := receipt.Validate(); err != nil { + t.Fatalf("valid receipt rejected: %v", err) + } + + receipt.AuthoritySources[0].Fingerprint = "substituted-source" + receipt.ID = "" + receipt.ID, err = contentID("trc-", receipt) + if err != nil { + t.Fatal(err) + } + if err := receipt.Validate(); err == nil || !strings.Contains(err.Error(), "authority fingerprint does not match") { + t.Fatalf("rehashed provenance substitution was accepted: %v", err) + } +} diff --git a/boatstack/internal/kernel/supervisor/guard_test.go b/boatstack/internal/kernel/supervisor/guard_test.go index adcb914..928fc7b 100644 --- a/boatstack/internal/kernel/supervisor/guard_test.go +++ b/boatstack/internal/kernel/supervisor/guard_test.go @@ -44,7 +44,8 @@ func syntheticManagedTransition(id catalog.TransitionID, class catalog.EventClas 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"}, + Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + DeclaredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, 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}}}, diff --git a/boatstack/internal/surfaces/catalog_render.go b/boatstack/internal/surfaces/catalog_render.go index 4acfa13..afce4a7 100644 --- a/boatstack/internal/surfaces/catalog_render.go +++ b/boatstack/internal/surfaces/catalog_render.go @@ -32,8 +32,8 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { output.WriteString(string(facet)) } output.WriteString("`.\n\n") - output.WriteString("| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Verifier | Recovery | Cost |\n") - output.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|---|\n") + output.WriteString("| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost |\n") + output.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n") for _, transition := range ordered { authority := joinAuthorities(transition.Authority) if len(transition.AuthorityAll) > 0 { @@ -52,10 +52,11 @@ func RenderCatalogMarkdown(transitions []catalog.Transition) string { 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", + fmt.Fprintf(&output, "| `%s` | %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) + markdownList(capabilityStrings(catalog.RequiredCapabilities(transition))), 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() @@ -156,6 +157,14 @@ func phaseStrings(values []model.ProtocolPhase) []string { return parts } +func capabilityStrings(values []catalog.Capability) []string { + parts := make([]string, len(values)) + for index, value := range values { + parts[index] = string(value) + } + return parts +} + func joinPhases(values []model.ProtocolPhase) string { return strings.Join(phaseStrings(values), " / ") } diff --git a/boatstack/internal/surfaces/protocol.go b/boatstack/internal/surfaces/protocol.go index c91e1a9..a0d89e5 100644 --- a/boatstack/internal/surfaces/protocol.go +++ b/boatstack/internal/surfaces/protocol.go @@ -11,7 +11,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" ) -const SchemaVersion = 3 +const SchemaVersion = 4 type Operation string diff --git a/boatstack/internal/surfaces/render.go b/boatstack/internal/surfaces/render.go index 1827f04..45eb2a4 100644 --- a/boatstack/internal/surfaces/render.go +++ b/boatstack/internal/surfaces/render.go @@ -30,7 +30,14 @@ func PrescriptionCommand(transition catalog.Transition, prescription protocol.Pr "--correlation", correlation, "--prescription-id", prescription.ID, "--expected-state-revision", strconv.FormatUint(prescription.ExpectedStateRevision, 10), "--expected-program-fingerprint", prescription.ExpectedProgramFingerprint, - "--expected-snapshot-fingerprint", prescription.ExpectedSnapshotFingerprint} + "--expected-snapshot-fingerprint", prescription.ExpectedSnapshotFingerprint, + "--authority-fingerprint", prescription.AuthorityFingerprint} + for _, capability := range prescription.RequiredCapabilities { + arguments = append(arguments, "--required-capability", string(capability)) + } + for _, capability := range prescription.EffectiveCapabilities { + arguments = append(arguments, "--effective-capability", string(capability)) + } if goal.Validate() == nil { arguments = append(arguments, "--goal-kind", string(goal.Kind), "--delivery", goal.DeliveryID, "--goal-id", goal.ID) } diff --git a/boatstack/internal/surfaces/render_test.go b/boatstack/internal/surfaces/render_test.go index b58eccd..379d1a0 100644 --- a/boatstack/internal/surfaces/render_test.go +++ b/boatstack/internal/surfaces/render_test.go @@ -21,10 +21,14 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { } goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"} parameters := protocol.Parameters{{Name: "source_path", Value: "/tmp/O'Brien plan.md"}, {Name: "delivery_id", Value: "delivery"}} - prescription := protocol.Prescription{ID: "prx-fixture", ExpectedStateRevision: 41, ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64)} + prescription := protocol.Prescription{ + ID: "prx-fixture", ExpectedStateRevision: 41, ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64), + AuthorityFingerprint: "auth-fixture", RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, + EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, + } command := PrescriptionCommand(transition, prescription, "corr-1", "/repo with space", goal, "flow", parameters) joined := strings.Join(command.Arguments, " ") - for _, binding := range []string{"--correlation corr-1", "--prescription-id prx-fixture", "--expected-state-revision 41", "--expected-program-fingerprint", "--expected-snapshot-fingerprint"} { + for _, binding := range []string{"--correlation corr-1", "--prescription-id prx-fixture", "--expected-state-revision 41", "--expected-program-fingerprint", "--expected-snapshot-fingerprint", "--authority-fingerprint auth-fixture", "--required-capability repository.write", "--required-capability command.execute", "--effective-capability repository.write", "--effective-capability command.execute"} { if !strings.Contains(joined, binding) { t.Fatalf("prescription command omitted CAS binding %q: %s", binding, joined) } @@ -56,6 +60,9 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { func TestCatalogArtifactsAreGeneratedFromEveryRuntimeTransition(t *testing.T) { registry := testprogram.StandardRegistry() markdown := RenderCatalogMarkdown(registry.All()) + if !strings.Contains(markdown, "| Required capabilities |") || !strings.Contains(markdown, "`repository.write`") { + t.Fatal("catalog markdown omitted kernel-classified capability requirements") + } mermaid := RenderCatalogMermaid(registry.All()) for _, transition := range registry.All() { rowPrefix := "\n| `" + string(transition.ID) + "` | " + string(transition.Origin.Kind) + ":" diff --git a/boatstack/kernel.go b/boatstack/kernel.go index e4d9770..390a44e 100644 --- a/boatstack/kernel.go +++ b/boatstack/kernel.go @@ -166,7 +166,7 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. ExtensionTransitionCount: summary.ExtensionTransitionCount, TransitionCount: summary.TotalTransitionCount, EnabledExtensions: extensionIDs, ProgramFingerprint: summary.ProgramFingerprint, } - observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation}) + observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation, Capabilities: request.Authority.GrantedCapabilities(k.clock.Now())}) if observeErr != nil { report.Healthy, report.Detail = false, observeErr.Error() response.Doctor = &report @@ -218,7 +218,7 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. response.Events = events return response, nil case surfaces.OperationGuard: - observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation}) + observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation, Capabilities: request.Authority.GrantedCapabilities(k.clock.Now())}) if observeErr != nil { response.Error = observeErr.Error() return response, observeErr @@ -257,7 +257,7 @@ func (k Kernel) deriveRepositoryAuthority(ctx context.Context, invocation model. return protocol.AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by Kernel") } } - observation, err := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation}) + observation, err := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation, Capabilities: bundle.GrantedCapabilities(k.clock.Now())}) if err != nil { return protocol.AuthorityBundle{}, err } diff --git a/boatstack/kernel_test.go b/boatstack/kernel_test.go index 720e8b0..13529f1 100644 --- a/boatstack/kernel_test.go +++ b/boatstack/kernel_test.go @@ -30,7 +30,8 @@ func TestRecoverSurfaceConsumesCompiledRegistryInsteadOfFixedProgramIDs(t *testi FlowID: "flow", TransitionID: "plan.create", } prescriptionSnapshot := model.Snapshot{Observation: model.Observation{StateRevision: 1, ProgramFingerprint: program.Fingerprint()}, Fingerprint: strings.Repeat("a", 64)} - request.Prescription, err = protocol.NewPrescription(prescriptionSnapshot, catalog.Transition{ID: request.TransitionID}) + projection := protocol.CapabilityProjection{AuthorityFingerprint: "auth-test", Required: []catalog.Capability{catalog.CapabilityRepositoryWrite}, Effective: []catalog.Capability{catalog.CapabilityRepositoryWrite}} + request.Prescription, err = protocol.NewPrescription(prescriptionSnapshot, catalog.Transition{ID: request.TransitionID}, projection) if err != nil { t.Fatal(err) } @@ -40,7 +41,7 @@ func TestRecoverSurfaceConsumesCompiledRegistryInsteadOfFixedProgramIDs(t *testi } request.TransitionID = "example.extension.recover" - request.Prescription, err = protocol.NewPrescription(prescriptionSnapshot, catalog.Transition{ID: request.TransitionID}) + request.Prescription, err = protocol.NewPrescription(prescriptionSnapshot, catalog.Transition{ID: request.TransitionID}, projection) if err != nil { t.Fatal(err) } diff --git a/boatstack/program_effects.go b/boatstack/program_effects.go index f53c925..2271d3e 100644 --- a/boatstack/program_effects.go +++ b/boatstack/program_effects.go @@ -22,6 +22,14 @@ type programEffectDriver struct { } func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { + if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { + return nil, err + } + if transition.RuntimeExecution { + if err := protocol.RequireCapability(admission.EffectiveCapabilities, catalog.CapabilityCommandExecute, "component runtime "+transition.Origin.ID); err != nil { + return nil, err + } + } if transition.Origin.Kind == catalog.OriginCoreSystem { return d.base.Prepare(ctx, admission, transition) } @@ -41,6 +49,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm ProtocolVersion: control.ProgramRuntimeProtocolVersion, ProgramID: flow.Identity.ID, ProgramVersion: flow.Identity.Version, ProgramFingerprint: admission.ExpectedProgramFingerprint, CorrelationID: admission.Invocation.Correlation, RepositoryRoot: admission.Invocation.InvokingPath, TransitionID: transition.ID, Parameters: parameters, Settings: flow.Manifest.Settings, + Capabilities: append([]control.Capability(nil), admission.EffectiveCapabilities...), } if transition.Class == catalog.EventOwnedExternal { prepared, err := effects.NewExtensionExternalPrepared(func(executionContext context.Context) (ports.EffectResult, error) { @@ -53,7 +62,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm return ports.EffectResult{}, err } return decodeExtensionSettlement(flow.Identity.ID, response.ExternalResult) - }) + }, admission, transition) if err != nil { return nil, err } @@ -74,7 +83,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm if err := validateProgramWrites(d.program, transition, flow.Identity.ID, response.Writes); err != nil { return nil, err } - prepared, err := effects.NewFlowLocalPrepared(admission.Invocation.InvokingPath, flow.Identity.ID, response.Writes) + prepared, err := effects.NewFlowLocalPrepared(admission.Invocation.InvokingPath, flow.Identity.ID, response.Writes, admission, transition) if err != nil { return nil, err } @@ -92,6 +101,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm ProtocolVersion: control.ExtensionProtocolVersion, ExtensionID: extension.Identity.ID, ExtensionVersion: extension.Identity.Version, ProgramFingerprint: admission.ExpectedProgramFingerprint, CorrelationID: admission.Invocation.Correlation, RepositoryRoot: admission.Invocation.InvokingPath, TransitionID: transition.ID, Parameters: parameters, Settings: extension.Manifest.Settings, + Capabilities: append([]control.Capability(nil), admission.EffectiveCapabilities...), } if transition.Class == catalog.EventOwnedExternal { prepared, err := effects.NewExtensionExternalPrepared(func(executionContext context.Context) (ports.EffectResult, error) { @@ -105,7 +115,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm return ports.EffectResult{}, err } return decodeExtensionSettlement(extension.Identity.ID, response.ExternalResult) - }) + }, admission, transition) if err != nil { return nil, err } @@ -126,7 +136,7 @@ func (d programEffectDriver) Prepare(ctx context.Context, admission protocol.Adm if err := validateProgramWrites(d.program, transition, extension.Identity.ID, response.Writes); err != nil { return nil, err } - prepared, err := effects.NewExtensionLocalPrepared(admission.Invocation.InvokingPath, extension.Identity.ID, response.Writes) + prepared, err := effects.NewExtensionLocalPrepared(admission.Invocation.InvokingPath, extension.Identity.ID, response.Writes, admission, transition) if err != nil { return nil, err } diff --git a/boatstack/program_observer.go b/boatstack/program_observer.go index 520f3ee..e0dbd7a 100644 --- a/boatstack/program_observer.go +++ b/boatstack/program_observer.go @@ -9,6 +9,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) type programObserver struct { @@ -39,6 +40,9 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR if flow.Runtime == nil { return model.Observation{}, fmt.Errorf("program runtime %q observer is unavailable", flow.Identity.ID) } + if err := requireComponentRuntimeCapabilities(request.Capabilities, flow.Manifest.Capabilities, flow.Identity.ID); err != nil { + return model.Observation{}, err + } projection := observation projection.ProgramFingerprint = o.program.Fingerprint() snapshot, encodeErr := json.Marshal(projection) @@ -50,6 +54,7 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR ProgramID: flow.Identity.ID, ProgramVersion: flow.Identity.Version, ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, RepositoryRoot: request.Invocation.InvokingPath, Snapshot: snapshot, Settings: flow.Manifest.Settings, + Capabilities: boundedComponentCapabilities(request.Capabilities, flow.Manifest.Capabilities), }) if invokeErr != nil { return model.Observation{}, fmt.Errorf("program runtime %q observation failed: %w", flow.Identity.ID, invokeErr) @@ -97,6 +102,9 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR if extension.Manifest.ExecutableSHA256 != "" && !observation.ExecutableRuntimeAdmitted(o.program.Fingerprint()) { continue } + if requireComponentRuntimeCapabilities(request.Capabilities, extension.Manifest.Capabilities, extension.Identity.ID) != nil { + continue + } if extension.Runtime == nil { return model.Observation{}, fmt.Errorf("extension %q observer is unavailable", extension.Identity.ID) } @@ -111,6 +119,7 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR 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, + Capabilities: boundedComponentCapabilities(request.Capabilities, extension.Manifest.Capabilities), }) if err != nil { return model.Observation{}, fmt.Errorf("extension %q observation failed: %w", extension.Identity.ID, err) @@ -148,6 +157,9 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR if request.VerifyTransitionID != "" { transition, ok := o.program.RuntimeRegistry().Lookup(request.VerifyTransitionID) if ok && transition.Origin.Kind == catalog.OriginControlProgram && flow.Manifest.RuntimeMode == control.ProgramRuntimeProtocol { + if err := requireComponentRuntimeCapabilities(request.Capabilities, flow.Manifest.Capabilities, flow.Identity.ID); err != nil { + return model.Observation{}, err + } snapshot, encodeErr := json.Marshal(observation) if encodeErr != nil { return model.Observation{}, encodeErr @@ -157,6 +169,7 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR ProgramID: flow.Identity.ID, ProgramVersion: flow.Identity.Version, ProgramFingerprint: o.program.Fingerprint(), CorrelationID: request.Invocation.Correlation, RepositoryRoot: request.Invocation.InvokingPath, TransitionID: transition.ID, Snapshot: snapshot, Settings: flow.Manifest.Settings, + Capabilities: boundedComponentCapabilities(request.Capabilities, flow.Manifest.Capabilities), }) if invokeErr != nil { return model.Observation{}, invokeErr @@ -173,6 +186,9 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR if !exists || extension.Runtime == nil { return model.Observation{}, fmt.Errorf("extension verifier %q is unavailable", transition.Verifier) } + if err := requireComponentRuntimeCapabilities(request.Capabilities, extension.Manifest.Capabilities, extension.Identity.ID); err != nil { + return model.Observation{}, err + } snapshot, err := json.Marshal(observation) if err != nil { return model.Observation{}, err @@ -182,6 +198,7 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR 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, + Capabilities: boundedComponentCapabilities(request.Capabilities, extension.Manifest.Capabilities), }) if err != nil { return model.Observation{}, err @@ -197,6 +214,24 @@ func (o programObserver) Observe(ctx context.Context, request ports.ObservationR return observation, nil } +func requireComponentRuntimeCapabilities(granted, declared []catalog.Capability, component string) error { + if !catalog.NewCapabilitySet(declared...)[catalog.CapabilityCommandExecute] { + return fmt.Errorf("component %q does not declare command.execute", component) + } + return protocol.RequireCapability(granted, catalog.CapabilityCommandExecute, "component runtime "+component) +} + +func boundedComponentCapabilities(granted, declared []catalog.Capability) []catalog.Capability { + grantedSet := catalog.NewCapabilitySet(granted...) + result := catalog.CapabilitySet{} + for _, capability := range declared { + if grantedSet[capability] { + result[capability] = true + } + } + return result.Sorted() +} + func validateProgramRuntimeResponse(flow control.CompiledProgramRuntime, operation control.ProgramRuntimeOperation, correlation string, response control.ProgramRuntimeResponse) error { if response.ProtocolVersion != control.ProgramRuntimeProtocolVersion || response.Operation != operation || response.ProgramID != flow.Identity.ID || response.ProgramVersion != flow.Identity.Version || response.CorrelationID != correlation { diff --git a/boatstack/program_observer_test.go b/boatstack/program_observer_test.go index 2fdefb6..c56096e 100644 --- a/boatstack/program_observer_test.go +++ b/boatstack/program_observer_test.go @@ -48,6 +48,7 @@ func (e isolatedObservationExtension) ExtensionManifest(context.Context) (contro manifest := control.ExtensionManifest{ ID: e.id, Version: "1.0.0", ProtocolVersion: control.ExtensionProtocolVersion, SettingsSchema: json.RawMessage(`{"type":"object"}`), Facts: []string{e.id + ".fact"}, + Capabilities: []control.Capability{control.CapabilityCommandExecute}, PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", } if e.executable { @@ -93,7 +94,7 @@ func TestExecutableExtensionObservationWaitsForVerifiedProgramBinding(t *testing 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}) + observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation, Capabilities: []control.Capability{control.CapabilityCommandExecute}}) if err != nil { t.Fatal(err) } @@ -102,7 +103,7 @@ func TestExecutableExtensionObservationWaitsForVerifiedProgramBinding(t *testing } base.RecordedProgramFingerprint = program.Fingerprint() observer.base = fixedObservation{value: base} - observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation, Capabilities: []control.Capability{control.CapabilityCommandExecute}}) if err != nil { t.Fatal(err) } @@ -127,7 +128,7 @@ func TestExtensionObserversConsumeOneOrderIndependentProjection(t *testing.T) { 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}) + }).Observe(context.Background(), ports.ObservationRequest{Invocation: invocation, Capabilities: []control.Capability{control.CapabilityCommandExecute}}) if err != nil { t.Fatal(err) } diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index 42abaa1..2fd3eab 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -50,6 +50,7 @@ type TransitionID = catalog.TransitionID type Transition = catalog.Transition type GoalScope = catalog.GoalScope type AuthorityClass = catalog.AuthorityClass +type Capability = catalog.Capability const ( GoalScopeOptionalPreserve = catalog.GoalScopeOptionalPreserve @@ -58,6 +59,13 @@ const ( AuthorityHuman = catalog.AuthorityHuman AuthorityAutonomy = catalog.AuthorityAutonomy AuthorityProvider = catalog.AuthorityProvider + + CapabilityRepositoryWrite = catalog.CapabilityRepositoryWrite + CapabilityCommandExecute = catalog.CapabilityCommandExecute + CapabilityProductMutate = catalog.CapabilityProductMutate + CapabilityPublicationPrepare = catalog.CapabilityPublicationPrepare + CapabilityPublicationPublish = catalog.CapabilityPublicationPublish + CapabilityHumanApprove = catalog.CapabilityHumanApprove ) type AuthorityReceipt = protocol.AuthorityReceipt diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index c64b29c..81ac990 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -23,6 +23,18 @@ func TestPublicProtocolCanBeConstructedWithoutInternalPackages(t *testing.T) { } } +func TestSDKPreservesCapabilityAdmissionProtocol(t *testing.T) { + // control-law: SDK and CLI consume the same versioned prescription fields + raw := []byte(`{"schema_version":4,"operation":"apply","repository":"/repo","host":"sdk","correlation_id":"correlation","flow_id":"flow","transition_id":"program/write","prescription":{"schema_version":2,"id":"prx-test","transition_id":"program/write","expected_state_revision":7,"expected_program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expected_snapshot_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","authority_fingerprint":"auth-test","required_capabilities":["repository.write"],"effective_capabilities":["repository.write"]}}`) + var request sdk.Request + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatal(err) + } + if request.Prescription.AuthorityFingerprint != "auth-test" || len(request.Prescription.RequiredCapabilities) != 1 || request.Prescription.RequiredCapabilities[0] != sdk.CapabilityRepositoryWrite { + t.Fatalf("SDK lost capability admission: %#v", request.Prescription) + } +} + func TestLowLevelSDKRequiresAndAcceptsExactlyOneNonStandardProgramRuntime(t *testing.T) { // control-law: low-level-sdk-never-inserts-or-multiplies-standard-flow if _, err := sdk.NewKernel(""); err == nil { @@ -57,7 +69,7 @@ func (syntheticFlow) RuntimeManifest(context.Context) (control.ProgramRuntimeMan ID: id, Version: 1, SelectionClass: control.SelectionProgramProgress, 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}, + Authority: []control.AuthorityClass{control.AuthorityRepository}, RequiredCapabilities: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute}, 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", @@ -80,6 +92,7 @@ func (syntheticFlow) RuntimeManifest(context.Context) (control.ProgramRuntimeMan 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}, + Capabilities: []control.Capability{control.CapabilityRepositoryWrite, control.CapabilityCommandExecute}, ConfigurationSchema: json.RawMessage(`{"type":"object"}`), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", }, nil diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index 444d106..ac553dd 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -5,70 +5,70 @@ Registry size: **63** transitions. Event classes: authority 9; owned-local 32; o Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `goal`. -| 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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | -| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | -| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / 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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | -| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | -| `installation.update` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | -| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | -| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | -| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | -| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | -| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | -| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | -| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | -| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | -| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | PROGRAM_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` | control-program:`boatstack.standard@1.0.0`
`537f5bd4ad3dbef327f6525d7d3c54bae3d0bb42ac0afe1619f580891a5e9207` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | +| Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | +| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `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` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | +| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | +| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | +| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | +| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `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`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | +| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | +| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | +| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | +| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | +| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`050e141a0e30430ae9e9340eb4722c93e5d76b3ddb41f58eb260dc59d4e291ef` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | +| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | +| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | +| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`1df082e8e42dfcc8af9a60c2ecdb118e6d5c9d9420ec219d92ddc35935b161ef` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `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/capability-authority-boundary.md b/docs/architecture/capability-authority-boundary.md new file mode 100644 index 0000000..efba3cb --- /dev/null +++ b/docs/architecture/capability-authority-boundary.md @@ -0,0 +1,77 @@ +# Capability and authority boundary + +A repository Control Program may narrow its executable surface. It cannot grant +itself authority. + +```text +program capability surface + + transition requirements + + kernel effect minimum + + external authority receipts + -> exact admission or deterministic refusal + -> effect-boundary recheck +``` + +The admission law is: + +```text +transition required + kernel effect required + subset of program declared + subset of externally granted +``` + +The effective set is the complete required set. Boatstack never continues with +a partial intersection. + +## Capability vocabulary + +| Capability | Kernel-mediated effect | +| --- | --- | +| `repository.write` | Install or remove Boatstack-managed repository and controller resources. | +| `command.execute` | Invoke a configured command or component runtime. | +| `product.mutate` | Change goal, plan, workspace, gate, evidence, delivery, or publication state. | +| `publication.prepare` | Create a publication preview artifact. | +| `publication.publish` | Perform or correct an external publication. | +| `human.approve` | Cross an admission policy that requires human approval. | + +`repository.read` is intentionally absent. Current observation uses ambient +host reads and cannot distinguish that capability at an enforceable boundary. +Capability names are exact identifiers with no aliases or implied hierarchy. + +The kernel maps existing authority receipt classes to grants. Repository, +human, autonomy, and external-provider receipts remain the evidence sources; +program declarations are never evidence. A repository-policy receipt is +derived only from current verified configuration evidence. Human approval and +external publication remain separate grants. + +## Exact bindings + +A prescription binds transition, state, program fingerprint, authority-source +fingerprint, required capabilities, and effective capabilities. Apply must use +the same context; changed authority requires re-resolution. Admissions and +receipts also retain granted capabilities and non-secret authority provenance. +A transition receipt is evidence only and is not accepted as an authority +receipt. + +Recovery uses a new admission. It does not inherit stronger capability from a +failed transition. Program capability-surface changes alter the program +fingerprint and invalidate old prescriptions. + +## Command execution frontier + +`command.execute` is a high-power capability. An arbitrary subprocess can use +filesystem permissions, Git credentials, environment credentials, and network +access to perform effects outside Boatstack's mediated handlers. Without an OS +sandbox or external broker, these finer guarantees have the following scope: + +| Guarantee | Status | +| --- | --- | +| Program cannot self-grant a Boatstack capability | Supported | +| Kernel-mediated writes/publication require their exact capability | Supported | +| Helpers and component runtimes receive the admitted context | Supported | +| Arbitrary command cannot write files or publish through ambient tools | Not enforceable with arbitrary command execution | +| Complete inventory of host credential effects | Unknown | + +Boatstack does not claim process isolation. The capability boundary governs +kernel-mediated effects; `command.execute` explicitly crosses into the host +process trust domain. diff --git a/docs/architecture/control-program-abi.md b/docs/architecture/control-program-abi.md index 8b4c5dc..8011735 100644 --- a/docs/architecture/control-program-abi.md +++ b/docs/architecture/control-program-abi.md @@ -23,7 +23,7 @@ repository source | `program_id` | identity | Lowercase semantic ID without `/`; included because it qualifies every transition ID. | | `program_version` | descriptive author identity | Non-empty deterministic token; excluded because changing it alone does not change executable semantics. | | `requires_runtime` | compatibility | Exact `>=MAJOR.MINOR.PATCH` minimum; checked before registry construction and excluded from the executable fingerprint. | -| `capabilities` | executable semantics | Exact, duplicate-free sets of used effect and verifier IDs; sorted canonically. Declaration does not grant authority. | +| `capabilities` | executable semantics | Exact, duplicate-free `effects`, `verifiers`, and `capability_surface` sets. The capability surface is the program's maximum intended effect surface; declaration does not grant authority. | | `owned_resources` | executable semantics | Exact, duplicate-free set of resources written by transitions; sorted canonically. | | `goal_contracts` | executable semantics | Sorted by goal; conjunctive conditions and their set-valued members are sorted. | | `transitions` | executable semantics | Local declarations are normalized, program-qualified, validated, and sorted by complete ID for hashing. | @@ -69,3 +69,16 @@ normalized transition graph, exact goal contracts, capability bindings, resource ownership, and program-qualified identity. Thus representation-only changes remain stable while every kernel-observable control-law change changes the fingerprint. + +## Capability boundary + +Each controllable transition declares `required_capabilities`. Validation +requires that set, plus the kernel-owned minimum for its concrete effect, to be +inside the program capability surface. Admission then requires all of those +capabilities from external authority. Missing or unknown capabilities fail +closed; intersection never produces a partially admitted transition. + +Prescriptions bind the authority source identity and exact required/effective +capability set. Admissions also retain the broader granted set. Effects receive +only the exact effective set and recheck the kernel minimum before execution. +See [Capability and authority boundary](capability-authority-boundary.md). diff --git a/release-notes/2026-08-12-capability-authority-boundary.md b/release-notes/2026-08-12-capability-authority-boundary.md new file mode 100644 index 0000000..65d6014 --- /dev/null +++ b/release-notes/2026-08-12-capability-authority-boundary.md @@ -0,0 +1,6 @@ +### Keep program declarations separate from authority + +Repository Control Programs now declare a bounded capability surface while the +kernel independently classifies effects and requires matching external +authority. Prescriptions, admissions, effects, and receipts preserve that exact +context, and missing or changed capabilities fail closed.