Skip to content

Commit 9d7692f

Browse files
authored
Persist durable, isolated, idempotently resumable control instances (#244)
* Persist durable, isolated, idempotently resumable control instances The kernel Store is now an explicit multi-instance persistence contract: atomic Create that cannot overwrite, Load returning the atomic instance record (state plus complete ordered receipt history), and per-instance revision CAS, locking, recovery, and history. Apply reconciles an exact committed retry to its original durable receipt with zero side effects, keeps uncommitted attempts recovery-required, and fails closed on fabricated, misrouted, duplicated, reordered, or truncated history. A reusable InstanceStoreConformance suite proves the law for the integer memory store, the settlement register store, and the reviewer's on-disk file store, with white-box counterexamples rejecting dishonest stores. * Report unprovisioned review instances read-only from status `boatstack-reviewer status` no longer fails on a branch whose control instance has not been provisioned. It reports the deterministic initial state the first mutating command will create, marked provisioned=false, without persisting anything, so read-only observers like the self-review workflow keep working while provisioning stays explicit and mutating. * Seal converged self-review attestation
1 parent 8658bbe commit 9d7692f

23 files changed

Lines changed: 2213 additions & 232 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"reviewed_tree": "975232189c6c9be401c457c38fa72e489d6a9b53",
3+
"program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155"
4+
}

boatstack/cmd/boatstack-reviewer/domain.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func (d *reviewDomain) observeValue() (observationValue, error) {
6767
return observationValue{}, err
6868
}
6969
value := observationValue{
70-
Instance: d.store.initial.InstanceID,
70+
Instance: d.store.instance,
7171
BaseRef: d.baseRef,
7272
MergeBase: mergeBase,
7373
HeadCommit: head,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/operatorstack/boatstack/boatstack/kernel"
7+
"github.com/operatorstack/boatstack/boatstack/kernel/conformance"
8+
)
9+
10+
// TestFileStoreInstanceConformance proves the reviewer's file store is a
11+
// durable, isolated, idempotently resumable multi-instance kernel Store:
12+
// reopening constructs a fresh handle over the same on-disk substrate, so
13+
// restart reconstruction exercises real file persistence.
14+
func TestFileStoreInstanceConformance(t *testing.T) {
15+
conformance.InstanceStoreConformance{New: func(t testing.TB) conformance.InstanceStoreHarness {
16+
gitDir := t.TempDir()
17+
store := newFileStore(gitDir, "instance-alpha")
18+
return conformance.InstanceStoreHarness{
19+
Store: store,
20+
Locker: func() kernel.Locker { return directoryLocker{store: store} },
21+
Reopen: func(testing.TB) kernel.Store {
22+
return newFileStore(gitDir, "instance-alpha")
23+
},
24+
}
25+
}}.Run(t)
26+
}

