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.