Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/reviews/cursor-pr5-instance-persistence.receipt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"reviewed_tree": "975232189c6c9be401c457c38fa72e489d6a9b53",
"program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155"
}
2 changes: 1 addition & 1 deletion boatstack/cmd/boatstack-reviewer/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (d *reviewDomain) observeValue() (observationValue, error) {
return observationValue{}, err
}
value := observationValue{
Instance: d.store.initial.InstanceID,
Instance: d.store.instance,
BaseRef: d.baseRef,
MergeBase: mergeBase,
HeadCommit: head,
Expand Down
26 changes: 26 additions & 0 deletions boatstack/cmd/boatstack-reviewer/instance_store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package main

import (
"testing"

"github.com/operatorstack/boatstack/boatstack/kernel"
"github.com/operatorstack/boatstack/boatstack/kernel/conformance"
)

// TestFileStoreInstanceConformance proves the reviewer's file store is a
// durable, isolated, idempotently resumable multi-instance kernel Store:
// reopening constructs a fresh handle over the same on-disk substrate, so
// restart reconstruction exercises real file persistence.
func TestFileStoreInstanceConformance(t *testing.T) {
conformance.InstanceStoreConformance{New: func(t testing.TB) conformance.InstanceStoreHarness {
gitDir := t.TempDir()
store := newFileStore(gitDir, "instance-alpha")
return conformance.InstanceStoreHarness{
Store: store,
Locker: func() kernel.Locker { return directoryLocker{store: store} },
Reopen: func(testing.TB) kernel.Store {
return newFileStore(gitDir, "instance-alpha")
},
}
}}.Run(t)
}
102 changes: 78 additions & 24 deletions boatstack/cmd/boatstack-reviewer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func newLoopContext(repoPath, delivery, baseRef string) (*loopContext, error) {
return nil, err
}
}
store := newFileStore(repo.GitDir, instance, program.Identity())
store := newFileStore(repo.GitDir, instance)
domain := &reviewDomain{repo: repo, store: store, policy: policy, baseRef: baseRef}
return &loopContext{
repo: repo,
Expand All @@ -128,14 +128,27 @@ func newLoopContext(repoPath, delivery, baseRef string) (*loopContext, error) {
func (c *loopContext) runtime() (kernel.Runtime, error) {
return kernel.NewRuntime(
c.program, c.domain, c.operator, reviewCapabilities{},
c.store, directoryLocker{path: c.store.lockPath()}, c.clock,
c.store, directoryLocker{store: c.store}, c.clock,
)
}

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

// ensureProvisioned provisions the command's control instance when it does
// not exist yet. Only the mutating entrypoints call it; read-only commands
// surface the typed not-found result instead of manufacturing state.
func (c *loopContext) ensureProvisioned(runtime kernel.Runtime) error {
if _, err := c.store.Load(context.Background(), c.instance); !kernel.IsInstanceNotFound(err) {
return err
}
if _, err := runtime.Provision(context.Background(), c.instance); err != nil && !kernel.IsInstanceExists(err) {
return err
}
return nil
}

func printJSON(value any) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
Expand Down Expand Up @@ -194,6 +207,9 @@ func commandResolve(arguments []string) error {
if err != nil {
return err
}
if err := loop.ensureProvisioned(runtime); err != nil {
return err
}
authority, err := loop.authority(*actor, capabilitySubmit)
if err != nil {
return err
Expand Down Expand Up @@ -276,6 +292,9 @@ func commandSubmit(arguments []string) error {
if err != nil {
return err
}
if err := loop.ensureProvisioned(runtime); err != nil {
return err
}
authority, err := loop.authority(*actor, capabilitySubmit)
if err != nil {
return err
Expand All @@ -300,10 +319,11 @@ func commandSubmit(arguments []string) error {
if err != nil {
return fmt.Errorf("submission did not commit: %w", err)
}
state, err := loop.store.Load(context.Background(), loop.instance)
record, err := loop.store.Load(context.Background(), loop.instance)
if err != nil {
return err
}
state := record.State
observed, err := loop.domain.observeValue()
if err != nil {
return err
Expand Down Expand Up @@ -346,35 +366,62 @@ func commandStatus(arguments []string) error {
if err != nil {
return err
}
state, err := loop.store.Load(context.Background(), loop.instance)
if err != nil {
return err
}
observed, err := loop.domain.observeValue()
report, err := loop.statusReport()
if err != nil {
return err
}
stale := state.Program != loop.program.Identity()
return printJSON(struct {
Instance string `json:"instance"`
Program kernel.ProgramIdentity `json:"program"`
State kernel.ControlState `json:"state"`
ProgramStale bool `json:"program_stale"`
Observation observationValue `json:"observation"`
Guidance string `json:"guidance,omitempty"`
}{
Instance: loop.instance,
Program: loop.program.Identity(),
return printJSON(report)
}

type statusReport struct {
Instance string `json:"instance"`
Program kernel.ProgramIdentity `json:"program"`
State kernel.ControlState `json:"state"`
Provisioned bool `json:"provisioned"`
ProgramStale bool `json:"program_stale"`
Observation observationValue `json:"observation"`
Guidance string `json:"guidance,omitempty"`
}

// statusReport observes the committed control state read-only. It never
// provisions: for a missing instance it reports the deterministic initial
// state the first mutating command will create, marked unprovisioned,
// without persisting anything.
func (c *loopContext) statusReport() (statusReport, error) {
record, err := c.store.Load(context.Background(), c.instance)
provisioned := true
if kernel.IsInstanceNotFound(err) {
provisioned = false
record = kernel.InstanceRecord{State: kernel.ControlState{
InstanceID: c.instance, Program: c.program.Identity(),
Mode: c.program.InitialMode, Revision: 1,
}}
} else if err != nil {
return statusReport{}, err
}
state := record.State
observed, err := c.domain.observeValue()
if err != nil {
return statusReport{}, err
}
stale := state.Program != c.program.Identity()
return statusReport{
Instance: c.instance,
Program: c.program.Identity(),
State: state,
Provisioned: provisioned,
ProgramStale: stale,
Observation: observed,
Guidance: func() string {
if !provisioned {
return "this control instance is not provisioned yet; the first mutating command (`boatstack-reviewer resolve`) provisions it"
}
if stale {
return "the admitted policy or law changed since this state was committed; `boatstack-reviewer reset --confirm` archives it"
}
return submissionGuidance(state.Mode)
}(),
})
}, nil
}

// commandShow prints a recorded review itself — the exact archived findings
Expand All @@ -392,10 +439,14 @@ func commandShow(arguments []string) error {
if err != nil {
return err
}
state, err := loop.store.Load(context.Background(), loop.instance)
record, err := loop.store.Load(context.Background(), loop.instance)
if kernel.IsInstanceNotFound(err) {
return fmt.Errorf("%w; run `boatstack-reviewer resolve` to provision it", err)
}
if err != nil {
return err
}
state := record.State
journal, err := loop.store.loadJournal()
if err != nil {
return err
Expand Down Expand Up @@ -491,7 +542,7 @@ func commandSeal(arguments []string) error {
if !report.Verified {
return fmt.Errorf("seal refused: the full receipt does not verify: %s", strings.Join(report.Failures, "; "))
}
archive := filepath.Join(loop.store.dir, "sealed-receipt.json")
archive := filepath.Join(loop.store.dir(), "sealed-receipt.json")
if err := writeSealedReceipt(archive, receipt); err != nil {
return err
}
Expand Down Expand Up @@ -576,6 +627,9 @@ func commandRequested(arguments []string, name, transition string, capability ke
if err != nil {
return err
}
if err := loop.ensureProvisioned(runtime); err != nil {
return err
}
authority, err := loop.authority(*actor, capability)
if err != nil {
return err
Expand Down Expand Up @@ -615,9 +669,9 @@ func commandReset(arguments []string) error {
return err
}
if !*confirm {
return fmt.Errorf("reset archives %s; pass --confirm to proceed", loop.store.dir)
return fmt.Errorf("reset archives %s; pass --confirm to proceed", loop.store.dir())
}
if _, err := os.Stat(loop.store.dir); os.IsNotExist(err) {
if _, err := os.Stat(loop.store.dir()); os.IsNotExist(err) {
return fmt.Errorf("instance %s has no local review state", loop.instance)
}
archived, err := loop.store.archive(time.Now().UTC().Format("20060102T150405Z"))
Expand Down
72 changes: 66 additions & 6 deletions boatstack/cmd/boatstack-reviewer/reviewer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ func newTestLoop(t *testing.T, scratch *scratchRepo, policy Policy) *loopContext
if err != nil {
t.Fatal(err)
}
store := newFileStore(scratch.repo.GitDir, "feature", program.Identity())
store := newFileStore(scratch.repo.GitDir, "feature")
domain := &reviewDomain{repo: scratch.repo, store: store, policy: policy, baseRef: "main"}
return &loopContext{
loop := &loopContext{
repo: scratch.repo,
policy: policy,
program: program,
Expand All @@ -109,6 +109,14 @@ func newTestLoop(t *testing.T, scratch *scratchRepo, policy Policy) *loopContext
instance: "feature",
baseRef: "main",
}
runtime, err := loop.runtime()
if err != nil {
t.Fatal(err)
}
if err := loop.ensureProvisioned(runtime); err != nil {
t.Fatal(err)
}
return loop
}

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

func mode(t *testing.T, loop *loopContext) string {
t.Helper()
state, err := loop.store.Load(context.Background(), loop.instance)
record, err := loop.store.Load(context.Background(), loop.instance)
if err != nil {
t.Fatal(err)
}
return state.Mode
return record.State.Mode
}

func TestReviewProgramControlLaw(t *testing.T) {
Expand Down Expand Up @@ -950,18 +958,19 @@ func TestRecoveryClearsInterruptedEffect(t *testing.T) {

// Simulate a crash between BeginEffect and CommitTransition: the store
// holds an attempt revision with an active recovery state.
state, err := loop.store.Load(context.Background(), loop.instance)
record, err := loop.store.Load(context.Background(), loop.instance)
if err != nil {
t.Fatal(err)
}
state := record.State
attempt := state
attempt.Revision = state.Revision + 1
attempt.Recovery = &kernel.RecoveryState{
PrescriptionID: "interrupted-prescription",
TransitionID: transitionRecord,
Reason: "simulated crash between effect and commit",
}
if err := loop.store.BeginEffect(context.Background(), state.Revision, attempt); err != nil {
if err := loop.store.BeginEffect(context.Background(), loop.instance, state.Revision, attempt); err != nil {
t.Fatal(err)
}
if err := loop.store.stageCandidate([]byte(correctReview()), "half-recorded"); err != nil {
Expand Down Expand Up @@ -1075,3 +1084,54 @@ func TestGitBinaryIsAvailableForThisSuite(t *testing.T) {
t.Fatal("this test suite requires git on PATH")
}
}

// control-law explicit-instance-provisioning: read-only status reports the
// deterministic initial state for an unprovisioned instance without
// persisting anything; only a mutating command provisions the instance.
func TestStatusReportsUnprovisionedInstanceWithoutPersisting(t *testing.T) {
scratch := newScratchRepo(t)
policy := testPolicy(t, scratch)
program, err := compileReviewProgram(policy)
if err != nil {
t.Fatal(err)
}
store := newFileStore(scratch.repo.GitDir, "feature")
domain := &reviewDomain{repo: scratch.repo, store: store, policy: policy, baseRef: "main"}
loop := &loopContext{
repo: scratch.repo,
policy: policy,
program: program,
store: store,
domain: domain,
operator: reviewOperator{store: store},
instance: "feature",
baseRef: "main",
}
report, err := loop.statusReport()
if err != nil {
t.Fatalf("status on an unprovisioned instance = %v, want a read-only report", err)
}
if report.Provisioned {
t.Fatal("unprovisioned instance reported as provisioned")
}
if report.State.InstanceID != "feature" || report.State.Mode != program.InitialMode || report.State.Revision != 1 || report.State.Program != program.Identity() {
t.Fatalf("unprovisioned status does not report the deterministic initial state: %#v", report.State)
}
if _, err := store.Load(context.Background(), "feature"); !kernel.IsInstanceNotFound(err) {
t.Fatalf("read-only status persisted the instance: %v", err)
}
runtime, err := loop.runtime()
if err != nil {
t.Fatal(err)
}
if err := loop.ensureProvisioned(runtime); err != nil {
t.Fatal(err)
}
provisioned, err := loop.statusReport()
if err != nil {
t.Fatal(err)
}
if !provisioned.Provisioned || provisioned.State.Revision != 1 {
t.Fatalf("provisioned status = %#v, want the durable initial state", provisioned)
}
}
2 changes: 1 addition & 1 deletion boatstack/cmd/boatstack-reviewer/seal.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (r SealedReceipt) contentFingerprint() (string, error) {
// refuses unless the instance is converged and the converged round binds the
// exact current reviewed tree.
func buildSealedReceipt(repo *gitRepo, store *fileStore, policy Policy, program kernel.Program, baseRef string, now time.Time) (SealedReceipt, error) {
document, err := store.loadDocument()
document, err := store.loadDocument(store.instance)
if err != nil {
return SealedReceipt{}, err
}
Expand Down
Loading
Loading