From 5b806d52685808439b6586db147050b30b815840 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 04:07:17 +0100 Subject: [PATCH 01/10] Implement planning-package contract v2 --- .../cmd/boatstack-helper/flow_command.go | 5 +- .../planning_package_command.go | 150 ++++ .../planning_package_command_test.go | 69 ++ boatstack/controlprogram/artifact.go | 5 +- boatstack/controlprogram/canonical.go | 8 + boatstack/controlprogram/canonical_test.go | 32 + boatstack/controlprogram/ir.go | 3 +- boatstack/flow/softwaredelivery/bindings.go | 9 +- boatstack/flow/softwaredelivery/definition.go | 9 +- .../softwaredelivery/parameter_runtime.go | 23 +- .../parameter_runtime_test.go | 11 +- .../flow/softwaredelivery/planning_package.go | 56 +- .../softwaredelivery/planning_package_test.go | 16 +- .../planningpackage/package.go | 691 ++++++++++++++++++ .../planningpackage/package_test.go | 105 +++ .../flow/softwaredelivery/projections.go | 10 +- .../flow/softwaredelivery/projections_test.go | 21 +- .../softwaredelivery/catalog/transition.go | 19 +- .../softwaredelivery/effects/artifacts.go | 292 +++++--- .../softwaredelivery/effects/driver.go | 2 +- .../effects/planning_package_test.go | 131 ++-- .../softwaredelivery/effects/prepared.go | 77 ++ .../softwaredelivery/effects/prepared_test.go | 49 ++ .../foregroundwork/manager.go | 3 +- .../softwaredelivery/plant/observer.go | 110 +-- .../softwaredelivery/plant/observer_test.go | 41 +- .../internal/softwaredelivery/ports/ports.go | 25 +- .../softwaredelivery/protocol/work.go | 3 + .../incident-response.raw.json | 129 +--- ...t-delivery-planning-package-manual.flow.ts | 4 +- .../product-delivery-planning-package.flow.ts | 2 +- ...product-delivery-planning-package.raw.json | 2 +- docs/architecture/compiler-and-artifacts.md | 4 +- docs/control-program-ir.md | 4 +- .../planning-and-foreground-work.md | 34 +- docs/product-delivery/writing-a-flow.md | 10 +- docs/typescript/base-sdk.md | 4 + docs/typescript/software-delivery-sdk.md | 4 +- .../boatstack-software-delivery/src/index.ts | 109 ++- .../test/software-delivery.test.mjs | 29 +- packages/boatstack/src/index.ts | 10 +- ...2026-08-18-planning-package-contract-v2.md | 2 + 42 files changed, 1768 insertions(+), 554 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/planning_package_command.go create mode 100644 boatstack/cmd/boatstack-helper/planning_package_command_test.go create mode 100644 boatstack/flow/softwaredelivery/planningpackage/package.go create mode 100644 boatstack/flow/softwaredelivery/planningpackage/package_test.go create mode 100644 release-notes/2026-08-18-planning-package-contract-v2.md diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index eeb2b41..a3c54df 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -23,7 +23,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -const flowCompilerVersion = "control-program.compiler.8" +const flowCompilerVersion = "control-program.compiler.9" type flowCommandOptions struct { repository string @@ -54,6 +54,9 @@ func runFlowCommand(arguments []string) error { if action == "input" { return runFlowInput(arguments[1:]) } + if action == "planning-package" { + return runFlowPlanningPackage(arguments[1:]) + } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) options := flowCommandOptions{} diff --git a/boatstack/cmd/boatstack-helper/planning_package_command.go b/boatstack/cmd/boatstack-helper/planning_package_command.go new file mode 100644 index 0000000..3bba11f --- /dev/null +++ b/boatstack/cmd/boatstack-helper/planning_package_command.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" +) + +func runFlowPlanningPackage(arguments []string) error { + if len(arguments) == 0 || arguments[0] != "verify" { + return fmt.Errorf("unknown planning-package action") + } + flags := flag.NewFlagSet("flow planning-package verify", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + var repository, deliveryID, packageFingerprint, format string + var all, requireApproval, requireCurrent bool + flags.StringVar(&repository, "repo", ".", "repository root") + flags.StringVar(&deliveryID, "delivery", "", "delivery identity") + flags.StringVar(&packageFingerprint, "package", "", "full package fingerprint") + flags.StringVar(&format, "format", "text", "text or json") + flags.BoolVar(&all, "all", false, "verify all canonical packages") + flags.BoolVar(&requireApproval, "require-approval", false, "require an exact approval") + flags.BoolVar(&requireCurrent, "require-current-program", false, "require the current checked Flow") + if err := flags.Parse(arguments[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } + if format != "text" && format != "json" { + return fmt.Errorf("unsupported format %q", format) + } + repository, err := filepath.Abs(repository) + if err != nil { + return err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return err + } + if all == (deliveryID != "" || packageFingerprint != "") { + return fmt.Errorf("select either --all or both --delivery and --package") + } + 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 { + current = &value + } else if requireCurrent { + return currentErr + } + var identities [][2]string + if all { + identities, err = planningpackage.Enumerate(repository) + } else { + identities = [][2]string{{deliveryID, packageFingerprint}} + } + if err != nil { + return err + } + results := make([]planningpackage.Result, 0, len(identities)) + failed := false + for _, identity := range identities { + result := planningpackage.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 { + failed = true + } + } + if format == "json" { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + var payload any = results + if !all { + payload = results[0] + } + if err := encoder.Encode(payload); err != nil { + return err + } + } else { + for _, result := range results { + fmt.Fprintf(os.Stdout, "%s/%s integrity=%s contract=%s approval=%s current_program=%s semantic_correctness=%s origin_authenticity=%s\n", result.DeliveryID, result.PackageFingerprint, result.Integrity, result.Contract, result.Approval, result.CurrentProgram, result.SemanticCorrectness, result.OriginAuthenticity) + } + } + if failed { + return fmt.Errorf("one or more planning packages failed required verification") + } + return nil +} + +func loadCurrentPlanningProgram(ctx context.Context, repository string) (planningpackage.CurrentProgram, error) { + path, err := resolveCheckArtifact(repository, "") + if err != nil { + return planningpackage.CurrentProgram{}, err + } + raw, err := os.ReadFile(path) + if err != nil { + return planningpackage.CurrentProgram{}, err + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return planningpackage.CurrentProgram{}, err + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return planningpackage.CurrentProgram{}, err + } + compiled, err := checkArtifactForCurrentProject(repository, artifact, resolver) + if err != nil { + return planningpackage.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 { + continue + } + work, ok := workByID[transition.Work] + if !ok { + return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package work is missing") + } + runtime, err := softwareflow.RuntimeWorkContract(work) + if err != nil { + return planningpackage.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: compiled.Fingerprint, WorkContractFingerprint: runtime.Fingerprint, PlanOutput: planOutput}, nil + } + return planningpackage.CurrentProgram{}, fmt.Errorf("current Flow has no planning-package admission") +} diff --git a/boatstack/cmd/boatstack-helper/planning_package_command_test.go b/boatstack/cmd/boatstack-helper/planning_package_command_test.go new file mode 100644 index 0000000..8cb547a --- /dev/null +++ b/boatstack/cmd/boatstack-helper/planning_package_command_test.go @@ -0,0 +1,69 @@ +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/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 680f2f3..1ea833e 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -17,7 +17,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 6 + ArtifactSchemaRevision = 7 ) type Artifact struct { @@ -216,6 +216,9 @@ func workAssetBindings(document Document) map[string]string { for _, contract := range document.Work { result[contract.Instructions.Path] = contract.Instructions.SHA256 for _, output := range contract.Outputs { + if output.Guidance != nil { + result[output.Guidance.Path] = output.Guidance.SHA256 + } if output.Schema != nil { result[output.Schema.Path] = output.Schema.SHA256 } diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index 6de7944..c6c59bd 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -212,6 +212,14 @@ func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkCon if output.MaxBytes < 1 || output.MaxBytes > maxOutputBytes { return nil, invalid("work."+contract.ID+".outputs."+output.ID+".max_bytes", "must be between 1 and 16 MiB") } + if output.Guidance != nil { + if err := resolveWorkAsset(output.Guidance, assets, maxInstructionBytes, "work."+contract.ID+".outputs."+output.ID+".guidance"); err != nil { + return nil, err + } + if strings.TrimSpace(output.Guidance.Content) == "" { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".guidance", "guidance asset must not be empty") + } + } if output.Schema != nil { if output.MediaType != "application/json" { return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schemas require application/json") diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index c3146ef..18f09d7 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -514,6 +514,38 @@ func TestForegroundWorkIsDomainNeutralAndFingerprintBound(t *testing.T) { } } +func TestArtifactGuidanceIsCompilerBoundAndChangesProgramIdentity(t *testing.T) { + // control-law: artifact-local guidance is exact generation data, never authority + baseDocument := incidentWorkProgram() + guidance := "Write a concise diagnosis grounded in the incident input." + digest := sha256.Sum256([]byte(guidance)) + baseDocument.Work[0].Outputs[0].Guidance = &controlprogram.WorkAsset{Path: "diagnosis-guidance.md", SHA256: hex.EncodeToString(digest[:]), Content: guidance} + base, err := controlprogram.Compile(baseDocument, nil) + if err != nil { + t.Fatal(err) + } + output := base.Document.Work[0].Outputs[0] + if output.Guidance == nil || output.Guidance.Content != guidance || output.Guidance.SHA256 != hex.EncodeToString(digest[:]) { + t.Fatalf("guidance=%#v", output.Guidance) + } + changedDocument := baseDocument + changedDocument.Work = append([]controlprogram.WorkContract(nil), baseDocument.Work...) + changedDocument.Work[0].Outputs = append([]controlprogram.WorkOutput(nil), baseDocument.Work[0].Outputs...) + changed := guidance + " Include recovery evidence." + changedDigest := sha256.Sum256([]byte(changed)) + changedDocument.Work[0].Outputs[0].Guidance = &controlprogram.WorkAsset{Path: "diagnosis-guidance.md", SHA256: hex.EncodeToString(changedDigest[:]), Content: changed} + semantic, err := controlprogram.Compile(changedDocument, nil) + if err != nil { + t.Fatal(err) + } + if semantic.Fingerprint == base.Fingerprint { + t.Fatal("guidance change preserved ProgramFingerprint") + } + if len(semantic.Document.Operators[0].Authority.AnyOf) != len(base.Document.Operators[0].Authority.AnyOf) { + t.Fatal("guidance changed authority") + } +} + func TestForegroundWorkRejectsUnboundAssetsInputsAndTransitions(t *testing.T) { // control-law: every foreground-work dependency is declared and exactly referenced for name, mutate := range map[string]func(*controlprogram.Document){ diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 74b25cf..b869c38 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 6 + SchemaRevision = 7 ) type Document struct { @@ -74,6 +74,7 @@ type WorkOutput struct { MediaType string `json:"media_type"` Required bool `json:"required"` MaxBytes int64 `json:"max_bytes,omitempty"` + Guidance *WorkAsset `json:"guidance,omitempty"` Schema *WorkAsset `json:"schema,omitempty"` } diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 1c53319..3596de0 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -107,7 +107,12 @@ func (r Resolver) ResolveParameterResolver(reference, version string) (controlpr } switch { case reference == ParameterResolverPrefix+"admitted-planning-package-fingerprint": - value.Dependencies = []string{"repository", "delivery_id", "admitted-planning-package-manifest"} + value.Dependencies = []string{"planning-package-fingerprint", "durable-state"} + case strings.HasPrefix(reference, planningPackagePlanOutputResolverPrefix): + if value := strings.TrimPrefix(reference, planningPackagePlanOutputResolverPrefix); !planningPackageSegment.MatchString(value) { + return controlprogram.ResolvedParameterResolver{}, fmt.Errorf("unknown software-delivery parameter resolver %q", reference) + } + value.Dependencies = []string{"compiled-planning-package-binding"} case reference == ParameterResolverPrefix+"repository-default-branch": value.Dependencies = []string{"repository", "verified-configuration"} case reference == ParameterResolverPrefix+"delivery-branch": @@ -229,6 +234,8 @@ func projectOperatorParameters(transition delivery.Transition) []controlprogram. switch { case transition.ID == PlanningPackageApprove && parameter.Name == "package_fingerprint": allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} + case transition.ID == PlanningPackageAdmit && parameter.Name == "plan_output": + allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} case transition.ID == "workspace.cut": allowed = []controlprogram.ParameterSourceKind{controlprogram.ParameterSourceTrustedResolver} case strings.HasPrefix(string(transition.ID), "gate.") && strings.HasSuffix(string(transition.ID), ".record"): diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index f91f64e..41ea71a 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -110,7 +110,11 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } if transition.ID == PlanningPackageAdmit { - if err := validatePlanningPackageWorkContract(*transition.Work); err != nil { + 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 { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) } } @@ -203,6 +207,9 @@ func RuntimeWorkContract(declaration controlprogram.WorkContract) (*delivery.Wor } for _, output := range declaration.Outputs { runtimeOutput := delivery.WorkOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} + if output.Guidance != nil { + runtimeOutput.GuidancePath, runtimeOutput.GuidanceSHA256, runtimeOutput.GuidanceContent = output.Guidance.Path, output.Guidance.SHA256, output.Guidance.Content + } if output.Schema != nil { runtimeOutput.SchemaPath, runtimeOutput.SchemaSHA256, runtimeOutput.SchemaContent = output.Schema.Path, output.Schema.SHA256, output.Schema.Content } diff --git a/boatstack/flow/softwaredelivery/parameter_runtime.go b/boatstack/flow/softwaredelivery/parameter_runtime.go index 8af2121..5605f30 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime.go @@ -39,10 +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, + "workspace_branch": state.WorkspaceBranch, + "preview_fingerprint": state.PreviewFingerprint, + "publication_id": state.PublicationID, + "transaction_id": state.TransactionID, + "planning_package_fingerprint": state.PlanningPackageFingerprint, } result := map[string]invocation.Value{} for facet, value := range values { @@ -91,11 +92,17 @@ 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, fingerprintErr := PlanningPackageFingerprint(r.Repository, r.DeliveryID) - if fingerprintErr != nil { - return invocation.Value{}, fingerprintErr + fingerprint, ok := materialization.State["planning_package_fingerprint"] + if !ok || len(fingerprint.Canonical) != 64 { + return invocation.Value{}, fmt.Errorf("admitted planning package fingerprint is unavailable from durable state") } - value.Canonical, value.Provenance = fingerprint, "planning-package-manifest:"+fingerprint + value.Canonical, value.Provenance = fingerprint.Canonical, fingerprint.Provenance + case strings.HasPrefix(binding.Reference, planningPackagePlanOutputResolverPrefix): + planOutput := strings.TrimPrefix(binding.Reference, planningPackagePlanOutputResolverPrefix) + if !planningPackageSegment.MatchString(planOutput) { + return invocation.Value{}, fmt.Errorf("planning-package plan output binding is invalid") + } + value.Canonical, value.Provenance = planOutput, "compiled-planning-package-binding" case binding.Reference == ParameterResolverPrefix+"repository-default-branch": configPath := filepath.Join(r.Repository, ".boatstack", "project.json") raw, readErr := os.ReadFile(configPath) diff --git a/boatstack/flow/softwaredelivery/parameter_runtime_test.go b/boatstack/flow/softwaredelivery/parameter_runtime_test.go index aa54765..8528906 100644 --- a/boatstack/flow/softwaredelivery/parameter_runtime_test.go +++ b/boatstack/flow/softwaredelivery/parameter_runtime_test.go @@ -2,8 +2,6 @@ package softwaredelivery import ( "context" - "os" - "path/filepath" "strings" "testing" "time" @@ -18,14 +16,6 @@ func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testi deliveryID := "todo-plan" manifestFingerprint := strings.Repeat("a", 64) workResultFingerprint := strings.Repeat("b", 64) - root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID) - if err := os.MkdirAll(root, 0o700); err != nil { - t.Fatal(err) - } - manifest := []byte(`{"work_result_fingerprint":"` + workResultFingerprint + `","fingerprint":"` + manifestFingerprint + `"}`) - if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifest, 0o600); err != nil { - t.Fatal(err) - } resolver, err := NewResolver(context.Background()) if err != nil { t.Fatal(err) @@ -44,6 +34,7 @@ func TestAdmittedPlanningPackageFingerprintMaterializesManifestIdentity(t *testi RunID: "run-package", ProgramFingerprint: strings.Repeat("c", 64), ExecutionProgramFingerprint: strings.Repeat("d", 64), EntryID: "run", TargetID: "published-pr", TransitionID: PlanningPackageApprove, StateRevision: 7, 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"}}, }, 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 index 8ab08ac..543b7a6 100644 --- a/boatstack/flow/softwaredelivery/planning_package.go +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -1,20 +1,20 @@ package softwaredelivery import ( - "encoding/json" "fmt" - "os" - "path" - "path/filepath" "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" @@ -40,6 +40,7 @@ func planningPackageTransitions(transitions map[string]delivery.Transition) ([]d // 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" @@ -98,44 +99,35 @@ func replacePlanCondition(values []delivery.FacetCondition, state model.PlanStat return result } -func validatePlanningPackageWorkContract(work delivery.WorkContract) error { +func validatePlanningPackageWorkContract(work delivery.WorkContract, planOutputID string) error { + outputs := make([]planningpackage.WorkOutput, 0, len(work.Outputs)) var planOutput *delivery.WorkOutput for index := range work.Outputs { output := &work.Outputs[index] - for _, reserved := range []string{"manifest.json", "approval.json"} { - if output.Path == reserved || strings.HasPrefix(output.Path, reserved+"/") || strings.HasPrefix(reserved, output.Path+"/") { - return fmt.Errorf("output %q conflicts with runtime-owned planning-package metadata %q", output.ID, reserved) - } - } - if output.ID == "plan" { + outputs = append(outputs, planningpackage.WorkOutput{ID: output.ID, Path: output.Path, MaxBytes: output.MaxBytes}) + if output.ID == planOutputID { planOutput = output } } - if planOutput == nil || !planOutput.Required { - return fmt.Errorf("planning-package admission requires a required output named %q", "plan") + if err := planningpackage.ValidateOutputPaths(outputs); err != nil { + return err } - if path.Clean(planOutput.Path) != planOutput.Path || planOutput.Path == "." { - return fmt.Errorf("planning-package plan output path is not canonical") + if planOutput == nil || !planOutput.Required { + return fmt.Errorf("planning-package admission requires designated output %q to exist exactly once and be required", planOutputID) } return nil } -// PlanningPackageFingerprint reads the current repository package projection. -// Effect preflight independently verifies the complete manifest before any -// mutation; this helper only binds the candidate parameter for continuation. -func PlanningPackageFingerprint(repository, deliveryID string) (string, error) { - if !planningPackageSegment.MatchString(deliveryID) { - return "", fmt.Errorf("invalid planning package delivery identity") - } - raw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, "manifest.json")) - if err != nil { - return "", err - } - var projection struct { - Fingerprint string `json:"fingerprint"` - } - if err := json.Unmarshal(raw, &projection); err != nil || len(projection.Fingerprint) != 64 { - return "", fmt.Errorf("planning package manifest has no valid fingerprint") +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 projection.Fingerprint, nil + return "", fmt.Errorf("planning-package admit requires an explicit plan_output binding") } diff --git a/boatstack/flow/softwaredelivery/planning_package_test.go b/boatstack/flow/softwaredelivery/planning_package_test.go index d91b62e..e9cd276 100644 --- a/boatstack/flow/softwaredelivery/planning_package_test.go +++ b/boatstack/flow/softwaredelivery/planning_package_test.go @@ -16,14 +16,14 @@ func TestPlanningPackageWorkRequiresOwnedPlanOutput(t *testing.T) { outputs []delivery.WorkOutput want string }{ - {name: "missing plan", outputs: []delivery.WorkOutput{{ID: "questions", Path: "questions.md", Required: true}}, want: `required output named "plan"`}, - {name: "optional plan", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md"}}, want: `required output named "plan"`}, - {name: "manifest collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "manifest.json", Required: true}}, want: "runtime-owned"}, - {name: "approval descendant collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md", Required: true}, {ID: "evidence", Path: "approval.json/evidence", Required: true}}, want: "runtime-owned"}, + {name: "missing plan", outputs: []delivery.WorkOutput{{ID: "questions", Path: "questions.md", Required: true, MaxBytes: 1}}, want: `designated output "plan"`}, + {name: "optional plan", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md", MaxBytes: 1}}, want: `designated output "plan"`}, + {name: "manifest collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "manifest.json", Required: true, MaxBytes: 1}}, want: "reserved"}, + {name: "approval descendant collision", outputs: []delivery.WorkOutput{{ID: "plan", Path: "plan.md", Required: true, MaxBytes: 1}, {ID: "evidence", Path: "approval.json/evidence", Required: true, MaxBytes: 1}}, want: "reserved"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - err := validatePlanningPackageWorkContract(delivery.WorkContract{ID: "planning", Outputs: test.outputs}) + err := validatePlanningPackageWorkContract(delivery.WorkContract{ID: "planning", Outputs: test.outputs}, "plan") if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("validation error = %v, want %q", err, test.want) } @@ -71,10 +71,10 @@ func containsString(values []string, want string) bool { func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { work := delivery.WorkContract{ID: "planning", Outputs: []delivery.WorkOutput{ - {ID: "plan", Path: "plan.md", Required: true}, - {ID: "questions", Path: "questions.md", Required: true}, + {ID: "plan", Path: "plan.md", Required: true, MaxBytes: 1}, + {ID: "questions", Path: "questions.md", Required: true, MaxBytes: 1}, }} - if err := validatePlanningPackageWorkContract(work); err != nil { + if err := validatePlanningPackageWorkContract(work, "plan"); err != nil { t.Fatal(err) } } diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go new file mode 100644 index 0000000..75108e1 --- /dev/null +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -0,0 +1,691 @@ +// Package planningpackage owns the portable, content-addressed planning-package +// format. It is deliberately independent of live controller state and authority. +package planningpackage + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + "unicode/utf8" + + general "github.com/operatorstack/boatstack/boatstack/kernel" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const ( + ManifestSchemaVersion = 2 + ContractSchemaVersion = 1 + WorkReceiptSchemaVersion = 1 + ApprovalSchemaVersion = 2 +) + +var segment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var fingerprint = regexp.MustCompile(`^[a-f0-9]{64}$`) + +var Reserved = []string{"approval.json", "contract.json", "manifest.json", "work-receipt.json"} + +type Asset struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Content string `json:"content"` +} + +type WorkOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + MaxBytes int64 `json:"max_bytes"` + Guidance *Asset `json:"guidance,omitempty"` + Schema *Asset `json:"schema,omitempty"` +} + +type WorkInput struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` +} + +type WorkContract struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + Instructions Asset `json:"instructions"` + Inputs []WorkInput `json:"inputs,omitempty"` + Outputs []WorkOutput `json:"outputs"` +} + +type Contract struct { + SchemaVersion int `json:"schema_version"` + Work WorkContract `json:"work"` + PlanOutput string `json:"plan_output"` + Fingerprint string `json:"fingerprint"` +} + +type Output struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + GuidanceSHA256 string `json:"guidance_sha256,omitempty"` + SchemaSHA256 string `json:"schema_sha256,omitempty"` +} + +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"` + 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"` + PlanOutput PlanOutput `json:"plan_output"` + Contract Reference `json:"contract"` + WorkReceipt Reference `json:"work_receipt"` + Outputs []Output `json:"outputs"` + Fingerprint string `json:"fingerprint"` +} + +type WorkReceipt struct { + SchemaVersion int `json:"schema_version"` + RequestID string `json:"request_id"` + RequestFingerprint string `json:"request_fingerprint"` + ResultFingerprint string `json:"result_fingerprint"` + ContractID string `json:"contract_id"` + ContractFingerprint string `json:"contract_fingerprint"` + TransitionID string `json:"transition_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ContextFingerprint string `json:"context_fingerprint"` + StateRevision uint64 `json:"state_revision"` + RepositoryID string `json:"repository_id"` + WorktreeID string `json:"worktree_id"` + Outputs []Output `json:"outputs"` + Fingerprint string `json:"fingerprint"` +} + +type AuthoritySource struct { + ID string `json:"id"` + Class string `json:"class"` + Subject string `json:"subject"` + Fingerprint string `json:"fingerprint"` +} + +type Approval struct { + SchemaVersion int `json:"schema_version"` + 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"` + IdentityRole string `json:"identity_role"` + IdentityProviderFingerprint string `json:"identity_provider_fingerprint"` + ApprovedAt time.Time `json:"approved_at"` + Fingerprint string `json:"fingerprint"` +} + +type Status string + +const ( + Valid Status = "valid" + Invalid Status = "invalid" + Missing Status = "missing" + Match Status = "match" + Different Status = "different" + Unavailable Status = "unavailable" +) + +type Result struct { + DeliveryID string `json:"delivery_id"` + PackageFingerprint string `json:"package_fingerprint"` + Integrity Status `json:"integrity"` + Contract Status `json:"contract"` + Approval Status `json:"approval"` + CurrentProgram Status `json:"current_program"` + SemanticCorrectness string `json:"semantic_correctness"` + OriginAuthenticity string `json:"origin_authenticity"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + +type CurrentProgram struct{ ProgramFingerprint, WorkContractFingerprint, PlanOutput string } + +func Encode(value any) ([]byte, error) { + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + return append(raw, '\n'), nil +} + +func Digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) } + +func StrictDecode(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return fmt.Errorf("artifact contains trailing JSON") + } + return nil +} + +func fingerprintValue(value any, clear func()) (string, error) { + clear() + raw, err := Encode(value) + if err != nil { + return "", err + } + return Digest(raw), nil +} + +func SealContract(value Contract) (Contract, []byte, error) { + value.SchemaVersion = ContractSchemaVersion + value.Fingerprint = "" + fp, err := fingerprintValue(value, func() { value.Fingerprint = "" }) + if err != nil { + return Contract{}, nil, err + } + value.Fingerprint = fp + raw, err := Encode(value) + return value, raw, err +} + +func SealWorkReceipt(value WorkReceipt) (WorkReceipt, []byte, error) { + value.SchemaVersion = WorkReceiptSchemaVersion + sort.Slice(value.Outputs, func(i, j int) bool { return value.Outputs[i].ID < value.Outputs[j].ID }) + value.Fingerprint = "" + raw, err := Encode(value) + if err != nil { + return WorkReceipt{}, nil, err + } + value.Fingerprint = Digest(raw) + raw, err = Encode(value) + return value, raw, err +} + +func SealManifest(value Manifest) (Manifest, []byte, error) { + value.SchemaVersion = ManifestSchemaVersion + sort.Slice(value.Outputs, func(i, j int) bool { return value.Outputs[i].ID < value.Outputs[j].ID }) + value.Fingerprint = "" + raw, err := Encode(value) + if err != nil { + return Manifest{}, nil, err + } + value.Fingerprint = Digest(raw) + raw, err = Encode(value) + return value, raw, err +} + +func SealApproval(value Approval) (Approval, []byte, error) { + value.SchemaVersion = ApprovalSchemaVersion + sort.Slice(value.AuthoritySources, func(i, j int) bool { return value.AuthoritySources[i].ID < value.AuthoritySources[j].ID }) + value.Fingerprint = "" + raw, err := Encode(value) + if err != nil { + return Approval{}, nil, err + } + value.Fingerprint = Digest(raw) + raw, err = Encode(value) + return value, raw, err +} + +func ValidSegment(value string) bool { + return segment.MatchString(value) && value != "." && value != ".." +} +func ValidFingerprint(value string) bool { return fingerprint.MatchString(value) } + +func ValidateOutputPaths(outputs []WorkOutput) error { + paths := map[string]string{} + ids := map[string]bool{} + for _, output := range outputs { + if !ValidSegment(output.ID) || !safeRelative(output.Path) || output.MaxBytes < 1 { + return fmt.Errorf("output %q has invalid identity, path, or bound", output.ID) + } + if ids[output.ID] { + return fmt.Errorf("output %q is duplicated", output.ID) + } + ids[output.ID] = true + folded := strings.ToLower(filepath.ToSlash(output.Path)) + for _, reserved := range Reserved { + if folded == reserved || strings.HasPrefix(folded, reserved+"/") || strings.HasPrefix(reserved, folded+"/") { + return fmt.Errorf("output %q conflicts with reserved path %q", output.ID, reserved) + } + } + for prior, id := range paths { + if folded == prior || strings.HasPrefix(folded, prior+"/") || strings.HasPrefix(prior, folded+"/") { + return fmt.Errorf("outputs %q and %q have colliding paths", id, output.ID) + } + } + paths[folded] = output.ID + } + return nil +} + +func safeRelative(value string) bool { + if value == "" || filepath.IsAbs(value) || filepath.Clean(value) != value || value == "." { + return false + } + return value != ".." && !strings.HasPrefix(value, ".."+string(filepath.Separator)) && !strings.Contains(value, `\`) +} + +func Verify(repository, deliveryID, packageFingerprint string, current *CurrentProgram) Result { + result := Result{DeliveryID: deliveryID, PackageFingerprint: packageFingerprint, Integrity: Invalid, Contract: Invalid, Approval: Missing, CurrentProgram: Unavailable, SemanticCorrectness: "not-evaluated", OriginAuthenticity: "not-proven"} + fail := func(message string) Result { result.Diagnostics = append(result.Diagnostics, message); return result } + if !ValidSegment(deliveryID) || !ValidFingerprint(packageFingerprint) { + return fail("package path identity is invalid") + } + root, err := filepath.Abs(repository) + if err != nil { + return fail(err.Error()) + } + info, err := os.Lstat(root) + if err != nil || !info.IsDir() { + return fail("repository root is unavailable") + } + packageRoot := filepath.Join(root, ".boatstack", "planning-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") + } + manifestRaw, err := readRegular(filepath.Join(packageRoot, "manifest.json")) + if err != nil { + return fail(err.Error()) + } + var manifest Manifest + if err = StrictDecode(manifestRaw, &manifest); err != nil { + return fail("manifest: " + err.Error()) + } + if !canonicalEncoding(manifestRaw, manifest) || !outputsSorted(manifest.Outputs) { + return fail("manifest encoding or output order is non-canonical") + } + manifestIdentity := manifest + manifestIdentity.Fingerprint = "" + identityRaw, _ := Encode(manifestIdentity) + if identityErr := validateManifestIdentity(manifest, deliveryID, packageFingerprint, Digest(identityRaw)); identityErr != nil { + return fail("manifest identity is invalid: " + identityErr.Error()) + } + contractRaw, err := readRegular(filepath.Join(packageRoot, "contract.json")) + if err != nil || Digest(contractRaw) != manifest.Contract.SHA256 || manifest.Contract.Path != "contract.json" { + return fail("contract reference is invalid") + } + var contract Contract + if err = StrictDecode(contractRaw, &contract); err != nil { + return fail("contract: " + err.Error()) + } + if !canonicalEncoding(contractRaw, contract) { + return fail("contract encoding is non-canonical") + } + contractIdentity := contract + contractIdentity.Fingerprint = "" + identityRaw, _ = Encode(contractIdentity) + if contract.SchemaVersion != ContractSchemaVersion || contract.Fingerprint != Digest(identityRaw) || contract.Work.Fingerprint != manifest.WorkContractFingerprint || contract.PlanOutput != manifest.PlanOutput.ID { + return fail("contract identity is invalid") + } + if err = validateContractAssets(contract.Work); err != nil { + return fail(err.Error()) + } + result.Contract = Valid + receiptRaw, err := readRegular(filepath.Join(packageRoot, "work-receipt.json")) + if err != nil || Digest(receiptRaw) != manifest.WorkReceipt.SHA256 || manifest.WorkReceipt.Path != "work-receipt.json" { + return fail("work receipt reference is invalid") + } + var receipt WorkReceipt + if err = StrictDecode(receiptRaw, &receipt); err != nil { + return fail("work receipt: " + err.Error()) + } + if !canonicalEncoding(receiptRaw, receipt) || !outputsSorted(receipt.Outputs) { + return fail("work receipt encoding or output order is non-canonical") + } + receiptIdentity := receipt + receiptIdentity.Fingerprint = "" + identityRaw, _ = Encode(receiptIdentity) + if receipt.SchemaVersion != WorkReceiptSchemaVersion || receipt.Fingerprint != Digest(identityRaw) || receipt.RequestID == "" || receipt.ContractID != manifest.WorkContractID || receipt.TransitionID != manifest.TransitionID || receipt.RepositoryID == "" || receipt.WorktreeID == "" || + receipt.RequestFingerprint != manifest.WorkRequestFingerprint || receipt.ResultFingerprint != manifest.WorkResultFingerprint || receipt.ContractFingerprint != manifest.WorkContractFingerprint || receipt.ProgramFingerprint != manifest.ProgramFingerprint || receipt.ContextFingerprint != manifest.ContextFingerprint || receipt.StateRevision != manifest.StateRevision { + return fail("work receipt identity is invalid") + } + if err = verifyOutputs(packageRoot, manifest, contract, receipt); err != nil { + return fail(err.Error()) + } + approvalPath := filepath.Join(packageRoot, "approval.json") + if _, statErr := os.Lstat(approvalPath); statErr == nil { + approvalRaw, readErr := readRegular(approvalPath) + if readErr != nil { + result.Approval = Invalid + return fail(readErr.Error()) + } + var approval Approval + if StrictDecode(approvalRaw, &approval) != nil || !canonicalEncoding(approvalRaw, approval) || !authoritySourcesSorted(approval.AuthoritySources) { + result.Approval = Invalid + return fail("approval is invalid") + } + identity := approval + identity.Fingerprint = "" + raw, _ := Encode(identity) + if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(raw) || 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.IdentityRole == "" || !ValidFingerprint(approval.IdentityProviderFingerprint) || len(approval.AuthoritySources) == 0 || approval.ApprovedAt.IsZero() { + result.Approval = Invalid + return fail("approval identity is invalid") + } + result.Approval = Valid + } else if !os.IsNotExist(statErr) { + result.Approval = Invalid + return fail("approval path is unsafe") + } + if err = rejectExtraFiles(packageRoot, manifest, result.Approval == Valid); err != nil { + return fail(err.Error()) + } + result.Integrity = Valid + if current != nil { + if current.ProgramFingerprint == manifest.ProgramFingerprint && current.WorkContractFingerprint == manifest.WorkContractFingerprint && current.PlanOutput == manifest.PlanOutput.ID { + result.CurrentProgram = Match + } else { + result.CurrentProgram = Different + } + } + return result +} + +func validateManifestIdentity(manifest Manifest, deliveryID, packageFingerprint, identityFingerprint string) error { + switch { + case manifest.SchemaVersion != ManifestSchemaVersion: + return fmt.Errorf("schema version") + case manifest.Fingerprint != identityFingerprint || manifest.Fingerprint != packageFingerprint: + return fmt.Errorf("package fingerprint") + case manifest.DeliveryID != deliveryID: + return fmt.Errorf("delivery identity") + case !ValidSegment(manifest.ProgramID) || !ValidSegment(manifest.EntryID) || !ValidSegment(manifest.RunID) || !ValidSegment(manifest.WorkContractID): + 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": + 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 + } +} + +func canonicalEncoding(raw []byte, value any) bool { + encoded, err := Encode(value) + return err == nil && bytes.Equal(raw, encoded) +} + +func outputsSorted(outputs []Output) bool { + for index := 1; index < len(outputs); index++ { + if outputs[index-1].ID >= outputs[index].ID { + return false + } + } + return true +} + +func authoritySourcesSorted(sources []AuthoritySource) bool { + for index, source := range sources { + if source.ID == "" || source.Class == "" || source.Subject == "" || !ValidFingerprint(source.Fingerprint) || index > 0 && sources[index-1].ID >= source.ID { + return false + } + } + return true +} + +func readRegular(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("package member is not regular: %s", path) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o644 { + return nil, fmt.Errorf("package member has non-canonical mode: %s", path) + } + return os.ReadFile(path) +} + +func validateContractAssets(work WorkContract) error { + if !ValidSegment(work.ID) || !ValidFingerprint(work.Fingerprint) || !safeRelative(work.Instructions.Path) || Digest([]byte(work.Instructions.Content)) != work.Instructions.SHA256 || !utf8.ValidString(work.Instructions.Content) { + return fmt.Errorf("embedded work contract is invalid") + } + inputIDs := map[string]bool{} + for _, input := range work.Inputs { + if !ValidSegment(input.ID) || !ValidSegment(input.EntryInput) || inputIDs[input.ID] { + return fmt.Errorf("embedded work input %q is invalid", input.ID) + } + inputIDs[input.ID] = true + } + if err := ValidateOutputPaths(work.Outputs); err != nil { + return err + } + computed, err := RuntimeWorkFingerprint(work) + if err != nil || computed != work.Fingerprint { + return fmt.Errorf("embedded work contract fingerprint is invalid") + } + for _, o := range work.Outputs { + if o.MediaType == "" { + return fmt.Errorf("output %q has no media type", o.ID) + } + for _, a := range []*Asset{o.Guidance, o.Schema} { + if a != nil && (!safeRelative(a.Path) || !utf8.ValidString(a.Content) || Digest([]byte(a.Content)) != a.SHA256) { + return fmt.Errorf("output %q has invalid embedded asset", o.ID) + } + } + if o.Schema != nil { + var schema any + if json.Unmarshal([]byte(o.Schema.Content), &schema) != nil { + return fmt.Errorf("output %q has invalid embedded schema JSON", o.ID) + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if compiler.AddResource(o.Schema.Path, schema) != nil { + return fmt.Errorf("output %q has invalid embedded schema", o.ID) + } + if _, err := compiler.Compile(o.Schema.Path); err != nil { + return fmt.Errorf("output %q has invalid embedded schema: %w", o.ID, err) + } + } + } + return nil +} + +func RuntimeWorkFingerprint(work WorkContract) (string, error) { + type runtimeOutput struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + MaxBytes int64 `json:"max_bytes"` + GuidancePath string `json:"guidance_path,omitempty"` + GuidanceSHA256 string `json:"guidance_sha256,omitempty"` + GuidanceContent string `json:"guidance_content,omitempty"` + SchemaPath string `json:"schema_path,omitempty"` + SchemaSHA256 string `json:"schema_sha256,omitempty"` + SchemaContent string `json:"schema_content,omitempty"` + } + outputs := make([]runtimeOutput, 0, len(work.Outputs)) + for _, output := range work.Outputs { + item := runtimeOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, Required: output.Required, MaxBytes: output.MaxBytes} + if output.Guidance != nil { + item.GuidancePath, item.GuidanceSHA256, item.GuidanceContent = output.Guidance.Path, output.Guidance.SHA256, output.Guidance.Content + } + if output.Schema != nil { + item.SchemaPath, item.SchemaSHA256, item.SchemaContent = output.Schema.Path, output.Schema.SHA256, output.Schema.Content + } + outputs = append(outputs, item) + } + return general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []WorkInput `json:"inputs,omitempty"` + Outputs []runtimeOutput `json:"outputs"` + }{work.ID, work.Instructions.Path, work.Instructions.SHA256, work.Instructions.Content, work.Inputs, outputs}) +} + +func verifyOutputs(root string, manifest Manifest, contract Contract, receipt WorkReceipt) error { + declared := map[string]WorkOutput{} + for _, o := range contract.Work.Outputs { + declared[o.ID] = o + } + receipts := map[string]Output{} + for _, o := range receipt.Outputs { + receipts[o.ID] = o + } + seen := map[string]bool{} + for _, o := range manifest.Outputs { + if seen[o.ID] { + return fmt.Errorf("manifest output %q is duplicated", o.ID) + } + seen[o.ID] = true + decl, ok := declared[o.ID] + rec, rok := receipts[o.ID] + if !ok || !rok || decl.Path != o.Path || decl.MediaType != o.MediaType || decl.Required != o.Required || o.Size < 0 || o.Size > decl.MaxBytes || rec != o { + return fmt.Errorf("output %q does not match contract and receipt", o.ID) + } + if (decl.Guidance == nil) != (o.GuidanceSHA256 == "") || decl.Guidance != nil && decl.Guidance.SHA256 != o.GuidanceSHA256 || (decl.Schema == nil) != (o.SchemaSHA256 == "") || decl.Schema != nil && decl.Schema.SHA256 != o.SchemaSHA256 { + return fmt.Errorf("output %q asset binding is invalid", o.ID) + } + raw, err := readRegular(filepath.Join(root, filepath.FromSlash(o.Path))) + if err != nil || int64(len(raw)) != o.Size || Digest(raw) != o.SHA256 { + return fmt.Errorf("output %q content is invalid", o.ID) + } + if strings.HasPrefix(o.MediaType, "text/") && !utf8.Valid(raw) { + return fmt.Errorf("output %q is not UTF-8", o.ID) + } + if o.MediaType == "application/json" { + var value any + if json.Unmarshal(raw, &value) != nil { + return fmt.Errorf("output %q is invalid JSON", o.ID) + } + if decl.Schema != nil { + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + var schema any + if json.Unmarshal([]byte(decl.Schema.Content), &schema) != nil || compiler.AddResource(decl.Schema.Path, schema) != nil { + return fmt.Errorf("output %q schema is invalid", o.ID) + } + compiled, err := compiler.Compile(decl.Schema.Path) + if err != nil || compiled.Validate(value) != nil { + return fmt.Errorf("output %q fails embedded schema", o.ID) + } + } + } + } + if len(seen) != len(receipts) { + return fmt.Errorf("package output set is incomplete") + } + for id, declaration := range declared { + if declaration.Required && !seen[id] { + 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 +} + +func rejectExtraFiles(root string, manifest Manifest, approval bool) error { + allowed := map[string]bool{"manifest.json": true, "contract.json": true, "work-receipt.json": true} + allowedDirectories := map[string]bool{".": true} + if approval { + allowed["approval.json"] = true + } + for _, o := range manifest.Outputs { + path := filepath.ToSlash(o.Path) + allowed[path] = true + for directory := filepath.ToSlash(filepath.Dir(path)); directory != "."; directory = filepath.ToSlash(filepath.Dir(directory)) { + allowedDirectories[directory] = true + } + } + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("package contains symlink %q", rel) + } + if d.IsDir() { + if !allowedDirectories[rel] { + return fmt.Errorf("package contains undeclared directory %q", rel) + } + return nil + } + if !d.Type().IsRegular() || !allowed[rel] { + return fmt.Errorf("package contains undeclared member %q", rel) + } + return nil + }) +} + +func Enumerate(repository string) ([][2]string, error) { + root := filepath.Join(repository, ".boatstack", "planning-packages") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var result [][2]string + for _, delivery := range entries { + if !delivery.IsDir() || !ValidSegment(delivery.Name()) { + continue + } + packages, _ := os.ReadDir(filepath.Join(root, delivery.Name())) + for _, pkg := range packages { + if pkg.IsDir() && ValidFingerprint(pkg.Name()) { + result = append(result, [2]string{delivery.Name(), pkg.Name()}) + } + } + } + sort.Slice(result, func(i, j int) bool { + if result[i][0] == result[j][0] { + return result[i][1] < result[j][1] + } + return result[i][0] < result[j][0] + }) + return result, nil +} diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/planningpackage/package_test.go new file mode 100644 index 0000000..21d2659 --- /dev/null +++ b/boatstack/flow/softwaredelivery/planningpackage/package_test.go @@ -0,0 +1,105 @@ +package planningpackage + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func installFixture(t *testing.T) (string, string, string, string) { + t.Helper() + repository := t.TempDir() + delivery := "proof" + plan := []byte("# plan\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}}} + 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}) + 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}}) + 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}}) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(repository, ".boatstack", "planning-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} { + if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } + return repository, delivery, manifest.Fingerprint, workFP +} + +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"}) + 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) + } + result = Verify(repository, delivery, fingerprint, &CurrentProgram{ProgramFingerprint: strings.Repeat("f", 64)}) + if result.Integrity != Valid || result.CurrentProgram != Different { + t.Fatalf("historical result=%#v", result) + } +} + +func TestVerifyRejectsIndependentTampering(t *testing.T) { + cases := []string{"manifest.json", "contract.json", "work-receipt.json", "plan.md"} + 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) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(raw, 'x'), 0o644); err != nil { + t.Fatal(err) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Integrity != Invalid { + t.Fatalf("tampered result=%#v", result) + } + }) + } +} + +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") + var err error + if symlink { + err = os.Symlink("plan.md", path) + } else { + err = os.WriteFile(path, []byte("extra"), 0o644) + } + if err != nil { + t.Fatal(err) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Integrity != Invalid { + t.Fatalf("extra member result=%#v", result) + } + } +} + +func TestValidateOutputPathsRejectsReservedCaseAndAncestors(t *testing.T) { + for _, outputs := range [][]WorkOutput{{{ID: "plan", Path: "Manifest.JSON", MaxBytes: 1}}, {{ID: "plan", Path: "a", MaxBytes: 1}, {ID: "detail", Path: "a/detail.md", MaxBytes: 1}}, {{ID: "plan", Path: "../plan.md", MaxBytes: 1}}} { + if err := ValidateOutputPaths(outputs); err == nil { + t.Fatalf("accepted paths=%#v", outputs) + } + } +} diff --git a/boatstack/flow/softwaredelivery/projections.go b/boatstack/flow/softwaredelivery/projections.go index 8087b9e..19e9a10 100644 --- a/boatstack/flow/softwaredelivery/projections.go +++ b/boatstack/flow/softwaredelivery/projections.go @@ -195,10 +195,12 @@ performing product effects. if len(compiled.Document.Work) != 0 { workProtocol = fmt.Sprintf(` When a response contains a `+"`work`"+` request, treat it as foreground work for -the selected transition, not as a second Flow. Read its exact instruction, -input bindings, output manifest, and staging root. Write only the declared -outputs beneath that staging root and stay within each media type and size -bound. +the selected transition, not as a second Flow. Read its exact package-wide +instruction, input bindings, output manifest, artifact-local guidance when +present, schemas, and staging root. Keep every output mutually consistent. +Write only the declared outputs beneath that staging root and stay within each +requiredness, media type, schema, and size bound. Guidance describes generation; +it grants no authority and does not verify an output. Never fabricate completion. If human input is required, record the typed suspension with: diff --git a/boatstack/flow/softwaredelivery/projections_test.go b/boatstack/flow/softwaredelivery/projections_test.go index 8cf8366..bef2914 100644 --- a/boatstack/flow/softwaredelivery/projections_test.go +++ b/boatstack/flow/softwaredelivery/projections_test.go @@ -311,17 +311,26 @@ func TestGeneratedProjectionsProjectForegroundWorkProtocolWithHostParity(t *test Work: []controlprogram.WorkContract{{ID: "diagnose"}}, Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, }} - files, err := softwareflow.GenerateProjections(compiled, []hostprojection.ID{hostprojection.Codex, hostprojection.Claude}) + files, err := softwareflow.GenerateProjections(compiled, hostprojection.CanonicalIDs()) if err != nil { t.Fatal(err) } - codex := string(files[".agents/skills/incident-response-respond/SKILL.md"]) - claude := string(files[".claude/skills/incident-response-respond/SKILL.md"]) - for _, contract := range []string{"not as a second Flow", "flow work input-required", "flow work answer", "flow work complete", "flow work block", "An answer is evidence, never authority", "Never edit the work record directly"} { - if !strings.Contains(codex, contract) { - t.Fatalf("generated foreground-work skill lacks %q", contract) + paths := map[string]string{ + "codex": ".agents/skills/incident-response-respond/SKILL.md", + "claude": ".claude/skills/incident-response-respond/SKILL.md", + "cursor": ".cursor/commands/incident-response-respond.md", + "gemini": ".gemini/skills/incident-response-respond/SKILL.md", + } + for host, path := range paths { + projection := string(files[path]) + for _, contract := range []string{"not as a second Flow", "artifact-local guidance", "grants no authority", "does not verify an output", "flow work input-required", "flow work answer", "flow work complete", "flow work block", "An answer is evidence, never authority", "Never edit the work record directly"} { + if !strings.Contains(projection, contract) { + t.Fatalf("%s foreground-work projection lacks %q", host, contract) + } } } + codex := string(files[paths["codex"]]) + claude := string(files[paths["claude"]]) if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { t.Fatal("Codex and Claude foreground-work projections differ") } diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index ef7a1c0..8c5340a 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -185,14 +185,17 @@ type WorkInput struct { } type WorkOutput struct { - ID string `json:"id"` - Path string `json:"path"` - MediaType string `json:"media_type"` - Required bool `json:"required"` - MaxBytes int64 `json:"max_bytes"` - SchemaPath string `json:"schema_path,omitempty"` - SchemaSHA256 string `json:"schema_sha256,omitempty"` - SchemaContent string `json:"schema_content,omitempty"` + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + Required bool `json:"required"` + MaxBytes int64 `json:"max_bytes"` + GuidancePath string `json:"guidance_path,omitempty"` + GuidanceSHA256 string `json:"guidance_sha256,omitempty"` + GuidanceContent string `json:"guidance_content,omitempty"` + SchemaPath string `json:"schema_path,omitempty"` + SchemaSHA256 string `json:"schema_sha256,omitempty"` + SchemaContent string `json:"schema_content,omitempty"` } // StateEffectKind selects the software-delivery domain's durable-state diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index a764cd4..f28e1bc 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -10,6 +10,7 @@ import ( "strings" "time" + planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" "github.com/operatorstack/boatstack/boatstack/internal/hostprojection" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" @@ -47,24 +48,6 @@ type approvalArtifact struct { ApprovedAt time.Time `json:"approved_at"` } -type planningPackageOutput struct { - ID string `json:"id"` - Path string `json:"path"` - MediaType string `json:"media_type"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` -} - -type planningPackageManifest struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - WorkRequestFingerprint string `json:"work_request_fingerprint"` - WorkResultFingerprint string `json:"work_result_fingerprint"` - PlanFingerprint string `json:"plan_fingerprint"` - Outputs []planningPackageOutput `json:"outputs"` - Fingerprint string `json:"fingerprint"` -} - type gateArtifact struct { SchemaVersion int `json:"schema_version"` DeliveryID string `json:"delivery_id"` @@ -110,7 +93,7 @@ type publicationPreview struct { CreatedAt time.Time `json:"created_at"` } -func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) { +func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State, humanIdentityRole ...string) ([]ports.ResourceMutation, error) { var mutations []ports.ResourceMutation var selectedProjections []hostprojection.ID var deliveryID string @@ -193,94 +176,96 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if admission.Work == nil || len(admission.Work.Outputs) == 0 { return nil, fmt.Errorf("planning package admission requires exact foreground work evidence") } - packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID) - manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: deliveryID, WorkRequestFingerprint: admission.Work.RequestFingerprint, WorkResultFingerprint: admission.Work.ResultFingerprint} - for _, output := range admission.Work.Outputs { - destination, pathErr := planningPackageOutputPath(packageRoot, output.Path) - if pathErr != nil { - return nil, pathErr + planOutput, _ := admission.Parameters.Get("plan_output") + manifest, files, buildErr := buildPlanningPackage(admission, transition, deliveryID, planOutput) + if buildErr != nil { + return nil, buildErr + } + packageRoot := filepath.Join(artifactRoot, "planning-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") } - mutation, mutationErr := mutationFor(destination, []byte(output.Content), 0o644, false, false) + 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, "; ")) + } + } else if !os.IsNotExist(statErr) { + return nil, statErr + } + for relative, raw := range files { + mutation, mutationErr := immutablePlanningMutation(filepath.Join(packageRoot, filepath.FromSlash(relative)), raw) if mutationErr != nil { return nil, mutationErr } - mutations = append(mutations, mutation) - manifest.Outputs = append(manifest.Outputs, planningPackageOutput{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: output.SHA256, Size: output.Size}) - if output.ID == "plan" { - manifest.PlanFingerprint = output.SHA256 + if relative == "manifest.json" { + mutation.InstallLast = true } + if !rootExists { + mutation.AtomicTreeRoot = packageRoot + } + mutations = append(mutations, mutation) } - if manifest.PlanFingerprint == "" { - return nil, fmt.Errorf("planning package requires a declared output with id plan") - } - identity := manifest - identity.Fingerprint = "" - identityRaw, encodeErr := encodeJSON(identity) - if encodeErr != nil { - return nil, encodeErr - } - manifest.Fingerprint = sha256Bytes(identityRaw) - manifestRaw, encodeErr := encodeJSON(manifest) - if encodeErr != nil { - return nil, encodeErr - } - manifestMutation, mutationErr := mutationFor(filepath.Join(packageRoot, "manifest.json"), manifestRaw, 0o644, false, false) - if mutationErr != nil { - return nil, mutationErr - } - mutations = append(mutations, manifestMutation) - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, "" + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, "" case "planning.package.approve": - manifest, manifestRaw, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + expected, _ := admission.Parameters.Get("package_fingerprint") + manifest, loadErr := loadPlanningPackageManifestV2(artifactRoot, deliveryID, expected) if loadErr != nil { return nil, loadErr } - expected, _ := admission.Parameters.Get("package_fingerprint") if expected == "" || expected != manifest.Fingerprint { return nil, fmt.Errorf("planning package approval fingerprint is stale") } if state.PlanningPackageFingerprint != manifest.Fingerprint { return nil, fmt.Errorf("planning package state fingerprint is stale") } - actor := authorityActor(admission) - artifact := approvalArtifact{SchemaVersion: 1, DeliveryID: deliveryID, PlanFingerprint: manifest.PlanFingerprint, PackageFingerprint: manifest.Fingerprint, Actor: actor, AdmissionID: admission.ID, ApprovedAt: admission.IssuedAt.UTC()} - raw, encodeErr := encodeJSON(artifact) + approval := planningpackage.Approval{DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint, PlanOutputID: manifest.PlanOutput.ID, PlanFingerprint: manifest.PlanOutput.SHA256, AdmissionID: admission.ID, Actor: authorityActor(admission), 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}) + } + if len(admission.Authority.Receipts) > 0 { + approval.IdentityRole = string(admission.Authority.Receipts[0].Class) + if len(humanIdentityRole) > 0 && humanIdentityRole[0] != "" { + approval.IdentityRole = humanIdentityRole[0] + } + approval.IdentityProviderFingerprint = admission.Authority.Receipts[0].Fingerprint + } + approval, raw, encodeErr := planningpackage.SealApproval(approval) if encodeErr != nil { return nil, encodeErr } - mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "planning-packages", deliveryID, "approval.json"), raw, 0o644, false, false) + approvalPath := filepath.Join(artifactRoot, "planning-packages", deliveryID, manifest.Fingerprint, "approval.json") + mutation, mutationErr := immutablePlanningMutation(approvalPath, raw) if mutationErr != nil { return nil, mutationErr } - mutations = append(mutations, mutation) - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(append(manifestRaw, raw...)) + if !mutation.PriorExists { + mutations = append(mutations, mutation) + } + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, approval.Fingerprint case "planning.package.promote": - manifest, _, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + manifest, loadErr := loadPlanningPackageManifestV2(artifactRoot, deliveryID, state.PlanningPackageFingerprint) if loadErr != nil { return nil, loadErr } - approvalPath := filepath.Join(artifactRoot, "planning-packages", deliveryID, "approval.json") + packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID, manifest.Fingerprint) + approvalPath := filepath.Join(packageRoot, "approval.json") approvalRaw, readErr := os.ReadFile(approvalPath) if readErr != nil { return nil, fmt.Errorf("read planning package approval: %w", readErr) } - var approval approvalArtifact - if decodeErr := decodeStrictArtifact(approvalRaw, &approval); decodeErr != nil || approval.SchemaVersion != 1 || approval.DeliveryID != deliveryID || approval.PackageFingerprint != manifest.Fingerprint || approval.PlanFingerprint != manifest.PlanFingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + var approval planningpackage.Approval + if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil || approval.SchemaVersion != planningpackage.ApprovalSchemaVersion || approval.DeliveryID != deliveryID || approval.PackageFingerprint != manifest.Fingerprint || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { return nil, fmt.Errorf("planning package approval does not bind the exact package") } - planPath := "" - for _, output := range manifest.Outputs { - if output.ID == "plan" { - planPath = output.Path - break - } - } - planArtifact, pathErr := planningPackageOutputPath(filepath.Join(artifactRoot, "planning-packages", deliveryID), planPath) + planArtifact, pathErr := planningPackageOutputPath(packageRoot, manifest.PlanOutput.Path) if pathErr != nil { return nil, pathErr } planRaw, readErr := readRegularWorkspacePlanArtifact(planArtifact) - if readErr != nil || sha256Bytes(planRaw) != manifest.PlanFingerprint { + if readErr != nil || sha256Bytes(planRaw) != manifest.PlanOutput.SHA256 { return nil, fmt.Errorf("planning package plan changed after approval") } planMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) @@ -292,7 +277,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, mutationErr } mutations = append(mutations, planMutation, approvalMutation) - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(approvalRaw) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, sha256Bytes(approvalRaw) case "plan.validate": path := filepath.Join(artifactRoot, "plans", deliveryID+".source") raw, readErr := os.ReadFile(path) @@ -498,10 +483,8 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp if actual := sha256Bytes(approvalRaw); actual != expectedApprovalFingerprint { return nil, fmt.Errorf("workspace approval artifact fingerprint changed: got %s", actual) } - var approval approvalArtifact - if err := decodeStrictArtifact(approvalRaw, &approval); err != nil || approval.SchemaVersion != 1 || approval.DeliveryID != deliveryID || - approval.PlanFingerprint != expectedPlanFingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { - return nil, fmt.Errorf("workspace approval artifact does not bind the admitted plan") + if err := validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerprint, approvalRaw); err != nil { + return nil, fmt.Errorf("workspace approval artifact does not bind the admitted plan: %w", err) } planMutation, err := mutationFor(filepath.Join(destinationRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) if err != nil { @@ -514,6 +497,39 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp return []ports.ResourceMutation{planMutation, approvalMutation}, nil } +func validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerprint string, raw []byte) error { + var envelope struct { + SchemaVersion int `json:"schema_version"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return err + } + switch envelope.SchemaVersion { + case 1: + var approval approvalArtifact + if err := decodeStrictArtifact(raw, &approval); err != nil || approval.DeliveryID != deliveryID || approval.PlanFingerprint != expectedPlanFingerprint || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + 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") + } + return nil + default: + return fmt.Errorf("unsupported approval schema %d", envelope.SchemaVersion) + } +} + func readRegularWorkspacePlanArtifact(path string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { @@ -549,59 +565,95 @@ func planningPackageOutputPath(root, relative string) (string, error) { return filepath.Join(root, relative), nil } -func loadPlanningPackageManifest(artifactRoot, deliveryID string) (planningPackageManifest, []byte, error) { - packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID) - path := filepath.Join(packageRoot, "manifest.json") - raw, err := os.ReadFile(path) +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}} + for _, input := range transition.Work.Inputs { + work.Inputs = append(work.Inputs, planningpackage.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} + 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} + } + work.Outputs = append(work.Outputs, item) + } + if err := planningpackage.ValidateOutputPaths(work.Outputs); err != nil { + return planningpackage.Manifest{}, nil, err + } + contract, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: work, PlanOutput: planOutputID}) + if err != nil { + return planningpackage.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} + 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) + } + 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} + 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) if err != nil { - return planningPackageManifest{}, nil, fmt.Errorf("read planning package manifest: %w", err) + return planningpackage.Manifest{}, nil, err } - var manifest planningPackageManifest - if err := decodeStrictArtifact(raw, &manifest); err != nil || manifest.SchemaVersion != 1 || manifest.DeliveryID != deliveryID || manifest.WorkRequestFingerprint == "" || manifest.WorkResultFingerprint == "" || manifest.PlanFingerprint == "" || len(manifest.Outputs) == 0 || manifest.Fingerprint == "" { - return planningPackageManifest{}, nil, fmt.Errorf("planning package manifest is invalid") + 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} + if manifest.ProgramID == "" || manifest.EntryID == "" || manifest.RunID == "" { + return planningpackage.Manifest{}, nil, fmt.Errorf("planning package work evidence lacks Flow identity") } - identity := manifest - identity.Fingerprint = "" - identityRaw, err := encodeJSON(identity) - if err != nil || sha256Bytes(identityRaw) != manifest.Fingerprint { - return planningPackageManifest{}, nil, fmt.Errorf("planning package manifest fingerprint is invalid") + manifest, manifestRaw, err := planningpackage.SealManifest(manifest) + if err != nil { + return planningpackage.Manifest{}, nil, err } - if err := validatePlanningPackageOutputs(packageRoot, manifest); err != nil { - return planningPackageManifest{}, nil, err + files["manifest.json"] = manifestRaw + _ = contract + _ = receipt + return manifest, files, nil +} + +func immutablePlanningMutation(path string, target []byte) (ports.ResourceMutation, error) { + mutation, err := mutationFor(path, target, 0o644, false, false) + if err != nil { + return ports.ResourceMutation{}, err } - return manifest, raw, nil + if mutation.PriorExists && !bytes.Equal(mutation.Prior, target) { + return ports.ResourceMutation{}, fmt.Errorf("immutable planning package member conflicts: %s", path) + } + return mutation, nil } -func validatePlanningPackageOutputs(packageRoot string, manifest planningPackageManifest) error { - seenIDs, seenPaths := map[string]bool{}, map[string]bool{} - planFound := false - for _, output := range manifest.Outputs { - if output.ID == "" || output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seenIDs[output.ID] || seenPaths[output.Path] { - return fmt.Errorf("planning package manifest output is invalid") - } - seenIDs[output.ID], seenPaths[output.Path] = true, true - path, err := planningPackageOutputPath(packageRoot, output.Path) - if err != nil { - return err - } - raw, err := readRegularWorkspacePlanArtifact(path) - if err != nil { - return fmt.Errorf("planning package output %q: %w", output.ID, err) - } - if int64(len(raw)) != output.Size || sha256Bytes(raw) != output.SHA256 { - return fmt.Errorf("planning package output %q changed after admission", output.ID) - } - if output.ID == "plan" { - planFound = true - if output.SHA256 != manifest.PlanFingerprint { - return fmt.Errorf("planning package plan does not match the manifest") - } - } +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, "; ")) + } + raw, err := os.ReadFile(filepath.Join(artifactRoot, "planning-packages", deliveryID, packageFingerprint, "manifest.json")) + if err != nil { + return planningpackage.Manifest{}, err } - if !planFound { - return fmt.Errorf("planning package manifest has no plan output") + var manifest planningpackage.Manifest + if err := planningpackage.StrictDecode(raw, &manifest); err != nil { + return planningpackage.Manifest{}, err } - return nil + return manifest, nil } func authorityActor(admission protocol.Admission) string { diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index 9fe3441..f5cf0ad 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -207,7 +207,7 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans next.WorkspaceSourceRef = "" } - mutations, err := prepareArtifacts(layout, admission, transition, &next) + mutations, err := prepareArtifacts(layout, admission, transition, &next, d.programHumanIdentityRole) if err != nil { return nil, err } diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 9b788c9..d000959 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -1,12 +1,14 @@ package effects import ( + "bytes" "os" "path/filepath" "strings" "testing" "time" + planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -14,24 +16,33 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -func TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(t *testing.T) { +func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { // control-law: planning-package promotion requires exact work and approval lineage repository := t.TempDir() layout := ports.ControllerLayout{RepositoryRoot: repository} - objective := model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} now := time.Unix(100, 0).UTC() - plan := "# Approved implementation plan\n" - feature := "# Feature specification\n" + plan := []byte("# implementation plan\n") + workContract := &catalog.WorkContract{ + ID: "planning", InstructionPath: "package.md", + 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) + if err != nil { + t.Fatal(err) + } + workContract.Fingerprint = contractFingerprint work := &protocol.WorkEvidence{ - RequestFingerprint: strings.Repeat("a", 64), ResultFingerprint: strings.Repeat("b", 64), - Outputs: []protocol.WorkOutputEvidence{ - {ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: sha256Bytes([]byte(plan)), Size: int64(len(plan)), Content: plan}, - {ID: "feature-spec", Path: "feature-spec.md", MediaType: "text/markdown", SHA256: sha256Bytes([]byte(feature)), Size: int64(len(feature)), Content: feature}, - }, + 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, + 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", 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} + 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"}}} if err := applyStateTransition(&state, admission, admit); err != nil { t.Fatal(err) } @@ -40,85 +51,87 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(t *testing.T) { t.Fatal(err) } installFixtureMutations(t, mutations) - manifest, _, err := loadPlanningPackageManifest(filepath.Join(repository, ".boatstack"), "delivery") + result := planningpackage.Verify(repository, "delivery", state.PlanningPackageFingerprint, nil) + if result.Integrity != planningpackage.Valid || result.Contract != planningpackage.Valid || result.Approval != planningpackage.Missing { + t.Fatalf("verification=%#v", result) + } + packageRoot := filepath.Join(repository, ".boatstack", "planning-packages", "delivery", state.PlanningPackageFingerprint) + priorManifest, _ := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) + mutations, err = prepareArtifacts(layout, admission, admit, &state) if err != nil { t.Fatal(err) } - if state.Plan != model.PlanPackageValid || state.PlanFingerprint != sha256Bytes([]byte(plan)) || manifest.WorkResultFingerprint != work.ResultFingerprint { - t.Fatalf("admitted state=%#v manifest=%#v", state, manifest) + for _, mutation := range mutations { + if mutation.PriorExists && !bytes.Equal(mutation.Prior, mutation.Target) { + t.Fatal("replay changed immutable bytes") + } } approve := catalog.Transition{ID: "planning.package.approve", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-approve"}} - admission = protocol.Admission{ - ID: "adm-approve", Objective: objective, IssuedAt: now.Add(time.Minute), Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: manifest.Fingerprint}}, - Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer"}}}, - } - featurePath := filepath.Join(repository, ".boatstack", "planning-packages", "delivery", "feature-spec.md") - if err := os.WriteFile(featurePath, []byte("tampered"), 0o600); err != nil { - t.Fatal(err) - } - if _, err := prepareArtifacts(layout, admission, approve, &state); err == nil || !strings.Contains(err.Error(), `output "feature-spec" changed after admission`) { - t.Fatalf("changed non-plan output approval result = %v", err) - } - if err := os.WriteFile(featurePath, []byte(feature), 0o600); err != nil { - t.Fatal(err) - } - if err := applyStateTransition(&state, admission, approve); err != nil { + 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{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer", Fingerprint: strings.Repeat("f", 64), IssuedAt: now}}}} + if err := applyStateTransition(&state, approvalAdmission, approve); err != nil { t.Fatal(err) } - mutations, err = prepareArtifacts(layout, admission, approve, &state) + mutations, err = prepareArtifacts(layout, approvalAdmission, approve, &state) if err != nil { t.Fatal(err) } installFixtureMutations(t, mutations) - if state.Plan != model.PlanPackageApproved || state.ApprovalFingerprint == "" { - t.Fatalf("approved package state=%#v", state) + result = planningpackage.Verify(repository, "delivery", state.PlanningPackageFingerprint, nil) + if result.Approval != planningpackage.Valid { + t.Fatalf("approval verification=%#v", result) } promote := catalog.Transition{ID: "planning.package.promote", TargetPhases: []model.ProtocolPhase{model.PhaseActive}, StateEffect: catalog.StateEffect{Kind: catalog.StateEffectNative, NativeHandler: "planning-package-promote"}} - admission = protocol.Admission{ID: "adm-promote", Objective: objective, IssuedAt: now.Add(2 * time.Minute)} - if err := applyStateTransition(&state, admission, promote); err != nil { + promotion := protocol.Admission{ID: "adm-promote", Objective: objective, IssuedAt: now.Add(2 * time.Minute)} + if err := applyStateTransition(&state, promotion, promote); err != nil { t.Fatal(err) } - mutations, err = prepareArtifacts(layout, admission, promote, &state) + mutations, err = prepareArtifacts(layout, promotion, promote, &state) if err != nil { t.Fatal(err) } installFixtureMutations(t, mutations) - canonicalPlan, err := os.ReadFile(filepath.Join(repository, ".boatstack", "plans", "delivery.source")) + current, _ := os.ReadFile(filepath.Join(repository, ".boatstack", "plans", "delivery.source")) + if !bytes.Equal(current, plan) { + t.Fatalf("promoted plan=%q", current) + } + workspace := t.TempDir() + transfers, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery", state.PlanFingerprint, state.ApprovalFingerprint) if err != nil { t.Fatal(err) } - if state.Plan != model.PlanApproved || string(canonicalPlan) != plan { - t.Fatalf("promoted state=%#v plan=%q", state, canonicalPlan) + 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) + } + after, _ := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) + if !bytes.Equal(after, priorManifest) { + t.Fatal("immutable manifest changed") } } -func TestPlanningPackageApprovalRejectsFingerprintDrift(t *testing.T) { - // control-law: approval cannot cross planning-package manifest drift - repository := t.TempDir() - manifestRoot := filepath.Join(repository, ".boatstack", "planning-packages", "delivery") - if err := os.MkdirAll(manifestRoot, 0o700); err != nil { +func approvalRawAt(t *testing.T, packageRoot string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Join(packageRoot, "approval.json")) + if err != nil { t.Fatal(err) } - plan := []byte("p") - planFingerprint := sha256Bytes(plan) - manifest := planningPackageManifest{SchemaVersion: 1, DeliveryID: "delivery", WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), PlanFingerprint: planFingerprint, Outputs: []planningPackageOutput{{ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}}} - identityRaw, _ := encodeJSON(manifest) - manifest.Fingerprint = sha256Bytes(identityRaw) - raw, _ := encodeJSON(manifest) - if err := os.WriteFile(filepath.Join(manifestRoot, "manifest.json"), raw, 0o600); err != nil { + return raw +} + +func TestPlanningPackageImmutableConflictFailsClosed(t *testing.T) { + path := filepath.Join(t.TempDir(), "member") + if err := os.WriteFile(path, []byte("prior"), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(manifestRoot, "plan.md"), plan, 0o600); err != nil { - t.Fatal(err) + if _, err := immutablePlanningMutation(path, []byte("changed")); err == nil || !strings.Contains(err.Error(), "immutable") { + t.Fatalf("conflict=%v", err) } - state := durable.State{Plan: model.PlanPackageApproved} - _, err := prepareArtifacts(ports.ControllerLayout{RepositoryRoot: repository}, protocol.Admission{ - ID: "adm", Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "package_fingerprint", Value: strings.Repeat("d", 64)}}, - }, catalog.Transition{ID: "planning.package.approve"}, &state) - if err == nil || !strings.Contains(err.Error(), "stale") { - t.Fatalf("fingerprint drift result = %v", err) + raw, _ := os.ReadFile(path) + if string(raw) != "prior" { + t.Fatal("conflict changed prior state") } } @@ -132,7 +145,7 @@ func installFixtureMutations(t *testing.T, mutations []ports.ResourceMutation) { if err := os.MkdirAll(filepath.Dir(mutation.Path), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(mutation.Path, mutation.Target, 0o600); err != nil { + if err := os.WriteFile(mutation.Path, mutation.Target, os.FileMode(mutation.Mode)); err != nil { t.Fatal(err) } } diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index 61d1fa0..a87fe60 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "os" + "path/filepath" "sort" + "strings" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -22,6 +24,7 @@ type preparedEffect struct { postVerify func(context.Context) error verifyInvocation *model.InvocationContext applied []ports.ResourceMutation + appliedTreeRoots []string boundarySettled bool effectResult ports.EffectResult transition catalog.Transition @@ -123,7 +126,36 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error } return ordered[i].Path < ordered[j].Path }) + treeGroups := map[string][]ports.ResourceMutation{} + regular := ordered[:0] for _, mutation := range ordered { + if mutation.AtomicTreeRoot != "" { + treeGroups[mutation.AtomicTreeRoot] = append(treeGroups[mutation.AtomicTreeRoot], mutation) + } else { + regular = append(regular, mutation) + } + } + treeRoots := make([]string, 0, len(treeGroups)) + for root := range treeGroups { + treeRoots = append(treeRoots, root) + } + sort.Strings(treeRoots) + for _, root := range treeRoots { + group := treeGroups[root] + allExisting := true + for _, mutation := range group { + allExisting = allExisting && mutation.PriorExists + } + if allExisting { + continue + } + if err := atomicInstallTree(root, group); err != nil { + return result, fmt.Errorf("install immutable resource tree %s: %w", root, err) + } + p.applied = append(p.applied, group...) + p.appliedTreeRoots = append(p.appliedTreeRoots, root) + } + for _, mutation := range regular { var err error if mutation.Delete { err = os.Remove(mutation.Path) @@ -160,6 +192,13 @@ func bindPreparedCapabilities(effect *preparedEffect, admission protocol.Admissi func (p *preparedEffect) Rollback(context.Context) error { var rollbackErrors []error + for index := len(p.appliedTreeRoots) - 1; index >= 0; index-- { + if err := os.RemoveAll(p.appliedTreeRoots[index]); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := syncDirectory(filepath.Dir(p.appliedTreeRoots[index])); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + } for index := len(p.applied) - 1; index >= 0; index-- { mutation := p.applied[index] if mutation.PriorLink != "" { @@ -175,8 +214,46 @@ func (p *preparedEffect) Rollback(context.Context) error { } } p.applied = nil + p.appliedTreeRoots = nil if p.boundarySettled { rollbackErrors = append(rollbackErrors, fmt.Errorf("external effect settled and requires reconciliation or compensation")) } return errors.Join(rollbackErrors...) } + +func atomicInstallTree(root string, mutations []ports.ResourceMutation) error { + if !filepath.IsAbs(root) || len(mutations) == 0 { + return fmt.Errorf("atomic tree requires an absolute root and members") + } + if _, err := os.Lstat(root); err == nil { + return fmt.Errorf("atomic tree target already exists") + } else if !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(root) + if err := os.MkdirAll(parent, 0o700); err != nil { + return err + } + stage, err := os.MkdirTemp(parent, ".boatstack-tree-*") + if err != nil { + return err + } + defer os.RemoveAll(stage) + for _, mutation := range mutations { + relative, relErr := filepath.Rel(root, mutation.Path) + if relErr != nil || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("atomic tree member escapes root") + } + target := filepath.Join(stage, relative) + if err := atomicWrite(target, mutation.Target, os.FileMode(mutation.Mode)); err != nil { + return err + } + } + if err := syncDirectory(stage); err != nil { + return err + } + if err := os.Rename(stage, root); err != nil { + return err + } + return syncDirectory(parent) +} diff --git a/boatstack/internal/softwaredelivery/effects/prepared_test.go b/boatstack/internal/softwaredelivery/effects/prepared_test.go index 693dcca..cd42022 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared_test.go +++ b/boatstack/internal/softwaredelivery/effects/prepared_test.go @@ -99,6 +99,55 @@ func TestPreparedEffectRefusesMissingKernelCapabilityBeforeAnyEffect(t *testing. } } +func TestPreparedEffectInstallsAndRecoversPlanningTreeAsOneResource(t *testing.T) { + // control-law: recovery removes only the immutable tree created by this transaction + root := filepath.Join(t.TempDir(), "package") + prepared := &preparedEffect{ + requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + mutations: []ports.ResourceMutation{ + {Path: filepath.Join(root, "manifest.json"), Target: []byte("manifest"), Mode: 0o644, AtomicTreeRoot: root, InstallLast: true}, + {Path: filepath.Join(root, "compiled", "tasks.json"), Target: []byte("tasks"), Mode: 0o644, AtomicTreeRoot: root}, + }, + } + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(filepath.Join(root, "compiled", "tasks.json")); err != nil || string(raw) != "tasks" { + t.Fatalf("installed tree member = %q, %v", raw, err) + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("transaction-created tree survived recovery: %v", err) + } +} + +func TestPreparedEffectPreservesConflictingPlanningTree(t *testing.T) { + // control-law: an existing fingerprint path is never replaced by staged installation + parent := t.TempDir() + root := filepath.Join(parent, "package") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + prior := filepath.Join(root, "manifest.json") + if err := os.WriteFile(prior, []byte("prior"), 0o644); err != nil { + t.Fatal(err) + } + prepared := &preparedEffect{ + requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + mutations: []ports.ResourceMutation{{Path: prior, Target: []byte("candidate"), Mode: 0o644, AtomicTreeRoot: root}}, + } + if _, err := prepared.Execute(context.Background()); err == nil { + t.Fatal("conflicting immutable tree was replaced") + } + if raw, err := os.ReadFile(prior); err != nil || string(raw) != "prior" { + t.Fatalf("conflicting tree changed: %q, %v", raw, err) + } +} + func TestPreparedEffectReportsOnlyActuallyAppliedEffects(t *testing.T) { root := t.TempDir() target := filepath.Join(root, "state.json") diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go index c08cbd3..d6d76bc 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -23,7 +23,7 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -const RecordSchemaVersion = 2 +const RecordSchemaVersion = 3 type Status string @@ -211,6 +211,7 @@ func (m Manager) Complete(ctx context.Context, invocation model.InvocationContex } evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: record.Request.ID, RequestFingerprint: record.Request.Fingerprint, + RunID: record.Request.RunID, ProgramID: record.Request.ProgramID, EntryID: record.Request.EntryID, ContractID: record.Request.Contract.ID, ContractFingerprint: record.Request.Contract.Fingerprint, TransitionID: record.Request.TransitionID, ProgramFingerprint: record.Request.ProgramFingerprint, ContextFingerprint: record.Request.ContextFingerprint, StateRevision: record.Request.StateRevision, RepositoryID: record.Request.RepositoryID, WorktreeID: record.Request.WorktreeID, Outputs: outputs, diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index c577ab1..d2ebf5e 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -16,6 +16,7 @@ import ( "strings" "time" + planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" @@ -502,24 +503,6 @@ type observedApproval struct { ApprovedAt time.Time `json:"approved_at"` } -type observedPlanningPackageOutput struct { - ID string `json:"id"` - Path string `json:"path"` - MediaType string `json:"media_type"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` -} - -type observedPlanningPackageManifest struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - WorkRequestFingerprint string `json:"work_request_fingerprint"` - WorkResultFingerprint string `json:"work_result_fingerprint"` - PlanFingerprint string `json:"plan_fingerprint"` - Outputs []observedPlanningPackageOutput `json:"outputs"` - Fingerprint string `json:"fingerprint"` -} - type observedGate struct { SchemaVersion int `json:"schema_version"` DeliveryID string `json:"delivery_id"` @@ -593,11 +576,17 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta if readErr != nil { return plan, verification, terminal, nil, nil, readErr } - var approval observedApproval - valid = valid && decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && - approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint && - approval.PackageFingerprint == state.PlanningPackageFingerprint && - approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() + 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() + } else { + var approval observedApproval + valid = valid && decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 && + approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint && approval.PackageFingerprint == "" && + approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() + } } if !valid { plan, terminal = model.PlanStale, model.TerminalStale @@ -680,9 +669,9 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } 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) + root := filepath.Join(layout.RepositoryRoot, ".boatstack", "planning-packages", state.Objective.DeliveryID, state.PlanningPackageFingerprint) manifestPath := filepath.Join(root, "manifest.json") - manifestEvidence, manifestFileFingerprint, exists, err := fileEvidence(manifestPath, "planning-package", now) + manifestEvidence, _, exists, err := fileEvidence(manifestPath, "planning-package", now) evidence := []model.Evidence{manifestEvidence} if err != nil || !exists { return evidence, false, err @@ -691,67 +680,20 @@ func observePlanningPackage(layout ports.ControllerLayout, state durable.State, if err != nil { return evidence, false, err } - var manifest observedPlanningPackageManifest - valid := decodeStrictJSON(manifestRaw, &manifest) == nil && manifest.SchemaVersion == 1 && - manifest.DeliveryID == state.Objective.DeliveryID && len(manifest.WorkRequestFingerprint) == 64 && len(manifest.WorkResultFingerprint) == 64 && - manifest.PlanFingerprint == state.PlanFingerprint && manifest.Fingerprint == state.PlanningPackageFingerprint && len(manifest.Outputs) > 0 - if valid { - identity := manifest - identity.Fingerprint = "" - identityRaw, encodeErr := json.MarshalIndent(identity, "", " ") - if encodeErr != nil { - return evidence, false, encodeErr - } - identityRaw = append(identityRaw, '\n') - valid = hashBytes(identityRaw) == manifest.Fingerprint && manifestFileFingerprint == hashBytes(manifestRaw) - } - planFound := false - seen, seenPaths := map[string]bool{}, map[string]bool{} - for _, output := range manifest.Outputs { - clean := filepath.Clean(filepath.FromSlash(output.Path)) - if output.ID == "" || output.Path == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || - filepath.ToSlash(clean) != output.Path || output.MediaType == "" || len(output.SHA256) != 64 || output.Size < 0 || seen[output.ID] || seenPaths[clean] { - valid = false - continue - } - seen[output.ID], seenPaths[clean] = true, true - outputPath := filepath.Join(root, clean) - outputEvidence, fingerprint, exists, outputErr := fileEvidence(outputPath, "planning-package-output-"+output.ID, now) - evidence = append(evidence, outputEvidence) - if outputErr != nil { - return evidence, false, outputErr - } - info, statErr := os.Lstat(outputPath) - if statErr != nil && !os.IsNotExist(statErr) { - return evidence, false, statErr - } - regular := statErr == nil && info.Mode().IsRegular() - sizeMatches := regular && info.Size() == output.Size - valid = valid && exists && regular && sizeMatches && fingerprint == output.SHA256 - if output.ID == "plan" { - planFound = true - valid = valid && output.SHA256 == manifest.PlanFingerprint - } - } - if !planFound { - valid = false + var manifest planningpackage.Manifest + if planningpackage.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 { - approvalPath := filepath.Join(root, "approval.json") - approvalEvidence, _, approvalExists, approvalErr := fileEvidence(approvalPath, "planning-package-approval", now) - evidence = append(evidence, approvalEvidence) - if approvalErr != nil { - return evidence, false, approvalErr - } - approvalRaw, readErr := os.ReadFile(approvalPath) - if readErr != nil && !os.IsNotExist(readErr) { - return evidence, false, readErr - } - var approval observedApproval - valid = valid && approvalExists && decodeStrictJSON(approvalRaw, &approval) == nil && approval.SchemaVersion == 1 && - approval.DeliveryID == state.Objective.DeliveryID && approval.PlanFingerprint == manifest.PlanFingerprint && approval.PackageFingerprint == manifest.Fingerprint && - approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero() && - state.ApprovalFingerprint == hashBytes(append(append([]byte(nil), manifestRaw...), approvalRaw...)) + valid = valid && result.Approval == planningpackage.Valid + if valid { + raw, _ := os.ReadFile(filepath.Join(root, "approval.json")) + var approval planningpackage.Approval + _ = planningpackage.StrictDecode(raw, &approval) + valid = approval.Fingerprint == state.ApprovalFingerprint + } } return evidence, valid, nil } diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index 3b5535b..b383f40 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + planningpackage "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery/planningpackage" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -25,52 +26,48 @@ func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t // control-law: an admitted package is verified from its exact manifest while the canonical approved plan remains absent repository := t.TempDir() deliveryID := "delivery" - root := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID) - if err := os.MkdirAll(root, 0o700); err != nil { - t.Fatal(err) - } plan := []byte("# Proposed plan\n") - feature := []byte("# Feature specification\n") planFingerprint := hashBytes(plan) - featureFingerprint := hashBytes(feature) - manifest := observedPlanningPackageManifest{ - SchemaVersion: 1, DeliveryID: deliveryID, WorkRequestFingerprint: strings.Repeat("a", 64), WorkResultFingerprint: strings.Repeat("b", 64), - PlanFingerprint: planFingerprint, Outputs: []observedPlanningPackageOutput{ - {ID: "plan", Path: "plan.md", MediaType: "text/markdown", SHA256: planFingerprint, Size: int64(len(plan))}, - {ID: "feature-spec", Path: "feature-spec.md", MediaType: "text/markdown", SHA256: featureFingerprint, Size: int64(len(feature))}, - }, - } - identityRaw, err := json.MarshalIndent(manifest, "", " ") + 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) if err != nil { t.Fatal(err) } - manifest.Fingerprint = hashBytes(append(identityRaw, '\n')) - manifestRaw, err := json.MarshalIndent(manifest, "", " ") + portableWork.Fingerprint = workFingerprint + _, contractRaw, err := planningpackage.SealContract(planningpackage.Contract{Work: portableWork, PlanOutput: output.ID}) if err != nil { t.Fatal(err) } - manifestRaw = append(manifestRaw, '\n') - if err := os.WriteFile(filepath.Join(root, "plan.md"), plan, 0o600); err != nil { + _, 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}}) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "feature-spec.md"), feature, 0o600); err != nil { + 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}}) + if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifestRaw, 0o600); err != nil { + 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, "plan.md": plan} { + if err := os.WriteFile(filepath.Join(root, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } state := durable.State{ Plan: model.PlanPackageValid, PlanFingerprint: planFingerprint, PlanningPackageFingerprint: 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()) - if err != nil || !valid || len(evidence) != 3 { + if err != nil || !valid || len(evidence) != 1 { t.Fatalf("planning package observation valid=%t evidence=%#v err=%v", valid, evidence, err) } if _, err := os.Stat(filepath.Join(repository, ".boatstack", "plans", deliveryID+".source")); !os.IsNotExist(err) { t.Fatalf("admission prematurely created a canonical plan: %v", err) } - if err := os.WriteFile(filepath.Join(root, "feature-spec.md"), []byte("tampered"), 0o600); err != nil { + 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 { diff --git a/boatstack/internal/softwaredelivery/ports/ports.go b/boatstack/internal/softwaredelivery/ports/ports.go index 5b7fd15..b562ee9 100644 --- a/boatstack/internal/softwaredelivery/ports/ports.go +++ b/boatstack/internal/softwaredelivery/ports/ports.go @@ -83,18 +83,19 @@ type EffectResult struct { } type ResourceMutation struct { - Resource string `json:"resource"` - Owner string `json:"owner"` - Path string `json:"path"` - Prior []byte `json:"prior,omitempty"` - Target []byte `json:"target,omitempty"` - PriorLink string `json:"prior_link,omitempty"` - TargetLink string `json:"target_link,omitempty"` - PriorExists bool `json:"prior_exists"` - Mode uint32 `json:"mode"` - InstallLast bool `json:"install_last,omitempty"` - Delete bool `json:"delete,omitempty"` - StateFacets []model.StateFacet `json:"state_facets,omitempty"` + Resource string `json:"resource"` + Owner string `json:"owner"` + Path string `json:"path"` + Prior []byte `json:"prior,omitempty"` + Target []byte `json:"target,omitempty"` + PriorLink string `json:"prior_link,omitempty"` + TargetLink string `json:"target_link,omitempty"` + PriorExists bool `json:"prior_exists"` + Mode uint32 `json:"mode"` + InstallLast bool `json:"install_last,omitempty"` + AtomicTreeRoot string `json:"atomic_tree_root,omitempty"` + Delete bool `json:"delete,omitempty"` + StateFacets []model.StateFacet `json:"state_facets,omitempty"` } type PreparedEffect interface { diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go index 339e271..a257c32 100644 --- a/boatstack/internal/softwaredelivery/protocol/work.go +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -47,6 +47,9 @@ type WorkEvidence struct { RequestID string `json:"request_id"` RequestFingerprint string `json:"request_fingerprint"` ResultFingerprint string `json:"result_fingerprint"` + RunID string `json:"run_id,omitempty"` + ProgramID string `json:"program_id,omitempty"` + EntryID string `json:"entry_id,omitempty"` ContractID string `json:"contract_id"` ContractFingerprint string `json:"contract_fingerprint"` TransitionID catalog.TransitionID `json:"transition_id"` diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 1dde105..0abcf29 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,128 +1 @@ -{ - "schema": "control-program", - "schema_revision": 6, - "program": { - "id": "incident-response", - "version": "1" - }, - "declarations": { - "capabilities": [ - "service.restart" - ], - "authorities": [ - "incident-commander" - ], - "effects": [ - "service.restart" - ], - "verifiers": [ - "healthcheck" - ] - }, - "facets": [ - { - "id": "incident", - "kind": "enum", - "values": [ - "open", - "mitigated" - ] - }, - { - "id": "service", - "kind": "enum", - "values": [ - "degraded", - "healthy" - ] - } - ], - "evidence": [ - { - "id": "healthcheck", - "subject": "service", - "kind": "observation" - } - ], - "work": [], - "operators": [ - { - "id": "restart", - "capabilities": [ - "service.restart" - ], - "authority": { - "any_of": [ - "incident-commander" - ] - }, - "effects": [ - "service.restart" - ], - "verifier": "healthcheck", - "recovery": "restart", - "execution_context": "preserve", - "state_effect": { - "kind": "assignments", - "assignments": [ - { - "facet": "incident", - "value": "mitigated" - } - ] - } - } - ], - "transitions": [ - { - "id": "restart", - "operator": "restart", - "guard": { - "fact": { - "facet": "incident", - "statuses": [ - "known" - ], - "values": [ - "open" - ] - } - }, - "target": { - "fact": { - "facet": "incident", - "statuses": [ - "known" - ], - "values": [ - "mitigated" - ] - } - }, - "priority": 10 - } - ], - "targets": [ - { - "id": "mitigated", - "predicate": { - "fact": { - "facet": "incident", - "statuses": [ - "known" - ], - "values": [ - "mitigated" - ] - } - } - } - ], - "entries": [ - { - "id": "respond", - "target": "mitigated", - "inputs": [] - } - ] -} +{"schema":"control-program","schema_revision":7,"program":{"id":"incident-response","version":"1"},"declarations":{"capabilities":["service.restart"],"authorities":["incident-commander"],"effects":["service.restart"],"verifiers":["healthcheck"]},"facets":[{"id":"incident","kind":"enum","values":["open","mitigated"]},{"id":"service","kind":"enum","values":["degraded","healthy"]}],"evidence":[{"id":"healthcheck","subject":"service","kind":"observation"}],"work":[],"operators":[{"id":"restart","capabilities":["service.restart"],"authority":{"any_of":["incident-commander"]},"effects":["service.restart"],"verifier":"healthcheck","recovery":"restart","execution_context":"preserve","state_effect":{"kind":"assignments","assignments":[{"facet":"incident","value":"mitigated"}]}}],"transitions":[{"id":"restart","operator":"restart","guard":{"fact":{"facet":"incident","statuses":["known"],"values":["open"]}},"target":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}},"priority":10}],"targets":[{"id":"mitigated","predicate":{"fact":{"facet":"incident","statuses":["known"],"values":["mitigated"]}}}],"entries":[{"id":"respond","target":"mitigated","inputs":[]}]} 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 fa77b88..11a776d 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 @@ -75,7 +75,9 @@ export default defineFlow({ evidence: softwareDeliveryEvidence, work: [planning], operators: trustedOperators(lifecycle), - transitions: trustedSoftwareDeliveryTransitions(lifecycle, { planningPackageWork: planning }), + transitions: trustedSoftwareDeliveryTransitions(lifecycle, { + planningPackage: { work: planning, planOutput: "plan" }, + }), targets: [ marked("published-pr", all( fact("verification", ["current"]), 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 57924d7..536bc54 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -67,7 +67,7 @@ export default defineFlow(softwareDelivery({ version: "1", humanIdentity: "developer", lifecycle: lifecycle, - planningPackageWork: planning, + 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 7931c5a..c85c34f 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":6,"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"},{"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":"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"}}]}]} diff --git a/docs/architecture/compiler-and-artifacts.md b/docs/architecture/compiler-and-artifacts.md index 9b9d8ce..1e13247 100644 --- a/docs/architecture/compiler-and-artifacts.md +++ b/docs/architecture/compiler-and-artifacts.md @@ -22,7 +22,7 @@ Flow TypeScript ## Control Program document The current document identifies `schema: "control-program"` and -`schema_revision: 6`. Its top-level sections are: +`schema_revision: 7`. Its top-level sections are: | Section | Purpose | | --- | --- | @@ -58,7 +58,7 @@ directories are never claimed wholesale. ## Artifact envelope The committed artifact identifies `schema: "control-program-artifact"` and -`schema_revision: 6`. It contains the compiler version; source and dependency +`schema_revision: 7`. It contains the compiler version; source and dependency lock paths and hashes; the program fingerprint; the canonical projection selection and its fingerprint; hashes of every generated projection; hashes of referenced work assets; and the compiled Control Program document. diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index 04e56c7..942a964 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,7 +6,7 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema is currently `schema_revision: 6`. It is +The `control-program` schema is currently `schema_revision: 7`. It is domain-neutral and declares typed facets, evidence relations, predicate ASTs, operators, capabilities, authority, effects, verification, recovery, bounded foreground work, @@ -86,7 +86,7 @@ execute `flow.ts`. The artifact filename comes from the declared program ID, not the source filename. The artifact binds the source hash, compiler version, dependency-lock hash, -foreground-work instruction and schema assets, trusted operator fingerprints, +foreground-work instruction, artifact-guidance, and schema assets, trusted operator fingerprints, canonical program fingerprint, and generated skill hashes. Unknown fields, duplicate declarations, invalid references, undeclared inline effects, missing recovery, binding drift, and generated-file diff --git a/docs/product-delivery/planning-and-foreground-work.md b/docs/product-delivery/planning-and-foreground-work.md index d1e4a87..ea6af7d 100644 --- a/docs/product-delivery/planning-and-foreground-work.md +++ b/docs/product-delivery/planning-and-foreground-work.md @@ -3,8 +3,12 @@ Planning is an optional repository-selected lifecycle, not a kernel primitive or a mandatory phase for every Flow. -A foreground-work contract declares immutable instructions, bounded inputs, -and typed output artifacts. The runtime materializes one request for the exact +A foreground-work contract declares immutable package instructions, bounded +inputs, and typed output artifacts with optional artifact-local `guidance`. +Both instruction layers are compiler-resolved UTF-8 assets whose exact bytes +and hashes participate in program and request identity. Guidance describes how +to generate one artifact; it is not authority, verification, or executable +repository code. The runtime materializes one request for the exact 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. @@ -12,13 +16,14 @@ create authority. The Product Delivery planning package uses three independent trusted operations when the repository includes them: -1. `planning.package.admit` verifies and stores the exact package manifest; -2. `planning.package.approve` records human approval bound to that manifest; +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. Repositories choose whether these operations belong to their lifecycle, their -priorities, and the target they serve. `planningPackageWork` is bound only to -the admit operation. Additional work must be explicitly registered and named +priorities, and the target they serve. `planningPackage.work` and its explicit +`planOutput` are bound only to admit. No output ID is implicitly special. +Additional work must be explicitly registered and named by every lifecycle step that consumes it. Missing actor-owned parameters create `TRANSITION_INPUT_REQUIRED` suspension. @@ -31,6 +36,23 @@ The selected transition consumes verified work output through its declared parameter producer. An answer or work receipt is evidence, not approval or delegated authority. +## Immutable packages and verification + +Admission writes `.boatstack/planning-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 \ + --package --require-approval --require-current-program --format json +``` + +The result reports integrity, contract, approval, and current-program status +separately. It always reports semantic correctness as `not-evaluated` and +origin authenticity as `not-proven`. + ## Related API - [`foregroundWork`, assets, and work outputs](../typescript/base-sdk.md#foreground-work) diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 3d8899c..70a9c5d 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -39,11 +39,12 @@ const planning = foregroundWork({ inputs: [entryInput("plan")], outputs: [ workArtifact({ - id: "plan", + id: "implementation-plan", path: "plan.md", media_type: "text/markdown", required: true, max_bytes: 262144, + guidance: instructionAsset(".boatstack/flows/assets/implementation-plan.md"), }), ], }); @@ -53,7 +54,10 @@ export default defineFlow(softwareDelivery({ version: "1", humanIdentity: "developer", lifecycle: lifecycle, - planningPackageWork: planning, + planningPackage: { + work: planning, + planOutput: "implementation-plan", + }, targets: [ marked( "published-pr", @@ -89,7 +93,7 @@ authority strengthening, and domain composition. | Input | Derived output | | --- | --- | | `lifecycle` | trusted operators and transitions, including explicit additional-work bindings | -| `planningPackageWork` | planning work registration and admit binding | +| `planningPackage` | planning work registration and explicit canonical plan-output binding | | `work` | additional work registration in caller order; every contract ID must be referenced by a lifecycle step's `work` field | | entry input `resolver` | `declarations.input_resolvers` | | software-delivery domain | canonical facets and evidence | diff --git a/docs/typescript/base-sdk.md b/docs/typescript/base-sdk.md index 40163ae..7beb981 100644 --- a/docs/typescript/base-sdk.md +++ b/docs/typescript/base-sdk.md @@ -23,6 +23,10 @@ requires one compatible producer for each required reachable parameter. `foregroundWork` declares bounded candidate work. `instructionAsset` and `schemaAsset` name repository assets that the compiler later resolves and fingerprints. `entryInput` binds an input; `workArtifact` declares an output. +An output may attach `guidance: instructionAsset(...)`; its exact UTF-8 bytes +are embedded in the runtime request and participate in the work and program +fingerprints. Package instructions coordinate all outputs, while guidance +describes only its artifact. Neither grants authority or verifies content. Work completion does not independently advance Flow state. ## Authority diff --git a/docs/typescript/software-delivery-sdk.md b/docs/typescript/software-delivery-sdk.md index c3a3f00..1547bc5 100644 --- a/docs/typescript/software-delivery-sdk.md +++ b/docs/typescript/software-delivery-sdk.md @@ -19,7 +19,9 @@ recovery semantics. `planningPackageAdmit`, `planningPackageApprove`, and `planningPackagePromote` are optional trusted steps. A repository includes them -explicitly. `planningPackageWork` binds foreground work only to admit. +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. ## Authority and delegation diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 06e490f..8865452 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -125,6 +125,46 @@ export const planningPackagePromote: TrustedStep = { priority: 45, }; +/** Repository-owned foreground-work contract and explicit canonical plan output. */ +export interface PlanningPackageDefinition { + work: WorkContract; + planOutput: string; +} + +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", + ); + } + 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 (path.length === 0 || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => part === "" || part === "." || part === "..")) { + throw new Error(`SOFTWARE_DELIVERY_OUTPUT_PATH_INVALID: ${JSON.stringify(output.path)}`); + } + for (const owned of reserved) { + if (path === owned || path.startsWith(`${owned}/`) || owned.startsWith(`${path}/`)) { + throw new Error(`SOFTWARE_DELIVERY_OUTPUT_PATH_RESERVED: ${JSON.stringify(output.path)}`); + } + } + for (const [prior, id] of paths) { + if (path === prior || path.startsWith(`${prior}/`) || prior.startsWith(`${path}/`)) { + throw new Error(`SOFTWARE_DELIVERY_OUTPUT_PATH_CONFLICT: ${JSON.stringify(id)} and ${JSON.stringify(output.id)}`); + } + } + paths.set(path, output.id); + } +} + /** * Repository-owned policy composed with canonical software-delivery wiring. * @@ -146,8 +186,8 @@ export interface SoftwareDeliveryFlowDefinition { description?: string; /** Explicit trusted lifecycle membership and repository-selected priorities. */ lifecycle: TrustedStep[]; - /** Foreground work bound specifically to `planning.package.admit`. */ - planningPackageWork?: WorkContract; + /** Foreground work and explicit canonical plan output bound to admission. */ + planningPackage?: PlanningPackageDefinition; /** Additional explicit work contracts, in repository-selected order. */ work?: WorkContract[]; /** Explicit repository completion predicates. */ @@ -189,8 +229,8 @@ function validateSoftwareDeliveryDefinition( } const workIDs = new Set(); - const work = definition.planningPackageWork - ? [definition.planningPackageWork, ...(definition.work ?? [])] + const work = definition.planningPackage + ? [definition.planningPackage.work, ...(definition.work ?? [])] : definition.work ?? []; for (const contract of work) { if (workIDs.has(contract.id)) { @@ -202,13 +242,21 @@ function validateSoftwareDeliveryDefinition( } if ( - definition.planningPackageWork && + definition.planningPackage && !lifecycleIDs.has(planningPackageAdmit.id) ) { throw new Error( - "SOFTWARE_DELIVERY_PLANNING_WORK_UNUSED: planningPackageWork requires exactly one planning.package.admit lifecycle step", + "SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.admit in lifecycle", + ); + } + if (!definition.planningPackage && lifecycleIDs.has(planningPackageAdmit.id)) { + throw new Error( + "SOFTWARE_DELIVERY_PLANNING_PACKAGE_REQUIRED: planning.package.admit requires planningPackage", ); } + if (definition.planningPackage) { + validatePlanningPackage(definition.planningPackage); + } const additionalWorkIDs = new Set( (definition.work ?? []).map((contract) => contract.id), @@ -224,12 +272,12 @@ function validateSoftwareDeliveryDefinition( ); } if ( - definition.planningPackageWork && + definition.planningPackage && (step.id === planningPackageAdmit.id || - step.work === definition.planningPackageWork.id) + step.work === definition.planningPackage.work.id) ) { throw new Error( - `SOFTWARE_DELIVERY_WORK_CONFLICT: ${JSON.stringify(step.id)} cannot replace or repeat planningPackageWork`, + `SOFTWARE_DELIVERY_WORK_CONFLICT: ${JSON.stringify(step.id)} cannot replace or repeat planningPackage.work`, ); } if (!additionalWorkIDs.has(step.work)) { @@ -278,11 +326,11 @@ export function softwareDelivery( validateSoftwareDeliveryDefinition(definition); const inputResolvers = referencedInputResolvers(definition.entries); - const work = definition.planningPackageWork - ? [definition.planningPackageWork, ...(definition.work ?? [])] + const work = definition.planningPackage + ? [definition.planningPackage.work, ...(definition.work ?? [])] : [...(definition.work ?? [])]; const transitions = trustedSoftwareDeliveryTransitions(definition.lifecycle, { - planningPackageWork: definition.planningPackageWork, + planningPackage: definition.planningPackage, }).map((transitionDefinition, index) => { const workID = definition.lifecycle[index].work; return workID === undefined @@ -616,15 +664,36 @@ export function trustedSoftwareDeliveryTransition( */ export function trustedSoftwareDeliveryTransitions( steps: TrustedStep[], - options: { planningPackageWork?: WorkContract } = {}, + options: { planningPackage?: PlanningPackageDefinition } = {}, ): TransitionDefinition[] { - return steps.map((step) => - trustedSoftwareDeliveryTransition(step, { - ...(step.id === planningPackageAdmit.id && options.planningPackageWork - ? { work: options.planningPackageWork } - : {}), - }), - ); + 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 (!hasAdmit && options.planningPackage) { + throw new Error("SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED: planningPackage requires planning.package.admit"); + } + if (options.planningPackage) validatePlanningPackage(options.planningPackage); + return steps.map((step) => { + const planning = step.id === planningPackageAdmit.id ? options.planningPackage : undefined; + const result = trustedSoftwareDeliveryTransition(step, { + ...(planning ? { work: planning.work } : {}), + }); + if (!planning) return result; + return { + ...result, + parameters: [ + ...(result.parameters ?? []), + { + parameter: "plan_output", + producer: trustedParameterResolver( + `${bindingPrefix}planning-package-plan-output/${planning.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 510d38c..13c0dd3 100644 --- a/packages/boatstack-software-delivery/test/software-delivery.test.mjs +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -18,7 +18,7 @@ const work = (id) => ({ id, instructions: { path: `${id}.md` }, inputs: [], - outputs: [], + outputs: [{ id: "implementation-plan", path: `${id}.out.md`, media_type: "text/markdown", required: true }], }); function definition(overrides = {}) { @@ -77,7 +77,7 @@ test("registers planning work exactly once before additional work", () => { { id: "plan.activate", priority: 50, work: "implementation" }, { id: "plan.abandon", priority: 31, work: "review" }, ], - planningPackageWork: planning, + planningPackage: { work: planning, planOutput: "implementation-plan" }, work: additional, }), ); @@ -125,12 +125,27 @@ test("planning work alone does not infer an input resolver", () => { const result = softwareDelivery( definition({ lifecycle: [planningPackageAdmit], - planningPackageWork: work("planning-package"), + planningPackage: { work: work("planning-package"), planOutput: "implementation-plan" }, }), ); assert.equal(result.declarations, undefined); }); +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" } } }]); +}); + +for (const [name, planning, error] of [ + ["unknown output", { work: work("planning"), planOutput: "missing" }, /SOFTWARE_DELIVERY_PLAN_OUTPUT_REQUIRED/], + ["invalid output ID", { work: work("planning"), planOutput: "Implementation Plan" }, /SOFTWARE_DELIVERY_PLAN_OUTPUT_INVALID/], + ["reserved path", { work: { ...work("planning"), outputs: [{ id: "implementation-plan", path: "Manifest.JSON", media_type: "text/markdown", required: true }] }, planOutput: "implementation-plan" }, /SOFTWARE_DELIVERY_OUTPUT_PATH_RESERVED/], + ["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)); +} + for (const [name, lifecycle, prefix] of [ ["blank lifecycle ID", [{ id: " ", priority: 1 }], "SOFTWARE_DELIVERY_LIFECYCLE_EMPTY"], [ @@ -160,7 +175,7 @@ test("rejects duplicate work IDs including repeated planning work", () => { softwareDelivery( definition({ lifecycle: [planningPackageAdmit], - planningPackageWork: planning, + planningPackage: { work: planning, planOutput: "implementation-plan" }, work: [planning], }), ), @@ -181,9 +196,9 @@ test("rejects planning work without its admit step", () => { assert.throws( () => softwareDelivery( - definition({ planningPackageWork: work("planning-package") }), + definition({ planningPackage: { work: work("planning-package"), planOutput: "implementation-plan" } }), ), - /SOFTWARE_DELIVERY_PLANNING_WORK_UNUSED/, + /SOFTWARE_DELIVERY_PLANNING_PACKAGE_UNUSED/, ); }); @@ -231,7 +246,7 @@ test("rejects replacing or repeating the planning work binding", () => { softwareDelivery( definition({ lifecycle, - planningPackageWork: planning, + planningPackage: { work: planning, planOutput: "implementation-plan" }, work: [work("implementation")], }), ), diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 34f3c04..1ed99d6 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -16,7 +16,7 @@ /** Canonical schema name emitted by {@link defineFlow}. */ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; /** Current revision of the canonical Control Program schema. */ -export const CONTROL_PROGRAM_SCHEMA_REVISION = 6 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 7 as const; /** * A declarative condition over runtime state facts. @@ -216,6 +216,8 @@ export interface WorkArtifactDefinition { media_type: string; required: boolean; max_bytes?: number; + /** Artifact-local, compiler-bound generation guidance. */ + guidance?: WorkAssetDefinition; schema?: WorkAssetDefinition; } @@ -412,7 +414,11 @@ export function entryInput(id: string): WorkInputDefinition { export function workArtifact( definition: WorkArtifactDefinition, ): WorkArtifactDefinition { - return { ...definition }; + return { + ...definition, + ...(definition.guidance ? { guidance: { ...definition.guidance } } : {}), + ...(definition.schema ? { schema: { ...definition.schema } } : {}), + }; } /** diff --git a/release-notes/2026-08-18-planning-package-contract-v2.md b/release-notes/2026-08-18-planning-package-contract-v2.md new file mode 100644 index 0000000..5922174 --- /dev/null +++ b/release-notes/2026-08-18-planning-package-contract-v2.md @@ -0,0 +1,2 @@ +### Planning-package contract v2 +Repositories can attach content-bound guidance to individual foreground-work outputs and explicitly designate the canonical plan output. Planning packages are now immutable fingerprint-addressed snapshots with embedded contracts and portable work receipts, exact one-time approvals, and an offline verification command. This is a breaking alpha change: repositories must adopt the v2 `planningPackage` definition and regenerate prior planning-package state. From b8c8cc67746ce80ac1bc85be4cad4f7eb88a4793 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 04:25:12 +0100 Subject: [PATCH 02/10] Verify planning packages against composed program --- .../boatstack-helper/planning_package_command.go | 11 ++++++++++- .../product_delivery_flow_e2e_test.go | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/boatstack/cmd/boatstack-helper/planning_package_command.go b/boatstack/cmd/boatstack-helper/planning_package_command.go index 3bba11f..0bc4ed5 100644 --- a/boatstack/cmd/boatstack-helper/planning_package_command.go +++ b/boatstack/cmd/boatstack-helper/planning_package_command.go @@ -11,6 +11,7 @@ import ( "strings" "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" ) @@ -119,6 +120,14 @@ func loadCurrentPlanningProgram(ctx context.Context, repository string) (plannin if err != nil { return planningpackage.CurrentProgram{}, err } + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + return planningpackage.CurrentProgram{}, err + } + program, err := distribution.ProgramForRepository(ctx, distribution.RepositoryProgramRequest{Repository: repository}, definition) + if err != nil { + return planningpackage.CurrentProgram{}, err + } workByID := map[string]controlprogram.WorkContract{} for _, work := range compiled.Document.Work { workByID[work.ID] = work @@ -144,7 +153,7 @@ func loadCurrentPlanningProgram(ctx context.Context, repository string) (plannin if planOutput == "" { return planningpackage.CurrentProgram{}, fmt.Errorf("current planning-package plan output is missing") } - return planningpackage.CurrentProgram{ProgramFingerprint: compiled.Fingerprint, WorkContractFingerprint: runtime.Fingerprint, PlanOutput: planOutput}, nil + return planningpackage.CurrentProgram{ProgramFingerprint: program.Fingerprint(), WorkContractFingerprint: runtime.Fingerprint, PlanOutput: planOutput}, nil } return planningpackage.CurrentProgram{}, fmt.Errorf("current Flow has no planning-package admission") } 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 f28c0e4..58fa9e4 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -18,6 +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/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -351,6 +352,19 @@ 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") + 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) + 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 { + t.Fatalf("portable planning package verification = %#v", packageVerification) + } 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) From f15df465c4164ab271d227442a84bdd90f196355 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 05:01:10 +0100 Subject: [PATCH 03/10] Make package path validation portable --- .../planningpackage/package.go | 10 ++++++-- .../planningpackage/package_test.go | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go index 75108e1..ea6831f 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "os" + pathpkg "path" "path/filepath" "regexp" "runtime" @@ -294,10 +295,15 @@ func ValidateOutputPaths(outputs []WorkOutput) error { } func safeRelative(value string) bool { - if value == "" || filepath.IsAbs(value) || filepath.Clean(value) != value || value == "." { + if value == "" || strings.Contains(value, `\`) || pathpkg.IsAbs(value) || hasWindowsVolumePrefix(value) { return false } - return value != ".." && !strings.HasPrefix(value, ".."+string(filepath.Separator)) && !strings.Contains(value, `\`) + clean := pathpkg.Clean(value) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") && clean == value +} + +func hasWindowsVolumePrefix(value string) bool { + return len(value) >= 2 && value[1] == ':' && ((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= 'A' && value[0] <= 'Z')) } func Verify(repository, deliveryID, packageFingerprint string, current *CurrentProgram) Result { diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/planningpackage/package_test.go index 21d2659..e3079fa 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package_test.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package_test.go @@ -57,6 +57,29 @@ func TestVerifySeparatesIntegrityApprovalAndCurrentProgram(t *testing.T) { } } +func TestSafeRelativeUsesPortableContractPathSemantics(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {value: "compiled/evidence.md", want: true}, + {value: "/absolute", want: false}, + {value: "../escape", want: false}, + {value: "a/../escape", want: false}, + {value: `a\escape`, want: false}, + {value: "C:/escape", want: false}, + {value: "C:escape", want: false}, + {value: "a//escape", want: false}, + } + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + if got := safeRelative(test.value); got != test.want { + t.Fatalf("safeRelative(%q) = %v, want %v", test.value, got, test.want) + } + }) + } +} + func TestVerifyRejectsIndependentTampering(t *testing.T) { cases := []string{"manifest.json", "contract.json", "work-receipt.json", "plan.md"} for _, name := range cases { From 18216f9ef69c5d65782e090489fbad930f2f3ac9 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 05:32:44 +0100 Subject: [PATCH 04/10] Harden planning package assurance boundaries --- .../boatstack-helper/delegation_runtime.go | 1 + boatstack/cmd/boatstack-helper/main.go | 1 + .../planningpackage/package.go | 23 +++-- .../planningpackage/package_test.go | 90 +++++++++++++++++++ .../softwaredelivery/effects/artifacts.go | 54 ++++++++--- .../effects/planning_package_test.go | 29 +++++- .../softwaredelivery/effects/prepared.go | 46 ++++++++++ .../softwaredelivery/effects/prepared_test.go | 37 ++++++++ .../softwaredelivery/effects/recovery.go | 6 ++ .../softwaredelivery/protocol/authority.go | 26 ++++-- 10 files changed, 287 insertions(+), 26 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index bac087d..d8fbe0c 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -210,6 +210,7 @@ func prepareFlowAuthorization(ctx context.Context, request *surfaces.Request) (p request.Authority.Receipts = append(request.Authority.Receipts, protocol.AuthorityReceipt{ ID: "delegation-" + hex.EncodeToString(receiptDigest[:8]), Class: authority, Subject: record.Actor, Fingerprint: record.RequestFingerprint, IssuedAt: record.AuthorizedAt, ExpiresAt: record.ExpiresAt, + IdentityRole: record.ActorIdentityRole, IdentityProviderFingerprint: record.ActorIdentityProviderFingerprint, }) } return lock, nil, nil diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 5817a35..5d5a02c 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -920,6 +920,7 @@ func loadAuthority(options commandOptions, correlation string, objective model.O fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor, options.humanIdentityRole, options.humanIdentityProviderFingerprint, string(parameterRaw)}, "\x00"))) bundle.Receipts = append(bundle.Receipts, protocol.AuthorityReceipt{ ID: "human-" + fingerprint[:16], Class: catalog.AuthorityHuman, Subject: options.humanActor, Fingerprint: fingerprint, + IdentityRole: options.humanIdentityRole, IdentityProviderFingerprint: options.humanIdentityProviderFingerprint, IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute), }) } diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go index ea6831f..801fb18 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -149,7 +149,7 @@ type Approval struct { AuthoritySources []AuthoritySource `json:"authority_sources"` Actor string `json:"actor"` IdentityRole string `json:"identity_role"` - IdentityProviderFingerprint string `json:"identity_provider_fingerprint"` + IdentityProviderFingerprint string `json:"identity_provider_fingerprint,omitempty"` ApprovedAt time.Time `json:"approved_at"` Fingerprint string `json:"fingerprint"` } @@ -355,7 +355,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.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 || contract.PlanOutput != manifest.PlanOutput.ID { return fail("contract identity is invalid") } if err = validateContractAssets(contract.Work); err != nil { @@ -398,7 +398,8 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP identity := approval identity.Fingerprint = "" raw, _ := Encode(identity) - if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(raw) || 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.IdentityRole == "" || !ValidFingerprint(approval.IdentityProviderFingerprint) || len(approval.AuthoritySources) == 0 || approval.ApprovedAt.IsZero() { + identityPresent := approval.IdentityRole != "" || approval.IdentityProviderFingerprint != "" + if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(raw) || approval.DeliveryID != deliveryID || approval.PackageFingerprint != packageFingerprint || approval.ManifestFingerprint != manifest.Fingerprint || approval.PlanOutputID != manifest.PlanOutput.ID || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || identityPresent && (approval.IdentityRole == "" || !ValidFingerprint(approval.IdentityProviderFingerprint)) || len(approval.AuthoritySources) == 0 || approval.ApprovedAt.IsZero() { result.Approval = Invalid return fail("approval identity is invalid") } @@ -458,13 +459,22 @@ func outputsSorted(outputs []Output) bool { func authoritySourcesSorted(sources []AuthoritySource) bool { for index, source := range sources { - if source.ID == "" || source.Class == "" || source.Subject == "" || !ValidFingerprint(source.Fingerprint) || index > 0 && sources[index-1].ID >= source.ID { + if source.ID == "" || !validAuthorityClass(source.Class) || source.Subject == "" || source.Fingerprint == "" || index > 0 && sources[index-1].ID >= source.ID { return false } } return true } +func validAuthorityClass(value string) bool { + switch value { + case "repository-policy", "human", "autonomy", "external-provider": + return true + default: + return false + } +} + func readRegular(path string) ([]byte, error) { info, err := os.Lstat(path) if err != nil { @@ -680,7 +690,10 @@ func Enumerate(repository string) ([][2]string, error) { if !delivery.IsDir() || !ValidSegment(delivery.Name()) { continue } - packages, _ := os.ReadDir(filepath.Join(root, delivery.Name())) + 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) + } for _, pkg := range packages { if pkg.IsDir() && ValidFingerprint(pkg.Name()) { result = append(result, [2]string{delivery.Name(), pkg.Name()}) diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/planningpackage/package_test.go index e3079fa..4b66e3f 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package_test.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package_test.go @@ -3,8 +3,10 @@ package planningpackage import ( "os" "path/filepath" + "runtime" "strings" "testing" + "time" ) func installFixture(t *testing.T) (string, string, string, string) { @@ -45,6 +47,94 @@ func installFixture(t *testing.T) (string, string, string, string) { return repository, delivery, manifest.Fingerprint, workFP } +func TestVerifyRejectsContradictoryEmbeddedWorkContractIdentity(t *testing.T) { + repository, delivery, fingerprint, _ := installFixture(t) + oldRoot := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint) + contractRaw, _ := os.ReadFile(filepath.Join(oldRoot, "contract.json")) + var contract Contract + if err := StrictDecode(contractRaw, &contract); err != nil { + t.Fatal(err) + } + contract.Work.ID = "embedded-planning" + contract.Work.Fingerprint, _ = RuntimeWorkFingerprint(contract.Work) + _, contractRaw, _ = SealContract(contract) + receiptRaw, _ := os.ReadFile(filepath.Join(oldRoot, "work-receipt.json")) + var receipt WorkReceipt + if err := StrictDecode(receiptRaw, &receipt); err != nil { + t.Fatal(err) + } + receipt.ContractID = "manifest-planning" + receipt.ContractFingerprint = contract.Work.Fingerprint + _, receiptRaw, _ = SealWorkReceipt(receipt) + manifestRaw, _ := os.ReadFile(filepath.Join(oldRoot, "manifest.json")) + var manifest Manifest + if err := StrictDecode(manifestRaw, &manifest); err != nil { + t.Fatal(err) + } + manifest.WorkContractID = receipt.ContractID + manifest.WorkContractFingerprint = contract.Work.Fingerprint + manifest.Contract.SHA256 = Digest(contractRaw) + manifest.WorkReceipt.SHA256 = Digest(receiptRaw) + manifest, manifestRaw, _ = SealManifest(manifest) + newRoot := filepath.Join(filepath.Dir(oldRoot), manifest.Fingerprint) + if err := os.Rename(oldRoot, newRoot); err != nil { + t.Fatal(err) + } + for name, raw := range map[string][]byte{"contract.json": contractRaw, "work-receipt.json": receiptRaw, "manifest.json": manifestRaw} { + if err := os.WriteFile(filepath.Join(newRoot, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } + result := Verify(repository, delivery, manifest.Fingerprint, nil) + if result.Integrity != Invalid || result.Contract != Invalid { + t.Fatalf("contradictory identity result=%#v", result) + } +} + +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", 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) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Valid || result.Integrity != Valid { + t.Fatalf("opaque authority fingerprint result=%#v", result) + } + approval.AuthoritySources[0].Class = "unknown" + _, raw, _ = SealApproval(approval) + if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Invalid { + t.Fatalf("unknown authority class result=%#v", result) + } +} + +func TestEnumeratePropagatesDeliveryReadFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission mode is Unix-specific") + } + repository := t.TempDir() + delivery := filepath.Join(repository, ".boatstack", "planning-packages", "proof") + if err := os.MkdirAll(delivery, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(delivery, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(delivery, 0o700) }) + if _, err := Enumerate(repository); err == nil { + t.Fatal("unreadable delivery directory was silently skipped") + } +} + 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"}) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index f28e1bc..3edb432 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -225,12 +225,12 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio 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}) } - if len(admission.Authority.Receipts) > 0 { - approval.IdentityRole = string(admission.Authority.Receipts[0].Class) - if len(humanIdentityRole) > 0 && humanIdentityRole[0] != "" { - approval.IdentityRole = humanIdentityRole[0] + for _, receipt := range admission.Authority.Receipts { + if receipt.Subject == approval.Actor && receipt.IdentityRole != "" && receipt.IdentityProviderFingerprint != "" { + approval.IdentityRole = receipt.IdentityRole + approval.IdentityProviderFingerprint = receipt.IdentityProviderFingerprint + break } - approval.IdentityProviderFingerprint = admission.Authority.Receipts[0].Fingerprint } approval, raw, encodeErr := planningpackage.SealApproval(approval) if encodeErr != nil { @@ -246,13 +246,30 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, approval.Fingerprint case "planning.package.promote": - manifest, loadErr := loadPlanningPackageManifestV2(artifactRoot, deliveryID, state.PlanningPackageFingerprint) + packagePath := filepath.Join(artifactRoot, "planning-packages", deliveryID, state.PlanningPackageFingerprint) + packageRoot, openErr := os.OpenRoot(packagePath) + if openErr != nil { + return nil, fmt.Errorf("open immutable planning 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, "; ")) + } + 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") + } + manifestRaw, loadErr := readRegularPlanningMember(packageRoot, "manifest.json") if loadErr != nil { return nil, loadErr } - packageRoot := filepath.Join(artifactRoot, "planning-packages", deliveryID, manifest.Fingerprint) - approvalPath := filepath.Join(packageRoot, "approval.json") - approvalRaw, readErr := os.ReadFile(approvalPath) + var manifest planningpackage.Manifest + if decodeErr := planningpackage.StrictDecode(manifestRaw, &manifest); decodeErr != nil || manifest.Fingerprint != state.PlanningPackageFingerprint { + return nil, fmt.Errorf("pinned planning package manifest does not bind durable state") + } + approvalRaw, readErr := readRegularPlanningMember(packageRoot, "approval.json") if readErr != nil { return nil, fmt.Errorf("read planning package approval: %w", readErr) } @@ -260,11 +277,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil || approval.SchemaVersion != planningpackage.ApprovalSchemaVersion || approval.DeliveryID != deliveryID || approval.PackageFingerprint != manifest.Fingerprint || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { return nil, fmt.Errorf("planning package approval does not bind the exact package") } - planArtifact, pathErr := planningPackageOutputPath(packageRoot, manifest.PlanOutput.Path) - if pathErr != nil { - return nil, pathErr - } - planRaw, readErr := readRegularWorkspacePlanArtifact(planArtifact) + planRaw, readErr := readRegularPlanningMember(packageRoot, manifest.PlanOutput.Path) if readErr != nil || sha256Bytes(planRaw) != manifest.PlanOutput.SHA256 { return nil, fmt.Errorf("planning package plan changed after approval") } @@ -450,6 +463,19 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return mutations, nil } +var verifyPlanningPackage = planningpackage.Verify + +func readRegularPlanningMember(root *os.Root, name string) ([]byte, error) { + info, err := root.Lstat(filepath.FromSlash(name)) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("planning package member is not regular: %s", name) + } + return root.ReadFile(filepath.FromSlash(name)) +} + // prepareWorkspacePlanTransfer carries runtime-owned plan artifacts into a // newly cut worktree. A run binds the plan bytes before the cut, so the target // worktree must observe those exact bytes rather than fall back to the inbox diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index d000959..5621ff6 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -68,7 +68,8 @@ 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"}} - 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{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer", Fingerprint: strings.Repeat("f", 64), IssuedAt: now}}}} + 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{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer", Fingerprint: "human-proof", IdentityRole: "developer", IdentityProviderFingerprint: providerFingerprint, IssuedAt: now}}}} if err := applyStateTransition(&state, approvalAdmission, approve); err != nil { t.Fatal(err) } @@ -81,12 +82,38 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if result.Approval != planningpackage.Valid { t.Fatalf("approval verification=%#v", result) } + var recordedApproval planningpackage.Approval + if err := planningpackage.StrictDecode(approvalRawAt(t, packageRoot), &recordedApproval); err != nil || 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)} if err := applyStateTransition(&state, promotion, promote); err != nil { t.Fatal(err) } + originalVerify := verifyPlanningPackage + swappedRoot := packageRoot + ".swapped" + verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + result := originalVerify(repository, deliveryID, packageFingerprint, current) + if err := os.Rename(packageRoot, swappedRoot); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(packageRoot, 0o700); err != nil { + t.Fatal(err) + } + return result + } + if _, err := prepareArtifacts(layout, promotion, promote, &state); err == nil || !strings.Contains(err.Error(), "changed during verification") { + t.Fatalf("package replacement was not rejected: %v", err) + } + verifyPlanningPackage = originalVerify + if err := os.Remove(packageRoot); err != nil { + t.Fatal(err) + } + if err := os.Rename(swappedRoot, packageRoot); err != nil { + t.Fatal(err) + } mutations, err = prepareArtifacts(layout, promotion, promote, &state) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index a87fe60..c6620ee 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -25,6 +25,7 @@ type preparedEffect struct { verifyInvocation *model.InvocationContext applied []ports.ResourceMutation appliedTreeRoots []string + removedTreeGroups []atomicTreeRemoval boundarySettled bool effectResult ports.EffectResult transition catalog.Transition @@ -33,6 +34,11 @@ type preparedEffect struct { changedStateFacets []model.StateFacet } +type atomicTreeRemoval struct { + root string + mutations []ports.ResourceMutation +} + func (p *preparedEffect) ChangedStateFacets() []model.StateFacet { return append([]model.StateFacet(nil), p.changedStateFacets...) } @@ -142,6 +148,27 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error sort.Strings(treeRoots) for _, root := range treeRoots { group := treeGroups[root] + allDelete := true + for _, mutation := range group { + allDelete = allDelete && mutation.Delete && mutation.TargetLink == "" && len(mutation.Target) == 0 + } + if allDelete { + for _, mutation := range group { + relative, err := filepath.Rel(root, mutation.Path) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return result, fmt.Errorf("atomic tree deletion contains member outside root: %s", mutation.Path) + } + } + if err := os.RemoveAll(root); err != nil { + return result, fmt.Errorf("remove immutable resource tree %s: %w", root, err) + } + if err := syncDirectory(filepath.Dir(root)); err != nil { + return result, fmt.Errorf("sync immutable resource tree parent %s: %w", root, err) + } + p.applied = append(p.applied, group...) + p.removedTreeGroups = append(p.removedTreeGroups, atomicTreeRemoval{root: root, mutations: group}) + continue + } allExisting := true for _, mutation := range group { allExisting = allExisting && mutation.PriorExists @@ -201,6 +228,9 @@ func (p *preparedEffect) Rollback(context.Context) error { } for index := len(p.applied) - 1; index >= 0; index-- { mutation := p.applied[index] + if mutation.AtomicTreeRoot != "" { + continue + } if mutation.PriorLink != "" { if err := atomicSymlink(mutation.Path, mutation.PriorLink); err != nil { rollbackErrors = append(rollbackErrors, err) @@ -213,8 +243,24 @@ func (p *preparedEffect) Rollback(context.Context) error { rollbackErrors = append(rollbackErrors, err) } } + for index := len(p.removedTreeGroups) - 1; index >= 0; index-- { + removed := p.removedTreeGroups[index] + restore := make([]ports.ResourceMutation, 0, len(removed.mutations)) + for _, mutation := range removed.mutations { + if !mutation.PriorExists { + continue + } + restore = append(restore, ports.ResourceMutation{Path: mutation.Path, Target: mutation.Prior, TargetLink: mutation.PriorLink, Mode: mutation.Mode, InstallLast: mutation.InstallLast, AtomicTreeRoot: removed.root}) + } + if len(restore) > 0 { + if err := atomicInstallTree(removed.root, restore); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + } + } p.applied = nil p.appliedTreeRoots = nil + p.removedTreeGroups = nil if p.boundarySettled { rollbackErrors = append(rollbackErrors, fmt.Errorf("external effect settled and requires reconciliation or compensation")) } diff --git a/boatstack/internal/softwaredelivery/effects/prepared_test.go b/boatstack/internal/softwaredelivery/effects/prepared_test.go index cd42022..ba5bcaa 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared_test.go +++ b/boatstack/internal/softwaredelivery/effects/prepared_test.go @@ -124,6 +124,43 @@ func TestPreparedEffectInstallsAndRecoversPlanningTreeAsOneResource(t *testing.T } } +func TestPreparedEffectRestartRollbackRemovesAndCanRestoreAtomicTree(t *testing.T) { + root := filepath.Join(t.TempDir(), "package") + manifest := filepath.Join(root, "manifest.json") + output := filepath.Join(root, "compiled", "tasks.json") + if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("manifest"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("tasks"), 0o644); err != nil { + t.Fatal(err) + } + manifestDelete, err := mutationForExactResource(manifest, nil, "", 0o644, true, true) + if err != nil { + t.Fatal(err) + } + outputDelete, err := mutationForExactResource(output, nil, "", 0o644, false, true) + if err != nil { + t.Fatal(err) + } + manifestDelete.AtomicTreeRoot, outputDelete.AtomicTreeRoot = root, root + prepared := &preparedEffect{requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, mutations: []ports.ResourceMutation{manifestDelete, outputDelete}} + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("restart rollback left atomic tree: %v", err) + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(output); err != nil || string(raw) != "tasks" { + t.Fatalf("failed recovery rollback did not restore prior tree: %q, %v", raw, err) + } +} + func TestPreparedEffectPreservesConflictingPlanningTree(t *testing.T) { // control-law: an existing fingerprint path is never replaced by staged installation parent := t.TempDir() diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index 987a55b..1e1a34c 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -38,6 +38,11 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll if err := validateRecoveryPath(layout, record.Admission, original.Path); err != nil { return nil, err } + if original.AtomicTreeRoot != "" { + if err := validateRecoveryPath(layout, record.Admission, original.AtomicTreeRoot); err != nil { + return nil, err + } + } var target []byte var targetLink string deleteResource := false @@ -56,6 +61,7 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll if mutationErr != nil { return nil, mutationErr } + mutation.AtomicTreeRoot = original.AtomicTreeRoot mutations = append(mutations, mutation) } mutations, err = d.advanceRecoveredState(layout, admission, transition.ID, mutations) diff --git a/boatstack/internal/softwaredelivery/protocol/authority.go b/boatstack/internal/softwaredelivery/protocol/authority.go index 1c768f0..c0ab9e5 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority.go +++ b/boatstack/internal/softwaredelivery/protocol/authority.go @@ -9,16 +9,19 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) type AuthorityReceipt struct { - ID string `json:"id"` - Class catalog.AuthorityClass `json:"class"` - Subject string `json:"subject"` - Fingerprint string `json:"fingerprint"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at,omitempty"` + ID string `json:"id"` + Class catalog.AuthorityClass `json:"class"` + Subject string `json:"subject"` + Fingerprint string `json:"fingerprint"` + IdentityRole string `json:"identity_role,omitempty"` + IdentityProviderFingerprint string `json:"identity_provider_fingerprint,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` } func (r AuthorityReceipt) Validate(now time.Time) error { @@ -28,6 +31,17 @@ func (r AuthorityReceipt) Validate(now time.Time) error { if !r.Class.Valid() || r.Class == catalog.AuthorityNone { return fmt.Errorf("authority receipt has invalid class %q", r.Class) } + if (r.IdentityRole == "") != (r.IdentityProviderFingerprint == "") { + return fmt.Errorf("authority receipt has incomplete identity provenance") + } + if r.IdentityRole != "" { + if err := humanidentity.ValidateRole(r.IdentityRole); err != nil || len(r.IdentityProviderFingerprint) != 64 { + return fmt.Errorf("authority receipt has invalid identity provenance") + } + if _, err := hex.DecodeString(r.IdentityProviderFingerprint); err != nil { + return fmt.Errorf("authority receipt has invalid identity provenance") + } + } if !r.ExpiresAt.IsZero() && !now.Before(r.ExpiresAt) { return fmt.Errorf("authority receipt %q expired", r.ID) } From ff36e368c3428d6c1892ab3ec5c54fde332e3146 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 05:47:52 +0100 Subject: [PATCH 05/10] Make snapshot race proof portable --- .../effects/planning_package_test.go | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 5621ff6..af6c0b3 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -94,25 +94,36 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { } originalVerify := verifyPlanningPackage swappedRoot := packageRoot + ".swapped" + replacementPerformed := false + var replacementBlocked error verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { result := originalVerify(repository, deliveryID, packageFingerprint, current) if err := os.Rename(packageRoot, swappedRoot); err != nil { - t.Fatal(err) + replacementBlocked = err + return result } + replacementPerformed = true if err := os.Mkdir(packageRoot, 0o700); err != nil { t.Fatal(err) } return result } - if _, err := prepareArtifacts(layout, promotion, promote, &state); err == nil || !strings.Contains(err.Error(), "changed during verification") { - t.Fatalf("package replacement was not rejected: %v", err) + _, raceErr := prepareArtifacts(layout, promotion, promote, &state) + if replacementPerformed { + if raceErr == nil || !strings.Contains(raceErr.Error(), "changed during verification") { + t.Fatalf("package replacement was not rejected: %v", raceErr) + } + } else if replacementBlocked == nil || raceErr != nil { + t.Fatalf("host neither blocked replacement nor completed safe promotion: blocked=%v promotion=%v", replacementBlocked, raceErr) } verifyPlanningPackage = originalVerify - if err := os.Remove(packageRoot); err != nil { - t.Fatal(err) - } - if err := os.Rename(swappedRoot, packageRoot); err != nil { - t.Fatal(err) + if replacementPerformed { + if err := os.Remove(packageRoot); err != nil { + t.Fatal(err) + } + if err := os.Rename(swappedRoot, packageRoot); err != nil { + t.Fatal(err) + } } mutations, err = prepareArtifacts(layout, promotion, promote, &state) if err != nil { From c78cf18e74ebfdb136214c858e0a0a0115f4b2b8 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 06:29:35 +0100 Subject: [PATCH 06/10] Harden planning package assurance boundaries --- .../planningpackage/package.go | 53 +++++++++++++++---- .../planningpackage/package_test.go | 53 ++++++++++++++++++- .../softwaredelivery/effects/artifacts.go | 24 ++++++++- .../effects/planning_package_test.go | 26 ++++++++- .../softwaredelivery/effects/prepared.go | 4 ++ .../softwaredelivery/effects/prepared_test.go | 21 ++++++++ .../softwaredelivery/effects/recovery.go | 6 +++ 7 files changed, 173 insertions(+), 14 deletions(-) diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go index 801fb18..24525a5 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -32,6 +32,7 @@ const ( var segment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var fingerprint = regexp.MustCompile(`^[a-f0-9]{64}$`) +var identityRole = regexp.MustCompile(`^[a-z][a-z0-9._-]*$`) var Reserved = []string{"approval.json", "contract.json", "manifest.json", "work-receipt.json"} @@ -395,13 +396,9 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP result.Approval = Invalid return fail("approval is invalid") } - identity := approval - identity.Fingerprint = "" - raw, _ := Encode(identity) - identityPresent := approval.IdentityRole != "" || approval.IdentityProviderFingerprint != "" - if approval.SchemaVersion != ApprovalSchemaVersion || approval.Fingerprint != Digest(raw) || approval.DeliveryID != deliveryID || approval.PackageFingerprint != packageFingerprint || approval.ManifestFingerprint != manifest.Fingerprint || approval.PlanOutputID != manifest.PlanOutput.ID || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || identityPresent && (approval.IdentityRole == "" || !ValidFingerprint(approval.IdentityProviderFingerprint)) || len(approval.AuthoritySources) == 0 || approval.ApprovedAt.IsZero() { + if err := ValidateApproval(approvalRaw, approval, manifest, deliveryID, packageFingerprint); err != nil { result.Approval = Invalid - return fail("approval identity is invalid") + return fail("approval identity is invalid: " + err.Error()) } result.Approval = Valid } else if !os.IsNotExist(statErr) { @@ -466,6 +463,36 @@ func authoritySourcesSorted(sources []AuthoritySource) bool { return true } +// ValidateApproval checks the complete portable approval admission contract +// against the exact manifest it authorizes. +func ValidateApproval(raw []byte, approval Approval, manifest Manifest, deliveryID, packageFingerprint string) error { + if !canonicalEncoding(raw, approval) || !authoritySourcesSorted(approval.AuthoritySources) { + return fmt.Errorf("encoding or authority sources") + } + identity := approval + identity.Fingerprint = "" + identityRaw, err := Encode(identity) + 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() { + return fmt.Errorf("lineage") + } + if len(approval.IdentityRole) == 0 || len(approval.IdentityRole) > 128 || !identityRole.MatchString(approval.IdentityRole) || !ValidFingerprint(approval.IdentityProviderFingerprint) { + return fmt.Errorf("human identity provenance") + } + matchingActor := false + for _, source := range approval.AuthoritySources { + if source.Subject == approval.Actor { + matchingActor = true + } + } + if !matchingActor { + return fmt.Errorf("authority does not match actor") + } + return nil +} + func validAuthorityClass(value string) bool { switch value { case "repository-policy", "human", "autonomy", "external-provider": @@ -490,7 +517,7 @@ func readRegular(path string) ([]byte, error) { } func validateContractAssets(work WorkContract) error { - if !ValidSegment(work.ID) || !ValidFingerprint(work.Fingerprint) || !safeRelative(work.Instructions.Path) || Digest([]byte(work.Instructions.Content)) != work.Instructions.SHA256 || !utf8.ValidString(work.Instructions.Content) { + if !ValidSegment(work.ID) || !ValidFingerprint(work.Fingerprint) || !safeRelative(work.Instructions.Path) || strings.TrimSpace(work.Instructions.Content) == "" || Digest([]byte(work.Instructions.Content)) != work.Instructions.SHA256 || !utf8.ValidString(work.Instructions.Content) || len(work.Outputs) == 0 { return fmt.Errorf("embedded work contract is invalid") } inputIDs := map[string]bool{} @@ -508,15 +535,21 @@ func validateContractAssets(work WorkContract) error { return fmt.Errorf("embedded work contract fingerprint is invalid") } for _, o := range work.Outputs { - if o.MediaType == "" { - return fmt.Errorf("output %q has no media type", o.ID) + if o.MaxBytes < 1 || o.MaxBytes > 16<<20 { + return fmt.Errorf("output %q has an invalid byte limit", o.ID) + } + if o.MediaType != "text/markdown" && o.MediaType != "text/plain" && o.MediaType != "application/json" { + return fmt.Errorf("output %q has unsupported media type", o.ID) } for _, a := range []*Asset{o.Guidance, o.Schema} { - if a != nil && (!safeRelative(a.Path) || !utf8.ValidString(a.Content) || Digest([]byte(a.Content)) != a.SHA256) { + if a != nil && (!safeRelative(a.Path) || strings.TrimSpace(a.Content) == "" || !utf8.ValidString(a.Content) || Digest([]byte(a.Content)) != a.SHA256) { return fmt.Errorf("output %q has invalid embedded asset", o.ID) } } if o.Schema != nil { + if o.MediaType != "application/json" { + return fmt.Errorf("output %q has a schema for a non-JSON media type", o.ID) + } var schema any if json.Unmarshal([]byte(o.Schema.Content), &schema) != nil { return fmt.Errorf("output %q has invalid embedded schema JSON", o.ID) diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/planningpackage/package_test.go index 4b66e3f..61949b1 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package_test.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package_test.go @@ -99,7 +99,7 @@ func TestVerifyApprovalUsesAuthorityReceiptContract(t *testing.T) { 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", ApprovedAt: time.Unix(100, 0).UTC()} + 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()} _, raw, _ := SealApproval(approval) if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { t.Fatal(err) @@ -107,6 +107,14 @@ func TestVerifyApprovalUsesAuthorityReceiptContract(t *testing.T) { if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Valid || result.Integrity != Valid { t.Fatalf("opaque authority fingerprint result=%#v", result) } + approval.AuthoritySources[0].Class = "autonomy" + _, raw, _ = SealApproval(approval) + if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Valid || result.Integrity != Valid { + t.Fatalf("delegated autonomy approval result=%#v", result) + } approval.AuthoritySources[0].Class = "unknown" _, raw, _ = SealApproval(approval) if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { @@ -115,6 +123,49 @@ func TestVerifyApprovalUsesAuthorityReceiptContract(t *testing.T) { if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Invalid { t.Fatalf("unknown authority class result=%#v", result) } + approval.AuthoritySources = []AuthoritySource{{ID: "repository", Class: "repository-policy", Subject: "repository", Fingerprint: "policy-proof"}} + _, raw, _ = SealApproval(approval) + if err := os.WriteFile(filepath.Join(root, "approval.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + if result := Verify(repository, delivery, fingerprint, nil); result.Approval != Invalid { + t.Fatalf("repository-only approval result=%#v", result) + } +} + +func TestVerifyRejectsContractOutsideRuntimeABI(t *testing.T) { + repository, delivery, fingerprint, _ := installFixture(t) + oldRoot := filepath.Join(repository, ".boatstack", "planning-packages", delivery, fingerprint) + contractRaw, _ := os.ReadFile(filepath.Join(oldRoot, "contract.json")) + var contract Contract + if err := StrictDecode(contractRaw, &contract); err != nil { + t.Fatal(err) + } + contract.Work.Outputs[0].Schema = &Asset{Path: "schema.json", Content: `{}`, SHA256: Digest([]byte(`{}`))} + contract.Work.Fingerprint, _ = RuntimeWorkFingerprint(contract.Work) + _, contractRaw, _ = SealContract(contract) + manifestRaw, _ := os.ReadFile(filepath.Join(oldRoot, "manifest.json")) + var manifest Manifest + if err := StrictDecode(manifestRaw, &manifest); err != nil { + t.Fatal(err) + } + manifest.WorkContractFingerprint = contract.Work.Fingerprint + manifest.Contract.SHA256 = Digest(contractRaw) + manifest, manifestRaw, _ = SealManifest(manifest) + newRoot := filepath.Join(filepath.Dir(oldRoot), manifest.Fingerprint) + if err := os.Rename(oldRoot, newRoot); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(newRoot, "contract.json"), contractRaw, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(newRoot, "manifest.json"), manifestRaw, 0o644); err != nil { + t.Fatal(err) + } + result := Verify(repository, delivery, manifest.Fingerprint, nil) + if result.Contract != Invalid || result.Integrity != Invalid { + t.Fatalf("non-JSON schema contract result=%#v", result) + } } func TestEnumeratePropagatesDeliveryReadFailure(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 3edb432..5d68c49 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -225,13 +225,23 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio 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}) } + expectedRole := "" + if len(humanIdentityRole) > 0 { + expectedRole = humanIdentityRole[0] + } for _, receipt := range admission.Authority.Receipts { if receipt.Subject == approval.Actor && receipt.IdentityRole != "" && receipt.IdentityProviderFingerprint != "" { + 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) + } approval.IdentityRole = receipt.IdentityRole approval.IdentityProviderFingerprint = receipt.IdentityProviderFingerprint break } } + if approval.IdentityRole == "" || approval.IdentityProviderFingerprint == "" { + return nil, fmt.Errorf("planning package approval requires admitted identity provenance matching actor %q", approval.Actor) + } approval, raw, encodeErr := planningpackage.SealApproval(approval) if encodeErr != nil { return nil, encodeErr @@ -266,7 +276,14 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, loadErr } var manifest planningpackage.Manifest - if decodeErr := planningpackage.StrictDecode(manifestRaw, &manifest); decodeErr != nil || manifest.Fingerprint != state.PlanningPackageFingerprint { + if decodeErr := planningpackage.StrictDecode(manifestRaw, &manifest); decodeErr != nil { + return nil, fmt.Errorf("decode pinned planning package manifest: %w", decodeErr) + } + canonicalManifestRaw, manifestEncodeErr := planningpackage.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") } approvalRaw, readErr := readRegularPlanningMember(packageRoot, "approval.json") @@ -274,7 +291,10 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, fmt.Errorf("read planning package approval: %w", readErr) } var approval planningpackage.Approval - if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil || approval.SchemaVersion != planningpackage.ApprovalSchemaVersion || approval.DeliveryID != deliveryID || approval.PackageFingerprint != manifest.Fingerprint || approval.PlanFingerprint != manifest.PlanOutput.SHA256 || approval.Actor == "" || approval.AdmissionID == "" || approval.ApprovedAt.IsZero() { + if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil { + return nil, fmt.Errorf("decode planning package approval: %w", decodeErr) + } + if approvalErr := planningpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.PlanningPackageFingerprint); approvalErr != nil { return nil, fmt.Errorf("planning package approval does not bind the exact package") } planRaw, readErr := readRegularPlanningMember(packageRoot, manifest.PlanOutput.Path) diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index af6c0b3..1e39909 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -73,7 +73,13 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if err := applyStateTransition(&state, approvalAdmission, approve); err != nil { t.Fatal(err) } - mutations, err = prepareArtifacts(layout, approvalAdmission, approve, &state) + wrongRole := approvalAdmission + wrongRole.Authority.Receipts = append([]protocol.AuthorityReceipt(nil), approvalAdmission.Authority.Receipts...) + wrongRole.Authority.Receipts[0].IdentityRole = "release-manager" + if _, mismatchErr := prepareArtifacts(layout, wrongRole, approve, &state, "developer"); mismatchErr == nil || !strings.Contains(mismatchErr.Error(), "does not match admitted program role") { + t.Fatalf("mismatched approval role was not rejected: %v", mismatchErr) + } + mutations, err = prepareArtifacts(layout, approvalAdmission, approve, &state, "developer") if err != nil { t.Fatal(err) } @@ -125,6 +131,24 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { t.Fatal(err) } } + originalManifest, err := os.ReadFile(filepath.Join(packageRoot, "manifest.json")) + if err != nil { + t.Fatal(err) + } + verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.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) + } + return result + } + 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 + if err := os.WriteFile(filepath.Join(packageRoot, "manifest.json"), originalManifest, 0o644); err != nil { + t.Fatal(err) + } mutations, err = prepareArtifacts(layout, promotion, promote, &state) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index c6620ee..3b95681 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -174,6 +174,10 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error allExisting = allExisting && mutation.PriorExists } if allExisting { + // Recovery reconstructs an already-installed atomic tree as an + // exact no-op mutation group. It still committed every staged + // resource, so retain the group for exact effect facts. + p.applied = append(p.applied, group...) continue } if err := atomicInstallTree(root, group); err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/prepared_test.go b/boatstack/internal/softwaredelivery/effects/prepared_test.go index ba5bcaa..72e6214 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared_test.go +++ b/boatstack/internal/softwaredelivery/effects/prepared_test.go @@ -124,6 +124,27 @@ func TestPreparedEffectInstallsAndRecoversPlanningTreeAsOneResource(t *testing.T } } +func TestPreparedEffectReportsAlreadyInstalledAtomicTreeFacts(t *testing.T) { + root := filepath.Join(t.TempDir(), "package") + manifest := filepath.Join(root, "manifest.json") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("manifest"), 0o644); err != nil { + t.Fatal(err) + } + mutation := ports.ResourceMutation{Path: manifest, Prior: []byte("manifest"), Target: []byte("manifest"), PriorExists: true, Mode: 0o644, AtomicTreeRoot: root} + transition := catalog.Transition{ID: "recovery.resume", Effect: "recovery.resume", Owner: "kernel", OwnedResources: []string{"transaction.journal"}} + prepared := &preparedEffect{transition: transition, requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, mutations: []ports.ResourceMutation{mutation}} + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } + facts := prepared.CommittedEffects() + if len(facts) != 1 || facts[0].Target != manifest || facts[0].Operation != "update" { + t.Fatalf("already-installed atomic tree facts = %#v", facts) + } +} + func TestPreparedEffectRestartRollbackRemovesAndCanRestoreAtomicTree(t *testing.T) { root := filepath.Join(t.TempDir(), "package") manifest := filepath.Join(root, "manifest.json") diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index 1e1a34c..2c7f5a1 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -1,6 +1,7 @@ package effects import ( + "bytes" "context" "errors" "fmt" @@ -61,6 +62,11 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll if mutationErr != nil { return nil, mutationErr } + if resume && original.AtomicTreeRoot != "" && !original.Delete { + if !mutation.PriorExists || mutation.PriorLink != original.TargetLink || !bytes.Equal(mutation.Prior, original.Target) || mutation.Mode != original.Mode { + return nil, fmt.Errorf("installed immutable resource tree differs from staged transaction at %s", original.Path) + } + } mutation.AtomicTreeRoot = original.AtomicTreeRoot mutations = append(mutations, mutation) } From 944169056f7219048ea6e9e75080b98e9ab9b889 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 06:55:16 +0100 Subject: [PATCH 07/10] Close planning package recovery races --- .../softwaredelivery/effects/artifacts.go | 71 ++++++++++++++++--- .../effects/planning_package_test.go | 45 ++++++++++++ .../softwaredelivery/effects/recovery.go | 33 ++++++++- .../softwaredelivery/effects/recovery_test.go | 15 ++++ 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 5d68c49..a3c09b3 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" + "runtime" "strings" "time" @@ -271,7 +273,21 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if pinnedErr != nil || currentErr != nil || !os.SameFile(pinnedInfo, currentInfo) { return nil, fmt.Errorf("immutable planning package changed during verification") } - manifestRaw, loadErr := readRegularPlanningMember(packageRoot, "manifest.json") + snapshotRepository, cleanupSnapshot, snapshotErr := capturePinnedPlanningPackage(packageRoot, deliveryID, state.PlanningPackageFingerprint) + 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") + } + 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, "; ")) + } + snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "planning-packages", deliveryID, state.PlanningPackageFingerprint) + manifestRaw, loadErr := os.ReadFile(filepath.Join(snapshotRoot, "manifest.json")) if loadErr != nil { return nil, loadErr } @@ -286,7 +302,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio 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") } - approvalRaw, readErr := readRegularPlanningMember(packageRoot, "approval.json") + approvalRaw, readErr := os.ReadFile(filepath.Join(snapshotRoot, "approval.json")) if readErr != nil { return nil, fmt.Errorf("read planning package approval: %w", readErr) } @@ -294,10 +310,10 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil { return nil, fmt.Errorf("decode planning package approval: %w", decodeErr) } - if approvalErr := planningpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.PlanningPackageFingerprint); approvalErr != nil { + if approvalErr := planningpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.PlanningPackageFingerprint); approvalErr != nil || approval.Fingerprint != state.ApprovalFingerprint { return nil, fmt.Errorf("planning package approval does not bind the exact package") } - planRaw, readErr := readRegularPlanningMember(packageRoot, manifest.PlanOutput.Path) + 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") } @@ -485,15 +501,50 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio var verifyPlanningPackage = planningpackage.Verify -func readRegularPlanningMember(root *os.Root, name string) ([]byte, error) { - info, err := root.Lstat(filepath.FromSlash(name)) +func capturePinnedPlanningPackage(root *os.Root, deliveryID, fingerprint string) (string, func(), error) { + repository, err := os.MkdirTemp("", "boatstack-planning-package-snapshot-") if err != nil { - return nil, err + return "", nil, err } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("planning package member is not regular: %s", name) + cleanup := func() { _ = os.RemoveAll(repository) } + destination := filepath.Join(repository, ".boatstack", "planning-packages", deliveryID, fingerprint) + if err := os.MkdirAll(destination, 0o700); err != nil { + cleanup() + return "", nil, err + } + err = fs.WalkDir(root.FS(), ".", func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if name == "." { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("planning package member is a symlink: %s", name) + } + target := filepath.Join(destination, filepath.FromSlash(name)) + if entry.IsDir() { + return os.Mkdir(target, 0o700) + } + 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) + } + raw, err := root.ReadFile(filepath.FromSlash(name)) + if err != nil { + return err + } + 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 os.WriteFile(target, raw, 0o644) + }) + if err != nil { + cleanup() + return "", nil, err } - return root.ReadFile(filepath.FromSlash(name)) + return repository, cleanup, nil } // prepareWorkspacePlanTransfer carries runtime-owned plan artifacts into a diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 1e39909..1b8cf39 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -149,6 +149,51 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { 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 { + t.Fatal(err) + } + substitutedApproval.Actor = "substitute" + for index := range substitutedApproval.AuthoritySources { + substitutedApproval.AuthoritySources[index].Subject = substitutedApproval.Actor + } + _, substitutedApprovalRaw, err := planningpackage.SealApproval(substitutedApproval) + if err != nil { + t.Fatal(err) + } + verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.Result { + result := originalVerify(repository, deliveryID, packageFingerprint, current) + if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), substitutedApprovalRaw, 0o644); err != nil { + t.Fatal(err) + } + return result + } + 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 + if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), originalApproval, 0o644); err != nil { + t.Fatal(err) + } + originalContract, err := os.ReadFile(filepath.Join(packageRoot, "contract.json")) + if err != nil { + t.Fatal(err) + } + verifyPlanningPackage = func(repository, deliveryID, packageFingerprint string, current *planningpackage.CurrentProgram) planningpackage.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) + } + return result + } + 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 + if err := os.WriteFile(filepath.Join(packageRoot, "contract.json"), originalContract, 0o644); err != nil { + t.Fatal(err) + } mutations, err = prepareArtifacts(layout, promotion, promote, &state) if err != nil { t.Fatal(err) diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index 2c7f5a1..9544e8d 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -34,6 +34,13 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll if resume && len(record.Mutations) == 0 { return nil, fmt.Errorf("transaction %s has no staged mutation manifest to resume", transactionID) } + atomicTreePresent := map[string]bool{} + if resume { + atomicTreePresent, err = recoveryAtomicTreePresence(record.Mutations) + if err != nil { + return nil, err + } + } mutations := make([]ports.ResourceMutation, 0, len(record.Mutations)+2) for _, original := range record.Mutations { if err := validateRecoveryPath(layout, record.Admission, original.Path); err != nil { @@ -62,7 +69,7 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll if mutationErr != nil { return nil, mutationErr } - if resume && original.AtomicTreeRoot != "" && !original.Delete { + if resume && original.AtomicTreeRoot != "" && !original.Delete && atomicTreePresent[original.AtomicTreeRoot] { if !mutation.PriorExists || mutation.PriorLink != original.TargetLink || !bytes.Equal(mutation.Prior, original.Target) || mutation.Mode != original.Mode { return nil, fmt.Errorf("installed immutable resource tree differs from staged transaction at %s", original.Path) } @@ -87,6 +94,30 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll return &preparedEffect{mutations: mutations, changedStateFacets: changed}, nil } +func recoveryAtomicTreePresence(mutations []ports.ResourceMutation) (map[string]bool, error) { + result := map[string]bool{} + for _, mutation := range mutations { + if mutation.AtomicTreeRoot == "" || mutation.Delete { + continue + } + if _, known := result[mutation.AtomicTreeRoot]; known { + continue + } + info, err := os.Lstat(mutation.AtomicTreeRoot) + switch { + case os.IsNotExist(err): + result[mutation.AtomicTreeRoot] = false + case err != nil: + return nil, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return nil, fmt.Errorf("installed immutable resource tree is unsafe: %s", mutation.AtomicTreeRoot) + default: + result[mutation.AtomicTreeRoot] = true + } + } + return result, nil +} + func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout ports.ControllerLayout, admission protocol.Admission, record journalRecord, pendingPath string) (ports.PreparedEffect, error) { if record.TransitionID != "workspace.cut" { return nil, fmt.Errorf("workspace reconciliation has no safe contract for interrupted transition %q", record.TransitionID) diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index 66cf5d9..868f2e9 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -22,6 +22,21 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) +func TestRecoveryAtomicTreePresenceAllowsAbsentAndRejectsUnsafeRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "package") + mutations := []ports.ResourceMutation{{Path: filepath.Join(root, "manifest.json"), Target: []byte("manifest"), Mode: 0o644, AtomicTreeRoot: root}} + presence, err := recoveryAtomicTreePresence(mutations) + if err != nil || presence[root] { + t.Fatalf("absent pre-install tree = %v, %v", presence, err) + } + if err := os.WriteFile(root, []byte("unsafe"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := recoveryAtomicTreePresence(mutations); err == nil { + t.Fatal("unsafe atomic-tree root was accepted") + } +} + type recoveryClock struct{ value time.Time } const testProgramFingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" From b9f8827114c599371967777c4515f834f8b90e9a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 07:15:16 +0100 Subject: [PATCH 08/10] Keep approval fingerprints in one domain --- boatstack/internal/softwaredelivery/effects/artifacts.go | 4 ++-- .../softwaredelivery/effects/planning_package_test.go | 6 ++++++ boatstack/internal/softwaredelivery/plant/observer.go | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index a3c09b3..92f6a4f 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -256,7 +256,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if !mutation.PriorExists { mutations = append(mutations, mutation) } - state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, approval.Fingerprint + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanOutput.SHA256, manifest.Fingerprint, sha256Bytes(raw) case "planning.package.promote": packagePath := filepath.Join(artifactRoot, "planning-packages", deliveryID, state.PlanningPackageFingerprint) packageRoot, openErr := os.OpenRoot(packagePath) @@ -310,7 +310,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if decodeErr := planningpackage.StrictDecode(approvalRaw, &approval); decodeErr != nil { return nil, fmt.Errorf("decode planning package approval: %w", decodeErr) } - if approvalErr := planningpackage.ValidateApproval(approvalRaw, approval, manifest, deliveryID, state.PlanningPackageFingerprint); approvalErr != nil || approval.Fingerprint != state.ApprovalFingerprint { + 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") } planRaw, readErr := os.ReadFile(filepath.Join(snapshotRoot, filepath.FromSlash(manifest.PlanOutput.Path))) diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 1b8cf39..2a9e7ea 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -84,6 +84,9 @@ 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") + } result = planningpackage.Verify(repository, "delivery", state.PlanningPackageFingerprint, nil) if result.Approval != planningpackage.Valid { t.Fatalf("approval verification=%#v", result) @@ -198,6 +201,9 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { if err != nil { t.Fatal(err) } + if _, err = prepareArtifacts(layout, promotion, promote, &state); err != nil { + t.Fatalf("repeated promotion preparation rejected unchanged package: %v", err) + } installFixtureMutations(t, mutations) current, _ := os.ReadFile(filepath.Join(repository, ".boatstack", "plans", "delivery.source")) if !bytes.Equal(current, plan) { diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index d2ebf5e..f93edf8 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -692,7 +692,7 @@ func observePlanningPackage(layout ports.ControllerLayout, state durable.State, raw, _ := os.ReadFile(filepath.Join(root, "approval.json")) var approval planningpackage.Approval _ = planningpackage.StrictDecode(raw, &approval) - valid = approval.Fingerprint == state.ApprovalFingerprint + valid = planningpackage.Digest(raw) == state.ApprovalFingerprint } } return evidence, valid, nil From ddf1c8985d37cd203c30709d24d8da082ecbcc82 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 07:58:54 +0100 Subject: [PATCH 09/10] Harden planning package review boundaries --- boatstack/controlprogram/artifact.go | 28 ++++++++- boatstack/controlprogram/path_test.go | 16 ++++++ .../planningpackage/package.go | 57 ++++++++++++++++--- .../planningpackage/package_test.go | 26 +++++++++ .../softwaredelivery/effects/artifacts.go | 17 ++++-- .../effects/planning_package_test.go | 10 +++- .../boatstack-software-delivery/src/index.ts | 21 ++++++- .../test/software-delivery.test.mjs | 2 + 8 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 boatstack/controlprogram/path_test.go diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 1ea833e..6ddcb8f 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -255,11 +255,35 @@ func (r RepositoryAssetResolver) ResolveAsset(path string, maxBytes int64) ([]by func digest(value []byte) string { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) } func safeRelative(value string) bool { - if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\\`) { + if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) || len(value) >= 2 && value[1] == ':' { return false } clean := filepath.Clean(filepath.FromSlash(value)) - return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.ToSlash(clean) != value { + return false + } + for _, component := range strings.Split(value, "/") { + if !portableWindowsPathComponent(component) { + return false + } + } + return true +} + +func portableWindowsPathComponent(value string) bool { + if value == "" || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") || strings.ContainsAny(value, `<>:"|?*`) { + return false + } + for _, character := range value { + if character < 32 { + return false + } + } + base := strings.ToUpper(strings.SplitN(value, ".", 2)[0]) + if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" { + return false + } + return !(len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') } func readRepositoryFile(repository, relative string) ([]byte, error) { diff --git a/boatstack/controlprogram/path_test.go b/boatstack/controlprogram/path_test.go new file mode 100644 index 0000000..83dc264 --- /dev/null +++ b/boatstack/controlprogram/path_test.go @@ -0,0 +1,16 @@ +package controlprogram + +import "testing" + +func TestSafeRelativeRejectsWindowsSpecialComponentsOnEveryHost(t *testing.T) { + for _, value := range []string{"NUL", "nested/con.txt", "COM1.log", "nested/LPT9", "plan.", "plan ", "nested/name:stream"} { + if safeRelative(value) { + t.Fatalf("safeRelative(%q) accepted a Windows-special component", value) + } + } + for _, value := range []string{"plan.md", "nested/null.md"} { + if !safeRelative(value) { + t.Fatalf("safeRelative(%q) rejected a portable path", value) + } + } +} diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go index 24525a5..bc4bdd0 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -28,6 +28,7 @@ const ( ContractSchemaVersion = 1 WorkReceiptSchemaVersion = 1 ApprovalSchemaVersion = 2 + maxPackageMetadataBytes = 16 << 20 ) var segment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -300,13 +301,37 @@ func safeRelative(value string) bool { return false } clean := pathpkg.Clean(value) - return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") && clean == value + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != value { + return false + } + for _, component := range strings.Split(value, "/") { + if !portableWindowsComponent(component) { + return false + } + } + return true } func hasWindowsVolumePrefix(value string) bool { return len(value) >= 2 && value[1] == ':' && ((value[0] >= 'a' && value[0] <= 'z') || (value[0] >= 'A' && value[0] <= 'Z')) } +func portableWindowsComponent(value string) bool { + if value == "" || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") || strings.ContainsAny(value, `<>:"|?*`) { + return false + } + for _, character := range value { + if character < 32 { + return false + } + } + base := strings.ToUpper(strings.SplitN(value, ".", 2)[0]) + if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" { + return false + } + return !(len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') +} + func Verify(repository, deliveryID, packageFingerprint string, current *CurrentProgram) Result { result := Result{DeliveryID: deliveryID, PackageFingerprint: packageFingerprint, Integrity: Invalid, Contract: Invalid, Approval: Missing, CurrentProgram: Unavailable, SemanticCorrectness: "not-evaluated", OriginAuthenticity: "not-proven"} fail := func(message string) Result { result.Diagnostics = append(result.Diagnostics, message); return result } @@ -325,7 +350,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP if info, err = os.Lstat(packageRoot); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fail("package directory is unavailable or unsafe") } - manifestRaw, err := readRegular(filepath.Join(packageRoot, "manifest.json")) + manifestRaw, err := readRegular(filepath.Join(packageRoot, "manifest.json"), maxPackageMetadataBytes) if err != nil { return fail(err.Error()) } @@ -342,7 +367,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()) } - contractRaw, err := readRegular(filepath.Join(packageRoot, "contract.json")) + 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") } @@ -363,7 +388,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP return fail(err.Error()) } result.Contract = Valid - receiptRaw, err := readRegular(filepath.Join(packageRoot, "work-receipt.json")) + receiptRaw, err := readRegular(filepath.Join(packageRoot, "work-receipt.json"), maxPackageMetadataBytes) if err != nil || Digest(receiptRaw) != manifest.WorkReceipt.SHA256 || manifest.WorkReceipt.Path != "work-receipt.json" { return fail("work receipt reference is invalid") } @@ -386,7 +411,7 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP } approvalPath := filepath.Join(packageRoot, "approval.json") if _, statErr := os.Lstat(approvalPath); statErr == nil { - approvalRaw, readErr := readRegular(approvalPath) + approvalRaw, readErr := readRegular(approvalPath, maxPackageMetadataBytes) if readErr != nil { result.Approval = Invalid return fail(readErr.Error()) @@ -502,7 +527,7 @@ func validAuthorityClass(value string) bool { } } -func readRegular(path string) ([]byte, error) { +func readRegular(path string, maxBytes int64) ([]byte, error) { info, err := os.Lstat(path) if err != nil { return nil, err @@ -513,7 +538,23 @@ func readRegular(path string) ([]byte, error) { if runtime.GOOS != "windows" && info.Mode().Perm() != 0o644 { return nil, fmt.Errorf("package member has non-canonical mode: %s", path) } - return os.ReadFile(path) + if maxBytes < 0 || info.Size() > maxBytes { + return nil, fmt.Errorf("package member exceeds its byte bound: %s", path) + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) + if err != nil { + return nil, err + } + after, err := file.Stat() + if err != nil || !after.Mode().IsRegular() || !os.SameFile(info, after) || after.Size() != int64(len(raw)) || int64(len(raw)) > maxBytes { + return nil, fmt.Errorf("package member changed or exceeded its byte bound: %s", path) + } + return raw, nil } func validateContractAssets(work WorkContract) error { @@ -625,7 +666,7 @@ func verifyOutputs(root string, manifest Manifest, contract Contract, receipt Wo if (decl.Guidance == nil) != (o.GuidanceSHA256 == "") || decl.Guidance != nil && decl.Guidance.SHA256 != o.GuidanceSHA256 || (decl.Schema == nil) != (o.SchemaSHA256 == "") || decl.Schema != nil && decl.Schema.SHA256 != o.SchemaSHA256 { return fmt.Errorf("output %q asset binding is invalid", o.ID) } - raw, err := readRegular(filepath.Join(root, filepath.FromSlash(o.Path))) + raw, err := readRegular(filepath.Join(root, filepath.FromSlash(o.Path)), decl.MaxBytes) if err != nil || int64(len(raw)) != o.Size || Digest(raw) != o.SHA256 { return fmt.Errorf("output %q content is invalid", o.ID) } diff --git a/boatstack/flow/softwaredelivery/planningpackage/package_test.go b/boatstack/flow/softwaredelivery/planningpackage/package_test.go index 61949b1..05f3e19 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package_test.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package_test.go @@ -211,6 +211,14 @@ func TestSafeRelativeUsesPortableContractPathSemantics(t *testing.T) { {value: "C:/escape", want: false}, {value: "C:escape", want: false}, {value: "a//escape", want: false}, + {value: "NUL", want: false}, + {value: "nested/con.txt", want: false}, + {value: "COM1.log", want: false}, + {value: "nested/LPT9", want: false}, + {value: "plan.", want: false}, + {value: "plan ", want: false}, + {value: "nested/name:stream", want: false}, + {value: "null.md", want: true}, } for _, test := range tests { t.Run(test.value, func(t *testing.T) { @@ -221,6 +229,24 @@ func TestSafeRelativeUsesPortableContractPathSemantics(t *testing.T) { } } +func TestReadRegularRejectsOversizedSparseMemberBeforeLoading(t *testing.T) { + path := filepath.Join(t.TempDir(), "member") + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(1 << 30); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, err := readRegular(path, 1024); err == nil || !strings.Contains(err.Error(), "byte bound") { + t.Fatalf("oversized sparse member result = %v", err) + } +} + func TestVerifyRejectsIndependentTampering(t *testing.T) { cases := []string{"manifest.json", "contract.json", "work-receipt.json", "plan.md"} for _, name := range cases { diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 92f6a4f..bab4357 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -223,7 +223,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if state.PlanningPackageFingerprint != manifest.Fingerprint { return nil, fmt.Errorf("planning 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, Actor: authorityActor(admission), ApprovedAt: admission.IssuedAt.UTC()} + 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()} 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}) } @@ -231,13 +231,20 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if len(humanIdentityRole) > 0 { expectedRole = humanIdentityRole[0] } - for _, receipt := range admission.Authority.Receipts { - if receipt.Subject == approval.Actor && receipt.IdentityRole != "" && receipt.IdentityProviderFingerprint != "" { + for _, authorityClass := range []catalog.AuthorityClass{catalog.AuthorityHuman, catalog.AuthorityAutonomy} { + for _, receipt := range admission.Authority.Receipts { + if receipt.Subject == "" || receipt.IdentityRole == "" || receipt.IdentityProviderFingerprint == "" || receipt.Class != authorityClass { + 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) } - approval.IdentityRole = receipt.IdentityRole - approval.IdentityProviderFingerprint = receipt.IdentityProviderFingerprint + 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") + } + approval.Actor, approval.IdentityRole, approval.IdentityProviderFingerprint = receipt.Subject, receipt.IdentityRole, receipt.IdentityProviderFingerprint + } + if approval.Actor != "" { break } } diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go index 2a9e7ea..b452d73 100644 --- a/boatstack/internal/softwaredelivery/effects/planning_package_test.go +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -69,13 +69,17 @@ 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"}} 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{{ID: "auth", Class: catalog.AuthorityHuman, Subject: "reviewer", Fingerprint: "human-proof", IdentityRole: "developer", IdentityProviderFingerprint: providerFingerprint, IssuedAt: now}}}} + 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{ + {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}, + }}} if err := applyStateTransition(&state, approvalAdmission, approve); err != nil { t.Fatal(err) } wrongRole := approvalAdmission wrongRole.Authority.Receipts = append([]protocol.AuthorityReceipt(nil), approvalAdmission.Authority.Receipts...) - wrongRole.Authority.Receipts[0].IdentityRole = "release-manager" + wrongRole.Authority.Receipts[2].IdentityRole = "release-manager" if _, mismatchErr := prepareArtifacts(layout, wrongRole, approve, &state, "developer"); mismatchErr == nil || !strings.Contains(mismatchErr.Error(), "does not match admitted program role") { t.Fatalf("mismatched approval role was not rejected: %v", mismatchErr) } @@ -92,7 +96,7 @@ func TestPlanningPackageAdmitApprovePromoteUsesExactV2Snapshot(t *testing.T) { t.Fatalf("approval verification=%#v", result) } var recordedApproval planningpackage.Approval - if err := planningpackage.StrictDecode(approvalRawAt(t, packageRoot), &recordedApproval); err != nil || recordedApproval.IdentityRole != "developer" || recordedApproval.IdentityProviderFingerprint != providerFingerprint { + if err := planningpackage.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) } diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 8865452..36495e0 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -131,6 +131,23 @@ export interface PlanningPackageDefinition { planOutput: string; } +function portablePlanningPath(path: string): boolean { + if (path.length === 0 || path.startsWith("/") || path.includes("\\")) return false; + for (const component of path.split("/")) { + if ( + component === "" || + component === "." || + component === ".." || + component.endsWith(".") || + component.endsWith(" ") || + /[<>:"|?*\u0000-\u001f]/u.test(component) + ) return false; + const base = component.split(".", 1)[0].toUpperCase(); + if (["CON", "PRN", "AUX", "NUL"].includes(base) || /^(COM|LPT)[1-9]$/u.test(base)) return false; + } + return true; +} + function validatePlanningPackage(planning: PlanningPackageDefinition): void { const { work, planOutput } = planning; if (!/^[a-z][a-z0-9._-]*$/.test(planOutput)) { @@ -148,7 +165,7 @@ function validatePlanningPackage(planning: PlanningPackageDefinition): void { const reserved = ["manifest.json", "contract.json", "work-receipt.json", "approval.json"]; for (const output of work.outputs) { const path = output.path.toLowerCase(); - if (path.length === 0 || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => part === "" || part === "." || part === "..")) { + if (!portablePlanningPath(output.path)) { throw new Error(`SOFTWARE_DELIVERY_OUTPUT_PATH_INVALID: ${JSON.stringify(output.path)}`); } for (const owned of reserved) { @@ -255,7 +272,7 @@ function validateSoftwareDeliveryDefinition( ); } if (definition.planningPackage) { - validatePlanningPackage(definition.planningPackage); + validatePlanningPackage(definition.planningPackage); } const additionalWorkIDs = new Set( diff --git a/packages/boatstack-software-delivery/test/software-delivery.test.mjs b/packages/boatstack-software-delivery/test/software-delivery.test.mjs index 13c0dd3..cc5169c 100644 --- a/packages/boatstack-software-delivery/test/software-delivery.test.mjs +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -141,6 +141,8 @@ for (const [name, planning, error] of [ ["unknown output", { work: work("planning"), planOutput: "missing" }, /SOFTWARE_DELIVERY_PLAN_OUTPUT_REQUIRED/], ["invalid output ID", { work: work("planning"), planOutput: "Implementation Plan" }, /SOFTWARE_DELIVERY_PLAN_OUTPUT_INVALID/], ["reserved path", { work: { ...work("planning"), outputs: [{ id: "implementation-plan", path: "Manifest.JSON", media_type: "text/markdown", required: true }] }, planOutput: "implementation-plan" }, /SOFTWARE_DELIVERY_OUTPUT_PATH_RESERVED/], + ["Windows device path", { work: { ...work("planning"), outputs: [{ id: "implementation-plan", path: "NUL.md", media_type: "text/markdown", required: true }] }, planOutput: "implementation-plan" }, /SOFTWARE_DELIVERY_OUTPUT_PATH_INVALID/], + ["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)); From 82e5923387d6706382802c075a0a087e7756a977 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 18 Aug 2026 08:32:01 +0100 Subject: [PATCH 10/10] Bind planning package control boundaries --- .../work_output_invocation_test.go | 1 + .../flow/softwaredelivery/planning_package.go | 18 +++-- .../softwaredelivery/planning_package_test.go | 15 +++++ .../planningpackage/package.go | 8 +++ .../softwaredelivery/effects/prepared.go | 31 ++++++--- .../softwaredelivery/effects/prepared_test.go | 65 +++++++++++++++++++ .../softwaredelivery/engine/engine_test.go | 1 + .../foregroundwork/manager.go | 3 +- .../foregroundwork/manager_test.go | 36 ++++++++++ .../softwaredelivery/protocol/authority.go | 1 + .../protocol/authority_test.go | 27 ++++++++ .../softwaredelivery/protocol/receipt.go | 20 ++++-- .../protocol/receipt_fact_test.go | 7 +- .../softwaredelivery/protocol/work.go | 2 +- 14 files changed, 213 insertions(+), 22 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/work_output_invocation_test.go b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go index f5691e3..d21c772 100644 --- a/boatstack/cmd/boatstack-helper/work_output_invocation_test.go +++ b/boatstack/cmd/boatstack-helper/work_output_invocation_test.go @@ -30,6 +30,7 @@ func TestWorkOutputProducerRejectsStaleExecutionScope(t *testing.T) { objective := model.Objective{ID: "objective", TargetID: "target", TrustedClass: "target", DeliveryID: "delivery"} result, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: requestFingerprint, + RunID: "run-1", ProgramID: "fixture", EntryID: "run", ContractID: contract.ID, ContractFingerprint: contract.Fingerprint, TransitionID: "planning.admit", ProgramFingerprint: programFingerprint, ContextFingerprint: contextFingerprint, StateRevision: 3, RepositoryID: current.RepositoryID, WorktreeID: current.WorktreeID, diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go index 543b7a6..6e55e74 100644 --- a/boatstack/flow/softwaredelivery/planning_package.go +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -100,22 +100,32 @@ func replacePlanCondition(values []delivery.FacetCondition, state model.PlanStat } func validatePlanningPackageWorkContract(work delivery.WorkContract, planOutputID string) error { - outputs := make([]planningpackage.WorkOutput, 0, len(work.Outputs)) + 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] - outputs = append(outputs, planningpackage.WorkOutput{ID: output.ID, Path: output.Path, MaxBytes: output.MaxBytes}) + 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(outputs); err != nil { + 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 nil + return planningpackage.ValidateContractMetadata(portable, planOutputID) } func planningPackagePlanOutput(bindings []controlprogram.TransitionParameterBinding) (string, error) { diff --git a/boatstack/flow/softwaredelivery/planning_package_test.go b/boatstack/flow/softwaredelivery/planning_package_test.go index e9cd276..8eb0034 100644 --- a/boatstack/flow/softwaredelivery/planning_package_test.go +++ b/boatstack/flow/softwaredelivery/planning_package_test.go @@ -2,6 +2,7 @@ package softwaredelivery import ( "context" + "fmt" "strings" "testing" @@ -78,3 +79,17 @@ func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { 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++ { + id := fmt.Sprintf("output-%02d", index) + work.Outputs = append(work.Outputs, delivery.WorkOutput{ + ID: id, Path: id + ".json", MediaType: "application/json", Required: true, MaxBytes: 1, + SchemaPath: id + ".schema.json", SchemaSHA256: strings.Repeat("b", 64), SchemaContent: strings.Repeat("x", 1<<20), + }) + } + if err := validatePlanningPackageWorkContract(work, "output-00"); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized portable contract = %v", err) + } +} diff --git a/boatstack/flow/softwaredelivery/planningpackage/package.go b/boatstack/flow/softwaredelivery/planningpackage/package.go index bc4bdd0..092dc33 100644 --- a/boatstack/flow/softwaredelivery/planningpackage/package.go +++ b/boatstack/flow/softwaredelivery/planningpackage/package.go @@ -222,9 +222,17 @@ 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 value, raw, err } +func ValidateContractMetadata(work WorkContract, planOutput string) error { + _, _, err := SealContract(Contract{Work: work, PlanOutput: planOutput}) + return err +} + func SealWorkReceipt(value WorkReceipt) (WorkReceipt, []byte, error) { value.SchemaVersion = WorkReceiptSchemaVersion sort.Slice(value.Outputs, func(i, j int) bool { return value.Outputs[i].ID < value.Outputs[j].ID }) diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index 3b95681..33f3eae 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -32,6 +32,14 @@ type preparedEffect struct { requiredCapabilities []catalog.Capability effectiveCapabilities []catalog.Capability changedStateFacets []model.StateFacet + directorySync func(string) error +} + +func (p *preparedEffect) syncDirectory(path string) error { + if p.directorySync != nil { + return p.directorySync(path) + } + return syncDirectory(path) } type atomicTreeRemoval struct { @@ -162,11 +170,11 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error if err := os.RemoveAll(root); err != nil { return result, fmt.Errorf("remove immutable resource tree %s: %w", root, err) } - if err := syncDirectory(filepath.Dir(root)); err != nil { - return result, fmt.Errorf("sync immutable resource tree parent %s: %w", root, err) - } p.applied = append(p.applied, group...) p.removedTreeGroups = append(p.removedTreeGroups, atomicTreeRemoval{root: root, mutations: group}) + if err := p.syncDirectory(filepath.Dir(root)); err != nil { + return result, fmt.Errorf("sync immutable resource tree parent %s: %w", root, err) + } continue } allExisting := true @@ -180,11 +188,14 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error p.applied = append(p.applied, group...) continue } - if err := atomicInstallTree(root, group); err != nil { + if err := atomicInstallTree(root, group, p.syncDirectory); err != nil { return result, fmt.Errorf("install immutable resource tree %s: %w", root, err) } p.applied = append(p.applied, group...) p.appliedTreeRoots = append(p.appliedTreeRoots, root) + if err := p.syncDirectory(filepath.Dir(root)); err != nil { + return result, fmt.Errorf("sync immutable resource tree parent %s: %w", root, err) + } } for _, mutation := range regular { var err error @@ -226,7 +237,7 @@ func (p *preparedEffect) Rollback(context.Context) error { for index := len(p.appliedTreeRoots) - 1; index >= 0; index-- { if err := os.RemoveAll(p.appliedTreeRoots[index]); err != nil { rollbackErrors = append(rollbackErrors, err) - } else if err := syncDirectory(filepath.Dir(p.appliedTreeRoots[index])); err != nil { + } else if err := p.syncDirectory(filepath.Dir(p.appliedTreeRoots[index])); err != nil { rollbackErrors = append(rollbackErrors, err) } } @@ -257,7 +268,9 @@ func (p *preparedEffect) Rollback(context.Context) error { restore = append(restore, ports.ResourceMutation{Path: mutation.Path, Target: mutation.Prior, TargetLink: mutation.PriorLink, Mode: mutation.Mode, InstallLast: mutation.InstallLast, AtomicTreeRoot: removed.root}) } if len(restore) > 0 { - if err := atomicInstallTree(removed.root, restore); err != nil { + if err := atomicInstallTree(removed.root, restore, p.syncDirectory); err != nil { + rollbackErrors = append(rollbackErrors, err) + } else if err := p.syncDirectory(filepath.Dir(removed.root)); err != nil { rollbackErrors = append(rollbackErrors, err) } } @@ -271,7 +284,7 @@ func (p *preparedEffect) Rollback(context.Context) error { return errors.Join(rollbackErrors...) } -func atomicInstallTree(root string, mutations []ports.ResourceMutation) error { +func atomicInstallTree(root string, mutations []ports.ResourceMutation, syncDir func(string) error) error { if !filepath.IsAbs(root) || len(mutations) == 0 { return fmt.Errorf("atomic tree requires an absolute root and members") } @@ -299,11 +312,11 @@ func atomicInstallTree(root string, mutations []ports.ResourceMutation) error { return err } } - if err := syncDirectory(stage); err != nil { + if err := syncDir(stage); err != nil { return err } if err := os.Rename(stage, root); err != nil { return err } - return syncDirectory(parent) + return nil } diff --git a/boatstack/internal/softwaredelivery/effects/prepared_test.go b/boatstack/internal/softwaredelivery/effects/prepared_test.go index 72e6214..b608403 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared_test.go +++ b/boatstack/internal/softwaredelivery/effects/prepared_test.go @@ -2,6 +2,7 @@ package effects import ( "context" + "errors" "os" "path/filepath" "testing" @@ -124,6 +125,70 @@ func TestPreparedEffectInstallsAndRecoversPlanningTreeAsOneResource(t *testing.T } } +func TestPreparedEffectRollsBackTreeAfterPostRenameSyncFailure(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "package") + failed := false + prepared := &preparedEffect{ + requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + mutations: []ports.ResourceMutation{{Path: filepath.Join(root, "manifest.json"), Target: []byte("manifest"), Mode: 0o644, AtomicTreeRoot: root}}, + directorySync: func(path string) error { + if path == parent && !failed { + failed = true + return errors.New("injected parent sync failure") + } + return syncDirectory(path) + }, + } + if _, err := prepared.Execute(context.Background()); err == nil || !failed { + t.Fatalf("post-rename sync failure = %v", err) + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("tree survived rollback after post-rename sync failure: %v", err) + } +} + +func TestPreparedEffectRestoresTreeAfterPostRemovalSyncFailure(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "package") + manifest := filepath.Join(root, "manifest.json") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, []byte("manifest"), 0o644); err != nil { + t.Fatal(err) + } + mutation, err := mutationForExactResource(manifest, nil, "", 0o644, true, true) + if err != nil { + t.Fatal(err) + } + mutation.AtomicTreeRoot = root + failed := false + prepared := &preparedEffect{ + requiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, effectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite}, + mutations: []ports.ResourceMutation{mutation}, + directorySync: func(path string) error { + if path == parent && !failed { + failed = true + return errors.New("injected parent sync failure") + } + return syncDirectory(path) + }, + } + if _, err := prepared.Execute(context.Background()); err == nil || !failed { + t.Fatalf("post-removal sync failure = %v", err) + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(manifest); err != nil || string(raw) != "manifest" { + t.Fatalf("prior tree was not restored: %q, %v", raw, err) + } +} + func TestPreparedEffectReportsAlreadyInstalledAtomicTreeFacts(t *testing.T) { root := filepath.Join(t.TempDir(), "package") manifest := filepath.Join(root, "manifest.json") diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 6f081ec..95ca5b1 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -536,6 +536,7 @@ func TestResolutionBindsForegroundWorkBeforeTrustedAdmission(t *testing.T) { contentDigest := sha256.Sum256([]byte(content)) evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: strings.Repeat("e", 64), + RunID: "flow", ProgramID: syntheticProgram.ID, EntryID: "entry", ContractID: work.ID, ContractFingerprint: work.Fingerprint, TransitionID: "test.advance", ProgramFingerprint: candidate.Snapshot.ProgramFingerprint, ContextFingerprint: mustWorkContextFingerprint(t, candidate.Snapshot), StateRevision: candidate.Snapshot.StateRevision, RepositoryID: candidate.Snapshot.Invocation.RepositoryID, WorktreeID: candidate.Snapshot.Invocation.WorktreeID, diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go index d6d76bc..a70cc94 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -462,7 +462,8 @@ func load(path string) (Record, error) { return Record{}, fmt.Errorf("foreground work request fingerprint is invalid") } if record.Result != nil { - if err := record.Result.Validate(); err != nil || record.Result.RequestFingerprint != record.Request.Fingerprint { + if err := record.Result.Validate(); err != nil || record.Result.RequestID != record.Request.ID || record.Result.RequestFingerprint != record.Request.Fingerprint || + record.Result.RunID != record.Request.RunID || record.Result.ProgramID != record.Request.ProgramID || record.Result.EntryID != record.Request.EntryID { return Record{}, fmt.Errorf("foreground work result is invalid: %v", err) } } diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go index 506f525..99cb671 100644 --- a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -2,6 +2,7 @@ package foregroundwork_test import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -115,6 +116,41 @@ func TestForegroundWorkQuestionCompletionAndDrift(t *testing.T) { } } +func TestForegroundWorkRejectsResultFlowIdentityDifferentFromRequest(t *testing.T) { + manager, snapshot, transition, root := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + record, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(record.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"overload"}`), 0o600); err != nil { + t.Fatal(err) + } + record, err = manager.Complete(ctx, invocation(), "run-1", "diagnose") + if err != nil { + t.Fatal(err) + } + tampered := *record.Result + tampered.ProgramID = "different-program" + tampered, err = protocol.SealWorkEvidence(tampered) + if err != nil { + t.Fatal(err) + } + record.Result = &tampered + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "flow", "work", "run-1", "diagnose", "record.json") + if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + if _, err := foregroundwork.LoadRecord(ports.ControllerLayout{FlowRoot: filepath.Join(root, "flow")}, "run-1", "diagnose"); err == nil || !strings.Contains(err.Error(), "result is invalid") { + t.Fatalf("mismatched result Flow identity = %v", err) + } +} + func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { // control-law: restart-local driver identity cannot invalidate otherwise-current work manager, snapshot, transition, _ := fixture(t) diff --git a/boatstack/internal/softwaredelivery/protocol/authority.go b/boatstack/internal/softwaredelivery/protocol/authority.go index c0ab9e5..15ff3de 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority.go +++ b/boatstack/internal/softwaredelivery/protocol/authority.go @@ -88,6 +88,7 @@ func (b AuthorityBundle) Fingerprint() (string, error) { for _, receipt := range canonical.Receipts { sources = append(sources, AuthoritySource{ ID: receipt.ID, Class: receipt.Class, Subject: receipt.Subject, Fingerprint: receipt.Fingerprint, + IdentityRole: receipt.IdentityRole, IdentityProviderFingerprint: receipt.IdentityProviderFingerprint, }) } return contentID("auth-", sources) diff --git a/boatstack/internal/softwaredelivery/protocol/authority_test.go b/boatstack/internal/softwaredelivery/protocol/authority_test.go index 4edf4f0..fa73be6 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority_test.go +++ b/boatstack/internal/softwaredelivery/protocol/authority_test.go @@ -1,6 +1,7 @@ package protocol import ( + "strings" "testing" "time" @@ -8,6 +9,32 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) +func TestAuthorityFingerprintBindsIdentityProvenance(t *testing.T) { + now := time.Unix(100, 0).UTC() + bundle := AuthorityBundle{Receipts: []AuthorityReceipt{{ + ID: "human", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "presentation", + IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("a", 64), IssuedAt: now, + }}} + base, err := bundle.Fingerprint() + if err != nil { + t.Fatal(err) + } + changedRole := bundle + changedRole.Receipts = append([]AuthorityReceipt(nil), bundle.Receipts...) + changedRole.Receipts[0].IdentityRole = "reviewer" + roleFingerprint, err := changedRole.Fingerprint() + if err != nil || roleFingerprint == base { + t.Fatalf("role fingerprint = %q, err=%v", roleFingerprint, err) + } + changedProvider := bundle + changedProvider.Receipts = append([]AuthorityReceipt(nil), bundle.Receipts...) + changedProvider.Receipts[0].IdentityProviderFingerprint = strings.Repeat("b", 64) + providerFingerprint, err := changedProvider.Fingerprint() + if err != nil || providerFingerprint == base { + t.Fatalf("provider fingerprint = %q, err=%v", providerFingerprint, err) + } +} + func TestRepositoryAuthorityWaitsForVerifiedConfiguration(t *testing.T) { // control-law: an authority request never fabricates repository evidence now := time.Unix(100, 0).UTC() diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 85b1794..9e09e58 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -9,6 +9,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) @@ -147,10 +148,12 @@ type TransitionReceipt struct { } type AuthoritySource struct { - ID string `json:"id"` - Class catalog.AuthorityClass `json:"class"` - Subject string `json:"subject"` - Fingerprint string `json:"fingerprint"` + ID string `json:"id"` + Class catalog.AuthorityClass `json:"class"` + Subject string `json:"subject"` + Fingerprint string `json:"fingerprint"` + IdentityRole string `json:"identity_role,omitempty"` + IdentityProviderFingerprint string `json:"identity_provider_fingerprint,omitempty"` } func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admission Admission, transition catalog.Transition, target model.Snapshot, changedStateFacets []model.StateFacet, effects []EffectFact, outputs Parameters, exercised []catalog.Capability, startedAt, committedAt time.Time) (TransitionReceipt, error) { @@ -178,7 +181,7 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi } sources := make([]AuthoritySource, 0, len(admission.Authority.Receipts)) for _, authority := range admission.Authority.Receipts { - sources = append(sources, AuthoritySource{ID: authority.ID, Class: authority.Class, Subject: authority.Subject, Fingerprint: authority.Fingerprint}) + sources = append(sources, AuthoritySource{ID: authority.ID, Class: authority.Class, Subject: authority.Subject, Fingerprint: authority.Fingerprint, IdentityRole: authority.IdentityRole, IdentityProviderFingerprint: authority.IdentityProviderFingerprint}) } terminal := model.TerminalUnknown if target.Terminal.Status == model.FactKnown { @@ -349,9 +352,14 @@ func (r TransitionReceipt) validate(schemaVersion int) error { sources := append([]AuthoritySource(nil), r.AuthoritySources...) sort.Slice(sources, func(i, j int) bool { return sources[i].ID < sources[j].ID }) for index, source := range sources { - if source.ID == "" || !source.Class.Valid() || source.Class == catalog.AuthorityNone || source.Subject == "" || source.Fingerprint == "" { + if source.ID == "" || !source.Class.Valid() || source.Class == catalog.AuthorityNone || source.Subject == "" || source.Fingerprint == "" || (source.IdentityRole == "") != (source.IdentityProviderFingerprint == "") { return fmt.Errorf("receipt has invalid authority provenance") } + if source.IdentityRole != "" { + if err := humanidentity.ValidateRole(source.IdentityRole); err != nil || !validSHA256(source.IdentityProviderFingerprint) { + return fmt.Errorf("receipt has invalid authority identity provenance") + } + } if index > 0 && sources[index-1].ID == source.ID { return fmt.Errorf("receipt duplicates authority source %q", source.ID) } diff --git a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go index 90f9a06..0a603b1 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt_fact_test.go @@ -13,7 +13,9 @@ import ( func committedReceiptFixture(t *testing.T) (TransitionReceipt, Admission, catalog.Transition, model.Snapshot, time.Time) { t.Helper() now := time.Unix(200, 0).UTC() - authority := capabilityAuthority(now, catalog.AuthorityRepository, "policy") + authority := capabilityAuthority(now, catalog.AuthorityHuman, "operator") + authority.Receipts[0].IdentityRole = "developer" + authority.Receipts[0].IdentityProviderFingerprint = strings.Repeat("9", 64) authorityFingerprint, err := authority.Fingerprint() if err != nil { t.Fatal(err) @@ -73,6 +75,9 @@ func TestCommittedTransitionFactBindsProgramTransitionStateAuthorityEffectsAndVe if receipt.AuthorityFingerprint != admission.AuthorityFingerprint || len(receipt.AuthoritySources) != 1 || len(receipt.RequiredCapabilities) != 1 || len(receipt.GrantedCapabilities) == 0 { t.Fatalf("authority fact = %#v", receipt) } + if receipt.AuthoritySources[0].IdentityRole != "developer" || receipt.AuthoritySources[0].IdentityProviderFingerprint != strings.Repeat("9", 64) { + t.Fatalf("authority identity provenance = %#v", receipt.AuthoritySources[0]) + } if len(receipt.ExercisedCapabilities) != 0 { t.Fatalf("receipt fabricated capability exercise: %#v", receipt.ExercisedCapabilities) } diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go index a257c32..1a61621 100644 --- a/boatstack/internal/softwaredelivery/protocol/work.go +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -63,7 +63,7 @@ type WorkEvidence struct { func (e WorkEvidence) Validate() error { if e.SchemaVersion != WorkEvidenceSchemaVersion || e.RequestID == "" || !validSHA256(e.RequestFingerprint) || - !validSHA256(e.ResultFingerprint) || e.ContractID == "" || !validSHA256(e.ContractFingerprint) || e.TransitionID == "" || + !validSHA256(e.ResultFingerprint) || e.RunID == "" || e.ProgramID == "" || e.EntryID == "" || e.ContractID == "" || !validSHA256(e.ContractFingerprint) || e.TransitionID == "" || !validSHA256(e.ProgramFingerprint) || !validSHA256(e.ContextFingerprint) || e.StateRevision == 0 || e.RepositoryID == "" || e.WorktreeID == "" || len(e.Outputs) == 0 { return fmt.Errorf("foreground work evidence has incomplete identity") }