From 4e58205f3b031862cfabefb245e06f118a18239a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 21:11:08 +0100 Subject: [PATCH 1/3] Close declarative state effect boundaries --- boatstack/delivery/program_manifest_test.go | 5 +- .../softwaredelivery/catalog/capability.go | 13 ++-- .../catalog/capability_test.go | 19 +++++- .../catalog/state_effect_closure_test.go | 13 ++++ .../softwaredelivery/catalog/state_facet.go | 20 ++++++ .../softwaredelivery/catalog/transition.go | 21 ++++-- .../softwaredelivery/effects/journal.go | 35 ++++------ .../effects/journal_schema_test.go | 20 ++++-- .../softwaredelivery/effects/recovery.go | 2 +- .../effects/state_facet_test.go | 27 +++++++- .../softwaredelivery/protocol/journal.go | 2 +- boatstack/program_effects_test.go | 4 +- .../boatstack-v2-transition-catalog.md | 66 +++++++++---------- .../2026-08-12-declarative-state-effects.md | 2 - 14 files changed, 166 insertions(+), 83 deletions(-) diff --git a/boatstack/delivery/program_manifest_test.go b/boatstack/delivery/program_manifest_test.go index 56fab49..e880b1b 100644 --- a/boatstack/delivery/program_manifest_test.go +++ b/boatstack/delivery/program_manifest_test.go @@ -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"} @@ -331,7 +332,7 @@ 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}, @@ -339,7 +340,7 @@ func programFixture() delivery.ProgramManifest { } 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 { diff --git a/boatstack/internal/softwaredelivery/catalog/capability.go b/boatstack/internal/softwaredelivery/catalog/capability.go index 473d1fd..e7671de 100644 --- a/boatstack/internal/softwaredelivery/catalog/capability.go +++ b/boatstack/internal/softwaredelivery/catalog/capability.go @@ -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 @@ -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 } diff --git a/boatstack/internal/softwaredelivery/catalog/capability_test.go b/boatstack/internal/softwaredelivery/catalog/capability_test.go index a2eecac..11072e1 100644 --- a/boatstack/internal/softwaredelivery/catalog/capability_test.go +++ b/boatstack/internal/softwaredelivery/catalog/capability_test.go @@ -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 { @@ -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}, } @@ -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] { diff --git a/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go b/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go index c6267e7..3962f56 100644 --- a/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go +++ b/boatstack/internal/softwaredelivery/catalog/state_effect_closure_test.go @@ -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}, diff --git a/boatstack/internal/softwaredelivery/catalog/state_facet.go b/boatstack/internal/softwaredelivery/catalog/state_facet.go index 3044c52..2944321 100644 --- a/boatstack/internal/softwaredelivery/catalog/state_facet.go +++ b/boatstack/internal/softwaredelivery/catalog/state_facet.go @@ -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 { diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index 507b538..aefb78c 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -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 { diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index f64d8cf..4c32560 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -33,19 +33,18 @@ 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"` - AllowedStateFacets []model.StateFacet `json:"allowed_state_facets"` - ReconcilesProgram bool `json:"reconciles_program,omitempty"` - Status string `json:"status"` - Mutations []ports.ResourceMutation `json:"mutations,omitempty"` - Reason string `json:"reason,omitempty"` - ReceiptID string `json:"receipt_id,omitempty"` - Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + SchemaVersion int `json:"schema_version"` + Admission protocol.Admission `json:"admission"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionClass catalog.EventClass `json:"transition_class"` + ReconcilesProgram bool `json:"reconciles_program,omitempty"` + Status string `json:"status"` + Mutations []ports.ResourceMutation `json:"mutations,omitempty"` + Reason string `json:"reason,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` + Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func journalName(id, suffix string) (string, error) { @@ -78,11 +77,7 @@ func (j *Journal) Begin(ctx context.Context, admission protocol.Admission, trans return statErr } now := j.clock.Now().UTC() - policy, err := catalog.DurableStateFacetPolicy(transition) - if err != nil { - return err - } - record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, AllowedStateFacets: policy.Writes, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now} + record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now} raw, err := encodeJSON(record) if err != nil { return err @@ -119,10 +114,6 @@ 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) } - allowed, err := model.NormalizeStateFacets("journal allowed state facets", record.AllowedStateFacets) - if err != nil || len(allowed) == 0 || !slices.Equal(allowed, record.AllowedStateFacets) { - return journalRecord{}, fmt.Errorf("invalid allowed state facets 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) { diff --git a/boatstack/internal/softwaredelivery/effects/journal_schema_test.go b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go index 0b86489..8b54b3b 100644 --- a/boatstack/internal/softwaredelivery/effects/journal_schema_test.go +++ b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go @@ -5,23 +5,31 @@ import ( "path/filepath" "strings" "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -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"}`) +func TestJournalRejectsMutableRecoveryFacetEnvelope(t *testing.T) { + path := filepath.Join(t.TempDir(), "adm-tampered.pending") + raw := []byte(`{"schema_version":7,"admission":{"id":"adm-tampered"},"transition_id":"plan.create","transition_class":"owned-local","allowed_state_facets":["control","installation","product"],"status":"begun"}`) if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } - 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) + if _, err := readJournal(path); err == nil || !strings.Contains(err.Error(), "unknown field \"allowed_state_facets\"") { + t.Fatalf("mutable recovery envelope error = %v, want strict unknown-field 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) + t.Fatalf("tampered journal changed during refusal:\n got %s\nwant %s", got, raw) + } +} + +func TestInstallationUpdateKeepsStableJournalSchema(t *testing.T) { + if protocol.JournalSchemaVersion != 7 { + t.Fatalf("journal schema = %d, want stable schema 7 for in-flight installation updates", protocol.JournalSchemaVersion) } } diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index f8f5074..2a1da05 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -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 } diff --git a/boatstack/internal/softwaredelivery/effects/state_facet_test.go b/boatstack/internal/softwaredelivery/effects/state_facet_test.go index df563e7..05cbe36 100644 --- a/boatstack/internal/softwaredelivery/effects/state_facet_test.go +++ b/boatstack/internal/softwaredelivery/effects/state_facet_test.go @@ -106,7 +106,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { after.Objective = model.Objective{ID: "invented", Kind: model.ObjectiveApprovedPlan, DeliveryID: "invented"} prior, _ := durable.EncodeState(before) target, _ := durable.EncodeState(after) - record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: prior, Target: target, StateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}}}} + 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", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") { t.Fatalf("recovery accepted product contamination: %v", err) } @@ -115,7 +115,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { func TestRecoveryRefusesUnclassifiedDurableMutation(t *testing.T) { state := ownershipState() raw, _ := durable.EncodeState(state) - record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}} + record := journalRecord{TransitionID: "installation.update", Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}} 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) } @@ -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", 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}, }}} @@ -151,6 +151,27 @@ 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", + 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") { diff --git a/boatstack/internal/softwaredelivery/protocol/journal.go b/boatstack/internal/softwaredelivery/protocol/journal.go index 261a231..4931b64 100644 --- a/boatstack/internal/softwaredelivery/protocol/journal.go +++ b/boatstack/internal/softwaredelivery/protocol/journal.go @@ -2,4 +2,4 @@ package protocol // JournalSchemaVersion identifies the transaction record that embeds an exact // prescription-bound admission. -const JournalSchemaVersion = 8 +const JournalSchemaVersion = 7 diff --git a/boatstack/program_effects_test.go b/boatstack/program_effects_test.go index 3a0efbc..7e27b2e 100644 --- a/boatstack/program_effects_test.go +++ b/boatstack/program_effects_test.go @@ -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, @@ -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 } diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index 92ae93f..c29513f 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | 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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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` | -| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | -| `objective.bind` | core-system:`boatstack.core@1.0.0`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `objective_kind*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `objective.bind` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `objective_kind*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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` | @@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `boatstack.core` | OBJECTIVE_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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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`
`85ee13e3604eee43bde7cbdfa48de4a5e15b47ea2987f915f0a9463be5312ab6` | `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` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `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` | diff --git a/release-notes/2026-08-12-declarative-state-effects.md b/release-notes/2026-08-12-declarative-state-effects.md index 11f7d87..c9f5eb2 100644 --- a/release-notes/2026-08-12-declarative-state-effects.md +++ b/release-notes/2026-08-12-declarative-state-effects.md @@ -5,5 +5,3 @@ Software-delivery transitions now declare their durable state assignments and ow The control-program schema is now version 4, and the program-runtime and extension protocols are version 3. Manifests must declare each controllable transition's owned facets and state effect. Native handlers now compile only against their registered component, effect, facet, and objective-policy contract. Declarative assignments must close durable-state invariants, and ordering-only declaration changes preserve the same program fingerprint. - -This alpha release does not read pending journals from earlier schema versions. Reset those local development fixtures before using the new runtime. From 1be3bfb04b9d486f0a12f4b877923cd7c41db4eb Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 21:18:06 +0100 Subject: [PATCH 2/3] Preserve append-only release history --- .../2026-08-12-declarative-state-effect-boundaries.md | 10 ++++++++++ release-notes/2026-08-12-declarative-state-effects.md | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 release-notes/2026-08-12-declarative-state-effect-boundaries.md diff --git a/release-notes/2026-08-12-declarative-state-effect-boundaries.md b/release-notes/2026-08-12-declarative-state-effect-boundaries.md new file mode 100644 index 0000000..ba87bc3 --- /dev/null +++ b/release-notes/2026-08-12-declarative-state-effect-boundaries.md @@ -0,0 +1,10 @@ +### Bind declarative effects to admitted control law + +Product-state effects now require product mutation authority based on their +owned facets. Declarative assignments must satisfy every target condition for +the affected facet, and recovery reconstructs its write boundary from the +admitted transition instead of mutable journal data. + +The declarative state-effect change keeps the existing journal schema, so an +installation update can resume its pending transaction after runtime +activation. diff --git a/release-notes/2026-08-12-declarative-state-effects.md b/release-notes/2026-08-12-declarative-state-effects.md index c9f5eb2..11f7d87 100644 --- a/release-notes/2026-08-12-declarative-state-effects.md +++ b/release-notes/2026-08-12-declarative-state-effects.md @@ -5,3 +5,5 @@ Software-delivery transitions now declare their durable state assignments and ow The control-program schema is now version 4, and the program-runtime and extension protocols are version 3. Manifests must declare each controllable transition's owned facets and state effect. Native handlers now compile only against their registered component, effect, facet, and objective-policy contract. Declarative assignments must close durable-state invariants, and ordering-only declaration changes preserve the same program fingerprint. + +This alpha release does not read pending journals from earlier schema versions. Reset those local development fixtures before using the new runtime. From 6b745cc79b95535094dd2d2788037d1f0401e8f7 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 21:29:50 +0100 Subject: [PATCH 3/3] Preserve journal continuity across runtime updates --- .../effects/cas_integration_test.go | 3 +- .../softwaredelivery/effects/journal.go | 37 ++++++++++++------- .../effects/journal_schema_test.go | 28 ++------------ .../softwaredelivery/effects/recovery_test.go | 12 ++++++ .../effects/state_facet_test.go | 9 +++-- .../softwaredelivery/protocol/journal.go | 2 +- 6 files changed, 47 insertions(+), 44 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go index f0817f6..f90f7b8 100644 --- a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go @@ -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 diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index 4c32560..94dc0bd 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -33,18 +33,21 @@ 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"` - ReconcilesProgram bool `json:"reconciles_program,omitempty"` - Status string `json:"status"` - Mutations []ports.ResourceMutation `json:"mutations,omitempty"` - Reason string `json:"reason,omitempty"` - ReceiptID string `json:"receipt_id,omitempty"` - Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + Mutations []ports.ResourceMutation `json:"mutations,omitempty"` + Reason string `json:"reason,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` + Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func journalName(id, suffix string) (string, error) { @@ -77,7 +80,11 @@ func (j *Journal) Begin(ctx context.Context, admission protocol.Admission, trans return statErr } now := j.clock.Now().UTC() - record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now} + policy, err := catalog.DurableStateFacetPolicy(transition) + if err != nil { + return err + } + record := journalRecord{SchemaVersion: protocol.JournalSchemaVersion, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, AllowedStateFacets: policy.Writes, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now} raw, err := encodeJSON(record) if err != nil { return err @@ -114,6 +121,10 @@ 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) } + allowed, err := model.NormalizeStateFacets("journal allowed state facets", record.AllowedStateFacets) + if err != nil || len(allowed) == 0 || !slices.Equal(allowed, record.AllowedStateFacets) { + return journalRecord{}, fmt.Errorf("invalid allowed state facets 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) { diff --git a/boatstack/internal/softwaredelivery/effects/journal_schema_test.go b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go index 8b54b3b..1faac72 100644 --- a/boatstack/internal/softwaredelivery/effects/journal_schema_test.go +++ b/boatstack/internal/softwaredelivery/effects/journal_schema_test.go @@ -1,35 +1,13 @@ package effects import ( - "os" - "path/filepath" - "strings" "testing" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -func TestJournalRejectsMutableRecoveryFacetEnvelope(t *testing.T) { - path := filepath.Join(t.TempDir(), "adm-tampered.pending") - raw := []byte(`{"schema_version":7,"admission":{"id":"adm-tampered"},"transition_id":"plan.create","transition_class":"owned-local","allowed_state_facets":["control","installation","product"],"status":"begun"}`) - if err := os.WriteFile(path, raw, 0o600); err != nil { - t.Fatal(err) - } - - if _, err := readJournal(path); err == nil || !strings.Contains(err.Error(), "unknown field \"allowed_state_facets\"") { - t.Fatalf("mutable recovery envelope error = %v, want strict unknown-field refusal", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(got) != string(raw) { - t.Fatalf("tampered journal changed during refusal:\n got %s\nwant %s", got, raw) - } -} - -func TestInstallationUpdateKeepsStableJournalSchema(t *testing.T) { - if protocol.JournalSchemaVersion != 7 { - t.Fatalf("journal schema = %d, want stable schema 7 for in-flight installation updates", protocol.JournalSchemaVersion) +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) } } diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index c5bb5be..6785292 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -1,6 +1,7 @@ package effects import ( + "bytes" "context" "os" "os/exec" @@ -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 { diff --git a/boatstack/internal/softwaredelivery/effects/state_facet_test.go b/boatstack/internal/softwaredelivery/effects/state_facet_test.go index 05cbe36..00208bf 100644 --- a/boatstack/internal/softwaredelivery/effects/state_facet_test.go +++ b/boatstack/internal/softwaredelivery/effects/state_facet_test.go @@ -106,7 +106,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { after.Objective = model.Objective{ID: "invented", Kind: model.ObjectiveApprovedPlan, 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}}}} + record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, 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", model.InvocationContext{}, nil); err == nil || !strings.Contains(err.Error(), "FACET_OWNERSHIP_VIOLATION") { t.Fatalf("recovery accepted product contamination: %v", err) } @@ -115,7 +115,7 @@ func TestRecoveryCannotReplayFacetOutsideInterruptedTransition(t *testing.T) { 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}}} + record := journalRecord{TransitionID: "installation.update", AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetInstallation}, Mutations: []ports.ResourceMutation{{Path: "/controller/state.json", PriorExists: true, Prior: raw, Target: raw}}} 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) } @@ -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", Admission: protocol.Admission{RequiredCapabilities: []catalog.Capability{catalog.CapabilityProductMutate}}, 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}, }}} @@ -158,7 +158,8 @@ func TestRecoveryCannotWidenFacetsOutsideAdmissionCapabilities(t *testing.T) { prior, _ := durable.EncodeState(before) target, _ := durable.EncodeState(after) record := journalRecord{ - TransitionID: "repository-program/advance", + TransitionID: "repository-program/advance", + AllowedStateFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, Admission: protocol.Admission{RequiredCapabilities: []catalog.Capability{ catalog.CapabilityRepositoryWrite, }}, diff --git a/boatstack/internal/softwaredelivery/protocol/journal.go b/boatstack/internal/softwaredelivery/protocol/journal.go index 4931b64..261a231 100644 --- a/boatstack/internal/softwaredelivery/protocol/journal.go +++ b/boatstack/internal/softwaredelivery/protocol/journal.go @@ -2,4 +2,4 @@ package protocol // JournalSchemaVersion identifies the transaction record that embeds an exact // prescription-bound admission. -const JournalSchemaVersion = 7 +const JournalSchemaVersion = 8