From e3e4dcf85ec587cf96af546992ebd3087bd38001 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 09:11:05 +0100 Subject: [PATCH 1/4] feat: add domain-neutral control debugger --- .github/tests/test_repository_contract.py | 6 +- README.md | 10 +- .../boatstack-helper/delegation_runtime.go | 11 +- .../cmd/boatstack-helper/flow_runtime_test.go | 147 ++++++++++++++++ boatstack/cmd/boatstack-helper/main.go | 134 ++++++++++++++- boatstack/controlprogram/canonical.go | 1 + boatstack/controlprogram/canonical_test.go | 19 +++ boatstack/controlprogram/ir.go | 10 +- boatstack/delivery_controller.go | 23 ++- boatstack/flow/softwaredelivery/skills.go | 17 +- .../flow/softwaredelivery/skills_test.go | 35 ++++ .../flow/standard/supervisor_parity_test.go | 32 +++- .../softwaredelivery/catalog/transition.go | 67 ++++++-- .../catalog/transition_trace_test.go | 28 +++ .../softwaredelivery/engine/engine.go | 71 +++++++- .../softwaredelivery/engine/engine_test.go | 141 +++++++++++++++ .../softwaredelivery/supervisor/supervisor.go | 139 +++++++++++---- .../softwaredelivery/surfaces/protocol.go | 10 +- .../surfaces/protocol_test.go | 12 ++ boatstack/kernel/relation.go | 91 +++++++++- boatstack/kernel/relation_test.go | 30 +++- boatstack/kernel/runtime.go | 141 ++++++++++++--- boatstack/kernel/runtime_test.go | 160 ++++++++++++++++++ boatstack/kernel/trace.go | 104 ++++++++++++ boatstack/sdk/sdk.go | 5 + boatstack/sdk/sdk_test.go | 7 + .../incident-response.raw.json | 2 +- .../product-delivery-c.flow.ts | 7 +- docs/control-program-ir.md | 19 ++- packages/boatstack/src/index.ts | 3 +- release-notes/2026-08-14-control-debugger.md | 2 + 31 files changed, 1374 insertions(+), 110 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/catalog/transition_trace_test.go create mode 100644 boatstack/kernel/trace.go create mode 100644 release-notes/2026-08-14-control-debugger.md diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index d8eb8a6..a76ba7a 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -562,8 +562,8 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> REPO / "boatstack" / "internal" / "softwaredelivery" / "protocol" / "prescription.go" ).read_text() - self.assertIn("Relate(RelationInput", runtime) - self.assertIn("general.Relate(general.RelationInput", software_relation) + self.assertIn("RelateWithTrace(RelationInput", runtime) + self.assertIn("general.RelateWithTrace(general.RelationInput", software_relation) self.assertIn("general.Freshness", software_prescription) self.assertIn("general.NewFreshness", software_prescription) @@ -652,7 +652,7 @@ def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: *sorted((REPO / "boatstack" / "references").glob("*.md")), ] registered = { - "status", "next", "next-status", "apply", "recover", "doctor", + "status", "next", "next-status", "explain", "apply", "recover", "doctor", "events", "catalog", "guard", "rpc", "retro", "version", "init", "update", "attach", "detach", "hydrate-runtime", "configure", "reconcile-update", diff --git a/README.md b/README.md index 6db0b11..4ff42b8 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ control graph. The complete list is generated from the registry in the | **Transactions** | Prescriptions bind the exact control instance, state revision, program, objective binding, observation, transition, and authority. Apply rechecks that boundary before execution. | | **Verification and receipts** | Fresh postcondition verification, atomic state-and-receipt commits, and immutable transition facts. | | **Recovery** | A durable effect attempt precedes execution. Interrupted or uncertain outcomes enter explicit recovery instead of blindly repeating an effect. | +| **Control debugging** | Read-only decision traces explain why a transition was selected, rejected, blocked, ambiguous, or waiting on authority without reconstructing lifecycle logic in the host. | | **Conformance** | A reusable, domain-neutral suite verifies objective handling, authority, freshness, recovery, atomic commit, replay isolation, concurrency, and marked-state reachability against any explicitly mapped domain fixture. | ### Software delivery @@ -152,6 +153,9 @@ boatstack next --repo . --objective-id --target-id \ # Resolve one repository-owned entry. boatstack next --repo . --flow product-delivery --entry run --format json +# Explain the current decision without executing an effect. +boatstack explain --repo . --flow product-delivery --entry run + # Inspect the exact program and transition surface. boatstack doctor --repo . --format text boatstack catalog --format json @@ -165,7 +169,11 @@ boatstack apply --repo . --transition --run-id \ --expected-snapshot-fingerprint --format json ``` -`status`, `next`, `doctor`, `catalog`, and `events` are read-only. Friendly +`status`, `next`, `explain`, `doctor`, `catalog`, and `events` are read-only. +The three Flow surfaces answer different questions: `flow check` verifies that +the artifact is a valid executable Control Program; `next` and `flow run` +resolve or execute the controller; `explain` reports why the current controller +decision occurred. It does not grant authority or recommend a fix. Friendly commands such as `plan-create`, `workspace-cut`, `record-test`, and `publish-pr` resolve and consume one exact prescription in the same invocation. diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 0ea3723..cf64cee 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -58,6 +58,9 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo record, err := delegation.Load(recordPath) if os.IsNotExist(err) { releaseOnError() + if request.Operation == surfaces.OperationExplain { + return nil, nil, nil + } return nil, &surfaces.Response{ SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective, Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"}, @@ -89,17 +92,23 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo } } request.Authority.Receipts = filtered - if record.Status == "completed" && request.Operation == surfaces.OperationResolve { + if record.Status == "completed" && (request.Operation == surfaces.OperationResolve || request.Operation == surfaces.OperationExplain) { // A completed delegation carries no authority, but resolving the exact // bound run remains safe and lets restarts replay its terminal state. return nil, nil, nil } if record.Status != "active" { releaseOnError() + if request.Operation == surfaces.OperationExplain { + return nil, nil, nil + } return nil, nil, fmt.Errorf("DELEGATION_REVOKED: run authorization is %s", record.Status) } if !record.ExpiresAt.IsZero() && !time.Now().UTC().Before(record.ExpiresAt) { releaseOnError() + if request.Operation == surfaces.OperationExplain { + return nil, nil, nil + } return nil, nil, fmt.Errorf("DELEGATION_EXPIRED: run authorization expired") } for _, authority := range request.DelegatedAuthorities { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index f7cfd0f..bccaa4e 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "encoding/json" + "io" "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "testing" @@ -23,6 +25,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + "github.com/operatorstack/boatstack/boatstack/kernel" ) func flowRepository(t *testing.T) string { @@ -49,6 +52,61 @@ func runFlowGit(t *testing.T, repository string, arguments ...string) { } } +func captureRunOutput(t *testing.T, arguments ...string) ([]byte, error) { + t.Helper() + return captureStdout(t, func() error { return run(arguments) }) +} + +func captureStdout(t *testing.T, action func() error) ([]byte, error) { + t.Helper() + oldStdout := os.Stdout + readSide, writeSide, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writeSide + runErr := action() + _ = writeSide.Close() + os.Stdout = oldStdout + output, readErr := io.ReadAll(readSide) + _ = readSide.Close() + if readErr != nil { + t.Fatal(readErr) + } + return output, runErr +} + +func TestExplanationTextPreservesAuthorityAlgebra(t *testing.T) { + response := surfaces.Response{ + Operation: surfaces.OperationExplain, ProgramID: "product-delivery", EntryID: "run", RunID: "run-fixture", + Objective: model.Objective{TargetID: model.ObjectiveOpenPR}, + Trace: &kernel.DecisionTrace{ + StateRevision: 7, CurrentMode: string(model.PhaseFrontier), + Decision: kernel.DecisionTraceValue{Kind: string(supervisor.DecisionFrontier), Transition: "publication.execute", Reason: "transition requires unavailable capabilities"}, + Candidates: []kernel.CandidateTrace{{ + TransitionID: "publication.execute", Disposition: kernel.DispositionAuthorityFrontier, + Authority: kernel.AuthorityTrace{ + RequiredAny: []kernel.Capability{"authority.autonomy", "authority.human"}, RequiredAll: []kernel.Capability{"authority.external-provider"}, + MissingAll: []kernel.Capability{"authority.external-provider"}, AnySatisfied: true, + }, + }}, + }, + } + output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) + if err != nil { + t.Fatal(err) + } + text := string(output) + for _, wanted := range []string{"any-of: autonomy, human", "all-of: external-provider", "Missing:\n external-provider", "No effect was executed."} { + if !strings.Contains(text, wanted) { + t.Fatalf("authority explanation lacks %q:\n%s", wanted, text) + } + } + if strings.Contains(strings.ToLower(text), "you should") { + t.Fatalf("authority explanation became prescriptive:\n%s", text) + } +} + func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory string) { t.Helper() if err := os.Remove(filepath.Join(repository, ".git")); err != nil { @@ -856,6 +914,68 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +func TestExplainUsesFlowContextAndCreatesNoStateEffectOrReceipt(t *testing.T) { + // control-law: controller-observation-cannot-change-controller + repository := flowRepository(t) + runFlowGit(t, repository, "init") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("private plan text must not appear")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + before := repositoryBytes(t, repository) + output, runErr := captureRunOutput(t, "explain", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--host", "codex", "--format", "json") + if runErr != nil { + t.Fatalf("explain failed: %v\n%s", runErr, output) + } + var response surfaces.Response + if err := json.Unmarshal(output, &response); err != nil { + t.Fatalf("decode explain response: %v\n%s", err, output) + } + if response.Operation != surfaces.OperationExplain || response.Trace == nil || response.RunID == "" || response.ProgramID != "product-delivery" || response.EntryID != "run" { + t.Fatalf("explain context = %#v", response) + } + if response.Snapshot != nil || response.Prescription != nil || response.Admission != nil || response.Receipt != nil { + t.Fatalf("explain exposed mutable or raw runtime payloads: %#v", response) + } + if strings.Contains(string(output), "private plan text must not appear") { + t.Fatalf("explain leaked raw repository input: %s", output) + } + textOutput, textErr := captureRunOutput(t, "explain", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--host", "codex", "--format", "text") + if textErr != nil || !strings.Contains(string(textOutput), "No effect was executed.") || strings.Contains(string(textOutput), "private plan text must not appear") { + t.Fatalf("text explanation is unsafe or incomplete: %v\n%s", textErr, textOutput) + } + after := repositoryBytes(t, repository) + if !reflect.DeepEqual(before, after) { + t.Fatalf("explain changed repository state:\nbefore=%v\nafter=%v", before, after) + } +} + +func repositoryBytes(t *testing.T, root string) map[string]string { + t.Helper() + result := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + result[filepath.ToSlash(relative)] = string(raw) + return nil + }) + if err != nil { + t.Fatal(err) + } + return result +} + func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) { // control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters repository := flowRepository(t) @@ -1212,6 +1332,14 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || lock != nil || suspension == nil || suspension.Delegation == nil || suspension.Delegation.Code != "DELEGATION_REQUIRED" || suspension.Delegation.RequestFingerprint != bound.delegationRequestFingerprint { t.Fatalf("delegation suspension = lock=%v response=%#v err=%v", lock, suspension, err) } + explainRequest, err := buildRequest(surfaces.OperationExplain, bound) + if err != nil { + t.Fatal(err) + } + explainLock, explainSuspension, err := prepareDelegation(context.Background(), &explainRequest) + if err != nil || explainLock != nil || explainSuspension != nil || explainRequest.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("explain created missing delegation authority: lock=%v response=%#v authority=%#v err=%v", explainLock, explainSuspension, explainRequest.Authority, err) + } resolver, err := plant.NewResolver("") if err != nil { @@ -1229,11 +1357,30 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil { t.Fatal(err) } + if _, err := os.Stat(recordPath); !os.IsNotExist(err) { + t.Fatalf("explain created a delegation record: %v", err) + } now := time.Now().UTC() record := delegation.Record{Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: bound.delegationRequest, RequestFingerprint: bound.delegationRequestFingerprint, ReceiptID: "authorization-test", Actor: "human@example.com", AuthorizedAt: now, Revision: 1, Status: "active"} if err := effects.StoreDelegationRecord(recordPath, record); err != nil { t.Fatal(err) } + recordBeforeExplain, err := os.ReadFile(recordPath) + if err != nil { + t.Fatal(err) + } + explainRequest, err = buildRequest(surfaces.OperationExplain, bound) + if err != nil { + t.Fatal(err) + } + explainLock, explainSuspension, err = prepareDelegation(context.Background(), &explainRequest) + if err != nil || explainLock != nil || explainSuspension != nil || !explainRequest.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("explain did not project existing delegation: lock=%v response=%#v authority=%#v err=%v", explainLock, explainSuspension, explainRequest.Authority, err) + } + recordAfterExplain, err := os.ReadFile(recordPath) + if err != nil || !bytes.Equal(recordBeforeExplain, recordAfterExplain) { + t.Fatalf("explain changed delegation record: %v", err) + } lock, suspension, err = prepareDelegation(context.Background(), &request) if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("authorized resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 31bca9a..e90c772 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -13,6 +13,7 @@ import ( "os/signal" "path/filepath" "runtime/debug" + "sort" "strings" "time" @@ -173,8 +174,10 @@ func run(arguments []string) error { request.Prescription = *resolved.Prescription } response, handleErr := kernel.Handle(context.Background(), request) - if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { - handleErr = settleErr + if operation != surfaces.OperationExplain { + if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { + handleErr = settleErr + } } if command == "events" && options.follow { if options.format != "jsonl" { @@ -189,7 +192,7 @@ func run(arguments []string) error { } func usageError() error { - return errors.New("usage: boatstack [flags]") + return errors.New("usage: boatstack [flags]") } func runRPC() error { @@ -229,8 +232,10 @@ func runRPC() error { return err } response, handleErr := kernel.Handle(context.Background(), request) - if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { - handleErr = settleErr + if request.Operation != surfaces.OperationExplain { + if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { + handleErr = settleErr + } } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") @@ -288,6 +293,8 @@ func classifyCommand(command string) (surfaces.Operation, catalog.TransitionID, switch command { case "status", "next", "next-status": return surfaces.OperationResolve, "", nil, nil + case "explain": + return surfaces.OperationExplain, "", nil, nil case "apply": return surfaces.OperationApply, "", nil, nil case "recover": @@ -310,7 +317,11 @@ func classifyCommand(command string) (surfaces.Operation, catalog.TransitionID, func parseOptions(command string, arguments []string, transition catalog.TransitionID, defaults map[string]string) (commandOptions, error) { flags := flag.NewFlagSet(command, flag.ContinueOnError) flags.SetOutput(os.Stderr) - options := commandOptions{format: "json", transitionID: string(transition), host: "cli"} + defaultFormat := "json" + if command == "explain" { + defaultFormat = "text" + } + options := commandOptions{format: defaultFormat, transitionID: string(transition), host: "cli"} if defaults != nil { options.targetID, options.deliveryID, options.objectiveID = defaults["target-id"], defaults["delivery"], defaults["objective-id"] } @@ -714,6 +725,9 @@ func renderResponse(response surfaces.Response, format string) error { } return encoder.Encode(response) case "text": + if response.Operation == surfaces.OperationExplain { + return renderExplanation(response) + } if response.Error != "" { fmt.Println("UNRESOLVED:", response.Error) if response.ProgramChange != nil { @@ -761,6 +775,114 @@ func renderResponse(response surfaces.Response, format string) error { } } +func renderExplanation(response surfaces.Response) error { + trace := response.Trace + if trace == nil { + if response.Error != "" { + fmt.Println("UNRESOLVED:", response.Error) + fmt.Println("No effect was executed.") + return nil + } + return fmt.Errorf("explain response has no decision trace") + } + if response.RunID != "" { + fmt.Println("Run:", response.RunID) + } + if response.ProgramID != "" { + fmt.Println("Flow:", response.ProgramID) + } + if response.EntryID != "" { + fmt.Println("Entry:", response.EntryID) + } + if response.Objective.TargetID != "" { + fmt.Println("Target:", response.Objective.TargetID) + } + fmt.Printf("State: revision %d, mode %s\n", trace.StateRevision, trace.CurrentMode) + fmt.Printf("Decision: %s\n", trace.Decision.Kind) + if trace.Decision.Reason != "" { + fmt.Println("Reason:", trace.Decision.Reason) + } + primary := trace.Decision.Transition + if primary == "" && len(trace.Decision.Candidates) != 0 { + primary = trace.Decision.Candidates[0] + } + for _, candidate := range trace.Candidates { + if candidate.TransitionID != primary { + continue + } + fmt.Println("\nCandidate:", candidate.TransitionID) + var satisfied []string + for label, evaluation := range map[string]general.EvaluationTrace{ + "source state": candidate.SourceMode, "recovery compatibility": candidate.RecoveryCompatible, + "objective": candidate.ObjectiveScope, "objective binding": candidate.ObjectiveMutation, + "domain predicate": candidate.DomainAdmissible, + } { + if evaluation.Evaluated && evaluation.Satisfied { + satisfied = append(satisfied, label) + } + } + sort.Strings(satisfied) + if len(satisfied) != 0 { + fmt.Println("\nSatisfied:") + for _, value := range satisfied { + fmt.Println(" " + value) + } + } + if len(candidate.Authority.RequiredAny) != 0 || len(candidate.Authority.RequiredAll) != 0 { + fmt.Println("\nAuthority:") + if len(candidate.Authority.RequiredAny) != 0 { + fmt.Println(" any-of:", renderCapabilities(candidate.Authority.RequiredAny)) + } + if len(candidate.Authority.RequiredAll) != 0 { + fmt.Println(" all-of:", renderCapabilities(candidate.Authority.RequiredAll)) + } + } + missing := append(append([]general.Capability(nil), candidate.Authority.MissingAll...), candidate.Authority.MissingAny...) + if len(missing) != 0 { + fmt.Println("\nMissing:") + for _, value := range missing { + fmt.Println(" " + strings.TrimPrefix(string(value), "authority.")) + } + } + break + } + var others []general.CandidateTrace + for _, candidate := range trace.Candidates { + if candidate.TransitionID != primary && candidate.Disposition != general.DispositionIrrelevantToRequest { + others = append(others, candidate) + } + } + if len(others) != 0 { + fmt.Println("\nOther candidates:") + for _, candidate := range others { + reason := candidateReason(candidate) + fmt.Printf(" %s\n %s: %s\n", candidate.TransitionID, candidate.Disposition, reason) + } + } + fmt.Println("\nNo effect was executed.") + return nil +} + +func candidateReason(candidate general.CandidateTrace) string { + for _, evaluation := range []general.EvaluationTrace{candidate.SourceMode, candidate.RecoveryCompatible, candidate.ObjectiveScope, candidate.ObjectiveMutation, candidate.DomainAdmissible, candidate.Selection} { + if evaluation.Evaluated && !evaluation.Satisfied && evaluation.Reason != "" { + return evaluation.Reason + } + } + if candidate.Disposition == general.DispositionAuthorityFrontier { + return "required authority is missing" + } + return "another canonical candidate was preferred" +} + +func renderCapabilities(values []general.Capability) string { + result := make([]string, len(values)) + for index, value := range values { + result[index] = strings.TrimPrefix(string(value), "authority.") + } + return strings.Join(result, ", ") +} + func hash(value []byte) string { digest := sha256.Sum256(value) return hex.EncodeToString(digest[:]) diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index a645be3..dd1c064 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -542,6 +542,7 @@ func stripDescriptions(value Document) Document { } for i := range value.Entries { value.Entries[i].Description = "" + value.Entries[i].Diagnostics = nil } return value } diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 5723555..e02a690 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -123,6 +123,25 @@ func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { } } +func TestEntryDiagnosticsAreArtifactBoundButNotExecutableControlLaw(t *testing.T) { + document := incidentProgram() + without, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + document.Entries[0].Diagnostics = &controlprogram.EntryDiagnostics{ExplainOnSuspend: true} + with, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + if with.Fingerprint != without.Fingerprint { + t.Fatalf("presentation preference changed executable fingerprint: %s != %s", with.Fingerprint, without.Fingerprint) + } + if bytes.Equal(with.Canonical, without.Canonical) || !bytes.Contains(with.Canonical, []byte(`"explain_on_suspend": true`)) { + t.Fatal("diagnostic preference is not preserved in the artifact projection") + } +} + func TestDelegationBindingIsResolvedAndFingerprintBound(t *testing.T) { // control-law: repository-source-can-request-but-cannot-grant-authority document := incidentProgram() diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 9d306b6..68b5b11 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 1 + SchemaRevision = 2 ) type Document struct { @@ -140,9 +140,17 @@ type Entry struct { Target string `json:"target"` Inputs []EntryInput `json:"inputs,omitempty"` Delegation *DelegationBinding `json:"delegation,omitempty"` + Diagnostics *EntryDiagnostics `json:"diagnostics,omitempty"` Description string `json:"description,omitempty"` } +// EntryDiagnostics controls generated host UX only. It is excluded from the +// executable Program fingerprint but remains bound by the source, artifact, +// and generated-projection digests. +type EntryDiagnostics struct { + ExplainOnSuspend bool `json:"explain_on_suspend,omitempty"` +} + type DelegationBinding struct { Reference string `json:"reference"` Version string `json:"version"` diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index ebca244..b225932 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -118,24 +118,31 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request } } switch request.Operation { - case surfaces.OperationResolve: - resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID}) - response.Objective, response.Decision = resolution.Objective, &resolution.Decision - if resolution.Prescription.ID != "" { + case surfaces.OperationResolve, surfaces.OperationExplain: + explain := request.Operation == surfaces.OperationExplain + resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain}) + response.Objective, response.Decision, response.Trace = resolution.Objective, &resolution.Decision, resolution.Trace + if !explain && resolution.Prescription.ID != "" { response.Prescription = &resolution.Prescription response.Admission = &resolution.Admission } - if resolution.Snapshot.Fingerprint != "" { + if !explain && resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot } - response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, resolution.Decision) - if response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { + if !explain { + response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, resolution.Decision) + } + if !explain && response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { if transition, ok := k.registry.Lookup(resolution.Decision.Candidates[0]); ok { questionDecision := supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &transition} response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, questionDecision) } } - response.ProgramChange = programChangeFor(response.Snapshot) + if explain { + response.ProgramChange = programChangeFor(&resolution.Snapshot) + } else { + response.ProgramChange = programChangeFor(response.Snapshot) + } if resolveErr != nil { response.Error = resolveErr.Error() return response, resolveErr diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index b2a452c..ece4d32 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -56,6 +56,20 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s description += " Use only when the user explicitly selects this repository Flow entry." supersession := "" delegation := "" + diagnostics := "" + if entry.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { + diagnostics = fmt.Sprintf(` +If Boatstack suspends this run without reaching the target or prescribing an +applicable action, invoke the read-only debugger with the exact same context: + +`+"`boatstack explain --repo . --flow %s --entry %s --run-id --host %s --format json`"+` + +Preserve its canonical trace exactly. Use it only to report the factual blocker, +ask for the exact missing evidence, or perform an action already prescribed by +Boatstack. An explanation is not authority: never grant authority, fabricate a +run ID, reconstruct the transition graph, or act on a rejected candidate. +`, compiled.Document.Program.ID, entry.ID, host) + } if entry.Delegation != nil { delegation = fmt.Sprintf(` The first `+"`next`"+` returns a typed `+"`DELEGATION_REQUIRED`"+` response before @@ -106,11 +120,12 @@ answer evidence, and resume the same run ID. Nothing continues in the background while input is missing. Never synthesize authority. %s %s +%s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 12521a2..2ba7555 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -83,6 +83,41 @@ func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) { } } +func TestGeneratedSkillExplanationIsEntryOptInWithHostParity(t *testing.T) { + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Entries: []controlprogram.Entry{ + {ID: "run", Target: "published-pr", Diagnostics: &controlprogram.EntryDiagnostics{ExplainOnSuspend: true}}, + {ID: "quiet", Target: "published-pr"}, + }, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + for _, host := range []string{"codex", "claude"} { + root := ".agents" + if host == "claude" { + root = ".claude" + } + run := string(files[root+"/skills/product-delivery-run/SKILL.md"]) + for _, wanted := range []string{"boatstack explain", "--run-id ", "An explanation is not authority", "reconstruct the transition graph"} { + if !strings.Contains(run, wanted) { + t.Fatalf("%s opt-in skill lacks %q", host, wanted) + } + } + quiet := string(files[root+"/skills/product-delivery-quiet/SKILL.md"]) + if strings.Contains(quiet, "boatstack explain") { + t.Fatalf("%s opt-out skill gained explain behavior", host) + } + } + codex := string(files[".agents/skills/product-delivery-run/SKILL.md"]) + claude := string(files[".claude/skills/product-delivery-run/SKILL.md"]) + if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { + t.Fatal("Codex and Claude diagnostic projections differ") + } +} + func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index b0c4081..eda0f44 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -3,6 +3,7 @@ package standard_test import ( "context" "path/filepath" + "strings" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" . "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) func testObjectiveContracts() catalog.ObjectiveContracts { @@ -273,10 +275,17 @@ func TestUntargetedResolutionStopsAtSelectedProviderBoundary(t *testing.T) { supervisor := New(testprogram.StandardRegistry(), testObjectiveContracts()) authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} - decision := supervisor.Resolve(snapshot, objective, authority, "") + decision, trace := supervisor.ResolveWithTrace(snapshot, objective, authority, "") if decision.Kind != DecisionFrontier || len(decision.Candidates) != 1 || decision.Candidates[0] != "publication.execute" { t.Fatalf("provider-free decision = %#v, want publication.execute FRONTIER", decision) } + for _, candidate := range trace { + if candidate.TransitionID == "publication.execute" { + if candidate.Disposition != general.DispositionAuthorityFrontier || !candidate.Authority.AnySatisfied || candidate.Authority.AllSatisfied || len(candidate.Authority.MissingAll) != 1 || candidate.Authority.MissingAll[0] != "authority.external-provider" { + t.Fatalf("publication authority diagnosis = %#v", candidate) + } + } + } authority[catalog.AuthorityProvider] = true decision = supervisor.Resolve(snapshot, objective, authority, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.execute" { @@ -284,6 +293,27 @@ func TestUntargetedResolutionStopsAtSelectedProviderBoundary(t *testing.T) { } } +func TestPublicationTraceNamesStaleVerificationEvidence(t *testing.T) { + snapshot, objective := openPRSnapshot(t, "build", "test", "review") + snapshot.Verification = model.Known(model.VerificationStale, snapshot.Verification.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + decision, trace := New(testprogram.StandardRegistry(), testObjectiveContracts()).ResolveWithTrace( + snapshot, objective, catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityProvider: true}, "publication.execute", + ) + if decision.Kind != DecisionRefused { + t.Fatalf("stale publication decision = %#v", decision) + } + for _, candidate := range trace { + if candidate.TransitionID == "publication.execute" { + if candidate.Disposition != general.DispositionSourceModeRejected || !strings.Contains(candidate.SourceMode.Reason, "verification") || !strings.Contains(candidate.SourceMode.Reason, "stale") { + t.Fatalf("stale publication diagnosis = %#v", candidate) + } + return + } + } + t.Fatal("publication candidate is absent") +} + func TestRequestedTransitionRequiresExactAuthority(t *testing.T) { // control-law: useful-action-is-not-effect-authority s := New(testprogram.StandardRegistry(), testObjectiveContracts()) diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index a3620b1..3ec7483 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -251,9 +251,16 @@ type FacetCondition struct { } func (c FacetCondition) Matches(snapshot model.Snapshot) bool { + matched, _ := c.Evaluate(snapshot) + return matched +} + +// Evaluate returns the bounded software-delivery reason for the same source +// predicate used by Matches. It never includes raw evidence payloads. +func (c FacetCondition) Evaluate(snapshot model.Snapshot) (bool, string) { status, value, ok := snapshot.Facet(c.Facet) if !ok { - return false + return false, fmt.Sprintf("source facet %q is unavailable", c.Facet) } statusMatch := false for _, candidate := range c.Statuses { @@ -263,17 +270,36 @@ func (c FacetCondition) Matches(snapshot model.Snapshot) bool { } } if !statusMatch { - return false + return false, fmt.Sprintf("source facet %q has status %q; requires one of %v", c.Facet, status, c.Statuses) } if len(c.Values) == 0 { - return true + return true, fmt.Sprintf("source facet %q has an allowed status", c.Facet) } + valueSafe := traceSafeFacetValue(c.Facet) for _, candidate := range c.Values { if candidate == value { - return true + if !valueSafe { + return true, fmt.Sprintf("source facet %q has an allowed value", c.Facet) + } + return true, fmt.Sprintf("source facet %q has allowed value %q", c.Facet, value) } } - return false + if !valueSafe { + return false, fmt.Sprintf("source facet %q does not match its allowed values", c.Facet) + } + return false, fmt.Sprintf("source facet %q has value %q; requires one of %v", c.Facet, value, c.Values) +} + +func traceSafeFacetValue(facet model.FacetName) bool { + switch facet { + case model.FacetPhase, model.FacetProgram, model.FacetTopology, model.FacetEngagement, + model.FacetDelivery, model.FacetWorkspace, model.FacetPlan, model.FacetConfiguration, + model.FacetRuntime, model.FacetPublication, model.FacetVerification, model.FacetRecovery, + model.FacetTransaction, model.FacetTerminal: + return true + default: + return false + } } // Transition is both the executable runtime declaration and the source for the @@ -341,11 +367,17 @@ func (t Transition) ImplicitlySelectable() bool { } func (t Transition) SupportsObjective(objective model.Objective) bool { + matched, _ := t.ObjectiveEvaluation(objective) + return matched +} + +// ObjectiveEvaluation evaluates the transition's compiled objective scope. +func (t Transition) ObjectiveEvaluation(objective model.Objective) (bool, string) { if t.Policy.ObjectiveScope == ObjectiveScopeOptionalPreserve { - return true + return true, "transition preserves the current objective binding" } if len(t.TargetIDs) == 0 { - return true + return true, "transition is not restricted to an objective target" } targetID := objective.TargetID if t.Origin.Kind == OriginCoreSystem { @@ -353,10 +385,10 @@ func (t Transition) SupportsObjective(objective model.Objective) bool { } for _, kind := range t.TargetIDs { if kind == targetID { - return true + return true, fmt.Sprintf("objective target %q is in scope", targetID) } } - return false + return false, fmt.Sprintf("objective target %q is outside supported targets %v", targetID, t.TargetIDs) } func containsPhase(phases []model.ProtocolPhase, phase model.ProtocolPhase) bool { @@ -369,18 +401,25 @@ func containsPhase(phases []model.ProtocolPhase, phase model.ProtocolPhase) bool } func (t Transition) SourceMatches(snapshot model.Snapshot) bool { + matched, _ := t.SourceEvaluation(snapshot) + return matched +} + +// SourceEvaluation evaluates the transition's actual source predicate and +// returns a bounded reason suitable for a generic decision trace. +func (t Transition) SourceEvaluation(snapshot model.Snapshot) (bool, string) { if snapshot.Phase.Status != model.FactKnown || !containsPhase(t.SourcePhases, snapshot.Phase.Value) { - return false + return false, fmt.Sprintf("source phase is %q with status %q; requires one of %v", snapshot.Phase.Value, snapshot.Phase.Status, t.SourcePhases) } for _, condition := range t.SourceConditions { - if !condition.Matches(snapshot) { - return false + if matched, reason := condition.Evaluate(snapshot); !matched { + return false, reason } } if t.Policy.ObjectiveScope == ObjectiveScopeOptionalPreserve && snapshot.Objective.Status != model.FactKnown && snapshot.Objective.Status != model.FactAbsent { - return false + return false, fmt.Sprintf("objective binding status %q cannot be preserved", snapshot.Objective.Status) } - return true + return true, "source phase and domain facets are satisfied" } func (t Transition) TargetMatches(snapshot model.Snapshot) bool { diff --git a/boatstack/internal/softwaredelivery/catalog/transition_trace_test.go b/boatstack/internal/softwaredelivery/catalog/transition_trace_test.go new file mode 100644 index 0000000..5ef242f --- /dev/null +++ b/boatstack/internal/softwaredelivery/catalog/transition_trace_test.go @@ -0,0 +1,28 @@ +package catalog + +import ( + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestFacetConditionReasonDoesNotExposeNamespacedValues(t *testing.T) { + evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(1, 0).UTC()} + snapshot := model.Snapshot{Observation: model.Observation{ProgramFacts: map[string]model.Fact[string]{ + "test.synthetic.secret": model.Known("private-actual-value", evidence), + }}} + condition := FacetCondition{ + Facet: "test.synthetic.secret", Statuses: []model.FactStatus{model.FactKnown}, Values: []string{"private-allowed-value"}, + } + allowed, reason := condition.Evaluate(snapshot) + if allowed || strings.Contains(reason, "private-actual-value") || strings.Contains(reason, "private-allowed-value") { + t.Fatalf("rejected namespaced value leaked through reason %q", reason) + } + condition.Values = []string{"private-actual-value"} + allowed, reason = condition.Evaluate(snapshot) + if !allowed || strings.Contains(reason, "private-actual-value") { + t.Fatalf("accepted namespaced value leaked through reason %q", reason) + } +} diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index cb1da80..ef4aa80 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -44,6 +44,7 @@ type ResolveRequest struct { Authority protocol.AuthorityBundle Parameters protocol.Parameters Requested catalog.TransitionID + Trace bool } type Resolution struct { @@ -52,6 +53,7 @@ type Resolution struct { Decision supervisor.Decision Prescription protocol.Prescription Admission protocol.Admission + Trace *general.DecisionTrace } func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution, error) { @@ -89,14 +91,23 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{}, fmt.Errorf("no valid requested or configured objective: %w", err) } } - decision := e.control.Resolve(snapshot, objective, request.Authority.Set(now), request.Requested) + decision, candidateTraces := e.control.ResolveWithTrace(snapshot, objective, request.Authority.Set(now), request.Requested) + var decisionTrace *general.DecisionTrace + if request.Trace { + authorityFingerprint, fingerprintErr := request.Authority.Fingerprint() + if fingerprintErr != nil { + return Resolution{}, fingerprintErr + } + decisionTrace = e.decisionTrace(snapshot, objective, authorityFingerprint, request.Requested, decision, candidateTraces) + } if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil { objective, err = protocol.ObjectiveForTransition(snapshot, objective, *decision.Transition) if err != nil { decision.Kind = supervisor.DecisionRefused decision.Reason = err.Error() decision.Transition = nil - return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision}, nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } if applicabilityErr := protocol.ValidateApplicability(snapshot, objective, *decision.Transition, request.Authority, request.Parameters, now); applicabilityErr != nil { if protocol.IsMissingParameter(applicabilityErr) { @@ -114,14 +125,16 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Kind = supervisor.DecisionRefused decision.Reason = capabilityErr.Error() decision.Transition = nil - return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision}, nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } prescription, prescriptionErr := protocol.NewPrescription(snapshot, *decision.Transition, capabilities) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() decision.Transition = nil - return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision}, nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } admission, admissionErr := protocol.NewAdmission(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, now, 2*time.Minute) if admissionErr != nil { @@ -133,11 +146,57 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Reason = fmt.Sprintf("transition %q failed deterministic effect preflight: %v", admission.TransitionID, preflightErr) decision.Transition = nil } else { - return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Prescription: prescription, Admission: admission}, nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Prescription: prescription, Admission: admission, Trace: decisionTrace}, nil } } } - return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision}, nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil +} + +func (e Engine) decisionTrace(snapshot model.Snapshot, objective model.Objective, authorityFingerprint string, requested catalog.TransitionID, decision supervisor.Decision, candidates []general.CandidateTrace) *general.DecisionTrace { + trace := &general.DecisionTrace{ + SchemaVersion: general.DecisionTraceSchemaVersion, InstanceID: snapshot.Invocation.RepositoryID, + StateRevision: snapshot.StateRevision, CurrentMode: string(snapshot.Phase.Value), + Program: general.ProgramIdentity{ID: e.program.ID, Version: e.program.Version, Fingerprint: e.program.Fingerprint}, + ObservationFingerprint: snapshot.Fingerprint, AuthorityFingerprint: authorityFingerprint, + RequestedTransition: string(requested), Marked: decision.Kind == supervisor.DecisionTerminal, + Decision: softwareDecisionTrace(decision), Candidates: candidates, + } + if snapshot.Objective.Status == model.FactKnown { + fingerprint, _ := general.Fingerprint(snapshot.Objective.Value) + trace.Objective.Binding = &general.ObjectiveIdentity{ID: snapshot.Objective.Value.ID, Fingerprint: fingerprint} + } + if objective.Validate() == nil { + fingerprint, _ := general.Fingerprint(objective) + trace.Objective.Requested = &general.ObjectiveIdentity{ID: objective.ID, Fingerprint: fingerprint} + } + if snapshot.Phase.Value == model.PhaseRecovery || (snapshot.Recovery.Status == model.FactKnown && snapshot.Recovery.Value != model.RecoveryNone) { + recovery := &general.RecoveryTrace{Active: true, Reason: "software-delivery recovery state is " + string(snapshot.Recovery.Value)} + if snapshot.TransactionInfo.Status == model.FactKnown { + recovery.TransitionID = snapshot.TransactionInfo.Value.TransitionID + } + trace.Recovery = recovery + } + return trace +} + +func softwareDecisionTrace(decision supervisor.Decision) general.DecisionTraceValue { + value := general.DecisionTraceValue{Kind: string(decision.Kind), Reason: decision.Reason} + if decision.Transition != nil { + value.Transition = string(decision.Transition.ID) + } + for _, candidate := range decision.Candidates { + value.Candidates = append(value.Candidates, string(candidate)) + } + return value +} + +func updateDecisionTrace(trace *general.DecisionTrace, decision supervisor.Decision) { + if trace != nil { + trace.Decision = softwareDecisionTrace(decision) + } } func unresolvedResolution(objective model.Objective, reason string) Resolution { diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 6c3ca1d..667651f 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -2,6 +2,7 @@ package engine import ( "context" + "encoding/json" "errors" "path/filepath" "reflect" @@ -15,6 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) type fixedClock struct{ now time.Time } @@ -477,6 +479,145 @@ func TestSyntheticStartVerifyTerminalContractNeedsNoStandardFlowFacet(t *testing } } +func TestSoftwareDecisionTracePreservesAuthorityClausesAndDomainReason(t *testing.T) { + // control-law: software-domain-evaluation-feeds-one-kernel-relation + transitions := testRegistryWithAdvanceClass(t, catalog.EventOwnedExternal).All() + for index := range transitions { + if transitions[index].ID == "test.advance" { + transitions[index].Authority = []catalog.AuthorityClass{catalog.AuthorityHuman, catalog.AuthorityAutonomy} + transitions[index].AuthorityAll = []catalog.AuthorityClass{catalog.AuthorityProvider} + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + snapshot, err := model.CanonicalizeForProgram(observation(model.PhaseObserved, "source"), syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + control := supervisor.New(registry, syntheticObjectiveContracts(t)) + decision, trace := control.ResolveWithTrace(snapshot, snapshot.Objective.Value, catalog.AuthoritySet{catalog.AuthorityAutonomy: true}, "") + if decision.Kind != supervisor.DecisionFrontier { + t.Fatalf("authority decision = %+v", decision) + } + for _, candidate := range trace { + if candidate.TransitionID != "test.advance" { + continue + } + if candidate.Disposition != general.DispositionAuthorityFrontier || !candidate.Authority.AnySatisfied || candidate.Authority.AllSatisfied || + !reflect.DeepEqual(candidate.Authority.MissingAll, []general.Capability{"authority.external-provider"}) { + t.Fatalf("authority trace collapsed clauses: %#v", candidate) + } + return + } + t.Fatal("publication candidate is absent") +} + +func TestSoftwareDecisionTraceExplainsStaleEvidenceWithoutLeakingUnrelatedFact(t *testing.T) { + transitions := testRegistry(t).All() + for index := range transitions { + if transitions[index].ID == "test.advance" { + transitions[index].SourceConditions = append(transitions[index].SourceConditions, catalog.FacetCondition{ + Facet: model.FacetVerification, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.VerificationCurrent)}, + }) + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + observed := observation(model.PhaseObserved, "source") + observed.ProgramFacts["test.synthetic.secret"] = model.Known("do-not-print", observed.Phase.Evidence[0]) + snapshot, err := model.CanonicalizeForProgram(observed, syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + decision, trace := supervisor.New(registry, syntheticObjectiveContracts(t)).ResolveWithTrace(snapshot, snapshot.Objective.Value, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "") + if decision.Kind != supervisor.DecisionUnresolved { + t.Fatalf("stale evidence decision = %+v", decision) + } + encoded, err := json.Marshal(trace) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "do-not-print") { + t.Fatalf("trace leaked unrelated domain payload: %s", encoded) + } + for _, candidate := range trace { + if candidate.TransitionID == "test.advance" { + if candidate.Disposition != general.DispositionSourceModeRejected || !strings.Contains(candidate.SourceMode.Reason, "verification") || !strings.Contains(candidate.SourceMode.Reason, "unverified") { + t.Fatalf("stale verification reason = %#v", candidate) + } + return + } + } + t.Fatal("stale candidate is absent") +} + +func TestEngineTraceLeavesDecisionPrescriptionAndEffectsUnchanged(t *testing.T) { + // control-law: controller-observation-cannot-change-controller + now := time.Unix(30, 0).UTC() + observer := &sequenceObserver{items: []model.Observation{ + observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), + }} + journal, effects, receipts := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{} + kernel, err := New(testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, journal, effects, receipts) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + plain, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.Trace = true + traced, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(plain.Decision, traced.Decision) || !reflect.DeepEqual(plain.Prescription, traced.Prescription) { + t.Fatalf("trace changed resolution: plain=%#v traced=%#v", plain, traced) + } + if traced.Trace == nil || effects.executions != 0 || journal.begun != 0 || journal.committed != 0 || len(receipts.values) != 0 { + t.Fatalf("trace executed or persisted an effect: trace=%#v effects=%d journal=%+v receipts=%d", traced.Trace, effects.executions, journal, len(receipts.values)) + } +} + +func TestEngineTraceExplainsRecoveryAndTargetReached(t *testing.T) { + now := time.Unix(30, 0).UTC() + t.Run("recovery", func(t *testing.T) { + observer := &sequenceObserver{items: []model.Observation{recoveryObservation("recovery")}} + kernel, err := New(testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + request.Requested = "" + request.Trace = true + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil || resolution.Trace == nil || resolution.Trace.Recovery == nil || !resolution.Trace.Recovery.Active || resolution.Trace.Recovery.Reason != "software-delivery recovery state is reconcile" || strings.Contains(resolution.Trace.Recovery.Reason, "receipt commit interrupted") { + t.Fatalf("recovery explanation = %#v, %v", resolution, err) + } + }) + + t.Run("target-reached", func(t *testing.T) { + observed := observation(model.PhaseActive, "target") + observer := &sequenceObserver{items: []model.Observation{observed}} + kernel, err := New(testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + request.Requested = "" + request.Trace = true + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil || resolution.Trace == nil || !resolution.Trace.Marked || resolution.Decision.Kind != supervisor.DecisionTerminal { + t.Fatalf("target explanation = %#v, %v", resolution, err) + } + }) +} + func TestExactPermittedRecoveryRemainsReachableAcrossProgramDrift(t *testing.T) { // control-law: interrupted-program-update-can-rollback-under-either-program-epoch observed := recoveryObservation("program-change-pending") diff --git a/boatstack/internal/softwaredelivery/supervisor/supervisor.go b/boatstack/internal/softwaredelivery/supervisor/supervisor.go index 434a070..1cae65e 100644 --- a/boatstack/internal/softwaredelivery/supervisor/supervisor.go +++ b/boatstack/internal/softwaredelivery/supervisor/supervisor.go @@ -40,86 +40,142 @@ func New(registry catalog.Registry, contracts catalog.ObjectiveContracts) Superv } func (s Supervisor) Resolve(snapshot model.Snapshot, objective model.Objective, authority catalog.AuthoritySet, requested catalog.TransitionID) Decision { + decision, _ := s.resolve(snapshot, objective, authority, requested) + return decision +} + +// ResolveWithTrace exposes the exact domain evaluation supplied to +// kernel.Relate. Resolve and ResolveWithTrace share this single path. +func (s Supervisor) ResolveWithTrace(snapshot model.Snapshot, objective model.Objective, authority catalog.AuthoritySet, requested catalog.TransitionID) (Decision, []general.CandidateTrace) { + return s.resolve(snapshot, objective, authority, requested) +} + +func (s Supervisor) resolve(snapshot model.Snapshot, objective model.Objective, authority catalog.AuthoritySet, requested catalog.TransitionID) (Decision, []general.CandidateTrace) { base := Decision{SnapshotFingerprint: snapshot.Fingerprint} + var traces []general.CandidateTrace + finish := func() (Decision, []general.CandidateTrace) { return base, traces } objectiveAbsent := snapshot.Objective.Status == model.FactAbsent objectiveProvided := objective.ID != "" || objective.TargetID != "" || objective.TrustedClass != "" || objective.DeliveryID != "" if (objectiveProvided && objective.Validate() != nil) || (!objectiveProvided && !objectiveAbsent) || snapshot.Fingerprint == "" { base.Kind, base.Reason = DecisionUnresolved, "objective or canonical snapshot is invalid" - return base + return finish() } if objectiveProvided && !s.contracts.Accepts(objective) { base.Kind, base.Reason = DecisionRefused, "objective target and trusted class do not match the compiled program" - return base + return finish() } if snapshot.Terminal.Status != model.FactKnown || snapshot.Phase.Status != model.FactKnown { base.Kind, base.Reason = DecisionUnresolved, "terminal or phase evidence is not known" - return base + return finish() } if snapshot.Program.Status != model.FactKnown { base.Kind, base.Reason = DecisionUnresolved, "compiled control program evidence is not known" - return base + return finish() } if snapshot.Program.Value == model.ProgramDrift { transition, ok := s.registry.Lookup(requested) if !ok || (!transition.Policy.ReconcilesProgram && !permittedProgramDriftRecovery(snapshot, transition)) { base.Kind, base.Reason = DecisionUnresolved, ReasonProgramDrift - return base + return finish() } } if snapshot.ConfigurationPolicy.Status == model.FactKnown && !hostEnabled(snapshot.ConfigurationPolicy.Value.Hosts, snapshot.Invocation.Host) { base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("host %q is not enabled by repository policy", snapshot.Invocation.Host) - return base + return finish() } marked := s.contracts.Matches(snapshot, objective) - admissible := s.registry.Admissible(snapshot, objective) - if snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value != objective { - filtered := admissible[:0] - for _, candidate := range admissible { - if candidate.Policy.BindsRequestedObjective { - filtered = append(filtered, candidate) - } - } - admissible = filtered - } - if snapshot.Phase.Value == model.PhaseRecovery { - filtered := admissible[:0] - for _, candidate := range admissible { - if candidate.Class == catalog.EventRecovery { - filtered = append(filtered, candidate) - } - } - admissible = filtered - } if requested != "" { transition, ok := s.registry.Lookup(requested) if !ok || !transition.Controllable() { base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("transition %q is not a controllable registry event", requested) - return base + return finish() } } - byID := make(map[string]catalog.Transition, len(admissible)) - candidates := make([]general.RelationCandidate, 0, len(admissible)) - for _, transition := range admissible { - if allowed, _ := policyAllows(snapshot, transition); !allowed { + allTransitions := s.registry.All() + byID := make(map[string]catalog.Transition, len(allTransitions)) + candidates := make([]general.RelationCandidate, 0, len(allTransitions)) + for _, transition := range allTransitions { + trace := general.CandidateTrace{ + TransitionID: string(transition.ID), Rank: transition.SelectionClass.Rank(), Priority: transition.Priority, + SelectionClass: string(transition.SelectionClass), + } + if requested != "" && transition.ID != requested { + trace.Disposition = general.DispositionIrrelevantToRequest + traces = append(traces, trace) + continue + } + if !transition.Controllable() { + trace.DomainAdmissible = general.EvaluationTrace{Evaluated: true, Reason: "observed external events are not controllable candidates"} + trace.Disposition = general.DispositionDomainRejected + traces = append(traces, trace) + continue + } + sourceAllowed, sourceReason := transition.SourceEvaluation(snapshot) + trace.SourceMode = general.EvaluationTrace{Evaluated: true, Satisfied: sourceAllowed, Reason: sourceReason} + if !sourceAllowed { + trace.Disposition = general.DispositionSourceModeRejected + traces = append(traces, trace) continue } + trace.RecoveryCompatible = general.EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "recovery state is compatible"} + if snapshot.Phase.Value == model.PhaseRecovery && transition.Class != catalog.EventRecovery { + trace.RecoveryCompatible = general.EvaluationTrace{Evaluated: true, Reason: "only recovery transitions are compatible with the recovery phase"} + trace.Disposition = general.DispositionRecoveryRejected + traces = append(traces, trace) + continue + } + objectiveAllowed, objectiveReason := transition.ObjectiveEvaluation(objective) + trace.ObjectiveScope = general.EvaluationTrace{Evaluated: true, Satisfied: objectiveAllowed, Reason: objectiveReason} + if !objectiveAllowed { + trace.Disposition = general.DispositionObjectiveRejected + traces = append(traces, trace) + continue + } + trace.ObjectiveMutation = general.EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "objective binding is compatible"} + if snapshot.Objective.Status == model.FactKnown && snapshot.Objective.Value != objective && !transition.Policy.BindsRequestedObjective { + trace.ObjectiveMutation = general.EvaluationTrace{Evaluated: true, Reason: "transition cannot replace the current objective binding"} + trace.Disposition = general.DispositionObjectiveRejected + traces = append(traces, trace) + continue + } + allowed, domainReason := policyAllows(snapshot, transition) + trace.DomainAdmissible = general.EvaluationTrace{Evaluated: true, Satisfied: allowed, Reason: domainReason} + if !allowed { + trace.Disposition = general.DispositionDomainRejected + traces = append(traces, trace) + continue + } + if trace.DomainAdmissible.Reason == "" { + trace.DomainAdmissible.Reason = "software-delivery predicates are satisfied" + } all, any := relationAuthority(snapshot, transition) id := string(transition.ID) byID[id] = transition + selectable := transition.ImplicitlySelectable() && !targetAlreadySatisfied(snapshot, objective, transition) + selectionReason := "transition is selectable for untargeted progress" + if !transition.ImplicitlySelectable() { + selectionReason = "transition requires an explicit request" + } else if targetAlreadySatisfied(snapshot, objective, transition) { + selectionReason = "transition target is already satisfied" + } + trace.Selection = general.EvaluationTrace{Evaluated: true, Satisfied: selectable, Reason: selectionReason} + trace.Selectable, trace.Survived, trace.Disposition = selectable, true, general.DispositionShadowed candidates = append(candidates, general.RelationCandidate{ ID: id, Rank: transition.SelectionClass.Rank(), Priority: transition.Priority, - Selectable: transition.ImplicitlySelectable() && !targetAlreadySatisfied(snapshot, objective, transition), + Selectable: selectable, RequiredAll: all, RequiredAny: any, }) + traces = append(traces, trace) } noCandidate := general.Unresolved if snapshot.Phase.Value == model.PhaseRecovery || snapshot.Phase.Value == model.PhaseUnresolved { noCandidate = general.Blocked } - relation := general.Relate(general.RelationInput{ + relation, relationTraces := general.RelateWithTrace(general.RelationInput{ Requested: string(requested), Marked: marked, NoCandidate: noCandidate, Candidates: candidates, Available: availableAuthority(authority), }) + mergeRelationTrace(traces, relationTraces) base.Kind = mapDecisionKind(relation.Kind) base.Reason = relation.Reason for _, candidate := range relation.Candidates { @@ -129,7 +185,24 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, objective model.Objective, transition := byID[relation.Transition] base.Transition = &transition } - return base + return finish() +} + +func mergeRelationTrace(candidates, relation []general.CandidateTrace) { + byID := make(map[string]general.CandidateTrace, len(relation)) + for _, candidate := range relation { + byID[candidate.TransitionID] = candidate + } + for index := range candidates { + resolved, ok := byID[candidates[index].TransitionID] + if !ok { + continue + } + candidates[index].Selectable = resolved.Selectable + candidates[index].Authority = resolved.Authority + candidates[index].Survived = resolved.Survived + candidates[index].Disposition = resolved.Disposition + } } func mapDecisionKind(kind general.DecisionKind) DecisionKind { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 68c3cdd..50fc15b 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -12,9 +12,10 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 7 +const SchemaVersion = 8 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -22,6 +23,7 @@ type Operation string const ( OperationResolve Operation = "resolve" + OperationExplain Operation = "explain" OperationApply Operation = "apply" OperationRecover Operation = "recover" OperationDoctor Operation = "doctor" @@ -32,7 +34,7 @@ const ( func (o Operation) Valid() bool { switch o { - case OperationResolve, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard: + case OperationResolve, OperationExplain, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard: return true default: return false @@ -109,6 +111,9 @@ func (r Request) Validate(now time.Time) error { return fmt.Errorf("apply/recover transition does not match prescription") } } + if r.Operation == OperationExplain && (r.Prescription.ID != "" || r.IdempotencyKey != "") { + return fmt.Errorf("explain request cannot carry a prescription or idempotency key") + } if r.Operation == OperationGuard && (strings.TrimSpace(r.Command) == "" || len(r.Command) > 1<<20) { return fmt.Errorf("guard operation requires a bounded command") } @@ -159,6 +164,7 @@ type Response struct { Objective model.Objective `json:"objective,omitempty"` Snapshot *model.Snapshot `json:"snapshot,omitempty"` Decision *supervisor.Decision `json:"decision,omitempty"` + Trace *general.DecisionTrace `json:"trace,omitempty"` Question *Question `json:"question,omitempty"` Prescription *protocol.Prescription `json:"prescription,omitempty"` Admission *protocol.Admission `json:"admission,omitempty"` diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index 1e4da28..338a46e 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -31,6 +31,18 @@ func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { } } +func TestExplainRequestRejectsMutationArtifacts(t *testing.T) { + now := time.Now().UTC() + request := Request{SchemaVersion: SchemaVersion, Operation: OperationExplain, Repository: "/repo", Host: "cli", CorrelationID: "explain"} + if err := request.Validate(now); err != nil { + t.Fatal(err) + } + request.IdempotencyKey = "idem-forbidden" + if err := request.Validate(now); err == nil { + t.Fatal("explain accepted an apply idempotency key") + } +} + func TestQuestionSuspendsAndBindsOneRunSnapshot(t *testing.T) { // control-law: human-input-suspension-cannot-cross-run-or-snapshot transition := catalog.Transition{ diff --git a/boatstack/kernel/relation.go b/boatstack/kernel/relation.go index 46e56fe..6f74047 100644 --- a/boatstack/kernel/relation.go +++ b/boatstack/kernel/relation.go @@ -30,8 +30,51 @@ type RelationInput struct { // decide whether a transition satisfies domain predicates; the kernel alone // applies target selection, ordering, ambiguity, and authority. func Relate(input RelationInput) Decision { + decision, _ := relate(input) + return decision +} + +// RelateWithTrace evaluates the same canonical relation as Relate and exposes +// its domain-neutral ordering and authority projection. The trace is an output +// of the selector and is never an input to it. +func RelateWithTrace(input RelationInput) (Decision, []CandidateTrace) { + return relate(input) +} + +func relate(input RelationInput) (Decision, []CandidateTrace) { + traces := make([]CandidateTrace, len(input.Candidates)) + for index, candidate := range input.Candidates { + traces[index] = CandidateTrace{ + TransitionID: candidate.ID, Selectable: candidate.Selectable, + Rank: candidate.Rank, Priority: candidate.Priority, Survived: true, + Disposition: DispositionShadowed, + Authority: authorityTrace(candidate, input.Available), + } + } + finish := func(decision Decision) (Decision, []CandidateTrace) { + for index := range traces { + trace := &traces[index] + if input.Requested == "" && !trace.Selectable { + trace.Survived = false + } + switch { + case input.Requested != "" && trace.TransitionID != input.Requested: + trace.Disposition = DispositionIrrelevantToRequest + trace.Survived = false + case decision.Kind == Prescribed && trace.TransitionID == decision.Transition: + trace.Disposition = DispositionSelected + case decision.Kind == Frontier && decision.Transition == trace.TransitionID: + trace.Disposition = DispositionAuthorityFrontier + case decision.Kind == Frontier && contains(decision.Candidates, trace.TransitionID): + trace.Disposition = DispositionAmbiguous + case decision.Kind == Refused && input.Requested == trace.TransitionID: + trace.Disposition = DispositionExplicitlyRefused + } + } + return decision, traces + } if input.Marked && input.Requested == "" { - return Decision{Kind: Marked, Reason: "program-defined marked state is established"} + return finish(Decision{Kind: Marked, Reason: "program-defined marked state is established"}) } candidates := append([]RelationCandidate(nil), input.Candidates...) if input.Requested != "" { @@ -45,13 +88,13 @@ func Relate(input RelationInput) Decision { } if len(candidates) == 0 { if input.Requested != "" { - return Decision{Kind: Refused, Transition: input.Requested, Reason: "requested transition is not admissible under the canonical relation"} + return finish(Decision{Kind: Refused, Transition: input.Requested, Reason: "requested transition is not admissible under the canonical relation"}) } kind := input.NoCandidate if kind == "" { kind = Unresolved } - return Decision{Kind: kind, Reason: "no transition is admissible under the canonical relation"} + return finish(Decision{Kind: kind, Reason: "no transition is admissible under the canonical relation"}) } if input.Requested == "" { filtered := candidates[:0] @@ -66,7 +109,7 @@ func Relate(input RelationInput) Decision { if kind == "" { kind = Unresolved } - return Decision{Kind: kind, Reason: "no transition is selectable under the canonical relation"} + return finish(Decision{Kind: kind, Reason: "no transition is selectable under the canonical relation"}) } } sort.Slice(candidates, func(i, j int) bool { @@ -86,12 +129,46 @@ func Relate(input RelationInput) Decision { } } if len(equal) > 1 { - return Decision{Kind: Frontier, Candidates: equal, Reason: "equally preferred admissible transitions require selection"} + return finish(Decision{Kind: Frontier, Candidates: equal, Reason: "equally preferred admissible transitions require selection"}) } if missing := missingAuthority(top, input.Available); len(missing) != 0 { - return Decision{Kind: Frontier, Transition: top.ID, Candidates: []string{top.ID}, Reason: fmt.Sprintf("transition requires unavailable capabilities: %v", missing)} + return finish(Decision{Kind: Frontier, Transition: top.ID, Candidates: []string{top.ID}, Reason: fmt.Sprintf("transition requires unavailable capabilities: %v", missing)}) + } + return finish(Decision{Kind: Prescribed, Transition: top.ID, Reason: "canonical relation admitted one highest-priority transition"}) +} + +func authorityTrace(candidate RelationCandidate, available []Capability) AuthorityTrace { + set := make(map[Capability]bool, len(available)) + for _, value := range available { + set[value] = true + } + trace := AuthorityTrace{ + Available: append([]Capability(nil), available...), + RequiredAll: append([]Capability(nil), candidate.RequiredAll...), + RequiredAny: append([]Capability(nil), candidate.RequiredAny...), + AllSatisfied: true, AnySatisfied: len(candidate.RequiredAny) == 0, + } + for _, value := range candidate.RequiredAll { + if !set[value] { + trace.MissingAll = append(trace.MissingAll, value) + trace.AllSatisfied = false + } + } + for _, value := range candidate.RequiredAny { + if set[value] { + trace.AnySatisfied = true + } + } + if !trace.AnySatisfied { + trace.MissingAny = append(trace.MissingAny, candidate.RequiredAny...) } - return Decision{Kind: Prescribed, Transition: top.ID, Reason: "canonical relation admitted one highest-priority transition"} + sort.Slice(trace.Available, func(i, j int) bool { return trace.Available[i] < trace.Available[j] }) + sort.Slice(trace.RequiredAll, func(i, j int) bool { return trace.RequiredAll[i] < trace.RequiredAll[j] }) + sort.Slice(trace.RequiredAny, func(i, j int) bool { return trace.RequiredAny[i] < trace.RequiredAny[j] }) + sort.Slice(trace.MissingAll, func(i, j int) bool { return trace.MissingAll[i] < trace.MissingAll[j] }) + sort.Slice(trace.MissingAny, func(i, j int) bool { return trace.MissingAny[i] < trace.MissingAny[j] }) + trace.Satisfied = trace.AllSatisfied && trace.AnySatisfied + return trace } func missingAuthority(candidate RelationCandidate, available []Capability) []Capability { diff --git a/boatstack/kernel/relation_test.go b/boatstack/kernel/relation_test.go index 79b3e77..bd1713c 100644 --- a/boatstack/kernel/relation_test.go +++ b/boatstack/kernel/relation_test.go @@ -1,6 +1,9 @@ package kernel -import "testing" +import ( + "reflect" + "testing" +) func TestRelationOwnsSelectionAndAuthority(t *testing.T) { candidates := []RelationCandidate{ @@ -31,15 +34,38 @@ func TestRelationTargetedAndUntargetedUseSameCandidates(t *testing.T) { } func TestRelationReportsEqualPreferenceAndMarkedState(t *testing.T) { - tied := Relate(RelationInput{Candidates: []RelationCandidate{ + tied, trace := RelateWithTrace(RelationInput{Candidates: []RelationCandidate{ {ID: "a", Priority: 1, Selectable: true}, {ID: "b", Priority: 1, Selectable: true}, }}) if tied.Kind != Frontier || len(tied.Candidates) != 2 { t.Fatalf("tie = %#v", tied) } + for _, candidate := range trace { + if candidate.Disposition != DispositionAmbiguous { + t.Fatalf("tie trace = %#v", trace) + } + } marked := Relate(RelationInput{Marked: true}) if marked.Kind != Marked { t.Fatalf("marked = %#v", marked) } } + +func TestRelationTracePreservesAnyOfAndAllOfAuthority(t *testing.T) { + decision, trace := RelateWithTrace(RelationInput{ + Candidates: []RelationCandidate{{ + ID: "publish", Priority: 1, Selectable: true, + RequiredAny: []Capability{"authority.autonomy", "authority.human"}, + RequiredAll: []Capability{"authority.external-provider"}, + }}, + Available: []Capability{"authority.autonomy"}, + }) + if decision.Kind != Frontier || len(trace) != 1 { + t.Fatalf("authority frontier = %#v / %#v", decision, trace) + } + authority := trace[0].Authority + if !authority.AnySatisfied || authority.AllSatisfied || authority.Satisfied || len(authority.MissingAny) != 0 || !reflect.DeepEqual(authority.MissingAll, []Capability{"authority.external-provider"}) { + t.Fatalf("authority clauses collapsed: %#v", authority) + } +} diff --git a/boatstack/kernel/runtime.go b/boatstack/kernel/runtime.go index 7134b6b..1fbac80 100644 --- a/boatstack/kernel/runtime.go +++ b/boatstack/kernel/runtime.go @@ -115,10 +115,11 @@ type Prescription struct { } type Resolution struct { - State ControlState `json:"state"` - Observation Observation `json:"observation"` - Decision Decision `json:"decision"` - Prescription *Prescription `json:"prescription,omitempty"` + State ControlState `json:"state"` + Observation Observation `json:"observation"` + Decision Decision `json:"decision"` + Prescription *Prescription `json:"prescription,omitempty"` + Trace *DecisionTrace `json:"trace,omitempty"` } type ResolveRequest struct { @@ -126,6 +127,7 @@ type ResolveRequest struct { Objective *Objective Authority Authority Requested string + Trace bool } type ApplyRequest struct { @@ -192,53 +194,102 @@ func (r Runtime) Resolve(ctx context.Context, request ResolveRequest) (Resolutio func (r Runtime) resolve(ctx context.Context, state ControlState, observation Observation, request ResolveRequest) (Resolution, error) { base := Resolution{State: state, Observation: observation} - if err := state.Validate(); err != nil || observation.Validate() != nil || state.InstanceID != request.InstanceID || state.Program != r.program.Identity() { - base.Decision = Decision{Kind: Unresolved, Reason: "control state, domain observation, instance, or program identity is invalid"} + trace := newDecisionTrace(state, observation, request, r.program.Identity()) + trace.Marked = r.program.Marked(state.Mode) + finish := func(decision Decision) (Resolution, error) { + base.Decision = decision + trace.Decision = TraceDecision(decision) + if request.Trace { + base.Trace = &trace + } return base, nil } + if err := state.Validate(); err != nil { + return finish(Decision{Kind: Unresolved, Reason: "control state is invalid: " + err.Error()}) + } + if err := observation.Validate(); err != nil { + return finish(Decision{Kind: Unresolved, Reason: "domain observation is invalid: " + err.Error()}) + } + if state.InstanceID != request.InstanceID { + return finish(Decision{Kind: Unresolved, Reason: "control state instance identity does not match the request"}) + } + if state.Program != r.program.Identity() { + return finish(Decision{Kind: Unresolved, Reason: "control state program identity is stale for the active program"}) + } if r.program.Marked(state.Mode) && state.Recovery == nil && request.Requested == "" { - base.Decision = Decision{Kind: Marked, Reason: "program-defined marked state is established"} - return base, nil + return finish(Decision{Kind: Marked, Reason: "program-defined marked state is established"}) } authority, err := request.Authority.projection(r.clock.Now()) if err != nil { - base.Decision = Decision{Kind: Refused, Reason: err.Error()} - return base, nil + return finish(Decision{Kind: Refused, Reason: err.Error()}) } + trace.AuthorityFingerprint = authority.Fingerprint transitions := map[string]Transition{} var candidates []RelationCandidate for _, transition := range r.program.Transitions { + candidateTrace := CandidateTrace{TransitionID: transition.ID, Priority: transition.Priority} if request.Requested != "" && transition.ID != request.Requested { + candidateTrace.Disposition = DispositionIrrelevantToRequest + trace.Candidates = append(trace.Candidates, candidateTrace) continue } if !contains(transition.SourceModes, state.Mode) { + candidateTrace.SourceMode = EvaluationTrace{Evaluated: true, Reason: fmt.Sprintf("current mode %q is not a declared source mode", state.Mode)} + candidateTrace.Disposition = DispositionSourceModeRejected + trace.Candidates = append(trace.Candidates, candidateTrace) continue } + candidateTrace.SourceMode = EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "current mode is a declared source mode"} if state.Recovery != nil && !contains(transition.Recovers, state.Recovery.TransitionID) { + candidateTrace.RecoveryCompatible = EvaluationTrace{Evaluated: true, Reason: fmt.Sprintf("transition does not recover %q", state.Recovery.TransitionID)} + candidateTrace.Disposition = DispositionRecoveryRejected + trace.Candidates = append(trace.Candidates, candidateTrace) continue } if state.Recovery == nil && len(transition.Recovers) != 0 { + candidateTrace.RecoveryCompatible = EvaluationTrace{Evaluated: true, Reason: "recovery transition requires an active recovery state"} + candidateTrace.Disposition = DispositionRecoveryRejected + trace.Candidates = append(trace.Candidates, candidateTrace) continue } + candidateTrace.RecoveryCompatible = EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "recovery state is compatible"} objective, reason := objectiveFor(state, request.Objective, transition) if reason != "" { + candidateTrace.ObjectiveScope = EvaluationTrace{Evaluated: true, Reason: reason} + candidateTrace.ObjectiveMutation = EvaluationTrace{Evaluated: true, Reason: reason} + candidateTrace.Disposition = DispositionObjectiveRejected + trace.Candidates = append(trace.Candidates, candidateTrace) continue } - allowed, _, evaluationErr := r.domain.Admissible(ctx, Evaluation{State: state, Observation: observation, Objective: objective, Transition: transition}) + candidateTrace.ObjectiveScope = EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "objective scope is satisfied"} + candidateTrace.ObjectiveMutation = EvaluationTrace{Evaluated: true, Satisfied: true, Reason: "objective mutation is admissible"} + allowed, domainReason, evaluationErr := r.domain.Admissible(ctx, Evaluation{State: state, Observation: observation, Objective: objective, Transition: transition}) if evaluationErr != nil { return Resolution{}, evaluationErr } - if allowed { - required, capabilityErr := requiredCapabilities(r.classifier, transition) - if capabilityErr != nil { - return Resolution{}, capabilityErr - } - transitions[transition.ID] = transition - candidates = append(candidates, RelationCandidate{ID: transition.ID, Priority: transition.Priority, Selectable: true, RequiredAll: required}) + candidateTrace.DomainAdmissible = EvaluationTrace{Evaluated: true, Satisfied: allowed, Reason: boundedTraceReason(domainReason)} + if !allowed { + candidateTrace.Disposition = DispositionDomainRejected + trace.Candidates = append(trace.Candidates, candidateTrace) + continue } - } - base.Decision = Relate(RelationInput{Requested: request.Requested, Candidates: candidates, Available: authority.Capabilities}) + required, capabilityErr := requiredCapabilities(r.classifier, transition) + if capabilityErr != nil { + return Resolution{}, capabilityErr + } + candidateTrace.Selectable, candidateTrace.Survived = true, true + transitions[transition.ID] = transition + candidates = append(candidates, RelationCandidate{ID: transition.ID, Priority: transition.Priority, Selectable: true, RequiredAll: required}) + trace.Candidates = append(trace.Candidates, candidateTrace) + } + var relationTraces []CandidateTrace + base.Decision, relationTraces = RelateWithTrace(RelationInput{Requested: request.Requested, Candidates: candidates, Available: authority.Capabilities}) + mergeRelationTraces(trace.Candidates, relationTraces) + trace.Decision = TraceDecision(base.Decision) if base.Decision.Kind != Prescribed { + if request.Trace { + base.Trace = &trace + } return base, nil } top := transitions[base.Decision.Transition] @@ -252,9 +303,59 @@ func (r Runtime) resolve(ctx context.Context, state ControlState, observation Ob return Resolution{}, err } base.Prescription = &prescription + if request.Trace { + base.Trace = &trace + } return base, nil } +func newDecisionTrace(state ControlState, observation Observation, request ResolveRequest, program ProgramIdentity) DecisionTrace { + stateProgram := state.Program + trace := DecisionTrace{ + SchemaVersion: DecisionTraceSchemaVersion, InstanceID: state.InstanceID, + StateRevision: state.Revision, CurrentMode: state.Mode, Program: program, StateProgram: &stateProgram, + ObservationFingerprint: observation.Fingerprint, RequestedTransition: request.Requested, + Marked: false, + } + if state.ObjectiveBinding != nil { + trace.Objective.Binding = &ObjectiveIdentity{ID: state.ObjectiveBinding.ObjectiveID, Revision: state.ObjectiveBinding.ObjectiveRevision, Fingerprint: state.ObjectiveBinding.ObjectiveFingerprint} + } + if request.Objective != nil { + trace.Objective.Requested = &ObjectiveIdentity{ID: request.Objective.ID, Revision: request.Objective.Revision, Fingerprint: request.Objective.Fingerprint} + } + if state.Recovery != nil { + trace.Recovery = &RecoveryTrace{Active: true, PrescriptionID: state.Recovery.PrescriptionID, TransitionID: state.Recovery.TransitionID, Reason: state.Recovery.Reason} + } + return trace +} + +func boundedTraceReason(reason string) string { + const limit = 1024 + if len(reason) <= limit { + return reason + } + return reason[:limit] +} + +func mergeRelationTraces(candidates, relation []CandidateTrace) { + byID := make(map[string]CandidateTrace, len(relation)) + for _, candidate := range relation { + byID[candidate.TransitionID] = candidate + } + for index := range candidates { + resolved, ok := byID[candidates[index].TransitionID] + if !ok { + continue + } + candidates[index].Selectable = resolved.Selectable + candidates[index].Rank = resolved.Rank + candidates[index].Priority = resolved.Priority + candidates[index].Authority = resolved.Authority + candidates[index].Survived = resolved.Survived + candidates[index].Disposition = resolved.Disposition + } +} + func (r Runtime) Apply(ctx context.Context, request ApplyRequest) (Receipt, error) { lock, err := r.locker.Acquire(ctx, request.InstanceID) if err != nil { diff --git a/boatstack/kernel/runtime_test.go b/boatstack/kernel/runtime_test.go index bb4ef79..5459a8d 100644 --- a/boatstack/kernel/runtime_test.go +++ b/boatstack/kernel/runtime_test.go @@ -1,11 +1,171 @@ package kernel_test import ( + "context" + "encoding/json" + "reflect" + "strings" "testing" + "github.com/operatorstack/boatstack/boatstack/kernel" "github.com/operatorstack/boatstack/boatstack/kernel/conformance" ) func TestRuntimeConformance(t *testing.T) { conformance.IntegerFixture().Run(t) } + +func TestResolveTraceIsNonInterferingForNonSoftwareDomain(t *testing.T) { + // control-law: controller-observation-cannot-change-controller + fixture := conformance.IntegerFixture() + runtime, err := kernel.NewRuntime(fixture.Program, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + request := kernel.ResolveRequest{InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, Authority: fixture.Scenario.Authority} + before := fixture.Scenario.Snapshot() + plain, err := runtime.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.Trace = true + traced, err := runtime.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + repeated, err := runtime.Resolve(context.Background(), request) + if err != nil || repeated.Trace == nil || !reflect.DeepEqual(traced.Trace.Candidates, repeated.Trace.Candidates) { + t.Fatalf("trace order is not deterministic: first=%#v repeated=%#v err=%v", traced.Trace, repeated.Trace, err) + } + after := fixture.Scenario.Snapshot() + if !reflect.DeepEqual(plain.Decision, traced.Decision) { + t.Fatalf("trace changed decision: %#v != %#v", plain.Decision, traced.Decision) + } + plainPrescription, _ := json.Marshal(plain.Prescription) + tracedPrescription, _ := json.Marshal(traced.Prescription) + if string(plainPrescription) != string(tracedPrescription) { + t.Fatalf("trace changed prescription bytes: %s != %s", plainPrescription, tracedPrescription) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("explain mutated state, effects, or receipts: before=%#v after=%#v", before, after) + } + if traced.Trace == nil || traced.Trace.Decision.Kind != string(kernel.Prescribed) || traced.Trace.Decision.Transition != fixture.Scenario.BindTransition { + t.Fatalf("trace does not identify prescribed transition: %#v", traced.Trace) + } + var selected *kernel.CandidateTrace + for index := range traced.Trace.Candidates { + candidate := &traced.Trace.Candidates[index] + if candidate.TransitionID == fixture.Scenario.BindTransition { + selected = candidate + } + } + if selected == nil || selected.Disposition != kernel.DispositionSelected || !selected.DomainAdmissible.Satisfied || !strings.Contains(selected.DomainAdmissible.Reason, "not yet bound") { + t.Fatalf("domain reason was not preserved: %#v", selected) + } +} + +func TestRequestedInadmissibleTransitionTraceNamesSourceRejection(t *testing.T) { + fixture := conformance.IntegerFixture() + runtime, err := kernel.NewRuntime(fixture.Program, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + resolution, err := runtime.Resolve(context.Background(), kernel.ResolveRequest{ + InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, + Authority: fixture.Scenario.Authority, Requested: fixture.Scenario.AdvanceTransitions[0], Trace: true, + }) + if err != nil { + t.Fatal(err) + } + if resolution.Decision.Kind != kernel.Refused || resolution.Trace == nil { + t.Fatalf("requested rejection = %#v", resolution) + } + for _, candidate := range resolution.Trace.Candidates { + if candidate.TransitionID == fixture.Scenario.AdvanceTransitions[0] { + if candidate.Disposition != kernel.DispositionSourceModeRejected || candidate.SourceMode.Satisfied { + t.Fatalf("source rejection trace = %#v", candidate) + } + return + } + } + t.Fatal("requested candidate is absent from trace") +} + +func TestTraceExplainsObjectiveMismatchRecoveryAndMarkedState(t *testing.T) { + fixture := conformance.IntegerFixture().New(t, conformance.SetupMaintenanceBound) + runtime, err := kernel.NewRuntime(fixture.Program, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + advance := fixture.Scenario.AdvanceTransitions[1] + mismatch, err := runtime.Resolve(context.Background(), kernel.ResolveRequest{ + InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.ConflictingObjective, + Authority: fixture.Scenario.Authority, Requested: advance, Trace: true, + }) + if err != nil || mismatch.Trace == nil || mismatch.Decision.Kind != kernel.Refused { + t.Fatalf("objective mismatch = %#v, %v", mismatch, err) + } + assertDisposition(t, mismatch.Trace, advance, kernel.DispositionObjectiveRejected) + + request := kernel.ResolveRequest{InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, Authority: fixture.Scenario.Authority, Requested: advance} + prescribed, err := runtime.Resolve(context.Background(), request) + if err != nil || prescribed.Prescription == nil { + t.Fatalf("prescribe interrupted transition: %#v, %v", prescribed, err) + } + fixture.Scenario.InterruptNextOperator() + if _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: *prescribed.Prescription}); !kernel.IsRecoveryRequired(err) { + t.Fatalf("interrupted apply = %v", err) + } + recovery, err := runtime.Resolve(context.Background(), kernel.ResolveRequest{InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, Authority: fixture.Scenario.Authority, Trace: true}) + if err != nil || recovery.Trace == nil || recovery.Trace.Recovery == nil || !recovery.Trace.Recovery.Active || recovery.Decision.Transition != fixture.Scenario.RecoveryTransition { + t.Fatalf("recovery trace = %#v, %v", recovery, err) + } + assertDisposition(t, recovery.Trace, advance, kernel.DispositionRecoveryRejected) + + markedFixture := conformance.IntegerFixture().New(t, conformance.SetupMaintenanceBound) + markedRuntime, _ := kernel.NewRuntime(markedFixture.Program, markedFixture.Domain, markedFixture.Operator, markedFixture.CapabilityClassifier, markedFixture.Store, markedFixture.Locker, markedFixture.Clock) + markedRequest := kernel.ResolveRequest{InstanceID: markedFixture.Scenario.InstanceID, Objective: &markedFixture.Scenario.Objective, Authority: markedFixture.Scenario.Authority, Requested: markedFixture.Scenario.AdvanceTransitions[1]} + markedPrescription, err := markedRuntime.Resolve(context.Background(), markedRequest) + if err != nil || markedPrescription.Prescription == nil { + t.Fatalf("marked setup resolve = %#v, %v", markedPrescription, err) + } + if _, err := markedRuntime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: markedRequest, Prescription: *markedPrescription.Prescription}); err != nil { + t.Fatal(err) + } + marked, err := markedRuntime.Resolve(context.Background(), kernel.ResolveRequest{InstanceID: markedFixture.Scenario.InstanceID, Objective: &markedFixture.Scenario.Objective, Authority: markedFixture.Scenario.Authority, Trace: true}) + if err != nil || marked.Decision.Kind != kernel.Marked || marked.Trace == nil || !marked.Trace.Marked { + t.Fatalf("marked trace = %#v, %v", marked, err) + } +} + +func TestTraceExplainsStaleControlProgramIdentity(t *testing.T) { + fixture := conformance.IntegerFixture() + runtime, err := kernel.NewRuntime(fixture.Program, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + fixture.Scenario.RetargetProgram(fixture.Scenario.AlternateProgram.Identity()) + resolution, err := runtime.Resolve(context.Background(), kernel.ResolveRequest{ + InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, + Authority: fixture.Scenario.Authority, Trace: true, + }) + if err != nil || resolution.Trace == nil || resolution.Decision.Kind != kernel.Unresolved || !strings.Contains(resolution.Decision.Reason, "program identity is stale") { + t.Fatalf("stale program trace = %#v, %v", resolution, err) + } + if resolution.Trace.Program != fixture.Program.Identity() || resolution.Trace.StateProgram == nil || *resolution.Trace.StateProgram != fixture.Scenario.AlternateProgram.Identity() { + t.Fatalf("stale program identities = %#v", resolution.Trace) + } +} + +func assertDisposition(t *testing.T, trace *kernel.DecisionTrace, transition string, disposition kernel.CandidateDisposition) { + t.Helper() + for _, candidate := range trace.Candidates { + if candidate.TransitionID == transition { + if candidate.Disposition != disposition { + t.Fatalf("%s disposition = %s, want %s", transition, candidate.Disposition, disposition) + } + return + } + } + t.Fatalf("trace does not contain %s", transition) +} diff --git a/boatstack/kernel/trace.go b/boatstack/kernel/trace.go new file mode 100644 index 0000000..f8a3d7e --- /dev/null +++ b/boatstack/kernel/trace.go @@ -0,0 +1,104 @@ +package kernel + +// DecisionTrace is a read-only projection of the exact evaluation that fed +// the canonical relation. It contains identities and bounded reasons, never +// domain observation payloads or objective references. +type DecisionTrace struct { + SchemaVersion int `json:"schema_version"` + InstanceID string `json:"instance_id"` + StateRevision uint64 `json:"state_revision"` + CurrentMode string `json:"current_mode"` + Program ProgramIdentity `json:"program"` + StateProgram *ProgramIdentity `json:"state_program,omitempty"` + ObservationFingerprint string `json:"observation_fingerprint"` + Objective ObjectiveTrace `json:"objective"` + AuthorityFingerprint string `json:"authority_fingerprint,omitempty"` + RequestedTransition string `json:"requested_transition,omitempty"` + Marked bool `json:"marked"` + Recovery *RecoveryTrace `json:"recovery,omitempty"` + Decision DecisionTraceValue `json:"decision"` + Candidates []CandidateTrace `json:"candidates,omitempty"` +} + +const DecisionTraceSchemaVersion = 1 + +type DecisionTraceValue struct { + Kind string `json:"kind"` + Transition string `json:"transition,omitempty"` + Candidates []string `json:"candidates,omitempty"` + Reason string `json:"reason"` +} + +func TraceDecision(decision Decision) DecisionTraceValue { + return DecisionTraceValue{ + Kind: string(decision.Kind), Transition: decision.Transition, + Candidates: append([]string(nil), decision.Candidates...), Reason: decision.Reason, + } +} + +type ObjectiveTrace struct { + Binding *ObjectiveIdentity `json:"binding,omitempty"` + Requested *ObjectiveIdentity `json:"requested,omitempty"` +} + +type ObjectiveIdentity struct { + ID string `json:"id"` + Revision uint64 `json:"revision"` + Fingerprint string `json:"fingerprint"` +} + +type RecoveryTrace struct { + Active bool `json:"active"` + PrescriptionID string `json:"prescription_id,omitempty"` + TransitionID string `json:"transition_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type EvaluationTrace struct { + Evaluated bool `json:"evaluated"` + Satisfied bool `json:"satisfied"` + Reason string `json:"reason,omitempty"` +} + +type CandidateDisposition string + +const ( + DispositionSelected CandidateDisposition = "selected" + DispositionSourceModeRejected CandidateDisposition = "source-mode-rejected" + DispositionRecoveryRejected CandidateDisposition = "recovery-rejected" + DispositionObjectiveRejected CandidateDisposition = "objective-rejected" + DispositionDomainRejected CandidateDisposition = "domain-rejected" + DispositionAuthorityFrontier CandidateDisposition = "authority-frontier" + DispositionShadowed CandidateDisposition = "shadowed" + DispositionAmbiguous CandidateDisposition = "ambiguous" + DispositionExplicitlyRefused CandidateDisposition = "explicitly-refused" + DispositionIrrelevantToRequest CandidateDisposition = "irrelevant-to-request" +) + +type CandidateTrace struct { + TransitionID string `json:"transition_id"` + SourceMode EvaluationTrace `json:"source_mode"` + RecoveryCompatible EvaluationTrace `json:"recovery_compatible"` + ObjectiveScope EvaluationTrace `json:"objective_scope"` + ObjectiveMutation EvaluationTrace `json:"objective_mutation"` + DomainAdmissible EvaluationTrace `json:"domain_admissible"` + Selection EvaluationTrace `json:"selection"` + Selectable bool `json:"selectable"` + SelectionClass string `json:"selection_class,omitempty"` + Rank int `json:"rank"` + Priority int `json:"priority"` + Authority AuthorityTrace `json:"authority"` + Survived bool `json:"survived"` + Disposition CandidateDisposition `json:"disposition"` +} + +type AuthorityTrace struct { + Available []Capability `json:"available,omitempty"` + RequiredAll []Capability `json:"required_all,omitempty"` + RequiredAny []Capability `json:"required_any,omitempty"` + MissingAll []Capability `json:"missing_all,omitempty"` + MissingAny []Capability `json:"missing_any,omitempty"` + AllSatisfied bool `json:"all_satisfied"` + AnySatisfied bool `json:"any_satisfied"` + Satisfied bool `json:"satisfied"` +} diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index 7e2022a..1c9764e 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -15,6 +15,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) const SchemaVersion = surfaces.SchemaVersion @@ -23,6 +24,7 @@ type Operation = surfaces.Operation const ( OperationResolve = surfaces.OperationResolve + OperationExplain = surfaces.OperationExplain OperationApply = surfaces.OperationApply OperationRecover = surfaces.OperationRecover OperationDoctor = surfaces.OperationDoctor @@ -99,6 +101,9 @@ const ( type Decision = supervisor.Decision type DecisionKind = supervisor.DecisionKind type GuardDecision = supervisor.GuardDecision +type DecisionTrace = general.DecisionTrace +type CandidateTrace = general.CandidateTrace +type AuthorityTrace = general.AuthorityTrace const ( DecisionPrescribed = supervisor.DecisionPrescribed diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index b8d9b61..1d5d0e1 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -24,6 +24,13 @@ func TestPublicProtocolCanBeConstructedWithoutInternalPackages(t *testing.T) { } } +func TestExplainIsPublicReadOnlyOperation(t *testing.T) { + request := sdk.Request{SchemaVersion: sdk.SchemaVersion, Operation: sdk.OperationExplain, Repository: "/repo", Host: sdk.HostIdentity, CorrelationID: "explain"} + if request.Operation != sdk.OperationExplain { + t.Fatalf("explain operation = %q", request.Operation) + } +} + func TestSDKPreservesCapabilityAdmissionProtocol(t *testing.T) { // control-law: SDK and CLI consume the same versioned prescription fields raw := []byte(`{"schema_version":5,"operation":"apply","repository":"/repo","host":"sdk","correlation_id":"correlation","flow_id":"flow","transition_id":"program/write","prescription":{"schema_version":3,"id":"prx-test","transition_id":"program/write","expected_state_revision":7,"expected_program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expected_snapshot_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","expected_objective_binding_fingerprint":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","authority_fingerprint":"auth-test","required_capabilities":["repository.write"],"effective_capabilities":["repository.write"]}}`) diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 81de4d7..8d00baa 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,6 +1,6 @@ { "schema": "control-program", - "schema_revision": 1, + "schema_revision": 2, "program": { "id": "incident-response", "version": "1" diff --git a/boatstack/testdata/control-programs/product-delivery-c.flow.ts b/boatstack/testdata/control-programs/product-delivery-c.flow.ts index 12219ae..438db2f 100644 --- a/boatstack/testdata/control-programs/product-delivery-c.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-c.flow.ts @@ -32,5 +32,10 @@ export default defineFlow({ fact("runtime", ["verified"]), fact("publication", ["open"]), ))], - entries: [entry({ id: "run", target: "published-pr", inputs: [inbox(".boatstack/plans/inbox")] })], + entries: [entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + diagnostics: { explain_on_suspend: true }, + })], }); diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index 07f54dc..a2bb784 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,7 +6,7 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema at revision `1` is domain-neutral. It declares typed facets, +The `control-program` schema at revision `2` is domain-neutral. It declares typed facets, evidence relations, predicate ASTs, operators, capabilities, authority, effects, verification, recovery, transitions, marked targets, and entries. Software terms such as plans, tests, Git, and pull requests belong to @@ -41,6 +41,23 @@ boatstack flow run --repo . --flow product-delivery --entry run --run-id --human ``` +An entry may opt its generated Codex and Claude projections into factual +diagnosis when a run suspends: + +```ts +entry({ + id: "run", + target: "published-pr", + diagnostics: { explain_on_suspend: true }, +}) +``` + +This setting changes generated-agent UX, not executable control semantics. It +is excluded from the executable program fingerprint while the source, +artifact, and generated-skill hashes still bind it. The generated skill calls +`boatstack explain` with the same run context and treats the explanation as +evidence, never as authority. + Compilation sends the exact source bytes to a restricted TypeScript frontend. The frontend path is explicit authority: Boatstack never selects or executes a repository `node_modules/.bin` program automatically. diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 91563ce..e1367db 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -1,5 +1,5 @@ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; -export const CONTROL_PROGRAM_SCHEMA_REVISION = 1 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 2 as const; export type Predicate = | { true: boolean } @@ -93,6 +93,7 @@ export interface EntryDefinition { target: string; inputs?: EntryInputDefinition[]; delegation?: DelegationBindingDefinition; + diagnostics?: { explain_on_suspend?: boolean }; description?: string; } diff --git a/release-notes/2026-08-14-control-debugger.md b/release-notes/2026-08-14-control-debugger.md new file mode 100644 index 0000000..70a97f3 --- /dev/null +++ b/release-notes/2026-08-14-control-debugger.md @@ -0,0 +1,2 @@ +### Add read-only control explanations +Boatstack can now explain the exact current controller decision, including rejected predicates and missing authority, without executing an effect. Flow entries may opt generated Codex and Claude skills into this diagnosis when a run suspends. From 20fc05f7d98d7143c04ec0473065793bc786729b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 09:58:41 +0100 Subject: [PATCH 2/4] test: avoid platform pipe deadlock --- .../cmd/boatstack-helper/flow_runtime_test.go | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index bccaa4e..417d50a 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -64,16 +64,41 @@ func captureStdout(t *testing.T, action func() error) ([]byte, error) { if err != nil { t.Fatal(err) } + type captureResult struct { + output []byte + err error + } + captured := make(chan captureResult, 1) + go func() { + output, readErr := io.ReadAll(readSide) + captured <- captureResult{output: output, err: readErr} + }() os.Stdout = writeSide runErr := action() - _ = writeSide.Close() os.Stdout = oldStdout - output, readErr := io.ReadAll(readSide) + if err := writeSide.Close(); err != nil { + t.Fatal(err) + } + result := <-captured _ = readSide.Close() - if readErr != nil { - t.Fatal(readErr) + if result.err != nil { + t.Fatal(result.err) + } + return result.output, runErr +} + +func TestCaptureStdoutDrainsWhileActionWrites(t *testing.T) { + payload := bytes.Repeat([]byte("x"), 1<<20) + output, err := captureStdout(t, func() error { + _, writeErr := os.Stdout.Write(payload) + return writeErr + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(output, payload) { + t.Fatalf("captured %d bytes, want %d", len(output), len(payload)) } - return output, runErr } func TestExplanationTextPreservesAuthorityAlgebra(t *testing.T) { From 8698dd708fc572a670aa811cf58aa5c5af9384b0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 10:47:37 +0100 Subject: [PATCH 3/4] fix: bind delegation authority to durable records --- .../boatstack-helper/delegation_runtime.go | 14 ++++---- .../cmd/boatstack-helper/flow_runtime_test.go | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index cf64cee..9cc829a 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -55,6 +55,13 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, err } + filtered := request.Authority.Receipts[:0] + for _, receipt := range request.Authority.Receipts { + if len(receipt.ID) < len("delegation-") || receipt.ID[:len("delegation-")] != "delegation-" { + filtered = append(filtered, receipt) + } + } + request.Authority.Receipts = filtered record, err := delegation.Load(recordPath) if os.IsNotExist(err) { releaseOnError() @@ -85,13 +92,6 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_CONTEXT_UNAUTHORIZED: current worktree is not in the verified run lineage") } - filtered := request.Authority.Receipts[:0] - for _, receipt := range request.Authority.Receipts { - if len(receipt.ID) < len("delegation-") || receipt.ID[:len("delegation-")] != "delegation-" { - filtered = append(filtered, receipt) - } - } - request.Authority.Receipts = filtered if record.Status == "completed" && (request.Operation == surfaces.OperationResolve || request.Operation == surfaces.OperationExplain) { // A completed delegation carries no authority, but resolving the exact // bound run remains safe and lets restarts replay its terminal state. diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 417d50a..fe3e2c6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1410,6 +1410,42 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("authorized resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) } + var replayedReceipt protocol.AuthorityReceipt + for _, receipt := range request.Authority.Receipts { + if strings.HasPrefix(receipt.ID, "delegation-") { + replayedReceipt = receipt + break + } + } + if replayedReceipt.ID == "" { + t.Fatal("authorized resolve did not materialize a delegation receipt") + } + if err := os.Remove(recordPath); err != nil { + t.Fatal(err) + } + replayedExplain, err := buildRequest(surfaces.OperationExplain, bound) + if err != nil { + t.Fatal(err) + } + replayedExplain.Authority.Receipts = append(replayedExplain.Authority.Receipts, replayedReceipt) + replayedLock, replayedSuspension, err := prepareDelegation(context.Background(), &replayedExplain) + if err != nil || replayedLock != nil || replayedSuspension != nil || replayedExplain.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("missing-record explain replayed delegation authority: lock=%v response=%#v authority=%#v err=%v", replayedLock, replayedSuspension, replayedExplain.Authority, err) + } + replayedKernel, err := standardKernel(context.Background(), replayedExplain) + if err != nil { + t.Fatal(err) + } + replayedResponse, err := replayedKernel.Handle(context.Background(), replayedExplain) + if err != nil || replayedResponse.Decision == nil || replayedResponse.Decision.Kind != supervisor.DecisionFrontier { + t.Fatalf("missing-record explain decision = %#v, err=%v", replayedResponse.Decision, err) + } + if _, err := os.Stat(recordPath); !os.IsNotExist(err) { + t.Fatalf("missing-record explain recreated delegation: %v", err) + } + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + t.Fatal(err) + } record.ExpiresAt = now.Add(-time.Second) if err := effects.StoreDelegationRecord(recordPath, record); err != nil { t.Fatal(err) From 7cbde2a74493cd1b23c153ae70a32c547150ea27 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Fri, 14 Aug 2026 11:13:42 +0100 Subject: [PATCH 4/4] fix: preserve debugger trace fidelity --- .../cmd/boatstack-helper/flow_runtime_test.go | 42 +++++++++++++++ boatstack/cmd/boatstack-helper/main.go | 28 ++++++++-- .../softwaredelivery/engine/engine.go | 10 ++-- .../softwaredelivery/engine/engine_test.go | 51 +++++++++++++++++++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index fe3e2c6..1fcac2f 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -132,6 +132,48 @@ func TestExplanationTextPreservesAuthorityAlgebra(t *testing.T) { } } +func TestExplanationTextUsesAuthoritativeCandidateOutcomes(t *testing.T) { + tests := []struct { + name string + decision kernel.DecisionTraceValue + candidates []kernel.CandidateTrace + want string + }{ + { + name: "ambiguity", + decision: kernel.DecisionTraceValue{Kind: string(supervisor.DecisionFrontier), Candidates: []string{"one", "two"}, Reason: "multiple candidates remain ambiguous"}, + candidates: []kernel.CandidateTrace{ + {TransitionID: "one", Disposition: kernel.DispositionAmbiguous}, + {TransitionID: "two", Disposition: kernel.DispositionAmbiguous}, + }, + want: "candidate is part of an unresolved canonical ambiguity", + }, + { + name: "selected candidate refused by preflight", + decision: kernel.DecisionTraceValue{Kind: string(supervisor.DecisionUnresolved), Reason: "transition \"one\" failed deterministic effect preflight: malformed artifact"}, + candidates: []kernel.CandidateTrace{ + {TransitionID: "one", Disposition: kernel.DispositionSelected}, + }, + want: "failed deterministic effect preflight: malformed artifact", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := surfaces.Response{Operation: surfaces.OperationExplain, Trace: &kernel.DecisionTrace{ + StateRevision: 1, CurrentMode: string(model.PhaseObserved), Decision: test.decision, Candidates: test.candidates, + }} + output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) + if err != nil { + t.Fatal(err) + } + text := string(output) + if !strings.Contains(text, test.want) || strings.Contains(text, "another canonical candidate was preferred") { + t.Fatalf("candidate explanation is not outcome-bound:\n%s", text) + } + }) + } +} + func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory string) { t.Helper() if err := os.Remove(filepath.Join(repository, ".git")); err != nil { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index e90c772..798acf8 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -855,7 +855,7 @@ func renderExplanation(response surfaces.Response) error { if len(others) != 0 { fmt.Println("\nOther candidates:") for _, candidate := range others { - reason := candidateReason(candidate) + reason := candidateReason(candidate, trace.Decision) fmt.Printf(" %s\n %s: %s\n", candidate.TransitionID, candidate.Disposition, reason) } } @@ -863,16 +863,34 @@ func renderExplanation(response surfaces.Response) error { return nil } -func candidateReason(candidate general.CandidateTrace) string { +func candidateReason(candidate general.CandidateTrace, decision general.DecisionTraceValue) string { + switch candidate.Disposition { + case general.DispositionAuthorityFrontier: + return "required authority is missing" + case general.DispositionAmbiguous: + return "candidate is part of an unresolved canonical ambiguity" + case general.DispositionSelected: + if decision.Reason != "" { + return decision.Reason + } + return "candidate was selected by the canonical relation" + case general.DispositionExplicitlyRefused: + if decision.Reason != "" { + return decision.Reason + } + return "candidate was explicitly refused" + } for _, evaluation := range []general.EvaluationTrace{candidate.SourceMode, candidate.RecoveryCompatible, candidate.ObjectiveScope, candidate.ObjectiveMutation, candidate.DomainAdmissible, candidate.Selection} { if evaluation.Evaluated && !evaluation.Satisfied && evaluation.Reason != "" { return evaluation.Reason } } - if candidate.Disposition == general.DispositionAuthorityFrontier { - return "required authority is missing" + switch candidate.Disposition { + case general.DispositionShadowed: + return "another canonical candidate was preferred" + default: + return "candidate was not selected" } - return "another canonical candidate was preferred" } func renderCapabilities(values []general.Capability) string { diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index ef4aa80..6dc1486 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -98,7 +98,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution if fingerprintErr != nil { return Resolution{}, fingerprintErr } - decisionTrace = e.decisionTrace(snapshot, objective, authorityFingerprint, request.Requested, decision, candidateTraces) + decisionTrace = e.decisionTrace(snapshot, request.Objective, authorityFingerprint, request.Requested, decision, candidateTraces) } if decision.Kind == supervisor.DecisionPrescribed && decision.Transition != nil { objective, err = protocol.ObjectiveForTransition(snapshot, objective, *decision.Transition) @@ -155,7 +155,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } -func (e Engine) decisionTrace(snapshot model.Snapshot, objective model.Objective, authorityFingerprint string, requested catalog.TransitionID, decision supervisor.Decision, candidates []general.CandidateTrace) *general.DecisionTrace { +func (e Engine) decisionTrace(snapshot model.Snapshot, requestedObjective model.Objective, authorityFingerprint string, requested catalog.TransitionID, decision supervisor.Decision, candidates []general.CandidateTrace) *general.DecisionTrace { trace := &general.DecisionTrace{ SchemaVersion: general.DecisionTraceSchemaVersion, InstanceID: snapshot.Invocation.RepositoryID, StateRevision: snapshot.StateRevision, CurrentMode: string(snapshot.Phase.Value), @@ -168,9 +168,9 @@ func (e Engine) decisionTrace(snapshot model.Snapshot, objective model.Objective fingerprint, _ := general.Fingerprint(snapshot.Objective.Value) trace.Objective.Binding = &general.ObjectiveIdentity{ID: snapshot.Objective.Value.ID, Fingerprint: fingerprint} } - if objective.Validate() == nil { - fingerprint, _ := general.Fingerprint(objective) - trace.Objective.Requested = &general.ObjectiveIdentity{ID: objective.ID, Fingerprint: fingerprint} + if requestedObjective.Validate() == nil { + fingerprint, _ := general.Fingerprint(requestedObjective) + trace.Objective.Requested = &general.ObjectiveIdentity{ID: requestedObjective.ID, Fingerprint: fingerprint} } if snapshot.Phase.Value == model.PhaseRecovery || (snapshot.Recovery.Status == model.FactKnown && snapshot.Recovery.Value != model.RecoveryNone) { recovery := &general.RecoveryTrace{Active: true, Reason: "software-delivery recovery state is " + string(snapshot.Recovery.Value)} diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 667651f..5ee2b7b 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -584,6 +584,57 @@ func TestEngineTraceLeavesDecisionPrescriptionAndEffectsUnchanged(t *testing.T) } } +func TestEngineTracePreservesCallerObjectiveIdentity(t *testing.T) { + now := time.Unix(30, 0).UTC() + + t.Run("absent request remains absent", func(t *testing.T) { + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}} + kernel, err := New(testRegistry(t), syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + request := request(t, now).ResolveRequest + request.Objective = model.Objective{} + request.Trace = true + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if resolution.Trace == nil || resolution.Trace.Objective.Binding == nil || resolution.Trace.Objective.Requested != nil { + t.Fatalf("objective trace = %#v, want durable binding and no requested objective", resolution.Trace) + } + }) + + t.Run("optional preserve retains ignored request", func(t *testing.T) { + transitions := testRegistry(t).All() + for index := range transitions { + if transitions[index].ID == "test.advance" { + transitions[index].Policy.ObjectiveScope = catalog.ObjectiveScopeOptionalPreserve + } + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source")}} + kernel, err := New(registry, syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + requested := model.Objective{ID: "caller-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "caller-delivery"} + request := request(t, now).ResolveRequest + request.Objective = requested + request.Trace = true + resolution, err := kernel.Resolve(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if resolution.Objective.ID != "objective" || resolution.Trace == nil || resolution.Trace.Objective.Binding == nil || resolution.Trace.Objective.Binding.ID != "objective" || resolution.Trace.Objective.Requested == nil || resolution.Trace.Objective.Requested.ID != requested.ID { + t.Fatalf("objective resolution lost binding/request separation: resolution=%#v", resolution) + } + }) +} + func TestEngineTraceExplainsRecoveryAndTargetReached(t *testing.T) { now := time.Unix(30, 0).UTC() t.Run("recovery", func(t *testing.T) {