Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions boatstack/delivery/program_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ func programFixture() delivery.ProgramManifest {
advance.TargetPhases = []delivery.ProtocolPhase{delivery.PhaseTerminal}
advance.ObjectiveKinds = []delivery.ObjectiveKind{delivery.ObjectiveVerified}
advance.Authority = []delivery.AuthorityClass{delivery.AuthorityHuman, delivery.AuthorityRepository}
advance.RequiredCapabilities = []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityProductMutate}
advance.Effect = "program.advance"
advance.LocalEffects = []delivery.EffectID{"program.advance"}
advance.Prescription = delivery.Prescription{Operation: "advance", Arguments: []string{"--exact"}, ExpectedPostcondition: "terminal"}
Expand All @@ -331,15 +332,15 @@ func programFixture() delivery.ProgramManifest {
SchemaVersion: delivery.ProgramSchemaVersion, ProgramID: "test-program", ProgramVersion: "1", RequiresRuntime: ">=1.0.0",
Capabilities: delivery.ProgramCapabilities{
Effects: []string{"program.advance", "program.recover"}, Verifiers: []string{"program.current", "program.terminal"},
CapabilitySurface: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute},
CapabilitySurface: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate},
},
OwnedResources: []string{"program.state"}, ObjectiveContracts: []delivery.ObjectiveContract{{ObjectiveKind: delivery.ObjectiveVerified, Conditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, "terminal")}}},
Transitions: []delivery.ProgramTransition{advance, recovery},
}
}

func runtimeFixture() delivery.RuntimeCompatibility {
return delivery.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}, Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityHumanApprove}}
return delivery.RuntimeCompatibility{Version: "v1.2.3", Effects: []string{"program.advance", "program.recover", "alternate.effect"}, Verifiers: []string{"program.current", "program.terminal", "alternate.verifier"}, Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate, delivery.CapabilityHumanApprove}}
}

func loadManifest(t *testing.T, manifest delivery.ProgramManifest) delivery.ControlProgram {
Expand Down
13 changes: 7 additions & 6 deletions boatstack/internal/softwaredelivery/catalog/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ package catalog
import (
"fmt"
"sort"
"strings"

"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
)

// Capability names a kernel-enforced class of effect. A declaration narrows
Expand Down Expand Up @@ -131,17 +132,17 @@ func KernelEffectCapabilities(transition Transition) []Capability {
if transition.RuntimeExecution {
required[CapabilityCommandExecute] = true
}
for _, facet := range transition.OwnedFacets {
if facet == model.StateFacetProduct {
required[CapabilityProductMutate] = 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, "objective.") || 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
}
Expand Down
19 changes: 18 additions & 1 deletion boatstack/internal/softwaredelivery/catalog/capability_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package catalog

import "testing"
import (
"testing"

"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model"
)

func TestCapabilityVocabularyFailsClosed(t *testing.T) {
if _, err := NormalizeCapabilities("test", []Capability{"production.nuke"}); err == nil {
Expand All @@ -15,6 +19,7 @@ func TestKernelEffectClassificationCannotBeWeakenedByTransitionDeclaration(t *te
// control-law: repository-authored requirements cannot under-classify a kernel effect
transition := Transition{
ID: "program/publish", Class: EventOwnedExternal, Effect: "publication.execute",
OwnedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
RequiredCapabilities: []Capability{CapabilityRepositoryWrite},
DeclaredCapabilities: []Capability{CapabilityRepositoryWrite, CapabilityCommandExecute, CapabilityProductMutate, CapabilityPublicationPublish},
}
Expand All @@ -26,6 +31,18 @@ func TestKernelEffectClassificationCannotBeWeakenedByTransitionDeclaration(t *te
}
}

func TestProductOwnershipRequiresProductMutationForArbitraryEffect(t *testing.T) {
transition := Transition{
ID: "program/advance", Class: EventOwnedLocal, Effect: "acme.advance",
OwnedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
RequiredCapabilities: []Capability{CapabilityRepositoryWrite},
}
required := NewCapabilitySet(RequiredCapabilities(transition)...)
if !required[CapabilityProductMutate] {
t.Fatalf("product-owning arbitrary effect was under-classified: %v", required.Sorted())
}
}

func TestCapabilityClassesHaveNoImplicitHierarchy(t *testing.T) {
granted := AuthorityCapabilities(AuthoritySet{AuthorityProvider: true})
if !granted[CapabilityPublicationPublish] || granted[CapabilityPublicationPrepare] || granted[CapabilityRepositoryWrite] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ func TestDeclarativeAssignmentsRejectApplyTimeOnlyValueConstraints(t *testing.T)
}
}

func TestStateAssignmentMustSatisfyEveryTargetCondition(t *testing.T) {
assignment := literalAssignment("delivery", string(model.DeliveryPublished))
transition := Transition{
TargetConditions: []FacetCondition{
{Facet: model.FacetDelivery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.DeliveryPublished)}},
{Facet: model.FacetDelivery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.DeliveryTerminal)}},
},
}
if stateAssignmentMatchesTarget(transition, assignment) {
t.Fatal("assignment matched only the first of two target conditions")
}
}

