From ddbc2f4f963bbeb1025dcdcb5f8962dfaa3d48ee Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 23:59:06 +0100 Subject: [PATCH 01/14] Generalize accepted work packages --- .../cmd/boatstack-helper/flow_command.go | 6 +- .../planning_package_command_test.go | 69 ---- .../product_delivery_flow_e2e_test.go | 14 +- ...age_command.go => work_package_command.go} | 57 ++- .../work_package_command_test.go | 69 ++++ .../frontend_conformance_test.go | 17 +- boatstack/flow/softwaredelivery/bindings.go | 14 +- boatstack/flow/softwaredelivery/definition.go | 37 +- .../softwaredelivery/parameter_runtime.go | 22 +- .../parameter_runtime_test.go | 10 +- .../flow/softwaredelivery/planning_package.go | 143 ------- .../flow/softwaredelivery/work_package.go | 171 ++++++++ ...g_package_test.go => work_package_test.go} | 50 ++- .../package.go | 54 +-- .../package_test.go | 67 ++- boatstack/flow/standard/completeness_test.go | 5 + .../catalog/native_state_handler.go | 4 +- .../softwaredelivery/catalog/state_facet.go | 4 + .../softwaredelivery/catalog/transition.go | 2 +- .../softwaredelivery/durable/state.go | 141 +++---- .../softwaredelivery/durable/state_facet.go | 4 +- .../durable/state_schema_test.go | 33 +- .../softwaredelivery/effects/artifacts.go | 380 ++++++++++++------ .../effects/command_boundary.go | 2 +- .../effects/integration_test.go | 76 ---- .../softwaredelivery/effects/state_reducer.go | 19 +- ...g_package_test.go => work_package_test.go} | 74 ++-- .../internal/softwaredelivery/model/facet.go | 5 +- .../softwaredelivery/model/objective.go | 11 +- .../internal/softwaredelivery/model/state.go | 30 +- .../softwaredelivery/model/state_test.go | 1 + .../softwaredelivery/plant/observer.go | 86 ++-- .../softwaredelivery/plant/observer_test.go | 25 +- ...t-delivery-planning-package-manual.flow.ts | 11 +- .../product-delivery-planning-package.flow.ts | 11 +- ...product-delivery-planning-package.raw.json | 2 +- .../boatstack-transition-catalog.md | 2 +- .../planning-and-foreground-work.md | 28 +- docs/product-delivery/writing-a-flow.md | 30 +- docs/typescript/software-delivery-sdk.md | 15 +- .../boatstack-software-delivery/src/index.ts | 178 +++++--- .../test/software-delivery.test.mjs | 72 +++- .../2026-08-18-generic-work-packages.md | 3 + scripts/check-docs-api.mjs | 8 +- scripts/check-docs-api.test.mjs | 7 +- 45 files changed, 1190 insertions(+), 879 deletions(-) delete mode 100644 boatstack/cmd/boatstack-helper/planning_package_command_test.go rename boatstack/cmd/boatstack-helper/{planning_package_command.go => work_package_command.go} (61%) create mode 100644 boatstack/cmd/boatstack-helper/work_package_command_test.go delete mode 100644 boatstack/flow/softwaredelivery/planning_package.go create mode 100644 boatstack/flow/softwaredelivery/work_package.go rename boatstack/flow/softwaredelivery/{planning_package_test.go => work_package_test.go} (60%) rename boatstack/flow/softwaredelivery/{planningpackage => workpackage}/package.go (92%) rename boatstack/flow/softwaredelivery/{planningpackage => workpackage}/package_test.go (72%) rename boatstack/internal/softwaredelivery/effects/{planning_package_test.go => work_package_test.go} (70%) create mode 100644 release-notes/2026-08-18-generic-work-packages.md diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index a3c54df..4c955f8 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -36,7 +36,7 @@ type flowCommandOptions struct { func runFlowCommand(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: boatstack flow [flags]") + return fmt.Errorf("usage: boatstack flow [flags]") } action := arguments[0] if action == "authorize" { @@ -54,8 +54,8 @@ func runFlowCommand(arguments []string) error { if action == "input" { return runFlowInput(arguments[1:]) } - if action == "planning-package" { - return runFlowPlanningPackage(arguments[1:]) + if action == "work-package" { + return runFlowWorkPackage(arguments[1:]) } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) diff --git a/boatstack/cmd/boatstack-helper/planning_package_command_test.go b/boatstack/cmd/boatstack-helper/planning_package_command_test.go deleted file mode 100644 index 8cb547a..0000000 --- a/boatstack/cmd/boatstack-helper/planning_package_command_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" -) - -func TestPlanningPackageVerifyCommandReportsSeparateStatuses(t *testing.T) { - repository, deliveryID, packageFingerprint := installPlanningPackageCommandFixture(t) - output, err := captureStdout(t, func() error { - return runFlowPlanningPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--format", "json"}) - }) - if err != nil { - t.Fatal(err) - } - var result planningpackage.Result - if err := json.Unmarshal(output, &result); err != nil { - t.Fatalf("single-package JSON is not an object: %v\n%s", err, output) - } - if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || result.Approval != planningpackage.Missing || result.CurrentProgram != planningpackage.Unavailable || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" { - t.Fatalf("verification result = %#v", result) - } - if _, err := captureStdout(t, func() error { - return runFlowPlanningPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--require-approval", "--format", "json"}) - }); err == nil { - t.Fatal("missing approval satisfied --require-approval") - } -} - -func installPlanningPackageCommandFixture(t *testing.T) (string, string, string) { - t.Helper() - repository, deliveryID := t.TempDir(), "proof" - plan := []byte("# plan\n") - output := planningpackage.Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: planningpackage.Digest(plan)} - work := planningpackage.WorkContract{ID: "planning", Instructions: planningpackage.Asset{Path: "package.md", SHA256: planningpackage.Digest([]byte("coordinate")), Content: "coordinate"}, Outputs: []planningpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}} - var err error - work.Fingerprint, err = planningpackage.RuntimeWorkFingerprint(work) - if err != nil { - t.Fatal(err) - } - _, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: work, PlanOutput: output.ID}) - if err != nil { - t.Fatal(err) - } - programFingerprint := strings.Repeat("b", 64) - _, receiptRaw, err := planningpackage.SealWorkReceipt(planningpackage.WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("c", 64), ResultFingerprint: strings.Repeat("d", 64), ContractID: work.ID, ContractFingerprint: work.Fingerprint, TransitionID: "planning.package.admit", ProgramFingerprint: programFingerprint, ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []planningpackage.Output{output}}) - if err != nil { - t.Fatal(err) - } - manifest, manifestRaw, err := planningpackage.SealManifest(planningpackage.Manifest{DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: programFingerprint, EntryID: "run", RunID: "run-proof", TransitionID: "planning.package.admit", WorkContractID: work.ID, WorkContractFingerprint: work.Fingerprint, WorkRequestFingerprint: strings.Repeat("c", 64), WorkResultFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, PlanOutput: planningpackage.PlanOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256}, Contract: planningpackage.Reference{Path: "contract.json", SHA256: planningpackage.Digest(contractRaw)}, WorkReceipt: planningpackage.Reference{Path: "work-receipt.json", SHA256: planningpackage.Digest(receiptRaw)}, Outputs: []planningpackage.Output{output}}) - if err != nil { - t.Fatal(err) - } - root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, manifest.Fingerprint) - if err := os.MkdirAll(root, 0o700); err != nil { - t.Fatal(err) - } - for name, raw := range map[string][]byte{"manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, output.Path: plan} { - if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil { - t.Fatal(err) - } - } - return repository, deliveryID, manifest.Fingerprint -} diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index 58fa9e4..8377715 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" - "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -314,7 +314,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T continue } if final.Receipt == nil || final.Prescription == nil { - t.Fatalf("unexpected non-terminal continuation response at step %d: %#v", step, final) + t.Fatalf("unexpected non-terminal continuation response at step %d: decision=%#v work=%#v response=%#v", step, final.Decision, final.Work, final) } if err := advanceContinuation(&continuation, final); err != nil { t.Fatal(err) @@ -332,7 +332,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T } wantTrace := []string{ "installation.initialize", "objective.bind", "engagement.begin", - "planning.package.admit", "planning.package.approve", "planning.package.promote", "plan.activate", + "work.package.admit", "work.package.approve", "planning.package.promote", "plan.activate", "workspace.cut", "workspace.activate", "gate.build.record", "gate.test.record", "gate.review.record", "publication.preview", "publication.execute", "publication.observe", @@ -352,17 +352,17 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T t.Fatalf("trace transition %d = %s, want %s", index+1, receipts[index].TransitionID, transitionID) } } - packageRoot := filepath.Join(repository, ".boatstack", "planning-packages", "todo") + packageRoot := filepath.Join(repository, ".boatstack", "work-packages", "todo") packages, err := os.ReadDir(packageRoot) if err != nil || len(packages) != 1 || !packages[0].IsDir() { t.Fatalf("planning package inventory = %#v, err=%v", packages, err) } - currentProgram, err := loadCurrentPlanningProgram(context.Background(), repository) + currentProgram, err := loadCurrentWorkProgram(context.Background(), repository) if err != nil { t.Fatalf("load current planning program: %v", err) } - packageVerification := planningpackage.Verify(repository, "todo", packages[0].Name(), ¤tProgram) - if packageVerification.Integrity != planningpackage.Valid || packageVerification.Contract != planningpackage.Valid || packageVerification.Approval != planningpackage.Valid || packageVerification.CurrentProgram != planningpackage.Match { + packageVerification := workpackage.Verify(repository, "todo", packages[0].Name(), ¤tProgram) + if packageVerification.Integrity != workpackage.Valid || packageVerification.Contract != workpackage.Valid || packageVerification.Approval != workpackage.Valid || packageVerification.CurrentProgram != workpackage.Match { t.Fatalf("portable planning package verification = %#v", packageVerification) } last := receipts[len(receipts)-1] diff --git a/boatstack/cmd/boatstack-helper/planning_package_command.go b/boatstack/cmd/boatstack-helper/work_package_command.go similarity index 61% rename from boatstack/cmd/boatstack-helper/planning_package_command.go rename to boatstack/cmd/boatstack-helper/work_package_command.go index 0bc4ed5..cf7eeee 100644 --- a/boatstack/cmd/boatstack-helper/planning_package_command.go +++ b/boatstack/cmd/boatstack-helper/work_package_command.go @@ -13,14 +13,14 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/distribution" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" ) -func runFlowPlanningPackage(arguments []string) error { +func runFlowWorkPackage(arguments []string) error { if len(arguments) == 0 || arguments[0] != "verify" { - return fmt.Errorf("unknown planning-package action") + return fmt.Errorf("unknown work-package action") } - flags := flag.NewFlagSet("flow planning-package verify", flag.ContinueOnError) + flags := flag.NewFlagSet("flow work-package verify", flag.ContinueOnError) flags.SetOutput(os.Stderr) var repository, deliveryID, packageFingerprint, format string var all, requireApproval, requireCurrent bool @@ -54,27 +54,27 @@ func runFlowPlanningPackage(arguments []string) error { if !all && (deliveryID == "" || packageFingerprint == "") { return fmt.Errorf("--delivery and --package are required together") } - var current *planningpackage.CurrentProgram - if value, currentErr := loadCurrentPlanningProgram(context.Background(), repository); currentErr == nil { + var current *workpackage.CurrentProgram + if value, currentErr := loadCurrentWorkProgram(context.Background(), repository); currentErr == nil { current = &value } else if requireCurrent { return currentErr } var identities [][2]string if all { - identities, err = planningpackage.Enumerate(repository) + identities, err = workpackage.Enumerate(repository) } else { identities = [][2]string{{deliveryID, packageFingerprint}} } if err != nil { return err } - results := make([]planningpackage.Result, 0, len(identities)) + results := make([]workpackage.Result, 0, len(identities)) failed := false for _, identity := range identities { - result := planningpackage.Verify(repository, identity[0], identity[1], current) + result := workpackage.Verify(repository, identity[0], identity[1], current) results = append(results, result) - if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || requireApproval && result.Approval != planningpackage.Valid || requireCurrent && result.CurrentProgram != planningpackage.Match { + if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || requireApproval && result.Approval != workpackage.Valid || requireCurrent && result.CurrentProgram != workpackage.Match { failed = true } } @@ -94,66 +94,57 @@ func runFlowPlanningPackage(arguments []string) error { } } if failed { - return fmt.Errorf("one or more planning packages failed required verification") + return fmt.Errorf("one or more work packages failed required verification") } return nil } -func loadCurrentPlanningProgram(ctx context.Context, repository string) (planningpackage.CurrentProgram, error) { +func loadCurrentWorkProgram(ctx context.Context, repository string) (workpackage.CurrentProgram, error) { path, err := resolveCheckArtifact(repository, "") if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } raw, err := os.ReadFile(path) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } resolver, err := softwareflow.NewResolver(ctx) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } compiled, err := checkArtifactForCurrentProject(repository, artifact, resolver) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } definition, err := softwareflow.NewDefinition(compiled, resolver) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } program, err := distribution.ProgramForRepository(ctx, distribution.RepositoryProgramRequest{Repository: repository}, definition) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } workByID := map[string]controlprogram.WorkContract{} for _, work := range compiled.Document.Work { workByID[work.ID] = work } for _, transition := range compiled.Document.Transitions { - if transition.ID != softwareflow.PlanningPackageAdmit { + if transition.ID != softwareflow.WorkPackageAdmit { continue } work, ok := workByID[transition.Work] if !ok { - return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package work is missing") + return workpackage.CurrentProgram{}, fmt.Errorf("current work-package work is missing") } runtime, err := softwareflow.RuntimeWorkContract(work) if err != nil { - return planningpackage.CurrentProgram{}, err + return workpackage.CurrentProgram{}, err } - planOutput := "" - for _, binding := range transition.Parameters { - if binding.Parameter == "plan_output" && binding.Producer.Binding != nil { - planOutput = strings.TrimPrefix(binding.Producer.Binding.Reference, "software-delivery/planning-package-plan-output/") - } - } - if planOutput == "" { - return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package plan output is missing") - } - return planningpackage.CurrentProgram{ProgramFingerprint: program.Fingerprint(), WorkContractFingerprint: runtime.Fingerprint, PlanOutput: planOutput}, nil + return workpackage.CurrentProgram{ProgramFingerprint: program.Fingerprint(), WorkContractFingerprint: runtime.Fingerprint}, nil } - return planningpackage.CurrentProgram{}, fmt.Errorf("current Flow has no planning-package admission") + return workpackage.CurrentProgram{}, fmt.Errorf("current Flow has no work-package admission") } diff --git a/boatstack/cmd/boatstack-helper/work_package_command_test.go b/boatstack/cmd/boatstack-helper/work_package_command_test.go new file mode 100644 index 0000000..3129bff --- /dev/null +++ b/boatstack/cmd/boatstack-helper/work_package_command_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" +) + +func TestWorkPackageVerifyCommandReportsSeparateStatuses(t *testing.T) { + repository, deliveryID, packageFingerprint := installWorkPackageCommandFixture(t) + output, err := captureStdout(t, func() error { + return runFlowWorkPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--format", "json"}) + }) + if err != nil { + t.Fatal(err) + } + var result workpackage.Result + if err := json.Unmarshal(output, &result); err != nil { + t.Fatalf("single-package JSON is not an object: %v\n%s", err, output) + } + if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || result.Approval != workpackage.Missing || result.CurrentProgram != workpackage.Unavailable || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" { + t.Fatalf("verification result = %#v", result) + } + if _, err := captureStdout(t, func() error { + return runFlowWorkPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--require-approval", "--format", "json"}) + }); err == nil { + t.Fatal("missing approval satisfied --require-approval") + } +} + +func installWorkPackageCommandFixture(t *testing.T) (string, string, string) { + t.Helper() + repository, deliveryID := t.TempDir(), "proof" + plan := []byte("# plan\n") + output := workpackage.Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: workpackage.Digest(plan)} + work := workpackage.WorkContract{ID: "planning", Instructions: workpackage.Asset{Path: "package.md", SHA256: workpackage.Digest([]byte("coordinate")), Content: "coordinate"}, Outputs: []workpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}} + var err error + work.Fingerprint, err = workpackage.RuntimeWorkFingerprint(work) + if err != nil { + t.Fatal(err) + } + _, contractRaw, err := workpackage.SealContract(workpackage.Contract{Work: work}) + if err != nil { + t.Fatal(err) + } + programFingerprint := strings.Repeat("b", 64) + _, receiptRaw, err := workpackage.SealWorkReceipt(workpackage.WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("c", 64), ResultFingerprint: strings.Repeat("d", 64), ContractID: work.ID, ContractFingerprint: work.Fingerprint, TransitionID: "work.package.admit", ProgramFingerprint: programFingerprint, ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []workpackage.Output{output}}) + if err != nil { + t.Fatal(err) + } + manifest, manifestRaw, err := workpackage.SealManifest(workpackage.Manifest{DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: programFingerprint, EntryID: "run", RunID: "run-proof", TransitionID: "work.package.admit", WorkContractID: work.ID, WorkContractFingerprint: work.Fingerprint, WorkRequestFingerprint: strings.Repeat("c", 64), WorkResultFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, Contract: workpackage.Reference{Path: "contract.json", SHA256: workpackage.Digest(contractRaw)}, WorkReceipt: workpackage.Reference{Path: "work-receipt.json", SHA256: workpackage.Digest(receiptRaw)}, Outputs: []workpackage.Output{output}}) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(repository, ".boatstack", "work-packages", deliveryID, manifest.Fingerprint) + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + for name, raw := range map[string][]byte{"manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, output.Path: plan} { + if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } + return repository, deliveryID, manifest.Fingerprint +} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 371ac5b..405becb 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -181,7 +181,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if compiled.Fingerprint != reference.Fingerprint { t.Fatalf("checked product-delivery IR is stale: frontend=%s checked=%s", compiled.Fingerprint, reference.Fingerprint) } - var packageProducer, publicationProducer controlprogram.ParameterProducer + var packageProducer, promotionProducer, publicationProducer controlprogram.ParameterProducer publicationStateInputDeclared, publicationReceiptInputDeclared := false, false for _, operator := range compiled.Document.Operators { if operator.ID == "publication.observe" && len(operator.StateInputs) == 1 && operator.StateInputs[0].Parameter == "publication_id" && operator.StateInputs[0].Facet == "publication_id" { @@ -196,16 +196,25 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if parameter.Producer.Kind == controlprogram.ParameterSourceHostInput && (transition.ID != "delivery.slice.advance" || parameter.Parameter != "slice_id") { t.Fatalf("deterministic parameter requires human text input: %s/%s", transition.ID, parameter.Parameter) } - if transition.ID == "planning.package.approve" && parameter.Parameter == "package_fingerprint" { + if transition.ID == "work.package.approve" && parameter.Parameter == "package_fingerprint" { packageProducer = parameter.Producer } + if transition.ID == "planning.package.promote" && parameter.Parameter == "plan_output" { + promotionProducer = parameter.Producer + } + if parameter.Parameter == "plan_output" && transition.ID != "planning.package.promote" { + t.Fatalf("plan-output semantics escaped promotion: %s", transition.ID) + } if transition.ID == "publication.observe" && parameter.Parameter == "publication_id" { publicationProducer = parameter.Producer } } } - if packageProducer.Kind != controlprogram.ParameterSourceTrustedResolver || packageProducer.Binding == nil || packageProducer.Binding.Reference != "software-delivery/admitted-planning-package-fingerprint" { - t.Fatalf("planning package producer = %#v", packageProducer) + if packageProducer.Kind != controlprogram.ParameterSourceTrustedResolver || packageProducer.Binding == nil || packageProducer.Binding.Reference != "software-delivery/admitted-work-package-fingerprint" { + t.Fatalf("work package producer = %#v", packageProducer) + } + if promotionProducer.Kind != controlprogram.ParameterSourceTrustedResolver || promotionProducer.Binding == nil || promotionProducer.Binding.Reference != "software-delivery/planning-package-plan-output/plan" { + t.Fatalf("planning promotion producer = %#v", promotionProducer) } if publicationProducer.Kind != controlprogram.ParameterSourceStateOrReceipt || publicationProducer.Facet != "publication_id" || publicationProducer.Transition != "publication.execute" || publicationProducer.Field != "publication_id" { t.Fatalf("publication identity producer = %#v", publicationProducer) diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 3596de0..976701b 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -34,11 +34,11 @@ func NewResolver(ctx context.Context) (Resolver, error) { for _, transition := range manifest.Transitions { transitions[string(transition.ID)] = transition } - planning, err := planningPackageTransitions(transitions) + acceptedWork, err := acceptedWorkTransitions(transitions) if err != nil { return Resolver{}, err } - for _, transition := range planning { + for _, transition := range acceptedWork { transitions[string(transition.ID)] = transition } return Resolver{transitions: transitions}, nil @@ -106,10 +106,10 @@ func (r Resolver) ResolveParameterResolver(reference, version string) (controlpr StabilityScope: "invocation", } switch { - case reference == ParameterResolverPrefix+"admitted-planning-package-fingerprint": - value.Dependencies = []string{"planning-package-fingerprint", "durable-state"} + case reference == ParameterResolverPrefix+"admitted-work-package-fingerprint": + value.Dependencies = []string{"work-package-fingerprint", "durable-state"} case strings.HasPrefix(reference, planningPackagePlanOutputResolverPrefix): - if value := strings.TrimPrefix(reference, planningPackagePlanOutputResolverPrefix); !planningPackageSegment.MatchString(value) { + if value := strings.TrimPrefix(reference, planningPackagePlanOutputResolverPrefix); !workPackageSegment.MatchString(value) { return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("unknown software-delivery parameter resolver %q", reference) } value.Dependencies = []string{"compiled-planning-package-binding"} @@ -232,9 +232,9 @@ func projectOperatorParameters(transition delivery.Transition) []controlprogram. for _, parameter := range transition.Parameters { allowed := []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceHostInput} switch { - case transition.ID == PlanningPackageApprove && parameter.Name == "package_fingerprint": + case transition.ID == WorkPackageApprove && parameter.Name == "package_fingerprint": allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} - case transition.ID == PlanningPackageAdmit && parameter.Name == "plan_output": + case transition.ID == PlanningPackagePromote && parameter.Name == "plan_output": allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} case transition.ID == "workspace.cut": allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 41ea71a..57bf804 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -61,6 +61,14 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim objectives := map[model.TargetID]EntryObjective{} contracts := map[model.TargetID]delivery.ObjectiveContract{} entriesByTarget := map[model.TargetID][]controlprogram.Entry{} + base.ObjectiveContracts = append(base.ObjectiveContracts, delivery.ObjectiveContract{ + TargetID: model.ObjectiveApprovedWorkPackage, + Conditions: []delivery.FacetCondition{{ + Facet: model.FacetWorkPackage, + Statuses: []model.FactStatus{model.FactKnown}, + Values: []string{string(model.WorkPackageApproved)}, + }}, + }) for _, entry := range d.compiled.Document.Entries { objective, objectiveErr := objectiveContractForEntry(d.compiled, base, entry.ID) if objectiveErr != nil { @@ -72,6 +80,8 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions)) seen := map[delivery.TransitionID]bool{} + var admittedPackageWork *delivery.WorkContract + var promotionPlanOutput string for _, declaration := range d.compiled.Document.Transitions { operator := operatorByID[declaration.Operator] if operator.Binding == nil { @@ -109,17 +119,24 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim if err != nil { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } - if transition.ID == PlanningPackageAdmit { - planOutput, bindingErr := planningPackagePlanOutput(declaration.Parameters) - if bindingErr != nil { - return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, bindingErr) - } - if err := validatePlanningPackageWorkContract(*transition.Work, planOutput); err != nil { + if transition.ID == WorkPackageAdmit { + if err := validateWorkPackageContract(*transition.Work); err != nil { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } + copy := *transition.Work + admittedPackageWork = © } transition.OwnedResources = append(transition.OwnedResources, "foreground-work-"+transition.Work.ID) } + if transition.ID == WorkPackageAdmit && transition.Work == nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q requires foreground work", declaration.ID) + } + if transition.ID == PlanningPackagePromote { + promotionPlanOutput, err = planningPackagePlanOutput(declaration.Parameters) + if err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q: %w", declaration.ID, err) + } + } for _, authority := range declaration.Requires.Authorities { transition.AuthorityAll = append(transition.AuthorityAll, delivery.AuthorityClass(authority)) } @@ -146,6 +163,14 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim } selected = append(selected, transition) } + if promotionPlanOutput != "" { + if admittedPackageWork == nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("%s requires %s with foreground work", PlanningPackagePromote, WorkPackageAdmit) + } + if err := validatePlanningPackageWorkContract(*admittedPackageWork, promotionPlanOutput); err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("%s: %w", PlanningPackagePromote, err) + } + } if len(selected) == 0 { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("software-delivery Flow selects no transitions") } diff --git a/boatstack/flow/softwaredelivery/parameter_runtime.go b/boatstack/flow/softwaredelivery/parameter_runtime.go index 5605f30..96226fa 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -39,11 +39,11 @@ type RuntimeParameterResolver struct { // domain-neutral invocation value interface. func StateParameterValues(state durable.State) map[string]invocation.Value { values := map[string]string{ - "workspace_branch": state.WorkspaceBranch, - "preview_fingerprint": state.PreviewFingerprint, - "publication_id": state.PublicationID, - "transaction_id": state.TransactionID, - "planning_package_fingerprint": state.PlanningPackageFingerprint, + "workspace_branch": state.WorkspaceBranch, + "preview_fingerprint": state.PreviewFingerprint, + "publication_id": state.PublicationID, + "transaction_id": state.TransactionID, + "work_package_fingerprint": state.WorkPackageFingerprint, } result := map[string]invocation.Value{} for facet, value := range values { @@ -91,15 +91,15 @@ func (r RuntimeParameterResolver) ResolveParameter(binding controlprogram.Parame } value := invocation.Value{Type: resolved.OutputType, ProducerFingerprint: resolved.Fingerprint} switch { - case binding.Reference == ParameterResolverPrefix+"admitted-planning-package-fingerprint": - fingerprint, ok := materialization.State["planning_package_fingerprint"] + case binding.Reference == ParameterResolverPrefix+"admitted-work-package-fingerprint": + fingerprint, ok := materialization.State["work_package_fingerprint"] if !ok || len(fingerprint.Canonical) != 64 { - return invocation.Value{}, fmt.Errorf("admitted planning package fingerprint is unavailable from durable state") + return invocation.Value{}, fmt.Errorf("admitted work package fingerprint is unavailable from durable state") } value.Canonical, value.Provenance = fingerprint.Canonical, fingerprint.Provenance case strings.HasPrefix(binding.Reference, planningPackagePlanOutputResolverPrefix): planOutput := strings.TrimPrefix(binding.Reference, planningPackagePlanOutputResolverPrefix) - if !planningPackageSegment.MatchString(planOutput) { + if !workPackageSegment.MatchString(planOutput) { return invocation.Value{}, fmt.Errorf("planning-package plan output binding is invalid") } value.Canonical, value.Provenance = planOutput, "compiled-planning-package-binding" @@ -240,14 +240,14 @@ func gateEvidenceInputPath(gate string) string { } func readCanonicalParameterArtifact(repository, deliveryID, name string) (string, []byte, error) { - if !planningPackageSegment.MatchString(deliveryID) || name == "" || filepath.Base(name) != name { + if !workPackageSegment.MatchString(deliveryID) || name == "" || filepath.Base(name) != name { return "", nil, fmt.Errorf("canonical parameter artifact identity is invalid") } return readRegularParameterArtifact(repository, filepath.Join(".boatstack", "evidence", deliveryID, name)) } func readCanonicalPublicationBody(repository, deliveryID string) (string, []byte, error) { - if !planningPackageSegment.MatchString(deliveryID) { + if !workPackageSegment.MatchString(deliveryID) { return "", nil, fmt.Errorf("publication body delivery identity is invalid") } return readRegularParameterArtifact(repository, filepath.Join(".boatstack", "publication", deliveryID+".body.md")) diff --git a/boatstack/flow/softwaredelivery/parameter_runtime_test.go b/boatstack/flow/softwaredelivery/parameter_runtime_test.go index 8528906..0ddd9a6 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime_test.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime_test.go @@ -11,7 +11,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/invocation" ) -func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testing.T) { +func TestAdmittedWorkPackageFingerprintMaterializesManifestIdentity(t *testing.T) { repository := t.TempDir() deliveryID := "todo-plan" manifestFingerprint := strings.Repeat("a", 64) @@ -20,21 +20,21 @@ func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testi if err != nil { t.Fatal(err) } - metadata, err := resolver.ResolveParameterResolver(ParameterResolverPrefix+"admitted-planning-package-fingerprint", "1") + metadata, err := resolver.ResolveParameterResolver(ParameterResolverPrefix+"admitted-work-package-fingerprint", "1") if err != nil { t.Fatal(err) } producer := controlprogram.ParameterProducer{Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{ - Reference: ParameterResolverPrefix + "admitted-planning-package-fingerprint", Version: "1", Fingerprint: metadata.Fingerprint, + Reference: ParameterResolverPrefix + "admitted-work-package-fingerprint", Version: "1", Fingerprint: metadata.Fingerprint, }} materialization, err := invocation.Materialize( []controlprogram.OperatorParameter{{ID: "package_fingerprint", Type: controlprogram.ValueTypeDefinition{Kind: "string"}, Required: true, AllowedSources: []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver}}}, []controlprogram.TransitionParameterBinding{{Parameter: "package_fingerprint", Producer: producer}}, invocation.Context{ RunID: "run-package", ProgramFingerprint: strings.Repeat("c", 64), ExecutionProgramFingerprint: strings.Repeat("d", 64), - EntryID: "run", TargetID: "published-pr", TransitionID: PlanningPackageApprove, StateRevision: 7, + EntryID: "run", TargetID: "published-pr", TransitionID: WorkPackageApprove, StateRevision: 8, ContextFingerprint: strings.Repeat("e", 64), ExecutionScopeFingerprint: strings.Repeat("f", 64), - State: map[string]invocation.Value{"planning_package_fingerprint": {Canonical: manifestFingerprint, Provenance: "durable-state:planning_package_fingerprint"}}, + State: map[string]invocation.Value{"work_package_fingerprint": {Canonical: manifestFingerprint, Provenance: "durable-state:work_package_fingerprint"}}, }, RuntimeParameterResolver{Context: context.Background(), Repository: repository, DeliveryID: deliveryID, Binding: resolver}, ) diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go deleted file mode 100644 index 6e55e74..0000000 --- a/boatstack/flow/softwaredelivery/planning_package.go +++ /dev/null @@ -1,143 +0,0 @@ -package softwaredelivery - -import ( - "fmt" - "regexp" - "strings" - - "github.com/operatorstack/boatstack/boatstack/controlprogram" - "github.com/operatorstack/boatstack/boatstack/delivery" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" - "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" -) - -var planningPackageSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) - -const planningPackagePlanOutputResolverPrefix = ParameterResolverPrefix + "planning-package-plan-output/" - -const ( - PlanningPackageAdmit = "planning.package.admit" - PlanningPackageApprove = "planning.package.approve" - PlanningPackagePromote = "planning.package.promote" -) - -// planningPackageTransitions derives optional trusted mechanisms from the -// standard delivery primitives. Repositories decide whether these transitions -// belong to their Flow and attach their own foreground-work contract to admit. -func planningPackageTransitions(transitions map[string]delivery.Transition) ([]delivery.Transition, error) { - clone := func(id string) (delivery.Transition, error) { - transition, ok := transitions[id] - if !ok { - return delivery.Transition{}, fmt.Errorf("trusted planning-package base %q is unavailable", id) - } - return transition, nil - } - admit, err := clone("plan.create") - if err != nil { - return nil, err - } - admit.ID, admit.Effect = PlanningPackageAdmit, PlanningPackageAdmit - // The planning-package effect consumes exact foreground-work evidence. The - // plan.create parameter contract is not part of this derived operation. - admit.Parameters = nil - admit.Parameters = []delivery.ParameterSpec{{Name: "plan_output", Required: true}} - admit.LocalEffects = []delivery.EffectID{PlanningPackageAdmit} - admit.Prescription.Operation = PlanningPackageAdmit - admit.Prescription.ExpectedPostcondition = "a schema-valid planning package is admitted" - admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "planning-package-admit-source", "exact-work-and-transition-admission", "planning-package-valid" - admit.Verifier = "verifier:fresh-observation:" + PlanningPackageAdmit - admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-admit"} - admit.TargetConditions = replacePlanCondition(admit.TargetConditions, model.PlanPackageValid) - admit.OwnedResources = []string{"plan", "planning-package"} - admit.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageAdmit - - approve, err := clone("plan.approve") - if err != nil { - return nil, err - } - approve.ID, approve.Effect = PlanningPackageApprove, PlanningPackageApprove - approve.LocalEffects = []delivery.EffectID{PlanningPackageApprove} - approve.Parameters = []delivery.ParameterSpec{{Name: "package_fingerprint", Required: true}} - approve.Prescription.Operation = PlanningPackageApprove - approve.Prescription.ExpectedPostcondition = "the exact planning package is approved" - approve.SourcePredicate, approve.AdmissionPredicate, approve.TargetPredicate = "planning-package-valid", "exact-package-approval", "planning-package-approved" - approve.Verifier = "verifier:fresh-observation:" + PlanningPackageApprove - approve.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-approve"} - approve.SourceConditions = replacePlanCondition(approve.SourceConditions, model.PlanPackageValid) - approve.TargetConditions = replacePlanCondition(approve.TargetConditions, model.PlanPackageApproved) - approve.OwnedResources = []string{"plan", "planning-package-approval"} - approve.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageApprove - - promote, err := clone("plan.activate") - if err != nil { - return nil, err - } - promote.ID, promote.Effect = PlanningPackagePromote, PlanningPackagePromote - promote.LocalEffects = []delivery.EffectID{PlanningPackagePromote} - promote.Prescription.Operation = PlanningPackagePromote - promote.Prescription.ExpectedPostcondition = "the approved package plan is the canonical delivery plan" - promote.SourcePredicate, promote.AdmissionPredicate, promote.TargetPredicate = "planning-package-approved", "exact-package-promotion", "plan-approved" - promote.Verifier = "verifier:fresh-observation:" + PlanningPackagePromote - promote.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-promote"} - promote.SourceConditions = replacePlanCondition(promote.SourceConditions, model.PlanPackageApproved) - promote.TargetConditions = replacePlanCondition(promote.TargetConditions, model.PlanApproved) - promote.TargetIDs = append([]model.TargetID(nil), admit.TargetIDs...) - promote.OwnedResources = []string{"plan", "planning-package-promotion"} - promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote - - return []delivery.Transition{admit, approve, promote}, nil -} - -func replacePlanCondition(values []delivery.FacetCondition, state model.PlanState) []delivery.FacetCondition { - result := append([]delivery.FacetCondition(nil), values...) - for index := range result { - if result[index].Facet == model.FacetPlan { - result[index].Statuses = []model.FactStatus{model.FactKnown} - result[index].Values = []string{string(state)} - } - } - return result -} - -func validatePlanningPackageWorkContract(work delivery.WorkContract, planOutputID string) error { - portable := planningpackage.WorkContract{ID: work.ID, Fingerprint: work.Fingerprint, Instructions: planningpackage.Asset{Path: work.InstructionPath, SHA256: work.InstructionSHA256, Content: work.InstructionContent}} - for _, input := range work.Inputs { - portable.Inputs = append(portable.Inputs, planningpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) - } - var planOutput *delivery.WorkOutput - for index := range work.Outputs { - output := &work.Outputs[index] - item := planningpackage.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} - if output.GuidancePath != "" { - item.Guidance = &planningpackage.Asset{Path: output.GuidancePath, SHA256: output.GuidanceSHA256, Content: output.GuidanceContent} - } - if output.SchemaPath != "" { - item.Schema = &planningpackage.Asset{Path: output.SchemaPath, SHA256: output.SchemaSHA256, Content: output.SchemaContent} - } - portable.Outputs = append(portable.Outputs, item) - if output.ID == planOutputID { - planOutput = output - } - } - if err := planningpackage.ValidateOutputPaths(portable.Outputs); err != nil { - return err - } - if planOutput == nil || !planOutput.Required { - return fmt.Errorf("planning-package admission requires designated output %q to exist exactly once and be required", planOutputID) - } - return planningpackage.ValidateContractMetadata(portable, planOutputID) -} - -func planningPackagePlanOutput(bindings []controlprogram.TransitionParameterBinding) (string, error) { - for _, binding := range bindings { - if binding.Parameter != "plan_output" || binding.Producer.Kind != controlprogram.ParameterSourceTrustedResolver || binding.Producer.Binding == nil { - continue - } - value, ok := strings.CutPrefix(binding.Producer.Binding.Reference, planningPackagePlanOutputResolverPrefix) - if !ok || !planningPackageSegment.MatchString(value) { - return "", fmt.Errorf("planning-package plan output binding is invalid") - } - return value, nil - } - return "", fmt.Errorf("planning-package admit requires an explicit plan_output binding") -} diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go new file mode 100644 index 0000000..4104537 --- /dev/null +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -0,0 +1,171 @@ +package softwaredelivery + +import ( + "fmt" + "regexp" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/delivery" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +var workPackageSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +const planningPackagePlanOutputResolverPrefix = ParameterResolverPrefix + "planning-package-plan-output/" + +const ( + WorkPackageAdmit = "work.package.admit" + WorkPackageApprove = "work.package.approve" + PlanningPackagePromote = "planning.package.promote" +) + +func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]delivery.Transition, error) { + clone := func(id string) (delivery.Transition, error) { + transition, ok := transitions[id] + if !ok { + return delivery.Transition{}, fmt.Errorf("trusted accepted-work base %q is unavailable", id) + } + return transition, nil + } + + admit, err := clone("plan.create") + if err != nil { + return nil, err + } + admit.ID, admit.Effect = WorkPackageAdmit, WorkPackageAdmit + admit.Parameters = nil + admit.LocalEffects = []delivery.EffectID{WorkPackageAdmit} + admit.Prescription.Operation = WorkPackageAdmit + admit.Prescription.ExpectedPostcondition = "a schema-valid work package is admitted" + admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "work-package-admit-source", "exact-work-and-transition-admission", "work-package-valid" + admit.Verifier = "verifier:fresh-observation:" + WorkPackageAdmit + admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "work-package-admit"} + admit.SourceConditions = withFacet(withoutFacet(admit.SourceConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageAbsent)) + admit.TargetConditions = withFacet(withoutFacet(admit.TargetConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageValid)) + admit.TargetIDs = appendUniqueTarget(admit.TargetIDs, model.ObjectiveApprovedWorkPackage) + admit.OwnedResources = []string{"work-package"} + admit.Interruption.ResumptionPredicate = "recovery-contract-for:" + WorkPackageAdmit + + approve, err := clone("plan.approve") + if err != nil { + return nil, err + } + approve.ID, approve.Effect = WorkPackageApprove, WorkPackageApprove + approve.LocalEffects = []delivery.EffectID{WorkPackageApprove} + approve.Parameters = []delivery.ParameterSpec{{Name: "package_fingerprint", Required: true}} + approve.Prescription.Operation = WorkPackageApprove + approve.Prescription.ExpectedPostcondition = "the exact work package is approved" + approve.SourcePredicate, approve.AdmissionPredicate, approve.TargetPredicate = "work-package-valid", "exact-package-approval", "work-package-approved" + approve.Verifier = "verifier:fresh-observation:" + WorkPackageApprove + approve.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "work-package-approve"} + approve.SourceConditions = withFacet(withoutFacet(approve.SourceConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageValid)) + approve.TargetConditions = withFacet(withoutFacet(approve.TargetConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageApproved)) + approve.TargetIDs = appendUniqueTarget(approve.TargetIDs, model.ObjectiveApprovedWorkPackage) + approve.OwnedResources = []string{"work-package-approval"} + approve.Interruption.ResumptionPredicate = "recovery-contract-for:" + WorkPackageApprove + + promote, err := clone("plan.activate") + if err != nil { + return nil, err + } + promote.ID, promote.Effect = PlanningPackagePromote, PlanningPackagePromote + promote.LocalEffects = []delivery.EffectID{PlanningPackagePromote} + promote.Parameters = []delivery.ParameterSpec{{Name: "plan_output", Required: true}} + promote.Prescription.Operation = PlanningPackagePromote + promote.Prescription.ExpectedPostcondition = "one compiler-bound approved-package output is the canonical delivery plan" + promote.SourcePredicate, promote.AdmissionPredicate, promote.TargetPredicate = "work-package-approved", "exact-package-output-promotion", "plan-approved" + promote.Verifier = "verifier:fresh-observation:" + PlanningPackagePromote + promote.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-promote"} + promote.SourceConditions = withFacet( + withFacet(withoutFacet(promote.SourceConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageApproved)), + model.FacetPlan, string(model.PlanAbsent), + ) + promote.TargetConditions = replaceFacet(promote.TargetConditions, model.FacetPlan, string(model.PlanApproved)) + promote.OwnedResources = []string{"plan", "planning-package-promotion"} + promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote + + return []delivery.Transition{admit, approve, promote}, nil +} + +func withoutFacet(values []delivery.FacetCondition, facet model.FacetName) []delivery.FacetCondition { + result := make([]delivery.FacetCondition, 0, len(values)) + for _, value := range values { + if value.Facet != facet { + result = append(result, value) + } + } + return result +} + +func withFacet(values []delivery.FacetCondition, facet model.FacetName, value string) []delivery.FacetCondition { + return append(values, delivery.FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{value}}) +} + +func replaceFacet(values []delivery.FacetCondition, facet model.FacetName, value string) []delivery.FacetCondition { + result := append([]delivery.FacetCondition(nil), values...) + for index := range result { + if result[index].Facet == facet { + result[index].Statuses = []model.FactStatus{model.FactKnown} + result[index].Values = []string{value} + } + } + return result +} + +func appendUniqueTarget(values []model.TargetID, target model.TargetID) []model.TargetID { + for _, value := range values { + if value == target { + return values + } + } + return append(values, target) +} + +func validateWorkPackageContract(work delivery.WorkContract) error { + portable := workpackage.WorkContract{ID: work.ID, Fingerprint: work.Fingerprint, Instructions: workpackage.Asset{Path: work.InstructionPath, SHA256: work.InstructionSHA256, Content: work.InstructionContent}} + for _, input := range work.Inputs { + portable.Inputs = append(portable.Inputs, workpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + } + for _, output := range work.Outputs { + item := workpackage.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} + if output.GuidancePath != "" { + item.Guidance = &workpackage.Asset{Path: output.GuidancePath, SHA256: output.GuidanceSHA256, Content: output.GuidanceContent} + } + if output.SchemaPath != "" { + item.Schema = &workpackage.Asset{Path: output.SchemaPath, SHA256: output.SchemaSHA256, Content: output.SchemaContent} + } + portable.Outputs = append(portable.Outputs, item) + } + if err := workpackage.ValidateOutputPaths(portable.Outputs); err != nil { + return err + } + return workpackage.ValidateContractMetadata(portable) +} + +func validatePlanningPackageWorkContract(work delivery.WorkContract, planOutputID string) error { + if err := validateWorkPackageContract(work); err != nil { + return err + } + for _, output := range work.Outputs { + if output.ID == planOutputID && output.Required { + return nil + } + } + return fmt.Errorf("planning package requires designated output %q to exist exactly once and be required", planOutputID) +} + +func planningPackagePlanOutput(bindings []controlprogram.TransitionParameterBinding) (string, error) { + for _, binding := range bindings { + if binding.Parameter != "plan_output" || binding.Producer.Kind != controlprogram.ParameterSourceTrustedResolver || binding.Producer.Binding == nil { + continue + } + value, ok := strings.CutPrefix(binding.Producer.Binding.Reference, planningPackagePlanOutputResolverPrefix) + if !ok || !workPackageSegment.MatchString(value) { + return "", fmt.Errorf("planning-package plan output binding is invalid") + } + return value, nil + } + return "", fmt.Errorf("planning.package.promote requires an explicit plan_output binding") +} diff --git a/boatstack/flow/softwaredelivery/planning_package_test.go b/boatstack/flow/softwaredelivery/work_package_test.go similarity index 60% rename from boatstack/flow/softwaredelivery/planning_package_test.go rename to boatstack/flow/softwaredelivery/work_package_test.go index 8eb0034..1724a0a 100644 --- a/boatstack/flow/softwaredelivery/planning_package_test.go +++ b/boatstack/flow/softwaredelivery/work_package_test.go @@ -32,7 +32,7 @@ func TestPlanningPackageWorkRequiresOwnedPlanOutput(t *testing.T) { } } -func TestPlanningPackageUsesDistinctPlanStateAndSharedPlanLease(t *testing.T) { +func TestAcceptedWorkUsesDistinctFacetAndOnlyPromotionOwnsPlan(t *testing.T) { manifest, err := standard.Definition().RuntimeManifest(context.Background()) if err != nil { t.Fatal(err) @@ -41,23 +41,45 @@ func TestPlanningPackageUsesDistinctPlanStateAndSharedPlanLease(t *testing.T) { for _, transition := range manifest.Transitions { base[string(transition.ID)] = transition } - transitions, err := planningPackageTransitions(base) + transitions, err := acceptedWorkTransitions(base) if err != nil { t.Fatal(err) } - for _, transition := range transitions { - if !containsString(transition.OwnedResources, "plan") { - t.Fatalf("%s does not serialize on the canonical plan resource: %v", transition.ID, transition.OwnedResources) + for _, transition := range transitions[:2] { + if containsString(transition.OwnedResources, "plan") { + t.Fatalf("%s unexpectedly owns the canonical plan resource: %v", transition.ID, transition.OwnedResources) + } + for _, condition := range append(append([]delivery.FacetCondition(nil), transition.SourceConditions...), transition.TargetConditions...) { + if condition.Facet == model.FacetPlan { + t.Fatalf("%s unexpectedly reaches plan state: %#v", transition.ID, condition) + } } } + if !containsString(transitions[2].OwnedResources, "plan") { + t.Fatalf("%s does not own the canonical plan resource: %v", transitions[2].ID, transitions[2].OwnedResources) + } var got []string for _, condition := range transitions[0].TargetConditions { - if condition.Facet == model.FacetPlan { + if condition.Facet == model.FacetWorkPackage { got = condition.Values } } - if !containsString(got, string(model.PlanPackageValid)) || containsString(got, string(model.PlanValid)) { - t.Fatalf("planning-package admission target = %v", got) + if !containsString(got, string(model.WorkPackageValid)) { + t.Fatalf("work-package admission target = %v", got) + } + for _, condition := range transitions[0].SourceConditions { + if condition.Facet == model.FacetWorkPackage && !containsString(condition.Values, string(model.WorkPackageAbsent)) { + t.Fatalf("work-package admission source = %v", condition.Values) + } + } + foundPlanAbsent := false + for _, condition := range transitions[2].SourceConditions { + if condition.Facet == model.FacetPlan && containsString(condition.Values, string(model.PlanAbsent)) { + foundPlanAbsent = true + } + } + if !foundPlanAbsent { + t.Fatal("planning promotion can replay after canonical plan creation") } } @@ -80,6 +102,18 @@ func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { } } +func TestWorkPackageTreatsDomainNamedOutputsUniformly(t *testing.T) { + work := delivery.WorkContract{ID: "accepted-work", Outputs: []delivery.WorkOutput{ + {ID: "architecture-plan", Path: "architecture-plan.md", Required: true, MaxBytes: 1}, + {ID: "tasks", Path: "tasks.json", Required: true, MaxBytes: 1}, + {ID: "verification-contract", Path: "verification.json", Required: true, MaxBytes: 1}, + {ID: "journey", Path: "journey.md", Required: true, MaxBytes: 1}, + }} + if err := validateWorkPackageContract(work); err != nil { + t.Fatal(err) + } +} + func TestPlanningPackageWorkRejectsContractAbovePortableMetadataBound(t *testing.T) { work := delivery.WorkContract{ID: "planning", InstructionPath: "instructions.md", InstructionSHA256: strings.Repeat("a", 64), InstructionContent: "Plan."} for index := 0; index < 17; index++ { diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/workpackage/package.go similarity index 92% rename from boatstack/flow/softwaredelivery/planningpackage/package.go rename to boatstack/flow/softwaredelivery/workpackage/package.go index 092dc33..e9b83a5 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/workpackage/package.go @@ -1,6 +1,6 @@ -// Package planningpackage owns the portable, content-addressed planning-package -// format. It is deliberately independent of live controller state and authority. -package planningpackage +// Package workpackage owns the portable, content-addressed accepted-work format. +// It is deliberately independent of live controller state and plan semantics. +package workpackage import ( "bytes" @@ -24,10 +24,10 @@ import ( ) const ( - ManifestSchemaVersion = 2 + ManifestSchemaVersion = 1 ContractSchemaVersion = 1 WorkReceiptSchemaVersion = 1 - ApprovalSchemaVersion = 2 + ApprovalSchemaVersion = 1 maxPackageMetadataBytes = 16 << 20 ) @@ -69,7 +69,6 @@ type WorkContract struct { type Contract struct { SchemaVersion int `json:"schema_version"` Work WorkContract `json:"work"` - PlanOutput string `json:"plan_output"` Fingerprint string `json:"fingerprint"` } @@ -88,13 +87,6 @@ type Reference struct { Path string `json:"path"` SHA256 string `json:"sha256"` } -type PlanOutput struct { - ID string `json:"id"` - Path string `json:"path"` - MediaType string `json:"media_type"` - SHA256 string `json:"sha256"` -} - type Manifest struct { SchemaVersion int `json:"schema_version"` DeliveryID string `json:"delivery_id"` @@ -109,7 +101,6 @@ type Manifest struct { WorkResultFingerprint string `json:"work_result_fingerprint"` ContextFingerprint string `json:"context_fingerprint"` StateRevision uint64 `json:"state_revision"` - PlanOutput PlanOutput `json:"plan_output"` Contract Reference `json:"contract"` WorkReceipt Reference `json:"work_receipt"` Outputs []Output `json:"outputs"` @@ -145,8 +136,6 @@ type Approval struct { DeliveryID string `json:"delivery_id"` PackageFingerprint string `json:"package_fingerprint"` ManifestFingerprint string `json:"manifest_fingerprint"` - PlanOutputID string `json:"plan_output_id"` - PlanFingerprint string `json:"plan_fingerprint"` AdmissionID string `json:"admission_id"` AuthoritySources []AuthoritySource `json:"authority_sources"` Actor string `json:"actor"` @@ -179,7 +168,7 @@ type Result struct { Diagnostics []string `json:"diagnostics,omitempty"` } -type CurrentProgram struct{ ProgramFingerprint, WorkContractFingerprint, PlanOutput string } +type CurrentProgram struct{ ProgramFingerprint, WorkContractFingerprint string } func Encode(value any) ([]byte, error) { raw, err := json.MarshalIndent(value, "", " ") @@ -223,13 +212,13 @@ func SealContract(value Contract) (Contract, []byte, error) { value.Fingerprint = fp raw, err := Encode(value) if err == nil && len(raw) > maxPackageMetadataBytes { - return Contract{}, nil, fmt.Errorf("planning package contract exceeds %d bytes", maxPackageMetadataBytes) + return Contract{}, nil, fmt.Errorf("work package contract exceeds %d bytes", maxPackageMetadataBytes) } return value, raw, err } -func ValidateContractMetadata(work WorkContract, planOutput string) error { - _, _, err := SealContract(Contract{Work: work, PlanOutput: planOutput}) +func ValidateContractMetadata(work WorkContract) error { + _, _, err := SealContract(Contract{Work: work}) return err } @@ -354,7 +343,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP if err != nil || !info.IsDir() { return fail("repository root is unavailable") } - packageRoot := filepath.Join(root, ".boatstack", "planning-packages", deliveryID, packageFingerprint) + packageRoot := filepath.Join(root, ".boatstack", "work-packages", deliveryID, packageFingerprint) if info, err = os.Lstat(packageRoot); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fail("package directory is unavailable or unsafe") } @@ -389,7 +378,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP contractIdentity := contract contractIdentity.Fingerprint = "" identityRaw, _ = Encode(contractIdentity) - if contract.SchemaVersion != ContractSchemaVersion || contract.Fingerprint != Digest(identityRaw) || contract.Work.ID != manifest.WorkContractID || contract.Work.Fingerprint != manifest.WorkContractFingerprint || contract.PlanOutput != manifest.PlanOutput.ID { + if contract.SchemaVersion != ContractSchemaVersion || contract.Fingerprint != Digest(identityRaw) || contract.Work.ID != manifest.WorkContractID || contract.Work.Fingerprint != manifest.WorkContractFingerprint { return fail("contract identity is invalid") } if err = validateContractAssets(contract.Work); err != nil { @@ -443,7 +432,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP } result.Integrity = Valid if current != nil { - if current.ProgramFingerprint == manifest.ProgramFingerprint && current.WorkContractFingerprint == manifest.WorkContractFingerprint && current.PlanOutput == manifest.PlanOutput.ID { + if current.ProgramFingerprint == manifest.ProgramFingerprint && current.WorkContractFingerprint == manifest.WorkContractFingerprint { result.CurrentProgram = Match } else { result.CurrentProgram = Different @@ -464,10 +453,8 @@ func validateManifestIdentity(manifest Manifest, deliveryID, packageFingerprint, return fmt.Errorf("semantic identity") case !ValidFingerprint(manifest.ProgramFingerprint) || !ValidFingerprint(manifest.WorkContractFingerprint) || !ValidFingerprint(manifest.WorkRequestFingerprint) || !ValidFingerprint(manifest.WorkResultFingerprint) || !ValidFingerprint(manifest.ContextFingerprint): return fmt.Errorf("lineage fingerprint") - case manifest.TransitionID != "planning.package.admit": + case manifest.TransitionID != "work.package.admit": return fmt.Errorf("transition identity") - case !ValidSegment(manifest.PlanOutput.ID) || !safeRelative(manifest.PlanOutput.Path) || manifest.PlanOutput.MediaType == "" || !ValidFingerprint(manifest.PlanOutput.SHA256): - return fmt.Errorf("plan output identity") default: return nil } @@ -508,7 +495,7 @@ func ValidateApproval(raw []byte, approval Approval, manifest Manifest, delivery if err != nil { return err } - if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(identityRaw) || approval.DeliveryID != deliveryID || approval.PackageFingerprint != packageFingerprint || approval.ManifestFingerprint != manifest.Fingerprint || approval.PlanOutputID != manifest.PlanOutput.ID || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(identityRaw) || approval.DeliveryID != deliveryID || approval.PackageFingerprint != packageFingerprint || approval.ManifestFingerprint != manifest.Fingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { return fmt.Errorf("lineage") } if len(approval.IdentityRole) == 0 || len(approval.IdentityRole) > 128 || !identityRole.MatchString(approval.IdentityRole) || !ValidFingerprint(approval.IdentityProviderFingerprint) { @@ -708,15 +695,6 @@ func verifyOutputs(root string, manifest Manifest, contract Contract, receipt Wo return fmt.Errorf("package is missing required output %q", id) } } - plan, ok := seen[manifest.PlanOutput.ID] - if !ok || !plan { - return fmt.Errorf("designated plan output is absent") - } - for _, o := range manifest.Outputs { - if o.ID == manifest.PlanOutput.ID && (o.Path != manifest.PlanOutput.Path || o.MediaType != manifest.PlanOutput.MediaType || o.SHA256 != manifest.PlanOutput.SHA256 || !o.Required) { - return fmt.Errorf("designated plan output is invalid") - } - } return nil } @@ -759,7 +737,7 @@ func rejectExtraFiles(root string, manifest Manifest, approval bool) error { } func Enumerate(repository string) ([][2]string, error) { - root := filepath.Join(repository, ".boatstack", "planning-packages") + root := filepath.Join(repository, ".boatstack", "work-packages") entries, err := os.ReadDir(root) if os.IsNotExist(err) { return nil, nil @@ -774,7 +752,7 @@ func Enumerate(repository string) ([][2]string, error) { } packages, err := os.ReadDir(filepath.Join(root, delivery.Name())) if err != nil { - return nil, fmt.Errorf("read planning-package delivery %q: %w", delivery.Name(), err) + return nil, fmt.Errorf("read work-package delivery %q: %w", delivery.Name(), err) } for _, pkg := range packages { if pkg.IsDir() && ValidFingerprint(pkg.Name()) { diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/workpackage/package_test.go similarity index 72% rename from boatstack/flow/softwaredelivery/planningpackage/package_test.go rename to boatstack/flow/softwaredelivery/workpackage/package_test.go index 05f3e19..e33bb9c 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package_test.go +++ b/boatstack/flow/softwaredelivery/workpackage/package_test.go @@ -1,4 +1,4 @@ -package planningpackage +package workpackage import ( "os" @@ -14,32 +14,45 @@ func installFixture(t *testing.T) (string, string, string, string) { repository := t.TempDir() delivery := "proof" plan := []byte("# plan\n") + architecture := []byte("# architecture\n") + tasks := []byte("{\"items\":[]}\n") + verification := []byte("# verification\n") + journey := []byte("# journey\n") programFP := strings.Repeat("b", 64) - output := Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: Digest(plan)} - work := WorkContract{ID: "planning", Instructions: Asset{Path: "package.md", SHA256: Digest([]byte("coordinate")), Content: "coordinate"}, Outputs: []WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}} + outputs := []Output{ + {ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: Digest(plan)}, + {ID: "architecture-plan", Path: "architecture.md", MediaType: "text/markdown", Required: true, Size: int64(len(architecture)), SHA256: Digest(architecture)}, + {ID: "tasks", Path: "tasks.json", MediaType: "application/json", Required: true, Size: int64(len(tasks)), SHA256: Digest(tasks)}, + {ID: "verification-contract", Path: "verification.md", MediaType: "text/markdown", Required: true, Size: int64(len(verification)), SHA256: Digest(verification)}, + {ID: "journey", Path: "journey.md", MediaType: "text/markdown", Required: true, Size: int64(len(journey)), SHA256: Digest(journey)}, + } + work := WorkContract{ID: "planning", Instructions: Asset{Path: "package.md", SHA256: Digest([]byte("coordinate")), Content: "coordinate"}} + for _, output := range outputs { + work.Outputs = append(work.Outputs, WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}) + } var err error work.Fingerprint, err = RuntimeWorkFingerprint(work) if err != nil { t.Fatal(err) } workFP := work.Fingerprint - _, contractRaw, err := SealContract(Contract{Work: work, PlanOutput: output.ID}) + _, contractRaw, err := SealContract(Contract{Work: work}) if err != nil { t.Fatal(err) } - _, receiptRaw, err := SealWorkReceipt(WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("c", 64), ResultFingerprint: strings.Repeat("d", 64), ContractID: "planning", ContractFingerprint: workFP, TransitionID: "planning.package.admit", ProgramFingerprint: programFP, ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []Output{output}}) + _, receiptRaw, err := SealWorkReceipt(WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("c", 64), ResultFingerprint: strings.Repeat("d", 64), ContractID: "planning", ContractFingerprint: workFP, TransitionID: "work.package.admit", ProgramFingerprint: programFP, ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: outputs}) if err != nil { t.Fatal(err) } - manifest, manifestRaw, err := SealManifest(Manifest{DeliveryID: delivery, ProgramID: "program", ProgramFingerprint: programFP, EntryID: "run", RunID: "run-proof", TransitionID: "planning.package.admit", WorkContractID: "planning", WorkContractFingerprint: workFP, WorkRequestFingerprint: strings.Repeat("c", 64), WorkResultFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, PlanOutput: PlanOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256}, Contract: Reference{Path: "contract.json", SHA256: Digest(contractRaw)}, WorkReceipt: Reference{Path: "work-receipt.json", SHA256: Digest(receiptRaw)}, Outputs: []Output{output}}) + manifest, manifestRaw, err := SealManifest(Manifest{DeliveryID: delivery, ProgramID: "program", ProgramFingerprint: programFP, EntryID: "run", RunID: "run-proof", TransitionID: "work.package.admit", WorkContractID: "planning", WorkContractFingerprint: workFP, WorkRequestFingerprint: strings.Repeat("c", 64), WorkResultFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, Contract: Reference{Path: "contract.json", SHA256: Digest(contractRaw)}, WorkReceipt: Reference{Path: "work-receipt.json", SHA256: Digest(receiptRaw)}, Outputs: outputs}) if err != nil { t.Fatal(err) } - root := filepath.Join(repository, ".boatstack", "planning-packages", delivery, manifest.Fingerprint) + root := filepath.Join(repository, ".boatstack", "work-packages", delivery, manifest.Fingerprint) if err := os.MkdirAll(root, 0o700); err != nil { t.Fatal(err) } - for name, raw := range map[string][]byte{"manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, "plan.md": plan} { + for name, raw := range map[string][]byte{"manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, "plan.md": plan, "architecture.md": architecture, "tasks.json": tasks, "verification.md": verification, "journey.md": journey} { if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil { t.Fatal(err) } @@ -49,7 +62,7 @@ func installFixture(t *testing.T) (string, string, string, string) { func TestVerifyRejectsContradictoryEmbeddedWorkContractIdentity(t *testing.T) { repository, delivery, fingerprint, _ := installFixture(t) - oldRoot := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint) + oldRoot := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint) contractRaw, _ := os.ReadFile(filepath.Join(oldRoot, "contract.json")) var contract Contract if err := StrictDecode(contractRaw, &contract); err != nil { @@ -93,13 +106,8 @@ func TestVerifyRejectsContradictoryEmbeddedWorkContractIdentity(t *testing.T) { func TestVerifyApprovalUsesAuthorityReceiptContract(t *testing.T) { repository, delivery, fingerprint, _ := installFixture(t) - root := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint) - manifestRaw, _ := os.ReadFile(filepath.Join(root, "manifest.json")) - var manifest Manifest - if err := StrictDecode(manifestRaw, &manifest); err != nil { - t.Fatal(err) - } - approval := Approval{DeliveryID: delivery, PackageFingerprint: fingerprint, ManifestFingerprint: fingerprint, PlanOutputID: manifest.PlanOutput.ID, PlanFingerprint: manifest.PlanOutput.SHA256, AdmissionID: "admission", AuthoritySources: []AuthoritySource{{ID: "human", Class: "human", Subject: "operator", Fingerprint: "human-proof"}}, Actor: "operator", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), ApprovedAt: time.Unix(100, 0).UTC()} + root := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint) + approval := Approval{DeliveryID: delivery, PackageFingerprint: fingerprint, ManifestFingerprint: fingerprint, AdmissionID: "admission", AuthoritySources: []AuthoritySource{{ID: "human", Class: "human", Subject: "operator", Fingerprint: "human-proof"}}, Actor: "operator", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), ApprovedAt: time.Unix(100, 0).UTC()} _, raw, _ := SealApproval(approval) if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { t.Fatal(err) @@ -133,9 +141,26 @@ func TestVerifyApprovalUsesAuthorityReceiptContract(t *testing.T) { } } +func TestGenericContractManifestAndApprovalContainNoPlanFields(t *testing.T) { + repository, delivery, fingerprint, _ := installFixture(t) + root := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint) + manifestRaw, _ := os.ReadFile(filepath.Join(root, "manifest.json")) + approval := Approval{DeliveryID: delivery, PackageFingerprint: fingerprint, ManifestFingerprint: fingerprint, AdmissionID: "admission", AuthoritySources: []AuthoritySource{{ID: "human", Class: "human", Subject: "operator", Fingerprint: "human-proof"}}, Actor: "operator", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), ApprovedAt: time.Unix(100, 0).UTC()} + _, approvalRaw, err := SealApproval(approval) + if err != nil { + t.Fatal(err) + } + contractRaw, _ := os.ReadFile(filepath.Join(root, "contract.json")) + for name, raw := range map[string][]byte{"contract": contractRaw, "manifest": manifestRaw, "approval": approvalRaw} { + if strings.Contains(string(raw), "plan_output") || strings.Contains(string(raw), "plan_fingerprint") { + t.Fatalf("%s contains privileged plan fields: %s", name, raw) + } + } +} + func TestVerifyRejectsContractOutsideRuntimeABI(t *testing.T) { repository, delivery, fingerprint, _ := installFixture(t) - oldRoot := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint) + oldRoot := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint) contractRaw, _ := os.ReadFile(filepath.Join(oldRoot, "contract.json")) var contract Contract if err := StrictDecode(contractRaw, &contract); err != nil { @@ -173,7 +198,7 @@ func TestEnumeratePropagatesDeliveryReadFailure(t *testing.T) { t.Skip("permission mode is Unix-specific") } repository := t.TempDir() - delivery := filepath.Join(repository, ".boatstack", "planning-packages", "proof") + delivery := filepath.Join(repository, ".boatstack", "work-packages", "proof") if err := os.MkdirAll(delivery, 0o700); err != nil { t.Fatal(err) } @@ -188,7 +213,7 @@ func TestEnumeratePropagatesDeliveryReadFailure(t *testing.T) { func TestVerifySeparatesIntegrityApprovalAndCurrentProgram(t *testing.T) { repository, delivery, fingerprint, workFingerprint := installFixture(t) - result := Verify(repository, delivery, fingerprint, &CurrentProgram{ProgramFingerprint: strings.Repeat("b", 64), WorkContractFingerprint: workFingerprint, PlanOutput: "implementation-plan"}) + result := Verify(repository, delivery, fingerprint, &CurrentProgram{ProgramFingerprint: strings.Repeat("b", 64), WorkContractFingerprint: workFingerprint}) if result.Integrity != Valid || result.Contract != Valid || result.Approval != Missing || result.CurrentProgram != Match || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" { t.Fatalf("result=%#v", result) } @@ -252,7 +277,7 @@ func TestVerifyRejectsIndependentTampering(t *testing.T) { for _, name := range cases { t.Run(name, func(t *testing.T) { repository, delivery, fingerprint, _ := installFixture(t) - path := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint, name) + path := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint, name) raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) @@ -270,7 +295,7 @@ func TestVerifyRejectsIndependentTampering(t *testing.T) { func TestVerifyRejectsUndeclaredAndSymlinkMembers(t *testing.T) { for _, symlink := range []bool{false, true} { repository, delivery, fingerprint, _ := installFixture(t) - path := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint, "extra") + path := filepath.Join(repository, ".boatstack", "work-packages", delivery, fingerprint, "extra") var err error if symlink { err = os.Symlink("plan.md", path) diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 41a3285..b2c7a5b 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -46,6 +46,11 @@ func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing } } for _, facet := range model.ControllingFacets() { + if facet == model.FacetWorkPackage { + // Accepted work is an optional software-delivery adapter capability. + // StandardFlow does not select or infer its lifecycle. + continue + } if !classified[facet] { t.Errorf("controlling facet %s is absent from executable predicates", facet) } diff --git a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go index 64b9c83..30c6baa 100644 --- a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go +++ b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go @@ -29,8 +29,8 @@ var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{ bindsRequestedObjective: true, }, "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), - "planning-package-admit": standardNative([]EffectID{"planning.package.admit"}, ObjectiveScopeBoundExact), - "planning-package-approve": standardNative([]EffectID{"planning.package.approve"}, ObjectiveScopeBoundExact), + "work-package-admit": standardNative([]EffectID{"work.package.admit"}, ObjectiveScopeBoundExact), + "work-package-approve": standardNative([]EffectID{"work.package.approve"}, ObjectiveScopeBoundExact), "planning-package-promote": standardNative([]EffectID{"planning.package.promote"}, ObjectiveScopeBoundExact), "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact), "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact), diff --git a/boatstack/internal/softwaredelivery/catalog/state_facet.go b/boatstack/internal/softwaredelivery/catalog/state_facet.go index 2944321..c6f71ac 100644 --- a/boatstack/internal/softwaredelivery/catalog/state_facet.go +++ b/boatstack/internal/softwaredelivery/catalog/state_facet.go @@ -69,6 +69,8 @@ func ValidDeclaredStateLiteral(field, value string) bool { return model.WorkspaceState(value).Valid() case "plan": return model.PlanState(value).Valid() + case "work_package": + return model.WorkPackageState(value).Valid() case "configuration": return model.ConfigurationState(value).Valid() case "runtime": @@ -104,6 +106,8 @@ func DeclaredStateResolverFacet(field string) (model.FacetName, bool) { return model.FacetWorkspace, true case "plan": return model.FacetPlan, true + case "work_package": + return model.FacetWorkPackage, true case "configuration": return model.FacetConfiguration, true case "runtime": diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index 8c5340a..bf28803 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -326,7 +326,7 @@ func (c FacetCondition) Evaluate(snapshot model.Snapshot) (bool, string) { 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.FacetDelivery, model.FacetWorkspace, model.FacetWorkPackage, model.FacetPlan, model.FacetConfiguration, model.FacetRuntime, model.FacetPublication, model.FacetVerification, model.FacetRecovery, model.FacetTransaction, model.FacetTerminal: return true diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index 8d3cbbe..0c57cf3 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -14,14 +14,13 @@ import ( ) const ( - StateSchemaVersion = 7 - priorStateSchemaVersion = 6 + StateSchemaVersion = 8 ) -// CanReadStateSchema reports the current schema and its single supported -// forward-migration predecessor. Older or future schemas remain fail-closed. +// CanReadStateSchema reports only the current alpha schema. Breaking schema +// changes are intentional and no migration reader is retained. func CanReadStateSchema(version int) bool { - return version == StateSchemaVersion || version == priorStateSchemaVersion + return version == StateSchemaVersion } type GateEvidence struct { @@ -31,66 +30,68 @@ type GateEvidence struct { } type State struct { - SchemaVersion int `json:"schema_version"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - WorktreeID string `json:"worktree_id"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - ProgramHumanIdentityRole string `json:"program_human_identity_role,omitempty"` - ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` - Revision uint64 `json:"revision"` - Phase model.ProtocolPhase `json:"phase"` - Engagement model.EngagementState `json:"engagement"` - Delivery model.DeliveryState `json:"delivery"` - Workspace model.WorkspaceState `json:"workspace"` - Plan model.PlanState `json:"plan"` - Configuration model.ConfigurationState `json:"configuration"` - Runtime model.RuntimeState `json:"runtime"` - Publication model.PublicationState `json:"publication"` - Verification model.VerificationState `json:"verification"` - Recovery model.RecoveryState `json:"recovery"` - Transaction model.TransactionState `json:"transaction"` - Terminal model.TerminalStatus `json:"terminal"` - Objective model.Objective `json:"objective"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - ConfigFingerprint string `json:"config_fingerprint,omitempty"` - PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` - VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` - ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` - IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` - EnabledHosts []string `json:"enabled_hosts,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSource string `json:"runtime_source_revision,omitempty"` - PlanFingerprint string `json:"plan_fingerprint,omitempty"` - PlanningPackageFingerprint string `json:"planning_package_fingerprint,omitempty"` - ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` - WorkspaceBranch string `json:"workspace_branch,omitempty"` - WorkspacePath string `json:"workspace_path,omitempty"` - WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` - WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` - WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` - WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` - PublicationID string `json:"publication_id,omitempty"` - PublicationURL string `json:"publication_url,omitempty"` - PreviewFingerprint string `json:"preview_fingerprint,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - TransactionTransition string `json:"transaction_transition,omitempty"` - RecoveryCause string `json:"recovery_cause,omitempty"` - RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` - RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` - RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` - LastTransition catalog.TransitionID `json:"last_transition,omitempty"` - Gates []GateEvidence `json:"gates,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + SchemaVersion int `json:"schema_version"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + ProgramHumanIdentityRole string `json:"program_human_identity_role,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + Revision uint64 `json:"revision"` + Phase model.ProtocolPhase `json:"phase"` + Engagement model.EngagementState `json:"engagement"` + Delivery model.DeliveryState `json:"delivery"` + Workspace model.WorkspaceState `json:"workspace"` + WorkPackage model.WorkPackageState `json:"work_package"` + Plan model.PlanState `json:"plan"` + Configuration model.ConfigurationState `json:"configuration"` + Runtime model.RuntimeState `json:"runtime"` + Publication model.PublicationState `json:"publication"` + Verification model.VerificationState `json:"verification"` + Recovery model.RecoveryState `json:"recovery"` + Transaction model.TransactionState `json:"transaction"` + Terminal model.TerminalStatus `json:"terminal"` + Objective model.Objective `json:"objective"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + ConfigFingerprint string `json:"config_fingerprint,omitempty"` + PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` + VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` + ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` + IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` + EnabledHosts []string `json:"enabled_hosts,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSource string `json:"runtime_source_revision,omitempty"` + PlanFingerprint string `json:"plan_fingerprint,omitempty"` + WorkPackageFingerprint string `json:"work_package_fingerprint,omitempty"` + WorkPackageApprovalFingerprint string `json:"work_package_approval_fingerprint,omitempty"` + ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` + WorkspaceBranch string `json:"workspace_branch,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` + WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` + WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` + WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` + PublicationID string `json:"publication_id,omitempty"` + PublicationURL string `json:"publication_url,omitempty"` + PreviewFingerprint string `json:"preview_fingerprint,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + TransactionTransition string `json:"transaction_transition,omitempty"` + RecoveryCause string `json:"recovery_cause,omitempty"` + RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` + RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` + RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` + LastTransition catalog.TransitionID `json:"last_transition,omitempty"` + Gates []GateEvidence `json:"gates,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } func Default(invocation model.InvocationContext, now time.Time) State { return State{ SchemaVersion: StateSchemaVersion, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, WorktreeID: invocation.WorktreeID, Revision: 1, Phase: model.PhaseDormant, Engagement: model.EngagementDormant, Delivery: model.DeliveryUninitialized, - Workspace: model.WorkspaceAbsent, Plan: model.PlanAbsent, Configuration: model.ConfigurationUnsupported, + Workspace: model.WorkspaceAbsent, WorkPackage: model.WorkPackageAbsent, Plan: model.PlanAbsent, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, UpdatedAt: now.UTC(), } @@ -127,13 +128,19 @@ func (s State) Validate() error { if s.ControlBundleFingerprint != "" && len(s.ControlBundleFingerprint) != 64 { return fmt.Errorf("durable state has invalid control-bundle fingerprint") } - if s.PlanningPackageFingerprint != "" && len(s.PlanningPackageFingerprint) != 64 { - return fmt.Errorf("durable state has invalid planning package fingerprint") + if s.WorkPackageFingerprint != "" && len(s.WorkPackageFingerprint) != 64 { + return fmt.Errorf("durable state has invalid work package fingerprint") } - if s.Plan == model.PlanPackageApproved && s.PlanningPackageFingerprint == "" { - return fmt.Errorf("approved planning package requires an exact package fingerprint") + if s.WorkPackageApprovalFingerprint != "" && len(s.WorkPackageApprovalFingerprint) != 64 { + return fmt.Errorf("durable state has invalid work package approval fingerprint") } - if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.Plan.Valid() || + if s.WorkPackage != model.WorkPackageAbsent && s.WorkPackageFingerprint == "" { + return fmt.Errorf("admitted work package requires an exact package fingerprint") + } + if s.WorkPackage == model.WorkPackageApproved && s.WorkPackageApprovalFingerprint == "" { + return fmt.Errorf("approved work package requires an exact approval fingerprint") + } + if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.WorkPackage.Valid() || !s.Plan.Valid() || !s.Configuration.Valid() || !s.Runtime.Valid() || !s.Publication.Valid() || !s.Verification.Valid() || !s.Recovery.Valid() || !s.Transaction.Valid() || !s.Terminal.Valid() { return fmt.Errorf("durable state contains an invalid controlling value") @@ -251,12 +258,6 @@ func DecodeState(value []byte) (State, error) { if err := decoder.Decode(&trailing); err != io.EOF { return State{}, fmt.Errorf("durable state contains trailing JSON") } - if state.SchemaVersion == priorStateSchemaVersion { - if state.ProgramHumanIdentityRole != "" { - return State{}, fmt.Errorf("durable state schema %d contains later identity fields", priorStateSchemaVersion) - } - state.SchemaVersion = StateSchemaVersion - } if err := state.Validate(); err != nil { return State{}, err } diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index 105b293..11feb2f 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -13,7 +13,7 @@ var stateFieldFacets = map[string]model.StateFacet{ "ProgramHumanIdentityRole": model.StateFacetProgram, "ControlBundleFingerprint": model.StateFacetControl, "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, - "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "Plan": model.StateFacetProduct, + "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "WorkPackage": model.StateFacetProduct, "Plan": model.StateFacetProduct, "Configuration": model.StateFacetControl, "Runtime": model.StateFacetInstallation, "Publication": model.StateFacetProduct, "Verification": model.StateFacetProduct, @@ -23,7 +23,7 @@ var stateFieldFacets = map[string]model.StateFacet{ "ConfigFingerprint": model.StateFacetControl, "PlanApprovalPolicy": model.StateFacetControl, "VisualEvidencePolicy": model.StateFacetControl, "ExternalEffectPolicy": model.StateFacetControl, "IndependentReview": model.StateFacetControl, "EnabledHosts": model.StateFacetControl, "RuntimeVersion": model.StateFacetInstallation, "RuntimeFingerprint": model.StateFacetInstallation, "RuntimeSource": model.StateFacetInstallation, - "PlanFingerprint": model.StateFacetProduct, "PlanningPackageFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, + "PlanFingerprint": model.StateFacetProduct, "WorkPackageFingerprint": model.StateFacetProduct, "WorkPackageApprovalFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, "WorkspaceBranch": model.StateFacetProduct, "WorkspacePath": model.StateFacetProduct, "WorkspaceBaseRef": model.StateFacetProduct, "WorkspaceSourcePath": model.StateFacetProduct, "WorkspaceSourceID": model.StateFacetProduct, "WorkspaceSourceRef": model.StateFacetProduct, "PublicationID": model.StateFacetProduct, "PublicationURL": model.StateFacetProduct, "PreviewFingerprint": model.StateFacetProduct, diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index ed086c3..7865bb3 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -1,8 +1,7 @@ package durable import ( - "bytes" - "encoding/json" + "strings" "testing" "time" @@ -19,7 +18,7 @@ func TestStateRejectsPriorObjectiveSchema(t *testing.T) { func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *testing.T) { state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 1, - Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, WorkPackage: model.WorkPackageAbsent, Plan: model.PlanApproved, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, PlanFingerprint: "legacy-plan", UpdatedAt: time.Unix(1, 0).UTC(), @@ -37,10 +36,10 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test } } -func TestDecodeStatePromotesExactBaseSchemaWithoutChangingPriorBytes(t *testing.T) { +func TestDecodeStateRejectsSchemaSevenWithoutMigration(t *testing.T) { state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, - Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, WorkPackage: model.WorkPackageAbsent, Plan: model.PlanApproved, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, PlanFingerprint: "legacy-plan", UpdatedAt: time.Unix(1, 0).UTC(), @@ -49,27 +48,9 @@ func TestDecodeStatePromotesExactBaseSchemaWithoutChangingPriorBytes(t *testing. if err != nil { t.Fatal(err) } - var legacy map[string]any - if err := json.Unmarshal(current, &legacy); err != nil { - t.Fatal(err) - } - legacy["schema_version"] = float64(priorStateSchemaVersion) - delete(legacy, "program_human_identity_role") - prior, err := json.MarshalIndent(legacy, "", " ") - if err != nil { - t.Fatal(err) - } - prior = append(prior, '\n') - rollback := append([]byte(nil), prior...) - decoded, err := DecodeState(prior) - if err != nil { - t.Fatal(err) - } - if decoded.SchemaVersion != StateSchemaVersion || decoded.Revision != state.Revision || decoded.Objective != state.Objective { - t.Fatalf("promoted state = %#v", decoded) - } - if !bytes.Equal(prior, rollback) { - t.Fatal("schema promotion changed prior rollback bytes") + prior := []byte(strings.Replace(string(current), `"schema_version": 8`, `"schema_version": 7`, 1)) + if _, err := DecodeState(prior); err == nil { + t.Fatal("schema 7 was migrated instead of rejected") } } diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index bab4357..4bec28d 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -12,7 +12,7 @@ import ( "strings" "time" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" @@ -50,6 +50,30 @@ type approvalArtifact struct { ApprovedAt time.Time `json:"approved_at"` } +type planPromotionReceipt struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + WorkPackageFingerprint string `json:"work_package_fingerprint"` + WorkPackageApprovalFingerprint string `json:"work_package_approval_fingerprint"` + PlanOutputID string `json:"plan_output_id"` + AdmissionID string `json:"admission_id"` + PromotedAt time.Time `json:"promoted_at"` + Fingerprint string `json:"fingerprint"` +} + +func sealPlanPromotionReceipt(value planPromotionReceipt) (planPromotionReceipt, []byte, error) { + value.SchemaVersion = 2 + value.Fingerprint = "" + identity, err := encodeJSON(value) + if err != nil { + return planPromotionReceipt{}, nil, err + } + value.Fingerprint = sha256Bytes(identity) + raw, err := encodeJSON(value) + return value, raw, err +} + type gateArtifact struct { SchemaVersion int `json:"schema_version"` DeliveryID string `json:"delivery_id"` @@ -172,33 +196,32 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } mutations = append(mutations, approvalMutation) state.PlanFingerprint = fingerprint - state.PlanningPackageFingerprint = "" + state.WorkPackage, state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint = model.WorkPackageAbsent, "", "" state.ApprovalFingerprint = "" - case "planning.package.admit": + case "work.package.admit": if admission.Work == nil || len(admission.Work.Outputs) == 0 { - return nil, fmt.Errorf("planning package admission requires exact foreground work evidence") + return nil, fmt.Errorf("work package admission requires exact foreground work evidence") } - planOutput, _ := admission.Parameters.Get("plan_output") - manifest, files, buildErr := buildPlanningPackage(admission, transition, deliveryID, planOutput) + manifest, files, buildErr := buildWorkPackage(admission, transition, deliveryID) if buildErr != nil { return nil, buildErr } - packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID, manifest.Fingerprint) + packageRoot := filepath.Join(artifactRoot, "work-packages", deliveryID, manifest.Fingerprint) rootExists := false if info, statErr := os.Lstat(packageRoot); statErr == nil { if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("immutable planning package root is unsafe") + return nil, fmt.Errorf("immutable work package root is unsafe") } rootExists = true - verified := planningpackage.Verify(layout.RepositoryRoot, deliveryID, manifest.Fingerprint, nil) - if verified.Integrity != planningpackage.Valid || verified.Contract != planningpackage.Valid || verified.Approval == planningpackage.Invalid { - return nil, fmt.Errorf("existing immutable planning package conflicts: %s", strings.Join(verified.Diagnostics, "; ")) + verified := workpackage.Verify(layout.RepositoryRoot, deliveryID, manifest.Fingerprint, nil) + if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval == workpackage.Invalid { + return nil, fmt.Errorf("existing immutable work package conflicts: %s", strings.Join(verified.Diagnostics, "; ")) } } else if !os.IsNotExist(statErr) { return nil, statErr } for relative, raw := range files { - mutation, mutationErr := immutablePlanningMutation(filepath.Join(packageRoot, filepath.FromSlash(relative)), raw) + mutation, mutationErr := immutableWorkPackageMutation(filepath.Join(packageRoot, filepath.FromSlash(relative)), raw) if mutationErr != nil { return nil, mutationErr } @@ -210,22 +233,22 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } mutations = append(mutations, mutation) } - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, "" - case "planning.package.approve": + state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint = manifest.Fingerprint, "" + case "work.package.approve": expected, _ := admission.Parameters.Get("package_fingerprint") - manifest, loadErr := loadPlanningPackageManifestV2(artifactRoot, deliveryID, expected) + manifest, loadErr := loadWorkPackageManifest(artifactRoot, deliveryID, expected) if loadErr != nil { return nil, loadErr } if expected == "" || expected != manifest.Fingerprint { - return nil, fmt.Errorf("planning package approval fingerprint is stale") + return nil, fmt.Errorf("work package approval fingerprint is stale") } - if state.PlanningPackageFingerprint != manifest.Fingerprint { - return nil, fmt.Errorf("planning package state fingerprint is stale") + if state.WorkPackageFingerprint != manifest.Fingerprint { + return nil, fmt.Errorf("work package state fingerprint is stale") } - approval := planningpackage.Approval{DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint, PlanOutputID: manifest.PlanOutput.ID, PlanFingerprint: manifest.PlanOutput.SHA256, AdmissionID: admission.ID, ApprovedAt: admission.IssuedAt.UTC()} + approval := workpackage.Approval{DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint, AdmissionID: admission.ID, ApprovedAt: admission.IssuedAt.UTC()} for _, receipt := range admission.Authority.Receipts { - approval.AuthoritySources = append(approval.AuthoritySources, planningpackage.AuthoritySource{ID: receipt.ID, Class: string(receipt.Class), Subject: receipt.Subject, Fingerprint: receipt.Fingerprint}) + approval.AuthoritySources = append(approval.AuthoritySources, workpackage.AuthoritySource{ID: receipt.ID, Class: string(receipt.Class), Subject: receipt.Subject, Fingerprint: receipt.Fingerprint}) } expectedRole := "" if len(humanIdentityRole) > 0 { @@ -237,10 +260,10 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio continue } if expectedRole != "" && receipt.IdentityRole != expectedRole { - return nil, fmt.Errorf("planning package approval identity role %q does not match admitted program role %q", receipt.IdentityRole, expectedRole) + return nil, fmt.Errorf("work package approval identity role %q does not match admitted program role %q", receipt.IdentityRole, expectedRole) } if approval.Actor != "" && (receipt.Subject != approval.Actor || receipt.IdentityRole != approval.IdentityRole || receipt.IdentityProviderFingerprint != approval.IdentityProviderFingerprint) { - return nil, fmt.Errorf("planning package approval identity provenance is ambiguous") + return nil, fmt.Errorf("work package approval identity provenance is ambiguous") } approval.Actor, approval.IdentityRole, approval.IdentityProviderFingerprint = receipt.Subject, receipt.IdentityRole, receipt.IdentityProviderFingerprint } @@ -249,91 +272,114 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } } if approval.IdentityRole == "" || approval.IdentityProviderFingerprint == "" { - return nil, fmt.Errorf("planning package approval requires admitted identity provenance matching actor %q", approval.Actor) + return nil, fmt.Errorf("work package approval requires admitted identity provenance matching actor %q", approval.Actor) } - approval, raw, encodeErr := planningpackage.SealApproval(approval) + approval, raw, encodeErr := workpackage.SealApproval(approval) if encodeErr != nil { return nil, encodeErr } - approvalPath := filepath.Join(artifactRoot, "planning-packages", deliveryID, manifest.Fingerprint, "approval.json") - mutation, mutationErr := immutablePlanningMutation(approvalPath, raw) + approvalPath := filepath.Join(artifactRoot, "work-packages", deliveryID, manifest.Fingerprint, "approval.json") + mutation, mutationErr := immutableWorkPackageMutation(approvalPath, raw) if mutationErr != nil { return nil, mutationErr } if !mutation.PriorExists { mutations = append(mutations, mutation) } - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, sha256Bytes(raw) + state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint = manifest.Fingerprint, approval.Fingerprint case "planning.package.promote": - packagePath := filepath.Join(artifactRoot, "planning-packages", deliveryID, state.PlanningPackageFingerprint) + planOutputID, _ := admission.Parameters.Get("plan_output") + if planOutputID == "" { + return nil, fmt.Errorf("planning promotion requires a compiler-bound plan output") + } + packagePath := filepath.Join(artifactRoot, "work-packages", deliveryID, state.WorkPackageFingerprint) packageRoot, openErr := os.OpenRoot(packagePath) if openErr != nil { - return nil, fmt.Errorf("open immutable planning package: %w", openErr) + return nil, fmt.Errorf("open immutable work package: %w", openErr) } defer packageRoot.Close() - verified := verifyPlanningPackage(layout.RepositoryRoot, deliveryID, state.PlanningPackageFingerprint, nil) - if verified.Integrity != planningpackage.Valid || verified.Contract != planningpackage.Valid || verified.Approval != planningpackage.Valid { - return nil, fmt.Errorf("planning package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) + verified := verifyWorkPackage(layout.RepositoryRoot, deliveryID, state.WorkPackageFingerprint, nil) + if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { + return nil, fmt.Errorf("work package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) } pinnedInfo, pinnedErr := packageRoot.Stat(".") currentInfo, currentErr := os.Stat(packagePath) if pinnedErr != nil || currentErr != nil || !os.SameFile(pinnedInfo, currentInfo) { - return nil, fmt.Errorf("immutable planning package changed during verification") + return nil, fmt.Errorf("immutable work package changed during verification") } - snapshotRepository, cleanupSnapshot, snapshotErr := capturePinnedPlanningPackage(packageRoot, deliveryID, state.PlanningPackageFingerprint) + snapshotRepository, cleanupSnapshot, snapshotErr := capturePinnedWorkPackage(packageRoot, deliveryID, state.WorkPackageFingerprint) if snapshotErr != nil { return nil, snapshotErr } defer cleanupSnapshot() currentInfo, currentErr = os.Stat(packagePath) if currentErr != nil || !os.SameFile(pinnedInfo, currentInfo) { - return nil, fmt.Errorf("immutable planning package changed during verification") + return nil, fmt.Errorf("immutable work package changed during verification") } - verified = verifyPlanningPackage(snapshotRepository, deliveryID, state.PlanningPackageFingerprint, nil) - if verified.Integrity != planningpackage.Valid || verified.Contract != planningpackage.Valid || verified.Approval != planningpackage.Valid { - return nil, fmt.Errorf("planning package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) + verified = verifyWorkPackage(snapshotRepository, deliveryID, state.WorkPackageFingerprint, nil) + if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { + return nil, fmt.Errorf("work package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) } - snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "planning-packages", deliveryID, state.PlanningPackageFingerprint) + snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "work-packages", deliveryID, state.WorkPackageFingerprint) manifestRaw, loadErr := os.ReadFile(filepath.Join(snapshotRoot, "manifest.json")) if loadErr != nil { return nil, loadErr } - var manifest planningpackage.Manifest - if decodeErr := planningpackage.StrictDecode(manifestRaw, &manifest); decodeErr != nil { - return nil, fmt.Errorf("decode pinned planning package manifest: %w", decodeErr) + var manifest workpackage.Manifest + if decodeErr := workpackage.StrictDecode(manifestRaw, &manifest); decodeErr != nil { + return nil, fmt.Errorf("decode pinned work package manifest: %w", decodeErr) } - canonicalManifestRaw, manifestEncodeErr := planningpackage.Encode(manifest) + canonicalManifestRaw, manifestEncodeErr := workpackage.Encode(manifest) manifestIdentity := manifest manifestIdentity.Fingerprint = "" - manifestIdentityRaw, manifestIdentityEncodeErr := planningpackage.Encode(manifestIdentity) - if manifestEncodeErr != nil || manifestIdentityEncodeErr != nil || !bytes.Equal(manifestRaw, canonicalManifestRaw) || manifest.Fingerprint != state.PlanningPackageFingerprint || sha256Bytes(manifestIdentityRaw) != manifest.Fingerprint { - return nil, fmt.Errorf("pinned planning package manifest does not bind durable state") + manifestIdentityRaw, manifestIdentityEncodeErr := workpackage.Encode(manifestIdentity) + if manifestEncodeErr != nil || manifestIdentityEncodeErr != nil || !bytes.Equal(manifestRaw, canonicalManifestRaw) || manifest.Fingerprint != state.WorkPackageFingerprint || sha256Bytes(manifestIdentityRaw) != manifest.Fingerprint { + return nil, fmt.Errorf("pinned work package manifest does not bind durable state") } approvalRaw, readErr := os.ReadFile(filepath.Join(snapshotRoot, "approval.json")) if readErr != nil { - return nil, fmt.Errorf("read planning package approval: %w", readErr) + return nil, fmt.Errorf("read work package approval: %w", readErr) } - var approval planningpackage.Approval - if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil { - return nil, fmt.Errorf("decode planning package approval: %w", decodeErr) + var approval workpackage.Approval + if decodeErr := workpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil { + return nil, fmt.Errorf("decode work package approval: %w", decodeErr) } - if approvalErr := planningpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.PlanningPackageFingerprint); approvalErr != nil || sha256Bytes(approvalRaw) != state.ApprovalFingerprint { - return nil, fmt.Errorf("planning package approval does not bind the exact package") + if approvalErr := workpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.WorkPackageFingerprint); approvalErr != nil || approval.Fingerprint != state.WorkPackageApprovalFingerprint { + return nil, fmt.Errorf("work package approval does not bind the exact package") } - planRaw, readErr := os.ReadFile(filepath.Join(snapshotRoot, filepath.FromSlash(manifest.PlanOutput.Path))) - if readErr != nil || sha256Bytes(planRaw) != manifest.PlanOutput.SHA256 { - return nil, fmt.Errorf("planning package plan changed after approval") + var selected workpackage.Output + for _, output := range manifest.Outputs { + if output.ID == planOutputID { + selected = output + break + } + } + if selected.ID == "" || !selected.Required { + return nil, fmt.Errorf("compiler-bound plan output %q is absent or optional", planOutputID) + } + planRaw, readErr := os.ReadFile(filepath.Join(snapshotRoot, filepath.FromSlash(selected.Path))) + if readErr != nil || sha256Bytes(planRaw) != selected.SHA256 { + return nil, fmt.Errorf("work package plan changed after approval") + } + promotion, promotionRaw, promotionErr := sealPlanPromotionReceipt(planPromotionReceipt{ + DeliveryID: deliveryID, PlanFingerprint: selected.SHA256, + WorkPackageFingerprint: manifest.Fingerprint, WorkPackageApprovalFingerprint: approval.Fingerprint, + PlanOutputID: selected.ID, AdmissionID: admission.ID, PromotedAt: admission.IssuedAt.UTC(), + }) + if promotionErr != nil { + return nil, promotionErr } planMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) if mutationErr != nil { return nil, mutationErr } - approvalMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), approvalRaw, 0o644, false, false) + approvalMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), promotionRaw, 0o644, false, false) if mutationErr != nil { return nil, mutationErr } mutations = append(mutations, planMutation, approvalMutation) - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, sha256Bytes(approvalRaw) + state.PlanFingerprint, state.ApprovalFingerprint = selected.SHA256, sha256Bytes(promotionRaw) + _ = promotion case "plan.validate": path := filepath.Join(artifactRoot, "plans", deliveryID+".source") raw, readErr := os.ReadFile(path) @@ -341,7 +387,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, fmt.Errorf("validate source plan %s: %w", path, readErr) } state.PlanFingerprint = sha256Bytes(raw) - state.PlanningPackageFingerprint = "" + state.WorkPackage, state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint = model.WorkPackageAbsent, "", "" case "plan.approve", "plan.approve-amendment": fingerprint, _ := admission.Parameters.Get("plan_fingerprint") actor, _ := admission.Parameters.Get("actor") @@ -506,15 +552,15 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return mutations, nil } -var verifyPlanningPackage = planningpackage.Verify +var verifyWorkPackage = workpackage.Verify -func capturePinnedPlanningPackage(root *os.Root, deliveryID, fingerprint string) (string, func(), error) { - repository, err := os.MkdirTemp("", "boatstack-planning-package-snapshot-") +func capturePinnedWorkPackage(root *os.Root, deliveryID, fingerprint string) (string, func(), error) { + repository, err := os.MkdirTemp("", "boatstack-work-package-snapshot-") if err != nil { return "", nil, err } cleanup := func() { _ = os.RemoveAll(repository) } - destination := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, fingerprint) + destination := filepath.Join(repository, ".boatstack", "work-packages", deliveryID, fingerprint) if err := os.MkdirAll(destination, 0o700); err != nil { cleanup() return "", nil, err @@ -527,7 +573,7 @@ func capturePinnedPlanningPackage(root *os.Root, deliveryID, fingerprint string) return nil } if entry.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("planning package member is a symlink: %s", name) + return fmt.Errorf("work package member is a symlink: %s", name) } target := filepath.Join(destination, filepath.FromSlash(name)) if entry.IsDir() { @@ -535,7 +581,7 @@ func capturePinnedPlanningPackage(root *os.Root, deliveryID, fingerprint string) } before, err := root.Lstat(filepath.FromSlash(name)) if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || runtime.GOOS != "windows" && before.Mode().Perm() != 0o644 { - return fmt.Errorf("planning package member is not canonical: %s", name) + return fmt.Errorf("work package member is not canonical: %s", name) } raw, err := root.ReadFile(filepath.FromSlash(name)) if err != nil { @@ -543,7 +589,7 @@ func capturePinnedPlanningPackage(root *os.Root, deliveryID, fingerprint string) } after, err := root.Lstat(filepath.FromSlash(name)) if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) || after.Size() != int64(len(raw)) || after.Mode().Perm() != before.Mode().Perm() { - return fmt.Errorf("planning package member changed while captured: %s", name) + return fmt.Errorf("work package member changed while captured: %s", name) } return os.WriteFile(target, raw, 0o644) }) @@ -598,7 +644,83 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp if err != nil { return nil, err } - return []ports.ResourceMutation{planMutation, approvalMutation}, nil + mutations := []ports.ResourceMutation{planMutation, approvalMutation} + var promotion planPromotionReceipt + if decodeStrictArtifact(approvalRaw, &promotion) == nil && promotion.SchemaVersion == 2 { + packageMutations, packageErr := prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, promotion.WorkPackageFingerprint) + if packageErr != nil { + return nil, packageErr + } + mutations = append(mutations, packageMutations...) + } + return mutations, nil +} + +func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint string) ([]ports.ResourceMutation, error) { + sourcePath := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, packageFingerprint) + root, err := os.OpenRoot(sourcePath) + if err != nil { + return nil, fmt.Errorf("open immutable work package for workspace transfer: %w", err) + } + defer root.Close() + pinnedInfo, err := root.Stat(".") + if err != nil { + return nil, err + } + snapshotRepository, cleanup, err := capturePinnedWorkPackage(root, deliveryID, packageFingerprint) + if err != nil { + return nil, err + } + defer cleanup() + currentInfo, err := os.Stat(sourcePath) + if err != nil || !os.SameFile(pinnedInfo, currentInfo) { + return nil, fmt.Errorf("immutable work package changed during workspace transfer") + } + verified := workpackage.Verify(snapshotRepository, deliveryID, packageFingerprint, nil) + if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { + return nil, fmt.Errorf("workspace work package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) + } + snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "work-packages", deliveryID, packageFingerprint) + destinationRoot := filepath.Join(workspacePath, ".boatstack", "work-packages", deliveryID, packageFingerprint) + destinationExists := false + if info, statErr := os.Lstat(destinationRoot); statErr == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("workspace work package destination is unsafe") + } + destinationExists = true + } else if !os.IsNotExist(statErr) { + return nil, statErr + } + var mutations []ports.ResourceMutation + err = filepath.WalkDir(snapshotRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(snapshotRoot, path) + if err != nil { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + mutation, err := immutableWorkPackageMutation(filepath.Join(destinationRoot, relative), raw) + if err != nil { + return err + } + if filepath.ToSlash(relative) == "manifest.json" { + mutation.InstallLast = true + } + if !destinationExists { + mutation.AtomicTreeRoot = destinationRoot + } + mutations = append(mutations, mutation) + return nil + }) + return mutations, err } func validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerprint string, raw []byte) error { @@ -615,20 +737,40 @@ func validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerpri return fmt.Errorf("schema-1 approval identity is invalid") } return nil - case planningpackage.ApprovalSchemaVersion: - var approval planningpackage.Approval - if err := planningpackage.StrictDecode(raw, &approval); err != nil || approval.DeliveryID != deliveryID || approval.PlanFingerprint != expectedPlanFingerprint || !planningpackage.ValidFingerprint(approval.PackageFingerprint) { - return fmt.Errorf("schema-2 approval identity is invalid") - } - verified := planningpackage.Verify(repositoryRoot, deliveryID, approval.PackageFingerprint, nil) - if verified.Integrity != planningpackage.Valid || verified.Contract != planningpackage.Valid || verified.Approval != planningpackage.Valid { - return fmt.Errorf("schema-2 approval package is invalid") - } - snapshotRaw, err := os.ReadFile(filepath.Join(repositoryRoot, ".boatstack", "planning-packages", deliveryID, approval.PackageFingerprint, "approval.json")) - if err != nil || !bytes.Equal(snapshotRaw, raw) { - return fmt.Errorf("schema-2 approval projection differs from immutable snapshot") + case 2: + var promotion planPromotionReceipt + if err := decodeStrictArtifact(raw, &promotion); err != nil || promotion.DeliveryID != deliveryID || promotion.PlanFingerprint != expectedPlanFingerprint || + !workpackage.ValidFingerprint(promotion.WorkPackageFingerprint) || !workpackage.ValidFingerprint(promotion.WorkPackageApprovalFingerprint) || + promotion.PlanOutputID == "" || promotion.AdmissionID == "" || promotion.PromotedAt.IsZero() { + return fmt.Errorf("schema-2 plan promotion identity is invalid") + } + identity := promotion + identity.Fingerprint = "" + identityRaw, err := encodeJSON(identity) + if err != nil || promotion.Fingerprint != sha256Bytes(identityRaw) { + return fmt.Errorf("schema-2 plan promotion fingerprint is invalid") + } + verified := workpackage.Verify(repositoryRoot, deliveryID, promotion.WorkPackageFingerprint, nil) + if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { + return fmt.Errorf("schema-2 promotion package is invalid") + } + packageRoot := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, promotion.WorkPackageFingerprint) + approvalRaw, err := os.ReadFile(filepath.Join(packageRoot, "approval.json")) + var approval workpackage.Approval + if err != nil || workpackage.StrictDecode(approvalRaw, &approval) != nil || approval.Fingerprint != promotion.WorkPackageApprovalFingerprint { + return fmt.Errorf("schema-2 promotion approval lineage is invalid") + } + manifestRaw, err := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) + var manifest workpackage.Manifest + if err != nil || workpackage.StrictDecode(manifestRaw, &manifest) != nil { + return fmt.Errorf("schema-2 promotion manifest is invalid") + } + for _, output := range manifest.Outputs { + if output.ID == promotion.PlanOutputID && output.Required && output.SHA256 == expectedPlanFingerprint { + return nil + } } - return nil + return fmt.Errorf("schema-2 promotion output is not bound") default: return fmt.Errorf("unsupported approval schema %d", envelope.SchemaVersion) } @@ -652,7 +794,7 @@ func readRegularWorkspacePlanArtifact(path string) ([]byte, error) { func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { switch id { case "plan.create", "plan.amend", "plan.validate", "plan.approve", "plan.approve-amendment", - "planning.package.admit", "planning.package.approve", "planning.package.promote", + "work.package.admit", "work.package.approve", "planning.package.promote", "evidence.approval.revoke", "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record", "evidence.visual.attach", "publication.preview", "publication.execute", "publication.correct": @@ -662,70 +804,56 @@ func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { } } -func planningPackageOutputPath(root, relative string) (string, error) { - if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("planning package output path is unsafe") +func buildWorkPackage(admission protocol.Admission, transition catalog.Transition, deliveryID string) (workpackage.Manifest, map[string][]byte, error) { + if admission.Work == nil || transition.Work == nil { + return workpackage.Manifest{}, nil, fmt.Errorf("work package requires work evidence and contract") } - return filepath.Join(root, relative), nil -} - -func buildPlanningPackage(admission protocol.Admission, transition catalog.Transition, deliveryID, planOutputID string) (planningpackage.Manifest, map[string][]byte, error) { - if admission.Work == nil || transition.Work == nil || planOutputID == "" { - return planningpackage.Manifest{}, nil, fmt.Errorf("planning package requires work evidence, contract, and explicit plan output") - } - work := planningpackage.WorkContract{ID: transition.Work.ID, Fingerprint: transition.Work.Fingerprint, Instructions: planningpackage.Asset{Path: transition.Work.InstructionPath, SHA256: transition.Work.InstructionSHA256, Content: transition.Work.InstructionContent}} + work := workpackage.WorkContract{ID: transition.Work.ID, Fingerprint: transition.Work.Fingerprint, Instructions: workpackage.Asset{Path: transition.Work.InstructionPath, SHA256: transition.Work.InstructionSHA256, Content: transition.Work.InstructionContent}} for _, input := range transition.Work.Inputs { - work.Inputs = append(work.Inputs, planningpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + work.Inputs = append(work.Inputs, workpackage.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) } declarations := map[string]catalog.WorkOutput{} for _, output := range transition.Work.Outputs { declarations[output.ID] = output - item := planningpackage.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} + item := workpackage.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} if output.GuidancePath != "" { - item.Guidance = &planningpackage.Asset{Path: output.GuidancePath, SHA256: output.GuidanceSHA256, Content: output.GuidanceContent} + item.Guidance = &workpackage.Asset{Path: output.GuidancePath, SHA256: output.GuidanceSHA256, Content: output.GuidanceContent} } if output.SchemaPath != "" { - item.Schema = &planningpackage.Asset{Path: output.SchemaPath, SHA256: output.SchemaSHA256, Content: output.SchemaContent} + item.Schema = &workpackage.Asset{Path: output.SchemaPath, SHA256: output.SchemaSHA256, Content: output.SchemaContent} } work.Outputs = append(work.Outputs, item) } - if err := planningpackage.ValidateOutputPaths(work.Outputs); err != nil { - return planningpackage.Manifest{}, nil, err + if err := workpackage.ValidateOutputPaths(work.Outputs); err != nil { + return workpackage.Manifest{}, nil, err } - contract, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: work, PlanOutput: planOutputID}) + contract, contractRaw, err := workpackage.SealContract(workpackage.Contract{Work: work}) if err != nil { - return planningpackage.Manifest{}, nil, err + return workpackage.Manifest{}, nil, err } - receipt := planningpackage.WorkReceipt{RequestID: admission.Work.RequestID, RequestFingerprint: admission.Work.RequestFingerprint, ResultFingerprint: admission.Work.ResultFingerprint, ContractID: admission.Work.ContractID, ContractFingerprint: admission.Work.ContractFingerprint, TransitionID: string(admission.Work.TransitionID), ProgramFingerprint: admission.Work.ProgramFingerprint, ContextFingerprint: admission.Work.ContextFingerprint, StateRevision: admission.Work.StateRevision, RepositoryID: admission.Work.RepositoryID, WorktreeID: admission.Work.WorktreeID} + receipt := workpackage.WorkReceipt{RequestID: admission.Work.RequestID, RequestFingerprint: admission.Work.RequestFingerprint, ResultFingerprint: admission.Work.ResultFingerprint, ContractID: admission.Work.ContractID, ContractFingerprint: admission.Work.ContractFingerprint, TransitionID: string(admission.Work.TransitionID), ProgramFingerprint: admission.Work.ProgramFingerprint, ContextFingerprint: admission.Work.ContextFingerprint, StateRevision: admission.Work.StateRevision, RepositoryID: admission.Work.RepositoryID, WorktreeID: admission.Work.WorktreeID} files := map[string][]byte{"contract.json": contractRaw} - var plan planningpackage.PlanOutput for _, evidence := range admission.Work.Outputs { declaration, ok := declarations[evidence.ID] if !ok { - return planningpackage.Manifest{}, nil, fmt.Errorf("planning package output %q is undeclared", evidence.ID) + return workpackage.Manifest{}, nil, fmt.Errorf("work package output %q is undeclared", evidence.ID) } - output := planningpackage.Output{ID: evidence.ID, Path: evidence.Path, MediaType: evidence.MediaType, Required: declaration.Required, Size: evidence.Size, SHA256: evidence.SHA256, GuidanceSHA256: declaration.GuidanceSHA256, SchemaSHA256: declaration.SchemaSHA256} + output := workpackage.Output{ID: evidence.ID, Path: evidence.Path, MediaType: evidence.MediaType, Required: declaration.Required, Size: evidence.Size, SHA256: evidence.SHA256, GuidanceSHA256: declaration.GuidanceSHA256, SchemaSHA256: declaration.SchemaSHA256} receipt.Outputs = append(receipt.Outputs, output) files[evidence.Path] = []byte(evidence.Content) - if evidence.ID == planOutputID { - plan = planningpackage.PlanOutput{ID: evidence.ID, Path: evidence.Path, MediaType: evidence.MediaType, SHA256: evidence.SHA256} - } - } - if plan.ID == "" || !declarations[planOutputID].Required { - return planningpackage.Manifest{}, nil, fmt.Errorf("designated plan output %q is missing or optional", planOutputID) } - receipt, receiptRaw, err := planningpackage.SealWorkReceipt(receipt) + receipt, receiptRaw, err := workpackage.SealWorkReceipt(receipt) if err != nil { - return planningpackage.Manifest{}, nil, err + return workpackage.Manifest{}, nil, err } files["work-receipt.json"] = receiptRaw - manifest := planningpackage.Manifest{DeliveryID: deliveryID, ProgramID: admission.Work.ProgramID, ProgramFingerprint: admission.Work.ProgramFingerprint, EntryID: admission.Work.EntryID, RunID: admission.Work.RunID, TransitionID: string(transition.ID), WorkContractID: transition.Work.ID, WorkContractFingerprint: transition.Work.Fingerprint, WorkRequestFingerprint: admission.Work.RequestFingerprint, WorkResultFingerprint: admission.Work.ResultFingerprint, ContextFingerprint: admission.Work.ContextFingerprint, StateRevision: admission.Work.StateRevision, PlanOutput: plan, Contract: planningpackage.Reference{Path: "contract.json", SHA256: planningpackage.Digest(contractRaw)}, WorkReceipt: planningpackage.Reference{Path: "work-receipt.json", SHA256: planningpackage.Digest(receiptRaw)}, Outputs: receipt.Outputs} + manifest := workpackage.Manifest{DeliveryID: deliveryID, ProgramID: admission.Work.ProgramID, ProgramFingerprint: admission.Work.ProgramFingerprint, EntryID: admission.Work.EntryID, RunID: admission.Work.RunID, TransitionID: string(transition.ID), WorkContractID: transition.Work.ID, WorkContractFingerprint: transition.Work.Fingerprint, WorkRequestFingerprint: admission.Work.RequestFingerprint, WorkResultFingerprint: admission.Work.ResultFingerprint, ContextFingerprint: admission.Work.ContextFingerprint, StateRevision: admission.Work.StateRevision, Contract: workpackage.Reference{Path: "contract.json", SHA256: workpackage.Digest(contractRaw)}, WorkReceipt: workpackage.Reference{Path: "work-receipt.json", SHA256: workpackage.Digest(receiptRaw)}, Outputs: receipt.Outputs} if manifest.ProgramID == "" || manifest.EntryID == "" || manifest.RunID == "" { - return planningpackage.Manifest{}, nil, fmt.Errorf("planning package work evidence lacks Flow identity") + return workpackage.Manifest{}, nil, fmt.Errorf("work package work evidence lacks Flow identity") } - manifest, manifestRaw, err := planningpackage.SealManifest(manifest) + manifest, manifestRaw, err := workpackage.SealManifest(manifest) if err != nil { - return planningpackage.Manifest{}, nil, err + return workpackage.Manifest{}, nil, err } files["manifest.json"] = manifestRaw _ = contract @@ -733,29 +861,29 @@ func buildPlanningPackage(admission protocol.Admission, transition catalog.Trans return manifest, files, nil } -func immutablePlanningMutation(path string, target []byte) (ports.ResourceMutation, error) { +func immutableWorkPackageMutation(path string, target []byte) (ports.ResourceMutation, error) { mutation, err := mutationFor(path, target, 0o644, false, false) if err != nil { return ports.ResourceMutation{}, err } if mutation.PriorExists && !bytes.Equal(mutation.Prior, target) { - return ports.ResourceMutation{}, fmt.Errorf("immutable planning package member conflicts: %s", path) + return ports.ResourceMutation{}, fmt.Errorf("immutable work package member conflicts: %s", path) } return mutation, nil } -func loadPlanningPackageManifestV2(artifactRoot, deliveryID, packageFingerprint string) (planningpackage.Manifest, error) { - result := planningpackage.Verify(filepath.Dir(artifactRoot), deliveryID, packageFingerprint, nil) - if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid { - return planningpackage.Manifest{}, fmt.Errorf("planning package verification failed: %s", strings.Join(result.Diagnostics, "; ")) +func loadWorkPackageManifest(artifactRoot, deliveryID, packageFingerprint string) (workpackage.Manifest, error) { + result := workpackage.Verify(filepath.Dir(artifactRoot), deliveryID, packageFingerprint, nil) + if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid { + return workpackage.Manifest{}, fmt.Errorf("work package verification failed: %s", strings.Join(result.Diagnostics, "; ")) } - raw, err := os.ReadFile(filepath.Join(artifactRoot, "planning-packages", deliveryID, packageFingerprint, "manifest.json")) + raw, err := os.ReadFile(filepath.Join(artifactRoot, "work-packages", deliveryID, packageFingerprint, "manifest.json")) if err != nil { - return planningpackage.Manifest{}, err + return workpackage.Manifest{}, err } - var manifest planningpackage.Manifest - if err := planningpackage.StrictDecode(raw, &manifest); err != nil { - return planningpackage.Manifest{}, err + var manifest workpackage.Manifest + if err := workpackage.StrictDecode(raw, &manifest); err != nil { + return workpackage.Manifest{}, err } return manifest, nil } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index 3aa9163..ff8ff92 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -416,7 +416,7 @@ func publicationProductStatus(status string) string { func publicationGeneratedPath(name string) bool { for _, prefix := range []string{ - ".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", + ".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/work-packages/", ".boatstack/plans/", ".boatstack/publication/", } { if strings.HasPrefix(name, prefix) { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index a643e85..429f961 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -20,7 +20,6 @@ import ( "github.com/operatorstack/boatstack/boatstack/flow/standard" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" - "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -806,81 +805,6 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if !bytes.Equal(afterSuccess, afterReplay) { t.Fatal("rejected repeated reconciliation mutated durable state") } - priorState, err := durable.DecodeState(afterSuccess) - if err != nil { - t.Fatal(err) - } - var legacyState map[string]any - if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { - t.Fatal(err) - } - legacyState["schema_version"] = float64(6) - delete(legacyState, "program_human_identity_role") - legacyRaw, err := json.MarshalIndent(legacyState, "", " ") - if err != nil { - t.Fatal(err) - } - legacyRaw = append(legacyRaw, '\n') - if err := os.WriteFile(layout.StatePath, legacyRaw, 0o600); err != nil { - t.Fatal(err) - } - legacyPinPath := boatstackruntime.PinPath(repository) - legacyPinRaw, err := os.ReadFile(legacyPinPath) - if err != nil { - t.Fatal(err) - } - legacyPin, err := boatstackruntime.DecodePin(legacyPinRaw) - if err != nil { - t.Fatal(err) - } - legacyPin.StateSchemaVersion = 6 - legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(legacyPinPath, legacyPinRaw, 0o600); err != nil { - t.Fatal(err) - } - updateRequest := surfaces.Request{ - SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-current-update", - FlowID: "flow-program-drift", Objective: model.Objective{ID: "ignored-command-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "ignored"}, - TransitionID: "installation.update", Authority: human, - Parameters: protocol.Parameters{ - {Name: "source_revision", Value: "program-current"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, - }, - } - updated, err := newKernel.Handle(ctx, prescribeSurface(t, ctx, newKernel, updateRequest)) - if err != nil { - t.Fatalf("current-program update after reconciliation: %v", err) - } - if updated.Snapshot == nil || updated.Snapshot.Objective.Status != model.FactAbsent || updated.Receipt == nil || updated.Receipt.ObjectiveStatus != model.FactAbsent || updated.Receipt.ObjectiveID != "" { - t.Fatalf("reconcile to update composition invented product intent: %#v", updated) - } - updatedRaw, err := os.ReadFile(layout.StatePath) - if err != nil { - t.Fatal(err) - } - updatedState, err := durable.DecodeState(updatedRaw) - if err != nil { - t.Fatal(err) - } - if updatedState.SchemaVersion != durable.StateSchemaVersion || updatedState.Objective != priorState.Objective || updatedState.Engagement != priorState.Engagement || - updatedState.Delivery != priorState.Delivery || updatedState.Workspace != priorState.Workspace || updatedState.Plan != priorState.Plan || - updatedState.Configuration != priorState.Configuration || updatedState.Publication != priorState.Publication || updatedState.Verification != priorState.Verification || - updatedState.Terminal != priorState.Terminal || updatedState.PlanFingerprint != priorState.PlanFingerprint || updatedState.ApprovalFingerprint != priorState.ApprovalFingerprint { - t.Fatalf("base-schema update changed existing product facets: before=%#v after=%#v", priorState, updatedState) - } - updatedPinRaw, err := os.ReadFile(legacyPinPath) - if err != nil { - t.Fatal(err) - } - updatedPin, err := boatstackruntime.DecodePin(updatedPinRaw) - if err != nil { - t.Fatal(err) - } - if updatedPin.StateSchemaVersion != durable.StateSchemaVersion { - t.Fatalf("runtime update left prior state schema in the pin: %#v", updatedPin) - } } func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index 19c54d5..5a5a7c7 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -85,8 +85,8 @@ var nativeStateHandlers = map[string]nativeStateHandler{ "catalog-reconcile": applyCatalogReconcile, "objective-bind": applyObjectiveBind, "plan-approve": applyPlanApprove, - "planning-package-admit": applyPlanningPackageAdmit, - "planning-package-approve": applyPlanningPackageApprove, + "work-package-admit": applyWorkPackageAdmit, + "work-package-approve": applyWorkPackageApprove, "planning-package-promote": applyPlanningPackagePromote, "abandon-delivery": applyAbandonDelivery, "workspace-cleanup": applyWorkspaceCleanup, @@ -372,10 +372,10 @@ func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ ca } func resetDeliveryState(state *durable.State) { - state.Delivery, state.Plan = model.DeliveryUninitialized, model.PlanAbsent + state.Delivery, state.WorkPackage, state.Plan = model.DeliveryUninitialized, model.WorkPackageAbsent, model.PlanAbsent state.Workspace = model.WorkspaceAbsent state.Publication, state.Verification = model.PublicationNone, model.VerificationUnverified - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "", "" + state.PlanFingerprint, state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "", "", "" state.WorkspaceBranch, state.WorkspacePath, state.WorkspaceBaseRef = "", "", "" state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = "", "", "" state.Gates = nil @@ -389,13 +389,16 @@ func applyPlanApprove(state *durable.State, admission protocol.Admission, _ cata return nil } -func applyPlanningPackageAdmit(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { - state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal +func applyWorkPackageAdmit(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { + state.WorkPackage, state.Delivery, state.Phase, state.Terminal = model.WorkPackageValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal return nil } -func applyPlanningPackageApprove(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { - state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageApproved, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal +func applyWorkPackageApprove(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { + state.WorkPackage, state.Delivery, state.Phase = model.WorkPackageApproved, model.DeliveryApproved, model.PhaseActive + if admission.Objective.TrustedObjectiveClass() == model.ObjectiveApprovedWorkPackage { + establishTerminal(state, model.PhaseTerminal) + } return nil } diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/work_package_test.go similarity index 70% rename from boatstack/internal/softwaredelivery/effects/planning_package_test.go rename to boatstack/internal/softwaredelivery/effects/work_package_test.go index b452d73..43dd8ad 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/work_package_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -16,8 +16,8 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { - // control-law: planning-package promotion requires exact work and approval lineage +func TestWorkPackageAdmitApprovePromoteUsesExactGenericSnapshot(t *testing.T) { + // control-law: planning promotion requires exact work-package and approval lineage repository := t.TempDir() layout := ports.ControllerLayout{RepositoryRoot: repository} now := time.Unix(100, 0).UTC() @@ -27,8 +27,8 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { InstructionSHA256: sha256Bytes([]byte("coordinate")), InstructionContent: "coordinate", Outputs: []catalog.WorkOutput{{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, MaxBytes: 1024}}, } - portableWork := planningpackage.WorkContract{ID: workContract.ID, Instructions: planningpackage.Asset{Path: workContract.InstructionPath, SHA256: workContract.InstructionSHA256, Content: workContract.InstructionContent}, Outputs: []planningpackage.WorkOutput{{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, MaxBytes: 1024}}} - contractFingerprint, err := planningpackage.RuntimeWorkFingerprint(portableWork) + portableWork := workpackage.WorkContract{ID: workContract.ID, Instructions: workpackage.Asset{Path: workContract.InstructionPath, SHA256: workContract.InstructionSHA256, Content: workContract.InstructionContent}, Outputs: []workpackage.WorkOutput{{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, MaxBytes: 1024}}} + contractFingerprint, err := workpackage.RuntimeWorkFingerprint(portableWork) if err != nil { t.Fatal(err) } @@ -36,13 +36,13 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { work := &protocol.WorkEvidence{ RequestID: "work-request", RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), RunID: "run-proof", ProgramID: "product-delivery", EntryID: "run", ContractID: "planning", ContractFingerprint: contractFingerprint, - TransitionID: "planning.package.admit", ProgramFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 3, + TransitionID: "work.package.admit", ProgramFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 3, RepositoryID: "repo", WorktreeID: "tree", Outputs: []protocol.WorkOutputEvidence{{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", SHA256: sha256Bytes(plan), Size: int64(len(plan)), Content: string(plan)}}, } objective := model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} - state := durable.State{Plan: model.PlanAbsent, Delivery: model.DeliveryUninitialized, Phase: model.PhaseObserved, Terminal: model.TerminalNonterminal} - admit := catalog.Transition{ID: "planning.package.admit", Work: workContract, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-admit"}} - admission := protocol.Admission{ID: "adm-admit", Objective: objective, Work: work, IssuedAt: now, Parameters: protocol.Parameters{{Name: "plan_output", Value: "implementation-plan"}}} + state := durable.State{WorkPackage: model.WorkPackageAbsent, Plan: model.PlanAbsent, Delivery: model.DeliveryUninitialized, Phase: model.PhaseObserved, Terminal: model.TerminalNonterminal} + admit := catalog.Transition{ID: "work.package.admit", Work: workContract, TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "work-package-admit"}} + admission := protocol.Admission{ID: "adm-admit", Objective: objective, Work: work, IssuedAt: now} if err := applyStateTransition(&state, admission, admit); err != nil { t.Fatal(err) } @@ -51,11 +51,11 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { t.Fatal(err) } installFixtureMutations(t, mutations) - result := planningpackage.Verify(repository, "delivery", state.PlanningPackageFingerprint, nil) - if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || result.Approval != planningpackage.Missing { + result := workpackage.Verify(repository, "delivery", state.WorkPackageFingerprint, nil) + if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || result.Approval != workpackage.Missing { t.Fatalf("verification=%#v", result) } - packageRoot := filepath.Join(repository, ".boatstack", "planning-packages", "delivery", state.PlanningPackageFingerprint) + packageRoot := filepath.Join(repository, ".boatstack", "work-packages", "delivery", state.WorkPackageFingerprint) priorManifest, _ := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) mutations, err = prepareArtifacts(layout, admission, admit, &state) if err != nil { @@ -67,9 +67,9 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { } } - approve := catalog.Transition{ID: "planning.package.approve", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-approve"}} + approve := catalog.Transition{ID: "work.package.approve", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "work-package-approve"}} providerFingerprint := strings.Repeat("9", 64) - approvalAdmission := protocol.Admission{ID: "adm-approve", Objective: objective, IssuedAt: now.Add(time.Minute), Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: state.PlanningPackageFingerprint}}, Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{ + approvalAdmission := protocol.Admission{ID: "adm-approve", Objective: objective, IssuedAt: now.Add(time.Minute), Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: state.WorkPackageFingerprint}}, Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{ {ID: "policy", Class: catalog.AuthorityRepository, Subject: "repository", Fingerprint: "policy-proof", IssuedAt: now}, {ID: "automation", Class: catalog.AuthorityAutonomy, Subject: "automation", Fingerprint: "autonomy-proof", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("8", 64), IssuedAt: now}, {ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer", Fingerprint: "human-proof", IdentityRole: "developer", IdentityProviderFingerprint: providerFingerprint, IssuedAt: now}, @@ -88,28 +88,28 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { t.Fatal(err) } installFixtureMutations(t, mutations) - if state.ApprovalFingerprint != sha256Bytes(approvalRawAt(t, packageRoot)) { - t.Fatal("durable approval fingerprint is not the canonical approval file digest") + if state.WorkPackageApprovalFingerprint == "" { + t.Fatal("durable work-package approval fingerprint is absent") } - result = planningpackage.Verify(repository, "delivery", state.PlanningPackageFingerprint, nil) - if result.Approval != planningpackage.Valid { + result = workpackage.Verify(repository, "delivery", state.WorkPackageFingerprint, nil) + if result.Approval != workpackage.Valid { t.Fatalf("approval verification=%#v", result) } - var recordedApproval planningpackage.Approval - if err := planningpackage.StrictDecode(approvalRawAt(t, packageRoot), &recordedApproval); err != nil || recordedApproval.Actor != "reviewer" || recordedApproval.IdentityRole != "developer" || recordedApproval.IdentityProviderFingerprint != providerFingerprint { + var recordedApproval workpackage.Approval + if err := workpackage.StrictDecode(approvalRawAt(t, packageRoot), &recordedApproval); err != nil || recordedApproval.Actor != "reviewer" || recordedApproval.IdentityRole != "developer" || recordedApproval.IdentityProviderFingerprint != providerFingerprint { t.Fatalf("approval identity provenance=%#v err=%v", recordedApproval, err) } promote := catalog.Transition{ID: "planning.package.promote", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-promote"}} - promotion := protocol.Admission{ID: "adm-promote", Objective: objective, IssuedAt: now.Add(2 * time.Minute)} + promotion := protocol.Admission{ID: "adm-promote", Objective: objective, IssuedAt: now.Add(2 * time.Minute), Parameters: protocol.Parameters{{Name: "plan_output", Value: "implementation-plan"}}} if err := applyStateTransition(&state, promotion, promote); err != nil { t.Fatal(err) } - originalVerify := verifyPlanningPackage + originalVerify := verifyWorkPackage swappedRoot := packageRoot + ".swapped" replacementPerformed := false var replacementBlocked error - verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + verifyWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result { result := originalVerify(repository, deliveryID, packageFingerprint, current) if err := os.Rename(packageRoot, swappedRoot); err != nil { replacementBlocked = err @@ -129,7 +129,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { } else if replacementBlocked == nil || raceErr != nil { t.Fatalf("host neither blocked replacement nor completed safe promotion: blocked=%v promotion=%v", replacementBlocked, raceErr) } - verifyPlanningPackage = originalVerify + verifyWorkPackage = originalVerify if replacementPerformed { if err := os.Remove(packageRoot); err != nil { t.Fatal(err) @@ -142,7 +142,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if err != nil { t.Fatal(err) } - verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + verifyWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result { result := originalVerify(repository, deliveryID, packageFingerprint, current) if err := os.WriteFile(filepath.Join(packageRoot, "manifest.json"), append(append([]byte(nil), originalManifest...), '\n'), 0o644); err != nil { t.Fatal(err) @@ -152,24 +152,24 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if _, mutationErr := prepareArtifacts(layout, promotion, promote, &state); mutationErr == nil || !strings.Contains(mutationErr.Error(), "manifest") { t.Fatalf("in-place package mutation was not rejected: %v", mutationErr) } - verifyPlanningPackage = originalVerify + verifyWorkPackage = originalVerify if err := os.WriteFile(filepath.Join(packageRoot, "manifest.json"), originalManifest, 0o644); err != nil { t.Fatal(err) } originalApproval := approvalRawAt(t, packageRoot) - var substitutedApproval planningpackage.Approval - if err := planningpackage.StrictDecode(originalApproval, &substitutedApproval); err != nil { + var substitutedApproval workpackage.Approval + if err := workpackage.StrictDecode(originalApproval, &substitutedApproval); err != nil { t.Fatal(err) } substitutedApproval.Actor = "substitute" for index := range substitutedApproval.AuthoritySources { substitutedApproval.AuthoritySources[index].Subject = substitutedApproval.Actor } - _, substitutedApprovalRaw, err := planningpackage.SealApproval(substitutedApproval) + _, substitutedApprovalRaw, err := workpackage.SealApproval(substitutedApproval) if err != nil { t.Fatal(err) } - verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + verifyWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result { result := originalVerify(repository, deliveryID, packageFingerprint, current) if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), substitutedApprovalRaw, 0o644); err != nil { t.Fatal(err) @@ -179,7 +179,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if _, substitutionErr := prepareArtifacts(layout, promotion, promote, &state); substitutionErr == nil || !strings.Contains(substitutionErr.Error(), "approval") { t.Fatalf("substituted approval fingerprint was not rejected: %v", substitutionErr) } - verifyPlanningPackage = originalVerify + verifyWorkPackage = originalVerify if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), originalApproval, 0o644); err != nil { t.Fatal(err) } @@ -187,7 +187,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if err != nil { t.Fatal(err) } - verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + verifyWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result { result := originalVerify(repository, deliveryID, packageFingerprint, current) if err := os.WriteFile(filepath.Join(packageRoot, "contract.json"), append(append([]byte(nil), originalContract...), '\n'), 0o644); err != nil { t.Fatal(err) @@ -197,7 +197,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if _, contractErr := prepareArtifacts(layout, promotion, promote, &state); contractErr == nil || !strings.Contains(contractErr.Error(), "verification failed") { t.Fatalf("post-verification contract mutation was not rejected: %v", contractErr) } - verifyPlanningPackage = originalVerify + verifyWorkPackage = originalVerify if err := os.WriteFile(filepath.Join(packageRoot, "contract.json"), originalContract, 0o644); err != nil { t.Fatal(err) } @@ -220,8 +220,8 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { } installFixtureMutations(t, transfers) transferredApproval, err := os.ReadFile(filepath.Join(workspace, ".boatstack", "approvals", "delivery.json")) - if err != nil || !bytes.Equal(transferredApproval, approvalRawAt(t, packageRoot)) { - t.Fatalf("workspace did not receive exact schema-2 approval: %v", err) + if err != nil || !bytes.Contains(transferredApproval, []byte(`"plan_output_id": "implementation-plan"`)) { + t.Fatalf("workspace did not receive exact plan-promotion receipt: %v", err) } after, _ := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) if !bytes.Equal(after, priorManifest) { @@ -238,12 +238,12 @@ func approvalRawAt(t *testing.T, packageRoot string) []byte { return raw } -func TestPlanningPackageImmutableConflictFailsClosed(t *testing.T) { +func TestWorkPackageImmutableConflictFailsClosed(t *testing.T) { path := filepath.Join(t.TempDir(), "member") if err := os.WriteFile(path, []byte("prior"), 0o600); err != nil { t.Fatal(err) } - if _, err := immutablePlanningMutation(path, []byte("changed")); err == nil || !strings.Contains(err.Error(), "immutable") { + if _, err := immutableWorkPackageMutation(path, []byte("changed")); err == nil || !strings.Contains(err.Error(), "immutable") { t.Fatalf("conflict=%v", err) } raw, _ := os.ReadFile(path) diff --git a/boatstack/internal/softwaredelivery/model/facet.go b/boatstack/internal/softwaredelivery/model/facet.go index 039d41c..a00316d 100644 --- a/boatstack/internal/softwaredelivery/model/facet.go +++ b/boatstack/internal/softwaredelivery/model/facet.go @@ -19,6 +19,7 @@ const ( FacetEngagement FacetName = "engagement" FacetDelivery FacetName = "delivery" FacetWorkspace FacetName = "workspace" + FacetWorkPackage FacetName = "work-package" FacetPlan FacetName = "plan" FacetConfiguration FacetName = "configuration" FacetConfigurationPolicy FacetName = "configuration-policy" @@ -35,7 +36,7 @@ const ( var controllingFacets = []FacetName{ FacetPhase, FacetProgram, FacetTopology, FacetEngagement, FacetDelivery, FacetWorkspace, - FacetPlan, FacetConfiguration, FacetConfigurationPolicy, FacetRuntime, FacetPublication, + FacetWorkPackage, FacetPlan, FacetConfiguration, FacetConfigurationPolicy, FacetRuntime, FacetPublication, FacetVerification, FacetRecovery, FacetTransaction, FacetRecoveryInfo, FacetTransactionInfo, FacetTerminal, FacetObjective, } @@ -74,6 +75,8 @@ func (s Snapshot) Facet(name FacetName) (FactStatus, string, bool) { return s.Delivery.Status, string(s.Delivery.Value), true case FacetWorkspace: return s.Workspace.Status, string(s.Workspace.Value), true + case FacetWorkPackage: + return s.WorkPackage.Status, string(s.WorkPackage.Value), true case FacetPlan: return s.Plan.Status, string(s.Plan.Value), true case FacetConfiguration: diff --git a/boatstack/internal/softwaredelivery/model/objective.go b/boatstack/internal/softwaredelivery/model/objective.go index dcae839..673c5dd 100644 --- a/boatstack/internal/softwaredelivery/model/objective.go +++ b/boatstack/internal/softwaredelivery/model/objective.go @@ -10,11 +10,12 @@ type TargetID string var safeObjectiveIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) const ( - ObjectiveApprovedPlan TargetID = "approved-plan" - ObjectiveVerified TargetID = "verified-implementation" - ObjectiveOpenPR TargetID = "open-or-updated-pr" - ObjectiveMerged TargetID = "merged-delivery" - ObjectiveAbandoned TargetID = "safely-abandoned" + ObjectiveApprovedWorkPackage TargetID = "approved-work-package" + ObjectiveApprovedPlan TargetID = "approved-plan" + ObjectiveVerified TargetID = "verified-implementation" + ObjectiveOpenPR TargetID = "open-or-updated-pr" + ObjectiveMerged TargetID = "merged-delivery" + ObjectiveAbandoned TargetID = "safely-abandoned" ) func (k TargetID) Valid() bool { diff --git a/boatstack/internal/softwaredelivery/model/state.go b/boatstack/internal/softwaredelivery/model/state.go index d2fa472..f94631c 100644 --- a/boatstack/internal/softwaredelivery/model/state.go +++ b/boatstack/internal/softwaredelivery/model/state.go @@ -9,7 +9,7 @@ import ( "time" ) -const SnapshotSchemaVersion = 5 +const SnapshotSchemaVersion = 6 const foregroundWorkContextIdentity = "foreground-work-context" @@ -132,8 +132,6 @@ const ( PlanAbsent PlanState = "absent" PlanDraft PlanState = "draft" PlanValid PlanState = "valid" - PlanPackageValid PlanState = "package-valid" - PlanPackageApproved PlanState = "package-approved" PlanApproved PlanState = "approved" PlanLocked PlanState = "locked" PlanStale PlanState = "stale" @@ -143,7 +141,24 @@ const ( func (s PlanState) Valid() bool { switch s { - case PlanAbsent, PlanDraft, PlanValid, PlanPackageValid, PlanPackageApproved, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: + case PlanAbsent, PlanDraft, PlanValid, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: + return true + default: + return false + } +} + +type WorkPackageState string + +const ( + WorkPackageAbsent WorkPackageState = "absent" + WorkPackageValid WorkPackageState = "valid" + WorkPackageApproved WorkPackageState = "approved" +) + +func (s WorkPackageState) Valid() bool { + switch s { + case WorkPackageAbsent, WorkPackageValid, WorkPackageApproved: return true default: return false @@ -402,6 +417,7 @@ type Observation struct { Engagement Fact[EngagementState] `json:"engagement"` Delivery Fact[DeliveryState] `json:"delivery"` Workspace Fact[WorkspaceState] `json:"workspace"` + WorkPackage Fact[WorkPackageState] `json:"work_package"` Plan Fact[PlanState] `json:"plan"` Configuration Fact[ConfigurationState] `json:"configuration"` ConfigurationPolicy Fact[ConfigurationPolicy] `json:"configuration_policy"` @@ -459,6 +475,9 @@ func Canonicalize(observation Observation) (Snapshot, error) { if observation.Program.Status == "" && observation.ProgramFingerprint == "" { observation.Program = Known(ProgramUnbound, Evidence{Source: "control-program:unbound", Fingerprint: "unbound", ObservedAt: observation.ObservedAt}) } + if observation.WorkPackage.Status == "" { + observation.WorkPackage = Known(WorkPackageAbsent, Evidence{Source: "work-package:absent", Fingerprint: "absent", ObservedAt: time.Unix(0, 0).UTC()}) + } if observation.ProgramFingerprint != "" && len(observation.ProgramFingerprint) != 64 { return Snapshot{}, fmt.Errorf("snapshot: invalid program fingerprint") } @@ -474,6 +493,7 @@ func Canonicalize(observation Observation) (Snapshot, error) { {"engagement", observation.Engagement.Validate("engagement")}, {"delivery", observation.Delivery.Validate("delivery")}, {"workspace", observation.Workspace.Validate("workspace")}, + {"work_package", observation.WorkPackage.Validate("work_package")}, {"plan", observation.Plan.Validate("plan")}, {"configuration", observation.Configuration.Validate("configuration")}, {"configuration_policy", observation.ConfigurationPolicy.Validate("configuration_policy")}, @@ -504,6 +524,7 @@ func Canonicalize(observation Observation) (Snapshot, error) { {"engagement", observation.Engagement.Status == FactKnown, observation.Engagement.Value.Valid()}, {"delivery", observation.Delivery.Status == FactKnown, observation.Delivery.Value.Valid()}, {"workspace", observation.Workspace.Status == FactKnown, observation.Workspace.Value.Valid()}, + {"work_package", observation.WorkPackage.Status == FactKnown, observation.WorkPackage.Value.Valid()}, {"plan", observation.Plan.Status == FactKnown, observation.Plan.Value.Valid()}, {"configuration", observation.Configuration.Status == FactKnown, observation.Configuration.Value.Valid()}, {"runtime", observation.Runtime.Status == FactKnown, observation.Runtime.Value.Valid()}, @@ -624,6 +645,7 @@ func observationFingerprint(observation Observation) (string, error) { zeroEvidenceTimes(&projection.Engagement) zeroEvidenceTimes(&projection.Delivery) zeroEvidenceTimes(&projection.Workspace) + zeroEvidenceTimes(&projection.WorkPackage) zeroEvidenceTimes(&projection.Plan) zeroEvidenceTimes(&projection.Configuration) zeroEvidenceTimes(&projection.ConfigurationPolicy) diff --git a/boatstack/internal/softwaredelivery/model/state_test.go b/boatstack/internal/softwaredelivery/model/state_test.go index b65fbf2..84272b8 100644 --- a/boatstack/internal/softwaredelivery/model/state_test.go +++ b/boatstack/internal/softwaredelivery/model/state_test.go @@ -119,6 +119,7 @@ func TestEveryControllingFacetChangesCanonicalIdentity(t *testing.T) { FacetEngagement: func(o *Observation) { o.Engagement = Known(EngagementCommand, evidence) }, FacetDelivery: func(o *Observation) { o.Delivery = Known(DeliveryApproved, evidence) }, FacetWorkspace: func(o *Observation) { o.Workspace = Known(WorkspaceCut, evidence) }, + FacetWorkPackage: func(o *Observation) { o.WorkPackage = Known(WorkPackageValid, evidence) }, FacetPlan: func(o *Observation) { o.Plan = Known(PlanValid, evidence) }, FacetConfiguration: func(o *Observation) { o.Configuration = Known(ConfigurationStale, evidence) }, FacetConfigurationPolicy: func(o *Observation) { diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index f93edf8..10aaec9 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -16,7 +16,7 @@ import ( "strings" "time" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" @@ -185,6 +185,20 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) if artifactVerification != state.Verification { verification = artifactVerification } + workPackageEvidence := append([]model.Evidence(nil), stateEvidence...) + workPackageFact := model.Fact[model.WorkPackageState]{Status: model.FactKnown, Value: state.WorkPackage, Evidence: workPackageEvidence} + if state.WorkPackage != model.WorkPackageAbsent { + evidence, valid, workPackageErr := observeWorkPackage(layout, state, now) + if workPackageErr != nil { + return model.Observation{}, workPackageErr + } + workPackageEvidence = append(workPackageEvidence, evidence...) + workPackageFact.Evidence = workPackageEvidence + if !valid { + workPackageFact.Status = model.FactStale + artifactTerminal = model.TerminalStale + } + } phase := state.Phase delivery := state.Delivery recoveryFact := model.Fact[model.RecoveryState]{Status: model.FactKnown, Value: state.Recovery, Evidence: stateEvidence} @@ -267,6 +281,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) Engagement: model.Fact[model.EngagementState]{Status: model.FactKnown, Value: state.Engagement, Evidence: stateEvidence}, Delivery: model.Fact[model.DeliveryState]{Status: model.FactKnown, Value: delivery, Evidence: deliveryEvidence}, Workspace: model.Fact[model.WorkspaceState]{Status: model.FactKnown, Value: state.Workspace, Evidence: deliveryEvidence}, + WorkPackage: workPackageFact, Plan: model.Fact[model.PlanState]{Status: model.FactKnown, Value: plan, Evidence: planEvidence}, Configuration: model.Fact[model.ConfigurationState]{Status: model.FactKnown, Value: configuration, Evidence: configurationEvidence}, ConfigurationPolicy: configurationPolicy, @@ -463,7 +478,7 @@ func canonicalProductStatus(status string) string { func generatedBoatstackPath(name string) bool { name = strings.TrimPrefix(filepath.ToSlash(name), "./") - for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", ".boatstack/plans/", ".boatstack/publication/"} { + for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/work-packages/", ".boatstack/plans/", ".boatstack/publication/"} { if strings.HasPrefix(name, prefix) { return true } @@ -503,6 +518,18 @@ type observedApproval struct { ApprovedAt time.Time `json:"approved_at"` } +type observedPlanPromotion struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + WorkPackageFingerprint string `json:"work_package_fingerprint"` + WorkPackageApprovalFingerprint string `json:"work_package_approval_fingerprint"` + PlanOutputID string `json:"plan_output_id"` + AdmissionID string `json:"admission_id"` + PromotedAt time.Time `json:"promoted_at"` + Fingerprint string `json:"fingerprint"` +} + type observedGate struct { SchemaVersion int `json:"schema_version"` DeliveryID string `json:"delivery_id"` @@ -542,17 +569,7 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } deliveryID := state.Objective.DeliveryID - packagePlan := state.Plan == model.PlanPackageValid || state.Plan == model.PlanPackageApproved - if packagePlan { - evidence, valid, err := observePlanningPackage(layout, state, now) - if err != nil { - return plan, verification, terminal, nil, nil, err - } - planEvidence = append(planEvidence, evidence...) - if !valid { - plan, terminal = model.PlanStale, model.TerminalStale - } - } else if state.Plan != model.PlanAbsent { + if state.Plan != model.PlanAbsent { path := filepath.Join(layout.RepositoryRoot, ".boatstack", "plans", deliveryID+".source") evidence, fingerprint, exists, err := fileEvidence(path, "plan", now) if err != nil { @@ -576,11 +593,20 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta if readErr != nil { return plan, verification, terminal, nil, nil, readErr } - if state.PlanningPackageFingerprint != "" { - var approval planningpackage.Approval - valid = valid && planningpackage.StrictDecode(raw, &approval) == nil && approval.SchemaVersion == planningpackage.ApprovalSchemaVersion && - approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint && approval.PackageFingerprint == state.PlanningPackageFingerprint && - approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() + if state.WorkPackageFingerprint != "" { + var promotion observedPlanPromotion + valid = valid && decodeStrictJSON(raw, &promotion) == nil && promotion.SchemaVersion == 2 && + promotion.DeliveryID == deliveryID && promotion.PlanFingerprint == state.PlanFingerprint && + promotion.WorkPackageFingerprint == state.WorkPackageFingerprint && + promotion.WorkPackageApprovalFingerprint == state.WorkPackageApprovalFingerprint && + promotion.PlanOutputID != "" && promotion.AdmissionID != "" && !promotion.PromotedAt.IsZero() + if valid { + identity := promotion + identity.Fingerprint = "" + identityRaw, encodeErr := json.MarshalIndent(identity, "", " ") + identityRaw = append(identityRaw, '\n') + valid = encodeErr == nil && promotion.Fingerprint == hashBytes(identityRaw) + } } else { var approval observedApproval valid = valid && decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && @@ -668,10 +694,10 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } -func observePlanningPackage(layout ports.ControllerLayout, state durable.State, now time.Time) ([]model.Evidence, bool, error) { - root := filepath.Join(layout.RepositoryRoot, ".boatstack", "planning-packages", state.Objective.DeliveryID, state.PlanningPackageFingerprint) +func observeWorkPackage(layout ports.ControllerLayout, state durable.State, now time.Time) ([]model.Evidence, bool, error) { + root := filepath.Join(layout.RepositoryRoot, ".boatstack", "work-packages", state.Objective.DeliveryID, state.WorkPackageFingerprint) manifestPath := filepath.Join(root, "manifest.json") - manifestEvidence, _, exists, err := fileEvidence(manifestPath, "planning-package", now) + manifestEvidence, _, exists, err := fileEvidence(manifestPath, "work-package", now) evidence := []model.Evidence{manifestEvidence} if err != nil || !exists { return evidence, false, err @@ -680,19 +706,19 @@ func observePlanningPackage(layout ports.ControllerLayout, state durable.State, if err != nil { return evidence, false, err } - var manifest planningpackage.Manifest - if planningpackage.StrictDecode(manifestRaw, &manifest) != nil { + var manifest workpackage.Manifest + if workpackage.StrictDecode(manifestRaw, &manifest) != nil { return evidence, false, nil } - result := planningpackage.Verify(layout.RepositoryRoot, state.Objective.DeliveryID, state.PlanningPackageFingerprint, nil) - valid := result.Integrity == planningpackage.Valid && result.Contract == planningpackage.Valid && manifest.PlanOutput.SHA256 == state.PlanFingerprint - if state.Plan == model.PlanPackageApproved { - valid = valid && result.Approval == planningpackage.Valid + result := workpackage.Verify(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint, nil) + valid := result.Integrity == workpackage.Valid && result.Contract == workpackage.Valid + if state.WorkPackage == model.WorkPackageApproved { + valid = valid && result.Approval == workpackage.Valid if valid { raw, _ := os.ReadFile(filepath.Join(root, "approval.json")) - var approval planningpackage.Approval - _ = planningpackage.StrictDecode(raw, &approval) - valid = planningpackage.Digest(raw) == state.ApprovalFingerprint + var approval workpackage.Approval + _ = workpackage.StrictDecode(raw, &approval) + valid = approval.Fingerprint == state.WorkPackageApprovalFingerprint } } return evidence, valid, nil diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index b383f40..dd932b3 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" + workpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/workpackage" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -22,32 +22,31 @@ type observerClock struct{ now time.Time } func (c observerClock) Now() time.Time { return c.now } -func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t *testing.T) { +func TestObserverValidatesAdmittedWorkPackageWithoutPrematurePlanPromotion(t *testing.T) { // control-law: an admitted package is verified from its exact manifest while the canonical approved plan remains absent repository := t.TempDir() deliveryID := "delivery" plan := []byte("# Proposed plan\n") - planFingerprint := hashBytes(plan) - output := planningpackage.Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: planFingerprint} - portableWork := planningpackage.WorkContract{ID: "planning", Instructions: planningpackage.Asset{Path: "package.md", SHA256: hashBytes([]byte("coordinate")), Content: "coordinate"}, Outputs: []planningpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}} - workFingerprint, err := planningpackage.RuntimeWorkFingerprint(portableWork) + output := workpackage.Output{ID: "implementation-plan", Path: "plan.md", MediaType: "text/markdown", Required: true, Size: int64(len(plan)), SHA256: hashBytes(plan)} + portableWork := workpackage.WorkContract{ID: "planning", Instructions: workpackage.Asset{Path: "package.md", SHA256: hashBytes([]byte("coordinate")), Content: "coordinate"}, Outputs: []workpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}} + workFingerprint, err := workpackage.RuntimeWorkFingerprint(portableWork) if err != nil { t.Fatal(err) } portableWork.Fingerprint = workFingerprint - _, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: portableWork, PlanOutput: output.ID}) + _, contractRaw, err := workpackage.SealContract(workpackage.Contract{Work: portableWork}) if err != nil { t.Fatal(err) } - _, receiptRaw, err := planningpackage.SealWorkReceipt(planningpackage.WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), ContractID: "planning", ContractFingerprint: workFingerprint, TransitionID: "planning.package.admit", ProgramFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []planningpackage.Output{output}}) + _, receiptRaw, err := workpackage.SealWorkReceipt(workpackage.WorkReceipt{RequestID: "request", RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), ContractID: "planning", ContractFingerprint: workFingerprint, TransitionID: "work.package.admit", ProgramFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, RepositoryID: "repo", WorktreeID: "tree", Outputs: []workpackage.Output{output}}) if err != nil { t.Fatal(err) } - manifest, manifestRaw, err := planningpackage.SealManifest(planningpackage.Manifest{DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: strings.Repeat("d", 64), EntryID: "run", RunID: "run-proof", TransitionID: "planning.package.admit", WorkContractID: "planning", WorkContractFingerprint: workFingerprint, WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, PlanOutput: planningpackage.PlanOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256}, Contract: planningpackage.Reference{Path: "contract.json", SHA256: planningpackage.Digest(contractRaw)}, WorkReceipt: planningpackage.Reference{Path: "work-receipt.json", SHA256: planningpackage.Digest(receiptRaw)}, Outputs: []planningpackage.Output{output}}) + manifest, manifestRaw, err := workpackage.SealManifest(workpackage.Manifest{DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: strings.Repeat("d", 64), EntryID: "run", RunID: "run-proof", TransitionID: "work.package.admit", WorkContractID: "planning", WorkContractFingerprint: workFingerprint, WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, Contract: workpackage.Reference{Path: "contract.json", SHA256: workpackage.Digest(contractRaw)}, WorkReceipt: workpackage.Reference{Path: "work-receipt.json", SHA256: workpackage.Digest(receiptRaw)}, Outputs: []workpackage.Output{output}}) if err != nil { t.Fatal(err) } - root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, manifest.Fingerprint) + root := filepath.Join(repository, ".boatstack", "work-packages", deliveryID, manifest.Fingerprint) if err := os.MkdirAll(root, 0o700); err != nil { t.Fatal(err) } @@ -57,10 +56,10 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t } } state := durable.State{ - Plan: model.PlanPackageValid, PlanFingerprint: planFingerprint, PlanningPackageFingerprint: manifest.Fingerprint, + WorkPackage: model.WorkPackageValid, Plan: model.PlanAbsent, WorkPackageFingerprint: manifest.Fingerprint, Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: deliveryID}, } - evidence, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()) + evidence, valid, err := observeWorkPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()) if err != nil || !valid || len(evidence) != 1 { t.Fatalf("planning package observation valid=%t evidence=%#v err=%v", valid, evidence, err) } @@ -70,7 +69,7 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("tampered"), 0o644); err != nil { t.Fatal(err) } - if _, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(101, 0).UTC()); err != nil || valid { + if _, valid, err := observeWorkPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(101, 0).UTC()); err != nil || valid { t.Fatalf("tampered planning package valid=%t err=%v", valid, err) } } diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts index 11a776d..de112fd 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts @@ -13,14 +13,15 @@ import { import { inbox, planInboxResolver, - planningPackageAdmit, - planningPackageApprove, + planningPackage, planningPackagePromote, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, trustedSoftwareDeliveryTransitions, + workPackageAdmit, + workPackageApprove, } from "@operatorstack/boatstack-software-delivery"; const planning = foregroundWork({ @@ -42,8 +43,8 @@ const planning = foregroundWork({ }); const lifecycle = [ - planningPackageAdmit, - planningPackageApprove, + workPackageAdmit, + workPackageApprove, planningPackagePromote, { id: "plan.abandon", priority: 31 }, { id: "plan.activate", priority: 50 }, @@ -76,7 +77,7 @@ export default defineFlow({ work: [planning], operators: trustedOperators(lifecycle), transitions: trustedSoftwareDeliveryTransitions(lifecycle, { - planningPackage: { work: planning, planOutput: "plan" }, + planningPackage: planningPackage({ work: planning, planOutput: "plan" }), }), targets: [ marked("published-pr", all( diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts index 536bc54..61044a7 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -12,11 +12,12 @@ import { } from "@operatorstack/boatstack"; import { inbox, - planningPackageAdmit, - planningPackageApprove, + planningPackage, planningPackagePromote, softwareDelivery, trustedDelegation, + workPackageAdmit, + workPackageApprove, } from "@operatorstack/boatstack-software-delivery"; const planning = foregroundWork({ @@ -38,8 +39,8 @@ const planning = foregroundWork({ }); const lifecycle = [ - planningPackageAdmit, - planningPackageApprove, + workPackageAdmit, + workPackageApprove, planningPackagePromote, { id: "plan.abandon", priority: 31 }, { id: "plan.activate", priority: 50 }, @@ -67,7 +68,7 @@ export default defineFlow(softwareDelivery({ version: "1", humanIdentity: "developer", lifecycle: lifecycle, - planningPackage: { work: planning, planOutput: "plan" }, + planningPackage: planningPackage({ work: planning, planOutput: "plan" }), targets: [ marked("published-pr", all( fact("verification", ["current"]), diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index c85c34f..e257994 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +1 @@ -{"schema":"control-program","schema_revision":7,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"declarations":{"input_resolvers":["software-delivery.plan-inbox"]},"facets":[{"id":"phase","kind":"string"},{"id":"program","kind":"string"},{"id":"engagement","kind":"string"},{"id":"objective","kind":"string"},{"id":"delivery","kind":"string"},{"id":"workspace","kind":"string"},{"id":"plan","kind":"string"},{"id":"configuration","kind":"string"},{"id":"configuration-policy","kind":"string"},{"id":"runtime","kind":"string"},{"id":"publication","kind":"string"},{"id":"verification","kind":"string"},{"id":"recovery","kind":"string"},{"id":"recovery-info","kind":"string"},{"id":"transaction","kind":"string"},{"id":"terminal","kind":"string"},{"id":"recovery_budget","kind":"string"},{"id":"recovery_cause","kind":"string"},{"id":"recovery_resumption","kind":"string"},{"id":"recovery_source_phase","kind":"string"},{"id":"source_revision","kind":"string"},{"id":"preview_fingerprint","kind":"string"},{"id":"publication_id","kind":"string"},{"id":"recovery_transaction_id","kind":"string"},{"id":"transaction_id","kind":"string"},{"id":"transaction_transition","kind":"string"},{"id":"workspace_base_ref","kind":"string"},{"id":"workspace_branch","kind":"string"},{"id":"workspace_path","kind":"string"},{"id":"workspace_source_id","kind":"string"},{"id":"workspace_source_path","kind":"string"},{"id":"workspace_source_ref","kind":"string"},{"id":"worktree_fingerprint","kind":"string"}],"evidence":[{"id":"plan-evidence","subject":"plan","kind":"artifact"},{"id":"publication-evidence","subject":"publication","kind":"provider-observation"}],"work":[{"id":"planning-package","instructions":{"path":"boatstack/testdata/control-programs/assets/planning-package.md"},"inputs":[{"id":"plan","entry_input":"plan"}],"outputs":[{"id":"plan","path":"plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"feature-spec","path":"feature-spec.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"questions","path":"questions.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"test-plan","path":"test-plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"gaps","path":"gaps.md","media_type":"text/markdown","required":false,"max_bytes":131072},{"id":"autonomy","path":"autonomy.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"tasks","path":"compiled/tasks.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"test-matrix","path":"compiled/test-matrix.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"journey-oracles","path":"compiled/journey-oracles.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"evidence","path":"compiled/evidence.md","media_type":"text/markdown","required":true,"max_bytes":131072}]}],"operators":[{"id":"planning.package.admit","binding":{"reference":"software-delivery/planning.package.admit","version":"1"}},{"id":"planning.package.approve","binding":{"reference":"software-delivery/planning.package.approve","version":"1"}},{"id":"planning.package.promote","binding":{"reference":"software-delivery/planning.package.promote","version":"1"}},{"id":"plan.abandon","binding":{"reference":"software-delivery/plan.abandon","version":"1"}},{"id":"plan.activate","binding":{"reference":"software-delivery/plan.activate","version":"1"}},{"id":"workspace.cut","binding":{"reference":"software-delivery/workspace.cut","version":"1"}},{"id":"workspace.activate","binding":{"reference":"software-delivery/workspace.activate","version":"1"}},{"id":"workspace.sync","binding":{"reference":"software-delivery/workspace.sync","version":"1"}},{"id":"gate.build.record","binding":{"reference":"software-delivery/gate.build.record","version":"1"}},{"id":"gate.test.record","binding":{"reference":"software-delivery/gate.test.record","version":"1"}},{"id":"gate.review.record","binding":{"reference":"software-delivery/gate.review.record","version":"1"}},{"id":"gate.change.record","binding":{"reference":"software-delivery/gate.change.record","version":"1"}},{"id":"gate.journey.record","binding":{"reference":"software-delivery/gate.journey.record","version":"1"}},{"id":"evidence.visual.attach","binding":{"reference":"software-delivery/evidence.visual.attach","version":"1"}},{"id":"delivery.slice.advance","binding":{"reference":"software-delivery/delivery.slice.advance","version":"1"}},{"id":"publication.preview","binding":{"reference":"software-delivery/publication.preview","version":"1"}},{"id":"workspace.publish","binding":{"reference":"software-delivery/workspace.publish","version":"1"}},{"id":"publication.execute","binding":{"reference":"software-delivery/publication.execute","version":"1"}},{"id":"publication.observe","binding":{"reference":"software-delivery/publication.observe","version":"1"}},{"id":"publication.correct","binding":{"reference":"software-delivery/publication.correct","version":"1"}},{"id":"workspace.reconcile","binding":{"reference":"software-delivery/workspace.reconcile","version":"1"}},{"id":"publication.reconcile","binding":{"reference":"software-delivery/publication.reconcile","version":"1"}}],"transitions":[{"id":"planning.package.admit","operator":"planning.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package","parameters":[{"parameter":"plan_output","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/planning-package-plan-output/plan","version":"1"}}}]},{"id":"planning.package.approve","operator":"planning.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-planning-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45},{"id":"plan.abandon","operator":"plan.abandon","guard":{"true":true},"target":{"true":true},"priority":31},{"id":"plan.activate","operator":"plan.activate","guard":{"true":true},"target":{"true":true},"priority":50},{"id":"workspace.cut","operator":"workspace.cut","guard":{"true":true},"target":{"true":true},"priority":52,"parameters":[{"parameter":"branch","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/delivery-branch","version":"1"}}},{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"destination","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/managed-worktree-destination","version":"1"}}}]},{"id":"workspace.activate","operator":"workspace.activate","guard":{"true":true},"target":{"true":true},"priority":53,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"workspace.sync","operator":"workspace.sync","guard":{"true":true},"target":{"true":true},"priority":58,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"gate.build.record","operator":"gate.build.record","guard":{"true":true},"target":{"true":true},"priority":61,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/build","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/build","version":"1"}}}]},{"id":"gate.test.record","operator":"gate.test.record","guard":{"true":true},"target":{"true":true},"priority":62,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/test","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/test","version":"1"}}}]},{"id":"gate.review.record","operator":"gate.review.record","guard":{"true":true},"target":{"true":true},"priority":63,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/review","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/review","version":"1"}}}]},{"id":"gate.change.record","operator":"gate.change.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/change","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/change","version":"1"}}}]},{"id":"gate.journey.record","operator":"gate.journey.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/journey","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/journey","version":"1"}}}]},{"id":"evidence.visual.attach","operator":"evidence.visual.attach","guard":{"true":true},"target":{"true":true},"priority":66,"parameters":[{"parameter":"manifest_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-manifest-path","version":"1"}}},{"parameter":"privacy_receipt","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-privacy-receipt","version":"1"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"delivery.slice.advance","operator":"delivery.slice.advance","guard":{"true":true},"target":{"true":true},"priority":68,"parameters":[{"parameter":"slice_id","producer":{"kind":"host-input","request":{"id":"delivery-slice","description":"Select the next bounded delivery slice.","authorities":["human","autonomy"],"scope":"transition"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"publication.preview","operator":"publication.preview","guard":{"true":true},"target":{"true":true},"priority":72,"parameters":[{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"head_ref","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}}]},{"id":"workspace.publish","operator":"workspace.publish","guard":{"true":true},"target":{"true":true},"priority":75,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"publication.execute","operator":"publication.execute","guard":{"true":true},"target":{"true":true},"priority":76,"parameters":[{"parameter":"preview_fingerprint","producer":{"kind":"state","facet":"preview_fingerprint","available_when":{"fact":{"facet":"preview_fingerprint","statuses":["known"],"values":[]}}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"state-or-receipt","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}},"transition":"publication.execute","field":"publication_id"}}]},{"id":"publication.correct","operator":"publication.correct","guard":{"true":true},"target":{"true":true},"priority":80,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}},{"parameter":"body_sha256","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-sha256","version":"1"}}}]},{"id":"workspace.reconcile","operator":"workspace.reconcile","guard":{"true":true},"target":{"true":true},"priority":2,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]}],"targets":[{"id":"published-pr","predicate":{"all":[{"fact":{"facet":"verification","statuses":["known"],"values":["current"]}},{"fact":{"facet":"configuration","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"runtime","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"publication","statuses":["known"],"values":["open"]}}]}},{"id":"safely-abandoned","predicate":{"all":[{"fact":{"facet":"delivery","statuses":["known"],"values":["discarded"]}},{"fact":{"facet":"workspace","statuses":["known"],"values":["abandoned","absent"]}}]}}],"entries":[{"id":"run","target":"published-pr","requires":{"authorities":["human"]},"inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}],"delegation":{"reference":"software-delivery/delegation/autonomy","version":"1"}},{"id":"abandon","target":"safely-abandoned","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}]}]} +{"schema":"control-program","schema_revision":7,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"declarations":{"input_resolvers":["software-delivery.plan-inbox"]},"facets":[{"id":"phase","kind":"string"},{"id":"program","kind":"string"},{"id":"engagement","kind":"string"},{"id":"objective","kind":"string"},{"id":"delivery","kind":"string"},{"id":"workspace","kind":"string"},{"id":"work-package","kind":"string"},{"id":"plan","kind":"string"},{"id":"configuration","kind":"string"},{"id":"configuration-policy","kind":"string"},{"id":"runtime","kind":"string"},{"id":"publication","kind":"string"},{"id":"verification","kind":"string"},{"id":"recovery","kind":"string"},{"id":"recovery-info","kind":"string"},{"id":"transaction","kind":"string"},{"id":"terminal","kind":"string"},{"id":"recovery_budget","kind":"string"},{"id":"recovery_cause","kind":"string"},{"id":"recovery_resumption","kind":"string"},{"id":"recovery_source_phase","kind":"string"},{"id":"source_revision","kind":"string"},{"id":"preview_fingerprint","kind":"string"},{"id":"publication_id","kind":"string"},{"id":"recovery_transaction_id","kind":"string"},{"id":"transaction_id","kind":"string"},{"id":"transaction_transition","kind":"string"},{"id":"workspace_base_ref","kind":"string"},{"id":"workspace_branch","kind":"string"},{"id":"workspace_path","kind":"string"},{"id":"workspace_source_id","kind":"string"},{"id":"workspace_source_path","kind":"string"},{"id":"workspace_source_ref","kind":"string"},{"id":"worktree_fingerprint","kind":"string"}],"evidence":[{"id":"plan-evidence","subject":"plan","kind":"artifact"},{"id":"publication-evidence","subject":"publication","kind":"provider-observation"}],"work":[{"id":"planning-package","instructions":{"path":"boatstack/testdata/control-programs/assets/planning-package.md"},"inputs":[{"id":"plan","entry_input":"plan"}],"outputs":[{"id":"plan","path":"plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"feature-spec","path":"feature-spec.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"questions","path":"questions.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"test-plan","path":"test-plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"gaps","path":"gaps.md","media_type":"text/markdown","required":false,"max_bytes":131072},{"id":"autonomy","path":"autonomy.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"tasks","path":"compiled/tasks.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"test-matrix","path":"compiled/test-matrix.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"journey-oracles","path":"compiled/journey-oracles.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"evidence","path":"compiled/evidence.md","media_type":"text/markdown","required":true,"max_bytes":131072}]}],"operators":[{"id":"work.package.admit","binding":{"reference":"software-delivery/work.package.admit","version":"1"}},{"id":"work.package.approve","binding":{"reference":"software-delivery/work.package.approve","version":"1"}},{"id":"planning.package.promote","binding":{"reference":"software-delivery/planning.package.promote","version":"1"}},{"id":"plan.abandon","binding":{"reference":"software-delivery/plan.abandon","version":"1"}},{"id":"plan.activate","binding":{"reference":"software-delivery/plan.activate","version":"1"}},{"id":"workspace.cut","binding":{"reference":"software-delivery/workspace.cut","version":"1"}},{"id":"workspace.activate","binding":{"reference":"software-delivery/workspace.activate","version":"1"}},{"id":"workspace.sync","binding":{"reference":"software-delivery/workspace.sync","version":"1"}},{"id":"gate.build.record","binding":{"reference":"software-delivery/gate.build.record","version":"1"}},{"id":"gate.test.record","binding":{"reference":"software-delivery/gate.test.record","version":"1"}},{"id":"gate.review.record","binding":{"reference":"software-delivery/gate.review.record","version":"1"}},{"id":"gate.change.record","binding":{"reference":"software-delivery/gate.change.record","version":"1"}},{"id":"gate.journey.record","binding":{"reference":"software-delivery/gate.journey.record","version":"1"}},{"id":"evidence.visual.attach","binding":{"reference":"software-delivery/evidence.visual.attach","version":"1"}},{"id":"delivery.slice.advance","binding":{"reference":"software-delivery/delivery.slice.advance","version":"1"}},{"id":"publication.preview","binding":{"reference":"software-delivery/publication.preview","version":"1"}},{"id":"workspace.publish","binding":{"reference":"software-delivery/workspace.publish","version":"1"}},{"id":"publication.execute","binding":{"reference":"software-delivery/publication.execute","version":"1"}},{"id":"publication.observe","binding":{"reference":"software-delivery/publication.observe","version":"1"}},{"id":"publication.correct","binding":{"reference":"software-delivery/publication.correct","version":"1"}},{"id":"workspace.reconcile","binding":{"reference":"software-delivery/workspace.reconcile","version":"1"}},{"id":"publication.reconcile","binding":{"reference":"software-delivery/publication.reconcile","version":"1"}}],"transitions":[{"id":"work.package.admit","operator":"work.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"work.package.approve","operator":"work.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-work-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45,"parameters":[{"parameter":"plan_output","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/planning-package-plan-output/plan","version":"1"}}}]},{"id":"plan.abandon","operator":"plan.abandon","guard":{"true":true},"target":{"true":true},"priority":31},{"id":"plan.activate","operator":"plan.activate","guard":{"true":true},"target":{"true":true},"priority":50},{"id":"workspace.cut","operator":"workspace.cut","guard":{"true":true},"target":{"true":true},"priority":52,"parameters":[{"parameter":"branch","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/delivery-branch","version":"1"}}},{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"destination","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/managed-worktree-destination","version":"1"}}}]},{"id":"workspace.activate","operator":"workspace.activate","guard":{"true":true},"target":{"true":true},"priority":53,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"workspace.sync","operator":"workspace.sync","guard":{"true":true},"target":{"true":true},"priority":58,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"gate.build.record","operator":"gate.build.record","guard":{"true":true},"target":{"true":true},"priority":61,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/build","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/build","version":"1"}}}]},{"id":"gate.test.record","operator":"gate.test.record","guard":{"true":true},"target":{"true":true},"priority":62,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/test","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/test","version":"1"}}}]},{"id":"gate.review.record","operator":"gate.review.record","guard":{"true":true},"target":{"true":true},"priority":63,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/review","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/review","version":"1"}}}]},{"id":"gate.change.record","operator":"gate.change.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/change","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/change","version":"1"}}}]},{"id":"gate.journey.record","operator":"gate.journey.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/journey","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/journey","version":"1"}}}]},{"id":"evidence.visual.attach","operator":"evidence.visual.attach","guard":{"true":true},"target":{"true":true},"priority":66,"parameters":[{"parameter":"manifest_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-manifest-path","version":"1"}}},{"parameter":"privacy_receipt","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-privacy-receipt","version":"1"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"delivery.slice.advance","operator":"delivery.slice.advance","guard":{"true":true},"target":{"true":true},"priority":68,"parameters":[{"parameter":"slice_id","producer":{"kind":"host-input","request":{"id":"delivery-slice","description":"Select the next bounded delivery slice.","authorities":["human","autonomy"],"scope":"transition"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"publication.preview","operator":"publication.preview","guard":{"true":true},"target":{"true":true},"priority":72,"parameters":[{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"head_ref","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}}]},{"id":"workspace.publish","operator":"workspace.publish","guard":{"true":true},"target":{"true":true},"priority":75,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"publication.execute","operator":"publication.execute","guard":{"true":true},"target":{"true":true},"priority":76,"parameters":[{"parameter":"preview_fingerprint","producer":{"kind":"state","facet":"preview_fingerprint","available_when":{"fact":{"facet":"preview_fingerprint","statuses":["known"],"values":[]}}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"state-or-receipt","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}},"transition":"publication.execute","field":"publication_id"}}]},{"id":"publication.correct","operator":"publication.correct","guard":{"true":true},"target":{"true":true},"priority":80,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}},{"parameter":"body_sha256","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-sha256","version":"1"}}}]},{"id":"workspace.reconcile","operator":"workspace.reconcile","guard":{"true":true},"target":{"true":true},"priority":2,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]}],"targets":[{"id":"published-pr","predicate":{"all":[{"fact":{"facet":"verification","statuses":["known"],"values":["current"]}},{"fact":{"facet":"configuration","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"runtime","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"publication","statuses":["known"],"values":["open"]}}]}},{"id":"safely-abandoned","predicate":{"all":[{"fact":{"facet":"delivery","statuses":["known"],"values":["discarded"]}},{"fact":{"facet":"workspace","statuses":["known"],"values":["abandoned","absent"]}}]}}],"entries":[{"id":"run","target":"published-pr","requires":{"authorities":["human"]},"inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}],"delegation":{"reference":"software-delivery/delegation/autonomy","version":"1"}},{"id":"abandon","target":"safely-abandoned","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}]}]} diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index e34a572..2e94cba 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -3,7 +3,7 @@ Registry size: **63** transitions. Event classes: authority 9; owned-local 32; owned-external 2; recovery 7; observed-external 13. -Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `objective`. +Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `work-package`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `objective`. | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| diff --git a/docs/product-delivery/planning-and-foreground-work.md b/docs/product-delivery/planning-and-foreground-work.md index ea6af7d..46f29f4 100644 --- a/docs/product-delivery/planning-and-foreground-work.md +++ b/docs/product-delivery/planning-and-foreground-work.md @@ -13,16 +13,20 @@ run and validates required outputs, media types, size limits, and schemas. Work produces candidate artifacts only; completion does not advance Flow state or create authority. -The Product Delivery planning package uses three independent trusted -operations when the repository includes them: +Accepted foreground work uses two plan-neutral trusted operations: -1. `planning.package.admit` verifies and stores an immutable fingerprint-addressed snapshot; -2. `planning.package.approve` records exact admitted authority and actor provenance; -3. `planning.package.promote` publishes the approved canonical plan. +1. `work.package.admit` verifies and stores an immutable fingerprint-addressed snapshot; +2. `work.package.approve` records exact admitted authority and actor provenance. + +The optional `planning.package.promote` specialization selects one +compiler-bound required output from an approved package and publishes its exact +bytes as the canonical plan. Generic admission and approval do not read or +change plan state. The promotion receipt alone gives that output plan meaning. Repositories choose whether these operations belong to their lifecycle, their -priorities, and the target they serve. `planningPackage.work` and its explicit -`planOutput` are bound only to admit. No output ID is implicitly special. +priorities, and the target they serve. `workPackage.work` and +`planningPackage.work` bind foreground work only to generic admission. +`planningPackage.planOutput` binds only promotion. No output ID is implicitly special. Additional work must be explicitly registered and named by every lifecycle step that consumes it. @@ -38,14 +42,14 @@ delegated authority. ## Immutable packages and verification -Admission writes `.boatstack/planning-packages///` with +Admission writes `.boatstack/work-packages///` with `manifest.json`, `contract.json`, `work-receipt.json`, and the exact output bytes. `approval.json` is a one-time immutable sidecar. Historical verification uses the embedded contract and never needs the old Flow or controller state. ```sh -boatstack flow planning-package verify --repo . --all --format json -boatstack flow planning-package verify --repo . --delivery \ +boatstack flow work-package verify --repo . --all --format json +boatstack flow work-package verify --repo . --delivery \ --package --require-approval --require-current-program --format json ``` @@ -56,11 +60,11 @@ origin authenticity as `not-proven`. ## Related API - [`foregroundWork`, assets, and work outputs](../typescript/base-sdk.md#foreground-work) -- [Planning helpers](../typescript/software-delivery-sdk.md#planning) +- [Accepted-work and planning helpers](../typescript/software-delivery-sdk.md#accepted-work-and-planning) - [Writing a Flow](writing-a-flow.md) ## Current implementation anchors -- [Planning-package binding](../../boatstack/flow/softwaredelivery/planning_package.go) +- [Accepted-work binding](../../boatstack/flow/softwaredelivery/work_package.go) - [Foreground-work manager](../../boatstack/internal/softwaredelivery/foregroundwork/manager.go) - [Journal/work conformance](../../boatstack/internal/softwaredelivery/effects/journal_work_test.go) diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 70a9c5d..13941c5 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -17,17 +17,18 @@ import { } from "@operatorstack/boatstack"; import { inbox, - planningPackageAdmit, - planningPackageApprove, + planningPackage, planningPackagePromote, softwareDelivery, trustedDelegation, + workPackageAdmit, + workPackageApprove, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; const lifecycle = [ - planningPackageAdmit, - planningPackageApprove, + workPackageAdmit, + workPackageApprove, planningPackagePromote, { id: "workspace.cut", priority: 52 }, // Add the repository's trusted execution, gate, and publication steps here. @@ -54,10 +55,10 @@ export default defineFlow(softwareDelivery({ version: "1", humanIdentity: "developer", lifecycle: lifecycle, - planningPackage: { + planningPackage: planningPackage({ work: planning, planOutput: "implementation-plan", - }, + }), targets: [ marked( "published-pr", @@ -128,7 +129,7 @@ The standard producer ownership is: | Value | Canonical producer | | --- | --- | -| admitted planning-package fingerprint | trusted manifest resolver | +| admitted work-package fingerprint | trusted manifest resolver | | workspace branch, preview fingerprint, recovery IDs | durable state | | publication ID | `publication.execute` effect receipt | | gate evidence path and fingerprint | trusted canonical-artifact resolver | @@ -160,10 +161,11 @@ Prepare gate evidence at request body at `.boatstack/publication/.body.md`. Boatstack binds the exact path and content fingerprint when the transition is materialized. -The planning-package operations are optional trusted mechanisms. A repository -that includes them owns the planning instruction, output contract, transition -order, approval policy, target, and entry. `planning.package.admit` atomically -stores the verified package, `planning.package.approve` binds approval to its -exact manifest, and `planning.package.promote` publishes the approved canonical -plan. Repositories that omit these operations retain the existing plan path; -StandardFlow does not add them automatically. +Accepted-work operations are optional trusted mechanisms. A repository that +includes them owns the instruction, output contract, transition order, +approval policy, target, and entry. `work.package.admit` atomically stores the +verified generic package and `work.package.approve` binds approval to its +exact manifest without changing plan state. A `planningPackage` may also +select `planning.package.promote`, which alone binds its explicit +`planOutput` to canonical plan bytes and a plan-promotion receipt. +StandardFlow adds none of these operations automatically. diff --git a/docs/typescript/software-delivery-sdk.md b/docs/typescript/software-delivery-sdk.md index 1547bc5..7bc08c1 100644 --- a/docs/typescript/software-delivery-sdk.md +++ b/docs/typescript/software-delivery-sdk.md @@ -15,13 +15,14 @@ bind operation IDs through the trusted registry. Repositories may strengthen authority but cannot replace minimum capability, effect, verification, or recovery semantics. -## Planning +## Accepted work and planning -`planningPackageAdmit`, `planningPackageApprove`, and -`planningPackagePromote` are optional trusted steps. A repository includes them -explicitly. `planningPackage: { work, planOutput }` binds foreground work and -one required canonical plan output only to admit. The plan-output ID becomes a -trusted compiled parameter binding; `plan` has no magic meaning. +`workPackageAdmit` and `workPackageApprove` are optional plan-neutral trusted +steps. `workPackage({ work })` validates a generic accepted-work declaration. +`planningPackage({ work, planOutput })` adds one optional specialization: +`planningPackagePromote` binds the selected required output to canonical plan +bytes. The plan-output ID is a trusted compiled promotion parameter; `plan` +has no magic meaning. ## Authority and delegation @@ -33,7 +34,7 @@ none of these helpers approves a run. ## Repository inputs and evidence Trusted producer helpers read the default branch, managed workspace identity, -admitted planning manifest, current revision, gate evidence, publication body, +admitted work-package manifest, current revision, gate evidence, publication body, visual evidence, and recovery transaction through runtime-owned boundaries. See [Product Delivery](../product-delivery/index.md) for lifecycle semantics and diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 36495e0..532677a 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -52,6 +52,7 @@ export const softwareDeliveryFacets: FacetDefinition[] = [ "objective", "delivery", "workspace", + "work-package", "plan", "configuration", "configuration-policy", @@ -103,18 +104,18 @@ export interface TrustedStep { work?: string; } -/** Admits a completed planning package into the delivery lifecycle. - * @category Planning +/** Admits a completed generic work package. + * @category Accepted work */ -export const planningPackageAdmit: TrustedStep = { - id: "planning.package.admit", +export const workPackageAdmit: TrustedStep = { + id: "work.package.admit", priority: 43, }; -/** Records approval of the exact admitted planning package. - * @category Planning +/** Records approval of the exact admitted generic work package. + * @category Accepted work */ -export const planningPackageApprove: TrustedStep = { - id: "planning.package.approve", +export const workPackageApprove: TrustedStep = { + id: "work.package.approve", priority: 44, }; /** Promotes an approved planning package into the active delivery plan. @@ -125,13 +126,22 @@ export const planningPackagePromote: TrustedStep = { priority: 45, }; -/** Repository-owned foreground-work contract and explicit canonical plan output. */ +/** Repository-owned foreground-work contract accepted as one immutable package. + * @category Accepted work + */ +export interface WorkPackageDefinition { + work: WorkContract; +} + +/** Repository-owned accepted work specialized with one canonical plan output. + * @category Planning + */ export interface PlanningPackageDefinition { work: WorkContract; planOutput: string; } -function portablePlanningPath(path: string): boolean { +function portableWorkPackagePath(path: string): boolean { if (path.length === 0 || path.startsWith("/") || path.includes("\\")) return false; for (const component of path.split("/")) { if ( @@ -148,24 +158,13 @@ function portablePlanningPath(path: string): boolean { return true; } -function validatePlanningPackage(planning: PlanningPackageDefinition): void { - const { work, planOutput } = planning; - if (!/^[a-z][a-z0-9._-]*$/.test(planOutput)) { - throw new Error( - "SOFTWARE_DELIVERY_PLAN_OUTPUT_INVALID: planOutput must be a canonical semantic artifact ID", - ); - } - const selected = work.outputs.filter((output) => output.id === planOutput); - if (selected.length !== 1 || !selected[0].required) { - throw new Error( - "SOFTWARE_DELIVERY_PLAN_OUTPUT_REQUIRED: planOutput must select exactly one required output", - ); - } +function validateWorkPackage(definition: WorkPackageDefinition): void { + const { work } = definition; const paths = new Map(); const reserved = ["manifest.json", "contract.json", "work-receipt.json", "approval.json"]; for (const output of work.outputs) { const path = output.path.toLowerCase(); - if (!portablePlanningPath(output.path)) { + if (!portableWorkPackagePath(output.path)) { throw new Error(`SOFTWARE_DELIVERY_OUTPUT_PATH_INVALID: ${JSON.stringify(output.path)}`); } for (const owned of reserved) { @@ -182,6 +181,38 @@ function validatePlanningPackage(planning: PlanningPackageDefinition): void { } } +function validatePlanningPackage(planning: PlanningPackageDefinition): void { + const { work, planOutput } = planning; + validateWorkPackage({ work }); + if (!/^[a-z][a-z0-9._-]*$/.test(planOutput)) { + throw new Error( + "SOFTWARE_DELIVERY_PLAN_OUTPUT_INVALID: planOutput must be a canonical semantic artifact ID", + ); + } + const selected = work.outputs.filter((output) => output.id === planOutput); + if (selected.length !== 1 || !selected[0].required) { + throw new Error( + "SOFTWARE_DELIVERY_PLAN_OUTPUT_REQUIRED: planOutput must select exactly one required output", + ); + } +} + +/** Validates and returns a plan-neutral accepted-work declaration. + * @category Accepted work + */ +export function workPackage(definition: WorkPackageDefinition): WorkPackageDefinition { + validateWorkPackage(definition); + return definition; +} + +/** Validates and returns an accepted-work declaration with one plan promotion output. + * @category Planning + */ +export function planningPackage(definition: PlanningPackageDefinition): PlanningPackageDefinition { + validatePlanningPackage(definition); + return definition; +} + /** * Repository-owned policy composed with canonical software-delivery wiring. * @@ -203,7 +234,9 @@ export interface SoftwareDeliveryFlowDefinition { description?: string; /** Explicit trusted lifecycle membership and repository-selected priorities. */ lifecycle: TrustedStep[]; - /** Foreground work and explicit canonical plan output bound to admission. */ + /** Plan-neutral foreground work admitted and approved as one package. */ + workPackage?: WorkPackageDefinition; + /** Foreground work plus one output bound only to planning promotion. */ planningPackage?: PlanningPackageDefinition; /** Additional explicit work contracts, in repository-selected order. */ work?: WorkContract[]; @@ -245,9 +278,15 @@ function validateSoftwareDeliveryDefinition( } } + if (definition.workPackage && definition.planningPackage) { + throw new Error( + "SOFTWARE_DELIVERY_PACKAGE_EXCLUSIVE: workPackage and planningPackage are mutually exclusive", + ); + } + const acceptedPackage = definition.workPackage ?? definition.planningPackage; const workIDs = new Set(); - const work = definition.planningPackage - ? [definition.planningPackage.work, ...(definition.work ?? [])] + const work = acceptedPackage + ? [acceptedPackage.work, ...(definition.work ?? [])] : definition.work ?? []; for (const contract of work) { if (workIDs.has(contract.id)) { @@ -258,19 +297,30 @@ function validateSoftwareDeliveryDefinition( workIDs.add(contract.id); } - if ( - definition.planningPackage && - !lifecycleIDs.has(planningPackageAdmit.id) - ) { + const packageSteps = [workPackageAdmit.id, workPackageApprove.id]; + if (acceptedPackage && packageSteps.some((id) => !lifecycleIDs.has(id))) { + throw new Error( + "SOFTWARE_DELIVERY_WORK_PACKAGE_UNUSED: a package requires work.package.admit and work.package.approve in lifecycle", + ); + } + if (!acceptedPackage && packageSteps.some((id) => lifecycleIDs.has(id))) { throw new Error( - "SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.admit in lifecycle", + "SOFTWARE_DELIVERY_WORK_PACKAGE_REQUIRED: accepted-work lifecycle steps require workPackage or planningPackage", ); } - if (!definition.planningPackage && lifecycleIDs.has(planningPackageAdmit.id)) { + if (definition.planningPackage && !lifecycleIDs.has(planningPackagePromote.id)) { throw new Error( - "SOFTWARE_DELIVERY_PLANNING_PACKAGE_REQUIRED: planning.package.admit requires planningPackage", + "SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.promote in lifecycle", ); } + if (!definition.planningPackage && lifecycleIDs.has(planningPackagePromote.id)) { + throw new Error( + "SOFTWARE_DELIVERY_PLANNING_PACKAGE_REQUIRED: planning.package.promote requires planningPackage", + ); + } + if (definition.workPackage) { + validateWorkPackage(definition.workPackage); + } if (definition.planningPackage) { validatePlanningPackage(definition.planningPackage); } @@ -289,12 +339,12 @@ function validateSoftwareDeliveryDefinition( ); } if ( - definition.planningPackage && - (step.id === planningPackageAdmit.id || - step.work === definition.planningPackage.work.id) + acceptedPackage && + (step.id === workPackageAdmit.id || + step.work === acceptedPackage.work.id) ) { throw new Error( - `SOFTWARE_DELIVERY_WORK_CONFLICT: ${JSON.stringify(step.id)} cannot replace or repeat planningPackage.work`, + `SOFTWARE_DELIVERY_WORK_CONFLICT: ${JSON.stringify(step.id)} cannot replace or repeat accepted package work`, ); } if (!additionalWorkIDs.has(step.work)) { @@ -343,10 +393,12 @@ export function softwareDelivery( validateSoftwareDeliveryDefinition(definition); const inputResolvers = referencedInputResolvers(definition.entries); - const work = definition.planningPackage - ? [definition.planningPackage.work, ...(definition.work ?? [])] + const acceptedPackage = definition.workPackage ?? definition.planningPackage; + const work = acceptedPackage + ? [acceptedPackage.work, ...(definition.work ?? [])] : [...(definition.work ?? [])]; const transitions = trustedSoftwareDeliveryTransitions(definition.lifecycle, { + workPackage: definition.workPackage, planningPackage: definition.planningPackage, }).map((transitionDefinition, index) => { const workID = definition.lifecycle[index].work; @@ -424,12 +476,12 @@ export function managedWorktreeDestination(): ParameterProducer { ); } -/** Reads the exact fingerprint of the planning-package manifest admitted for this delivery. +/** Reads the exact fingerprint of the work-package manifest admitted for this delivery. * @category Parameter producers */ -export function admittedPlanningPackageFingerprint(): ParameterProducer { +export function admittedWorkPackageFingerprint(): ParameterProducer { return trustedParameterResolver( - "software-delivery/admitted-planning-package-fingerprint", + "software-delivery/admitted-work-package-fingerprint", "1", ); } @@ -497,8 +549,8 @@ function canonicalGateParameters(gate: "build" | "test" | "review" | "change" | */ export function standardSoftwareDeliveryParameters(step: TrustedStep): Record { switch (step.id) { - case "planning.package.approve": - return { package_fingerprint: admittedPlanningPackageFingerprint() }; + case "work.package.approve": + return { package_fingerprint: admittedWorkPackageFingerprint() }; case "workspace.cut": return { branch: deliveryBranch(), @@ -681,22 +733,38 @@ export function trustedSoftwareDeliveryTransition( */ export function trustedSoftwareDeliveryTransitions( steps: TrustedStep[], - options: { planningPackage?: PlanningPackageDefinition } = {}, + options: { + workPackage?: WorkPackageDefinition; + planningPackage?: PlanningPackageDefinition; + } = {}, ): TransitionDefinition[] { - const hasAdmit = steps.some((step) => step.id === planningPackageAdmit.id); - if (hasAdmit && !options.planningPackage) { - throw new Error("SOFTWARE_DELIVERY_PLANNING_PACKAGE_REQUIRED: planning.package.admit requires planningPackage"); + if (options.workPackage && options.planningPackage) { + throw new Error("SOFTWARE_DELIVERY_PACKAGE_EXCLUSIVE: workPackage and planningPackage are mutually exclusive"); + } + const acceptedPackage = options.workPackage ?? options.planningPackage; + const hasAdmit = steps.some((step) => step.id === workPackageAdmit.id); + const hasApprove = steps.some((step) => step.id === workPackageApprove.id); + const hasPromote = steps.some((step) => step.id === planningPackagePromote.id); + if ((hasAdmit || hasApprove) && !acceptedPackage) { + throw new Error("SOFTWARE_DELIVERY_WORK_PACKAGE_REQUIRED: accepted-work lifecycle steps require a package"); + } + if ((!hasAdmit || !hasApprove) && acceptedPackage) { + throw new Error("SOFTWARE_DELIVERY_WORK_PACKAGE_UNUSED: a package requires admit and approve lifecycle steps"); + } + if (hasPromote && !options.planningPackage) { + throw new Error("SOFTWARE_DELIVERY_PLANNING_PACKAGE_REQUIRED: planning.package.promote requires planningPackage"); } - if (!hasAdmit && options.planningPackage) { - throw new Error("SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.admit"); + if (!hasPromote && options.planningPackage) { + throw new Error("SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.promote"); } + if (options.workPackage) validateWorkPackage(options.workPackage); if (options.planningPackage) validatePlanningPackage(options.planningPackage); return steps.map((step) => { - const planning = step.id === planningPackageAdmit.id ? options.planningPackage : undefined; + const packageAdmission = step.id === workPackageAdmit.id ? acceptedPackage : undefined; const result = trustedSoftwareDeliveryTransition(step, { - ...(planning ? { work: planning.work } : {}), + ...(packageAdmission ? { work: packageAdmission.work } : {}), }); - if (!planning) return result; + if (step.id !== planningPackagePromote.id || !options.planningPackage) return result; return { ...result, parameters: [ @@ -704,7 +772,7 @@ export function trustedSoftwareDeliveryTransitions( { parameter: "plan_output", producer: trustedParameterResolver( - `${bindingPrefix}planning-package-plan-output/${planning.planOutput}`, + `${bindingPrefix}planning-package-plan-output/${options.planningPackage.planOutput}`, "1", ), }, diff --git a/packages/boatstack-software-delivery/test/software-delivery.test.mjs b/packages/boatstack-software-delivery/test/software-delivery.test.mjs index cc5169c..b93af71 100644 --- a/packages/boatstack-software-delivery/test/software-delivery.test.mjs +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -2,10 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - planningPackageAdmit, + planningPackage, + planningPackagePromote, softwareDelivery, softwareDeliveryEvidence, softwareDeliveryFacets, + workPackage, + workPackageAdmit, + workPackageApprove, } from "../dist/index.js"; const target = { id: "done", predicate: { true: true } }; @@ -73,11 +77,13 @@ test("registers planning work exactly once before additional work", () => { const result = softwareDelivery( definition({ lifecycle: [ - planningPackageAdmit, + workPackageAdmit, + workPackageApprove, + planningPackagePromote, { id: "plan.activate", priority: 50, work: "implementation" }, { id: "plan.abandon", priority: 31, work: "review" }, ], - planningPackage: { work: planning, planOutput: "implementation-plan" }, + planningPackage: planningPackage({ work: planning, planOutput: "implementation-plan" }), work: additional, }), ); @@ -88,8 +94,8 @@ test("registers planning work exactly once before additional work", () => { "review", ]); assert.equal(result.transitions[0].work, "planning-package"); - assert.equal(result.transitions[1].work, "implementation"); - assert.equal(result.transitions[2].work, "review"); + assert.equal(result.transitions[3].work, "implementation"); + assert.equal(result.transitions[4].work, "review"); }); test("closes resolver declarations over exact first use", () => { @@ -124,8 +130,8 @@ test("closes resolver declarations over exact first use", () => { test("planning work alone does not infer an input resolver", () => { const result = softwareDelivery( definition({ - lifecycle: [planningPackageAdmit], - planningPackage: { work: work("planning-package"), planOutput: "implementation-plan" }, + lifecycle: [workPackageAdmit, workPackageApprove, planningPackagePromote], + planningPackage: planningPackage({ work: work("planning-package"), planOutput: "implementation-plan" }), }), ); assert.equal(result.declarations, undefined); @@ -133,8 +139,38 @@ test("planning work alone does not infer an input resolver", () => { test("binds the explicit plan output through trusted compiled semantics", () => { const planning = work("planning-package"); - const result = softwareDelivery(definition({ lifecycle: [planningPackageAdmit], planningPackage: { work: planning, planOutput: "implementation-plan" } })); - assert.deepEqual(result.transitions[0].parameters, [{ parameter: "plan_output", producer: { kind: "trusted-resolver", binding: { reference: "software-delivery/planning-package-plan-output/implementation-plan", version: "1" } } }]); + const result = softwareDelivery(definition({ lifecycle: [workPackageAdmit, workPackageApprove, planningPackagePromote], planningPackage: planningPackage({ work: planning, planOutput: "implementation-plan" }) })); + assert.equal(result.transitions[0].parameters, undefined); + assert.deepEqual(result.transitions[2].parameters, [{ parameter: "plan_output", producer: { kind: "trusted-resolver", binding: { reference: "software-delivery/planning-package-plan-output/implementation-plan", version: "1" } } }]); +}); + +test("generic work package binds no plan semantics", () => { + const accepted = work("accepted-work"); + accepted.outputs.push( + { id: "architecture-plan", path: "architecture-plan.md", media_type: "text/markdown", required: true }, + { id: "tasks", path: "tasks.json", media_type: "application/json", required: true }, + { id: "verification", path: "verification.json", media_type: "application/json", required: true }, + { id: "journey", path: "journey.md", media_type: "text/markdown", required: true }, + ); + const result = softwareDelivery(definition({ + lifecycle: [workPackageAdmit, workPackageApprove], + workPackage: workPackage({ work: accepted }), + })); + assert.equal(result.transitions[0].work, accepted.id); + assert.equal(result.transitions[0].parameters, undefined); + assert.equal(result.transitions.some(({ id }) => id === "planning.package.promote"), false); +}); + +test("rejects simultaneous generic and planning package declarations", () => { + const accepted = work("accepted-work"); + assert.throws( + () => softwareDelivery(definition({ + lifecycle: [workPackageAdmit, workPackageApprove, planningPackagePromote], + workPackage: workPackage({ work: accepted }), + planningPackage: planningPackage({ work: accepted, planOutput: "implementation-plan" }), + })), + /SOFTWARE_DELIVERY_PACKAGE_EXCLUSIVE/, + ); }); for (const [name, planning, error] of [ @@ -145,7 +181,7 @@ for (const [name, planning, error] of [ ["Windows trailing-dot path", { work: { ...work("planning"), outputs: [{ id: "implementation-plan", path: "plan.", media_type: "text/markdown", required: true }] }, planOutput: "implementation-plan" }, /SOFTWARE_DELIVERY_OUTPUT_PATH_INVALID/], ["ancestor collision", { work: { ...work("planning"), outputs: [{ id: "implementation-plan", path: "planning", media_type: "text/markdown", required: true }, { id: "details", path: "planning/details.md", media_type: "text/markdown", required: true }] }, planOutput: "implementation-plan" }, /SOFTWARE_DELIVERY_OUTPUT_PATH_CONFLICT/], ]) { - test(`rejects ${name}`, () => assert.throws(() => softwareDelivery(definition({ lifecycle: [planningPackageAdmit], planningPackage: planning })), error)); + test(`rejects ${name}`, () => assert.throws(() => softwareDelivery(definition({ lifecycle: [workPackageAdmit, workPackageApprove, planningPackagePromote], planningPackage: planning })), error)); } for (const [name, lifecycle, prefix] of [ @@ -176,8 +212,8 @@ test("rejects duplicate work IDs including repeated planning work", () => { () => softwareDelivery( definition({ - lifecycle: [planningPackageAdmit], - planningPackage: { work: planning, planOutput: "implementation-plan" }, + lifecycle: [workPackageAdmit, workPackageApprove, planningPackagePromote], + planningPackage: planningPackage({ work: planning, planOutput: "implementation-plan" }), work: [planning], }), ), @@ -194,13 +230,13 @@ for (const humanIdentity of [undefined, "", "Developer", "1developer", "develope }); } -test("rejects planning work without its admit step", () => { +test("rejects planning work without its complete package lifecycle", () => { assert.throws( () => softwareDelivery( - definition({ planningPackage: { work: work("planning-package"), planOutput: "implementation-plan" } }), + definition({ planningPackage: planningPackage({ work: work("planning-package"), planOutput: "implementation-plan" }) }), ), - /SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED/, + /SOFTWARE_DELIVERY_WORK_PACKAGE_UNUSED/, ); }); @@ -240,15 +276,15 @@ test("rejects empty lifecycle work references", () => { test("rejects replacing or repeating the planning work binding", () => { const planning = work("planning-package"); for (const lifecycle of [ - [{ ...planningPackageAdmit, work: "implementation" }], - [planningPackageAdmit, { id: "plan.activate", priority: 50, work: planning.id }], + [{ ...workPackageAdmit, work: "implementation" }, workPackageApprove, planningPackagePromote], + [workPackageAdmit, workPackageApprove, planningPackagePromote, { id: "plan.activate", priority: 50, work: planning.id }], ]) { assert.throws( () => softwareDelivery( definition({ lifecycle, - planningPackage: { work: planning, planOutput: "implementation-plan" }, + planningPackage: planningPackage({ work: planning, planOutput: "implementation-plan" }), work: [work("implementation")], }), ), diff --git a/release-notes/2026-08-18-generic-work-packages.md b/release-notes/2026-08-18-generic-work-packages.md new file mode 100644 index 0000000..fc01e6a --- /dev/null +++ b/release-notes/2026-08-18-generic-work-packages.md @@ -0,0 +1,3 @@ +### Generic accepted work packages + +Software-delivery Flows can admit and approve immutable foreground-work packages without assigning plan semantics to their artifacts. Planning is now an explicit promotion specialization. This breaking alpha change replaces planning-package storage, operations, fingerprints, verification commands, and durable schema 7 with generic work-package equivalents and durable schema 8; existing state is not migrated. diff --git a/scripts/check-docs-api.mjs b/scripts/check-docs-api.mjs index 4ac9153..2e4b171 100644 --- a/scripts/check-docs-api.mjs +++ b/scripts/check-docs-api.mjs @@ -28,14 +28,17 @@ const required = new Map([ "@operatorstack/boatstack-software-delivery", [ "SoftwareDeliveryFlowDefinition", + "WorkPackageDefinition", "inbox", - "planningPackageAdmit", - "planningPackageApprove", + "planningPackage", "planningPackagePromote", "softwareDelivery", "trustedDelegation", "trustedSoftwareDeliveryTransitions", "trustedTransition", + "workPackage", + "workPackageAdmit", + "workPackageApprove", ], ], ]); @@ -57,6 +60,7 @@ const requiredCategories = new Map([ "@operatorstack/boatstack-software-delivery", [ "Authority and delegation", + "Accepted work", "Flow composition", "Parameter producers", "Planning", diff --git a/scripts/check-docs-api.test.mjs b/scripts/check-docs-api.test.mjs index 3c42778..3b82f1c 100644 --- a/scripts/check-docs-api.test.mjs +++ b/scripts/check-docs-api.test.mjs @@ -25,14 +25,17 @@ const requiredFunctions = { ], "@operatorstack/boatstack-software-delivery": [ "SoftwareDeliveryFlowDefinition", + "WorkPackageDefinition", "inbox", - "planningPackageAdmit", - "planningPackageApprove", + "planningPackage", "planningPackagePromote", "softwareDelivery", "trustedDelegation", "trustedSoftwareDeliveryTransitions", "trustedTransition", + "workPackage", + "workPackageAdmit", + "workPackageApprove", ], }; From 2c0abe61a4e73e5cd2d2a675df3cb6bb1d424506 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 00:07:00 +0100 Subject: [PATCH 02/14] Allow planning packages to stop at approved plan --- .../flow/softwaredelivery/work_package.go | 1 + .../work_package_internal_test.go | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 boatstack/flow/softwaredelivery/work_package_internal_test.go diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go index 4104537..90af9ae 100644 --- a/boatstack/flow/softwaredelivery/work_package.go +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -83,6 +83,7 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli model.FacetPlan, string(model.PlanAbsent), ) promote.TargetConditions = replaceFacet(promote.TargetConditions, model.FacetPlan, string(model.PlanApproved)) + promote.TargetIDs = appendUniqueTarget(promote.TargetIDs, model.ObjectiveApprovedPlan) promote.OwnedResources = []string{"plan", "planning-package-promotion"} promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote diff --git a/boatstack/flow/softwaredelivery/work_package_internal_test.go b/boatstack/flow/softwaredelivery/work_package_internal_test.go new file mode 100644 index 0000000..2368033 --- /dev/null +++ b/boatstack/flow/softwaredelivery/work_package_internal_test.go @@ -0,0 +1,37 @@ +package softwaredelivery + +import ( + "context" + "testing" + + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestPlanningPackagePromoteSupportsApprovedPlanObjective(t *testing.T) { + manifest, err := standard.Definition().RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + base := make(map[string]delivery.Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + base[string(transition.ID)] = transition + } + transitions, err := acceptedWorkTransitions(base) + if err != nil { + t.Fatal(err) + } + for _, transition := range transitions { + if transition.ID != PlanningPackagePromote { + continue + } + for _, target := range transition.TargetIDs { + if target == model.ObjectiveApprovedPlan { + return + } + } + t.Fatalf("planning promotion targets = %#v", transition.TargetIDs) + } + t.Fatal("planning promotion transition is missing") +} From fde75649cad4c46f9c6e5a5ffad3a054038490ff Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 00:09:21 +0100 Subject: [PATCH 03/14] Resolve accepted package entry objectives --- boatstack/flow/softwaredelivery/definition.go | 21 ++++++++++++------- .../flow/softwaredelivery/definition_test.go | 20 ++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 57bf804..fa85200 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -61,14 +61,7 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim objectives := map[model.TargetID]EntryObjective{} contracts := map[model.TargetID]delivery.ObjectiveContract{} entriesByTarget := map[model.TargetID][]controlprogram.Entry{} - base.ObjectiveContracts = append(base.ObjectiveContracts, delivery.ObjectiveContract{ - TargetID: model.ObjectiveApprovedWorkPackage, - Conditions: []delivery.FacetCondition{{ - Facet: model.FacetWorkPackage, - Statuses: []model.FactStatus{model.FactKnown}, - Values: []string{string(model.WorkPackageApproved)}, - }}, - }) + addAcceptedWorkPackageObjective(&base) for _, entry := range d.compiled.Document.Entries { objective, objectiveErr := objectiveContractForEntry(d.compiled, base, entry.ID) if objectiveErr != nil { @@ -268,11 +261,23 @@ func uniqueAuthorities(values []delivery.AuthorityClass) []delivery.AuthorityCla return result } +func addAcceptedWorkPackageObjective(base *delivery.ProgramRuntimeManifest) { + base.ObjectiveContracts = append(base.ObjectiveContracts, delivery.ObjectiveContract{ + TargetID: model.ObjectiveApprovedWorkPackage, + Conditions: []delivery.FacetCondition{{ + Facet: model.FacetWorkPackage, + Statuses: []model.FactStatus{model.FactKnown}, + Values: []string{string(model.WorkPackageApproved)}, + }}, + }) +} + func ObjectiveForEntry(ctx context.Context, compiled controlprogram.Compiled, resolver Resolver, entryID string) (EntryObjective, error) { base, err := standard.Definition().RuntimeManifest(ctx) if err != nil { return EntryObjective{}, err } + addAcceptedWorkPackageObjective(&base) return objectiveContractForEntry(compiled, base, entryID) } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 4e7d246..67d5d5a 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -328,6 +328,26 @@ func TestRepositoryTransitionCannotWidenTrustedTargetIDs(t *testing.T) { } } +func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + document := compiled.Document + document.Facets = append(document.Facets, controlprogram.Facet{ID: "work-package", Kind: "string"}) + document.Targets = []controlprogram.Target{{ID: "approved-package", Predicate: fact("work-package", "approved")}} + document.Entries = []controlprogram.Entry{{ID: "accept", Target: "approved-package"}} + accepted, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + objective, err := softwareflow.ObjectiveForEntry(context.Background(), accepted, resolver, "accept") + if err != nil { + t.Fatal(err) + } + if objective.TrustedClass != model.ObjectiveApprovedWorkPackage || objective.TargetID != "approved-package" { + t.Fatalf("accepted-work objective = %#v", objective) + } +} + func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) { truth := true resolver, err := softwareflow.NewResolver(context.Background()) From ecc795786ecf6844b00f06585cb40bf95653bd0a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 02:38:02 +0100 Subject: [PATCH 04/14] Version foreground work evidence identity --- .../softwaredelivery/protocol/work.go | 2 +- .../softwaredelivery/protocol/work_test.go | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 boatstack/internal/softwaredelivery/protocol/work_test.go diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go index 1a61621..c62ee8c 100644 --- a/boatstack/internal/softwaredelivery/protocol/work.go +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -11,7 +11,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const WorkEvidenceSchemaVersion = 2 +const WorkEvidenceSchemaVersion = 3 // WorkInputValue binds the value presented to foreground work to the exact // bytes selected by the trusted entry-input resolver. Value is an ergonomic diff --git a/boatstack/internal/softwaredelivery/protocol/work_test.go b/boatstack/internal/softwaredelivery/protocol/work_test.go new file mode 100644 index 0000000..4ac4750 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/work_test.go @@ -0,0 +1,51 @@ +package protocol + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" +) + +func workEvidenceFixture() WorkEvidence { + content := "accepted output\n" + digest := sha256.Sum256([]byte(content)) + return WorkEvidence{ + SchemaVersion: WorkEvidenceSchemaVersion, + RequestID: "work-request", + RequestFingerprint: strings.Repeat("1", 64), + RunID: "run-current", + ProgramID: "work-package-proof", + EntryID: "accept", + ContractID: "work-package-proof", + ContractFingerprint: strings.Repeat("2", 64), + TransitionID: catalog.TransitionID("work.package.admit"), + ProgramFingerprint: strings.Repeat("3", 64), + ContextFingerprint: strings.Repeat("4", 64), + StateRevision: 1, + RepositoryID: "repository", + WorktreeID: "worktree", + Outputs: []WorkOutputEvidence{{ + ID: "implementation-plan", Path: "artifacts/implementation-plan.md", MediaType: "text/markdown", + SHA256: hex.EncodeToString(digest[:]), Size: int64(len(content)), Content: content, + }}, + } +} + +func TestWorkEvidenceSchemaBindsRequiredExecutionIdentity(t *testing.T) { + current, err := SealWorkEvidence(workEvidenceFixture()) + if err != nil { + t.Fatal(err) + } + if err := current.Validate(); err != nil { + t.Fatalf("current work evidence rejected: %v", err) + } + + stale := workEvidenceFixture() + stale.SchemaVersion = 2 + if _, err := SealWorkEvidence(stale); err == nil || !strings.Contains(err.Error(), "incomplete identity") { + t.Fatalf("schema-2 work evidence validation = %v", err) + } +} From 65f7e12b43776e66b8c1f579edc21517cb5b0bf5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 03:26:34 +0100 Subject: [PATCH 05/14] Keep objective binding target neutral --- boatstack/core/system_test.go | 18 +++++ boatstack/core/transitions.json | 7 -- .../flow/softwaredelivery/definition_test.go | 63 ++++++++++++++++++ .../boatstack-transition-catalog.md | 66 +++++++++---------- 4 files changed, 114 insertions(+), 40 deletions(-) diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index f9d894b..366352d 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -44,6 +44,24 @@ func TestInstallationInitializationAcceptsExplicitlyDelegatedAutonomy(t *testing t.Fatal("installation.initialize is absent") } +func TestObjectiveBindingIsTargetNeutral(t *testing.T) { + // control-law: core-binds-only-domain-admitted-objectives-without-owning-domain-vocabulary + manifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, transition := range manifest.Transitions { + if transition.ID != "objective.bind" { + continue + } + if len(transition.TargetIDs) != 0 { + t.Fatalf("objective.bind owns domain targets %v", transition.TargetIDs) + } + return + } + t.Fatal("objective.bind is absent") +} + func hasPrefix(value string, prefixes ...string) bool { for _, prefix := range prefixes { if strings.HasPrefix(value, prefix) { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 6d59897..4ce7226 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -3149,13 +3149,6 @@ "ACTIVE", "FRONTIER" ], - "target_ids": [ - "approved-plan", - "verified-implementation", - "open-or-updated-pr", - "merged-delivery", - "safely-abandoned" - ], "required_identity": [ "repository-id", "git-common-id", diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 67d5d5a..3361e26 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -4,14 +4,18 @@ import ( "context" "crypto/sha256" "encoding/hex" + "path/filepath" "strings" "testing" + "time" "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/core" "github.com/operatorstack/boatstack/boatstack/delivery" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" ) func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram.Compiled, softwareflow.Resolver) { @@ -335,6 +339,19 @@ func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { document.Facets = append(document.Facets, controlprogram.Facet{ID: "work-package", Kind: "string"}) document.Targets = []controlprogram.Target{{ID: "approved-package", Predicate: fact("work-package", "approved")}} document.Entries = []controlprogram.Entry{{ID: "accept", Target: "approved-package"}} + document.Operators = []controlprogram.Operator{{ID: "work.package.approve", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/work.package.approve", Version: "1"}}} + metadata, err := resolver.ResolveParameterResolver(softwareflow.ParameterResolverPrefix+"admitted-work-package-fingerprint", "1") + if err != nil { + t.Fatal(err) + } + document.Transitions = []controlprogram.Transition{{ + ID: "work.package.approve", Operator: "work.package.approve", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 44, + Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "package_fingerprint", Producer: controlprogram.ParameterProducer{ + Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{ + Reference: softwareflow.ParameterResolverPrefix + "admitted-work-package-fingerprint", Version: "1", Fingerprint: metadata.Fingerprint, + }, + }}}, + }} accepted, err := controlprogram.Compile(document, resolver) if err != nil { t.Fatal(err) @@ -346,6 +363,52 @@ func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { if objective.TrustedClass != model.ObjectiveApprovedWorkPackage || objective.TargetID != "approved-package" { t.Fatalf("accepted-work objective = %#v", objective) } + definition, err := softwareflow.NewDefinition(accepted, resolver) + if err != nil { + t.Fatal(err) + } + program, err := delivery.Compile(context.Background(), delivery.CompileRequest{ + KernelVersion: "v2.0.0", Core: core.System(), Runtime: definition, Settings: map[string]string{"repo": "fixture"}, + }) + if err != nil { + t.Fatal(err) + } + requested := model.Objective{ID: "accepted-package", TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: "delivery"} + evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()} + snapshot, err := model.Canonicalize(model.Observation{ + SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 1, + Invocation: model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/feature", + ControllerID: "controller", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "runtime", + RuntimePath: filepath.Join(t.TempDir(), "boatstack"), RuntimeFingerprint: "fingerprint", + Topology: model.TopologyEmbedded, Host: "cursor", Correlation: "correlation", + }, + Program: model.Known(model.ProgramCurrent, evidence), Phase: model.Known(model.PhaseObserved, evidence), + Engagement: model.Known(model.EngagementDormant, evidence), Delivery: model.Known(model.DeliveryUninitialized, evidence), + Workspace: model.Known(model.WorkspaceAbsent, evidence), Plan: model.Known(model.PlanAbsent, evidence), + Configuration: model.Known(model.ConfigurationVerified, evidence), Runtime: model.Known(model.RuntimeVerified, evidence), + ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli", "cursor"}}, evidence), + Publication: model.Known(model.PublicationNone, evidence), Verification: model.Known(model.VerificationUnverified, evidence), + Recovery: model.Known(model.RecoveryNone, evidence), Transaction: model.Known(model.TransactionNone, evidence), + RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence), TransactionInfo: model.Absent[model.TransactionContext]("none", evidence), + Terminal: model.Known(model.TerminalNonterminal, evidence), Objective: model.Absent[model.Objective]("not configured", evidence), + ObservedAt: time.Unix(10, 0).UTC(), + }) + if err != nil { + t.Fatal(err) + } + decision := supervisor.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts()).Resolve( + snapshot, requested, catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityAutonomy: true}, "", + ) + if decision.Kind != supervisor.DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" { + t.Fatalf("accepted-work objective decision = %#v, want objective.bind", decision) + } + spoofed := requested + spoofed.TrustedClass = model.ObjectiveApprovedPlan + decision = supervisor.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts()).Resolve(snapshot, spoofed, nil, "") + if decision.Kind != supervisor.DecisionRefused || decision.Transition != nil { + t.Fatalf("spoofed accepted-work objective decision = %#v, want REFUSED", decision) + } } func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) { diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index 2e94cba..09b2cb1 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | -| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | -| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | -| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | | `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | -| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | -| `engagement.release` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | -| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | | `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | -| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | -| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | -| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | -| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | -| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | -| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | -| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | -| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | -| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | -| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | -| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | -| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | -| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | +| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | +| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | +| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | +| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | | `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | | `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | | `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | | `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | | `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | -| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | -| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | -| `installation.update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | -| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | -| `objective.bind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `objective.bind` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | | `plan.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | | `plan.amend` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | @@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | | `publication.preview` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | | `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | -| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | -| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | -| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | -| `repository.attach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | -| `repository.detach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | -| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | -| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | | `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | | `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | | `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | From 31544f1b1f618839702e57289f5b515270f7630e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 03:38:21 +0100 Subject: [PATCH 06/14] Surface program drift after delegation revocation --- .../boatstack-helper/delegation_runtime.go | 20 +++++++ .../cmd/boatstack-helper/flow_runtime.go | 7 ++- .../cmd/boatstack-helper/flow_runtime_test.go | 52 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index d8fbe0c..916fb2f 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -47,6 +47,26 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo ) } +// revokedDelegationCanReachProgramPreflight permits no authority. It only lets +// an otherwise exact revoked request reach the read-only program-change probe +// that runs before delegation preparation. If that probe does not return the +// exact typed reconciliation suspension, ordinary delegation drift checks still +// reject the unadmitted candidate. +func revokedDelegationCanReachProgramPreflight(record delegation.Record, current delegation.Request) bool { + prior := record.Request + return record.Status == "revoked" && + prior.RunID == current.RunID && prior.ProgramID == current.ProgramID && + prior.EntryID == current.EntryID && prior.TargetID == current.TargetID && + prior.ObjectiveID == current.ObjectiveID && prior.DeliveryID == current.DeliveryID && + prior.RepositoryID == current.RepositoryID && prior.GitCommonID == current.GitCommonID && + prior.InitialWorktreeID == current.InitialWorktreeID && prior.InitialRef == current.InitialRef && + prior.BindingFingerprint == current.BindingFingerprint && prior.Description == current.Description && + prior.HumanIdentityRole == current.HumanIdentityRole && prior.HumanIdentityProviderFingerprint == current.HumanIdentityProviderFingerprint && + equalEntryInputFingerprints(prior.InputFingerprints, current.InputFingerprints) && + sameStringSet(prior.EntryActivationAuthorities, current.EntryActivationAuthorities) && + sameStringSet(prior.RequestedAuthorities, current.RequestedAuthorities) +} + func admittedDelegationReprojection(prior, current delegation.Request, configurationAdmits, installationAdmits func() (bool, error)) (bool, error) { configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityRole != current.HumanIdentityRole || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint if prior.ProgramFingerprint == current.ProgramFingerprint && configurationChanged { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 9d12b02..82a22df 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -306,9 +306,12 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return commandOptions{}, reprojectErr } if !reprojected { - return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) + if !revokedDelegationCanReachProgramPreflight(record, delegationRequest) { + return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) + } + } else { + options.delegationReprojection = true } - options.delegationReprojection = true } else { delegationRequest = bound } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index c7adbfd..92098f4 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -3308,6 +3308,58 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi } } +func TestRevokedDelegationMayOnlyReachProgramPreflightWithExactStructuralContext(t *testing.T) { + // control-law: a revoked product delegation carries no authority while an + // exact request may still expose the installation reconciliation boundary. + prior := delegation.Request{ + RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), + EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []delegation.InputFingerprint{{ID: "plan", Fingerprint: strings.Repeat("1", 64)}}, + RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/feature", + BindingFingerprint: strings.Repeat("c", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("f", 64), + EntryActivationAuthorities: []string{"human"}, RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", + } + record := delegation.Record{Request: prior, Status: "revoked"} + candidate := prior + candidate.ProgramFingerprint = strings.Repeat("d", 64) + candidate.ControlBundleFingerprint = strings.Repeat("e", 64) + if !revokedDelegationCanReachProgramPreflight(record, candidate) { + t.Fatal("exact revoked program candidate could not reach program preflight") + } + active := record + active.Status = "active" + if revokedDelegationCanReachProgramPreflight(active, candidate) { + t.Fatal("active authority crossed an unadmitted program boundary") + } + tests := []struct { + name string + mutate func(*delegation.Request) + }{ + {name: "entry", mutate: func(request *delegation.Request) { request.EntryID = "other" }}, + {name: "objective", mutate: func(request *delegation.Request) { request.ObjectiveID = "other" }}, + {name: "input", mutate: func(request *delegation.Request) { request.InputFingerprints[0].Fingerprint = strings.Repeat("2", 64) }}, + {name: "repository", mutate: func(request *delegation.Request) { request.RepositoryID = "other" }}, + {name: "worktree", mutate: func(request *delegation.Request) { request.InitialWorktreeID = "other" }}, + {name: "ref", mutate: func(request *delegation.Request) { request.InitialRef = "refs/heads/other" }}, + {name: "binding", mutate: func(request *delegation.Request) { request.BindingFingerprint = strings.Repeat("9", 64) }}, + {name: "identity", mutate: func(request *delegation.Request) { request.HumanIdentityRole = "other" }}, + {name: "activation authority", mutate: func(request *delegation.Request) { request.EntryActivationAuthorities = []string{"repository-policy"} }}, + {name: "delegated authority", mutate: func(request *delegation.Request) { request.RequestedAuthorities = []string{"external-provider"} }}, + {name: "description", mutate: func(request *delegation.Request) { request.Description = "other" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + changed := candidate + changed.InputFingerprints = append([]delegation.InputFingerprint(nil), candidate.InputFingerprints...) + changed.EntryActivationAuthorities = append([]string(nil), candidate.EntryActivationAuthorities...) + changed.RequestedAuthorities = append([]string(nil), candidate.RequestedAuthorities...) + test.mutate(&changed) + if revokedDelegationCanReachProgramPreflight(record, changed) { + t.Fatal("structural delegation drift reached program preflight") + } + }) + } +} + func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { // control-law: ordinary input or objective drift cannot be relabeled as an // installation or configuration-identity reprojection. From 6d69cba742b3948fcc262d4100bc86476e89637a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 04:00:10 +0100 Subject: [PATCH 07/14] Keep engagement activation target neutral --- boatstack/core/system_test.go | 18 +++++ boatstack/core/transitions.json | 7 -- .../flow/softwaredelivery/definition_test.go | 11 ++++ .../work_package_internal_test.go | 43 ++++++++++++ .../boatstack-transition-catalog.md | 66 +++++++++---------- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index 366352d..0e07441 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -62,6 +62,24 @@ func TestObjectiveBindingIsTargetNeutral(t *testing.T) { t.Fatal("objective.bind is absent") } +func TestEngagementBeginningIsTargetNeutral(t *testing.T) { + // control-law: core-engages-only-domain-admitted-objectives-without-owning-domain-vocabulary + manifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, transition := range manifest.Transitions { + if transition.ID != "engagement.begin" { + continue + } + if len(transition.TargetIDs) != 0 { + t.Fatalf("engagement.begin owns domain targets %v", transition.TargetIDs) + } + return + } + t.Fatal("engagement.begin is absent") +} + func hasPrefix(value string, prefixes ...string) bool { for _, prefix := range prefixes { if strings.HasPrefix(value, prefix) { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 4ce7226..60d2961 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -19,13 +19,6 @@ "OBSERVED", "ACTIVE" ], - "target_ids": [ - "approved-plan", - "verified-implementation", - "open-or-updated-pr", - "merged-delivery", - "safely-abandoned" - ], "required_identity": [ "repository-id", "git-common-id", diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 3361e26..062d0ca 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -403,6 +403,17 @@ func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { if decision.Kind != supervisor.DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" { t.Fatalf("accepted-work objective decision = %#v, want objective.bind", decision) } + snapshot.Objective = model.Known(requested, evidence) + snapshot, err = model.Canonicalize(snapshot.Observation) + if err != nil { + t.Fatal(err) + } + decision = supervisor.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts()).Resolve( + snapshot, requested, catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityAutonomy: true, catalog.AuthorityRepository: true}, "", + ) + if decision.Kind != supervisor.DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "engagement.begin" { + t.Fatalf("accepted-work engagement decision = %#v, want engagement.begin", decision) + } spoofed := requested spoofed.TrustedClass = model.ObjectiveApprovedPlan decision = supervisor.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts()).Resolve(snapshot, spoofed, nil, "") diff --git a/boatstack/flow/softwaredelivery/work_package_internal_test.go b/boatstack/flow/softwaredelivery/work_package_internal_test.go index 2368033..7ea3a1e 100644 --- a/boatstack/flow/softwaredelivery/work_package_internal_test.go +++ b/boatstack/flow/softwaredelivery/work_package_internal_test.go @@ -35,3 +35,46 @@ func TestPlanningPackagePromoteSupportsApprovedPlanObjective(t *testing.T) { } t.Fatal("planning promotion transition is missing") } + +func TestWorkPackageAdmitRequiresEstablishedEngagement(t *testing.T) { + // control-law: generic CoreSystem engagement precedes package-domain work + manifest, err := standard.Definition().RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + base := make(map[string]delivery.Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + base[string(transition.ID)] = transition + } + transitions, err := acceptedWorkTransitions(base) + if err != nil { + t.Fatal(err) + } + for _, transition := range transitions { + if transition.ID != WorkPackageAdmit { + continue + } + for _, condition := range transition.SourceConditions { + if condition.Facet != model.FacetEngagement { + continue + } + if containsValue(condition.Values, string(model.EngagementDormant)) || + !containsValue(condition.Values, string(model.EngagementCommand)) || + !containsValue(condition.Values, string(model.EngagementActive)) { + t.Fatalf("work-package engagement source = %v", condition.Values) + } + return + } + t.Fatal("work-package admission has no engagement source condition") + } + t.Fatal("work-package admission transition is missing") +} + +func containsValue(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index 09b2cb1..947e151 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | -| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | -| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | -| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | | `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | -| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | -| `engagement.release` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | -| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | | `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | -| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | -| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | -| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | -| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | -| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | -| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | -| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | -| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | -| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | -| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | -| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | -| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | -| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | +| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | +| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | +| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | +| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | | `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | | `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | | `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | | `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | | `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | -| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | -| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | -| `installation.update` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | -| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | -| `objective.bind` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `objective.bind` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | | `plan.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | | `plan.amend` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | @@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | | `publication.preview` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | | `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | -| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | -| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | -| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | -| `repository.attach` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | -| `repository.detach` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | -| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | -| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`ce8fc0840a7850329caea49a361a46d71de44d7892c68eb50c27be39ffda1f19` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`e804f69a58976337ec62fd8033bc07d7d4e2f15eead1e2f13b7c6eb464b8e437` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | | `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | | `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | | `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`fae06d6a413728801533dd7870d82c324c15b93d6e5bb1dc4b97e37eb3a044f3` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | From 32dd7abe60d1ddee9f86855461d8b94efc327141 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 04:22:48 +0100 Subject: [PATCH 08/14] Declare terminal planning promotion outcome --- .../flow/softwaredelivery/work_package.go | 10 +++++ .../work_package_internal_test.go | 12 ++++++ .../effects/work_package_test.go | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go index 90af9ae..8025c04 100644 --- a/boatstack/flow/softwaredelivery/work_package.go +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -83,6 +83,7 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli model.FacetPlan, string(model.PlanAbsent), ) promote.TargetConditions = replaceFacet(promote.TargetConditions, model.FacetPlan, string(model.PlanApproved)) + promote.TargetPhases = appendUniquePhase(promote.TargetPhases, model.PhaseTerminal) promote.TargetIDs = appendUniqueTarget(promote.TargetIDs, model.ObjectiveApprovedPlan) promote.OwnedResources = []string{"plan", "planning-package-promotion"} promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote @@ -115,6 +116,15 @@ func replaceFacet(values []delivery.FacetCondition, facet model.FacetName, value return result } +func appendUniquePhase(values []model.ProtocolPhase, phase model.ProtocolPhase) []model.ProtocolPhase { + for _, value := range values { + if value == phase { + return values + } + } + return append(values, phase) +} + func appendUniqueTarget(values []model.TargetID, target model.TargetID) []model.TargetID { for _, value := range values { if value == target { diff --git a/boatstack/flow/softwaredelivery/work_package_internal_test.go b/boatstack/flow/softwaredelivery/work_package_internal_test.go index 7ea3a1e..20bd010 100644 --- a/boatstack/flow/softwaredelivery/work_package_internal_test.go +++ b/boatstack/flow/softwaredelivery/work_package_internal_test.go @@ -26,6 +26,9 @@ func TestPlanningPackagePromoteSupportsApprovedPlanObjective(t *testing.T) { if transition.ID != PlanningPackagePromote { continue } + if !containsPhase(transition.TargetPhases, model.PhaseActive) || !containsPhase(transition.TargetPhases, model.PhaseTerminal) { + t.Fatalf("planning promotion target phases = %#v", transition.TargetPhases) + } for _, target := range transition.TargetIDs { if target == model.ObjectiveApprovedPlan { return @@ -36,6 +39,15 @@ func TestPlanningPackagePromoteSupportsApprovedPlanObjective(t *testing.T) { t.Fatal("planning promotion transition is missing") } +func containsPhase(values []model.ProtocolPhase, want model.ProtocolPhase) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestWorkPackageAdmitRequiresEstablishedEngagement(t *testing.T) { // control-law: generic CoreSystem engagement precedes package-domain work manifest, err := standard.Definition().RuntimeManifest(context.Background()) diff --git a/boatstack/internal/softwaredelivery/effects/work_package_test.go b/boatstack/internal/softwaredelivery/effects/work_package_test.go index 43dd8ad..b1ba674 100644 --- a/boatstack/internal/softwaredelivery/effects/work_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/work_package_test.go @@ -229,6 +229,46 @@ func TestWorkPackageAdmitApprovePromoteUsesExactGenericSnapshot(t *testing.T) { } } +func TestPlanningPackagePromotePhaseFollowsObjective(t *testing.T) { + transition := catalog.Transition{ + ID: "planning.package.promote", + TargetPhases: []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, + StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-promote"}, + } + base := durable.State{ + WorkPackage: model.WorkPackageApproved, + Plan: model.PlanAbsent, + Delivery: model.DeliveryApproved, + Phase: model.PhaseActive, + Terminal: model.TerminalNonterminal, + } + + approvedPlan := base + admission := protocol.Admission{Objective: model.Objective{ID: "plan", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery"}} + if err := applyStateTransition(&approvedPlan, admission, transition); err != nil { + t.Fatal(err) + } + if approvedPlan.Plan != model.PlanApproved || approvedPlan.Phase != model.PhaseTerminal { + t.Fatalf("approved-plan promotion = %#v", approvedPlan) + } + + longerDelivery := base + admission.Objective = model.Objective{ID: "pull-request", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} + if err := applyStateTransition(&longerDelivery, admission, transition); err != nil { + t.Fatal(err) + } + if longerDelivery.Plan != model.PlanApproved || longerDelivery.Phase != model.PhaseActive || longerDelivery.Terminal != model.TerminalNonterminal { + t.Fatalf("longer-delivery promotion = %#v", longerDelivery) + } + + undeclared := base + admission.Objective = model.Objective{ID: "plan", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery"} + transition.TargetPhases = []model.ProtocolPhase{model.PhaseActive} + if err := applyStateTransition(&undeclared, admission, transition); err == nil || !strings.Contains(err.Error(), "undeclared target phase TERMINAL") { + t.Fatalf("undeclared terminal phase error = %v", err) + } +} + func approvalRawAt(t *testing.T, packageRoot string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join(packageRoot, "approval.json")) From 5dc00ae222c5ff3b16f6ccc47aeb8bd292b689d0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 04:58:26 +0100 Subject: [PATCH 09/14] Verify packages against their bound Flow --- .../product_delivery_flow_e2e_test.go | 13 ++++++- .../boatstack-helper/work_package_command.go | 20 ++++++----- .../work_package_command_test.go | 17 ++++++++- .../softwaredelivery/workpackage/package.go | 36 ++++++++++--------- 4 files changed, 58 insertions(+), 28 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index 8377715..8d4e7dd 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -357,7 +357,8 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if err != nil || len(packages) != 1 || !packages[0].IsDir() { t.Fatalf("planning package inventory = %#v, err=%v", packages, err) } - currentProgram, err := loadCurrentWorkProgram(context.Background(), repository) + writeFixture(t, repository, ".boatstack/flows/unrelated.flow.ir.json", []byte("must not be selected\n")) + currentProgram, err := loadCurrentWorkProgram(context.Background(), repository, "product-delivery") if err != nil { t.Fatalf("load current planning program: %v", err) } @@ -365,6 +366,16 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if packageVerification.Integrity != workpackage.Valid || packageVerification.Contract != workpackage.Valid || packageVerification.Approval != workpackage.Valid || packageVerification.CurrentProgram != workpackage.Match { t.Fatalf("portable planning package verification = %#v", packageVerification) } + verificationRaw, err := captureStdout(t, func() error { + return runFlowWorkPackage([]string{"verify", "--repo", repository, "--delivery", "todo", "--package", packages[0].Name(), "--require-approval", "--require-current-program", "--format", "json"}) + }) + if err != nil { + t.Fatalf("multi-Flow package-bound verification: %v", err) + } + var commandVerification workpackage.Result + if err := json.Unmarshal(verificationRaw, &commandVerification); err != nil || commandVerification.ProgramID != "product-delivery" || commandVerification.CurrentProgram != workpackage.Match { + t.Fatalf("multi-Flow package-bound result = %#v, decode=%v", commandVerification, err) + } last := receipts[len(receipts)-1] if last.TransitionID != "publication.observe" || final.Snapshot.Publication.Value != model.PublicationOpen { t.Fatalf("final publication observation = receipt=%s state=%s", last.TransitionID, final.Snapshot.Publication.Value) diff --git a/boatstack/cmd/boatstack-helper/work_package_command.go b/boatstack/cmd/boatstack-helper/work_package_command.go index cf7eeee..1b80e4e 100644 --- a/boatstack/cmd/boatstack-helper/work_package_command.go +++ b/boatstack/cmd/boatstack-helper/work_package_command.go @@ -54,12 +54,6 @@ func runFlowWorkPackage(arguments []string) error { if !all && (deliveryID == "" || packageFingerprint == "") { return fmt.Errorf("--delivery and --package are required together") } - var current *workpackage.CurrentProgram - if value, currentErr := loadCurrentWorkProgram(context.Background(), repository); currentErr == nil { - current = &value - } else if requireCurrent { - return currentErr - } var identities [][2]string if all { identities, err = workpackage.Enumerate(repository) @@ -72,7 +66,15 @@ func runFlowWorkPackage(arguments []string) error { results := make([]workpackage.Result, 0, len(identities)) failed := false for _, identity := range identities { - result := workpackage.Verify(repository, identity[0], identity[1], current) + result := workpackage.Verify(repository, identity[0], identity[1], nil) + if result.Integrity == workpackage.Valid && result.ProgramID != "" { + current, currentErr := loadCurrentWorkProgram(context.Background(), repository, result.ProgramID) + if currentErr == nil { + result = workpackage.Verify(repository, identity[0], identity[1], ¤t) + } else if requireCurrent { + return currentErr + } + } results = append(results, result) if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || requireApproval && result.Approval != workpackage.Valid || requireCurrent && result.CurrentProgram != workpackage.Match { failed = true @@ -99,8 +101,8 @@ func runFlowWorkPackage(arguments []string) error { return nil } -func loadCurrentWorkProgram(ctx context.Context, repository string) (workpackage.CurrentProgram, error) { - path, err := resolveCheckArtifact(repository, "") +func loadCurrentWorkProgram(ctx context.Context, repository, programID string) (workpackage.CurrentProgram, error) { + path, err := resolveCheckArtifact(repository, filepath.Join(".boatstack", "flows", programID+".flow.ir.json")) if err != nil { return workpackage.CurrentProgram{}, err } diff --git a/boatstack/cmd/boatstack-helper/work_package_command_test.go b/boatstack/cmd/boatstack-helper/work_package_command_test.go index 3129bff..78613b0 100644 --- a/boatstack/cmd/boatstack-helper/work_package_command_test.go +++ b/boatstack/cmd/boatstack-helper/work_package_command_test.go @@ -22,7 +22,7 @@ func TestWorkPackageVerifyCommandReportsSeparateStatuses(t *testing.T) { if err := json.Unmarshal(output, &result); err != nil { t.Fatalf("single-package JSON is not an object: %v\n%s", err, output) } - if result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || result.Approval != workpackage.Missing || result.CurrentProgram != workpackage.Unavailable || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" { + if result.ProgramID != "program" || result.Integrity != workpackage.Valid || result.Contract != workpackage.Valid || result.Approval != workpackage.Missing || result.CurrentProgram != workpackage.Unavailable || result.SemanticCorrectness != "not-evaluated" || result.OriginAuthenticity != "not-proven" { t.Fatalf("verification result = %#v", result) } if _, err := captureStdout(t, func() error { @@ -30,6 +30,21 @@ func TestWorkPackageVerifyCommandReportsSeparateStatuses(t *testing.T) { }); err == nil { t.Fatal("missing approval satisfied --require-approval") } + manifestPath := filepath.Join(repository, ".boatstack", "work-packages", deliveryID, packageFingerprint, "manifest.json") + manifestRaw, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + manifestRaw = []byte(strings.Replace(string(manifestRaw), `"program_id": "program"`, `"program_id": "missing"`, 1)) + if err := os.WriteFile(manifestPath, manifestRaw, 0o644); err != nil { + t.Fatal(err) + } + _, err = captureStdout(t, func() error { + return runFlowWorkPackage([]string{"verify", "--repo", repository, "--delivery", deliveryID, "--package", packageFingerprint, "--require-current-program", "--format", "json"}) + }) + if err == nil || !strings.Contains(err.Error(), "failed required verification") || strings.Contains(err.Error(), "missing.flow.ir.json") { + t.Fatalf("invalid manifest selected a Flow artifact: %v", err) + } } func installWorkPackageCommandFixture(t *testing.T) (string, string, string) { diff --git a/boatstack/flow/softwaredelivery/workpackage/package.go b/boatstack/flow/softwaredelivery/workpackage/package.go index e9b83a5..bb6fb89 100644 --- a/boatstack/flow/softwaredelivery/workpackage/package.go +++ b/boatstack/flow/softwaredelivery/workpackage/package.go @@ -88,23 +88,23 @@ type Reference struct { SHA256 string `json:"sha256"` } type Manifest struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - ProgramID string `json:"program_id"` - ProgramFingerprint string `json:"program_fingerprint"` - EntryID string `json:"entry_id"` - RunID string `json:"run_id"` - TransitionID string `json:"transition_id"` - WorkContractID string `json:"work_contract_id"` - WorkContractFingerprint string `json:"work_contract_fingerprint"` - WorkRequestFingerprint string `json:"work_request_fingerprint"` - WorkResultFingerprint string `json:"work_result_fingerprint"` - ContextFingerprint string `json:"context_fingerprint"` - StateRevision uint64 `json:"state_revision"` - Contract Reference `json:"contract"` - WorkReceipt Reference `json:"work_receipt"` - Outputs []Output `json:"outputs"` - Fingerprint string `json:"fingerprint"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + ProgramID string `json:"program_id"` + ProgramFingerprint string `json:"program_fingerprint"` + EntryID string `json:"entry_id"` + RunID string `json:"run_id"` + TransitionID string `json:"transition_id"` + WorkContractID string `json:"work_contract_id"` + WorkContractFingerprint string `json:"work_contract_fingerprint"` + WorkRequestFingerprint string `json:"work_request_fingerprint"` + WorkResultFingerprint string `json:"work_result_fingerprint"` + ContextFingerprint string `json:"context_fingerprint"` + StateRevision uint64 `json:"state_revision"` + Contract Reference `json:"contract"` + WorkReceipt Reference `json:"work_receipt"` + Outputs []Output `json:"outputs"` + Fingerprint string `json:"fingerprint"` } type WorkReceipt struct { @@ -159,6 +159,7 @@ const ( type Result struct { DeliveryID string `json:"delivery_id"` PackageFingerprint string `json:"package_fingerprint"` + ProgramID string `json:"program_id,omitempty"` Integrity Status `json:"integrity"` Contract Status `json:"contract"` Approval Status `json:"approval"` @@ -364,6 +365,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP if identityErr := validateManifestIdentity(manifest, deliveryID, packageFingerprint, Digest(identityRaw)); identityErr != nil { return fail("manifest identity is invalid: " + identityErr.Error()) } + result.ProgramID = manifest.ProgramID contractRaw, err := readRegular(filepath.Join(packageRoot, "contract.json"), maxPackageMetadataBytes) if err != nil || Digest(contractRaw) != manifest.Contract.SHA256 || manifest.Contract.Path != "contract.json" { return fail("contract reference is invalid") From 618f0748e8435ec59cd47695cc14fa12eda04435 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 05:49:42 +0100 Subject: [PATCH 10/14] Bind approval evidence after package verification --- .../softwaredelivery/workpackage/package.go | 62 +++++++++++++++++++ .../softwaredelivery/effects/artifacts.go | 28 +++++---- .../effects/work_package_test.go | 16 +++++ .../softwaredelivery/plant/observer.go | 18 ++---- .../softwaredelivery/plant/observer_test.go | 29 +++++++++ 5 files changed, 128 insertions(+), 25 deletions(-) diff --git a/boatstack/flow/softwaredelivery/workpackage/package.go b/boatstack/flow/softwaredelivery/workpackage/package.go index bb6fb89..8a7872c 100644 --- a/boatstack/flow/softwaredelivery/workpackage/package.go +++ b/boatstack/flow/softwaredelivery/workpackage/package.go @@ -171,6 +171,15 @@ type Result struct { type CurrentProgram struct{ ProgramFingerprint, WorkContractFingerprint string } +// VerifiedApproval is the exact portable approval evidence read after full +// package verification. Raw contains the canonical approval bytes validated +// against Manifest. +type VerifiedApproval struct { + Manifest Manifest + Approval Approval + Raw []byte +} + func Encode(value any) ([]byte, error) { raw, err := json.MarshalIndent(value, "", " ") if err != nil { @@ -443,6 +452,59 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP return result } +// ReadVerifiedApproval rereads and validates the current manifest and +// approval together. Callers use it after Verify when durable or transferred +// state must bind the exact approval bytes that remain present. +func ReadVerifiedApproval(repository, deliveryID, packageFingerprint string) (VerifiedApproval, error) { + if !ValidSegment(deliveryID) || !ValidFingerprint(packageFingerprint) { + return VerifiedApproval{}, fmt.Errorf("package path identity is invalid") + } + root, err := filepath.Abs(repository) + if err != nil { + return VerifiedApproval{}, err + } + info, err := os.Lstat(root) + if err != nil || !info.IsDir() { + return VerifiedApproval{}, fmt.Errorf("repository root is unavailable") + } + packageRoot := filepath.Join(root, ".boatstack", "work-packages", deliveryID, packageFingerprint) + if info, err = os.Lstat(packageRoot); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return VerifiedApproval{}, fmt.Errorf("package directory is unavailable or unsafe") + } + manifestRaw, err := readRegular(filepath.Join(packageRoot, "manifest.json"), maxPackageMetadataBytes) + if err != nil { + return VerifiedApproval{}, err + } + var manifest Manifest + if err := StrictDecode(manifestRaw, &manifest); err != nil { + return VerifiedApproval{}, fmt.Errorf("manifest: %w", err) + } + if !canonicalEncoding(manifestRaw, manifest) || !outputsSorted(manifest.Outputs) { + return VerifiedApproval{}, fmt.Errorf("manifest encoding or output order is non-canonical") + } + manifestIdentity := manifest + manifestIdentity.Fingerprint = "" + identityRaw, err := Encode(manifestIdentity) + if err != nil { + return VerifiedApproval{}, err + } + if err := validateManifestIdentity(manifest, deliveryID, packageFingerprint, Digest(identityRaw)); err != nil { + return VerifiedApproval{}, fmt.Errorf("manifest identity is invalid: %w", err) + } + approvalRaw, err := readRegular(filepath.Join(packageRoot, "approval.json"), maxPackageMetadataBytes) + if err != nil { + return VerifiedApproval{}, err + } + var approval Approval + if err := StrictDecode(approvalRaw, &approval); err != nil { + return VerifiedApproval{}, fmt.Errorf("approval: %w", err) + } + if err := ValidateApproval(approvalRaw, approval, manifest, deliveryID, packageFingerprint); err != nil { + return VerifiedApproval{}, fmt.Errorf("approval identity is invalid: %w", err) + } + return VerifiedApproval{Manifest: manifest, Approval: approval, Raw: approvalRaw}, nil +} + func validateManifestIdentity(manifest Manifest, deliveryID, packageFingerprint, identityFingerprint string) error { switch { case manifest.SchemaVersion != ManifestSchemaVersion: diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 4bec28d..ad84dad 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -553,6 +553,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } var verifyWorkPackage = workpackage.Verify +var prepareWorkspaceWorkPackageTransferFn = prepareWorkspaceWorkPackageTransfer func capturePinnedWorkPackage(root *os.Root, deliveryID, fingerprint string) (string, func(), error) { repository, err := os.MkdirTemp("", "boatstack-work-package-snapshot-") @@ -647,7 +648,7 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp mutations := []ports.ResourceMutation{planMutation, approvalMutation} var promotion planPromotionReceipt if decodeStrictArtifact(approvalRaw, &promotion) == nil && promotion.SchemaVersion == 2 { - packageMutations, packageErr := prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, promotion.WorkPackageFingerprint) + packageMutations, packageErr := prepareWorkspaceWorkPackageTransferFn(repositoryRoot, workspacePath, deliveryID, promotion.WorkPackageFingerprint, promotion.WorkPackageApprovalFingerprint) if packageErr != nil { return nil, packageErr } @@ -656,7 +657,7 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp return mutations, nil } -func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint string) ([]ports.ResourceMutation, error) { +func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) { sourcePath := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, packageFingerprint) root, err := os.OpenRoot(sourcePath) if err != nil { @@ -680,6 +681,10 @@ func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, delivery if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { return nil, fmt.Errorf("workspace work package verification failed: %s", strings.Join(verified.Diagnostics, "; ")) } + verifiedApproval, err := workpackage.ReadVerifiedApproval(snapshotRepository, deliveryID, packageFingerprint) + if err != nil || verifiedApproval.Approval.Fingerprint != expectedApprovalFingerprint { + return nil, fmt.Errorf("workspace work package approval does not bind promotion lineage") + } snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "work-packages", deliveryID, packageFingerprint) destinationRoot := filepath.Join(workspacePath, ".boatstack", "work-packages", deliveryID, packageFingerprint) destinationExists := false @@ -707,6 +712,12 @@ func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, delivery if err != nil { return err } + if filepath.ToSlash(relative) == "approval.json" { + if !bytes.Equal(raw, verifiedApproval.Raw) { + return fmt.Errorf("workspace work package approval changed after verification") + } + raw = verifiedApproval.Raw + } mutation, err := immutableWorkPackageMutation(filepath.Join(destinationRoot, relative), raw) if err != nil { return err @@ -754,18 +765,11 @@ func validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerpri if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid { return fmt.Errorf("schema-2 promotion package is invalid") } - packageRoot := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, promotion.WorkPackageFingerprint) - approvalRaw, err := os.ReadFile(filepath.Join(packageRoot, "approval.json")) - var approval workpackage.Approval - if err != nil || workpackage.StrictDecode(approvalRaw, &approval) != nil || approval.Fingerprint != promotion.WorkPackageApprovalFingerprint { + verifiedApproval, err := workpackage.ReadVerifiedApproval(repositoryRoot, deliveryID, promotion.WorkPackageFingerprint) + if err != nil || verifiedApproval.Approval.Fingerprint != promotion.WorkPackageApprovalFingerprint { return fmt.Errorf("schema-2 promotion approval lineage is invalid") } - manifestRaw, err := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) - var manifest workpackage.Manifest - if err != nil || workpackage.StrictDecode(manifestRaw, &manifest) != nil { - return fmt.Errorf("schema-2 promotion manifest is invalid") - } - for _, output := range manifest.Outputs { + for _, output := range verifiedApproval.Manifest.Outputs { if output.ID == promotion.PlanOutputID && output.Required && output.SHA256 == expectedPlanFingerprint { return nil } diff --git a/boatstack/internal/softwaredelivery/effects/work_package_test.go b/boatstack/internal/softwaredelivery/effects/work_package_test.go index b1ba674..be7c243 100644 --- a/boatstack/internal/softwaredelivery/effects/work_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/work_package_test.go @@ -214,6 +214,22 @@ func TestWorkPackageAdmitApprovePromoteUsesExactGenericSnapshot(t *testing.T) { t.Fatalf("promoted plan=%q", current) } workspace := t.TempDir() + originalTransfer := prepareWorkspaceWorkPackageTransferFn + t.Cleanup(func() { prepareWorkspaceWorkPackageTransferFn = originalTransfer }) + prepareWorkspaceWorkPackageTransferFn = func(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) { + if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), substitutedApprovalRaw, 0o644); err != nil { + t.Fatal(err) + } + return originalTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint) + } + _, transferSubstitutionErr := prepareWorkspacePlanTransfer(repository, workspace, "delivery", state.PlanFingerprint, state.ApprovalFingerprint) + prepareWorkspaceWorkPackageTransferFn = originalTransfer + if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), originalApproval, 0o644); err != nil { + t.Fatal(err) + } + if transferSubstitutionErr == nil || !strings.Contains(transferSubstitutionErr.Error(), "promotion lineage") { + t.Fatalf("workspace transfer accepted substituted package approval: %v", transferSubstitutionErr) + } transfers, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery", state.PlanFingerprint, state.ApprovalFingerprint) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 10aaec9..0d2694c 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -702,28 +702,20 @@ func observeWorkPackage(layout ports.ControllerLayout, state durable.State, now if err != nil || !exists { return evidence, false, err } - manifestRaw, err := os.ReadFile(manifestPath) - if err != nil { - return evidence, false, err - } - var manifest workpackage.Manifest - if workpackage.StrictDecode(manifestRaw, &manifest) != nil { - return evidence, false, nil - } - result := workpackage.Verify(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint, nil) + result := verifyObservedWorkPackage(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint, nil) valid := result.Integrity == workpackage.Valid && result.Contract == workpackage.Valid if state.WorkPackage == model.WorkPackageApproved { valid = valid && result.Approval == workpackage.Valid if valid { - raw, _ := os.ReadFile(filepath.Join(root, "approval.json")) - var approval workpackage.Approval - _ = workpackage.StrictDecode(raw, &approval) - valid = approval.Fingerprint == state.WorkPackageApprovalFingerprint + verified, approvalErr := workpackage.ReadVerifiedApproval(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint) + valid = approvalErr == nil && verified.Approval.Fingerprint == state.WorkPackageApprovalFingerprint } } return evidence, valid, nil } +var verifyObservedWorkPackage = workpackage.Verify + type pendingJournalHeader struct { SchemaVersion int `json:"schema_version"` TransitionID string `json:"transition_id"` diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index dd932b3..6537314 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -66,6 +66,35 @@ func TestObserverValidatesAdmittedWorkPackageWithoutPrematurePlanPromotion(t *te if _, err := os.Stat(filepath.Join(repository, ".boatstack", "plans", deliveryID+".source")); !os.IsNotExist(err) { t.Fatalf("admission prematurely created a canonical plan: %v", err) } + approval, approvalRaw, err := workpackage.SealApproval(workpackage.Approval{ + DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint, + AdmissionID: "adm-approve", Actor: "reviewer", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), ApprovedAt: time.Unix(99, 0).UTC(), + AuthoritySources: []workpackage.AuthoritySource{{ID: "human", Class: "human", Subject: "reviewer", Fingerprint: "authority-proof"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "approval.json"), approvalRaw, 0o644); err != nil { + t.Fatal(err) + } + state.WorkPackage = model.WorkPackageApproved + state.WorkPackageApprovalFingerprint = approval.Fingerprint + originalVerify := verifyObservedWorkPackage + t.Cleanup(func() { verifyObservedWorkPackage = originalVerify }) + verifyObservedWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result { + result := originalVerify(repository, deliveryID, packageFingerprint, current) + if err := os.WriteFile(filepath.Join(root, "approval.json"), append(append([]byte(nil), approvalRaw...), '\n'), 0o644); err != nil { + t.Fatal(err) + } + return result + } + if _, valid, err := observeWorkPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()); err != nil || valid { + t.Fatalf("post-verification approval substitution valid=%t err=%v", valid, err) + } + verifyObservedWorkPackage = originalVerify + if err := os.WriteFile(filepath.Join(root, "approval.json"), approvalRaw, 0o644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("tampered"), 0o644); err != nil { t.Fatal(err) } From 9703f7a3010771c6fbe106e74f86c7ca9cf59947 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 06:40:07 +0100 Subject: [PATCH 11/14] Restore stale work package reachability --- .../flow/softwaredelivery/work_package.go | 19 ++- .../softwaredelivery/work_package_test.go | 27 +++-- .../effects/work_package_test.go | 24 ++++ .../softwaredelivery/plant/observer.go | 8 +- .../softwaredelivery/plant/observer_test.go | 114 ++++++++++++++++++ .../2026-08-18-generic-work-packages.md | 2 +- 6 files changed, 182 insertions(+), 12 deletions(-) diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go index 8025c04..3a07bbd 100644 --- a/boatstack/flow/softwaredelivery/work_package.go +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -42,7 +42,12 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "work-package-admit-source", "exact-work-and-transition-admission", "work-package-valid" admit.Verifier = "verifier:fresh-observation:" + WorkPackageAdmit admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "work-package-admit"} - admit.SourceConditions = withFacet(withoutFacet(admit.SourceConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageAbsent)) + admit.SourceConditions = withFacetStatuses( + withoutFacet(admit.SourceConditions, model.FacetPlan), + model.FacetWorkPackage, + []model.FactStatus{model.FactKnown, model.FactStale}, + string(model.WorkPackageAbsent), + ) admit.TargetConditions = withFacet(withoutFacet(admit.TargetConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageValid)) admit.TargetIDs = appendUniqueTarget(admit.TargetIDs, model.ObjectiveApprovedWorkPackage) admit.OwnedResources = []string{"work-package"} @@ -78,9 +83,11 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli promote.SourcePredicate, promote.AdmissionPredicate, promote.TargetPredicate = "work-package-approved", "exact-package-output-promotion", "plan-approved" promote.Verifier = "verifier:fresh-observation:" + PlanningPackagePromote promote.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-promote"} - promote.SourceConditions = withFacet( + promote.SourceConditions = withFacetStatuses( withFacet(withoutFacet(promote.SourceConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageApproved)), - model.FacetPlan, string(model.PlanAbsent), + model.FacetPlan, + []model.FactStatus{model.FactKnown}, + string(model.PlanAbsent), string(model.PlanStale), ) promote.TargetConditions = replaceFacet(promote.TargetConditions, model.FacetPlan, string(model.PlanApproved)) promote.TargetPhases = appendUniquePhase(promote.TargetPhases, model.PhaseTerminal) @@ -105,6 +112,12 @@ func withFacet(values []delivery.FacetCondition, facet model.FacetName, value st return append(values, delivery.FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{value}}) } +func withFacetStatuses(values []delivery.FacetCondition, facet model.FacetName, statuses []model.FactStatus, valuesAllowed ...string) []delivery.FacetCondition { + return append(values, delivery.FacetCondition{ + Facet: facet, Statuses: append([]model.FactStatus(nil), statuses...), Values: append([]string(nil), valuesAllowed...), + }) +} + func replaceFacet(values []delivery.FacetCondition, facet model.FacetName, value string) []delivery.FacetCondition { result := append([]delivery.FacetCondition(nil), values...) for index := range result { diff --git a/boatstack/flow/softwaredelivery/work_package_test.go b/boatstack/flow/softwaredelivery/work_package_test.go index 1724a0a..febf736 100644 --- a/boatstack/flow/softwaredelivery/work_package_test.go +++ b/boatstack/flow/softwaredelivery/work_package_test.go @@ -68,18 +68,22 @@ func TestAcceptedWorkUsesDistinctFacetAndOnlyPromotionOwnsPlan(t *testing.T) { t.Fatalf("work-package admission target = %v", got) } for _, condition := range transitions[0].SourceConditions { - if condition.Facet == model.FacetWorkPackage && !containsString(condition.Values, string(model.WorkPackageAbsent)) { - t.Fatalf("work-package admission source = %v", condition.Values) + if condition.Facet != model.FacetWorkPackage { + continue + } + if len(condition.Values) != 1 || condition.Values[0] != string(model.WorkPackageAbsent) || + !containsStatus(condition.Statuses, model.FactKnown) || !containsStatus(condition.Statuses, model.FactStale) { + t.Fatalf("work-package admission source = statuses %v values %v", condition.Statuses, condition.Values) } } - foundPlanAbsent := false + foundRecoverablePlan := false for _, condition := range transitions[2].SourceConditions { - if condition.Facet == model.FacetPlan && containsString(condition.Values, string(model.PlanAbsent)) { - foundPlanAbsent = true + if condition.Facet == model.FacetPlan && containsString(condition.Values, string(model.PlanAbsent)) && containsString(condition.Values, string(model.PlanStale)) { + foundRecoverablePlan = true } } - if !foundPlanAbsent { - t.Fatal("planning promotion can replay after canonical plan creation") + if !foundRecoverablePlan { + t.Fatal("planning promotion cannot recover a stale canonical plan") } } @@ -92,6 +96,15 @@ func containsString(values []string, want string) bool { return false } +func containsStatus(values []model.FactStatus, want model.FactStatus) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { work := delivery.WorkContract{ID: "planning", Outputs: []delivery.WorkOutput{ {ID: "plan", Path: "plan.md", Required: true, MaxBytes: 1}, diff --git a/boatstack/internal/softwaredelivery/effects/work_package_test.go b/boatstack/internal/softwaredelivery/effects/work_package_test.go index be7c243..97562e4 100644 --- a/boatstack/internal/softwaredelivery/effects/work_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/work_package_test.go @@ -245,6 +245,30 @@ func TestWorkPackageAdmitApprovePromoteUsesExactGenericSnapshot(t *testing.T) { } } +func TestWorkPackageReadmissionPreservesStalePlanForExplicitPromotion(t *testing.T) { + // control-law: generic package recovery cannot acquire plan authority + state := durable.State{ + WorkPackage: model.WorkPackageApproved, + Plan: model.PlanStale, + Delivery: model.DeliveryPlanning, + Phase: model.PhaseActive, + Terminal: model.TerminalStale, + } + transition := catalog.Transition{ + ID: "work.package.admit", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, + StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "work-package-admit"}, + } + if err := applyStateTransition(&state, protocol.Admission{Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery"}}, transition); err != nil { + t.Fatal(err) + } + if state.WorkPackage != model.WorkPackageValid || state.Terminal != model.TerminalNonterminal || state.Phase != model.PhaseActive { + t.Fatalf("re-admission state = package=%s terminal=%s phase=%s", state.WorkPackage, state.Terminal, state.Phase) + } + if state.Plan != model.PlanStale { + t.Fatalf("generic re-admission changed plan state to %s", state.Plan) + } +} + func TestPlanningPackagePromotePhaseFollowsObjective(t *testing.T) { transition := catalog.Transition{ ID: "planning.package.promote", diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 0d2694c..6162430 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -196,6 +196,12 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) workPackageFact.Evidence = workPackageEvidence if !valid { workPackageFact.Status = model.FactStale + // The durable value records what was last admitted. Once its + // immutable evidence is invalid, the resolver must not treat that + // value as a current valid or approved package. Project absence with + // explicit staleness so the already-selected admission transition is + // the only package-domain recovery path. + workPackageFact.Value = model.WorkPackageAbsent artifactTerminal = model.TerminalStale } } @@ -214,7 +220,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) if runtimeState == model.RuntimeAbsent { phase = model.PhaseObserved } - } else if class == model.ObjectiveApprovedPlan && terminal == model.TerminalStale { + } else if (class == model.ObjectiveApprovedPlan || class == model.ObjectiveApprovedWorkPackage) && terminal == model.TerminalStale { phase, delivery = model.PhaseActive, model.DeliveryPlanning } if class == model.ObjectiveMerged && state.Publication == model.PublicationMerged && state.Delivery == model.DeliveryTerminal && diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 6537314..ea766cc 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -103,6 +103,120 @@ func TestObserverValidatesAdmittedWorkPackageWithoutPrematurePlanPromotion(t *te } } +func TestObserverProjectsTamperedApprovedPackageToRecoverableAdmission(t *testing.T) { + // control-law: stale accepted work reopens the existing explicit admission path + repository := t.TempDir() + runGit(t, repository, "init", "-q") + runGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runGit(t, repository, "config", "user.name", "Boatstack Test") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repository, "add", "README.md") + runGit(t, repository, "commit", "-q", "-m", "fixture") + + deliveryID := "accepted-package" + outputRaw := []byte("accepted evidence\n") + output := workpackage.Output{ID: "evidence", Path: "evidence.md", MediaType: "text/markdown", Required: true, Size: int64(len(outputRaw)), SHA256: hashBytes(outputRaw)} + portableWork := workpackage.WorkContract{ + ID: "accepted-work", Instructions: workpackage.Asset{Path: "accepted.md", SHA256: hashBytes([]byte("accept")), Content: "accept"}, + Outputs: []workpackage.WorkOutput{{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: true, MaxBytes: 1024}}, + } + workFingerprint, err := workpackage.RuntimeWorkFingerprint(portableWork) + if err != nil { + t.Fatal(err) + } + portableWork.Fingerprint = workFingerprint + _, contractRaw, err := workpackage.SealContract(workpackage.Contract{Work: portableWork}) + if err != nil { + t.Fatal(err) + } + _, receiptRaw, err := workpackage.SealWorkReceipt(workpackage.WorkReceipt{ + RequestID: "request", RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), + ContractID: portableWork.ID, ContractFingerprint: workFingerprint, TransitionID: "work.package.admit", + ProgramFingerprint: strings.Repeat("d", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, + RepositoryID: "repo", WorktreeID: "tree", Outputs: []workpackage.Output{output}, + }) + if err != nil { + t.Fatal(err) + } + manifest, manifestRaw, err := workpackage.SealManifest(workpackage.Manifest{ + DeliveryID: deliveryID, ProgramID: "program", ProgramFingerprint: strings.Repeat("d", 64), EntryID: "accept", RunID: "run-proof", + TransitionID: "work.package.admit", WorkContractID: portableWork.ID, WorkContractFingerprint: workFingerprint, + WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), ContextFingerprint: strings.Repeat("e", 64), StateRevision: 2, + Contract: workpackage.Reference{Path: "contract.json", SHA256: workpackage.Digest(contractRaw)}, + WorkReceipt: workpackage.Reference{Path: "work-receipt.json", SHA256: workpackage.Digest(receiptRaw)}, Outputs: []workpackage.Output{output}, + }) + if err != nil { + t.Fatal(err) + } + approval, approvalRaw, err := workpackage.SealApproval(workpackage.Approval{ + DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint, + AdmissionID: "adm-approve", Actor: "reviewer", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), + ApprovedAt: time.Unix(99, 0).UTC(), AuthoritySources: []workpackage.AuthoritySource{{ID: "human", Class: "human", Subject: "reviewer", Fingerprint: "authority-proof"}}, + }) + if err != nil { + t.Fatal(err) + } + packageRoot := filepath.Join(repository, ".boatstack", "work-packages", deliveryID, manifest.Fingerprint) + if err := os.MkdirAll(packageRoot, 0o700); err != nil { + t.Fatal(err) + } + for name, raw := range map[string][]byte{ + "manifest.json": manifestRaw, "contract.json": contractRaw, "work-receipt.json": receiptRaw, + "approval.json": approvalRaw, output.Path: outputRaw, + } { + if err := os.WriteFile(filepath.Join(packageRoot, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(packageRoot, output.Path), []byte("tampered\n"), 0o644); err != nil { + t.Fatal(err) + } + + resolver, err := NewResolver(t.TempDir()) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "stale-package-recovery") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + state := durable.Default(invocation, time.Unix(100, 0).UTC()) + state.Objective = model.Objective{ID: "objective", TargetID: model.ObjectiveApprovedWorkPackage, TrustedClass: model.ObjectiveApprovedWorkPackage, DeliveryID: deliveryID} + state.Engagement, state.Delivery, state.Phase, state.Terminal = model.EngagementActive, model.DeliveryApproved, model.PhaseTerminal, model.TerminalEstablished + state.WorkPackage, state.WorkPackageFingerprint, state.WorkPackageApprovalFingerprint = model.WorkPackageApproved, manifest.Fingerprint, approval.Fingerprint + state.Plan = model.PlanAbsent + stateRaw, err := durable.EncodeState(state) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.StatePath, stateRaw, 0o600); err != nil { + t.Fatal(err) + } + observer, err := NewObserver(resolver, observerClock{now: time.Unix(101, 0).UTC()}) + if err != nil { + t.Fatal(err) + } + observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if observed.WorkPackage.Status != model.FactStale || observed.WorkPackage.Value != model.WorkPackageAbsent { + t.Fatalf("tampered package projection = status %s value %s", observed.WorkPackage.Status, observed.WorkPackage.Value) + } + if observed.Phase.Value != model.PhaseActive || observed.Delivery.Value != model.DeliveryPlanning || observed.Terminal.Value != model.TerminalStale { + t.Fatalf("tampered package recovery frontier = phase %s delivery %s terminal %s", observed.Phase.Value, observed.Delivery.Value, observed.Terminal.Value) + } +} + func runGit(t *testing.T, directory string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", directory}, arguments...)...) diff --git a/release-notes/2026-08-18-generic-work-packages.md b/release-notes/2026-08-18-generic-work-packages.md index fc01e6a..9879233 100644 --- a/release-notes/2026-08-18-generic-work-packages.md +++ b/release-notes/2026-08-18-generic-work-packages.md @@ -1,3 +1,3 @@ ### Generic accepted work packages -Software-delivery Flows can admit and approve immutable foreground-work packages without assigning plan semantics to their artifacts. Planning is now an explicit promotion specialization. This breaking alpha change replaces planning-package storage, operations, fingerprints, verification commands, and durable schema 7 with generic work-package equivalents and durable schema 8; existing state is not migrated. +Software-delivery Flows can admit and approve immutable foreground-work packages without assigning plan semantics to their artifacts. Planning is now an explicit promotion specialization. Corrupt package evidence reopens the explicit admission path, while stale planning state still requires explicit promotion. This breaking alpha change replaces planning-package storage, operations, fingerprints, verification commands, and durable schema 7 with generic work-package equivalents and durable schema 8; existing state is not migrated. From 09833f1cdbaa072b0de2534962e0ac08c28be59b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 07:30:35 +0100 Subject: [PATCH 12/14] Clear transferred work package state when parking source --- .../softwaredelivery/effects/driver.go | 3 ++ .../effects/driver_internal_test.go | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 boatstack/internal/softwaredelivery/effects/driver_internal_test.go diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index f5cf0ad..4ba49cf 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -467,6 +467,7 @@ func parkedSourceState(state durable.State, resultingRevision uint64, transition state.Engagement = model.EngagementDormant state.Delivery = model.DeliveryUninitialized state.Workspace = model.WorkspaceAbsent + state.WorkPackage = model.WorkPackageAbsent state.Plan = model.PlanAbsent state.Publication = model.PublicationNone state.Verification = model.VerificationUnverified @@ -477,6 +478,8 @@ func parkedSourceState(state durable.State, resultingRevision uint64, transition state.SourceRevision = "" state.WorktreeFingerprint = "" state.PlanFingerprint = "" + state.WorkPackageFingerprint = "" + state.WorkPackageApprovalFingerprint = "" state.WorkspaceBranch = "" state.WorkspacePath = "" state.WorkspaceBaseRef = "" diff --git a/boatstack/internal/softwaredelivery/effects/driver_internal_test.go b/boatstack/internal/softwaredelivery/effects/driver_internal_test.go new file mode 100644 index 0000000..eb65997 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/driver_internal_test.go @@ -0,0 +1,53 @@ +package effects + +import ( + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestParkedSourceStateClearsWorkPackageLineageBeforeNewObjective(t *testing.T) { + // control-law: workspace-cut-parks-source-without-transferred-delivery-lineage + now := time.Unix(3_000, 0).UTC() + priorObjective := model.Objective{ID: "objective-a", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery-a"} + state := durable.State{ + Revision: 8, + Phase: model.PhaseActive, + Engagement: model.EngagementActive, + Delivery: model.DeliveryApproved, + Workspace: model.WorkspaceCut, + WorkPackage: model.WorkPackageApproved, + Plan: model.PlanApproved, + Objective: priorObjective, + PlanFingerprint: "plan-a", + WorkPackageFingerprint: "package-a", + WorkPackageApprovalFingerprint: "approval-a", + } + + parked := parkedSourceState(state, 9, "workspace.cut", now) + if parked.WorkPackage != model.WorkPackageAbsent || parked.WorkPackageFingerprint != "" || parked.WorkPackageApprovalFingerprint != "" { + t.Fatalf("parked source retained work-package lineage: %#v", parked) + } + if state.WorkPackage != model.WorkPackageApproved || state.WorkPackageFingerprint != "package-a" || state.WorkPackageApprovalFingerprint != "approval-a" { + t.Fatalf("source parking mutated the transferred destination candidate: %#v", state) + } + + nextObjective := model.Objective{ID: "objective-b", TargetID: model.ObjectiveApprovedPlan, DeliveryID: "delivery-b"} + admission := protocol.Admission{ + Objective: nextObjective, + Parameters: protocol.Parameters{ + {Name: "target_id", Value: string(nextObjective.TargetID)}, + {Name: "delivery_id", Value: nextObjective.DeliveryID}, + }, + } + if err := applyObjectiveBind(&parked, admission, catalog.Transition{}); err != nil { + t.Fatalf("bind a different objective after source parking: %v", err) + } + if parked.Objective != nextObjective || parked.Phase != model.PhaseObserved || parked.Engagement != model.EngagementDormant || parked.WorkPackage != model.WorkPackageAbsent { + t.Fatalf("parked source did not return to a clean engagement-begin state: %#v", parked) + } +} From 3d85e73328571eca4f382062ac931af80b36d07b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 08:25:27 +0100 Subject: [PATCH 13/14] fix(flow): require complete work package lifecycle --- boatstack/flow/softwaredelivery/definition.go | 26 +++++ .../flow/softwaredelivery/definition_test.go | 108 ++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index fa85200..d32b706 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -164,6 +164,9 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return delivery.ProgramRuntimeManifest{}, fmt.Errorf("%s: %w", PlanningPackagePromote, err) } } + if err := requireCompleteWorkPackageLifecycle(seen, selected, objectives); err != nil { + return delivery.ProgramRuntimeManifest{}, err + } if len(selected) == 0 { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("software-delivery Flow selects no transitions") } @@ -193,6 +196,29 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return base, nil } +func requireCompleteWorkPackageLifecycle(selectedIDs map[delivery.TransitionID]bool, transitions []delivery.Transition, objectives map[model.TargetID]EntryObjective) error { + hasPackageLifecycle := selectedIDs[WorkPackageAdmit] || selectedIDs[WorkPackageApprove] || selectedIDs[PlanningPackagePromote] + if hasPackageLifecycle || hasTrustedClass(objectives, model.ObjectiveApprovedWorkPackage) { + if !selectedIDs[WorkPackageAdmit] || !selectedIDs[WorkPackageApprove] { + return fmt.Errorf("software-delivery work-package lifecycle requires %s and %s", WorkPackageAdmit, WorkPackageApprove) + } + } + if !hasPackageLifecycle || selectedIDs[PlanningPackagePromote] { + return nil + } + for _, transition := range transitions { + if transition.ID != WorkPackageAdmit && transition.ID != WorkPackageApprove { + continue + } + for _, targetID := range transition.TargetIDs { + if objective, ok := objectives[targetID]; ok && objective.TrustedClass != model.ObjectiveApprovedWorkPackage { + return fmt.Errorf("software-delivery work-package lifecycle for target %q requires %s", targetID, PlanningPackagePromote) + } + } + } + return nil +} + func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget map[model.TargetID][]controlprogram.Entry) error { if transition.Work == nil || len(transition.Work.Inputs) == 0 { return nil diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 062d0ca..d394464 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -1,9 +1,12 @@ package softwaredelivery_test import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -57,6 +60,60 @@ func fact(facet, value string) controlprogram.Predicate { return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} } +func compiledPackageLifecycle(t *testing.T, targetID string, target controlprogram.Predicate, transitionIDs ...string) (controlprogram.Compiled, softwareflow.Resolver) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "testdata", "control-programs", "product-delivery-planning-package.raw.json")) + if err != nil { + t.Fatal(err) + } + var document controlprogram.Document + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + selected := make(map[string]bool, len(transitionIDs)) + for _, id := range transitionIDs { + selected[id] = true + } + operators := document.Operators[:0] + for _, operator := range document.Operators { + if selected[operator.ID] { + operators = append(operators, operator) + } + } + document.Operators = operators + transitions := document.Transitions[:0] + for _, transition := range document.Transitions { + if selected[transition.ID] { + transitions = append(transitions, transition) + } + } + document.Transitions = transitions + if !selected[softwareflow.WorkPackageAdmit] { + document.Work = nil + } + document.Targets = []controlprogram.Target{{ID: targetID, Predicate: target}} + document.Entries = document.Entries[:1] + document.Entries[0].ID = "accept" + document.Entries[0].Target = targetID + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + mutated, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(mutated), resolver, controlprogram.RepositoryAssetResolver{Repository: repositoryRoot}) + if err != nil { + t.Fatal(err) + } + return compiled, resolver +} + func TestTrustedFlowLowersThroughStandardStateEffectBoundary(t *testing.T) { // control-law: repository-flow-selects-trusted-semantics-without-redeclaring-native-effects truth := true @@ -333,29 +390,7 @@ func TestRepositoryTransitionCannotWidenTrustedTargetIDs(t *testing.T) { } func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { - truth := true - compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) - document := compiled.Document - document.Facets = append(document.Facets, controlprogram.Facet{ID: "work-package", Kind: "string"}) - document.Targets = []controlprogram.Target{{ID: "approved-package", Predicate: fact("work-package", "approved")}} - document.Entries = []controlprogram.Entry{{ID: "accept", Target: "approved-package"}} - document.Operators = []controlprogram.Operator{{ID: "work.package.approve", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/work.package.approve", Version: "1"}}} - metadata, err := resolver.ResolveParameterResolver(softwareflow.ParameterResolverPrefix+"admitted-work-package-fingerprint", "1") - if err != nil { - t.Fatal(err) - } - document.Transitions = []controlprogram.Transition{{ - ID: "work.package.approve", Operator: "work.package.approve", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 44, - Parameters: []controlprogram.TransitionParameterBinding{{Parameter: "package_fingerprint", Producer: controlprogram.ParameterProducer{ - Kind: controlprogram.ParameterSourceTrustedResolver, Binding: &controlprogram.ParameterResolverBinding{ - Reference: softwareflow.ParameterResolverPrefix + "admitted-work-package-fingerprint", Version: "1", Fingerprint: metadata.Fingerprint, - }, - }}}, - }} - accepted, err := controlprogram.Compile(document, resolver) - if err != nil { - t.Fatal(err) - } + accepted, resolver := compiledPackageLifecycle(t, "approved-package", fact("work-package", "approved"), softwareflow.WorkPackageAdmit, softwareflow.WorkPackageApprove) objective, err := softwareflow.ObjectiveForEntry(context.Background(), accepted, resolver, "accept") if err != nil { t.Fatal(err) @@ -422,6 +457,33 @@ func TestApprovedWorkPackageEntryResolvesTrustedObjective(t *testing.T) { } } +func TestRuntimeManifestRejectsIncompleteWorkPackageLifecycles(t *testing.T) { + tests := []struct { + name string + targetID string + target controlprogram.Predicate + transitions []string + want string + }{ + {name: "approve only", targetID: "approved-package", target: fact("work-package", "approved"), transitions: []string{softwareflow.WorkPackageApprove}, want: "requires work.package.admit and work.package.approve"}, + {name: "admit only", targetID: "approved-package", target: fact("work-package", "approved"), transitions: []string{softwareflow.WorkPackageAdmit}, want: "requires work.package.admit and work.package.approve"}, + {name: "admit and promote", targetID: "approved-plan", target: fact("plan", "approved"), transitions: []string{softwareflow.WorkPackageAdmit, softwareflow.PlanningPackagePromote}, want: "requires work.package.admit and work.package.approve"}, + {name: "package approval cannot satisfy plan", targetID: "approved-plan", target: fact("plan", "approved"), transitions: []string{softwareflow.WorkPackageAdmit, softwareflow.WorkPackageApprove}, want: "requires planning.package.promote"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + compiled, resolver := compiledPackageLifecycle(t, test.targetID, test.target, test.transitions...) + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("incomplete lifecycle result = %v, want %q", err, test.want) + } + }) + } +} + func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) { truth := true resolver, err := softwareflow.NewResolver(context.Background()) From bafd8e4bcee3c7ceb01acd374c7c06a7f1377669 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 19 Aug 2026 09:23:44 +0100 Subject: [PATCH 14/14] fix(flow): restore stale package recovery --- .../flow/softwaredelivery/definition_test.go | 77 +++++++++++++++++++ .../flow/softwaredelivery/work_package.go | 12 +++ .../softwaredelivery/work_package_test.go | 14 ++++ 3 files changed, 103 insertions(+) diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index d394464..c057a59 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -114,6 +114,34 @@ func compiledPackageLifecycle(t *testing.T, targetID string, target controlprogr return compiled, resolver } +func packageRecoverySnapshot(t *testing.T, objective model.Objective, workPackage model.Fact[model.WorkPackageState], plan model.PlanState) model.Snapshot { + t.Helper() + evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()} + snapshot, err := model.Canonicalize(model.Observation{ + SchemaVersion: model.SnapshotSchemaVersion, StateRevision: 7, + Invocation: model.InvocationContext{ + RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/feature", + ControllerID: "controller", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "runtime", + RuntimePath: filepath.Join(t.TempDir(), "boatstack"), RuntimeFingerprint: "fingerprint", + Topology: model.TopologyEmbedded, Host: "cursor", Correlation: "correlation", + }, + Program: model.Known(model.ProgramCurrent, evidence), Phase: model.Known(model.PhaseActive, evidence), + Engagement: model.Known(model.EngagementActive, evidence), Delivery: model.Known(model.DeliveryPlanning, evidence), + Workspace: model.Known(model.WorkspaceAbsent, evidence), WorkPackage: workPackage, Plan: model.Known(plan, evidence), + Configuration: model.Known(model.ConfigurationVerified, evidence), Runtime: model.Known(model.RuntimeVerified, evidence), + ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli", "cursor"}}, evidence), + Publication: model.Known(model.PublicationNone, evidence), Verification: model.Known(model.VerificationUnverified, evidence), + Recovery: model.Known(model.RecoveryNone, evidence), Transaction: model.Known(model.TransactionNone, evidence), + RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence), TransactionInfo: model.Absent[model.TransactionContext]("none", evidence), + Terminal: model.Known(model.TerminalStale, evidence), Objective: model.Known(objective, evidence), + ObservedAt: time.Unix(10, 0).UTC(), + }) + if err != nil { + t.Fatal(err) + } + return snapshot +} + func TestTrustedFlowLowersThroughStandardStateEffectBoundary(t *testing.T) { // control-law: repository-flow-selects-trusted-semantics-without-redeclaring-native-effects truth := true @@ -484,6 +512,55 @@ func TestRuntimeManifestRejectsIncompleteWorkPackageLifecycles(t *testing.T) { } } +func TestStalePackageAndPlanObservationsRetainRecoveryTransitions(t *testing.T) { + evidence := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()} + tests := []struct { + name string + targetID string + target controlprogram.Predicate + trusted model.TargetID + transitions []string + workPackage model.Fact[model.WorkPackageState] + plan model.PlanState + want delivery.TransitionID + }{ + { + name: "corrupted generic package is re-admitted", targetID: "approved-package", target: fact("work-package", "approved"), + trusted: model.ObjectiveApprovedWorkPackage, transitions: []string{softwareflow.WorkPackageAdmit, softwareflow.WorkPackageApprove}, + workPackage: model.Fact[model.WorkPackageState]{Status: model.FactStale, Value: model.WorkPackageAbsent, Evidence: []model.Evidence{evidence}}, + plan: model.PlanAbsent, want: softwareflow.WorkPackageAdmit, + }, + { + name: "corrupted promoted plan is re-promoted", targetID: "approved-plan", target: fact("plan", "approved"), + trusted: model.ObjectiveApprovedPlan, transitions: []string{softwareflow.WorkPackageAdmit, softwareflow.WorkPackageApprove, softwareflow.PlanningPackagePromote}, + workPackage: model.Known(model.WorkPackageApproved, evidence), plan: model.PlanStale, want: softwareflow.PlanningPackagePromote, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + compiled, resolver := compiledPackageLifecycle(t, test.targetID, test.target, test.transitions...) + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + t.Fatal(err) + } + program, err := delivery.Compile(context.Background(), delivery.CompileRequest{ + KernelVersion: "v2.0.0", Core: core.System(), Runtime: definition, Settings: map[string]string{"repo": "fixture"}, + }) + if err != nil { + t.Fatal(err) + } + objective := model.Objective{ID: "objective", TargetID: model.TargetID(test.targetID), TrustedClass: test.trusted, DeliveryID: "delivery"} + snapshot := packageRecoverySnapshot(t, objective, test.workPackage, test.plan) + decision := supervisor.New(program.RuntimeRegistry(), program.RuntimeObjectiveContracts()).Resolve( + snapshot, objective, catalog.AuthoritySet{catalog.AuthorityAutonomy: true, catalog.AuthorityRepository: true}, "", + ) + if decision.Kind != supervisor.DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != test.want { + t.Fatalf("stale recovery decision = %#v, want %s", decision, test.want) + } + }) + } +} + func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) { truth := true resolver, err := softwareflow.NewResolver(context.Background()) diff --git a/boatstack/flow/softwaredelivery/work_package.go b/boatstack/flow/softwaredelivery/work_package.go index 3a07bbd..2f440b6 100644 --- a/boatstack/flow/softwaredelivery/work_package.go +++ b/boatstack/flow/softwaredelivery/work_package.go @@ -48,6 +48,12 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli []model.FactStatus{model.FactKnown, model.FactStale}, string(model.WorkPackageAbsent), ) + admit.SourceConditions = withFacetStatuses( + withoutFacet(admit.SourceConditions, model.FacetTerminal), + model.FacetTerminal, + []model.FactStatus{model.FactKnown}, + string(model.TerminalNonterminal), string(model.TerminalStale), + ) admit.TargetConditions = withFacet(withoutFacet(admit.TargetConditions, model.FacetPlan), model.FacetWorkPackage, string(model.WorkPackageValid)) admit.TargetIDs = appendUniqueTarget(admit.TargetIDs, model.ObjectiveApprovedWorkPackage) admit.OwnedResources = []string{"work-package"} @@ -89,6 +95,12 @@ func acceptedWorkTransitions(transitions map[string]delivery.Transition) ([]deli []model.FactStatus{model.FactKnown}, string(model.PlanAbsent), string(model.PlanStale), ) + promote.SourceConditions = withFacetStatuses( + withoutFacet(promote.SourceConditions, model.FacetTerminal), + model.FacetTerminal, + []model.FactStatus{model.FactKnown}, + string(model.TerminalNonterminal), string(model.TerminalStale), + ) promote.TargetConditions = replaceFacet(promote.TargetConditions, model.FacetPlan, string(model.PlanApproved)) promote.TargetPhases = appendUniquePhase(promote.TargetPhases, model.PhaseTerminal) promote.TargetIDs = appendUniqueTarget(promote.TargetIDs, model.ObjectiveApprovedPlan) diff --git a/boatstack/flow/softwaredelivery/work_package_test.go b/boatstack/flow/softwaredelivery/work_package_test.go index febf736..bccc2c8 100644 --- a/boatstack/flow/softwaredelivery/work_package_test.go +++ b/boatstack/flow/softwaredelivery/work_package_test.go @@ -85,6 +85,20 @@ func TestAcceptedWorkUsesDistinctFacetAndOnlyPromotionOwnsPlan(t *testing.T) { if !foundRecoverablePlan { t.Fatal("planning promotion cannot recover a stale canonical plan") } + for _, transition := range []delivery.Transition{transitions[0], transitions[2]} { + foundRecoverableTerminal := false + for _, condition := range transition.SourceConditions { + if condition.Facet == model.FacetTerminal && containsString(condition.Values, string(model.TerminalNonterminal)) && containsString(condition.Values, string(model.TerminalStale)) { + if len(condition.Statuses) != 1 || condition.Statuses[0] != model.FactKnown || len(condition.Values) != 2 { + t.Fatalf("%s widened terminal recovery condition: %#v", transition.ID, condition) + } + foundRecoverableTerminal = true + } + } + if !foundRecoverableTerminal { + t.Fatalf("%s cannot recover observer-projected stale terminal state", transition.ID) + } + } } func containsString(values []string, want string) bool {