From fcb4b26a01c5cb027ee28851070d232f402b1836 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 04:47:42 +0100 Subject: [PATCH] Make transition receipts durable commit facts --- .github/tests/test_detached_supervision.py | 7 +- .github/tests/test_repository_contract.py | 11 +- .../internal/effects/cas_integration_test.go | 15 +- .../internal/effects/integration_test.go | 16 +- boatstack/internal/effects/journal.go | 142 +++++++++++- boatstack/internal/effects/prepared.go | 63 +++++- boatstack/internal/effects/prepared_test.go | 44 ++++ boatstack/internal/effects/receipts.go | 113 +++++----- boatstack/internal/effects/recovery.go | 1 + boatstack/internal/effects/recovery_test.go | 4 +- boatstack/internal/kernel/engine/engine.go | 44 ++-- .../internal/kernel/engine/engine_test.go | 78 +++++-- .../kernel/engine/maintenance_replay_test.go | 2 +- boatstack/internal/kernel/ports/ports.go | 3 +- boatstack/internal/kernel/protocol/journal.go | 2 +- boatstack/internal/kernel/protocol/receipt.go | 211 ++++++++++++++---- .../protocol/receipt_capability_test.go | 7 +- .../kernel/protocol/receipt_fact_test.go | 181 +++++++++++++++ boatstack/kernel.go | 3 +- boatstack/sdk/sdk.go | 14 ++ boatstack/sdk/sdk_test.go | 23 ++ docs/architecture/boatstack-v2-kernel.md | 33 ++- .../architecture/prescription-transactions.md | 20 +- .../2026-08-12-durable-transition-facts.md | 3 + 24 files changed, 864 insertions(+), 176 deletions(-) create mode 100644 boatstack/internal/kernel/protocol/receipt_fact_test.go create mode 100644 release-notes/2026-08-12-durable-transition-facts.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 256a869..5cc689b 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -310,7 +310,12 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - self.assertEqual(applied_process.stderr, "") self.assertEqual(applied["receipt"]["transition_id"], "plan.create") self.assertEqual(applied["receipt"]["flow_id"], "flow-codex-driver-authority-triggers") - self.assertEqual(applied["receipt"]["outcome"], "succeeded") + self.assertEqual(applied["receipt"]["kind"], "transition-committed") + self.assertTrue(applied["receipt"]["program"]["id"]) + self.assertTrue(applied["receipt"]["program"]["version"]) + self.assertTrue(applied["receipt"]["program"]["fingerprint"]) + self.assertTrue(applied["receipt"]["committed_effects"]) + self.assertEqual(applied["receipt"]["verification"]["result"], "satisfied") self.assertTrue(applied["receipt"]["target_fingerprint"]) self.assertEqual(applied["receipt"]["recovery"], "recovery.resume") self.assertEqual(applied["snapshot"]["plan"]["value"], "draft") diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index d264bac..6a50a17 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -573,7 +573,9 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.assertTrue(event["authority_fingerprint"]) self.assertTrue(event["required_capabilities"]) self.assertTrue(event["granted_capabilities"]) - self.assertTrue(event["exercised_capabilities"]) + self.assertNotIn("exercised_capabilities", event) + self.assertTrue(event["committed_effects"]) + self.assertEqual(event["verification"]["result"], "satisfied") def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None: # control-law: accepted-program-delta-atomically-pins-runtime-and-program @@ -692,9 +694,14 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No if receipt["transition_id"] == "installation.reconcile-update" ) self.assertTrue(update["program_change_accepted"]) + self.assertEqual(update["kind"], "transition-committed") self.assertRegex(update["prior_program_fingerprint"], r"^[0-9a-f]{64}$") - self.assertRegex(update["program_fingerprint"], r"^[0-9a-f]{64}$") + self.assertTrue(update["program"]["id"]) + self.assertTrue(update["program"]["version"]) + self.assertRegex(update["program"]["fingerprint"], r"^[0-9a-f]{64}$") self.assertRegex(update["program_delta_fingerprint"], r"^[0-9a-f]{64}$") + self.assertTrue(update["committed_effects"]) + self.assertEqual(update["verification"]["result"], "satisfied") self.assertEqual( update["runtime_fingerprint"], hashlib.sha256(self.helper.read_bytes()).hexdigest() ) diff --git a/boatstack/internal/effects/cas_integration_test.go b/boatstack/internal/effects/cas_integration_test.go index 9d793f7..89b53d7 100644 --- a/boatstack/internal/effects/cas_integration_test.go +++ b/boatstack/internal/effects/cas_integration_test.go @@ -120,7 +120,7 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) { t.Fatalf("concurrent results: success=%d stale=%d one=%v two=%v three=%v", successes, stale, one.err, two.err, three.err) } if committed.Receipt == nil || committed.Receipt.PriorStateRevision != 1 || committed.Receipt.ResultingStateRevision != 2 || - committed.Receipt.ProgramFingerprint != program.Fingerprint() || committed.Receipt.PrescriptionID != request.Prescription.ID { + committed.Receipt.Program.Fingerprint != program.Fingerprint() || committed.Receipt.PrescriptionID != request.Prescription.ID { t.Fatalf("commit receipt does not prove the consumed revision/program pair: %#v", committed.Receipt) } @@ -136,6 +136,19 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) { if err != nil || bytes.Count(receiptRaw, []byte("\n")) != 1 { t.Fatalf("receipt stream contains more than one commit: %v %q", err, receiptRaw) } + committedJournals, err := filepath.Glob(filepath.Join(layout.JournalRoot, "*.committed")) + if err != nil || len(committedJournals) != 1 { + 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")) { + t.Fatalf("committed journal lacks its complete transition fact: %v %q", err, committedRaw) + } + // Simulate a crash after canonical commit but before the passive receipt + // projection reaches its consumer. Replay must recover from the journal fact. + if err := os.Remove(layout.ReceiptPath); err != nil { + t.Fatal(err) + } replayRequest := request replayRequest.IdempotencyKey = committed.Receipt.IdempotencyKey diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go index 230a7b4..2d0038d 100644 --- a/boatstack/internal/effects/integration_test.go +++ b/boatstack/internal/effects/integration_test.go @@ -35,6 +35,8 @@ type fixedClock struct{ value time.Time } const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +var testProgramIdentity = protocol.ProgramIdentity{ID: "standard", Version: "test", Fingerprint: testProgramFingerprint} + func testGoalContracts() catalog.GoalContracts { manifest, err := standard.Definition().RuntimeManifest(context.Background()) if err != nil { @@ -158,7 +160,7 @@ func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { if err != nil { t.Fatal(err) } - kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } @@ -320,7 +322,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err != nil { t.Fatal(err) } - if initialized.Receipt == nil || initialized.Receipt.ProgramFingerprint != oldProgram.Fingerprint() { + if initialized.Receipt == nil || initialized.Receipt.Program.Fingerprint != oldProgram.Fingerprint() { t.Fatalf("initial receipt did not freeze old program: %#v", initialized.Receipt) } if initialized.Snapshot == nil || initialized.Snapshot.Goal.Status != model.FactAbsent || initialized.Receipt.GoalStatus != model.FactAbsent || initialized.Receipt.GoalID != "" { @@ -390,7 +392,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err != nil { t.Fatal(err) } - if reconciled.Receipt == nil || reconciled.Receipt.ProgramFingerprint != newProgram.Fingerprint() || + if reconciled.Receipt == nil || reconciled.Receipt.Program.Fingerprint != newProgram.Fingerprint() || reconciled.Receipt.PriorProgramFingerprint != oldProgram.Fingerprint() || reconciled.Receipt.ProgramDeltaFingerprint == "" || !reconciled.Receipt.ProgramChangeAccepted || reconciled.Receipt.RuntimeFingerprint != digestBytes(runtimeRaw) || reconciled.Receipt.RuntimeSourceRevision != "program-new" || reconciled.Snapshot == nil || @@ -506,7 +508,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test {Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) - if initialized.Receipt == nil || initialized.Receipt.AuthorityFingerprint == "" || len(initialized.Receipt.AuthoritySources) != 1 || len(initialized.Receipt.RequiredCapabilities) == 0 || len(initialized.Receipt.GrantedCapabilities) == 0 || len(initialized.Receipt.ExercisedCapabilities) == 0 { + if initialized.Receipt == nil || initialized.Receipt.AuthorityFingerprint == "" || len(initialized.Receipt.AuthoritySources) != 1 || len(initialized.Receipt.RequiredCapabilities) == 0 || len(initialized.Receipt.GrantedCapabilities) == 0 || len(initialized.Receipt.ExercisedCapabilities) != 0 || len(initialized.Receipt.CommittedEffects) == 0 || initialized.Receipt.Verification.Result != protocol.VerificationSatisfied { t.Fatalf("receipt lost capability or authority provenance: %#v", initialized.Receipt) } apply("goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}}) @@ -549,7 +551,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test t.Fatalf("unmet extension obligation decision = %#v", next.Decision) } completed := apply(releasenote.Transition, authority(catalog.AuthorityRepository), nil) - if completed.Receipt == nil || completed.Receipt.TransitionID != releasenote.Transition || completed.Receipt.ProgramFingerprint != program.Fingerprint() || + if completed.Receipt == nil || completed.Receipt.TransitionID != releasenote.Transition || completed.Receipt.Program.Fingerprint != program.Fingerprint() || completed.Snapshot == nil || completed.Snapshot.ExtensionFacts[releasenote.FactID].Value != "verified" { t.Fatalf("extension did not traverse verified receipt path: %#v", completed) } @@ -588,7 +590,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing. journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) - kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } @@ -734,7 +736,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary()) - kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts) + kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/effects/journal.go b/boatstack/internal/effects/journal.go index 6337317..30f5ae8 100644 --- a/boatstack/internal/effects/journal.go +++ b/boatstack/internal/effects/journal.go @@ -3,10 +3,12 @@ package effects import ( "context" "encoding/json" + "errors" "fmt" "io" "os" "path/filepath" + "slices" "strings" "time" @@ -30,17 +32,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"` - 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"` - 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) { @@ -110,9 +113,72 @@ 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) } + 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) + } + receipt := record.Receipt + admission := record.Admission + if receipt.PrescriptionID != admission.PrescriptionID || receipt.TransitionVersion != admission.TransitionVersion || receipt.Program.Fingerprint != admission.ExpectedProgramFingerprint || + receipt.PriorStateRevision != admission.ExpectedStateRevision || receipt.SourceFingerprint != admission.ExpectedSnapshotFingerprint || + receipt.AuthorityFingerprint != admission.AuthorityFingerprint || !slices.Equal(receipt.RequiredCapabilities, admission.RequiredCapabilities) || + !slices.Equal(receipt.GrantedCapabilities, admission.GrantedCapabilities) || receipt.GoalID != admission.Goal.ID || receipt.GoalKind != admission.Goal.Kind || + 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 { + return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) + } + } + if strings.HasSuffix(path, ".committed") && (record.Status != "committed" || record.Receipt == nil) { + return journalRecord{}, fmt.Errorf("committed transaction journal %s lacks its canonical transition fact", path) + } return record, nil } +func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, facts []protocol.EffectFact) error { + resourceFacts := make([]protocol.EffectFact, 0, len(facts)) + boundarySettled := false + for _, fact := range facts { + if fact.Kind == protocol.EffectResourceMutation { + resourceFacts = append(resourceFacts, fact) + } else if fact.Kind == protocol.EffectBoundarySettled { + boundarySettled = true + } + } + if class == catalog.EventOwnedExternal && !boundarySettled { + return fmt.Errorf("owned external transaction lacks a settled boundary fact") + } + if len(resourceFacts) != len(mutations) { + return fmt.Errorf("resource fact count %d does not match staged mutation count %d", len(resourceFacts), len(mutations)) + } + matched := make([]bool, len(resourceFacts)) + for _, mutation := range mutations { + operation := "update" + switch { + case mutation.Delete: + operation = "delete" + case mutation.TargetLink != "": + operation = "symlink" + case !mutation.PriorExists: + operation = "create" + } + prior := mutationStateFingerprint(mutation.PriorExists, mutation.Prior, mutation.PriorLink, mutation.Mode) + result := mutationStateFingerprint(!mutation.Delete, mutation.Target, mutation.TargetLink, mutation.Mode) + found := false + for index, fact := range resourceFacts { + if !matched[index] && fact.Target == mutation.Path && fact.Operation == operation && fact.PriorFingerprint == prior && fact.ResultingFingerprint == result { + matched[index], found = true, true + break + } + } + if !found { + return fmt.Errorf("staged mutation %s has no exact committed effect fact", mutation.Path) + } + } + return nil +} + func (j *Journal) update(ctx context.Context, admissionID string, update func(*journalRecord)) error { name, err := journalName(admissionID, ".pending") if err != nil { @@ -192,6 +258,10 @@ func (j *Journal) finalize(ctx context.Context, admissionID, suffix, status, rea return err } record.Status, record.Reason, record.ReceiptID, record.UpdatedAt = status, reason, receiptID, j.clock.Now().UTC() + if status != "committed" { + record.ReceiptID = "" + record.Receipt = nil + } raw, err := encodeJSON(record) if err != nil { return err @@ -208,7 +278,54 @@ func (j *Journal) finalize(ctx context.Context, admissionID, suffix, status, rea } func (j *Journal) Commit(ctx context.Context, receipt protocol.TransitionReceipt) error { - return j.finalize(ctx, receipt.AdmissionID, ".committed", "committed", "", receipt.ID) + if err := receipt.Validate(); err != nil { + return err + } + name, err := journalName(receipt.AdmissionID, ".pending") + if err != nil { + return err + } + path, ok := j.activePath(name) + if !ok { + return fmt.Errorf("transaction journal path is not bound for %s", receipt.AdmissionID) + } + record, err := readJournal(path) + if err != nil { + return err + } + if record.Admission.ID != receipt.AdmissionID || record.TransitionID != receipt.TransitionID { + return fmt.Errorf("transition fact does not match its transaction journal") + } + record.Status, record.Reason, record.ReceiptID, record.UpdatedAt = "committed", "", receipt.ID, j.clock.Now().UTC() + receiptCopy := receipt + record.Receipt = &receiptCopy + raw, err := encodeJSON(record) + if err != nil { + return err + } + // Persist the complete fact into the pending record, then atomically rename + // that same record. A crash exposes either recovery-required pending work or + // one canonical committed fact, never a separate success that outruns it. + if err := atomicWrite(path, raw, 0o600); err != nil { + return err + } + finalPath := strings.TrimSuffix(path, ".pending") + ".committed" + if _, statErr := os.Stat(finalPath); statErr == nil { + return fmt.Errorf("committed transaction journal already exists for %s", receipt.AdmissionID) + } else if !os.IsNotExist(statErr) { + return statErr + } + if err := replaceFile(path, finalPath); err != nil { + return err + } + if err := syncDirectory(filepath.Dir(path)); err != nil { + if rollbackErr := replaceFile(finalPath, path); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("restore pending journal after directory sync failure: %w", rollbackErr)) + } + return err + } + j.unbind(name) + return nil } func (j *Journal) Abort(ctx context.Context, admissionID, reason string) error { @@ -218,6 +335,7 @@ func (j *Journal) Abort(ctx context.Context, admissionID, reason string) error { func (j *Journal) RequireRecovery(ctx context.Context, admissionID, reason string) error { return j.update(ctx, admissionID, func(record *journalRecord) { record.Status, record.Reason = "recovery-required", reason + record.ReceiptID, record.Receipt = "", nil }) } diff --git a/boatstack/internal/effects/prepared.go b/boatstack/internal/effects/prepared.go index 8f426f5..35db3c6 100644 --- a/boatstack/internal/effects/prepared.go +++ b/boatstack/internal/effects/prepared.go @@ -2,6 +2,7 @@ package effects import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -20,7 +21,9 @@ type preparedEffect struct { boundary boundaryCall verifyInvocation *model.InvocationContext applied []ports.ResourceMutation - externalSettled bool + boundarySettled bool + effectResult ports.EffectResult + transition catalog.Transition requiredCapabilities []catalog.Capability effectiveCapabilities []catalog.Capability } @@ -31,6 +34,58 @@ func (p *preparedEffect) Manifest() []ports.ResourceMutation { return result } +func (p *preparedEffect) CommittedEffects() []protocol.EffectFact { + facts := make([]protocol.EffectFact, 0, len(p.applied)+1) + for _, mutation := range p.applied { + resource, owner := mutation.Resource, mutation.Owner + if resource == "" && len(p.transition.OwnedResources) > 0 { + resource = p.transition.OwnedResources[0] + } + if owner == "" { + owner = p.transition.Owner + } + operation := "update" + switch { + case mutation.Delete: + operation = "delete" + case mutation.TargetLink != "": + operation = "symlink" + case !mutation.PriorExists: + operation = "create" + } + facts = append(facts, protocol.EffectFact{ + Kind: protocol.EffectResourceMutation, EffectID: p.transition.Effect, Owner: owner, Resource: resource, + Target: mutation.Path, Operation: operation, + PriorFingerprint: mutationStateFingerprint(mutation.PriorExists, mutation.Prior, mutation.PriorLink, mutation.Mode), + ResultingFingerprint: mutationStateFingerprint(!mutation.Delete, mutation.Target, mutation.TargetLink, mutation.Mode), + }) + } + if p.boundarySettled { + raw, _ := json.Marshal(p.effectResult) + facts = append(facts, protocol.EffectFact{ + Kind: protocol.EffectBoundarySettled, EffectID: p.transition.Effect, Owner: p.transition.Owner, + Target: string(p.transition.ID), Operation: "settled", + PriorFingerprint: sha256Bytes([]byte("not-applicable")), ResultingFingerprint: sha256Bytes(raw), + }) + } + return facts +} + +func mutationStateFingerprint(exists bool, content []byte, link string, mode uint32) string { + value := struct { + Exists bool `json:"exists"` + ContentSHA256 string `json:"content_sha256,omitempty"` + Link string `json:"link,omitempty"` + Mode uint32 `json:"mode,omitempty"` + }{Exists: exists, Link: link} + if exists && link == "" { + value.ContentSHA256 = sha256Bytes(content) + value.Mode = mode + } + raw, _ := json.Marshal(value) + return sha256Bytes(raw) +} + func (p *preparedEffect) VerificationInvocation() (model.InvocationContext, bool) { if p.verifyInvocation == nil { return model.InvocationContext{}, false @@ -52,7 +107,8 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error if err != nil || result.Settlement == ports.EffectUnknown { return result, err } - p.externalSettled = true + p.boundarySettled = true + p.effectResult = result } ordered := append([]ports.ResourceMutation(nil), p.mutations...) sort.SliceStable(ordered, func(i, j int) bool { @@ -87,6 +143,7 @@ func bindPreparedCapabilities(effect *preparedEffect, admission protocol.Admissi } effect.requiredCapabilities = catalog.RequiredCapabilities(transition) effect.effectiveCapabilities = append([]catalog.Capability(nil), admission.EffectiveCapabilities...) + effect.transition = transition return nil } @@ -107,7 +164,7 @@ func (p *preparedEffect) Rollback(context.Context) error { } } p.applied = nil - if p.externalSettled { + if p.boundarySettled { rollbackErrors = append(rollbackErrors, fmt.Errorf("external effect settled and requires reconciliation or compensation")) } return errors.Join(rollbackErrors...) diff --git a/boatstack/internal/effects/prepared_test.go b/boatstack/internal/effects/prepared_test.go index 2a0401c..3420af0 100644 --- a/boatstack/internal/effects/prepared_test.go +++ b/boatstack/internal/effects/prepared_test.go @@ -8,6 +8,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" ) func TestPreparedEffectNeverAcceptsMixedEpochAtWriteBoundary(t *testing.T) { @@ -97,3 +98,46 @@ func TestPreparedEffectRefusesMissingKernelCapabilityBeforeAnyEffect(t *testing. t.Fatalf("denied effect mutated repository: %v", err) } } + +func TestPreparedEffectReportsOnlyActuallyAppliedEffects(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "state.json") + transition := catalog.Transition{ID: "program/write", Effect: "program.write", Owner: "program", OwnedResources: []string{"program.state"}} + prepared := &preparedEffect{ + transition: transition, requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + mutations: []ports.ResourceMutation{{Resource: "program.state", Owner: "program", Path: target, Target: []byte("committed"), Mode: 0o600}}, + } + if facts := prepared.CommittedEffects(); len(facts) != 0 { + t.Fatalf("planned mutation escaped as committed evidence: %#v", facts) + } + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + facts := prepared.CommittedEffects() + if len(facts) != 1 || facts[0].Kind != protocol.EffectResourceMutation || facts[0].EffectID != transition.Effect || facts[0].Target != target || facts[0].Operation != "create" { + t.Fatalf("applied mutation fact = %#v", facts) + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + if facts := prepared.CommittedEffects(); len(facts) != 0 { + t.Fatalf("rolled-back mutation remained committed: %#v", facts) + } +} + +func TestSettledEffectBoundaryProducesAStableKernelFact(t *testing.T) { + transition := catalog.Transition{ID: "publication.execute", Effect: "publication.execute", Owner: "product-delivery"} + prepared := &preparedEffect{ + transition: transition, requiredCapabilities: []catalog.Capability{catalog.CapabilityPublicationPublish}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityPublicationPublish}, + boundary: func(context.Context) (ports.EffectResult, error) { + return ports.EffectResult{Settlement: ports.EffectSettled, Detail: "provider-adapter-settled"}, nil + }, + } + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + facts := prepared.CommittedEffects() + if len(facts) != 1 || facts[0].Kind != protocol.EffectBoundarySettled || facts[0].Target != string(transition.ID) || len(facts[0].ResultingFingerprint) != 64 { + t.Fatalf("settled boundary fact = %#v", facts) + } +} diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go index 66fab8e..583cdf5 100644 --- a/boatstack/internal/effects/receipts.go +++ b/boatstack/internal/effects/receipts.go @@ -1,12 +1,13 @@ package effects import ( - "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" + "sort" + "strings" "time" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" @@ -71,31 +72,31 @@ func (s *ReceiptStore) layoutForFlow(_ context.Context, flowID string) (ports.Co return binding.layout, nil } -func scanReceipts(path string, visit func(protocol.TransitionReceipt) error) error { - file, err := os.Open(path) +func scanCommittedReceipts(layout ports.ControllerLayout, visit func(protocol.TransitionReceipt) error) error { + entries, err := os.ReadDir(layout.JournalRoot) if err != nil { if os.IsNotExist(err) { return nil } return err } - defer file.Close() - scanner := bufio.NewScanner(file) - buffer := make([]byte, 64*1024) - scanner.Buffer(buffer, 4*1024*1024) - for scanner.Scan() { - var receipt protocol.TransitionReceipt - if err := json.Unmarshal(scanner.Bytes(), &receipt); err != nil { - return fmt.Errorf("decode receipt stream: %w", err) + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".committed") { + continue } - if err := receipt.Validate(); err != nil { - return fmt.Errorf("verify receipt stream: %w", err) + record, err := readJournal(filepath.Join(layout.JournalRoot, entry.Name())) + if err != nil { + return err + } + if record.Receipt == nil { + return fmt.Errorf("committed transaction %s lacks a transition fact", entry.Name()) } - if err := visit(receipt); err != nil { + if err := visit(*record.Receipt); err != nil { return err } } - return scanner.Err() + return nil } func (s *ReceiptStore) NextSequence(ctx context.Context, flowID string) (uint64, error) { @@ -104,7 +105,7 @@ func (s *ReceiptStore) NextSequence(ctx context.Context, flowID string) (uint64, return 0, err } var maximum uint64 - err = scanReceipts(layout.ReceiptPath, func(receipt protocol.TransitionReceipt) error { + err = scanCommittedReceipts(layout, func(receipt protocol.TransitionReceipt) error { if receipt.FlowID == flowID && receipt.Sequence > maximum { maximum = receipt.Sequence } @@ -122,8 +123,11 @@ func (s *ReceiptStore) FindByIdempotency(ctx context.Context, invocation model.I return protocol.TransitionReceipt{}, false, err } var found protocol.TransitionReceipt - err = scanReceipts(layout.ReceiptPath, func(receipt protocol.TransitionReceipt) error { + err = scanCommittedReceipts(layout, func(receipt protocol.TransitionReceipt) error { if receipt.IdempotencyKey == key { + if found.ID != "" && found.ID != receipt.ID { + return fmt.Errorf("idempotency key %q identifies multiple committed transition facts", key) + } found = receipt } return nil @@ -132,30 +136,32 @@ func (s *ReceiptStore) FindByIdempotency(ctx context.Context, invocation model.I } type processEvent struct { - SchemaVersion int `json:"schema_version"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - Timestamp time.Time `json:"timestamp"` - GoalID string `json:"goal_id"` - GoalScope string `json:"goal_scope,omitempty"` - GoalStatus string `json:"goal_status,omitempty"` - TransitionID string `json:"transition_id"` - ProgramFingerprint string `json:"program_fingerprint"` - PrescriptionID string `json:"prescription_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultingStateRevision uint64 `json:"resulting_state_revision"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - Outcome string `json:"outcome"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - AuthorityClasses []string `json:"authority_classes,omitempty"` - AuthorityFingerprint string `json:"authority_fingerprint"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities"` - Recovery string `json:"recovery,omitempty"` - Terminal string `json:"terminal"` - FailureClass string `json:"failure_class,omitempty"` + SchemaVersion int `json:"schema_version"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + Timestamp time.Time `json:"timestamp"` + GoalID string `json:"goal_id"` + GoalScope string `json:"goal_scope,omitempty"` + GoalStatus string `json:"goal_status,omitempty"` + TransitionID string `json:"transition_id"` + ProgramID string `json:"program_id"` + ProgramVersion string `json:"program_version"` + ProgramFingerprint string `json:"program_fingerprint"` + PrescriptionID string `json:"prescription_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultingStateRevision uint64 `json:"resulting_state_revision"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + FactKind string `json:"fact_kind"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + AuthorityClasses []string `json:"authority_classes,omitempty"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + CommittedEffects []protocol.EffectFact `json:"committed_effects"` + Verification protocol.VerificationFact `json:"verification"` + Recovery string `json:"recovery,omitempty"` + Terminal string `json:"terminal"` } func appendLine(path string, value any, mode os.FileMode) error { @@ -181,7 +187,10 @@ func appendLine(path string, value any, mode os.FileMode) error { return file.Close() } -func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionReceipt) error { +func (s *ReceiptStore) Project(ctx context.Context, receipt protocol.TransitionReceipt) error { + if err := receipt.Validate(); err != nil { + return err + } layout, err := s.layoutForFlow(ctx, receipt.FlowID) if err != nil { return err @@ -190,17 +199,21 @@ func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionRe return err } event := processEvent{ - SchemaVersion: 3, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, + SchemaVersion: 4, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID, GoalScope: string(receipt.GoalScope), GoalStatus: string(receipt.GoalStatus), - TransitionID: string(receipt.TransitionID), ProgramFingerprint: receipt.ProgramFingerprint, PrescriptionID: receipt.PrescriptionID, + 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, SourceFingerprint: receipt.SourceFingerprint, TargetFingerprint: receipt.TargetFingerprint, - Outcome: string(receipt.Outcome), DurationNanoseconds: receipt.DurationNanoseconds, - AuthorityClasses: append([]string(nil), receipt.AuthorityClasses...), Recovery: string(receipt.Recovery), Terminal: string(receipt.Terminal), FailureClass: receipt.FailureClass, - AuthorityFingerprint: receipt.AuthorityFingerprint, - RequiredCapabilities: append([]catalog.Capability(nil), receipt.RequiredCapabilities...), - GrantedCapabilities: append([]catalog.Capability(nil), receipt.GrantedCapabilities...), - ExercisedCapabilities: append([]catalog.Capability(nil), receipt.ExercisedCapabilities...), + FactKind: string(receipt.Kind), DurationNanoseconds: receipt.DurationNanoseconds, + Recovery: string(receipt.Recovery), Terminal: string(receipt.Terminal), + AuthorityFingerprint: receipt.AuthorityFingerprint, + RequiredCapabilities: append([]catalog.Capability(nil), receipt.RequiredCapabilities...), + GrantedCapabilities: append([]catalog.Capability(nil), receipt.GrantedCapabilities...), + CommittedEffects: append([]protocol.EffectFact(nil), receipt.CommittedEffects...), + Verification: receipt.Verification, + } + for _, source := range receipt.AuthoritySources { + event.AuthorityClasses = append(event.AuthorityClasses, string(source.Class)) } // Telemetry is passive and never changes transition success. _ = appendLine(layout.EventPath, event, 0o600) diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go index 7352dce..6310bd2 100644 --- a/boatstack/internal/effects/recovery.go +++ b/boatstack/internal/effects/recovery.go @@ -262,6 +262,7 @@ func prepareJournalClosure(layout ports.ControllerLayout, transactionID, outcome func prepareJournalClosureFromRecord(pendingPath string, record journalRecord, outcome string, now time.Time) ([]ports.ResourceMutation, error) { record.Status = "recovered" record.Reason = strings.TrimSpace(outcome) + record.ReceiptID, record.Receipt = "", nil record.UpdatedAt = now.UTC() raw, err := encodeJSON(record) if err != nil { diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go index a58714d..0cc5eb0 100644 --- a/boatstack/internal/effects/recovery_test.go +++ b/boatstack/internal/effects/recovery_test.go @@ -25,6 +25,8 @@ type recoveryClock struct{ value time.Time } const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +var testProgramIdentity = protocol.ProgramIdentity{ID: "standard", Version: "test", Fingerprint: testProgramFingerprint} + func testGoalContracts() catalog.GoalContracts { manifest, err := standard.Definition().RuntimeManifest(context.Background()) if err != nil { @@ -170,7 +172,7 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. locker, _ := NewLocker(resolver) journalAfterRestart, _ := NewJournal(resolver, clock) receipts, _ := NewReceiptStore(resolver, clock) - restartedEngine, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journalAfterRestart, driver, receipts) + restartedEngine, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journalAfterRestart, driver, receipts) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go index 35d1321..027bdb8 100644 --- a/boatstack/internal/kernel/engine/engine.go +++ b/boatstack/internal/kernel/engine/engine.go @@ -15,26 +15,26 @@ import ( ) type Engine struct { - registry catalog.Registry - control supervisor.Supervisor - programFingerprint string - observer ports.Observer - clock ports.Clock - locker ports.Locker - journal ports.Journal - effects ports.EffectDriver - receipts ports.ReceiptStore + registry catalog.Registry + control supervisor.Supervisor + program protocol.ProgramIdentity + observer ports.Observer + clock ports.Clock + locker ports.Locker + journal ports.Journal + effects ports.EffectDriver + receipts ports.ReceiptStore } -func New(registry catalog.Registry, contracts catalog.GoalContracts, programFingerprint string, observer ports.Observer, clock ports.Clock, locker ports.Locker, journal ports.Journal, effects ports.EffectDriver, receipts ports.ReceiptStore) (Engine, error) { - if registry.Len() == 0 || len(contracts) == 0 || len(programFingerprint) != 64 || observer == nil || clock == nil || locker == nil || journal == nil || effects == nil || receipts == nil { +func New(registry catalog.Registry, contracts catalog.GoalContracts, program protocol.ProgramIdentity, observer ports.Observer, clock ports.Clock, locker ports.Locker, journal ports.Journal, effects ports.EffectDriver, receipts ports.ReceiptStore) (Engine, error) { + if registry.Len() == 0 || len(contracts) == 0 || program.Validate() != nil || observer == nil || clock == nil || locker == nil || journal == nil || effects == nil || receipts == nil { return Engine{}, fmt.Errorf("kernel engine requires registry, goal contracts, observer, clock, locker, journal, effects, and receipt store") } - return Engine{registry: registry, control: supervisor.New(registry, contracts), programFingerprint: programFingerprint, observer: observer, clock: clock, locker: locker, journal: journal, effects: effects, receipts: receipts}, nil + return Engine{registry: registry, control: supervisor.New(registry, contracts), program: program, observer: observer, clock: clock, locker: locker, journal: journal, effects: effects, receipts: receipts}, nil } func (e Engine) canonicalize(observation model.Observation) (model.Snapshot, error) { - return model.CanonicalizeForProgram(observation, e.programFingerprint) + return model.CanonicalizeForProgram(observation, e.program.Fingerprint) } type ResolveRequest struct { @@ -240,7 +240,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check supplied idempotency key: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: request.Authority.GrantedCapabilities(e.clock.Now())}) @@ -304,7 +304,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check idempotency: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: admission.GrantedCapabilities}) @@ -353,7 +353,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check locked idempotency: %w", findErr) } if ok { - if err := validateReplayRequest(prior, request, e.programFingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { return result, err } if err := validateReplayGoalState(prior, lockedSnapshot); err != nil { @@ -455,16 +455,16 @@ 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, admission, transition, target, startedAt, completedAt, protocol.OutcomeSucceeded, "") + receipt, err := protocol.NewReceipt(request.FlowID, sequence, e.program, admission, transition, target, prepared.CommittedEffects(), nil, startedAt, completedAt) if err != nil { return result, requireRecovery("receipt construction failed after verified effect", err) } - if err := e.receipts.Append(ctx, receipt); err != nil { - return result, requireRecovery("receipt append failed after verified effect", err) - } if err := e.journal.Commit(ctx, receipt); err != nil { - return result, requireRecovery("journal commit failed after receipt", err) + return result, requireRecovery("transition fact commit failed after verified effect", err) } + // Receipt and event streams are passive projections. The committed journal + // record above is the canonical idempotency and audit fact. + _ = e.receipts.Project(ctx, receipt) result.Receipt = receipt return result, nil } @@ -489,7 +489,7 @@ func validatePrescriptionCurrent(prescription protocol.Prescription, snapshot mo } func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string) error { - if prior.ProgramFingerprint != programFingerprint { + if prior.Program.Fingerprint != programFingerprint { return fmt.Errorf("idempotency receipt belongs to a different control program") } if prior.FlowID != request.FlowID { diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index c081278..e72275b 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -21,6 +21,8 @@ type fixedClock struct{ now time.Time } const syntheticProgramFingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +var syntheticProgram = protocol.ProgramIdentity{ID: "test.synthetic", Version: "1.0.0", Fingerprint: syntheticProgramFingerprint} + func syntheticGoalContracts(t *testing.T) catalog.GoalContracts { t.Helper() contracts, err := catalog.NewGoalContracts([]catalog.GoalContract{{ @@ -78,6 +80,7 @@ func (l fakeLocker) Acquire(context.Context, model.InvocationContext, []string) type fakeJournal struct { begun, committed, aborted, recovery int failMark string + commitErr error } func (j *fakeJournal) Begin(context.Context, protocol.Admission, catalog.Transition) error { @@ -92,6 +95,9 @@ func (j *fakeJournal) Mark(_ context.Context, _ string, status string) error { return nil } func (j *fakeJournal) Commit(context.Context, protocol.TransitionReceipt) error { + if j.commitErr != nil { + return j.commitErr + } j.committed++ return nil } @@ -106,15 +112,23 @@ type fakeEffects struct { result ports.EffectResult err error prepareErr error + transition catalog.Transition } -func (e *fakeEffects) Prepare(context.Context, protocol.Admission, catalog.Transition) (ports.PreparedEffect, error) { +func (e *fakeEffects) Prepare(_ context.Context, _ protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) { if e.prepareErr != nil { return nil, e.prepareErr } + e.transition = transition return e, nil } func (e *fakeEffects) Manifest() []ports.ResourceMutation { return nil } +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], + Target: "/test/state.json", Operation: "update", PriorFingerprint: strings.Repeat("1", 64), ResultingFingerprint: strings.Repeat("2", 64), + }} +} func (e *fakeEffects) VerificationInvocation() (model.InvocationContext, bool) { return model.InvocationContext{}, false } @@ -128,8 +142,9 @@ func (e *fakeEffects) Rollback(context.Context) error { } type memoryReceipts struct { - next uint64 - values []protocol.TransitionReceipt + next uint64 + values []protocol.TransitionReceipt + projectErr error } func (s *memoryReceipts) Bind(context.Context, string, protocol.Admission) error { return nil } @@ -147,7 +162,10 @@ func (s *memoryReceipts) FindByIdempotency(_ context.Context, _ model.Invocation } return protocol.TransitionReceipt{}, false, nil } -func (s *memoryReceipts) Append(_ context.Context, receipt protocol.TransitionReceipt) error { +func (s *memoryReceipts) Project(_ context.Context, receipt protocol.TransitionReceipt) error { + if s.projectErr != nil { + return s.projectErr + } s.values = append(s.values, receipt) return nil } @@ -268,7 +286,7 @@ func TestRequiredObserverFailureReturnsTypedUnresolvedDecision(t *testing.T) { now := time.Unix(30, 0).UTC() journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} kernel, err := New( - testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, + testRegistry(t), syntheticGoalContracts(t), syntheticProgram, failingObserver{err: errors.New("observer unavailable")}, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts, ) @@ -310,7 +328,7 @@ func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T t.Fatal(err) } observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} - kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) if err != nil { t.Fatal(err) } @@ -340,7 +358,7 @@ func TestResolutionDoesNotPrescribeAnEffectThatDeterministicPreflightRejects(t * effects := &fakeEffects{prepareErr: errors.New("malformed artifact")} observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}} journal := &fakeJournal{} - kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, &memoryReceipts{}) + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, &memoryReceipts{}) if err != nil { t.Fatal(err) } @@ -361,7 +379,7 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target"), observation(model.PhaseActive, "target")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) if err != nil { t.Fatal(err) } @@ -383,6 +401,36 @@ func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) } } +func TestCommitFailureCannotProjectSuccessfulTransitionFact(t *testing.T) { + // control-law: canonical commit precedes every passive success projection + now := time.Unix(30, 0).UTC() + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target")}} + journal := &fakeJournal{commitErr: errors.New("injected canonical commit failure")} + effects, receipts := &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{} + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, receipts) + result, err := kernel.Apply(context.Background(), request(t, now)) + if err == nil || !strings.Contains(err.Error(), "canonical commit failure") { + t.Fatalf("error=%v, want canonical commit failure", err) + } + if result.Receipt.ID != "" || len(receipts.values) != 0 || journal.recovery != 1 { + t.Fatalf("failed commit escaped as success: result=%#v projections=%d journal=%+v", result.Receipt, len(receipts.values), journal) + } +} + +func TestProjectionFailureCannotUndoCanonicalTransitionFact(t *testing.T) { + // control-law: receipt JSONL and telemetry are projections, not commit authority + now := time.Unix(30, 0).UTC() + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target")}} + journal := &fakeJournal{} + effects := &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}} + receipts := &memoryReceipts{projectErr: errors.New("projection unavailable")} + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, receipts) + result, err := kernel.Apply(context.Background(), request(t, now)) + if err != nil || result.Receipt.ID == "" || journal.committed != 1 || journal.recovery != 0 || len(receipts.values) != 0 { + t.Fatalf("passive projection changed commit result: result=%#v err=%v journal=%+v", result.Receipt, err, journal) + } +} + func TestSyntheticStartVerifyTerminalContractNeedsNoStandardFlowFacet(t *testing.T) { // control-law: kernel-terminal-is-defined-only-by-the-compiled-control-program-contract goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"} @@ -463,7 +511,7 @@ func TestIdempotencyReceiptCannotHideUncommittedRecoveryJournal(t *testing.T) { recoveryObservation("recovery"), }} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, err := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) if err != nil { t.Fatal(err) } @@ -488,7 +536,7 @@ func TestApplyRejectsSnapshotDriftBeforeEffect(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "drifted")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(t, now)) var stale StalePrescriptionError if !errors.As(err, &stale) { @@ -506,7 +554,7 @@ func TestApplyRejectsHumanRevisionAdvanceBeforeEffect(t *testing.T) { advanced.StateRevision = 2 observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), advanced}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(t, now)) var stale StalePrescriptionError if !errors.As(err, &stale) || stale.ExpectedStateRevision != 1 || stale.ObservedStateRevision != 2 { @@ -522,7 +570,7 @@ func TestApplyRollsBackFailedPostconditionAndDoesNotReceipt(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(t, now)) var postcondition PostconditionError if !errors.As(err, &postcondition) { @@ -539,7 +587,7 @@ func TestApplyRequiresRecoveryWhenJournalFailsAfterEffect(t *testing.T) { observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} journal := &fakeJournal{failMark: "verifying"} effects, receipts, lock := &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(t, now)) if err == nil || !strings.Contains(err.Error(), "injected journal mark failure") { t.Fatalf("error=%v, want injected post-effect journal failure", err) @@ -554,7 +602,7 @@ func TestApplyPreservesUnknownExternalOutcomeForReconciliation(t *testing.T) { now := time.Unix(30, 0).UTC() observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectUnknown}}, &memoryReceipts{}, &fakeLock{} - kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, _ := New(testRegistry(t), syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) _, err := kernel.Apply(context.Background(), request(t, now)) var unknown ExternalOutcomeUnknownError if !errors.As(err, &unknown) { @@ -571,7 +619,7 @@ func TestOwnedExternalExecutionErrorRequiresRecoveryWithoutRollback(t *testing.T registry := testRegistryWithAdvanceClass(t, catalog.EventOwnedExternal) observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}} journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{err: context.DeadlineExceeded}, &memoryReceipts{}, &fakeLock{} - kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgramFingerprint, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) + kernel, err := New(registry, syntheticGoalContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/kernel/engine/maintenance_replay_test.go b/boatstack/internal/kernel/engine/maintenance_replay_test.go index 330f9a8..85a233c 100644 --- a/boatstack/internal/kernel/engine/maintenance_replay_test.go +++ b/boatstack/internal/kernel/engine/maintenance_replay_test.go @@ -28,7 +28,7 @@ func TestMaintenanceReplayBindsDurableGoalState(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { test.receipt.FlowID = request.FlowID - test.receipt.ProgramFingerprint = syntheticProgramFingerprint + test.receipt.Program = syntheticProgram if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint); err != nil { t.Fatalf("command goal affected maintenance replay identity: %v", err) } diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index 35380ea..19bf280 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -89,6 +89,7 @@ type ResourceMutation struct { type PreparedEffect interface { Manifest() []ResourceMutation + CommittedEffects() []protocol.EffectFact VerificationInvocation() (model.InvocationContext, bool) Execute(context.Context) (EffectResult, error) Rollback(context.Context) error @@ -105,5 +106,5 @@ type ReceiptStore interface { Unbind(string) NextSequence(context.Context, string) (uint64, error) FindByIdempotency(context.Context, model.InvocationContext, string) (protocol.TransitionReceipt, bool, error) - Append(context.Context, protocol.TransitionReceipt) error + Project(context.Context, protocol.TransitionReceipt) error } diff --git a/boatstack/internal/kernel/protocol/journal.go b/boatstack/internal/kernel/protocol/journal.go index 2ee6520..5fb8f00 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 = 4 +const JournalSchemaVersion = 5 diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index b383447..a3d7622 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -1,33 +1,102 @@ package protocol import ( + "encoding/hex" "fmt" "sort" + "strings" "time" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const ReceiptSchemaVersion = 5 +const ReceiptSchemaVersion = 6 -type Outcome string +type TransitionFactKind string + +const TransitionCommitted TransitionFactKind = "transition-committed" + +type ProgramIdentity struct { + ID string `json:"id"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint"` +} + +func (p ProgramIdentity) Validate() error { + if p.ID == "" || p.Version == "" || !validSHA256(p.Fingerprint) { + return fmt.Errorf("receipt requires exact program id, version, and fingerprint") + } + return nil +} + +type EffectFactKind string const ( - OutcomeSucceeded Outcome = "succeeded" - OutcomeRecovered Outcome = "recovered" - OutcomeRefused Outcome = "refused" - OutcomeUnknown Outcome = "external-outcome-unknown" + EffectResourceMutation EffectFactKind = "resource-mutation" + EffectBoundarySettled EffectFactKind = "effect-boundary-settled" ) +type EffectFact struct { + Kind EffectFactKind `json:"kind"` + EffectID catalog.EffectID `json:"effect_id"` + Owner string `json:"owner"` + Resource string `json:"resource,omitempty"` + Target string `json:"target"` + Operation string `json:"operation"` + PriorFingerprint string `json:"prior_fingerprint"` + ResultingFingerprint string `json:"resulting_fingerprint"` +} + +func (f EffectFact) Validate() error { + if f.EffectID == "" || f.Owner == "" || f.Target == "" || f.Operation == "" || !validSHA256(f.PriorFingerprint) || !validSHA256(f.ResultingFingerprint) { + return fmt.Errorf("receipt effect fact has incomplete identity") + } + switch f.Kind { + case EffectResourceMutation: + if f.Resource == "" { + return fmt.Errorf("resource mutation fact requires a governed resource") + } + case EffectBoundarySettled: + if f.Resource != "" || f.Operation != "settled" { + return fmt.Errorf("settled boundary fact has invalid resource or operation") + } + default: + return fmt.Errorf("receipt effect fact has invalid kind %q", f.Kind) + } + return nil +} + +type VerificationResult string + +const VerificationSatisfied VerificationResult = "satisfied" + +type VerificationFact struct { + Verifier string `json:"verifier"` + ExpectedPostcondition string `json:"expected_postcondition"` + Result VerificationResult `json:"result"` + EvidenceFingerprint string `json:"evidence_fingerprint"` + VerifiedAt time.Time `json:"verified_at"` +} + +func (v VerificationFact) Validate() error { + if v.Verifier == "" || v.ExpectedPostcondition == "" || v.Result != VerificationSatisfied || !validSHA256(v.EvidenceFingerprint) || v.VerifiedAt.IsZero() { + return fmt.Errorf("receipt verification fact is incomplete or not satisfied") + } + return nil +} + +// TransitionReceipt is the immutable fact for one committed transition. It is +// not a request, prescription, admission, refusal, or recovery authorization. type TransitionReceipt struct { SchemaVersion int `json:"schema_version"` + Kind TransitionFactKind `json:"kind"` ID string `json:"id"` FlowID string `json:"flow_id"` Sequence uint64 `json:"sequence"` + Program ProgramIdentity `json:"program"` TransitionID catalog.TransitionID `json:"transition_id"` TransitionVersion int `json:"transition_version"` - ProgramFingerprint string `json:"program_fingerprint"` PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` @@ -45,21 +114,19 @@ type TransitionReceipt struct { GoalStatus model.FactStatus `json:"goal_status,omitempty"` SourceFingerprint string `json:"source_fingerprint"` TargetFingerprint string `json:"target_fingerprint"` - AuthorityClasses []string `json:"authority_classes"` AuthorityFingerprint string `json:"authority_fingerprint"` AuthoritySources []AuthoritySource `json:"authority_sources"` RequiredCapabilities []catalog.Capability `json:"required_capabilities"` GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` + CommittedEffects []EffectFact `json:"committed_effects"` + Verification VerificationFact `json:"verification"` IdempotencyKey string `json:"idempotency_key"` - Verifier string `json:"verifier"` - Outcome Outcome `json:"outcome"` Recovery catalog.TransitionID `json:"recovery,omitempty"` Terminal model.TerminalStatus `json:"terminal"` StartedAt time.Time `json:"started_at"` - CompletedAt time.Time `json:"completed_at"` + CommittedAt time.Time `json:"committed_at"` DurationNanoseconds int64 `json:"duration_nanoseconds"` - FailureClass string `json:"failure_class,omitempty"` } type AuthoritySource struct { @@ -69,42 +136,51 @@ type AuthoritySource struct { Fingerprint string `json:"fingerprint"` } -func NewReceipt(flowID string, sequence uint64, admission Admission, transition catalog.Transition, target model.Snapshot, startedAt, completedAt time.Time, outcome Outcome, failureClass string) (TransitionReceipt, error) { +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) { if flowID == "" || sequence == 0 || admission.ID == "" || target.Fingerprint == "" { return TransitionReceipt{}, fmt.Errorf("receipt requires flow, sequence, admission, and target identity") } - if completedAt.Before(startedAt) { - return TransitionReceipt{}, fmt.Errorf("receipt completion precedes start") + if err := program.Validate(); err != nil { + return TransitionReceipt{}, err + } + if program.Fingerprint != admission.ExpectedProgramFingerprint { + return TransitionReceipt{}, fmt.Errorf("receipt program identity differs from admitted program") + } + if committedAt.Before(startedAt) { + return TransitionReceipt{}, fmt.Errorf("receipt commit precedes effect start") } if admission.ExpectedStateRevision == ^uint64(0) || target.StateRevision != admission.ExpectedStateRevision+1 { return TransitionReceipt{}, fmt.Errorf("receipt target revision must advance exactly once from the prescribed revision") } - classes := make([]string, 0, len(admission.Authority.Receipts)) + if len(effects) == 0 { + return TransitionReceipt{}, fmt.Errorf("committed transition requires kernel-observed effect facts") + } sources := make([]AuthoritySource, 0, len(admission.Authority.Receipts)) for _, authority := range admission.Authority.Receipts { - classes = append(classes, string(authority.Class)) sources = append(sources, AuthoritySource{ID: authority.ID, Class: authority.Class, Subject: authority.Subject, Fingerprint: authority.Fingerprint}) } terminal := model.TerminalUnknown if target.Terminal.Status == model.FactKnown { terminal = target.Terminal.Value } + canonicalEffects := append([]EffectFact(nil), effects...) + sortEffectFacts(canonicalEffects) receipt := TransitionReceipt{ - SchemaVersion: ReceiptSchemaVersion, FlowID: flowID, Sequence: sequence, TransitionID: transition.ID, - TransitionVersion: transition.Version, ProgramFingerprint: admission.ExpectedProgramFingerprint, + SchemaVersion: ReceiptSchemaVersion, Kind: TransitionCommitted, FlowID: flowID, Sequence: sequence, Program: program, + TransitionID: transition.ID, TransitionVersion: transition.Version, PrescriptionID: admission.PrescriptionID, AdmissionID: admission.ID, PriorStateRevision: admission.ExpectedStateRevision, ResultingStateRevision: target.StateRevision, GoalID: admission.Goal.ID, GoalKind: admission.Goal.Kind, DeliveryID: admission.Goal.DeliveryID, GoalScope: admission.GoalScope, GoalStatus: admission.GoalStatus, SourceFingerprint: admission.ExpectedSnapshotFingerprint, TargetFingerprint: target.Fingerprint, - AuthorityClasses: classes, IdempotencyKey: admission.IdempotencyKey, Verifier: transition.Verifier, AuthorityFingerprint: admission.AuthorityFingerprint, AuthoritySources: sources, RequiredCapabilities: append([]catalog.Capability(nil), admission.RequiredCapabilities...), GrantedCapabilities: append([]catalog.Capability(nil), admission.GrantedCapabilities...), - ExercisedCapabilities: append([]catalog.Capability(nil), admission.EffectiveCapabilities...), - Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal, - StartedAt: startedAt.UTC(), CompletedAt: completedAt.UTC(), DurationNanoseconds: completedAt.Sub(startedAt).Nanoseconds(), - FailureClass: failureClass, + ExercisedCapabilities: append([]catalog.Capability(nil), exercised...), + CommittedEffects: canonicalEffects, + 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(), } receipt.PriorProgramFingerprint = admission.PriorProgramFingerprint receipt.ProgramDeltaFingerprint = admission.ProgramDeltaFingerprint @@ -130,8 +206,11 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition } func (r TransitionReceipt) Validate() error { - if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || len(r.ProgramFingerprint) != 64 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision == 0 || r.ResultingStateRevision != r.PriorStateRevision+1 || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || len(r.ExercisedCapabilities) == 0 || r.IdempotencyKey == "" || r.Verifier == "" { - return fmt.Errorf("receipt has incomplete identity or evidence") + 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 { + return fmt.Errorf("receipt has incomplete committed-transition identity or evidence") + } + if err := r.Program.Validate(); err != nil { + return err } if _, err := catalog.NormalizeCapabilities("receipt.required_capabilities", r.RequiredCapabilities); err != nil { return err @@ -139,17 +218,32 @@ func (r TransitionReceipt) Validate() error { if _, err := catalog.NormalizeCapabilities("receipt.granted_capabilities", r.GrantedCapabilities); err != nil { return err } - if _, err := catalog.NormalizeCapabilities("receipt.exercised_capabilities", r.ExercisedCapabilities); err != nil { - return err + if missing := catalog.MissingCapability(r.RequiredCapabilities, catalog.NewCapabilitySet(r.GrantedCapabilities...)); missing != "" { + return fmt.Errorf("receipt required capability %q was not admitted", missing) } - if missing := catalog.MissingCapability(r.ExercisedCapabilities, catalog.NewCapabilitySet(r.GrantedCapabilities...)); missing != "" { - return fmt.Errorf("receipt exercised ungranted capability %q", missing) + if len(r.ExercisedCapabilities) > 0 { + if _, err := catalog.NormalizeCapabilities("receipt.exercised_capabilities", r.ExercisedCapabilities); err != nil { + return err + } + if missing := catalog.MissingCapability(r.ExercisedCapabilities, catalog.NewCapabilitySet(r.RequiredCapabilities...)); missing != "" { + return fmt.Errorf("receipt exercised capability %q outside exact admission", missing) + } } - if !sameCapabilities(r.RequiredCapabilities, r.ExercisedCapabilities) { - return fmt.Errorf("receipt exercised capabilities differ from exact admitted requirements") + if err := r.Verification.Validate(); err != nil || r.Verification.EvidenceFingerprint != r.TargetFingerprint { + return fmt.Errorf("receipt verification does not prove its target snapshot: %v", err) } - if len(r.AuthoritySources) != len(r.AuthorityClasses) { - return fmt.Errorf("receipt authority provenance and class counts differ") + effects := append([]EffectFact(nil), r.CommittedEffects...) + sortEffectFacts(effects) + for index, effect := range effects { + if err := effect.Validate(); err != nil { + return err + } + if effect != r.CommittedEffects[index] { + return fmt.Errorf("receipt committed effects are not canonical") + } + if index > 0 && effect == effects[index-1] { + return fmt.Errorf("receipt duplicates a committed effect fact") + } } authoritySet := catalog.AuthoritySet{} sources := append([]AuthoritySource(nil), r.AuthoritySources...) @@ -161,7 +255,7 @@ func (r TransitionReceipt) Validate() error { if index > 0 && sources[index-1].ID == source.ID { return fmt.Errorf("receipt duplicates authority source %q", source.ID) } - if source != r.AuthoritySources[index] || r.AuthorityClasses[index] != string(source.Class) { + if source != r.AuthoritySources[index] { return fmt.Errorf("receipt authority provenance is not canonical") } authoritySet[source.Class] = true @@ -192,14 +286,9 @@ func (r TransitionReceipt) Validate() error { } else if r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" { return fmt.Errorf("receipt has incomplete product-goal identity") } - if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || r.DurationNanoseconds != r.CompletedAt.Sub(r.StartedAt).Nanoseconds() { + if r.StartedAt.IsZero() || r.CommittedAt.Before(r.StartedAt) || r.DurationNanoseconds != r.CommittedAt.Sub(r.StartedAt).Nanoseconds() || r.Verification.VerifiedAt.After(r.CommittedAt) { return fmt.Errorf("receipt has invalid timing evidence") } - switch r.Outcome { - case OutcomeSucceeded, OutcomeRecovered, OutcomeRefused, OutcomeUnknown: - default: - return fmt.Errorf("receipt has invalid outcome %q", r.Outcome) - } if (r.PriorProgramFingerprint == "") != (r.ProgramDeltaFingerprint == "") { return fmt.Errorf("receipt has incomplete program delta identity") } @@ -207,7 +296,7 @@ func (r TransitionReceipt) Validate() error { return fmt.Errorf("receipt has incomplete runtime version, digest, or source identity") } if r.PriorProgramFingerprint != "" { - delta, err := ProgramDeltaFingerprint(r.PriorProgramFingerprint, r.ProgramFingerprint) + delta, err := ProgramDeltaFingerprint(r.PriorProgramFingerprint, r.Program.Fingerprint) if err != nil || delta != r.ProgramDeltaFingerprint { return fmt.Errorf("receipt has invalid program delta identity") } @@ -230,3 +319,39 @@ func (r TransitionReceipt) Validate() error { } return nil } + +func validSHA256(value string) bool { + if len(value) != 64 || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func sortEffectFacts(facts []EffectFact) { + sort.Slice(facts, func(i, j int) bool { + left, right := facts[i], facts[j] + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + if left.EffectID != right.EffectID { + return left.EffectID < right.EffectID + } + if left.Owner != right.Owner { + return left.Owner < right.Owner + } + if left.Resource != right.Resource { + return left.Resource < right.Resource + } + if left.Target != right.Target { + return left.Target < right.Target + } + if left.Operation != right.Operation { + return left.Operation < right.Operation + } + if left.PriorFingerprint != right.PriorFingerprint { + return left.PriorFingerprint < right.PriorFingerprint + } + return left.ResultingFingerprint < right.ResultingFingerprint + }) +} diff --git a/boatstack/internal/kernel/protocol/receipt_capability_test.go b/boatstack/internal/kernel/protocol/receipt_capability_test.go index 9677391..d4604e2 100644 --- a/boatstack/internal/kernel/protocol/receipt_capability_test.go +++ b/boatstack/internal/kernel/protocol/receipt_capability_test.go @@ -18,7 +18,7 @@ func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { } admission := Admission{ ID: "admission", PrescriptionID: "prescription", ExpectedStateRevision: 1, - ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: "source", + ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64), Goal: model.Goal{ID: "goal", Kind: model.GoalApprovedPlan, DeliveryID: "delivery"}, Authority: authority, AuthorityFingerprint: authorityFingerprint, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, @@ -26,8 +26,9 @@ func TestReceiptRejectsRehashedAuthorityProvenanceTampering(t *testing.T) { EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, IdempotencyKey: "idempotency", } - transition := catalog.Transition{ID: "program/write", Version: 1, Verifier: "program.written"} - receipt, err := NewReceipt("flow", 1, admission, transition, model.Snapshot{Observation: model.Observation{StateRevision: 2}, Fingerprint: "target"}, now, now.Add(time.Second), OutcomeSucceeded, "") + 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)) 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 new file mode 100644 index 0000000..2ecf707 --- /dev/null +++ b/boatstack/internal/kernel/protocol/receipt_fact_test.go @@ -0,0 +1,181 @@ +package protocol + +import ( + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" +) + +func committedReceiptFixture(t *testing.T) (TransitionReceipt, Admission, catalog.Transition, model.Snapshot, time.Time) { + t.Helper() + now := time.Unix(200, 0).UTC() + authority := capabilityAuthority(now, catalog.AuthorityRepository, "policy") + authorityFingerprint, err := authority.Fingerprint() + if err != nil { + t.Fatal(err) + } + admission := Admission{ + ID: "adm-fixture", PrescriptionID: "prx-fixture", ExpectedStateRevision: 41, + ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64), + Goal: model.Goal{ID: "goal", Kind: model.GoalApprovedPlan, DeliveryID: "delivery"}, + Authority: authority, AuthorityFingerprint: authorityFingerprint, + RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, GrantedCapabilities: authority.GrantedCapabilities(now), EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + IdempotencyKey: "idem-fixture", + } + transition := catalog.Transition{ + ID: "product-delivery/build.begin", Version: 3, Owner: "product-delivery", Effect: "build.begin", + TargetPredicate: "build is active", Verifier: "product-delivery.build-active", + } + target := model.Snapshot{Observation: model.Observation{StateRevision: 42}, Fingerprint: strings.Repeat("c", 64)} + effects := []EffectFact{ + {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)) + if err != nil { + t.Fatal(err) + } + return receipt, admission, transition, target, now +} + +func rehashReceipt(t *testing.T, receipt TransitionReceipt) TransitionReceipt { + t.Helper() + receipt.ID = "" + id, err := contentID("trc-", receipt) + if err != nil { + t.Fatal(err) + } + receipt.ID = id + return receipt +} + +func TestCommittedTransitionFactBindsProgramTransitionStateAuthorityEffectsAndVerification(t *testing.T) { + receipt, admission, transition, target, _ := committedReceiptFixture(t) + if err := receipt.Validate(); err != nil { + t.Fatal(err) + } + if receipt.Kind != TransitionCommitted || receipt.Program.ID != "product-delivery" || receipt.Program.Version != "2.1.0" || receipt.Program.Fingerprint != admission.ExpectedProgramFingerprint { + t.Fatalf("program fact = %#v", receipt.Program) + } + if receipt.TransitionID != transition.ID || receipt.PriorStateRevision != 41 || receipt.ResultingStateRevision != 42 { + t.Fatalf("transition/revision fact = %#v", receipt) + } + if receipt.AuthorityFingerprint != admission.AuthorityFingerprint || len(receipt.AuthoritySources) != 1 || len(receipt.RequiredCapabilities) != 1 || len(receipt.GrantedCapabilities) == 0 { + t.Fatalf("authority fact = %#v", receipt) + } + if len(receipt.ExercisedCapabilities) != 0 { + t.Fatalf("receipt fabricated capability exercise: %#v", receipt.ExercisedCapabilities) + } + 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 receipt.Verification.Result != VerificationSatisfied || receipt.Verification.ExpectedPostcondition != transition.TargetPredicate || receipt.Verification.EvidenceFingerprint != target.Fingerprint { + t.Fatalf("verification fact = %#v", receipt.Verification) + } +} + +func TestCommittedTransitionFactRejectsNonSuccessSemantics(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + receipt.Kind = "transition-refused" + receipt = rehashReceipt(t, receipt) + if err := receipt.Validate(); err == nil { + t.Fatal("refusal was accepted as a successful transition fact") + } +} + +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) + if err == nil || !strings.Contains(err.Error(), "differs from admitted program") { + t.Fatalf("program mismatch error = %v", err) + } +} + +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) + if err == nil || !strings.Contains(err.Error(), "advance exactly once") { + t.Fatalf("revision mismatch error = %v", err) + } +} + +func TestCommittedTransitionFactRejectsExercisedCapabilityOutsideAdmission(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + receipt.ExercisedCapabilities = []catalog.Capability{catalog.CapabilityPublicationPublish} + receipt = rehashReceipt(t, receipt) + if err := receipt.Validate(); err == nil || !strings.Contains(err.Error(), "outside exact admission") { + t.Fatalf("unadmitted exercise error = %v", err) + } +} + +func TestCommittedTransitionFactRejectsRequiredButUngrantedCapability(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + receipt.RequiredCapabilities = []catalog.Capability{catalog.CapabilityPublicationPublish} + receipt = rehashReceipt(t, receipt) + if err := receipt.Validate(); err == nil || !strings.Contains(err.Error(), "was not admitted") { + t.Fatalf("ungranted declaration error = %v", err) + } +} + +func TestCommittedTransitionFactRejectsFailedOrMismatchedVerification(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + for _, mutate := range []func(*TransitionReceipt){ + func(value *TransitionReceipt) { value.Verification.Result = "failed" }, + func(value *TransitionReceipt) { value.Verification.EvidenceFingerprint = strings.Repeat("d", 64) }, + } { + candidate := receipt + mutate(&candidate) + candidate = rehashReceipt(t, candidate) + if err := candidate.Validate(); err == nil { + t.Fatalf("invalid verification accepted: %#v", candidate.Verification) + } + } +} + +func TestCommittedTransitionFactRejectsPlannedOrDuplicateEffectEvidence(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + for _, mutate := range []func(*TransitionReceipt){ + func(value *TransitionReceipt) { value.CommittedEffects = nil }, + func(value *TransitionReceipt) { + value.CommittedEffects = append(value.CommittedEffects, value.CommittedEffects[0]) + }, + func(value *TransitionReceipt) { value.CommittedEffects[0].PriorFingerprint = "planned" }, + } { + candidate := receipt + candidate.CommittedEffects = append([]EffectFact(nil), receipt.CommittedEffects...) + mutate(&candidate) + candidate = rehashReceipt(t, candidate) + if err := candidate.Validate(); err == nil { + t.Fatalf("invalid committed effect evidence accepted: %#v", candidate.CommittedEffects) + } + } +} + +func TestHistoricalTransitionFactRetainsOriginalProgramIdentity(t *testing.T) { + receipt, _, _, _, _ := committedReceiptFixture(t) + upgraded := ProgramIdentity{ID: receipt.Program.ID, Version: "3.0.0", Fingerprint: strings.Repeat("b", 64)} + if upgraded == receipt.Program || receipt.Program.Version != "2.1.0" || receipt.Program.Fingerprint != strings.Repeat("a", 64) { + t.Fatalf("historical identity was reinterpreted: receipt=%#v current=%#v", receipt.Program, upgraded) + } + if err := receipt.Validate(); err != nil { + t.Fatalf("program upgrade invalidated historical fact: %v", err) + } +} + +func TestMateriallyDifferentCommitsHaveDifferentReceiptIdentity(t *testing.T) { + first, _, _, _, _ := committedReceiptFixture(t) + second := first + second.Sequence++ + second.ResultingStateRevision++ + second.PriorStateRevision++ + second = rehashReceipt(t, second) + if first.ID == second.ID { + t.Fatal("distinct commits shared one receipt identity") + } +} diff --git a/boatstack/kernel.go b/boatstack/kernel.go index 390a44e..41b2d3e 100644 --- a/boatstack/kernel.go +++ b/boatstack/kernel.go @@ -71,7 +71,8 @@ func NewKernel(externalStateRoot string, program control.ControlProgram) (Kernel } driver := programEffectDriver{base: baseDriver, program: program, resolver: resolver, clock: clock} registry := program.RuntimeRegistry() - runtimeEngine, err := engine.New(registry, program.RuntimeGoalContracts(), program.Fingerprint(), observer, clock, locker, journal, driver, receipts) + summary := program.Summary() + runtimeEngine, err := engine.New(registry, program.RuntimeGoalContracts(), protocol.ProgramIdentity{ID: summary.ProgramID, Version: summary.ProgramVersion, Fingerprint: summary.ProgramFingerprint}, observer, clock, locker, journal, driver, receipts) if err != nil { return Kernel{}, err } diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index 2fd3eab..19e1b2a 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -74,6 +74,20 @@ type Parameter = protocol.Parameter type Parameters = protocol.Parameters type Admission = protocol.Admission type TransitionReceipt = protocol.TransitionReceipt +type TransitionFactKind = protocol.TransitionFactKind +type ProgramIdentity = protocol.ProgramIdentity +type EffectFactKind = protocol.EffectFactKind +type EffectFact = protocol.EffectFact +type VerificationResult = protocol.VerificationResult +type VerificationFact = protocol.VerificationFact + +const ( + TransitionCommitted = protocol.TransitionCommitted + EffectResourceMutation = protocol.EffectResourceMutation + EffectBoundarySettled = protocol.EffectBoundarySettled + VerificationSatisfied = protocol.VerificationSatisfied +) + type Decision = supervisor.Decision type DecisionKind = supervisor.DecisionKind type GuardDecision = supervisor.GuardDecision diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index 81ac990..0d5ca4e 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -3,6 +3,7 @@ package sdk_test import ( "context" "encoding/json" + "reflect" "testing" "github.com/operatorstack/boatstack/boatstack/control" @@ -35,6 +36,28 @@ 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}}`) + var response sdk.Response + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + var before, after map[string]any + if err := json.Unmarshal(raw, &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(encoded, &after); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before["receipt"], after["receipt"]) { + t.Fatalf("SDK changed receipt facts:\nbefore=%#v\nafter=%#v", before["receipt"], after["receipt"]) + } +} + func TestLowLevelSDKRequiresAndAcceptsExactlyOneNonStandardProgramRuntime(t *testing.T) { // control-law: low-level-sdk-never-inserts-or-multiplies-standard-flow if _, err := sdk.NewKernel(""); err == nil { diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md index 7826866..f67bbcd 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-v2-kernel.md @@ -594,7 +594,11 @@ Local transitions follow one journaled protocol: 8. install effects in declared order; 9. install durable state revision `N+1` last; 10. re-observe independently and verify the target predicate; -11. append the immutable receipt, commit the journal, and release the lock. +11. construct the immutable transition fact from applied effects and the + verified target; +12. atomically finalize the journal with that complete fact; +13. project the fact to receipt JSONL and passive process events, then release + the lock. Failure restores exact prior bytes where reversible. A mixed epoch is never an accepted snapshot. An irreversible or unknown external outcome produces a typed @@ -622,16 +626,23 @@ fresh observation and evaluates the catalog's target predicate. Success requires both effect completion and postcondition truth. Otherwise the engine enters the declared rollback, compensation, or recovery path and returns non-success. -`TransitionReceipt` is immutable and content-addressed. It binds schema, flow -and sequence IDs, transition ID/version, prescription and admission IDs, -executable program fingerprint, prior and resulting durable state revisions, -goal ID, source and target fingerprints, authority classes, idempotency key, -timestamps/duration, outcome, postcondition verifier, recovery/terminal -classification, and a privacy-safe failure class. The admission ID transitively -binds invocation, authority receipts, parameters, and expiry. A receipt never -embeds arbitrary output, source, prompts, or secrets. - -Receipts are the only accepted evidence that a managed transition occurred. +`TransitionReceipt` is the immutable, content-addressed fact for one committed +transition. It binds the exact Control Program ID/version/fingerprint, +canonical transition ID/version, prescription and admission IDs, prior and +resulting durable revisions, source and target fingerprints, admitted authority +provenance and capabilities, kernel-observed committed effects, and the exact +postcondition/verifier/evidence result. It contains no refusal, unknown outcome, +requested effect, arbitrary output, source, prompt, credential, or secret. + +The canonical fact is embedded in the atomically finalized `.committed` +transaction journal. Receipt JSONL and process events are passive projections. +A pending, aborted, rolled-back, or recovery-required journal is never a +successful receipt, even if it contains staged mutations. Capability exercise +is omitted unless an effect handler can prove it; admitted capability is not +silently relabeled as exercised capability. + +Committed journal facts are the only accepted evidence that a managed +transition occurred. Plan approvals, publication settlement, and terminal claims point to exact receipts. Idempotency replay validates the stored receipt identity, returns it with a fresh current snapshot, and never repeats the effect. diff --git a/docs/architecture/prescription-transactions.md b/docs/architecture/prescription-transactions.md index 370e4c8..58b8da3 100644 --- a/docs/architecture/prescription-transactions.md +++ b/docs/architecture/prescription-transactions.md @@ -10,7 +10,10 @@ snapshot(state revision N, program fingerprint P) -> repository-scoped lock -> re-observe and compare the complete binding -> effect and durable state commit N+1 - -> receipt(N, N+1, P, prescription, admission) + -> verify target postcondition + -> atomically committed fact(program ID/version/P, transition, N, N+1, + admission, committed effects, verification) + -> passive receipt/event projections ``` The prescription is content-addressed and carries no authority. Apply and @@ -43,3 +46,18 @@ without advancing the committed revision. Recovery of an interrupted journal is itself a prescribed transition and commits a new revision. An external effect whose settlement cannot be proven remains recovery-required and is never blindly retried. + +## Durable transition fact + +A successful `TransitionReceipt` is not created for a request, prescription, +admission, refusal, rollback, or recovery-required transaction. The kernel +constructs it only from the exact applied effect set and a fresh satisfied +target observation. The complete receipt is part of the canonical committed +journal record; JSONL and process events do not authorize replay and are not +commit authority. + +If the process stops after state installation but before fact finalization, the +pending journal keeps the transaction recovery-required and prevents duplicate +execution. If it stops after journal finalization but before returning or +projecting the receipt, idempotent retry discovers the canonical fact in the +committed journal and returns it without executing the effect again. diff --git a/release-notes/2026-08-12-durable-transition-facts.md b/release-notes/2026-08-12-durable-transition-facts.md new file mode 100644 index 0000000..52ef505 --- /dev/null +++ b/release-notes/2026-08-12-durable-transition-facts.md @@ -0,0 +1,3 @@ +### Make committed transition receipts durable facts + +Successful transition receipts now bind the exact Control Program, committed effects, durable revision change, admitted authority, and verified postcondition. The canonical receipt is finalized with its transaction journal before passive history projections are written.