func closureTransition(source FacetCondition, assignments ...StateAssignment) Transition {
transition := Transition{
ID: "test.transition", SourcePhases: []model.ProtocolPhase{model.PhaseActive}, TargetPhases: []model.ProtocolPhase{model.PhaseActive},
Expand Down
20 changes: 20 additions & 0 deletions boatstack/internal/softwaredelivery/catalog/state_facet.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@ func DurableStateFacetPolicy(transition Transition) (StateFacetPolicy, error) {
return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: writes}, nil
}

// RecoveryStateFacets reconstructs the interrupted transition's write envelope
// from admission-bound capability identity. Privileged core facets remain
// closed over the fixed transition IDs that own them; repository-authored
// programs can recover only control and admitted product mutations.
func RecoveryStateFacets(id TransitionID, required []Capability) []model.StateFacet {
switch id {
case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.update":
return []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}
case "installation.initialize", "installation.reconcile-update":
return []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}
case "repository.attach", "catalog.reconcile":
return []model.StateFacet{model.StateFacetControl, model.StateFacetProgram}
}
writes := []model.StateFacet{model.StateFacetControl}
if NewCapabilitySet(required...).ContainsAll([]Capability{CapabilityProductMutate}) {
writes = append(writes, model.StateFacetProduct)
}
return writes
}

