From f4df28c7c69e05f5bd333238545d9f2318e0a4e6 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 07:58:45 +0100 Subject: [PATCH 1/2] Enforce durable state facet ownership --- boatstack/internal/effects/driver.go | 14 ++- boatstack/internal/effects/journal.go | 18 ++- boatstack/internal/effects/prepared.go | 5 + boatstack/internal/effects/receipts.go | 4 +- boatstack/internal/effects/recovery.go | 24 +++- boatstack/internal/effects/revision.go | 12 ++ boatstack/internal/effects/state_facet.go | 96 +++++++++++++++ .../internal/effects/state_facet_test.go | 115 ++++++++++++++++++ .../internal/kernel/catalog/state_facet.go | 86 +++++++++++++ .../kernel/catalog/state_facet_test.go | 47 +++++++ .../internal/kernel/catalog/transition.go | 3 + .../internal/kernel/durable/state_facet.go | 73 +++++++++++ .../kernel/durable/state_facet_test.go | 43 +++++++ boatstack/internal/kernel/engine/engine.go | 2 +- .../internal/kernel/engine/engine_test.go | 3 + .../internal/kernel/model/state_facet.go | 57 +++++++++ boatstack/internal/kernel/ports/ports.go | 24 ++-- boatstack/internal/kernel/protocol/journal.go | 2 +- boatstack/internal/kernel/protocol/receipt.go | 18 ++- .../protocol/receipt_capability_test.go | 2 +- .../kernel/protocol/receipt_fact_test.go | 26 +++- boatstack/sdk/sdk.go | 6 + boatstack/sdk/sdk_test.go | 5 +- ...026-08-12-durable-state-facet-ownership.md | 6 + 24 files changed, 663 insertions(+), 28 deletions(-) create mode 100644 boatstack/internal/effects/state_facet.go create mode 100644 boatstack/internal/effects/state_facet_test.go create mode 100644 boatstack/internal/kernel/catalog/state_facet.go create mode 100644 boatstack/internal/kernel/catalog/state_facet_test.go create mode 100644 boatstack/internal/kernel/durable/state_facet.go create mode 100644 boatstack/internal/kernel/durable/state_facet_test.go create mode 100644 boatstack/internal/kernel/model/state_facet.go create mode 100644 release-notes/2026-08-12-durable-state-facet-ownership.md diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go index 5782f65..013277f 100644 --- a/boatstack/internal/effects/driver.go +++ b/boatstack/internal/effects/driver.go @@ -208,8 +208,11 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } mutations = append(mutations, bindingMutation) } + var facetGroups [][2]durable.State + facetGroups = append(facetGroups, [2]durable.State{state, next}) if transition.ID == "workspace.cut" { parked := parkedSourceState(state, next.Revision, transition.ID, d.clock.Now()) + facetGroups = append(facetGroups, [2]durable.State{state, parked}) parkedRaw, encodeErr := durable.EncodeState(parked) if encodeErr != nil { return nil, encodeErr @@ -271,7 +274,16 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans mutations[index].Resource = transition.OwnedResources[0] mutations[index].Owner = transition.Owner } - prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation} + changedFacets, facetErr := changedStateFacets(facetGroups...) + if facetErr != nil { + return nil, facetErr + } + changedFacets, facetErr = validateTransitionStateFacets(transition, changedFacets) + if facetErr != nil { + return nil, facetErr + } + mutations = annotateStateFacetMutations(mutations, changedFacets) + prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changedFacets} if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { return nil, err } diff --git a/boatstack/internal/effects/journal.go b/boatstack/internal/effects/journal.go index 30f5ae8..876db87 100644 --- a/boatstack/internal/effects/journal.go +++ b/boatstack/internal/effects/journal.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -113,6 +114,12 @@ func readJournal(path string) (journalRecord, error) { if err := record.Admission.ValidateIdentity(); err != nil || record.Admission.TransitionID != record.TransitionID { return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, err) } + for _, mutation := range record.Mutations { + facets, err := model.NormalizeStateFacets("journal mutation state facets", mutation.StateFacets) + if err != nil || !slices.Equal(facets, mutation.StateFacets) { + return journalRecord{}, fmt.Errorf("invalid transaction state facets in %s: %v", path, err) + } + } if record.Receipt != nil { if err := record.Receipt.Validate(); err != nil || record.Receipt.ID != record.ReceiptID || record.Receipt.AdmissionID != record.Admission.ID || record.Receipt.TransitionID != record.TransitionID { return journalRecord{}, fmt.Errorf("invalid committed transition fact in %s: %v", path, err) @@ -126,7 +133,7 @@ func readJournal(path string) (journalRecord, error) { receipt.DeliveryID != admission.Goal.DeliveryID || receipt.GoalScope != admission.GoalScope || receipt.GoalStatus != admission.GoalStatus { return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its exact admission", path) } - if err := validateCommittedMutationFacts(record.TransitionClass, record.Mutations, receipt.CommittedEffects); err != nil { + if err := validateCommittedMutationFacts(record.TransitionClass, record.Mutations, receipt.ChangedStateFacets, receipt.CommittedEffects); err != nil { return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) } } @@ -136,7 +143,14 @@ func readJournal(path string) (journalRecord, error) { return record, nil } -func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, facts []protocol.EffectFact) error { +func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, receiptFacets []model.StateFacet, facts []protocol.EffectFact) error { + var mutationFacets []model.StateFacet + for _, mutation := range mutations { + mutationFacets = model.UnionStateFacets(mutationFacets, mutation.StateFacets) + } + if !slices.Equal(mutationFacets, receiptFacets) { + return fmt.Errorf("receipt changed state facets %v do not match staged mutation facets %v", receiptFacets, mutationFacets) + } resourceFacts := make([]protocol.EffectFact, 0, len(facts)) boundarySettled := false for _, fact := range facts { diff --git a/boatstack/internal/effects/prepared.go b/boatstack/internal/effects/prepared.go index 35db3c6..a84b62f 100644 --- a/boatstack/internal/effects/prepared.go +++ b/boatstack/internal/effects/prepared.go @@ -26,6 +26,11 @@ type preparedEffect struct { transition catalog.Transition requiredCapabilities []catalog.Capability effectiveCapabilities []catalog.Capability + changedStateFacets []model.StateFacet +} + +func (p *preparedEffect) ChangedStateFacets() []model.StateFacet { + return append([]model.StateFacet(nil), p.changedStateFacets...) } func (p *preparedEffect) Manifest() []ports.ResourceMutation { diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go index 583cdf5..0b12096 100644 --- a/boatstack/internal/effects/receipts.go +++ b/boatstack/internal/effects/receipts.go @@ -159,6 +159,7 @@ type processEvent struct { RequiredCapabilities []catalog.Capability `json:"required_capabilities"` GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` CommittedEffects []protocol.EffectFact `json:"committed_effects"` + ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` Verification protocol.VerificationFact `json:"verification"` Recovery string `json:"recovery,omitempty"` Terminal string `json:"terminal"` @@ -199,7 +200,7 @@ func (s *ReceiptStore) Project(ctx context.Context, receipt protocol.TransitionR return err } event := processEvent{ - SchemaVersion: 4, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, + SchemaVersion: 5, 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), ProgramID: receipt.Program.ID, ProgramVersion: receipt.Program.Version, ProgramFingerprint: receipt.Program.Fingerprint, PrescriptionID: receipt.PrescriptionID, PriorStateRevision: receipt.PriorStateRevision, ResultingStateRevision: receipt.ResultingStateRevision, @@ -210,6 +211,7 @@ func (s *ReceiptStore) Project(ctx context.Context, receipt protocol.TransitionR RequiredCapabilities: append([]catalog.Capability(nil), receipt.RequiredCapabilities...), GrantedCapabilities: append([]catalog.Capability(nil), receipt.GrantedCapabilities...), CommittedEffects: append([]protocol.EffectFact(nil), receipt.CommittedEffects...), + ChangedStateFacets: append([]model.StateFacet(nil), receipt.ChangedStateFacets...), Verification: receipt.Verification, } for _, source := range receipt.AuthoritySources { diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go index 6310bd2..dd3c667 100644 --- a/boatstack/internal/effects/recovery.go +++ b/boatstack/internal/effects/recovery.go @@ -66,7 +66,12 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll return nil, err } mutations = append(mutations, closure...) - return &preparedEffect{mutations: mutations}, nil + changed, err := recoveryStateFacets(record, transition.ID) + if err != nil { + return nil, err + } + mutations = annotateStateFacetMutations(mutations, changed) + return &preparedEffect{mutations: mutations, changedStateFacets: changed}, nil } func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout ports.ControllerLayout, admission protocol.Admission, record journalRecord, pendingPath string) (ports.PreparedEffect, error) { @@ -129,7 +134,22 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po return nil, err } mutations = append(mutations, closure...) - return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation}, nil + changed, err := recoveryStateFacets(record, "workspace.reconcile") + if err != nil { + return nil, err + } + mutations = annotateStateFacetMutations(mutations, changed) + return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed}, nil +} + +func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID) ([]model.StateFacet, error) { + changed, err := journalStateFacets(record.Mutations) + if err != nil { + return nil, err + } + changed = model.UnionStateFacets(changed, []model.StateFacet{model.StateFacetControl}) + allowed := model.UnionStateFacets(catalog.DurableStateWritesForRecovery(record.TransitionID), []model.StateFacet{model.StateFacetControl}) + return validateAllowedStateFacets(recovery, changed, allowed) } func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.TransitionID, mutations []ports.ResourceMutation) ([]ports.ResourceMutation, error) { diff --git a/boatstack/internal/effects/revision.go b/boatstack/internal/effects/revision.go index 9184888..0bf850e 100644 --- a/boatstack/internal/effects/revision.go +++ b/boatstack/internal/effects/revision.go @@ -6,6 +6,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) @@ -43,6 +44,7 @@ func BindStateRevision(ctx context.Context, prepared ports.PreparedEffect, resol if state.ProgramFingerprint != "" && state.ProgramFingerprint != admission.ExpectedProgramFingerprint { return nil, fmt.Errorf("compiled control program changed before revision binding") } + before := state state.Revision, err = durable.NextRevision(state.Revision) if err != nil { return nil, err @@ -62,5 +64,15 @@ func BindStateRevision(ctx context.Context, prepared ports.PreparedEffect, resol } mutation.Resource, mutation.Owner = kernelStateResource, kernelStateOwner effect.mutations = append(effect.mutations, mutation) + changed, err := changedStateFacets([2]durable.State{before, state}) + if err != nil { + return nil, err + } + changed, err = validateTransitionStateFacets(transition, changed) + if err != nil { + return nil, err + } + effect.mutations = annotateStateFacetMutations(effect.mutations, changed) + effect.changedStateFacets = model.UnionStateFacets(effect.changedStateFacets, changed) return effect, nil } diff --git a/boatstack/internal/effects/state_facet.go b/boatstack/internal/effects/state_facet.go new file mode 100644 index 0000000..19d82b6 --- /dev/null +++ b/boatstack/internal/effects/state_facet.go @@ -0,0 +1,96 @@ +package effects + +import ( + "fmt" + "path/filepath" + + "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" +) + +func validateTransitionStateFacets(transition catalog.Transition, changed []model.StateFacet) ([]model.StateFacet, error) { + policy, err := catalog.DurableStateFacetPolicy(transition) + if err != nil { + return nil, err + } + return validateAllowedStateFacets(transition.ID, changed, policy.Writes) +} + +func validateAllowedStateFacets(transition catalog.TransitionID, changed, allowed []model.StateFacet) ([]model.StateFacet, error) { + canonical, err := model.NormalizeStateFacets("changed state facets", changed) + if err != nil { + return nil, err + } + allowedSet := map[model.StateFacet]bool{} + for _, facet := range allowed { + allowedSet[facet] = true + } + for _, facet := range canonical { + if !allowedSet[facet] { + return nil, fmt.Errorf("FACET_OWNERSHIP_VIOLATION: transition %q changed %q outside its kernel-approved durable state facets", transition, facet) + } + } + return canonical, nil +} + +func changedStateFacets(groups ...[2]durable.State) ([]model.StateFacet, error) { + var result []model.StateFacet + for _, group := range groups { + changed, err := durable.ChangedFacets(group[0], group[1]) + if err != nil { + return nil, err + } + result = model.UnionStateFacets(result, changed) + } + return result, nil +} + +// journalStateFacets consumes the semantic state delta that was validated and +// staged with the interrupted transaction. Recovery cannot widen it. +func journalStateFacets(mutations []ports.ResourceMutation) ([]model.StateFacet, error) { + var changed []model.StateFacet + for _, mutation := range mutations { + if filepath.Base(mutation.Path) != "state.json" { + continue + } + stateMutation := false + if mutation.PriorExists { + _, err := durable.DecodeState(mutation.Prior) + stateMutation = err == nil + } + if !stateMutation && !mutation.Delete && mutation.TargetLink == "" { + _, err := durable.DecodeState(mutation.Target) + stateMutation = err == nil + } + if !stateMutation { + continue + } + facets, err := model.NormalizeStateFacets("journal state facets", mutation.StateFacets) + if err != nil || len(facets) == 0 { + return nil, fmt.Errorf("STATE_FACET_UNCLASSIFIED: interrupted durable state mutation %s has no valid staged facets: %v", mutation.Path, err) + } + changed = model.UnionStateFacets(changed, facets) + } + return changed, nil +} + +func annotateStateFacetMutations(mutations []ports.ResourceMutation, facets []model.StateFacet) []ports.ResourceMutation { + for index := range mutations { + mutation := &mutations[index] + stateMutation := false + if mutation.PriorExists && filepath.Base(mutation.Path) == "state.json" { + _, err := durable.DecodeState(mutation.Prior) + stateMutation = err == nil + } + if !stateMutation && !mutation.Delete && mutation.TargetLink == "" && filepath.Base(mutation.Path) == "state.json" { + _, err := durable.DecodeState(mutation.Target) + stateMutation = err == nil + } + if stateMutation { + mutation.StateFacets = append([]model.StateFacet(nil), facets...) + } + } + return mutations +} diff --git a/boatstack/internal/effects/state_facet_test.go b/boatstack/internal/effects/state_facet_test.go new file mode 100644 index 0000000..3cb315a --- /dev/null +++ b/boatstack/internal/effects/state_facet_test.go @@ -0,0 +1,115 @@ +package effects + +import ( + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" +) + +func ownershipState() durable.State { + state := durable.Default(model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree"}, time.Unix(100, 0).UTC()) + state.ProgramFingerprint = strings.Repeat("a", 64) + return state +} + +func transitionFixture(id catalog.TransitionID, origin catalog.OriginKind, runtime bool) catalog.Transition { + return catalog.Transition{ID: id, Class: catalog.EventOwnedLocal, RuntimeExecution: runtime, Origin: catalog.TransitionOrigin{Kind: origin, ID: "fixture", Version: "1", ManifestFingerprint: "manifest"}} +} + +func requireOwnedChange(t *testing.T, transition catalog.Transition, before, after durable.State, wantError bool) { + t.Helper() + changed, err := changedStateFacets([2]durable.State{before, after}) + if err == nil { + _, err = validateTransitionStateFacets(transition, changed) + } + if wantError && (err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION")) { + t.Fatalf("expected ownership refusal, got %v", err) + } + if !wantError && err != nil { + t.Fatal(err) + } +} + +func TestStateFacetIsolationMatrix(t *testing.T) { + base := ownershipState() + knownGoal := model.Goal{ID: "goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"} + fixtures := []struct { + name string + transition catalog.Transition + mutate func(*durable.State) + refused bool + }{ + {"installation owns installation", transitionFixture("installation.update", catalog.OriginCoreSystem, false), func(s *durable.State) { s.RuntimeVersion = "v2" }, false}, + {"installation owns control", transitionFixture("installation.update", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Revision++ }, false}, + {"installation cannot mutate product", transitionFixture("installation.update", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Goal = knownGoal }, true}, + {"program reconcile owns program", transitionFixture("catalog.reconcile", catalog.OriginCoreSystem, false), func(s *durable.State) { s.ProgramFingerprint = strings.Repeat("b", 64) }, false}, + {"program reconcile owns control", transitionFixture("catalog.reconcile", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Revision++ }, false}, + {"program reconcile cannot mutate product", transitionFixture("catalog.reconcile", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Goal = knownGoal }, true}, + {"product owns product", transitionFixture("goal.configure", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Goal = knownGoal }, false}, + {"product owns control", transitionFixture("goal.configure", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Revision++ }, false}, + {"product cannot mutate installation", transitionFixture("goal.configure", catalog.OriginCoreSystem, false), func(s *durable.State) { s.RuntimeVersion = "v2" }, true}, + {"control cannot synthesize engagement", transitionFixture("recovery.escalate", catalog.OriginCoreSystem, false), func(s *durable.State) { s.Engagement = model.EngagementActive }, true}, + {"repository write cannot bypass product ownership", transitionFixture("repository-program/write", catalog.OriginControlProgram, true), func(s *durable.State) { s.Goal = knownGoal }, true}, + } + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + after := base + fixture.mutate(&after) + requireOwnedChange(t, fixture.transition, base, after, fixture.refused) + }) + } +} + +func TestMaintenancePreservesAbsentAndKnownGoalExactly(t *testing.T) { + transition := transitionFixture("installation.update", catalog.OriginCoreSystem, false) + transition.Policy.GoalScope = catalog.GoalScopeOptionalPreserve + transition.TargetPhases = []model.ProtocolPhase{model.PhaseDormant} + for _, goal := range []model.Goal{{}, {ID: "known", Kind: model.GoalOpenPR, DeliveryID: "delivery"}} { + state := ownershipState() + state.Goal = goal + admission := protocol.Admission{GoalStatus: model.FactAbsent} + if goal.Validate() == nil { + admission.Goal, admission.GoalStatus = goal, model.FactKnown + } + if err := applyStateTransition(&state, admission, transition); err != nil { + t.Fatal(err) + } + if state.Goal != goal { + t.Fatalf("goal changed from %#v to %#v", goal, state.Goal) + } + } +} + +func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { + before := ownershipState() + after := before + after.Goal = model.Goal{ID: "invented", Kind: model.GoalApprovedPlan, DeliveryID: "invented"} + prior, _ := durable.EncodeState(before) + target, _ := durable.EncodeState(after) + record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}}} + if _, err := recoveryStateFacets(record, "recovery.resume"); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") { + t.Fatalf("recovery accepted product contamination: %v", err) + } +} + +func TestRecoveryRefusesUnclassifiedDurableMutation(t *testing.T) { + state := ownershipState() + raw, _ := durable.EncodeState(state) + record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}} + if _, err := recoveryStateFacets(record, "recovery.resume"); err == nil || !strings.Contains(err.Error(), "STATE_FACET_UNCLASSIFIED") { + t.Fatalf("recovery accepted an unclassified state mutation: %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") { + t.Fatalf("receipt/state facet mismatch accepted: %v", err) + } +} diff --git a/boatstack/internal/kernel/catalog/state_facet.go b/boatstack/internal/kernel/catalog/state_facet.go new file mode 100644 index 0000000..6082576 --- /dev/null +++ b/boatstack/internal/kernel/catalog/state_facet.go @@ -0,0 +1,86 @@ +package catalog + +import ( + "fmt" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +type StateFacetPolicy struct { + Reads []model.StateFacet + Writes []model.StateFacet +} + +var allStateFacets = []model.StateFacet{ + model.StateFacetInstallation, + model.StateFacetProgram, + model.StateFacetControl, + model.StateFacetProduct, +} + +var controlStateFacets = []model.StateFacet{model.StateFacetControl} +var productStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetProduct} +var installationStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation} +var installationProgramStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram} +var programStateFacets = []model.StateFacet{model.StateFacetControl, model.StateFacetProgram} + +// DurableStateFacetPolicy is kernel-owned. A repository program's manifest +// cannot grant itself access to installation or program durable state. +func DurableStateFacetPolicy(transition Transition) (StateFacetPolicy, error) { + if transition.Class == EventObservedExternal { + return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...)}, nil + } + writes, known := durableStateWritesForID(transition.ID) + if known { + return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: writes}, nil + } + switch transition.Origin.Kind { + case OriginControlProgram: + if transition.RuntimeExecution { + return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), controlStateFacets...)}, nil + } + return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), productStateFacets...)}, nil + case OriginExtension: + return StateFacetPolicy{Reads: append([]model.StateFacet(nil), allStateFacets...), Writes: append([]model.StateFacet(nil), controlStateFacets...)}, nil + case OriginCoreSystem: + return StateFacetPolicy{}, fmt.Errorf("core transition %q has no durable state facet policy", transition.ID) + default: + return StateFacetPolicy{}, fmt.Errorf("transition %q has no valid durable state facet policy origin", transition.ID) + } +} + +// DurableStateWritesForRecovery returns the write envelope for a transition +// recorded in an interrupted journal. Unknown repository-defined transitions +// fail closed to control bookkeeping only. +func DurableStateWritesForRecovery(id TransitionID) []model.StateFacet { + if writes, ok := durableStateWritesForID(id); ok { + return writes + } + return append([]model.StateFacet(nil), controlStateFacets...) +} + +func durableStateWritesForID(id TransitionID) ([]model.StateFacet, bool) { + switch id { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.update": + return append([]model.StateFacet(nil), installationStateFacets...), true + case "installation.initialize", "installation.reconcile-update": + return append([]model.StateFacet(nil), installationProgramStateFacets...), true + case "repository.attach", "catalog.reconcile": + return append([]model.StateFacet(nil), programStateFacets...), true + case "invocation.rebind", "configuration.initialize", "configuration.mutate", "configuration.reconcile", "recovery.escalate": + return append([]model.StateFacet(nil), controlStateFacets...), true + case "engagement.begin", "engagement.renew", "engagement.release", "repository.detach", "goal.configure", + "plan.create", "plan.validate", "plan.approve", "plan.activate", "plan.amend", "plan.approve-amendment", "plan.invalidate", "plan.abandon", + "workspace.cut", "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon", "workspace.reconcile", + "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record", + "evidence.visual.attach", "evidence.approval.revoke", "delivery.slice.advance", + "publication.preview", "publication.execute", "publication.observe", "publication.reconcile", "publication.correct", "publication.abandon": + return append([]model.StateFacet(nil), productStateFacets...), true + case "recovery.resume", "recovery.rollback": + return append([]model.StateFacet(nil), controlStateFacets...), true + case "external.files-changed", "external.head-changed", "external.branch-changed", "external.runtime-disappeared", "external.configuration-drifted", "external.lease-expired", "external.host-interrupted", "external.ci-completed", "external.pr-opened", "external.pr-updated", "external.pr-closed", "external.pr-merged", "external.provider-unavailable": + return nil, true + default: + return nil, false + } +} diff --git a/boatstack/internal/kernel/catalog/state_facet_test.go b/boatstack/internal/kernel/catalog/state_facet_test.go new file mode 100644 index 0000000..46fab7d --- /dev/null +++ b/boatstack/internal/kernel/catalog/state_facet_test.go @@ -0,0 +1,47 @@ +package catalog + +import ( + "slices" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func TestKernelOwnsDurableStateFacetPolicies(t *testing.T) { + fixtures := []struct { + id TransitionID + writes []model.StateFacet + }{ + {"installation.update", []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}}, + {"installation.reconcile-update", []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation, model.StateFacetProgram}}, + {"catalog.reconcile", []model.StateFacet{model.StateFacetControl, model.StateFacetProgram}}, + {"goal.configure", []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}, + {"recovery.escalate", []model.StateFacet{model.StateFacetControl}}, + } + for _, fixture := range fixtures { + policy, err := DurableStateFacetPolicy(Transition{ID: fixture.id, Class: EventOwnedLocal, Origin: TransitionOrigin{Kind: OriginCoreSystem}}) + if err != nil { + t.Fatalf("%s: %v", fixture.id, err) + } + if !slices.Equal(policy.Writes, fixture.writes) { + t.Fatalf("%s writes=%v, want %v", fixture.id, policy.Writes, fixture.writes) + } + if !slices.Contains(policy.Reads, model.StateFacetProduct) { + t.Fatalf("%s lost read/write separation evidence", fixture.id) + } + } +} + +func TestRepositoryProgramCannotSelfGrantInstallationFacet(t *testing.T) { + transition := Transition{ + ID: "repository-program/advance", Class: EventOwnedLocal, RuntimeExecution: true, + Origin: TransitionOrigin{Kind: OriginControlProgram, ID: "repository-program", Version: "1", ManifestFingerprint: "manifest"}, + } + policy, err := DurableStateFacetPolicy(transition) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(policy.Writes, []model.StateFacet{model.StateFacetControl}) || slices.Contains(policy.Writes, model.StateFacetInstallation) { + t.Fatalf("repository program received installation ownership: %v", policy.Writes) + } +} diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go index 0727b8b..12d5558 100644 --- a/boatstack/internal/kernel/catalog/transition.go +++ b/boatstack/internal/kernel/catalog/transition.go @@ -402,6 +402,9 @@ func validateTransition(t Transition) error { if !t.Origin.Kind.Valid() || t.Origin.ID == "" || t.Origin.Version == "" || t.Origin.ManifestFingerprint == "" || t.Owner == "" { return fmt.Errorf("%s: transition origin, owner, version, and manifest fingerprint are required", t.ID) } + if _, err := DurableStateFacetPolicy(t); err != nil { + return err + } if !t.SelectionClass.Valid() { return fmt.Errorf("%s: invalid selection class %q", t.ID, t.SelectionClass) } diff --git a/boatstack/internal/kernel/durable/state_facet.go b/boatstack/internal/kernel/durable/state_facet.go new file mode 100644 index 0000000..8fbee15 --- /dev/null +++ b/boatstack/internal/kernel/durable/state_facet.go @@ -0,0 +1,73 @@ +package durable + +import ( + "fmt" + "reflect" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +var stateFieldFacets = map[string]model.StateFacet{ + "SchemaVersion": model.StateFacetControl, "RepositoryID": model.StateFacetControl, "GitCommonID": model.StateFacetControl, "WorktreeID": model.StateFacetControl, + "ProgramFingerprint": model.StateFacetProgram, + "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, + "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "Plan": model.StateFacetProduct, + "Configuration": model.StateFacetControl, + "Runtime": model.StateFacetInstallation, + "Publication": model.StateFacetProduct, "Verification": model.StateFacetProduct, + "Recovery": model.StateFacetControl, "Transaction": model.StateFacetControl, + "Terminal": model.StateFacetProduct, "Goal": model.StateFacetProduct, + "SourceRevision": model.StateFacetProduct, "WorktreeFingerprint": model.StateFacetProduct, + "ConfigFingerprint": model.StateFacetControl, "PlanApprovalPolicy": model.StateFacetControl, "VisualEvidencePolicy": model.StateFacetControl, + "ExternalEffectPolicy": model.StateFacetControl, "IndependentReview": model.StateFacetControl, "EnabledHosts": model.StateFacetControl, + "RuntimeVersion": model.StateFacetInstallation, "RuntimeFingerprint": model.StateFacetInstallation, "RuntimeSource": model.StateFacetInstallation, + "PlanFingerprint": model.StateFacetProduct, + "WorkspaceBranch": model.StateFacetProduct, "WorkspacePath": model.StateFacetProduct, "WorkspaceBaseRef": model.StateFacetProduct, + "WorkspaceSourcePath": model.StateFacetProduct, "WorkspaceSourceID": model.StateFacetProduct, "WorkspaceSourceRef": model.StateFacetProduct, + "PublicationID": model.StateFacetProduct, "PublicationURL": model.StateFacetProduct, "PreviewFingerprint": model.StateFacetProduct, + "TransactionID": model.StateFacetControl, "TransactionTransition": model.StateFacetControl, "RecoveryCause": model.StateFacetControl, + "RecoverySourcePhase": model.StateFacetControl, "RecoveryResumption": model.StateFacetControl, "RecoveryBudget": model.StateFacetControl, + "LastTransition": model.StateFacetControl, "Gates": model.StateFacetProduct, "UpdatedAt": model.StateFacetControl, +} + +func StateFieldFacets() map[string]model.StateFacet { + result := make(map[string]model.StateFacet, len(stateFieldFacets)) + for field, facet := range stateFieldFacets { + result[field] = facet + } + return result +} + +func ValidateStateFieldFacets() error { + typeOfState := reflect.TypeOf(State{}) + if len(stateFieldFacets) != typeOfState.NumField() { + return fmt.Errorf("STATE_FACET_UNCLASSIFIED: durable State has %d fields but %d facet assignments", typeOfState.NumField(), len(stateFieldFacets)) + } + for index := 0; index < typeOfState.NumField(); index++ { + field := typeOfState.Field(index) + facet, ok := stateFieldFacets[field.Name] + if !ok || !facet.Valid() { + return fmt.Errorf("STATE_FACET_UNCLASSIFIED: durable State field %s has no valid owner", field.Name) + } + } + return nil +} + +func ChangedFacets(before, after State) ([]model.StateFacet, error) { + if err := ValidateStateFieldFacets(); err != nil { + return nil, err + } + typeOfState := reflect.TypeOf(before) + beforeValue, afterValue := reflect.ValueOf(before), reflect.ValueOf(after) + changed := map[model.StateFacet]bool{} + for index := 0; index < typeOfState.NumField(); index++ { + if !reflect.DeepEqual(beforeValue.Field(index).Interface(), afterValue.Field(index).Interface()) { + changed[stateFieldFacets[typeOfState.Field(index).Name]] = true + } + } + result := make([]model.StateFacet, 0, len(changed)) + for facet := range changed { + result = append(result, facet) + } + return model.NormalizeStateFacets("changed state facets", result) +} diff --git a/boatstack/internal/kernel/durable/state_facet_test.go b/boatstack/internal/kernel/durable/state_facet_test.go new file mode 100644 index 0000000..82211f2 --- /dev/null +++ b/boatstack/internal/kernel/durable/state_facet_test.go @@ -0,0 +1,43 @@ +package durable + +import ( + "reflect" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func facetFixture() State { + return Default(model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree"}, time.Unix(100, 0).UTC()) +} + +func TestEveryDurableStateFieldHasOneFacetOwner(t *testing.T) { + if err := ValidateStateFieldFacets(); err != nil { + t.Fatal(err) + } + if got, want := len(StateFieldFacets()), reflect.TypeOf(State{}).NumField(); got != want { + t.Fatalf("classified fields=%d, want %d", got, want) + } +} + +func TestChangedFacetsPreservesExactDomainValues(t *testing.T) { + before := facetFixture() + before.ProgramFingerprint = "program-a" + before.RuntimeVersion = "runtime-a" + before.Goal = model.Goal{ID: "goal-a", Kind: model.GoalOpenPR, DeliveryID: "delivery-a"} + after := before + after.RuntimeVersion = "runtime-b" + after.Revision++ + facets, err := ChangedFacets(before, after) + if err != nil { + t.Fatal(err) + } + want := []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation} + if !reflect.DeepEqual(facets, want) { + t.Fatalf("changed facets=%v, want %v", facets, want) + } + if after.ProgramFingerprint != before.ProgramFingerprint || after.Goal != before.Goal { + t.Fatal("facet classification changed legacy program or product values") + } +} diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 027bdb8..7eb04f1 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -455,7 +455,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, requireRecovery("sequence allocation failed after verified effect", err) } completedAt := e.clock.Now() - receipt, err := protocol.NewReceipt(request.FlowID, sequence, e.program, admission, transition, target, prepared.CommittedEffects(), nil, startedAt, completedAt) + receipt, err := protocol.NewReceipt(request.FlowID, sequence, e.program, admission, transition, target, prepared.ChangedStateFacets(), prepared.CommittedEffects(), nil, startedAt, completedAt) if err != nil { return result, requireRecovery("receipt construction failed after verified effect", err) } diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index e72275b..b491197 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -123,6 +123,9 @@ func (e *fakeEffects) Prepare(_ context.Context, _ protocol.Admission, transitio return e, nil } func (e *fakeEffects) Manifest() []ports.ResourceMutation { return nil } +func (e *fakeEffects) ChangedStateFacets() []model.StateFacet { + return []model.StateFacet{model.StateFacetControl} +} func (e *fakeEffects) CommittedEffects() []protocol.EffectFact { return []protocol.EffectFact{{ Kind: protocol.EffectResourceMutation, EffectID: e.transition.Effect, Owner: e.transition.Owner, Resource: e.transition.OwnedResources[0], diff --git a/boatstack/internal/kernel/model/state_facet.go b/boatstack/internal/kernel/model/state_facet.go new file mode 100644 index 0000000..28cfc71 --- /dev/null +++ b/boatstack/internal/kernel/model/state_facet.go @@ -0,0 +1,57 @@ +package model + +import ( + "fmt" + "sort" +) + +// StateFacet names one kernel-owned semantic domain in durable State. It is +// deliberately distinct from FacetName, which names resolver predicates. +type StateFacet string + +const ( + StateFacetInstallation StateFacet = "installation" + StateFacetProgram StateFacet = "program" + StateFacetControl StateFacet = "control" + StateFacetProduct StateFacet = "product" +) + +func (f StateFacet) Valid() bool { + switch f { + case StateFacetInstallation, StateFacetProgram, StateFacetControl, StateFacetProduct: + return true + default: + return false + } +} + +func NormalizeStateFacets(name string, facets []StateFacet) ([]StateFacet, error) { + result := append([]StateFacet(nil), facets...) + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + for index, facet := range result { + if !facet.Valid() { + return nil, fmt.Errorf("%s contains invalid state facet %q", name, facet) + } + if index > 0 && result[index-1] == facet { + return nil, fmt.Errorf("%s duplicates state facet %q", name, facet) + } + } + return result, nil +} + +func UnionStateFacets(groups ...[]StateFacet) []StateFacet { + set := map[StateFacet]bool{} + for _, group := range groups { + for _, facet := range group { + if facet.Valid() { + set[facet] = true + } + } + } + result := make([]StateFacet, 0, len(set)) + for facet := range set { + result = append(result, facet) + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index 19bf280..0192b29 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -74,22 +74,24 @@ type EffectResult struct { } type ResourceMutation struct { - Resource string `json:"resource"` - Owner string `json:"owner"` - Path string `json:"path"` - Prior []byte `json:"prior,omitempty"` - Target []byte `json:"target,omitempty"` - PriorLink string `json:"prior_link,omitempty"` - TargetLink string `json:"target_link,omitempty"` - PriorExists bool `json:"prior_exists"` - Mode uint32 `json:"mode"` - InstallLast bool `json:"install_last,omitempty"` - Delete bool `json:"delete,omitempty"` + Resource string `json:"resource"` + Owner string `json:"owner"` + Path string `json:"path"` + Prior []byte `json:"prior,omitempty"` + Target []byte `json:"target,omitempty"` + PriorLink string `json:"prior_link,omitempty"` + TargetLink string `json:"target_link,omitempty"` + PriorExists bool `json:"prior_exists"` + Mode uint32 `json:"mode"` + InstallLast bool `json:"install_last,omitempty"` + Delete bool `json:"delete,omitempty"` + StateFacets []model.StateFacet `json:"state_facets,omitempty"` } type PreparedEffect interface { Manifest() []ResourceMutation CommittedEffects() []protocol.EffectFact + ChangedStateFacets() []model.StateFacet VerificationInvocation() (model.InvocationContext, bool) Execute(context.Context) (EffectResult, error) Rollback(context.Context) error diff --git a/boatstack/internal/kernel/protocol/journal.go b/boatstack/internal/kernel/protocol/journal.go index 5fb8f00..97a806a 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 = 5 +const JournalSchemaVersion = 6 diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index a3d7622..9bc76fd 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -3,6 +3,7 @@ package protocol import ( "encoding/hex" "fmt" + "slices" "sort" "strings" "time" @@ -11,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const ReceiptSchemaVersion = 6 +const ReceiptSchemaVersion = 7 type TransitionFactKind string @@ -120,6 +121,7 @@ type TransitionReceipt struct { GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` CommittedEffects []EffectFact `json:"committed_effects"` + ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` Verification VerificationFact `json:"verification"` IdempotencyKey string `json:"idempotency_key"` Recovery catalog.TransitionID `json:"recovery,omitempty"` @@ -136,7 +138,7 @@ type AuthoritySource struct { Fingerprint string `json:"fingerprint"` } -func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admission Admission, transition catalog.Transition, target model.Snapshot, effects []EffectFact, exercised []catalog.Capability, startedAt, committedAt time.Time) (TransitionReceipt, error) { +func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admission Admission, transition catalog.Transition, target model.Snapshot, changedStateFacets []model.StateFacet, effects []EffectFact, exercised []catalog.Capability, startedAt, committedAt time.Time) (TransitionReceipt, error) { if flowID == "" || sequence == 0 || admission.ID == "" || target.Fingerprint == "" { return TransitionReceipt{}, fmt.Errorf("receipt requires flow, sequence, admission, and target identity") } @@ -165,6 +167,10 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi } canonicalEffects := append([]EffectFact(nil), effects...) sortEffectFacts(canonicalEffects) + canonicalFacets, err := model.NormalizeStateFacets("receipt.changed_state_facets", changedStateFacets) + if err != nil || len(canonicalFacets) == 0 { + return TransitionReceipt{}, fmt.Errorf("receipt requires canonical changed state facets: %v", err) + } receipt := TransitionReceipt{ SchemaVersion: ReceiptSchemaVersion, Kind: TransitionCommitted, FlowID: flowID, Sequence: sequence, Program: program, TransitionID: transition.ID, TransitionVersion: transition.Version, @@ -178,6 +184,7 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi GrantedCapabilities: append([]catalog.Capability(nil), admission.GrantedCapabilities...), ExercisedCapabilities: append([]catalog.Capability(nil), exercised...), CommittedEffects: canonicalEffects, + ChangedStateFacets: canonicalFacets, Verification: VerificationFact{Verifier: transition.Verifier, ExpectedPostcondition: transition.TargetPredicate, Result: VerificationSatisfied, EvidenceFingerprint: target.Fingerprint, VerifiedAt: committedAt.UTC()}, IdempotencyKey: admission.IdempotencyKey, Recovery: transition.Interruption.Recovery, Terminal: terminal, StartedAt: startedAt.UTC(), CommittedAt: committedAt.UTC(), DurationNanoseconds: committedAt.Sub(startedAt).Nanoseconds(), @@ -197,7 +204,6 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi } identity := receipt identity.ID = "" - var err error receipt.ID, err = contentID("trc-", identity) if err != nil { return TransitionReceipt{}, err @@ -206,9 +212,13 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi } func (r TransitionReceipt) Validate() error { - if r.SchemaVersion != ReceiptSchemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 { + if r.SchemaVersion != ReceiptSchemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 || len(r.ChangedStateFacets) == 0 { return fmt.Errorf("receipt has incomplete committed-transition identity or evidence") } + canonicalFacets, err := model.NormalizeStateFacets("receipt.changed_state_facets", r.ChangedStateFacets) + if err != nil || !slices.Equal(canonicalFacets, r.ChangedStateFacets) { + return fmt.Errorf("receipt changed state facets are invalid or non-canonical: %v", err) + } if err := r.Program.Validate(); err != nil { return err } diff --git a/boatstack/internal/kernel/protocol/receipt_capability_test.go b/boatstack/internal/kernel/protocol/receipt_capability_test.go index d4604e2..7525e18 100644 --- a/boatstack/internal/kernel/protocol/receipt_capability_test.go +++ b/boatstack/internal/kernel/protocol/receipt_capability_test.go @@ -28,7 +28,7 @@ func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { } transition := catalog.Transition{ID: "program/write", Version: 1, Owner: "program", Effect: "program.write", TargetPredicate: "program.written", Verifier: "program.written"} effects := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "program.state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - receipt, err := NewReceipt("flow", 1, ProgramIdentity{ID: "program", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: strings.Repeat("c", 64)}, effects, nil, now, now.Add(time.Second)) + receipt, err := NewReceipt("flow", 1, ProgramIdentity{ID: "program", Version: "1.0.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: strings.Repeat("c", 64)}, []model.StateFacet{model.StateFacetControl}, effects, nil, now, now.Add(time.Second)) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/kernel/protocol/receipt_fact_test.go b/boatstack/internal/kernel/protocol/receipt_fact_test.go index 2ecf707..9e14258 100644 --- a/boatstack/internal/kernel/protocol/receipt_fact_test.go +++ b/boatstack/internal/kernel/protocol/receipt_fact_test.go @@ -1,6 +1,7 @@ package protocol import ( + "slices" "strings" "testing" "time" @@ -34,7 +35,7 @@ func committedReceiptFixture(t *testing.T) (TransitionReceipt, Admission, catalo {Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "product-delivery.state", Target: "/repo/.boatstack/state.json", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}, {Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "product-delivery.evidence", Target: "/repo/.boatstack/build.json", Operation: "create", PriorFingerprint: strings.Repeat("3", 64), ResultingFingerprint: strings.Repeat("4", 64)}, } - receipt, err := NewReceipt("flow", 7, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, effects, nil, now, now.Add(time.Second)) + receipt, err := NewReceipt("flow", 7, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, effects, nil, now, now.Add(time.Second)) if err != nil { t.Fatal(err) } @@ -72,6 +73,9 @@ func TestCommittedTransitionFactBindsProgramTransitionStateAuthorityEffectsAndVe if len(receipt.CommittedEffects) != 2 || receipt.CommittedEffects[0].Target > receipt.CommittedEffects[1].Target { t.Fatalf("committed effects are absent or non-canonical: %#v", receipt.CommittedEffects) } + if !slices.Equal(receipt.ChangedStateFacets, []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}) { + t.Fatalf("changed state facets = %v", receipt.ChangedStateFacets) + } if receipt.Verification.Result != VerificationSatisfied || receipt.Verification.ExpectedPostcondition != transition.TargetPredicate || receipt.Verification.EvidenceFingerprint != target.Fingerprint { t.Fatalf("verification fact = %#v", receipt.Verification) } @@ -89,7 +93,7 @@ func TestCommittedTransitionFactRejectsNonSuccessSemantics(t *testing.T) { func TestCommittedTransitionFactRejectsProgramMismatchAtConstruction(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) effect := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "other", Version: "1", Fingerprint: strings.Repeat("b", 64)}, admission, transition, target, effect, nil, now, now) + _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "other", Version: "1", Fingerprint: strings.Repeat("b", 64)}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, now, now) if err == nil || !strings.Contains(err.Error(), "differs from admitted program") { t.Fatalf("program mismatch error = %v", err) } @@ -99,7 +103,7 @@ func TestCommittedTransitionFactRejectsWrongRevision(t *testing.T) { _, admission, transition, target, now := committedReceiptFixture(t) target.StateRevision = 43 effect := []EffectFact{{Kind: EffectResourceMutation, EffectID: transition.Effect, Owner: transition.Owner, Resource: "state", Target: "/state", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64)}} - _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, effect, nil, now, now) + _, err := NewReceipt("flow", 1, ProgramIdentity{ID: "product-delivery", Version: "2.1.0", Fingerprint: admission.ExpectedProgramFingerprint}, admission, transition, target, []model.StateFacet{model.StateFacetControl}, effect, nil, now, now) if err == nil || !strings.Contains(err.Error(), "advance exactly once") { t.Fatalf("revision mismatch error = %v", err) } @@ -157,6 +161,22 @@ func TestCommittedTransitionFactRejectsPlannedOrDuplicateEffectEvidence(t *testi } } +func TestCommittedTransitionFactRejectsMissingOrNonCanonicalStateFacets(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + for _, facets := range [][]model.StateFacet{ + nil, + {model.StateFacetProduct, model.StateFacetControl}, + {model.StateFacetControl, model.StateFacetControl}, + } { + candidate := receipt + candidate.ChangedStateFacets = facets + candidate = rehashReceipt(t, candidate) + if err := candidate.Validate(); err == nil { + t.Fatalf("invalid changed state facets accepted: %v", facets) + } + } +} + func TestHistoricalTransitionFactRetainsOriginalProgramIdentity(t *testing.T) { receipt, _, _, _, _ := committedReceiptFixture(t) upgraded := ProgramIdentity{ID: receipt.Program.ID, Version: "3.0.0", Fingerprint: strings.Repeat("b", 64)} diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index 19e1b2a..240cd6e 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -37,6 +37,7 @@ type DoctorReport = surfaces.DoctorReport type ProgramChange = surfaces.ProgramChange type Goal = model.Goal type GoalKind = model.GoalKind +type StateFacet = model.StateFacet const ( GoalApprovedPlan = model.GoalApprovedPlan @@ -44,6 +45,11 @@ const ( GoalOpenPR = model.GoalOpenPR GoalMerged = model.GoalMerged GoalAbandoned = model.GoalAbandoned + + StateFacetInstallation = model.StateFacetInstallation + StateFacetProgram = model.StateFacetProgram + StateFacetControl = model.StateFacetControl + StateFacetProduct = model.StateFacetProduct ) type TransitionID = catalog.TransitionID diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index 0d5ca4e..f0957f1 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -37,7 +37,7 @@ func TestSDKPreservesCapabilityAdmissionProtocol(t *testing.T) { } func TestSDKSerializesTheSameDurableTransitionFactAsTheSurface(t *testing.T) { - raw := []byte(`{"schema_version":4,"operation":"apply","receipt":{"schema_version":6,"kind":"transition-committed","id":"trc-fact","flow_id":"flow","sequence":1,"program":{"id":"product-delivery","version":"1.0.0","fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"transition_id":"product-delivery/build.begin","transition_version":1,"prescription_id":"prx","admission_id":"adm","prior_state_revision":41,"resulting_state_revision":42,"goal_id":"goal","goal_kind":"verified","delivery_id":"delivery","goal_scope":"required","source_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","target_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","authority_fingerprint":"auth","authority_sources":[],"required_capabilities":["repository.write"],"granted_capabilities":["repository.write"],"committed_effects":[{"kind":"resource-mutation","effect_id":"build.begin","owner":"product-delivery","resource":"state","target":"/state","operation":"update","prior_fingerprint":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","resulting_fingerprint":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}],"verification":{"verifier":"build-active","expected_postcondition":"active","result":"satisfied","evidence_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","verified_at":"2026-08-12T00:00:00Z"},"idempotency_key":"idem","terminal":"nonterminal","started_at":"2026-08-12T00:00:00Z","committed_at":"2026-08-12T00:00:01Z","duration_nanoseconds":1000000000}}`) + raw := []byte(`{"schema_version":4,"operation":"apply","receipt":{"schema_version":7,"kind":"transition-committed","id":"trc-fact","flow_id":"flow","sequence":1,"program":{"id":"product-delivery","version":"1.0.0","fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"transition_id":"product-delivery/build.begin","transition_version":1,"prescription_id":"prx","admission_id":"adm","prior_state_revision":41,"resulting_state_revision":42,"goal_id":"goal","goal_kind":"verified","delivery_id":"delivery","goal_scope":"required","source_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","target_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","authority_fingerprint":"auth","authority_sources":[],"required_capabilities":["repository.write"],"granted_capabilities":["repository.write"],"committed_effects":[{"kind":"resource-mutation","effect_id":"build.begin","owner":"product-delivery","resource":"state","target":"/state","operation":"update","prior_fingerprint":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","resulting_fingerprint":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}],"changed_state_facets":["control","product"],"verification":{"verifier":"build-active","expected_postcondition":"active","result":"satisfied","evidence_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","verified_at":"2026-08-12T00:00:00Z"},"idempotency_key":"idem","terminal":"nonterminal","started_at":"2026-08-12T00:00:00Z","committed_at":"2026-08-12T00:00:01Z","duration_nanoseconds":1000000000}}`) var response sdk.Response if err := json.Unmarshal(raw, &response); err != nil { t.Fatal(err) @@ -56,6 +56,9 @@ func TestSDKSerializesTheSameDurableTransitionFactAsTheSurface(t *testing.T) { if !reflect.DeepEqual(before["receipt"], after["receipt"]) { t.Fatalf("SDK changed receipt facts:\nbefore=%#v\nafter=%#v", before["receipt"], after["receipt"]) } + if len(response.Receipt.ChangedStateFacets) != 2 || response.Receipt.ChangedStateFacets[0] != sdk.StateFacetControl || response.Receipt.ChangedStateFacets[1] != sdk.StateFacetProduct { + t.Fatalf("SDK lost durable state facet facts: %v", response.Receipt.ChangedStateFacets) + } } func TestLowLevelSDKRequiresAndAcceptsExactlyOneNonStandardProgramRuntime(t *testing.T) { diff --git a/release-notes/2026-08-12-durable-state-facet-ownership.md b/release-notes/2026-08-12-durable-state-facet-ownership.md new file mode 100644 index 0000000..17861bf --- /dev/null +++ b/release-notes/2026-08-12-durable-state-facet-ownership.md @@ -0,0 +1,6 @@ +### Enforce durable state facet ownership + +Boatstack now classifies durable state as installation, program, control, or +product state. Every native, repository-program, and recovery commit fails +closed if it changes a facet outside the kernel-owned transition policy, and +committed receipts record the exact changed facets. From 64c7214fb2a3a9423ec94c23a0c5069c602e1ffc Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 08:13:59 +0100 Subject: [PATCH 2/2] Derive recovery facets from committed delta --- boatstack/internal/effects/recovery.go | 16 ++++++--- boatstack/internal/effects/state_facet.go | 32 +++++++++++++++++ .../internal/effects/state_facet_test.go | 35 +++++++++++++++++-- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go index dd3c667..9b08b3e 100644 --- a/boatstack/internal/effects/recovery.go +++ b/boatstack/internal/effects/recovery.go @@ -66,7 +66,7 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll return nil, err } mutations = append(mutations, closure...) - changed, err := recoveryStateFacets(record, transition.ID) + changed, err := recoveryStateFacets(record, transition.ID, admission.Invocation, mutations) if err != nil { return nil, err } @@ -134,7 +134,7 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po return nil, err } mutations = append(mutations, closure...) - changed, err := recoveryStateFacets(record, "workspace.reconcile") + changed, err := recoveryStateFacets(record, "workspace.reconcile", admission.Invocation, mutations) if err != nil { return nil, err } @@ -142,13 +142,19 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed}, nil } -func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID) ([]model.StateFacet, error) { - changed, err := journalStateFacets(record.Mutations) +func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, invocation model.InvocationContext, mutations []ports.ResourceMutation) ([]model.StateFacet, error) { + staged, err := journalStateFacets(record.Mutations) if err != nil { return nil, err } - changed = model.UnionStateFacets(changed, []model.StateFacet{model.StateFacetControl}) allowed := model.UnionStateFacets(catalog.DurableStateWritesForRecovery(record.TransitionID), []model.StateFacet{model.StateFacetControl}) + if _, err := validateAllowedStateFacets(recovery, staged, allowed); err != nil { + return nil, err + } + changed, err := mutationStateFacets(invocation, mutations) + if err != nil { + return nil, err + } return validateAllowedStateFacets(recovery, changed, allowed) } diff --git a/boatstack/internal/effects/state_facet.go b/boatstack/internal/effects/state_facet.go index 19d82b6..1f54967 100644 --- a/boatstack/internal/effects/state_facet.go +++ b/boatstack/internal/effects/state_facet.go @@ -76,6 +76,38 @@ func journalStateFacets(mutations []ports.ResourceMutation) ([]model.StateFacet, return changed, nil } +// mutationStateFacets derives the semantic delta that the prepared mutation +// will actually commit. Recovery receipts must describe this delta, not the +// interrupted transition's wider staged envelope. +func mutationStateFacets(invocation model.InvocationContext, mutations []ports.ResourceMutation) ([]model.StateFacet, error) { + var changed []model.StateFacet + for _, mutation := range mutations { + if filepath.Base(mutation.Path) != "state.json" || mutation.Delete || mutation.TargetLink != "" { + continue + } + after, err := durable.DecodeState(mutation.Target) + if err != nil { + continue + } + before := durable.Default(invocation, after.UpdatedAt) + if mutation.PriorExists { + before, err = durable.DecodeState(mutation.Prior) + if err != nil { + return nil, fmt.Errorf("STATE_FACET_UNCLASSIFIED: prepared durable state mutation %s has invalid prior state: %w", mutation.Path, err) + } + } + facets, err := durable.ChangedFacets(before, after) + if err != nil { + return nil, err + } + changed = model.UnionStateFacets(changed, facets) + } + if len(changed) == 0 { + return nil, fmt.Errorf("STATE_FACET_UNCLASSIFIED: prepared effect has no durable state delta") + } + return changed, nil +} + func annotateStateFacetMutations(mutations []ports.ResourceMutation, facets []model.StateFacet) []ports.ResourceMutation { for index := range mutations { mutation := &mutations[index] diff --git a/boatstack/internal/effects/state_facet_test.go b/boatstack/internal/effects/state_facet_test.go index 3cb315a..e6d644d 100644 --- a/boatstack/internal/effects/state_facet_test.go +++ b/boatstack/internal/effects/state_facet_test.go @@ -1,6 +1,7 @@ package effects import ( + "slices" "strings" "testing" "time" @@ -93,7 +94,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { prior, _ := durable.EncodeState(before) target, _ := durable.EncodeState(after) record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}}} - if _, err := recoveryStateFacets(record, "recovery.resume"); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") { + if _, err := recoveryStateFacets(record, "recovery.resume", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") { t.Fatalf("recovery accepted product contamination: %v", err) } } @@ -102,11 +103,41 @@ func TestRecoveryRefusesUnclassifiedDurableMutation(t *testing.T) { state := ownershipState() raw, _ := durable.EncodeState(state) record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}} - if _, err := recoveryStateFacets(record, "recovery.resume"); err == nil || !strings.Contains(err.Error(), "STATE_FACET_UNCLASSIFIED") { + if _, err := recoveryStateFacets(record, "recovery.resume", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "STATE_FACET_UNCLASSIFIED") { t.Fatalf("recovery accepted an unclassified state mutation: %v", err) } } +func TestRecoveryReportsActualStateDeltaInsteadOfInterruptedEnvelope(t *testing.T) { + invocation := model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree"} + before := durable.Default(invocation, time.Unix(100, 0).UTC()) + staged := before + staged.Plan = model.PlanDraft + staged.Revision++ + staged.LastTransition = "plan.create" + staged.UpdatedAt = time.Unix(101, 0).UTC() + prior, _ := durable.EncodeState(before) + target, _ := durable.EncodeState(staged) + record := journalRecord{TransitionID: "plan.create", Mutations: []ports.ResourceMutation{{ + Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, + StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, + }}} + + recovered := before + recovered.Revision++ + recovered.LastTransition = "recovery.rollback" + recovered.UpdatedAt = time.Unix(102, 0).UTC() + recoveredRaw, _ := durable.EncodeState(recovered) + mutations := []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: recoveredRaw}} + changed, err := recoveryStateFacets(record, "recovery.rollback", invocation, mutations) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(changed, []model.StateFacet{model.StateFacetControl}) { + t.Fatalf("recovery facets = %v, want actual control-only delta", changed) + } +} + 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") {