Skip to content

Commit a925772

Browse files
authored
Make transition receipts durable commit facts (#198)
1 parent 18edd86 commit a925772

24 files changed

Lines changed: 864 additions & 176 deletions

.github/tests/test_detached_supervision.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,12 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -
310310
self.assertEqual(applied_process.stderr, "")
311311
self.assertEqual(applied["receipt"]["transition_id"], "plan.create")
312312
self.assertEqual(applied["receipt"]["flow_id"], "flow-codex-driver-authority-triggers")
313-
self.assertEqual(applied["receipt"]["outcome"], "succeeded")
313+
self.assertEqual(applied["receipt"]["kind"], "transition-committed")
314+
self.assertTrue(applied["receipt"]["program"]["id"])
315+
self.assertTrue(applied["receipt"]["program"]["version"])
316+
self.assertTrue(applied["receipt"]["program"]["fingerprint"])
317+
self.assertTrue(applied["receipt"]["committed_effects"])
318+
self.assertEqual(applied["receipt"]["verification"]["result"], "satisfied")
314319
self.assertTrue(applied["receipt"]["target_fingerprint"])
315320
self.assertEqual(applied["receipt"]["recovery"], "recovery.resume")
316321
self.assertEqual(applied["snapshot"]["plan"]["value"], "draft")

.github/tests/test_repository_contract.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,9 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -
573573
self.assertTrue(event["authority_fingerprint"])
574574
self.assertTrue(event["required_capabilities"])
575575
self.assertTrue(event["granted_capabilities"])
576-
self.assertTrue(event["exercised_capabilities"])
576+
self.assertNotIn("exercised_capabilities", event)
577+
self.assertTrue(event["committed_effects"])
578+
self.assertEqual(event["verification"]["result"], "satisfied")
577579

578580
def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None:
579581
# 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
692694
if receipt["transition_id"] == "installation.reconcile-update"
693695
)
694696
self.assertTrue(update["program_change_accepted"])
697+
self.assertEqual(update["kind"], "transition-committed")
695698
self.assertRegex(update["prior_program_fingerprint"], r"^[0-9a-f]{64}$")
696-
self.assertRegex(update["program_fingerprint"], r"^[0-9a-f]{64}$")
699+
self.assertTrue(update["program"]["id"])
700+
self.assertTrue(update["program"]["version"])
701+
self.assertRegex(update["program"]["fingerprint"], r"^[0-9a-f]{64}$")
697702
self.assertRegex(update["program_delta_fingerprint"], r"^[0-9a-f]{64}$")
703+
self.assertTrue(update["committed_effects"])
704+
self.assertEqual(update["verification"]["result"], "satisfied")
698705
self.assertEqual(
699706
update["runtime_fingerprint"], hashlib.sha256(self.helper.read_bytes()).hexdigest()
700707
)