boatstack/cmd/boatstack-reviewer/main.go

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func newLoopContext(repoPath, delivery, baseRef string) (*loopContext, error) {
111111
return nil, err
112112
}
113113
}
114-
store := newFileStore(repo.GitDir, instance, program.Identity())
114+
store := newFileStore(repo.GitDir, instance)
115115
domain := &reviewDomain{repo: repo, store: store, policy: policy, baseRef: baseRef}
116116
return &loopContext{
117117
repo: repo,
@@ -128,14 +128,27 @@ func newLoopContext(repoPath, delivery, baseRef string) (*loopContext, error) {
128128
func (c *loopContext) runtime() (kernel.Runtime, error) {
129129
return kernel.NewRuntime(
130130
c.program, c.domain, c.operator, reviewCapabilities{},
131-
c.store, directoryLocker{path: c.store.lockPath()}, c.clock,
131+
c.store, directoryLocker{store: c.store}, c.clock,
132132
)
133133
}
134134

135135
func (c *loopContext) authority(actor string, capabilities ...kernel.Capability) (kernel.Authority, error) {
136136
return localAuthority(actor, c.clock.Now(), capabilities...)
137137
}
138138

139+
// ensureProvisioned provisions the command's control instance when it does
140+
// not exist yet. Only the mutating entrypoints call it; read-only commands
141+
// surface the typed not-found result instead of manufacturing state.
142+
func (c *loopContext) ensureProvisioned(runtime kernel.Runtime) error {
143+
if _, err := c.store.Load(context.Background(), c.instance); !kernel.IsInstanceNotFound(err) {
144+
return err
145+
}
146+
if _, err := runtime.Provision(context.Background(), c.instance); err != nil && !kernel.IsInstanceExists(err) {
147+
return err
148+
}
149+
return nil
150+
}
151+
139152
func printJSON(value any) error {
140153
encoder := json.NewEncoder(os.Stdout)
141154
encoder.SetIndent("", " ")
@@ -194,6 +207,9 @@ func commandResolve(arguments []string) error {
194207
if err != nil {
195208
return err
196209
}
210+
if err := loop.ensureProvisioned(runtime); err != nil {
211+
return err
212+
}
197213
authority, err := loop.authority(*actor, capabilitySubmit)
198214
if err != nil {
199215
return err
@@ -276,6 +292,9 @@ func commandSubmit(arguments []string) error {
276292
if err != nil {
277293
return err
278294
}
295+
if err := loop.ensureProvisioned(runtime); err != nil {
296+
return err
297+
}
279298
authority, err := loop.authority(*actor, capabilitySubmit)
280299
if err != nil {
281300
return err
@@ -300,10 +319,11 @@ func commandSubmit(arguments []string) error {
300319
if err != nil {
301320
return fmt.Errorf("submission did not commit: %w", err)
302321
}
303-
state, err := loop.store.Load(context.Background(), loop.instance)
322+
record, err := loop.store.Load(context.Background(), loop.instance)
304323
if err != nil {
305324
return err
306325
}
326+
state := record.State
307327
observed, err := loop.domain.observeValue()
308328
if err != nil {
309329
return err
@@ -346,35 +366,62 @@ func commandStatus(arguments []string) error {
346366
if err != nil {
347367
return err
348368
}
349-
state, err := loop.store.Load(context.Background(), loop.instance)
350-
if err != nil {
351-
return err
352-
}
353-
observed, err := loop.domain.observeValue()
369+
report, err := loop.statusReport()
354370
if err != nil {
355371
return err
356372
}
357-
stale := state.Program != loop.program.Identity()
358-
return printJSON(struct {
359-
Instance string `json:"instance"`
360-
Program kernel.ProgramIdentity `json:"program"`
361-
State kernel.ControlState `json:"state"`
362-
ProgramStale bool `json:"program_stale"`
363-
Observation observationValue `json:"observation"`
364-
Guidance string `json:"guidance,omitempty"`
365-
}{
366-
Instance: loop.instance,
367-
Program: loop.program.Identity(),
373+
return printJSON(report)
374+
}
375+
376+
type statusReport struct {
377+
Instance string `json:"instance"`
378+
Program kernel.ProgramIdentity `json:"program"`
379+
State kernel.ControlState `json:"state"`
380+
Provisioned bool `json:"provisioned"`
381+
ProgramStale bool `json:"program_stale"`
382+
Observation observationValue `json:"observation"`
383+
Guidance string `json:"guidance,omitempty"`
384+
}
385+
386+
// statusReport observes the committed control state read-only. It never
387+
// provisions: for a missing instance it reports the deterministic initial
388+
// state the first mutating command will create, marked unprovisioned,
389+
// without persisting anything.
390+
func (c *loopContext) statusReport() (statusReport, error) {
391+
record, err := c.store.Load(context.Background(), c.instance)
392+
provisioned := true
393+
if kernel.IsInstanceNotFound(err) {
394+
provisioned = false
395+
record = kernel.InstanceRecord{State: kernel.ControlState{
396+
InstanceID: c.instance, Program: c.program.Identity(),
397+
Mode: c.program.InitialMode, Revision: 1,
398+
}}
399+
} else if err != nil {
400+
return statusReport{}, err
401+
}
402+
state := record.State
403+
observed, err := c.domain.observeValue()
404+
if err != nil {
405+
return statusReport{}, err
406+
}
407+
stale := state.Program != c.program.Identity()
408+
return statusReport{
409+
Instance: c.instance,
410+
Program: c.program.Identity(),
368411
State: state,
412+
Provisioned: provisioned,
369413
ProgramStale: stale,
370414
Observation: observed,
371415
Guidance: func() string {
416+
if !provisioned {
417+
return "this control instance is not provisioned yet; the first mutating command (`boatstack-reviewer resolve`) provisions it"
418+
}
372419
if stale {
373420
return "the admitted policy or law changed since this state was committed; `boatstack-reviewer reset --confirm` archives it"
374421
}
375422
return submissionGuidance(state.Mode)
376423
}(),
377-
})
424+
}, nil
378425
}
379426

380427
// commandShow prints a recorded review itself — the exact archived findings
@@ -392,10 +439,14 @@ func commandShow(arguments []string) error {
392439
if err != nil {
393440
return err
394441
}
395-
state, err := loop.store.Load(context.Background(), loop.instance)
442+
record, err := loop.store.Load(context.Background(), loop.instance)
443+
if kernel.IsInstanceNotFound(err) {
444+
return fmt.Errorf("%w; run `boatstack-reviewer resolve` to provision it", err)
445+
}
396446
if err != nil {
397447
return err
398448
}
449+
state := record.State
399450
journal, err := loop.store.loadJournal()
400451
if err != nil {
401452
return err
@@ -491,7 +542,7 @@ func commandSeal(arguments []string) error {
491542
if !report.Verified {
492543
return fmt.Errorf("seal refused: the full receipt does not verify: %s", strings.Join(report.Failures, "; "))
493544
}
494-
archive := filepath.Join(loop.store.dir, "sealed-receipt.json")
545+
archive := filepath.Join(loop.store.dir(), "sealed-receipt.json")
495546
if err := writeSealedReceipt(archive, receipt); err != nil {
496547
return err
497548
}
@@ -576,6 +627,9 @@ func commandRequested(arguments []string, name, transition string, capability ke
576627
if err != nil {
577628
return err
578629
}
630+
if err := loop.ensureProvisioned(runtime); err != nil {
631+
return err
632+
}
579633
authority, err := loop.authority(*actor, capability)
580634
if err != nil {
581635
return err
@@ -615,9 +669,9 @@ func commandReset(arguments []string) error {
615669
return err
616670
}
617671
if !*confirm {
618-
return fmt.Errorf("reset archives %s; pass --confirm to proceed", loop.store.dir)
672+
return fmt.Errorf("reset archives %s; pass --confirm to proceed", loop.store.dir())
619673
}
620-
if _, err := os.Stat(loop.store.dir); os.IsNotExist(err) {
674+
if _, err := os.Stat(loop.store.dir()); os.IsNotExist(err) {
621675
return fmt.Errorf("instance %s has no local review state", loop.instance)
622676
}
623677
archived, err := loop.store.archive(time.Now().UTC().Format("20060102T150405Z"))

boatstack/cmd/boatstack-reviewer/reviewer_test.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ func newTestLoop(t *testing.T, scratch *scratchRepo, policy Policy) *loopContext
9797
if err != nil {
9898
t.Fatal(err)
9999
}
100-
store := newFileStore(scratch.repo.GitDir, "feature", program.Identity())
100+
store := newFileStore(scratch.repo.GitDir, "feature")
101101
domain := &reviewDomain{repo: scratch.repo, store: store, policy: policy, baseRef: "main"}
102-
return &loopContext{
102+
loop := &loopContext{
103103
repo: scratch.repo,
104104
policy: policy,
105105
program: program,
@@ -109,6 +109,14 @@ func newTestLoop(t *testing.T, scratch *scratchRepo, policy Policy) *loopContext
109109
instance: "feature",
110110
baseRef: "main",
111111
}
112+
runtime, err := loop.runtime()
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
if err := loop.ensureProvisioned(runtime); err != nil {
117+
t.Fatal(err)
118+
}
119+
return loop
112120
}
113121

114122
func testPolicy(t *testing.T, scratch *scratchRepo) Policy {
@@ -195,11 +203,11 @@ func submit(t *testing.T, loop *loopContext, candidate string) (kernel.Resolutio
195203

196204
func mode(t *testing.T, loop *loopContext) string {
197205
t.Helper()
198-
state, err := loop.store.Load(context.Background(), loop.instance)
206+
record, err := loop.store.Load(context.Background(), loop.instance)
199207
if err != nil {
200208
t.Fatal(err)
201209
}
202-
return state.Mode
210+
return record.State.Mode
203211
}
204212

205213
func TestReviewProgramControlLaw(t *testing.T) {
@@ -950,18 +958,19 @@ func TestRecoveryClearsInterruptedEffect(t *testing.T) {
950958

951959
// Simulate a crash between BeginEffect and CommitTransition: the store
952960
// holds an attempt revision with an active recovery state.
953-
state, err := loop.store.Load(context.Background(), loop.instance)
961+
record, err := loop.store.Load(context.Background(), loop.instance)
954962
if err != nil {
955963
t.Fatal(err)
956964
}
965+
state := record.State
957966
attempt := state
958967
attempt.Revision = state.Revision + 1
959968
attempt.Recovery = &kernel.RecoveryState{
960969
PrescriptionID: "interrupted-prescription",
961970
TransitionID: transitionRecord,
962971
Reason: "simulated crash between effect and commit",
963972
}
964-
if err := loop.store.BeginEffect(context.Background(), state.Revision, attempt); err != nil {
973+
if err := loop.store.BeginEffect(context.Background(), loop.instance, state.Revision, attempt); err != nil {
965974
t.Fatal(err)
966975
}
967976
if err := loop.store.stageCandidate([]byte(correctReview()), "half-recorded"); err != nil {
@@ -1075,3 +1084,54 @@ func TestGitBinaryIsAvailableForThisSuite(t *testing.T) {
10751084
t.Fatal("this test suite requires git on PATH")
10761085
}
10771086
}
1087+
1088+
// control-law explicit-instance-provisioning: read-only status reports the
1089+
// deterministic initial state for an unprovisioned instance without
1090+
// persisting anything; only a mutating command provisions the instance.
1091+
func TestStatusReportsUnprovisionedInstanceWithoutPersisting(t *testing.T) {
1092+
scratch := newScratchRepo(t)
1093+
policy := testPolicy(t, scratch)
1094+
program, err := compileReviewProgram(policy)
1095+
if err != nil {
1096+
t.Fatal(err)
1097+
}
1098+
store := newFileStore(scratch.repo.GitDir, "feature")
1099+
domain := &reviewDomain{repo: scratch.repo, store: store, policy: policy, baseRef: "main"}
1100+
loop := &loopContext{
1101+
repo: scratch.repo,
1102+
policy: policy,
1103+
program: program,
1104+
store: store,
1105+
domain: domain,
1106+
operator: reviewOperator{store: store},
1107+
instance: "feature",
1108+
baseRef: "main",
1109+
}
1110+
report, err := loop.statusReport()
1111+
if err != nil {
1112+
t.Fatalf("status on an unprovisioned instance = %v, want a read-only report", err)
1113+
}
1114+
if report.Provisioned {
1115+
t.Fatal("unprovisioned instance reported as provisioned")
1116+
}
1117+
if report.State.InstanceID != "feature" || report.State.Mode != program.InitialMode || report.State.Revision != 1 || report.State.Program != program.Identity() {
1118+
t.Fatalf("unprovisioned status does not report the deterministic initial state: %#v", report.State)
1119+
}
1120+
if _, err := store.Load(context.Background(), "feature"); !kernel.IsInstanceNotFound(err) {
1121+
t.Fatalf("read-only status persisted the instance: %v", err)
1122+
}
1123+
runtime, err := loop.runtime()
1124+
if err != nil {
1125+
t.Fatal(err)
1126+
}
1127+
if err := loop.ensureProvisioned(runtime); err != nil {
1128+
t.Fatal(err)
1129+
}
1130+
provisioned, err := loop.statusReport()
1131+
if err != nil {
1132+
t.Fatal(err)
1133+
}
1134+
if !provisioned.Provisioned || provisioned.State.Revision != 1 {
1135+
t.Fatalf("provisioned status = %#v, want the durable initial state", provisioned)
1136+
}
1137+
}

boatstack/cmd/boatstack-reviewer/seal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func (r SealedReceipt) contentFingerprint() (string, error) {
8080
// refuses unless the instance is converged and the converged round binds the
8181
// exact current reviewed tree.
8282
func buildSealedReceipt(repo *gitRepo, store *fileStore, policy Policy, program kernel.Program, baseRef string, now time.Time) (SealedReceipt, error) {
83-
document, err := store.loadDocument()
83+
document, err := store.loadDocument(store.instance)
8484
if err != nil {
8585
return SealedReceipt{}, err
8686
}

0 commit comments

Comments
 (0)