func containsStateFacet(values []model.StateFacet, wanted model.StateFacet) bool {
for _, value := range values {
if value == wanted {
Expand Down
21 changes: 17 additions & 4 deletions boatstack/internal/softwaredelivery/catalog/transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -858,22 +858,35 @@ func stateAssignmentMatchesTarget(t Transition, assignment StateAssignment) bool
if target.Facet != facet {
continue
}
if len(target.Statuses) != 1 || target.Statuses[0] != model.FactKnown {
if !containsFactStatus(target.Statuses, model.FactKnown) {
return false
}
if len(target.Values) == 0 {
return true
continue
}
matched := false
for _, value := range target.Values {
if value == *assignment.Value {
return true
matched = true
break
}
}
return false
if !matched {
return false
}
}
return true
}

func containsFactStatus(values []model.FactStatus, wanted model.FactStatus) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}

func cloneConditions(values []FacetCondition) []FacetCondition {
result := make([]FacetCondition, len(values))
for index, value := range values {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) {
t.Fatalf("canonical committed journal count=%d err=%v", len(committedJournals), err)
}
committedRaw, err := os.ReadFile(committedJournals[0])
if err != nil || !bytes.Contains(committedRaw, []byte(committed.Receipt.ID)) || !bytes.Contains(committedRaw, []byte("committed_effects")) {
if err != nil || !bytes.Contains(committedRaw, []byte(committed.Receipt.ID)) || !bytes.Contains(committedRaw, []byte("committed_effects")) ||
!bytes.Contains(committedRaw, []byte(`"schema_version": 8`)) || !bytes.Contains(committedRaw, []byte(`"allowed_state_facets"`)) {
t.Fatalf("committed journal lacks its complete transition fact: %v %q", err, committedRaw)
}
// Simulate a crash after canonical commit but before the passive receipt
Expand Down
10 changes: 6 additions & 4 deletions boatstack/internal/softwaredelivery/effects/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal,
}

type journalRecord struct {
SchemaVersion int `json:"schema_version"`
Admission protocol.Admission `json:"admission"`
TransitionID catalog.TransitionID `json:"transition_id"`
TransitionClass catalog.EventClass `json:"transition_class"`
SchemaVersion int `json:"schema_version"`
Admission protocol.Admission `json:"admission"`
TransitionID catalog.TransitionID `json:"transition_id"`
TransitionClass catalog.EventClass `json:"transition_class"`
// AllowedStateFacets preserves the current schema-8 record shape. Recovery
// authority is reconstructed from Admission.RequiredCapabilities instead.
AllowedStateFacets []model.StateFacet `json:"allowed_state_facets"`
ReconcilesProgram bool `json:"reconciles_program,omitempty"`
Status string `json:"status"`
Expand Down
24 changes: 5 additions & 19 deletions boatstack/internal/softwaredelivery/effects/journal_schema_test.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
package effects

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestPriorJournalSchemaRequiresExplicitReset(t *testing.T) {
path := filepath.Join(t.TempDir(), "adm-prior.pending")
raw := []byte(`{"schema_version":7,"admission":{"id":"adm-prior"},"transition_id":"plan.create","transition_class":"owned-local","status":"begun"}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol"
)

if _, err := readJournal(path); err == nil || !strings.Contains(err.Error(), "invalid transaction journal") {
t.Fatalf("read prior journal schema error = %v, want explicit invalid journal refusal", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(raw) {
t.Fatalf("prior journal changed during refusal:\n got %s\nwant %s", got, raw)
func TestInstallationUpdateKeepsCurrentJournalSchema(t *testing.T) {
if protocol.JournalSchemaVersion != 8 {
t.Fatalf("journal schema = %d, want current schema 8 for in-flight installation updates", protocol.JournalSchemaVersion)
}
}
2 changes: 1 addition & 1 deletion boatstack/internal/softwaredelivery/effects/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, in
if err != nil {
return nil, err
}
allowed := model.UnionStateFacets(record.AllowedStateFacets, []model.StateFacet{model.StateFacetControl})
allowed := catalog.RecoveryStateFacets(record.TransitionID, record.Admission.RequiredCapabilities)
if _, err := validateAllowedStateFacets(recovery, staged, allowed); err != nil {
return nil, err
}
Expand Down
12 changes: 12 additions & 0 deletions boatstack/internal/softwaredelivery/effects/recovery_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package effects

import (
"bytes"
"context"
"os"
"os/exec"
Expand Down Expand Up @@ -152,6 +153,17 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing.
if err := journalBeforeRestart.RequireRecovery(ctx, admission.ID, "simulated process loss after effect"); err != nil {
t.Fatal(err)
}
basePendingPath, err := journalBeforeRestart.pendingPath(ctx, admission)
if err != nil {
t.Fatal(err)
}
basePending, err := os.ReadFile(basePendingPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(basePending, []byte(`"schema_version": 8`)) || !bytes.Contains(basePending, []byte(`"allowed_state_facets"`)) {
t.Fatalf("pending update journal is not current-main schema 8: %s", basePending)
}

restartedInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "after-restart")
if err != nil {
Expand Down
24 changes: 23 additions & 1 deletion boatstack/internal/softwaredelivery/effects/state_facet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func TestRecoveryReportsActualStateDeltaInsteadOfInterruptedEnvelope(t *testing.
staged.UpdatedAt = time.Unix(101, 0).UTC()
prior, _ := durable.EncodeState(before)
target, _ := durable.EncodeState(staged)
record := journalRecord{TransitionID: "plan.create", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, Mutations: []ports.ResourceMutation{{
record := journalRecord{TransitionID: "plan.create", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, Admission: protocol.Admission{RequiredCapabilities: []catalog.Capability{catalog.CapabilityProductMutate}}, Mutations: []ports.ResourceMutation{{
Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target,
StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
}}}
Expand All @@ -151,6 +151,28 @@ func TestRecoveryReportsActualStateDeltaInsteadOfInterruptedEnvelope(t *testing.
}
}

func TestRecoveryCannotWidenFacetsOutsideAdmissionCapabilities(t *testing.T) {
before := ownershipState()
after := before
after.Plan = model.PlanApproved
prior, _ := durable.EncodeState(before)
target, _ := durable.EncodeState(after)
record := journalRecord{
TransitionID: "repository-program/advance",
AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct},
Admission: protocol.Admission{RequiredCapabilities: []catalog.Capability{
catalog.CapabilityRepositoryWrite,
}},
Mutations: []ports.ResourceMutation{{
Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target,
StateFacets: []model.StateFacet{model.StateFacetProduct},
}},
}
if _, err := recoveryStateFacets(record, "recovery.resume", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") {
t.Fatalf("recovery widened admission-bound facets: %v", err)
}
}

func TestJournalRejectsReceiptFacetMismatch(t *testing.T) {
err := validateCommittedMutationFacts(catalog.EventOwnedLocal, []ports.ResourceMutation{{StateFacets: []model.StateFacet{model.StateFacetControl}}}, []model.StateFacet{model.StateFacetProduct}, nil)
if err == nil || !strings.Contains(err.Error(), "do not match staged mutation facets") {
Expand Down
4 changes: 2 additions & 2 deletions boatstack/program_effects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (protocolStateRuntime) RuntimeManifest(context.Context) (delivery.ProgramRu
SourcePhases: []delivery.ProtocolPhase{delivery.PhaseActive}, TargetPhases: []delivery.ProtocolPhase{delivery.PhaseActive},
ObjectiveKinds: []delivery.ObjectiveKind{delivery.ObjectiveVerified}, RequiredIdentity: []string{"repository-id", "git-common-id", "worktree-id"},
Authority: []delivery.AuthorityClass{delivery.AuthorityHuman, delivery.AuthorityRepository},
RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute}, RequiredEvidence: []string{"snapshot", "objective"},
RequiredCapabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate}, RequiredEvidence: []string{"snapshot", "objective"},
OwnedResources: []string{resource}, OwnedFacets: []delivery.StateFacet{delivery.StateFacetControl, delivery.StateFacetProduct},
StateEffect: delivery.StateEffect{Kind: delivery.StateEffectAssignments, Assignments: []delivery.StateAssignment{{Facet: "delivery", Value: &published}}},
Effect: publishEffect, LocalEffects: []delivery.EffectID{publishEffect}, Idempotent: true,
Expand All @@ -109,7 +109,7 @@ func (protocolStateRuntime) RuntimeManifest(context.Context) (delivery.ProgramRu
ObjectiveContracts: []delivery.ObjectiveContract{{ObjectiveKind: delivery.ObjectiveVerified, Conditions: []delivery.FacetCondition{delivery.KnownCondition(delivery.FacetDelivery, string(model.DeliveryPublished))}}},
Transitions: []delivery.Transition{publish, recover}, OwnedResources: []string{resource},
Effects: []string{string(publishEffect), string(recoverEffect)}, Verifiers: []string{"fixture.state.published", "fixture.state.recovered"},
Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute},
Capabilities: []delivery.Capability{delivery.CapabilityRepositoryWrite, delivery.CapabilityCommandExecute, delivery.CapabilityProductMutate},
ConfigurationSchema: json.RawMessage(`{"type":"object"}`), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt",
}, nil
}
Expand Down
Loading