boatstack/internal/effects/cas_integration_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) {
120120
t.Fatalf("concurrent results: success=%d stale=%d one=%v two=%v three=%v", successes, stale, one.err, two.err, three.err)
121121
}
122122
if committed.Receipt == nil || committed.Receipt.PriorStateRevision != 1 || committed.Receipt.ResultingStateRevision != 2 ||
123-
committed.Receipt.ProgramFingerprint != program.Fingerprint() || committed.Receipt.PrescriptionID != request.Prescription.ID {
123+
committed.Receipt.Program.Fingerprint != program.Fingerprint() || committed.Receipt.PrescriptionID != request.Prescription.ID {
124124
t.Fatalf("commit receipt does not prove the consumed revision/program pair: %#v", committed.Receipt)
125125
}
126126

@@ -136,6 +136,19 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) {
136136
if err != nil || bytes.Count(receiptRaw, []byte("\n")) != 1 {
137137
t.Fatalf("receipt stream contains more than one commit: %v %q", err, receiptRaw)
138138
}
139+
committedJournals, err := filepath.Glob(filepath.Join(layout.JournalRoot, "*.committed"))
140+
if err != nil || len(committedJournals) != 1 {
141+
t.Fatalf("canonical committed journal count=%d err=%v", len(committedJournals), err)
142+
}
143+
committedRaw, err := os.ReadFile(committedJournals[0])
144+
if err != nil || !bytes.Contains(committedRaw, []byte(committed.Receipt.ID)) || !bytes.Contains(committedRaw, []byte("committed_effects")) {
145+
t.Fatalf("committed journal lacks its complete transition fact: %v %q", err, committedRaw)
146+
}
147+
// Simulate a crash after canonical commit but before the passive receipt
148+
// projection reaches its consumer. Replay must recover from the journal fact.
149+
if err := os.Remove(layout.ReceiptPath); err != nil {
150+
t.Fatal(err)
151+
}
139152

140153
replayRequest := request
141154
replayRequest.IdempotencyKey = committed.Receipt.IdempotencyKey

boatstack/internal/effects/integration_test.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type fixedClock struct{ value time.Time }
3535

3636
const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
3737

38+
var testProgramIdentity = protocol.ProgramIdentity{ID: "standard", Version: "test", Fingerprint: testProgramFingerprint}
39+
3840
func testGoalContracts() catalog.GoalContracts {
3941
manifest, err := standard.Definition().RuntimeManifest(context.Background())
4042
if err != nil {
@@ -158,7 +160,7 @@ func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) {
158160
if err != nil {
159161
t.Fatal(err)
160162
}
161-
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts)
163+
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts)
162164
if err != nil {
163165
t.Fatal(err)
164166
}
@@ -320,7 +322,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) {
320322
if err != nil {
321323
t.Fatal(err)
322324
}
323-
if initialized.Receipt == nil || initialized.Receipt.ProgramFingerprint != oldProgram.Fingerprint() {
325+
if initialized.Receipt == nil || initialized.Receipt.Program.Fingerprint != oldProgram.Fingerprint() {
324326
t.Fatalf("initial receipt did not freeze old program: %#v", initialized.Receipt)
325327
}
326328
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) {
390392
if err != nil {
391393
t.Fatal(err)
392394
}
393-
if reconciled.Receipt == nil || reconciled.Receipt.ProgramFingerprint != newProgram.Fingerprint() ||
395+
if reconciled.Receipt == nil || reconciled.Receipt.Program.Fingerprint != newProgram.Fingerprint() ||
394396
reconciled.Receipt.PriorProgramFingerprint != oldProgram.Fingerprint() || reconciled.Receipt.ProgramDeltaFingerprint == "" ||
395397
!reconciled.Receipt.ProgramChangeAccepted || reconciled.Receipt.RuntimeFingerprint != digestBytes(runtimeRaw) ||
396398
reconciled.Receipt.RuntimeSourceRevision != "program-new" || reconciled.Snapshot == nil ||
@@ -506,7 +508,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test
506508
{Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)},
507509
{Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
508510
})
509-
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 {
511+
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 {
510512
t.Fatalf("receipt lost capability or authority provenance: %#v", initialized.Receipt)
511513
}
512514
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
549551
t.Fatalf("unmet extension obligation decision = %#v", next.Decision)
550552
}
551553
completed := apply(releasenote.Transition, authority(catalog.AuthorityRepository), nil)
552-
if completed.Receipt == nil || completed.Receipt.TransitionID != releasenote.Transition || completed.Receipt.ProgramFingerprint != program.Fingerprint() ||
554+
if completed.Receipt == nil || completed.Receipt.TransitionID != releasenote.Transition || completed.Receipt.Program.Fingerprint != program.Fingerprint() ||
553555
completed.Snapshot == nil || completed.Snapshot.ExtensionFacts[releasenote.FactID].Value != "verified" {
554556
t.Fatalf("extension did not traverse verified receipt path: %#v", completed)
555557
}
@@ -588,7 +590,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing.
588590
journal, _ := effects.NewJournal(resolver, clock)
589591
receipts, _ := effects.NewReceiptStore(resolver, clock)
590592
driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
591-
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts)
593+
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts)
592594
if err != nil {
593595
t.Fatal(err)
594596
}
@@ -734,7 +736,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T)
734736
journal, _ := effects.NewJournal(resolver, clock)
735737
receipts, _ := effects.NewReceiptStore(resolver, clock)
736738
driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
737-
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramFingerprint, observer, clock, locker, journal, driver, receipts)
739+
kernel, err := engine.New(testprogram.StandardRegistry(), testGoalContracts(), testProgramIdentity, observer, clock, locker, journal, driver, receipts)
738740
if err != nil {
739741
t.Fatal(err)
740742
}

boatstack/internal/effects/journal.go

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package effects
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"os"
910
"path/filepath"
11+
"slices"
1012
"strings"
1113
"time"
1214

@@ -30,17 +32,18 @@ func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal,
3032
}
3133

