From 480e1e656f88c27eb4bcbe5edbd9f498caab131b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 23 Aug 2026 17:24:24 +0100 Subject: [PATCH 1/2] Settle candidates only through verified atomic binding-plus-receipt commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An immutable candidate becomes accepted state only when verification succeeds and the exact objective binding plus its receipt commit atomically. Receipts move to schema 4 with explicit prior/requested/result objective lineage so the exact accepted delta is provable from the receipt alone. A second domain-neutral revisioned-register fixture executes reusable settlement laws — positive admission, verification rejection, freshness drift, substitution, commit failure, concurrency, replay, restart reconstruction, and fail-closed reading — and white-box counterexamples prove those laws reject dishonest store and reader implementations. --- boatstack/kernel/conformance/conformance.go | 22 +- .../kernel/conformance/conformance_test.go | 43 ++ .../kernel/conformance/revisioned_register.go | 604 ++++++++++++++++++ boatstack/kernel/conformance/settlement.go | 551 ++++++++++++++++ .../kernel/conformance/settlement_law_test.go | 222 +++++++ boatstack/kernel/receipt_test.go | 56 ++ boatstack/kernel/runtime.go | 62 +- boatstack/kernel/settlement_test.go | 11 + boatstack/kernel/types.go | 2 +- docs/architecture/kernel.md | 59 +- ...ions-verification-receipts-and-recovery.md | 10 + .../2026-08-23-trusted-settlement.md | 22 + 12 files changed, 1635 insertions(+), 29 deletions(-) create mode 100644 boatstack/kernel/conformance/revisioned_register.go create mode 100644 boatstack/kernel/conformance/settlement.go create mode 100644 boatstack/kernel/conformance/settlement_law_test.go create mode 100644 boatstack/kernel/receipt_test.go create mode 100644 boatstack/kernel/settlement_test.go create mode 100644 release-notes/2026-08-23-trusted-settlement.md diff --git a/boatstack/kernel/conformance/conformance.go b/boatstack/kernel/conformance/conformance.go index 34f85e79..2a39f659 100644 --- a/boatstack/kernel/conformance/conformance.go +++ b/boatstack/kernel/conformance/conformance.go @@ -114,7 +114,10 @@ func (suite KernelConformance) objectiveBinding(t *testing.T) { if after.State.ObjectiveBinding == nil || !after.State.ObjectiveBinding.Matches(fixture.Scenario.Objective) { t.Fatal("control-law objective-binding: apply did not bind the exact objective") } - if after.CommitCount != before.CommitCount+1 || len(after.Receipts) != len(before.Receipts)+1 || receipt.ObjectiveBinding == nil { + if after.CommitCount != before.CommitCount+1 || len(after.Receipts) != len(before.Receipts)+1 || + receipt.PriorObjectiveBinding != nil || + receipt.RequestedObjectiveBinding == nil || !receipt.RequestedObjectiveBinding.Matches(fixture.Scenario.Objective) || + receipt.ResultObjectiveBinding == nil || !receipt.ResultObjectiveBinding.Matches(fixture.Scenario.Objective) { t.Fatalf("control-law objective-binding: commit/receipt evidence is incomplete: %#v", after) } } @@ -127,6 +130,10 @@ func (suite KernelConformance) objectiveAbsence(t *testing.T) { if before.State.ObjectiveBinding != nil || after.State.ObjectiveBinding != nil { t.Fatal("control-law objective-absence: maintenance synthesized an objective binding") } + receipt := after.Receipts[len(after.Receipts)-1] + if receipt.PriorObjectiveBinding != nil || receipt.RequestedObjectiveBinding != nil || receipt.ResultObjectiveBinding != nil { + t.Fatalf("control-law objective-absence: maintenance receipt synthesized objective lineage: %#v", receipt) + } } func (suite KernelConformance) maintenancePreservesExactBinding(t *testing.T) { @@ -137,6 +144,12 @@ func (suite KernelConformance) maintenancePreservesExactBinding(t *testing.T) { if before.State.ObjectiveBinding == nil || after.State.ObjectiveBinding == nil || *after.State.ObjectiveBinding != *before.State.ObjectiveBinding { t.Fatal("control-law objective-preservation: maintenance changed the exact binding") } + receipt := after.Receipts[len(after.Receipts)-1] + if !reflect.DeepEqual(receipt.PriorObjectiveBinding, before.State.ObjectiveBinding) || + receipt.RequestedObjectiveBinding != nil || + !reflect.DeepEqual(receipt.ResultObjectiveBinding, after.State.ObjectiveBinding) { + t.Fatalf("control-law objective-preservation: receipt lineage differs from preserved binding: %#v", receipt) + } } func (suite KernelConformance) objectiveRevisionInvalidatesPrescription(t *testing.T) { @@ -811,7 +824,12 @@ func committedOutcomeError(program kernel.Program, scenario Scenario, before, af if !ok { return fmt.Errorf("returned receipt transition is absent from program") } - if after.State.InstanceID != returned.InstanceID || after.State.Program != returned.Program || after.State.Revision != returned.ResultStateRevision || after.State.Mode != transition.TargetMode || after.State.Recovery != nil || !reflect.DeepEqual(after.State.ObjectiveBinding, returned.ObjectiveBinding) { + if !reflect.DeepEqual(returned.PriorObjectiveBinding, before.State.ObjectiveBinding) || + !reflect.DeepEqual(returned.RequestedObjectiveBinding, prescription.RequestedObjectiveBinding) || + !reflect.DeepEqual(returned.ResultObjectiveBinding, after.State.ObjectiveBinding) { + return fmt.Errorf("receipt objective lineage differs from prior, requested, or resulting state") + } + if after.State.InstanceID != returned.InstanceID || after.State.Program != returned.Program || after.State.Revision != returned.ResultStateRevision || after.State.Mode != transition.TargetMode || after.State.Recovery != nil { return fmt.Errorf("durable state differs from winning receipt outcome") } if err := exactEffectDelta(before.Effects, after.Effects, returned.TransitionID, 1); err != nil { diff --git a/boatstack/kernel/conformance/conformance_test.go b/boatstack/kernel/conformance/conformance_test.go index 294ded9f..7962d15d 100644 --- a/boatstack/kernel/conformance/conformance_test.go +++ b/boatstack/kernel/conformance/conformance_test.go @@ -229,6 +229,49 @@ func TestCommittedOutcomeRejectsFalsePriorObservation(t *testing.T) { } } +func TestCommittedOutcomeRejectsObjectiveLineageSubstitution(t *testing.T) { + for name, mutate := range map[string]func(*kernel.Receipt, *kernel.ObjectiveBinding){ + "prior": func(receipt *kernel.Receipt, other *kernel.ObjectiveBinding) { + receipt.PriorObjectiveBinding = other + }, + "requested": func(receipt *kernel.Receipt, other *kernel.ObjectiveBinding) { + receipt.RequestedObjectiveBinding = other + }, + "result": func(receipt *kernel.Receipt, other *kernel.ObjectiveBinding) { + receipt.ResultObjectiveBinding = other + }, + } { + t.Run(name, func(t *testing.T) { + fixture := newIntegerFixture(SetupUnbound) + runtime := mustRuntime(t, fixture) + request, prescription := resolve(t, runtime, fixture.Scenario, fixture.Scenario.BindTransition, &fixture.Scenario.Objective, fixture.Scenario.Authority) + before := fixture.Scenario.Snapshot() + returned, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if err != nil { + t.Fatal(err) + } + other, err := kernel.BindObjective(fixture.Scenario.ConflictingObjective) + if err != nil { + t.Fatal(err) + } + mutate(&returned, &other) + returned.ID = "" + digest, err := kernel.Fingerprint(returned) + if err != nil { + t.Fatal(err) + } + returned.ID = "rcp-" + digest + receipts := fixture.Store.(*MemoryStateStore).receipts + receipts.mu.Lock() + receipts.values[len(receipts.values)-1] = returned + receipts.mu.Unlock() + if err := committedOutcomeError(fixture.Program, fixture.Scenario, before, fixture.Scenario.Snapshot(), prescription, returned); err == nil { + t.Fatalf("content-rehashed %s objective lineage substitution was accepted", name) + } + }) + } +} + func TestCommittedOutcomeRejectsNoOpAcceptedByDomainVerifier(t *testing.T) { fixture := newIntegerFixture(SetupBound) domain := fixture.Domain.(*IntegerDomain) diff --git a/boatstack/kernel/conformance/revisioned_register.go b/boatstack/kernel/conformance/revisioned_register.go new file mode 100644 index 00000000..e4f7acbb --- /dev/null +++ b/boatstack/kernel/conformance/revisioned_register.go @@ -0,0 +1,604 @@ +package conformance + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "sync" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +const ( + registerAcceptTransition = "register.accept" + registerRecoverTransition = "register.recover" + registerInspectOperation = "register.inspect" + registerRecoverOperation = "register.recover" +) + +// SettlementSnapshot is deterministic evidence from the settlement fixture. +// Candidate content is intentionally absent: staging is not accepted state. +type SettlementSnapshot struct { + State kernel.ControlState + Observation kernel.Observation + Receipts []kernel.Receipt + Effects map[string]int + Verifications map[string]int + CommitCount int +} + +// AcceptedValue is the independently reconstructed accepted register value. +type AcceptedValue struct { + Binding kernel.ObjectiveBinding + CandidateFingerprint string + Content json.RawMessage + Receipt kernel.Receipt +} + +// SettlementConformance binds a domain-neutral settlement fixture to reusable +// laws. New returns an isolated fixture; Reopen returns a fresh Runtime over +// the same durable ports. +type SettlementConformance struct { + Program kernel.Program + AlternateProgram kernel.Program + InstanceID string + Authority kernel.Authority + AcceptTransition string + RecoveryTransition string + + New func(testing.TB) SettlementConformance + Stage func(any) (kernel.Objective, error) + Accepted func() (AcceptedValue, error) + Snapshot func() SettlementSnapshot + Runtime func(testing.TB, kernel.Program, kernel.Locker) kernel.Runtime + Reopen func(testing.TB) kernel.Runtime + IndependentLocker func() kernel.Locker + FailNextVerification func() + FailNextCommit func() + BumpStateRevision func() + DriftObjectiveBinding func(kernel.Objective) + RetargetProgram func(kernel.ProgramIdentity) + RetargetInstance func(string) + CorruptCandidate func(string) + HideCandidate func(string) + RemoveCommittedReceipts func() + SubstituteCommittedReceipt func(kernel.Receipt) +} + +type registerCandidate struct { + Objective kernel.Objective + ContentFingerprint string + Content json.RawMessage +} + +type registerCandidates struct { + mu sync.Mutex + byObjective map[string]registerCandidate + hidden map[string]bool +} + +func newRegisterCandidates() *registerCandidates { + return ®isterCandidates{byObjective: map[string]registerCandidate{}, hidden: map[string]bool{}} +} + +func (s *registerCandidates) stage(value any) (kernel.Objective, error) { + content, err := json.Marshal(value) + if err != nil { + return kernel.Objective{}, err + } + sum := sha256.Sum256(content) + contentFingerprint := hex.EncodeToString(sum[:]) + objective, err := kernel.NewObjective("register-"+contentFingerprint[:16], 1, struct { + CandidateFingerprint string `json:"candidate_fingerprint"` + }{contentFingerprint}) + if err != nil { + return kernel.Objective{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.byObjective[objective.Fingerprint]; ok { + if existing.ContentFingerprint != contentFingerprint || !reflect.DeepEqual(existing.Content, json.RawMessage(content)) { + return kernel.Objective{}, fmt.Errorf("immutable candidate identity already contains different content") + } + return existing.Objective, nil + } + s.byObjective[objective.Fingerprint] = registerCandidate{ + Objective: objective, ContentFingerprint: contentFingerprint, + Content: append(json.RawMessage(nil), content...), + } + return objective, nil +} + +func (s *registerCandidates) resolve(objective kernel.Objective) (registerCandidate, error) { + if err := objective.Validate(); err != nil { + return registerCandidate{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + candidate, ok := s.byObjective[objective.Fingerprint] + if !ok || s.hidden[objective.Fingerprint] || !candidate.ValidateIs(objective) { + return registerCandidate{}, fmt.Errorf("exact immutable candidate is unavailable") + } + sum := sha256.Sum256(candidate.Content) + if hex.EncodeToString(sum[:]) != candidate.ContentFingerprint { + return registerCandidate{}, fmt.Errorf("immutable candidate fingerprint mismatch") + } + referenceFingerprint, err := candidateFingerprintFromObjective(objective) + if err != nil || referenceFingerprint != candidate.ContentFingerprint { + return registerCandidate{}, fmt.Errorf("objective does not resolve to the immutable candidate") + } + candidate.Content = append(json.RawMessage(nil), candidate.Content...) + return candidate, nil +} + +func (s *registerCandidates) resolveBinding(binding kernel.ObjectiveBinding) (registerCandidate, error) { + s.mu.Lock() + candidate, ok := s.byObjective[binding.ObjectiveFingerprint] + s.mu.Unlock() + if !ok || !binding.Matches(candidate.Objective) { + return registerCandidate{}, fmt.Errorf("objective binding does not identify a staged immutable candidate") + } + return s.resolve(candidate.Objective) +} + +func (s *registerCandidates) identities() []string { + s.mu.Lock() + defer s.mu.Unlock() + identities := make([]string, 0, len(s.byObjective)) + for identity := range s.byObjective { + if !s.hidden[identity] { + identities = append(identities, identity) + } + } + sort.Strings(identities) + return identities +} + +func (s *registerCandidates) corrupt(objectiveFingerprint string) { + s.mu.Lock() + defer s.mu.Unlock() + candidate := s.byObjective[objectiveFingerprint] + candidate.Content = append(candidate.Content, byte(' ')) + s.byObjective[objectiveFingerprint] = candidate +} + +func (s *registerCandidates) hide(objectiveFingerprint string) { + s.mu.Lock() + defer s.mu.Unlock() + s.hidden[objectiveFingerprint] = true +} + +func candidateFingerprintFromObjective(objective kernel.Objective) (string, error) { + var reference struct { + CandidateFingerprint string `json:"candidate_fingerprint"` + } + if err := json.Unmarshal(objective.Reference, &reference); err != nil || len(reference.CandidateFingerprint) != 64 { + return "", fmt.Errorf("objective reference lacks an exact candidate fingerprint") + } + return reference.CandidateFingerprint, nil +} + +// ValidateIs compares immutable objective bytes without accepting an alias. +func (o registerCandidate) ValidateIs(objective kernel.Objective) bool { + return o.Objective.ID == objective.ID && + o.Objective.Revision == objective.Revision && + o.Objective.Fingerprint == objective.Fingerprint && + reflect.DeepEqual(o.Objective.Reference, objective.Reference) +} + +type registerDomain struct { + candidates *registerCandidates + mu sync.Mutex + failVerification bool + verifications map[string]int +} + +func (d *registerDomain) Observe(context.Context, string) (kernel.Observation, error) { + return kernel.NewObservation(struct { + Candidates []string `json:"candidates"` + }{d.candidates.identities()}) +} + +func (d *registerDomain) Admissible(_ context.Context, evaluation kernel.Evaluation) (bool, string, error) { + switch evaluation.Transition.Operation { + case registerInspectOperation: + if evaluation.State.ObjectiveBinding != nil || evaluation.Objective == nil { + return false, "initial acceptance requires an unbound instance and one exact candidate", nil + } + if _, err := d.candidates.resolve(*evaluation.Objective); err != nil { + return false, err.Error(), nil + } + return true, "exact immutable candidate is available", nil + case registerRecoverOperation: + return evaluation.State.Recovery != nil, "an unresolved candidate inspection requires recovery", nil + default: + return false, "unknown register operation", nil + } +} + +func (d *registerDomain) Verify(_ context.Context, evaluation kernel.Evaluation, effect kernel.Effect, target kernel.Observation) error { + d.mu.Lock() + d.verifications[evaluation.Transition.ID]++ + fail := d.failVerification + d.failVerification = false + d.mu.Unlock() + if fail { + return fmt.Errorf("simulated deterministic verification rejection") + } + if target.Fingerprint != evaluation.Observation.Fingerprint { + return fmt.Errorf("candidate inventory changed during execution") + } + switch evaluation.Transition.Operation { + case registerInspectOperation: + if evaluation.Objective == nil { + return fmt.Errorf("candidate inspection lacks an objective") + } + candidate, err := d.candidates.resolve(*evaluation.Objective) + if err != nil { + return err + } + want := kernel.EffectFact{Facet: "register.candidate", Operation: registerInspectOperation, Fingerprint: candidate.ContentFingerprint} + if len(effect.Facts) != 1 || effect.Facts[0] != want { + return fmt.Errorf("candidate inspection fact does not identify the exact content") + } + case registerRecoverOperation: + if len(effect.Facts) != 1 || effect.Facts[0].Operation != registerRecoverOperation { + return fmt.Errorf("recovery fact is incomplete") + } + default: + return fmt.Errorf("unknown register verification operation") + } + return nil +} + +func (d *registerDomain) failNext() { + d.mu.Lock() + defer d.mu.Unlock() + d.failVerification = true +} + +func (d *registerDomain) snapshot() map[string]int { + d.mu.Lock() + defer d.mu.Unlock() + result := make(map[string]int, len(d.verifications)) + for transition, count := range d.verifications { + result[transition] = count + } + return result +} + +type registerOperator struct { + candidates *registerCandidates + mu sync.Mutex + executions map[string]int +} + +func (o *registerOperator) Execute(_ context.Context, operation kernel.Operation) (kernel.Effect, error) { + o.mu.Lock() + o.executions[operation.Transition.ID]++ + o.mu.Unlock() + switch operation.Transition.Operation { + case registerInspectOperation: + if operation.Objective == nil { + return kernel.Effect{}, fmt.Errorf("candidate inspection lacks an objective") + } + candidate, err := o.candidates.resolve(*operation.Objective) + if err != nil { + return kernel.Effect{}, err + } + return kernel.Effect{Facts: []kernel.EffectFact{{ + Facet: "register.candidate", Operation: registerInspectOperation, Fingerprint: candidate.ContentFingerprint, + }}}, nil + case registerRecoverOperation: + return kernel.Effect{Facts: []kernel.EffectFact{{ + Facet: "register.candidate", Operation: registerRecoverOperation, Fingerprint: "recovery-complete", + }}}, nil + default: + return kernel.Effect{}, fmt.Errorf("unknown register operation") + } +} + +func (o *registerOperator) snapshot() map[string]int { + o.mu.Lock() + defer o.mu.Unlock() + result := make(map[string]int, len(o.executions)) + for transition, count := range o.executions { + result[transition] = count + } + return result +} + +type registerCapabilities struct{} + +func (registerCapabilities) RequiredCapabilities(transition kernel.Transition) ([]kernel.Capability, error) { + switch transition.Operation { + case registerInspectOperation: + return []kernel.Capability{"objective.bind", "register.inspect"}, nil + case registerRecoverOperation: + return []kernel.Capability{"register.recover"}, nil + default: + return nil, fmt.Errorf("unclassified register operation %q", transition.Operation) + } +} + +type registerStore struct { + mu sync.Mutex + state kernel.ControlState + receipts []kernel.Receipt + commitFailures int + commitCount int +} + +func (s *registerStore) Load(context.Context, string) (kernel.ControlState, error) { + s.mu.Lock() + defer s.mu.Unlock() + return cloneSettlementState(s.state), nil +} + +func (s *registerStore) BeginEffect(_ context.Context, revision uint64, target kernel.ControlState) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.state.Revision != revision { + return fmt.Errorf("stale revision") + } + s.state = cloneSettlementState(target) + return nil +} + +func (s *registerStore) CommitTransition(_ context.Context, revision uint64, target kernel.ControlState, receipt kernel.Receipt) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.commitFailures > 0 { + s.commitFailures-- + return fmt.Errorf("simulated atomic transaction failure") + } + if s.state.Revision != revision { + return fmt.Errorf("stale revision") + } + s.state = cloneSettlementState(target) + s.receipts = append(s.receipts, receipt) + s.commitCount++ + return nil +} + +func (s *registerStore) snapshot() (kernel.ControlState, []kernel.Receipt, int) { + s.mu.Lock() + defer s.mu.Unlock() + return cloneSettlementState(s.state), append([]kernel.Receipt(nil), s.receipts...), s.commitCount +} + +func (s *registerStore) failNextCommit() { + s.mu.Lock() + defer s.mu.Unlock() + s.commitFailures++ +} + +func (s *registerStore) bumpRevision() { + s.mu.Lock() + defer s.mu.Unlock() + s.state.Revision++ +} + +func (s *registerStore) retargetProgram(program kernel.ProgramIdentity) { + s.mu.Lock() + defer s.mu.Unlock() + s.state.Program = program +} + +func (s *registerStore) retargetInstance(instanceID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.state.InstanceID = instanceID +} + +func (s *registerStore) setBinding(objective kernel.Objective) { + s.mu.Lock() + defer s.mu.Unlock() + binding, err := kernel.BindObjective(objective) + if err != nil { + panic(err) + } + s.state.ObjectiveBinding = &binding +} + +func (s *registerStore) removeReceipts() { + s.mu.Lock() + defer s.mu.Unlock() + s.receipts = nil +} + +func (s *registerStore) substituteLastReceipt(receipt kernel.Receipt) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.receipts) == 0 { + panic("no committed receipt to substitute") + } + s.receipts[len(s.receipts)-1] = receipt +} + +func (s *registerStore) reset(state kernel.ControlState) { + s.mu.Lock() + defer s.mu.Unlock() + s.state = cloneSettlementState(state) +} + +type settlementLocker struct{ mu sync.Mutex } + +func (l *settlementLocker) Acquire(context.Context, string) (kernel.Lock, error) { + l.mu.Lock() + return settlementLock{mu: &l.mu}, nil +} + +type settlementLock struct{ mu *sync.Mutex } + +func (l settlementLock) Unlock() error { + l.mu.Unlock() + return nil +} + +type settlementClock struct{ now time.Time } + +func (c settlementClock) Now() time.Time { return c.now } + +// RevisionedRegisterFixture returns the second domain-neutral kernel fixture. +func RevisionedRegisterFixture() SettlementConformance { + fixture, _ := newRevisionedRegisterFixture() + return fixture +} + +// registerPorts exposes the concrete fixture ports so white-box counterexample +// tests can wire dishonest store or reader variants against the same laws. +type registerPorts struct { + program kernel.Program + initial kernel.ControlState + candidates *registerCandidates + domain *registerDomain + operator *registerOperator + classifier registerCapabilities + store *registerStore + clock settlementClock +} + +func newRevisionedRegisterFixture() (SettlementConformance, registerPorts) { + program, err := compileRegisterProgram(1) + if err != nil { + panic(err) + } + alternate, err := compileRegisterProgram(2) + if err != nil { + panic(err) + } + instanceID := "revisioned-register" + now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + authority := kernel.Authority{Receipts: []kernel.AuthorityReceipt{{ + ID: "register-authority", Subject: "fixture", Fingerprint: "register-authority", + Capabilities: []kernel.Capability{"objective.bind", "register.inspect", "register.recover"}, + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + candidates := newRegisterCandidates() + domain := ®isterDomain{candidates: candidates, verifications: map[string]int{}} + operator := ®isterOperator{candidates: candidates, executions: map[string]int{}} + store := ®isterStore{state: kernel.ControlState{ + InstanceID: instanceID, Program: program.Identity(), Mode: "unbound", Revision: 1, + }} + clock := settlementClock{now: now} + classifier := registerCapabilities{} + + runtimeFor := func(t testing.TB, selected kernel.Program, selectedLocker kernel.Locker) kernel.Runtime { + t.Helper() + runtime, runtimeErr := kernel.NewRuntime(selected, domain, operator, classifier, store, selectedLocker, clock) + if runtimeErr != nil { + t.Fatalf("construct settlement runtime: %v", runtimeErr) + } + return runtime + } + snapshot := func() SettlementSnapshot { + state, receipts, commits := store.snapshot() + observation, observeErr := domain.Observe(context.Background(), state.InstanceID) + if observeErr != nil { + panic(observeErr) + } + return SettlementSnapshot{ + State: state, Observation: observation, Receipts: receipts, + Effects: operator.snapshot(), Verifications: domain.snapshot(), CommitCount: commits, + } + } + accepted := func() (AcceptedValue, error) { + state, receipts, _ := store.snapshot() + if state.ObjectiveBinding == nil { + return AcceptedValue{}, fmt.Errorf("accepted objective binding is absent") + } + candidate, resolveErr := candidates.resolveBinding(*state.ObjectiveBinding) + if resolveErr != nil { + return AcceptedValue{}, resolveErr + } + var matching []kernel.Receipt + for _, receipt := range receipts { + if receipt.TransitionID == registerAcceptTransition && + reflect.DeepEqual(receipt.ResultObjectiveBinding, state.ObjectiveBinding) { + matching = append(matching, receipt) + } + } + if len(matching) != 1 { + return AcceptedValue{}, fmt.Errorf("accepted binding requires exactly one matching committed receipt, got %d", len(matching)) + } + receipt := matching[0] + if err := receipt.Validate(); err != nil || + receipt.InstanceID != state.InstanceID || + receipt.ResultStateRevision != state.Revision || + receipt.PriorObjectiveBinding != nil || + !reflect.DeepEqual(receipt.RequestedObjectiveBinding, state.ObjectiveBinding) { + return AcceptedValue{}, fmt.Errorf("committed receipt does not prove the exact accepted binding") + } + return AcceptedValue{ + Binding: *state.ObjectiveBinding, CandidateFingerprint: candidate.ContentFingerprint, + Content: append(json.RawMessage(nil), candidate.Content...), Receipt: receipt, + }, nil + } + + fixture := SettlementConformance{ + Program: program, AlternateProgram: alternate, InstanceID: instanceID, Authority: authority, + AcceptTransition: registerAcceptTransition, RecoveryTransition: registerRecoverTransition, + Stage: candidates.stage, Accepted: accepted, Snapshot: snapshot, + Runtime: runtimeFor, + Reopen: func(t testing.TB) kernel.Runtime { return runtimeFor(t, program, &settlementLocker{}) }, + IndependentLocker: func() kernel.Locker { return &settlementLocker{} }, + FailNextVerification: domain.failNext, FailNextCommit: store.failNextCommit, + BumpStateRevision: store.bumpRevision, DriftObjectiveBinding: store.setBinding, + RetargetProgram: store.retargetProgram, + RetargetInstance: store.retargetInstance, + CorruptCandidate: candidates.corrupt, HideCandidate: candidates.hide, + RemoveCommittedReceipts: store.removeReceipts, + SubstituteCommittedReceipt: store.substituteLastReceipt, + } + fixture.New = func(testing.TB) SettlementConformance { + fresh, _ := newRevisionedRegisterFixture() + return fresh + } + ports := registerPorts{ + program: program, initial: cloneSettlementState(kernel.ControlState{ + InstanceID: instanceID, Program: program.Identity(), Mode: "unbound", Revision: 1, + }), + candidates: candidates, domain: domain, operator: operator, + classifier: classifier, store: store, clock: clock, + } + return fixture, ports +} + +func compileRegisterProgram(priority int) (kernel.Program, error) { + return kernel.CompileProgram("revisioned-register", "1.0.0", "kernel-v1", "unbound", []string{"accepted"}, []kernel.Transition{ + { + ID: registerAcceptTransition, SourceModes: []string{"unbound"}, TargetMode: "accepted", + ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindObjectiveMutation, + RequiredCapabilities: []kernel.Capability{"objective.bind", "register.inspect"}, + OwnedFacets: []string{"register.candidate", "supervisor.objective"}, + Operation: registerInspectOperation, SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: priority, + }, + { + ID: registerRecoverTransition, SourceModes: []string{"unbound"}, TargetMode: "unbound", + ObjectiveScope: kernel.ObjectiveOptionalPreserve, ObjectiveMutation: kernel.PreserveObjective, + RequiredCapabilities: []kernel.Capability{"register.recover"}, OwnedFacets: []string{"register.candidate"}, + Operation: registerRecoverOperation, SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10, + Recovers: []string{registerAcceptTransition, registerRecoverTransition}, + }, + }) +} + +func cloneSettlementState(state kernel.ControlState) kernel.ControlState { + copy := state + if state.ObjectiveBinding != nil { + binding := *state.ObjectiveBinding + copy.ObjectiveBinding = &binding + } + if state.Recovery != nil { + recovery := *state.Recovery + copy.Recovery = &recovery + } + return copy +} diff --git a/boatstack/kernel/conformance/settlement.go b/boatstack/kernel/conformance/settlement.go new file mode 100644 index 00000000..312d8676 --- /dev/null +++ b/boatstack/kernel/conformance/settlement.go @@ -0,0 +1,551 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sync" + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +// Run executes the trusted-settlement laws against a fresh fixture per law. +func (suite SettlementConformance) Run(t *testing.T) { + t.Helper() + t.Run("candidate_staging_and_resolve_are_unaccepted", suite.stagingAndResolveAreUnaccepted) + t.Run("verified_initial_acceptance_commits_binding_and_receipt_together", suite.verifiedInitialAcceptance) + t.Run("verification_rejection_cannot_accept", suite.verificationRejection) + t.Run("freshness_drift_rejects_before_effects", suite.freshnessDrift) + t.Run("candidate_substitution_rejects_before_effects", suite.candidateSubstitution) + t.Run("commit_failure_preserves_unaccepted_recovery", suite.commitFailure) + t.Run("different_candidates_race_to_one_acceptance", suite.concurrentCandidates) + t.Run("prescription_replay_cannot_create_another_fact", suite.replay) + t.Run("cross_instance_prescription_replay_is_rejected", suite.crossInstanceReplay) + t.Run("restart_reconstructs_exact_accepted_candidate", suite.restartReconstruction) + t.Run("accepted_reader_fails_closed_on_missing_evidence", suite.acceptedReaderFailsClosed) +} + +func (suite SettlementConformance) stagingAndResolveAreUnaccepted(t *testing.T) { + fixture := suite.freshSettlement(t) + before := fixture.Snapshot() + objective := stageRegisterValue(t, fixture, "alpha") + staged := fixture.Snapshot() + if !reflect.DeepEqual(before.State, staged.State) || + !reflect.DeepEqual(before.Receipts, staged.Receipts) || + !reflect.DeepEqual(before.Effects, staged.Effects) || + before.CommitCount != staged.CommitCount { + t.Fatalf("control-law trusted-settlement-staging: staging changed accepted state: before=%#v after=%#v", before, staged) + } + if staged.Observation.Fingerprint == before.Observation.Fingerprint { + t.Fatal("control-law trusted-settlement-staging: staged candidate was not observable to verification") + } + assertSettlementUnaccepted(t, fixture) + + runtime := fixture.Reopen(t) + request := settlementRequest(fixture, objective) + beforeResolve := fixture.Snapshot() + first, err := runtime.Resolve(context.Background(), request) + if err != nil || first.Decision.Kind != kernel.Prescribed || first.Prescription == nil { + t.Fatalf("resolve candidate: decision=%#v error=%v", first.Decision, err) + } + request.Trace = true + traced, err := runtime.Resolve(context.Background(), request) + if err != nil || !reflect.DeepEqual(traced.Decision, first.Decision) || traced.Prescription == nil || traced.Prescription.ID != first.Prescription.ID { + t.Fatalf("traced resolve changed the prescription: plain=%#v traced=%#v error=%v", first, traced, err) + } + if afterResolve := fixture.Snapshot(); !reflect.DeepEqual(beforeResolve, afterResolve) { + t.Fatalf("control-law trusted-settlement-resolve-readonly: resolve mutated fixture state: before=%#v after=%#v", beforeResolve, afterResolve) + } + assertSettlementUnaccepted(t, fixture) +} + +func (suite SettlementConformance) verifiedInitialAcceptance(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + before := fixture.Snapshot() + receipt, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if err != nil { + t.Fatal(err) + } + after := fixture.Snapshot() + if after.State.ObjectiveBinding == nil || !after.State.ObjectiveBinding.Matches(objective) || + after.State.Mode != "accepted" || after.State.Recovery != nil { + t.Fatalf("control-law trusted-settlement-positive: exact objective did not become accepted: %#v", after.State) + } + if after.CommitCount != before.CommitCount+1 || len(after.Receipts) != len(before.Receipts)+1 || + settlementCount(after.Effects, fixture.AcceptTransition) != settlementCount(before.Effects, fixture.AcceptTransition)+1 || + settlementCount(after.Verifications, fixture.AcceptTransition) != settlementCount(before.Verifications, fixture.AcceptTransition)+1 { + t.Fatalf("control-law trusted-settlement-positive: execution/verification/commit evidence is incomplete: before=%#v after=%#v", before, after) + } + if !reflect.DeepEqual(after.Receipts[len(after.Receipts)-1], receipt) { + t.Fatal("control-law trusted-settlement-positive: returned receipt differs from durable committed receipt") + } + if receipt.PriorObjectiveBinding != nil || + !reflect.DeepEqual(receipt.RequestedObjectiveBinding, after.State.ObjectiveBinding) || + !reflect.DeepEqual(receipt.ResultObjectiveBinding, after.State.ObjectiveBinding) { + t.Fatalf("control-law trusted-settlement-lineage: receipt does not prove nil -> requested -> result: %#v", receipt) + } + wantContent, _ := json.Marshal(registerValue{Value: "alpha"}) + if err := settlementAcceptedEvidenceError(fixture, objective, wantContent, receipt); err != nil { + t.Fatalf("control-law trusted-settlement-reader: %v", err) + } +} + +func (suite SettlementConformance) verificationRejection(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "reject") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + before := fixture.Snapshot() + fixture.FailNextVerification() + _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if !kernel.IsRecoveryRequired(err) { + t.Fatalf("verification rejection = %v, want recovery required", err) + } + after := fixture.Snapshot() + assertFailedSettlement(t, fixture, before, after, 1, 1) +} + +func (suite SettlementConformance) freshnessDrift(t *testing.T) { + cases := map[string]struct { + drift func(SettlementConformance, kernel.Objective) + apply func(testing.TB, SettlementConformance) kernel.Runtime + auth func(kernel.Authority) kernel.Authority + }{ + "state": { + drift: func(f SettlementConformance, _ kernel.Objective) { f.BumpStateRevision() }, + }, + "observation": { + drift: func(f SettlementConformance, _ kernel.Objective) { stageRegisterValue(t, f, "observation-drift") }, + }, + "objective binding": { + drift: func(f SettlementConformance, _ kernel.Objective) { + other := stageRegisterValue(t, f, "binding-drift") + f.DriftObjectiveBinding(other) + }, + }, + "program": { + apply: func(tb testing.TB, f SettlementConformance) kernel.Runtime { + return f.Runtime(tb, f.AlternateProgram, f.IndependentLocker()) + }, + }, + "authority": { + auth: func(kernel.Authority) kernel.Authority { return kernel.Authority{} }, + }, + } + for name, test := range cases { + t.Run(name, func(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + if test.drift != nil { + test.drift(fixture, objective) + } + if test.auth != nil { + request.Authority = test.auth(request.Authority) + } + if test.apply != nil { + runtime = test.apply(t, fixture) + } + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err == nil { + t.Fatal("drifted prescription unexpectedly applied") + } + after := fixture.Snapshot() + if settlementCount(after.Effects, fixture.AcceptTransition) != settlementCount(before.Effects, fixture.AcceptTransition) || + settlementCount(after.Verifications, fixture.AcceptTransition) != settlementCount(before.Verifications, fixture.AcceptTransition) || + len(after.Receipts) != len(before.Receipts) || after.CommitCount != before.CommitCount { + t.Fatalf("control-law trusted-settlement-freshness: %s drift crossed effect or commit boundary: before=%#v after=%#v", name, before, after) + } + assertSettlementUnaccepted(t, fixture) + }) + } +} + +func (suite SettlementConformance) candidateSubstitution(t *testing.T) { + fixture := suite.freshSettlement(t) + candidateA := stageRegisterValue(t, fixture, "alpha") + candidateB := stageRegisterValue(t, fixture, "beta") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, candidateA) + request.Objective = &candidateB + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); !kernel.IsStale(err) { + t.Fatalf("substituted candidate error = %v, want stale", err) + } + after := fixture.Snapshot() + if !reflect.DeepEqual(before, after) { + t.Fatalf("control-law trusted-settlement-substitution: candidate B crossed A's prescription: before=%#v after=%#v", before, after) + } + assertSettlementUnaccepted(t, fixture) +} + +func (suite SettlementConformance) commitFailure(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + before := fixture.Snapshot() + fixture.FailNextCommit() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); !kernel.IsRecoveryRequired(err) { + t.Fatalf("commit failure = %v, want recovery required", err) + } + failed := fixture.Snapshot() + assertFailedSettlement(t, fixture, before, failed, 1, 1) + + reopened := fixture.Reopen(t) + recoveryRequest := settlementRequest(fixture, objective) + recoveryRequest.Requested = fixture.RecoveryTransition + recovery, err := reopened.Resolve(context.Background(), recoveryRequest) + if err != nil || recovery.Prescription == nil || recovery.Decision.Transition != fixture.RecoveryTransition { + t.Fatalf("restart did not reconstruct recovery: resolution=%#v error=%v", recovery, err) + } + if _, err := reopened.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: recoveryRequest, Prescription: *recovery.Prescription}); err != nil { + t.Fatalf("recovery apply: %v", err) + } + settled := fixture.Snapshot() + if settled.State.ObjectiveBinding != nil || settled.State.Recovery != nil || + settlementCount(settled.Effects, fixture.AcceptTransition) != 1 || + len(settled.Receipts) != 1 || settled.Receipts[0].TransitionID != fixture.RecoveryTransition { + t.Fatalf("control-law trusted-settlement-recovery: recovery accepted or duplicated the candidate: %#v", settled) + } + assertSettlementUnaccepted(t, fixture) + if _, err := reopened.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err == nil { + t.Fatal("failed original prescription replayed after recovery") + } + if settlementCount(fixture.Snapshot().Effects, fixture.AcceptTransition) != 1 { + t.Fatal("retry after recovery duplicated candidate inspection") + } +} + +func (suite SettlementConformance) concurrentCandidates(t *testing.T) { + fixture := suite.freshSettlement(t) + candidateA := stageRegisterValue(t, fixture, "alpha") + candidateB := stageRegisterValue(t, fixture, "beta") + resolver := fixture.Reopen(t) + requestA, prescriptionA := resolveSettlement(t, resolver, fixture, candidateA) + requestB, prescriptionB := resolveSettlement(t, resolver, fixture, candidateB) + type application struct { + objective kernel.Objective + request kernel.ResolveRequest + prescription kernel.Prescription + runtime kernel.Runtime + } + applications := []application{ + {candidateA, requestA, prescriptionA, fixture.Runtime(t, fixture.Program, fixture.IndependentLocker())}, + {candidateB, requestB, prescriptionB, fixture.Runtime(t, fixture.Program, fixture.IndependentLocker())}, + } + type result struct { + objective kernel.Objective + receipt kernel.Receipt + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + var group sync.WaitGroup + for _, application := range applications { + application := application + group.Add(1) + go func() { + defer group.Done() + <-start + receipt, err := application.runtime.Apply(context.Background(), kernel.ApplyRequest{ + ResolveRequest: application.request, Prescription: application.prescription, + }) + results <- result{objective: application.objective, receipt: receipt, err: err} + }() + } + close(start) + group.Wait() + close(results) + var winners []result + for result := range results { + if result.err == nil { + winners = append(winners, result) + } + } + if len(winners) != 1 { + t.Fatalf("control-law trusted-settlement-concurrency: %d candidates committed, want exactly one", len(winners)) + } + if err := settlementSingleAcceptanceError(fixture); err != nil { + t.Fatalf("control-law trusted-settlement-concurrency: %v", err) + } + accepted, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + winner := winners[0] + if !accepted.Binding.Matches(winner.objective) || accepted.Receipt.ID != winner.receipt.ID { + t.Fatalf("winning receipt claims a candidate different from the accepted binding: winner=%#v accepted=%#v", winner, accepted) + } + loser := candidateA + if accepted.Binding.Matches(candidateA) { + loser = candidateB + } + if accepted.Binding.Matches(loser) { + t.Fatal("losing candidate became accepted") + } +} + +func (suite SettlementConformance) replay(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err != nil { + t.Fatal(err) + } + before := fixture.Snapshot() + beforeAccepted, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err == nil { + t.Fatal("settled prescription replayed") + } + after := fixture.Snapshot() + afterAccepted, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(beforeAccepted, afterAccepted) { + t.Fatalf("control-law trusted-settlement-replay: replay changed accepted facts: before=%#v after=%#v", before, after) + } +} + +func (suite SettlementConformance) crossInstanceReplay(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + fixture.RetargetInstance("other-register") + request.InstanceID = "other-register" + before := fixture.Snapshot() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); !kernel.IsStale(err) { + t.Fatalf("cross-instance replay error = %v, want stale", err) + } + if after := fixture.Snapshot(); !reflect.DeepEqual(before, after) { + t.Fatalf("control-law trusted-settlement-instance-replay: replay changed the retargeted instance: before=%#v after=%#v", before, after) + } + assertSettlementUnaccepted(t, fixture) +} + +func (suite SettlementConformance) restartReconstruction(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err != nil { + t.Fatal(err) + } + before, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + if err := settlementRestartError(fixture, fixture.Reopen(t), objective, before); err != nil { + t.Fatalf("control-law trusted-settlement-restart: %v", err) + } +} + +func (suite SettlementConformance) acceptedReaderFailsClosed(t *testing.T) { + for name, damage := range map[string]func(*testing.T, SettlementConformance, kernel.Objective){ + "hidden content": func(_ *testing.T, f SettlementConformance, objective kernel.Objective) { + f.HideCandidate(objective.Fingerprint) + }, + "fingerprint mismatch": func(_ *testing.T, f SettlementConformance, objective kernel.Objective) { + f.CorruptCandidate(objective.Fingerprint) + }, + "missing receipt": func(_ *testing.T, f SettlementConformance, _ kernel.Objective) { + f.RemoveCommittedReceipts() + }, + "substituted receipt": func(t *testing.T, f SettlementConformance, _ kernel.Objective) { + decoy := stageRegisterValue(t, f, "decoy") + binding, err := kernel.BindObjective(decoy) + if err != nil { + t.Fatal(err) + } + receipts := f.Snapshot().Receipts + substituted := receipts[len(receipts)-1] + substituted.RequestedObjectiveBinding = &binding + substituted.ResultObjectiveBinding = &binding + substituted.ID = "" + digest, err := kernel.Fingerprint(substituted) + if err != nil { + t.Fatal(err) + } + substituted.ID = "rcp-" + digest + f.SubstituteCommittedReceipt(substituted) + }, + } { + t.Run(name, func(t *testing.T) { + fixture := suite.freshSettlement(t) + objective := stageRegisterValue(t, fixture, "alpha") + runtime := fixture.Reopen(t) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}); err != nil { + t.Fatal(err) + } + damage(t, fixture, objective) + if accepted, err := fixture.Accepted(); err == nil { + t.Fatalf("control-law trusted-settlement-reconstruction: damaged evidence resolved as accepted: %#v", accepted) + } + }) + } +} + +type registerValue struct { + Value string `json:"value"` +} + +func (suite SettlementConformance) freshSettlement(t testing.TB) SettlementConformance { + t.Helper() + if suite.New == nil { + t.Fatal("settlement conformance requires a fresh fixture factory") + } + fixture := suite.New(t) + if fixture.Program.Validate() != nil || fixture.AlternateProgram.Validate() != nil || + fixture.Program.Fingerprint == fixture.AlternateProgram.Fingerprint || + fixture.Stage == nil || fixture.Accepted == nil || fixture.Snapshot == nil || + fixture.Runtime == nil || fixture.Reopen == nil || fixture.IndependentLocker == nil || + fixture.FailNextVerification == nil || fixture.FailNextCommit == nil || + fixture.BumpStateRevision == nil || fixture.DriftObjectiveBinding == nil || + fixture.RetargetProgram == nil || fixture.RetargetInstance == nil || + fixture.CorruptCandidate == nil || fixture.HideCandidate == nil || + fixture.RemoveCommittedReceipts == nil || fixture.SubstituteCommittedReceipt == nil || + fixture.InstanceID == "" || fixture.AcceptTransition == "" || fixture.RecoveryTransition == "" { + t.Fatal("settlement conformance fixture is incomplete") + } + return fixture +} + +func stageRegisterValue(t testing.TB, fixture SettlementConformance, value string) kernel.Objective { + t.Helper() + objective, err := fixture.Stage(registerValue{Value: value}) + if err != nil { + t.Fatalf("stage register value: %v", err) + } + return objective +} + +func settlementRequest(fixture SettlementConformance, objective kernel.Objective) kernel.ResolveRequest { + return kernel.ResolveRequest{ + InstanceID: fixture.InstanceID, Objective: &objective, + Authority: fixture.Authority, Requested: fixture.AcceptTransition, + } +} + +func resolveSettlement(t testing.TB, runtime kernel.Runtime, fixture SettlementConformance, objective kernel.Objective) (kernel.ResolveRequest, kernel.Prescription) { + t.Helper() + request := settlementRequest(fixture, objective) + resolution, err := runtime.Resolve(context.Background(), request) + if err != nil || resolution.Decision.Kind != kernel.Prescribed || resolution.Prescription == nil { + t.Fatalf("resolve settlement: decision=%#v error=%v", resolution.Decision, err) + } + return request, *resolution.Prescription +} + +func assertFailedSettlement(t testing.TB, fixture SettlementConformance, before, after SettlementSnapshot, effects, verifications int) { + t.Helper() + if after.State.ObjectiveBinding != nil || after.State.Mode != before.State.Mode || + after.State.Recovery == nil || after.CommitCount != before.CommitCount || + len(after.Receipts) != len(before.Receipts) || + settlementCount(after.Effects, fixture.AcceptTransition) != settlementCount(before.Effects, fixture.AcceptTransition)+effects || + settlementCount(after.Verifications, fixture.AcceptTransition) != settlementCount(before.Verifications, fixture.AcceptTransition)+verifications { + t.Fatalf("control-law trusted-settlement-failure: failed candidate became accepted or evidence is incomplete: before=%#v after=%#v", before, after) + } + assertSettlementUnaccepted(t, fixture) +} + +func assertSettlementUnaccepted(t testing.TB, fixture SettlementConformance) { + t.Helper() + if err := settlementUnacceptedError(fixture); err != nil { + t.Fatal(err) + } +} + +func settlementCount(values map[string]int, transition string) int { + return values[transition] +} + +// settlementUnacceptedError is the oracle for "no acceptance yet": the +// accepted reader must fail closed while no exact binding plus receipt exists. +func settlementUnacceptedError(fixture SettlementConformance) error { + if accepted, err := fixture.Accepted(); err == nil { + return fmt.Errorf("candidate without exact binding and receipt appeared accepted: %#v", accepted) + } + return nil +} + +// settlementAcceptedEvidenceError is the oracle for positive settlement: the +// reader must reconstruct the exact committed candidate from durable evidence. +func settlementAcceptedEvidenceError(fixture SettlementConformance, objective kernel.Objective, wantContent json.RawMessage, receipt kernel.Receipt) error { + accepted, err := fixture.Accepted() + if err != nil { + return fmt.Errorf("read accepted value: %w", err) + } + if !accepted.Binding.Matches(objective) { + return fmt.Errorf("accepted binding does not match the committed candidate: %#v", accepted.Binding) + } + if !reflect.DeepEqual(accepted.Content, wantContent) { + return fmt.Errorf("accepted content %q differs from the exact committed candidate %q", accepted.Content, wantContent) + } + if accepted.Receipt.ID != receipt.ID { + return fmt.Errorf("accepted receipt %q differs from the committed receipt %q", accepted.Receipt.ID, receipt.ID) + } + if len(receipt.Effects) != 1 || accepted.CandidateFingerprint != receipt.Effects[0].Fingerprint { + return fmt.Errorf("accepted candidate fingerprint differs from committed effect evidence: %#v", accepted) + } + return nil +} + +// settlementSingleAcceptanceError is the oracle for exactly-once settlement +// from an unbound base: one commit, one receipt, one execution, one +// verification, and a reader consistent with that single committed receipt. +func settlementSingleAcceptanceError(fixture SettlementConformance) error { + after := fixture.Snapshot() + if after.CommitCount != 1 || len(after.Receipts) != 1 || + settlementCount(after.Effects, fixture.AcceptTransition) != 1 || + settlementCount(after.Verifications, fixture.AcceptTransition) != 1 { + return fmt.Errorf("settlement produced torn or duplicate evidence: commits=%d receipts=%d effects=%d verifications=%d", + after.CommitCount, len(after.Receipts), + settlementCount(after.Effects, fixture.AcceptTransition), + settlementCount(after.Verifications, fixture.AcceptTransition)) + } + accepted, err := fixture.Accepted() + if err != nil { + return fmt.Errorf("read accepted value: %w", err) + } + if accepted.Receipt.ID != after.Receipts[0].ID || + !reflect.DeepEqual(accepted.Receipt.ResultObjectiveBinding, after.State.ObjectiveBinding) { + return fmt.Errorf("accepted evidence differs from the single committed receipt: %#v", accepted) + } + return nil +} + +// settlementRestartError is the oracle for reconstruction: a fresh runtime +// over the same durable ports must report the settled objective as already +// accepted and the reader must resolve the identical accepted value. +func settlementRestartError(fixture SettlementConformance, reopened kernel.Runtime, objective kernel.Objective, before AcceptedValue) error { + resolution, err := reopened.Resolve(context.Background(), kernel.ResolveRequest{ + InstanceID: fixture.InstanceID, Objective: &objective, Authority: fixture.Authority, + }) + if err != nil { + return fmt.Errorf("resolve after restart: %w", err) + } + if resolution.Decision.Kind != kernel.Marked { + return fmt.Errorf("fresh runtime did not reconstruct marked accepted state: %#v", resolution.Decision) + } + after, err := fixture.Accepted() + if err != nil { + return fmt.Errorf("read accepted value after restart: %w", err) + } + if !reflect.DeepEqual(before, after) { + return fmt.Errorf("accepted value changed across restart: before=%#v after=%#v", before, after) + } + return nil +} diff --git a/boatstack/kernel/conformance/settlement_law_test.go b/boatstack/kernel/conformance/settlement_law_test.go new file mode 100644 index 00000000..374e222b --- /dev/null +++ b/boatstack/kernel/conformance/settlement_law_test.go @@ -0,0 +1,222 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel" +) + +// settleRegisterValue drives the honest positive path once and returns the +// staged objective, its exact content, and the receipt returned by Apply. +func settleRegisterValue(t *testing.T, fixture SettlementConformance, runtime kernel.Runtime, value string) (kernel.Objective, json.RawMessage, kernel.Receipt) { + t.Helper() + objective := stageRegisterValue(t, fixture, value) + request, prescription := resolveSettlement(t, runtime, fixture, objective) + receipt, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + if err != nil { + t.Fatal(err) + } + content, err := json.Marshal(registerValue{Value: value}) + if err != nil { + t.Fatal(err) + } + return objective, content, receipt +} + +// A store that silently mutates candidate content paired with a reader that +// does not re-verify fingerprints must fail the accepted-evidence oracle. +func TestSettlementSuiteRejectsMutableCandidateStore(t *testing.T) { + fixture, ports := newRevisionedRegisterFixture() + fixture.Accepted = func() (AcceptedValue, error) { + state, receipts, _ := ports.store.snapshot() + if state.ObjectiveBinding == nil || len(receipts) == 0 { + return AcceptedValue{}, fmt.Errorf("nothing accepted") + } + ports.candidates.mu.Lock() + candidate := ports.candidates.byObjective[state.ObjectiveBinding.ObjectiveFingerprint] + ports.candidates.mu.Unlock() + return AcceptedValue{ + Binding: *state.ObjectiveBinding, CandidateFingerprint: candidate.ContentFingerprint, + Content: append(json.RawMessage(nil), candidate.Content...), Receipt: receipts[len(receipts)-1], + }, nil + } + objective, content, receipt := settleRegisterValue(t, fixture, fixture.Reopen(t), "alpha") + fixture.CorruptCandidate(objective.Fingerprint) + if err := settlementAcceptedEvidenceError(fixture, objective, content, receipt); err == nil { + t.Fatal("mutable candidate store passed the accepted-evidence oracle") + } +} + +// A reader that reports the latest staged candidate instead of the committed +// binding must fail both the unaccepted and accepted-evidence oracles. +func TestSettlementSuiteRejectsLatestStagedReader(t *testing.T) { + fixture, ports := newRevisionedRegisterFixture() + var latest kernel.Objective + honestStage := fixture.Stage + fixture.Stage = func(value any) (kernel.Objective, error) { + objective, err := honestStage(value) + if err == nil { + latest = objective + } + return objective, err + } + fixture.Accepted = func() (AcceptedValue, error) { + candidate, err := ports.candidates.resolve(latest) + if err != nil { + return AcceptedValue{}, err + } + binding, err := kernel.BindObjective(latest) + if err != nil { + return AcceptedValue{}, err + } + var receipt kernel.Receipt + if _, receipts, _ := ports.store.snapshot(); len(receipts) > 0 { + receipt = receipts[len(receipts)-1] + } + return AcceptedValue{ + Binding: binding, CandidateFingerprint: candidate.ContentFingerprint, + Content: candidate.Content, Receipt: receipt, + }, nil + } + stageRegisterValue(t, fixture, "alpha") + if err := settlementUnacceptedError(fixture); err == nil { + t.Fatal("latest-staged reader claimed acceptance before any committed binding") + } + fixture.Stage = honestStage + objective, content, receipt := settleRegisterValue(t, fixture, fixture.Reopen(t), "alpha") + fixture.Stage = func(value any) (kernel.Objective, error) { + staged, err := honestStage(value) + if err == nil { + latest = staged + } + return staged, err + } + stageRegisterValue(t, fixture, "beta") + if err := settlementAcceptedEvidenceError(fixture, objective, content, receipt); err == nil { + t.Fatal("latest-staged reader passed the accepted-evidence oracle after a newer staging") + } +} + +// tornSettlementStore persists the target state but silently drops the receipt. +type tornSettlementStore struct{ inner *registerStore } + +func (s tornSettlementStore) Load(ctx context.Context, instanceID string) (kernel.ControlState, error) { + return s.inner.Load(ctx, instanceID) +} + +func (s tornSettlementStore) BeginEffect(ctx context.Context, revision uint64, target kernel.ControlState) error { + return s.inner.BeginEffect(ctx, revision, target) +} + +func (s tornSettlementStore) CommitTransition(_ context.Context, revision uint64, target kernel.ControlState, _ kernel.Receipt) error { + s.inner.mu.Lock() + defer s.inner.mu.Unlock() + if s.inner.state.Revision != revision { + return fmt.Errorf("stale revision") + } + s.inner.state = cloneSettlementState(target) + s.inner.commitCount++ + return nil +} + +func TestSettlementSuiteRejectsTornStateReceiptCommit(t *testing.T) { + fixture, ports := newRevisionedRegisterFixture() + runtime, err := kernel.NewRuntime(ports.program, ports.domain, ports.operator, ports.classifier, + tornSettlementStore{inner: ports.store}, &settlementLocker{}, ports.clock) + if err != nil { + t.Fatal(err) + } + objective, content, receipt := settleRegisterValue(t, fixture, runtime, "alpha") + if err := settlementAcceptedEvidenceError(fixture, objective, content, receipt); err == nil { + t.Fatal("torn commit without a durable receipt passed the accepted-evidence oracle") + } + if err := settlementSingleAcceptanceError(fixture); err == nil { + t.Fatal("torn commit passed the single-acceptance oracle") + } +} + +// blindSettlementStore ignores the compare-and-swap revision on both the effect +// attempt and the final commit, so concurrent candidates can both settle. +type blindSettlementStore struct { + inner *registerStore + barrier *twoPartyBarrier +} + +func (s blindSettlementStore) Load(ctx context.Context, instanceID string) (kernel.ControlState, error) { + return s.inner.Load(ctx, instanceID) +} + +func (s blindSettlementStore) BeginEffect(_ context.Context, _ uint64, target kernel.ControlState) error { + _ = s.barrier.wait() + s.inner.mu.Lock() + defer s.inner.mu.Unlock() + s.inner.state = cloneSettlementState(target) + return nil +} + +func (s blindSettlementStore) CommitTransition(_ context.Context, _ uint64, target kernel.ControlState, receipt kernel.Receipt) error { + s.inner.mu.Lock() + defer s.inner.mu.Unlock() + s.inner.state = cloneSettlementState(target) + s.inner.receipts = append(s.inner.receipts, receipt) + s.inner.commitCount++ + return nil +} + +func TestSettlementSuiteRejectsBlindCommit(t *testing.T) { + fixture, ports := newRevisionedRegisterFixture() + candidateA := stageRegisterValue(t, fixture, "alpha") + candidateB := stageRegisterValue(t, fixture, "beta") + resolver := fixture.Reopen(t) + requestA, prescriptionA := resolveSettlement(t, resolver, fixture, candidateA) + requestB, prescriptionB := resolveSettlement(t, resolver, fixture, candidateB) + store := blindSettlementStore{inner: ports.store, barrier: newTwoPartyBarrier()} + applications := []kernel.ApplyRequest{ + {ResolveRequest: requestA, Prescription: prescriptionA}, + {ResolveRequest: requestB, Prescription: prescriptionB}, + } + errs := make(chan error, len(applications)) + for _, application := range applications { + application := application + runtime, err := kernel.NewRuntime(ports.program, ports.domain, ports.operator, ports.classifier, + store, &settlementLocker{}, ports.clock) + if err != nil { + t.Fatal(err) + } + go func() { + _, applyErr := runtime.Apply(context.Background(), application) + errs <- applyErr + }() + } + for range applications { + if err := <-errs; err != nil { + t.Fatalf("blind store did not admit both candidates: %v", err) + } + } + if err := settlementSingleAcceptanceError(fixture); err == nil { + t.Fatal("blind commit that settled two candidates passed the single-acceptance oracle") + } +} + +// A runtime whose reopen path resets durable state must fail the restart +// oracle instead of silently forgetting the accepted candidate. +func TestSettlementSuiteRejectsRestartStateReset(t *testing.T) { + fixture, ports := newRevisionedRegisterFixture() + honestReopen := fixture.Reopen + fixture.Reopen = func(t testing.TB) kernel.Runtime { + ports.store.reset(ports.initial) + return honestReopen(t) + } + runtime := fixture.Runtime(t, fixture.Program, fixture.IndependentLocker()) + objective, _, _ := settleRegisterValue(t, fixture, runtime, "alpha") + before, err := fixture.Accepted() + if err != nil { + t.Fatal(err) + } + if err := settlementRestartError(fixture, fixture.Reopen(t), objective, before); err == nil { + t.Fatal("restart state reset passed the reconstruction oracle") + } +} diff --git a/boatstack/kernel/receipt_test.go b/boatstack/kernel/receipt_test.go new file mode 100644 index 00000000..d05d5782 --- /dev/null +++ b/boatstack/kernel/receipt_test.go @@ -0,0 +1,56 @@ +package kernel + +import ( + "strings" + "testing" + "time" +) + +func TestReceiptRejectsLegacySchemaAndInvalidObjectiveLineage(t *testing.T) { + bindingA := &ObjectiveBinding{ObjectiveID: "candidate-a", ObjectiveRevision: 1, ObjectiveFingerprint: strings.Repeat("a", 64)} + bindingB := &ObjectiveBinding{ObjectiveID: "candidate-b", ObjectiveRevision: 1, ObjectiveFingerprint: strings.Repeat("b", 64)} + base := Receipt{ + SchemaVersion: ReceiptSchemaVersion, InstanceID: "settlement", PrescriptionID: "prx-test", + Program: ProgramIdentity{ID: "settlement", Version: "1", Fingerprint: strings.Repeat("c", 64)}, + TransitionID: "register.accept", PriorStateRevision: 1, AttemptStateRevision: 2, ResultStateRevision: 3, + RequestedObjectiveBinding: bindingB, ResultObjectiveBinding: bindingB, + AuthorityFingerprint: "authority", Capabilities: []Capability{"register.accept"}, + Effects: []EffectFact{{Facet: "register.candidate", Operation: "register.inspect", Fingerprint: strings.Repeat("d", 64)}}, + PriorObservation: strings.Repeat("e", 64), ResultObservation: strings.Repeat("e", 64), + Verification: "satisfied", CommittedAt: time.Unix(1, 0).UTC(), + } + sealReceipt := func(receipt Receipt) Receipt { + t.Helper() + receipt.ID = "" + digest, err := contentHash(receipt) + if err != nil { + t.Fatal(err) + } + receipt.ID = "rcp-" + digest + return receipt + } + + if valid := sealReceipt(base); valid.Validate() != nil { + t.Fatalf("valid receipt rejected: %v", valid.Validate()) + } + for name, mutate := range map[string]func(*Receipt){ + "legacy schema": func(receipt *Receipt) { receipt.SchemaVersion = 3 }, + "requested differs from result": func(receipt *Receipt) { + receipt.RequestedObjectiveBinding = bindingA + }, + "unrequested result differs from prior": func(receipt *Receipt) { + receipt.PriorObjectiveBinding = bindingA + receipt.RequestedObjectiveBinding = nil + receipt.ResultObjectiveBinding = bindingB + }, + } { + t.Run(name, func(t *testing.T) { + candidate := base + mutate(&candidate) + candidate = sealReceipt(candidate) + if err := candidate.Validate(); err == nil { + t.Fatalf("invalid receipt was accepted: %#v", candidate) + } + }) + } +} diff --git a/boatstack/kernel/runtime.go b/boatstack/kernel/runtime.go index ed41e468..ad573729 100644 --- a/boatstack/kernel/runtime.go +++ b/boatstack/kernel/runtime.go @@ -136,23 +136,25 @@ type ApplyRequest struct { } type Receipt struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - InstanceID string `json:"instance_id"` - PrescriptionID string `json:"prescription_id"` - Program ProgramIdentity `json:"program"` - TransitionID string `json:"transition_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - AttemptStateRevision uint64 `json:"attempt_state_revision"` - ResultStateRevision uint64 `json:"result_state_revision"` - ObjectiveBinding *ObjectiveBinding `json:"objective_binding,omitempty"` - AuthorityFingerprint string `json:"authority_fingerprint"` - Capabilities []Capability `json:"capabilities"` - Effects []EffectFact `json:"effects"` - PriorObservation string `json:"prior_observation"` - ResultObservation string `json:"result_observation"` - Verification string `json:"verification"` - CommittedAt time.Time `json:"committed_at"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + InstanceID string `json:"instance_id"` + PrescriptionID string `json:"prescription_id"` + Program ProgramIdentity `json:"program"` + TransitionID string `json:"transition_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + AttemptStateRevision uint64 `json:"attempt_state_revision"` + ResultStateRevision uint64 `json:"result_state_revision"` + PriorObjectiveBinding *ObjectiveBinding `json:"prior_objective_binding,omitempty"` + RequestedObjectiveBinding *ObjectiveBinding `json:"requested_objective_binding,omitempty"` + ResultObjectiveBinding *ObjectiveBinding `json:"result_objective_binding,omitempty"` + AuthorityFingerprint string `json:"authority_fingerprint"` + Capabilities []Capability `json:"capabilities"` + Effects []EffectFact `json:"effects"` + PriorObservation string `json:"prior_observation"` + ResultObservation string `json:"result_observation"` + Verification string `json:"verification"` + CommittedAt time.Time `json:"committed_at"` } type Runtime struct { @@ -453,7 +455,16 @@ func (r Runtime) Apply(ctx context.Context, request ApplyRequest) (Receipt, erro target.ObjectiveBinding = nil } target.Recovery = nil - receipt := Receipt{SchemaVersion: ReceiptSchemaVersion, InstanceID: state.InstanceID, PrescriptionID: request.Prescription.ID, Program: state.Program, TransitionID: transition.ID, PriorStateRevision: state.Revision, AttemptStateRevision: attempt.Revision, ResultStateRevision: target.Revision, ObjectiveBinding: cloneBinding(target.ObjectiveBinding), AuthorityFingerprint: authority.Fingerprint, Capabilities: required, Effects: append([]EffectFact(nil), effect.Facts...), PriorObservation: observation.Fingerprint, ResultObservation: targetObservation.Fingerprint, Verification: "satisfied", CommittedAt: r.clock.Now().UTC()} + receipt := Receipt{ + SchemaVersion: ReceiptSchemaVersion, InstanceID: state.InstanceID, + PrescriptionID: request.Prescription.ID, Program: state.Program, TransitionID: transition.ID, + PriorStateRevision: state.Revision, AttemptStateRevision: attempt.Revision, ResultStateRevision: target.Revision, + PriorObjectiveBinding: cloneBinding(state.ObjectiveBinding), RequestedObjectiveBinding: cloneBinding(request.Prescription.RequestedObjectiveBinding), + ResultObjectiveBinding: cloneBinding(target.ObjectiveBinding), + AuthorityFingerprint: authority.Fingerprint, Capabilities: required, Effects: append([]EffectFact(nil), effect.Facts...), + PriorObservation: observation.Fingerprint, ResultObservation: targetObservation.Fingerprint, + Verification: "satisfied", CommittedAt: r.clock.Now().UTC(), + } identity := receipt identity.ID = "" receipt.ID, err = contentHash(identity) @@ -484,10 +495,19 @@ func (r Receipt) Validate() error { if err := r.Program.Validate(); err != nil { return err } - if r.ObjectiveBinding != nil { - if err := r.ObjectiveBinding.Validate(); err != nil { - return err + for _, binding := range []*ObjectiveBinding{r.PriorObjectiveBinding, r.RequestedObjectiveBinding, r.ResultObjectiveBinding} { + if binding != nil { + if err := binding.Validate(); err != nil { + return err + } + } + } + if r.RequestedObjectiveBinding != nil { + if !equalBinding(r.RequestedObjectiveBinding, r.ResultObjectiveBinding) { + return fmt.Errorf("receipt requested objective binding differs from its committed result") } + } else if r.ResultObjectiveBinding != nil && !equalBinding(r.PriorObjectiveBinding, r.ResultObjectiveBinding) { + return fmt.Errorf("receipt synthesized an unrequested objective binding") } if _, err := normalizeCapabilities(r.Capabilities); err != nil { return err diff --git a/boatstack/kernel/settlement_test.go b/boatstack/kernel/settlement_test.go new file mode 100644 index 00000000..6078a4df --- /dev/null +++ b/boatstack/kernel/settlement_test.go @@ -0,0 +1,11 @@ +package kernel_test + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/kernel/conformance" +) + +func TestTrustedSettlementConformance(t *testing.T) { + conformance.RevisionedRegisterFixture().Run(t) +} diff --git a/boatstack/kernel/types.go b/boatstack/kernel/types.go index 694e8bc9..c574ea5a 100644 --- a/boatstack/kernel/types.go +++ b/boatstack/kernel/types.go @@ -17,7 +17,7 @@ import ( const ( ProgramSchemaVersion = 1 PrescriptionSchemaVersion = 2 - ReceiptSchemaVersion = 3 + ReceiptSchemaVersion = 4 ) var ( diff --git a/docs/architecture/kernel.md b/docs/architecture/kernel.md index e7b7235d..da0eb04d 100644 --- a/docs/architecture/kernel.md +++ b/docs/architecture/kernel.md @@ -131,14 +131,46 @@ Programs declare capabilities, but a trusted capability classifier supplies the minimum for each concrete operation. The operator receives only that admitted set. Effect facts must stay inside transition-owned facets. -The generic `Store` is one durability boundary. `CommitTransition` atomically -persists the target control state and its verified receipt; neither may become -visible alone. If an operator may have changed domain state but that atomic -commit fails, `EnterRecovery` records recovery against the unchanged +The generic `Store` is one durability boundary. `BeginEffect` atomically +persists the attempt state — including its recovery obligation — before any +operator effect. `CommitTransition` atomically persists the target control +state and its verified receipt; neither may become visible alone. If an +operator may have changed domain state but verification or that atomic commit +fails, the durable attempt state keeps recovery active against the unchanged pre-commit mode. Program compilation rejects any recovery mapping that cannot run from every source mode of the transition it recovers. -## Non-software proof fixture +## Trusted settlement + +Candidate state and accepted state are distinct: + +```text +candidate: immutable external content, staged, inspectable, untrusted +accepted: exact durable ObjectiveBinding + one committed receipt +``` + +A candidate becomes accepted state only through the full flow — exact +candidate/objective reference, resolve and prescription, current-state +revalidation, verification, then one atomic objective-binding-plus-receipt +commit. Staging, resolving, model output, an attempted effect, or mutable +domain state never constitute acceptance. Any failure before the final atomic +commit leaves the prior accepted binding authoritative; rejected or orphaned +candidates may persist but remain untrusted. + +Each committed receipt records explicit objective lineage — the prior accepted +binding, the requested candidate binding, and the resulting accepted binding — +so a verifier can prove the exact accepted delta from the receipt alone. The +runtime never synthesizes an unrequested binding: an absent requested binding +must leave the prior binding as the result. + +Accepted-state readers reconstruct from durable evidence only: load the exact +objective binding, resolve it to immutable candidate content, and require the +matching committed receipt. Missing content, a fingerprint mismatch, or a +missing or substituted receipt fails closed rather than inventing accepted +content. Conformance detects dishonest store or reader implementations; the +runtime does not claim to make adversarial ports safe. + +## Non-software proof fixtures `boatstack/kernel/runtime_test.go` runs an integer control instance: @@ -153,6 +185,19 @@ observation bindings, denies missing capability authority, and recovers an interrupted operator. The fixture imports no software-delivery package and requires no Git executable or repository. +`boatstack/kernel/settlement_test.go` runs a revisioned-register instance that +proves the trusted-settlement laws. Immutable candidate records are identified +by content fingerprint and staged outside control state; the accepted-value +reader reconstructs only from the durable objective binding plus its committed +receipt and fails closed on damaged evidence. The suite covers staging and +read-only resolve, verified initial acceptance, verification rejection, +freshness drift across all five identities, candidate substitution, atomic +commit failure with recovery, racing candidates settling at most once, +same-instance and cross-instance replay, restart reconstruction, and +fail-closed reading. White-box counterexamples prove the laws reject a mutable +candidate store, a latest-staged reader, a torn or blind state-receipt commit, +receipt substitution, and restart state reset. + ## Enforced properties 1. Program determinacy: executable law is bound to one program fingerprint. @@ -169,6 +214,8 @@ requires no Git executable or repository. 12. Marked-state generality: the program defines accepted modes. 13. Operator neutrality: the fixture uses deterministic functions, not an agent. 14. Domain substitution: the integer domain runs without kernel changes. +15. Trusted settlement: only an exact committed binding plus receipt is accepted state. +16. Receipt lineage: receipts prove the prior, requested, and resulting bindings. ## Current implementation anchors @@ -176,3 +223,5 @@ requires no Git executable or repository. - [Resolve/apply runtime](../../boatstack/kernel/runtime.go) - [Relation tests](../../boatstack/kernel/relation_test.go) - [Domain-neutral conformance fixture](../../boatstack/kernel/conformance/integer.go) +- [Settlement fixture](../../boatstack/kernel/conformance/revisioned_register.go) +- [Settlement laws](../../boatstack/kernel/conformance/settlement.go) diff --git a/docs/concepts/prescriptions-verification-receipts-and-recovery.md b/docs/concepts/prescriptions-verification-receipts-and-recovery.md index 1df1da74..fe944911 100644 --- a/docs/concepts/prescriptions-verification-receipts-and-recovery.md +++ b/docs/concepts/prescriptions-verification-receipts-and-recovery.md @@ -22,9 +22,19 @@ Apply locks the instance, re-observes, compares every freshness binding, and re-runs the canonical relation. Stale prescriptions fail before effects. Verification then decides whether the candidate postcondition may commit. +The same boundary settles candidates: an immutable candidate may exist and be +inspected, but it becomes accepted state only when verification succeeds and +the exact objective binding plus receipt commit atomically. Accepted state is +never inferred from staged content, mutable domain state, or an attempted +effect. + ## Invariants - Receipts are emitted only with committed state. +- Receipts record the prior, requested, and resulting objective bindings, so + the exact accepted delta is provable from the receipt alone. +- Accepted-state readers fail closed on missing candidate content, fingerprint + mismatch, or a missing committed receipt. - Local reversible effects roll back on failed settlement. - Possibly completed external effects are not blindly retried. - Recovery uses a new admission and does not inherit stronger authority. diff --git a/release-notes/2026-08-23-trusted-settlement.md b/release-notes/2026-08-23-trusted-settlement.md new file mode 100644 index 00000000..da8202b6 --- /dev/null +++ b/release-notes/2026-08-23-trusted-settlement.md @@ -0,0 +1,22 @@ +### Kernel receipts prove trusted settlement with explicit objective lineage + +An immutable candidate now becomes accepted state only when verification +succeeds and the exact objective binding plus its receipt commit atomically; +staged, rejected, stale, unverified, interrupted, or unreceipted candidates +never do. Generic receipts moved from a result-only objective binding to +explicit lineage — the prior accepted binding, the requested candidate +binding, and the resulting accepted binding — so a verifier can prove the +exact accepted delta from the receipt alone, and receipt validation rejects +any synthesized unrequested binding. The receipt schema version advanced to 4 +with no compatibility reader for the previous shape. + +A second domain-neutral conformance fixture, a revisioned register with +content-addressed immutable candidates and a fail-closed accepted-value +reader, executes reusable settlement laws covering positive admission, +verification rejection, freshness drift, candidate substitution, atomic +commit failure with recovery, racing candidates settling at most once, +replay, restart reconstruction, and fail-closed reading. White-box +counterexamples prove those laws reject a mutable candidate store, a +latest-staged reader, torn or blind state-receipt commits, receipt +substitution, and restart state reset. All existing shared behavioral laws +continue to run against both registered backends with zero skips. From f7bf9e9584fc53dc1febf812693b4dced1b646ef Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 23 Aug 2026 17:28:18 +0100 Subject: [PATCH 2/2] Seal converged self-review attestation --- .github/reviews/trusted-settlement.receipt.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/reviews/trusted-settlement.receipt.json diff --git a/.github/reviews/trusted-settlement.receipt.json b/.github/reviews/trusted-settlement.receipt.json new file mode 100644 index 00000000..46351e32 --- /dev/null +++ b/.github/reviews/trusted-settlement.receipt.json @@ -0,0 +1,4 @@ +{ + "reviewed_tree": "a76b668137a154fe54cbf0d3cf5398ebda7387ff", + "program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155" +}