3234
type journalRecord struct {
33-
SchemaVersion int `json:"schema_version"`
34-
Admission protocol.Admission `json:"admission"`
35-
TransitionID catalog.TransitionID `json:"transition_id"`
36-
TransitionClass catalog.EventClass `json:"transition_class"`
37-
ReconcilesProgram bool `json:"reconciles_program,omitempty"`
38-
Status string `json:"status"`
39-
Mutations []ports.ResourceMutation `json:"mutations,omitempty"`
40-
Reason string `json:"reason,omitempty"`
41-
ReceiptID string `json:"receipt_id,omitempty"`
42-
CreatedAt time.Time `json:"created_at"`
43-
UpdatedAt time.Time `json:"updated_at"`
35+
SchemaVersion int `json:"schema_version"`
36+
Admission protocol.Admission `json:"admission"`
37+
TransitionID catalog.TransitionID `json:"transition_id"`
38+
TransitionClass catalog.EventClass `json:"transition_class"`
39+
ReconcilesProgram bool `json:"reconciles_program,omitempty"`
40+
Status string `json:"status"`
41+
Mutations []ports.ResourceMutation `json:"mutations,omitempty"`
42+
Reason string `json:"reason,omitempty"`
43+
ReceiptID string `json:"receipt_id,omitempty"`
44+
Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"`
45+
CreatedAt time.Time `json:"created_at"`
46+
UpdatedAt time.Time `json:"updated_at"`
4447
}
4548

4649
func journalName(id, suffix string) (string, error) {
@@ -110,9 +113,72 @@ func readJournal(path string) (journalRecord, error) {
110113
if err := record.Admission.ValidateIdentity(); err != nil || record.Admission.TransitionID != record.TransitionID {
111114
return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, err)
112115
}
116+
if record.Receipt != nil {
117+
if err := record.Receipt.Validate(); err != nil || record.Receipt.ID != record.ReceiptID || record.Receipt.AdmissionID != record.Admission.ID || record.Receipt.TransitionID != record.TransitionID {
118+
return journalRecord{}, fmt.Errorf("invalid committed transition fact in %s: %v", path, err)
119+
}
120+
receipt := record.Receipt
121+
admission := record.Admission
122+
if receipt.PrescriptionID != admission.PrescriptionID || receipt.TransitionVersion != admission.TransitionVersion || receipt.Program.Fingerprint != admission.ExpectedProgramFingerprint ||
123+
receipt.PriorStateRevision != admission.ExpectedStateRevision || receipt.SourceFingerprint != admission.ExpectedSnapshotFingerprint ||
124+
receipt.AuthorityFingerprint != admission.AuthorityFingerprint || !slices.Equal(receipt.RequiredCapabilities, admission.RequiredCapabilities) ||
125+
!slices.Equal(receipt.GrantedCapabilities, admission.GrantedCapabilities) || receipt.GoalID != admission.Goal.ID || receipt.GoalKind != admission.Goal.Kind ||
126+
receipt.DeliveryID != admission.Goal.DeliveryID || receipt.GoalScope != admission.GoalScope || receipt.GoalStatus != admission.GoalStatus {
127+
return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its exact admission", path)
128+
}
129+
if err := validateCommittedMutationFacts(record.TransitionClass, record.Mutations, receipt.CommittedEffects); err != nil {
130+
return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err)
131+
}
132+
}
133+
if strings.HasSuffix(path, ".committed") && (record.Status != "committed" || record.Receipt == nil) {
134+
return journalRecord{}, fmt.Errorf("committed transaction journal %s lacks its canonical transition fact", path)
135+
}
113136
return record, nil
114137
}
115138

139+
func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, facts []protocol.EffectFact) error {
140+
resourceFacts := make([]protocol.EffectFact, 0, len(facts))
141+
boundarySettled := false
142+
for _, fact := range facts {
143+
if fact.Kind == protocol.EffectResourceMutation {
144+
resourceFacts = append(resourceFacts, fact)
145+
} else if fact.Kind == protocol.EffectBoundarySettled {
146+
boundarySettled = true
147+
}
148+
}
149+
if class == catalog.EventOwnedExternal && !boundarySettled {
150+
return fmt.Errorf("owned external transaction lacks a settled boundary fact")
151+
}
152+
if len(resourceFacts) != len(mutations) {
153+
return fmt.Errorf("resource fact count %d does not match staged mutation count %d", len(resourceFacts), len(mutations))
154+
}
155+
matched := make([]bool, len(resourceFacts))
156+
for _, mutation := range mutations {
157+
operation := "update"
158+
switch {
159+
case mutation.Delete:
160+
operation = "delete"
161+
case mutation.TargetLink != "":
162+
operation = "symlink"
163+
case !mutation.PriorExists:
164+
operation = "create"
165+
}
166+
prior := mutationStateFingerprint(mutation.PriorExists, mutation.Prior, mutation.PriorLink, mutation.Mode)
167+
result := mutationStateFingerprint(!mutation.Delete, mutation.Target, mutation.TargetLink, mutation.Mode)
168+
found := false
169+
for index, fact := range resourceFacts {
170+
if !matched[index] && fact.Target == mutation.Path && fact.Operation == operation && fact.PriorFingerprint == prior && fact.ResultingFingerprint == result {
171+
matched[index], found = true, true
172+
break
173+
}
174+
}
175+
if !found {
176+
return fmt.Errorf("staged mutation %s has no exact committed effect fact", mutation.Path)
177+
}
178+
}
179+
return nil
180+
}
181+
116182
func (j *Journal) update(ctx context.Context, admissionID string, update func(*journalRecord)) error {
117183
name, err := journalName(admissionID, ".pending")
118184
if err != nil {
@@ -192,6 +258,10 @@ func (j *Journal) finalize(ctx context.Context, admissionID, suffix, status, rea
192258
return err
193259
}
194260
record.Status, record.Reason, record.ReceiptID, record.UpdatedAt = status, reason, receiptID, j.clock.Now().UTC()
261+
if status != "committed" {
262+
record.ReceiptID = ""
263+
record.Receipt = nil
264+
}
195265
raw, err := encodeJSON(record)
196266
if err != nil {
197267
return err
@@ -208,7 +278,54 @@ func (j *Journal) finalize(ctx context.Context, admissionID, suffix, status, rea
208278
}
209279

210280
func (j *Journal) Commit(ctx context.Context, receipt protocol.TransitionReceipt) error {
211-
return j.finalize(ctx, receipt.AdmissionID, ".committed", "committed", "", receipt.ID)
281+
if err := receipt.Validate(); err != nil {
282+
return err
283+
}
284+
name, err := journalName(receipt.AdmissionID, ".pending")
285+
if err != nil {
286+
return err
287+
}
288+
path, ok := j.activePath(name)
289+
if !ok {
290+
return fmt.Errorf("transaction journal path is not bound for %s", receipt.AdmissionID)
291+
}
292+
record, err := readJournal(path)
293+
if err != nil {
294+
return err
295+
}
296+
if record.Admission.ID != receipt.AdmissionID || record.TransitionID != receipt.TransitionID {
297+
return fmt.Errorf("transition fact does not match its transaction journal")
298+
}
299+
record.Status, record.Reason, record.ReceiptID, record.UpdatedAt = "committed", "", receipt.ID, j.clock.Now().UTC()
300+
receiptCopy := receipt
301+
record.Receipt = &receiptCopy
302+
raw, err := encodeJSON(record)
303+
if err != nil {
304+
return err
305+
}
306+
// Persist the complete fact into the pending record, then atomically rename
307+
// that same record. A crash exposes either recovery-required pending work or
308+
// one canonical committed fact, never a separate success that outruns it.
309+
if err := atomicWrite(path, raw, 0o600); err != nil {
310+
return err
311+
}
312+
finalPath := strings.TrimSuffix(path, ".pending") + ".committed"
313+
if _, statErr := os.Stat(finalPath); statErr == nil {
314+
return fmt.Errorf("committed transaction journal already exists for %s", receipt.AdmissionID)
315+
} else if !os.IsNotExist(statErr) {
316+
return statErr
317+
}
318+
if err := replaceFile(path, finalPath); err != nil {
319+
return err
320+
}
321+
if err := syncDirectory(filepath.Dir(path)); err != nil {
322+
if rollbackErr := replaceFile(finalPath, path); rollbackErr != nil {
323+
return errors.Join(err, fmt.Errorf("restore pending journal after directory sync failure: %w", rollbackErr))
324+
}
325+
return err
326+
}
327+
j.unbind(name)
328+
return nil
212329
}
213330

214331
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 {
218335
func (j *Journal) RequireRecovery(ctx context.Context, admissionID, reason string) error {
219336
return j.update(ctx, admissionID, func(record *journalRecord) {
220337
record.Status, record.Reason = "recovery-required", reason
338+
record.ReceiptID, record.Receipt = "", nil
221339
})
222340
}
223341

0 commit comments

Comments
 (0)