diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index b21fc30..97a4356 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -435,7 +435,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali self.assertEqual(bound["decision"]["kind"], "PRESCRIBED") self.assertEqual(bound["decision"]["transition"]["id"], "plan.create") - def test_repository_authority_rematerialization_fails_closed_without_verified_config(self) -> None: + def test_repository_authority_rematerialization_waits_for_verified_config(self) -> None: # control-law: repository-authority-requires-exact-verified-fingerprint root = Path(self.work.name) / "unverified" root.mkdir() @@ -446,19 +446,18 @@ def test_repository_authority_rematerialization_fails_closed_without_verified_co self._git(root, "add", "README.md") self._git(root, "commit", "-m", "fixture") before = self.porcelain(root) - result = self.run_helper( + result = self.helper_json( "next", "--repo", root, "--objective-id", "unverified-authority", "--target-id", "open-or-updated-pr", "--delivery", "unverified-authority", "--run-id", "flow-unverified-authority", "--human", "contract", "--repository-authority", - cwd=root, expected=1, - ) - self.assertIn( - "repository authority requires current verified configuration evidence", - result.stderr, + cwd=root, ) + self.assertEqual(result["decision"]["kind"], "CANDIDATE") + self.assertEqual(result["decision"]["transition"]["id"], "installation.initialize") + self.assertNotIn('"repository-policy"', json.dumps(result)) self.assertEqual(self.porcelain(root), before) diff --git a/boatstack/cmd/boatstack-helper/control_bundle.go b/boatstack/cmd/boatstack-helper/control_bundle.go new file mode 100644 index 0000000..3c95b24 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/control_bundle.go @@ -0,0 +1,326 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +type hostSkillProjectionManifest struct { + SchemaVersion int `json:"schema_version"` + Files map[string]string `json:"files"` +} + +func buildRepositoryControlBundle(ctx context.Context, repository string) (boatstackruntime.ControlBundleSnapshot, error) { + return buildRepositoryControlBundleAllowingInitialization(ctx, repository, false) +} + +func buildRepositoryControlBundleAllowingInitialization(ctx context.Context, repository string, allowMissingProject bool) (boatstackruntime.ControlBundleSnapshot, error) { + repository, err := filepath.Abs(repository) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + paths := map[string]struct{}{} + absent := []string{} + if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "project.json")); statErr == nil { + paths[".boatstack/project.json"] = struct{}{} + } else if os.IsNotExist(statErr) && allowMissingProject { + absent = append(absent, ".boatstack/project.json") + } else if statErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: .boatstack/project.json is required: %w", statErr) + } + if _, statErr := os.Lstat(filepath.Join(repository, ".boatstack", "runtime.json")); statErr == nil { + paths[".boatstack/runtime.json"] = struct{}{} + } else if os.IsNotExist(statErr) { + absent = append(absent, ".boatstack/runtime.json") + } else if !os.IsNotExist(statErr) { + return boatstackruntime.ControlBundleSnapshot{}, statErr + } + manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") + if raw, readErr := os.ReadFile(manifestPath); readErr == nil { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var manifest hostSkillProjectionManifest + decodeErr := decoder.Decode(&manifest) + var trailing any + trailingErr := decoder.Decode(&trailing) + if decodeErr != nil || trailingErr != io.EOF || manifest.SchemaVersion != 1 || manifest.Files == nil { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: host-skill manifest is malformed") + } + paths[".boatstack/host-skills.json"] = struct{}{} + for path, expected := range manifest.Files { + absolute, pathErr := exactRepositoryPath(repository, filepath.FromSlash(path)) + if pathErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, pathErr + } + raw, fileErr := os.ReadFile(absolute) + if fileErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, fileErr + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != expected { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_STALE: host skill %s does not match its manifest", path) + } + paths[filepath.ToSlash(path)] = struct{}{} + } + } else if !os.IsNotExist(readErr) { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } else { + absent = append(absent, ".boatstack/host-skills.json") + } + artifacts, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ir.json")) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + sort.Strings(artifacts) + artifactPaths := make([]string, 0, len(artifacts)) + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, err + } + for _, artifactPath := range artifacts { + raw, readErr := os.ReadFile(artifactPath) + if readErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } + artifact, loadErr := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if loadErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, loadErr + } + if _, checkErr := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills); checkErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, checkErr + } + relative, relErr := filepath.Rel(repository, artifactPath) + if relErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, relErr + } + relative = filepath.ToSlash(relative) + artifactPaths = append(artifactPaths, relative) + paths[relative] = struct{}{} + paths[artifact.SourcePath] = struct{}{} + paths[artifact.DependencyLockPath] = struct{}{} + for path := range artifact.Assets { + paths[path] = struct{}{} + } + for path := range artifact.GeneratedSkills { + paths[path] = struct{}{} + } + } + files := make(map[string][]byte, len(paths)) + for path := range paths { + absolute, pathErr := exactRepositoryPath(repository, filepath.FromSlash(path)) + if pathErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, pathErr + } + info, statErr := os.Lstat(absolute) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return boatstackruntime.ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %s is not a regular file", path) + } + raw, readErr := os.ReadFile(absolute) + if readErr != nil { + return boatstackruntime.ControlBundleSnapshot{}, readErr + } + files[filepath.ToSlash(path)] = raw + } + return boatstackruntime.NewControlBundleSnapshotWithMemberSets(files, absent, []boatstackruntime.ControlBundleMemberSet{{ + Root: ".boatstack/flows", Suffix: ".flow.ir.json", Paths: artifactPaths, + }}) +} + +func controlBundleRequired(id catalog.TransitionID) bool { + switch id { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile", + "workspace.cut", "workspace.cleanup", "workspace.reap", "workspace.reconcile": + return true + default: + return false + } +} + +func bindControlBundle(ctx context.Context, repository string, transitionID catalog.TransitionID, parameters protocol.Parameters) (*boatstackruntime.ControlBundleContract, string, error) { + snapshot, err := buildRepositoryControlBundleAllowingInitialization(ctx, repository, transitionID == "installation.initialize") + if err != nil { + return nil, "", err + } + var sourcePin *boatstackruntime.Pin + if pinRaw, readErr := os.ReadFile(boatstackruntime.PinPath(repository)); readErr == nil { + pin, decodeErr := boatstackruntime.DecodePin(pinRaw) + if decodeErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_INVALID: decode runtime pin: %w", decodeErr) + } + sourcePin = &pin + } else if !os.IsNotExist(readErr) { + return nil, "", readErr + } + if !controlBundleRequired(transitionID) { + contract, contractErr := boatstackruntime.NewControlBundleContractWithPins(snapshot, nil, "", sourcePin, nil) + return &contract, snapshot.Fingerprint, contractErr + } + var target *boatstackruntime.ControlBundleSnapshot + targetRevision := "" + switch transitionID { + case "installation.initialize": + configPath, ok := parameters.Get("config_path") + if !ok { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: installation.initialize requires config_path") + } + configInfo, statErr := os.Lstat(configPath) + if statErr != nil || configInfo.Mode()&os.ModeSymlink != 0 || !configInfo.Mode().IsRegular() { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: project configuration is not a regular file") + } + configRaw, readErr := os.ReadFile(configPath) + if readErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: read project configuration: %w", readErr) + } + projected, projectErr := boatstackruntime.ReplaceControlBundleFile(snapshot, ".boatstack/project.json", configRaw) + if projectErr != nil { + return nil, "", projectErr + } + config, decodeErr := protocol.DecodeProjectConfig(configRaw) + if decodeErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: decode project configuration: %w", decodeErr) + } + _, configFingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(configRaw) + if fingerprintErr != nil { + return nil, "", fingerprintErr + } + if expected, exists := parameters.Get("config_sha256"); !exists || expected != configFingerprint { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: project configuration fingerprint changed") + } + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + if projectionErr != nil { + return nil, "", projectionErr + } + projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, ".boatstack/host-skills.json", manifestRaw) + if projectErr != nil { + return nil, "", projectErr + } + for path, raw := range hostFiles { + projected, projectErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) + if projectErr != nil { + return nil, "", projectErr + } + } + target = &projected + case "installation.update", "installation.reconcile-update": + configRaw, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if readErr != nil { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: read project configuration: %w", readErr) + } + config, decodeErr := protocol.DecodeProjectConfig(configRaw) + if decodeErr != nil { + return nil, "", decodeErr + } + hostFiles, manifestRaw, projectionErr := effects.ProjectedHostSkillFiles(config.Hosts) + if projectionErr != nil { + return nil, "", projectionErr + } + projected, projectionErr := boatstackruntime.ReplaceControlBundleFile(snapshot, ".boatstack/host-skills.json", manifestRaw) + if projectionErr != nil { + return nil, "", projectionErr + } + for path, raw := range hostFiles { + projected, projectionErr = boatstackruntime.ReplaceControlBundleFile(projected, path, raw) + if projectionErr != nil { + return nil, "", projectionErr + } + } + target = &projected + case "workspace.cut": + baseRef, exists := parameters.Get("base_ref") + if !exists { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: workspace.cut requires an exact base_ref") + } + resolvedRevision, resolveErr := boatstackruntime.ResolveCommitRevision(ctx, repository, baseRef) + if resolveErr != nil { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: resolve base_ref %q: %w", baseRef, resolveErr) + } + targetRevision = resolvedRevision + if verifyErr := boatstackruntime.VerifyControlBundleRevision(ctx, repository, targetRevision, snapshot); verifyErr != nil { + return nil, "", fmt.Errorf("WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED: %w", verifyErr) + } + copy := snapshot + copy.Files = append([]boatstackruntime.ControlBundleFile(nil), snapshot.Files...) + target = © + case "workspace.cleanup", "workspace.reap": + copy := snapshot + copy.Files = append([]boatstackruntime.ControlBundleFile(nil), snapshot.Files...) + target = © + case "workspace.reconcile": + transactionID, ok := parameters.Get("transaction_id") + if !ok { + return nil, "", fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: workspace.reconcile requires transaction_id") + } + resolver, resolverErr := plant.NewResolver("") + if resolverErr != nil { + return nil, "", resolverErr + } + invocation, invocationErr := resolver.ResolveInvocation(ctx, repository, "cli", "workspace-control-bundle-recovery") + if invocationErr != nil { + return nil, "", invocationErr + } + layout, _, layoutErr := resolver.ResolveLayout(ctx, invocation) + if layoutErr != nil { + return nil, "", layoutErr + } + recoveredTarget, recoveredRevision, recoveryErr := effects.InterruptedWorkspaceTarget(layout, transactionID) + if recoveryErr != nil { + return nil, "", recoveryErr + } + target, targetRevision = &recoveredTarget, recoveredRevision + } + var targetPin *boatstackruntime.Pin + if target != nil { + targetPin = sourcePin + } + contract, err := boatstackruntime.NewControlBundleContractWithPins(snapshot, target, targetRevision, sourcePin, targetPin) + return &contract, snapshot.Fingerprint, err +} + +func bindTrustedRequestControlBundle(ctx context.Context, request *surfaces.Request) error { + if request == nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request is nil") + } + request.ControlBundle = nil + request.ControlBundleFingerprint = "" + if request.ProgramID == "" && !controlBundleRequired(request.TransitionID) { + return nil + } + bundle, fingerprint, err := bindControlBundle(ctx, request.Repository, request.TransitionID, request.Parameters) + if err != nil { + return err + } + request.ControlBundle = bundle + request.ControlBundleFingerprint = fingerprint + return nil +} + +func verifyTrustedRequestControlBundle(request surfaces.Request) error { + if request.ControlBundle == nil { + if controlBundleRequired(request.TransitionID) { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q has no trusted bundle", request.TransitionID) + } + return nil + } + return boatstackruntime.VerifyControlBundleRoot(request.Repository, request.ControlBundle.Source) +} diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 01a1fb6..f90f244 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -10,6 +10,7 @@ import ( "os" "time" + "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -19,6 +20,26 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) +var resolveGitHubProviderAuthority = func(ctx context.Context, repository, previewFingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { + return effects.NewNativeBoundary().ResolveGitHubProviderAuthority(ctx, repository, previewFingerprint, now) +} + +func trustedProviderAuthorityParameter(ctx context.Context, transitionID string) (string, error) { + if transitionID == "" { + return "", nil + } + manifest, err := standard.Definition().RuntimeManifest(ctx) + if err != nil { + return "", err + } + for _, transition := range manifest.Transitions { + if string(transition.ID) == transitionID { + return transition.AuthorityFingerprintParameter, nil + } + } + return "", nil +} + func runFlowAuthorize(arguments []string) error { flags := flag.NewFlagSet("flow authorize", flag.ContinueOnError) flags.SetOutput(os.Stderr) @@ -250,7 +271,16 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + resolveLease, err := acquireFlowExecutionLease(resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + resolveLease.Release() + return surfaces.Response{}, err + } resolved, err := kernel.Handle(ctx, resolveRequest) + resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } @@ -258,7 +288,13 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return resolved, err } - rebound, changed, rebindErr := bindContinuationCandidate(ctx, bound, resolved) + rebound, changed, rebindErr := bindTrustedProviderCandidate(ctx, bound, resolved) + if rebindErr != nil { + return surfaces.Response{}, rebindErr + } + if !changed { + rebound, changed, rebindErr = bindContinuationCandidate(ctx, bound, resolved) + } if rebindErr != nil { return surfaces.Response{}, rebindErr } @@ -280,7 +316,16 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } + resolveLease, err = acquireFlowExecutionLease(resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if err := verifyTrustedRequestControlBundle(resolveRequest); err != nil { + resolveLease.Release() + return surfaces.Response{}, err + } resolved, err = kernel.Handle(ctx, resolveRequest) + resolveLease.Release() if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } @@ -310,6 +355,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return surfaces.Response{}, err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(applyRequest); err != nil { + return surfaces.Response{}, err + } applied, err := kernel.Handle(ctx, applyRequest) targetSatisfied := kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective) if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { @@ -335,6 +383,36 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return applied, nil } +func bindTrustedProviderCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { + if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || + (response.Decision.Kind != supervisor.DecisionFrontier && response.Decision.Kind != supervisor.DecisionCandidate) || + len(response.Decision.Candidates) != 1 { + return bound, false, nil + } + candidate := string(response.Decision.Candidates[0]) + authorityParameter, err := trustedProviderAuthorityParameter(ctx, candidate) + if err != nil { + return commandOptions{}, false, err + } + if authorityParameter == "" { + return bound, false, nil + } + rebound := bound + rebound.transitionID = candidate + rebound, err = bindFlowEntry(ctx, rebound) + if err != nil { + return commandOptions{}, false, err + } + parameters, err := parseParameters(rebound.parameters) + if err != nil { + return commandOptions{}, false, err + } + if fingerprint, ok := parameters.Get(authorityParameter); !ok || fingerprint == "" { + return bound, false, nil + } + return rebound, true, nil +} + func bindContinuationCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) { if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || response.Decision.Kind != supervisor.DecisionCandidate || response.Decision.Transition == nil || len(response.Decision.Candidates) != 1 { return bound, false, nil @@ -381,5 +459,6 @@ func advanceContinuation(options *commandOptions, response surfaces.Response) er options.requiredCapabilities = nil options.effectiveCapabilities = nil options.idempotencyKey = "" + options.trustedAuthorityReceipts = nil return nil } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 9cc829a..49f43dd 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -77,7 +77,7 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, err } - if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { + if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint { releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") } diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 6f2ddeb..a606115 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -21,7 +21,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) -const flowCompilerVersion = "control-program.compiler.1" +const flowCompilerVersion = "control-program.compiler.3" type flowCommandOptions struct { repository string @@ -33,7 +33,7 @@ type flowCommandOptions struct { func runFlowCommand(arguments []string) error { if len(arguments) == 0 { - return fmt.Errorf("usage: boatstack flow [flags]") + return fmt.Errorf("usage: boatstack flow [flags]") } action := arguments[0] if action == "authorize" { @@ -45,6 +45,9 @@ func runFlowCommand(arguments []string) error { if action == "run" { return runFlowContinuation(arguments[1:]) } + if action == "work" { + return runFlowWork(arguments[1:]) + } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) options := flowCommandOptions{} @@ -116,7 +119,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - compiled, err := controlprogram.Load(bytes.NewReader(rawIR), resolver) + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(rawIR), resolver, controlprogram.RepositoryAssetResolver{Repository: options.repository}) if err != nil { return err } @@ -162,13 +165,29 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { writes = append(writes, boatstackruntime.ProjectionWrite{ Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactPrevious, PublishLast: true, }) - if err := rejectProjectionInputOverlap(lockPath, writes, removals); err != nil { - return err - } expectations := []boatstackruntime.ProjectionExpectation{ {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, } + compileInputs := []string{source, lockPath} + assetPaths := make([]string, 0, len(artifact.Assets)) + for relative := range artifact.Assets { + assetPaths = append(assetPaths, relative) + } + sort.Strings(assetPaths) + for _, relative := range assetPaths { + absolute, pathErr := exactRepositoryPath(options.repository, relative) + if pathErr != nil { + return pathErr + } + compileInputs = append(compileInputs, absolute) + expectations = append(expectations, boatstackruntime.ProjectionExpectation{ + Path: absolute, Exists: true, ExpectedSHA256: artifact.Assets[relative], + }) + } + if err := rejectProjectionInputOverlap(compileInputs, writes, removals); err != nil { + return err + } artifactRelative, _ := filepath.Rel(options.repository, artifactPath) nextOwnership := boatstackruntime.NewFlowProjectionOwnership(filepath.ToSlash(sourceRelative), filepath.ToSlash(artifactRelative), artifactRaw, skills) if err := boatstackruntime.ApplyOwnedFlowProjection(options.repository, writes, removals, expectations, ownership, nextOwnership); err != nil { @@ -177,16 +196,19 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { return renderFlowResult("compiled", artifactPath, artifact) } -func rejectProjectionInputOverlap(lockPath string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error { - lockPath = filepath.Clean(lockPath) +func rejectProjectionInputOverlap(inputs []string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error { + bound := make(map[string]bool, len(inputs)) + for _, input := range inputs { + bound[filepath.Clean(input)] = true + } for _, write := range writes { - if filepath.Clean(write.Path) == lockPath { - return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a projection output") + if bound[filepath.Clean(write.Path)] { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: compile input %s is a projection output", write.Path) } } for _, removal := range removals { - if filepath.Clean(removal.Path) == lockPath { - return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a retired projection output") + if bound[filepath.Clean(removal.Path)] { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: compile input %s is a retired projection output", removal.Path) } } return nil diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index bbc9f7e..bac1ce6 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -14,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" @@ -88,18 +89,19 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } planDigest := sha256.Sum256(planRaw) planFingerprint = hex.EncodeToString(planDigest[:]) - repositoryIdentity, identityErr := flowRepositoryIdentity(repository) - if identityErr != nil { - return commandOptions{}, identityErr - } - runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint) - if options.runID != "" && options.runID != runID { - return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + options, err = bindSelectedPlanRun(options, repository, compiled.Fingerprint, deliveryID, planFingerprint) + if err != nil { + return commandOptions{}, err } - options.runID = runID } else if options.runID == "" { return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity") } + options.workInputs = map[string]protocol.WorkInputValue{} + for _, input := range entry.Inputs { + if plan != "" { + options.workInputs[input.ID] = protocol.WorkInputValue{Value: plan, Fingerprint: planFingerprint} + } + } options.repository = repository if options.targetID == "" { options.targetID = string(objective.TargetID) @@ -117,6 +119,32 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if options.targetID != string(objective.TargetID) || options.trustedObjectiveClass != string(objective.TrustedClass) || options.deliveryID != deliveryID || options.objectiveID != expectedObjectiveID { return commandOptions{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: objective or delivery changed across the run") } + if options.transitionID == "installation.initialize" { + configPath := filepath.Join(repository, ".boatstack", "project.json") + initialParameters, parseErr := parseParameters(options.parameters) + if parseErr != nil { + return commandOptions{}, parseErr + } + if err := bindResolvedParameter(&options, initialParameters, "config_path", configPath); err != nil { + return commandOptions{}, err + } + if err := populateProjectConfigFingerprint(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind verified project configuration: %w", err) + } + if err := populateRuntimeParameters(&options); err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: bind exact runtime identity: %w", err) + } + } + parameters, err := parseParameters(options.parameters) + if err != nil { + return commandOptions{}, err + } + bundle, bundleFingerprint, err := bindControlBundle(ctx, repository, catalog.TransitionID(options.transitionID), parameters) + if err != nil { + return commandOptions{}, err + } + options.controlBundle = bundle + options.controlBundleFingerprint = bundleFingerprint if entry.Delegation != nil { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { @@ -136,7 +164,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } delegationRequest := delegation.Request{ RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, - EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, + ControlBundleFingerprint: bundleFingerprint, + EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, InputFingerprints: []string{planFingerprint}, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, BindingFingerprint: entry.Delegation.Fingerprint, RequestedAuthorities: append([]string(nil), entry.Delegation.Authorities...), @@ -152,8 +181,9 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } if record, loadErr := delegation.Load(recordPath); loadErr == nil { bound := record.Request - if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { - return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request") + inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") + if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + return commandOptions{}, fmt.Errorf("DELEGATION_DRIFT: current Flow context does not match the authorized request (bundle %s, authorized %s)", delegationRequest.ControlBundleFingerprint, bound.ControlBundleFingerprint) } delegationRequest = bound } else if !os.IsNotExist(loadErr) { @@ -169,10 +199,6 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, options.delegationDescription = description options.delegationRequest = delegationRequest } - parameters, err := parseParameters(options.parameters) - if err != nil { - return commandOptions{}, err - } for name, expected := range map[string]string{ "target_id": string(objective.TargetID), "delivery_id": deliveryID, @@ -191,7 +217,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { return commandOptions{}, err } - case "plan.create", "plan.amend": + case "plan.create", "plan.amend", softwareflow.PlanningPackageAdmit: if err := bindResolvedParameter(&options, parameters, "source_path", plan); err != nil { return commandOptions{}, err } @@ -201,14 +227,107 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if err := bindResolvedParameter(&options, parameters, "source_fingerprint", planFingerprint); err != nil { return commandOptions{}, err } + case softwareflow.PlanningPackageApprove: + fingerprint, fingerprintErr := softwareflow.PlanningPackageFingerprint(repository, deliveryID) + if fingerprintErr != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read planning package fingerprint: %w", fingerprintErr) + } + if err := bindResolvedParameter(&options, parameters, "package_fingerprint", fingerprint); err != nil { + return commandOptions{}, err + } + case "publication.preview": + if err := bindPublicationPreviewParameters(ctx, repository, deliveryID, options.host, &options, parameters); err != nil { + return commandOptions{}, err + } } return options, nil } -func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective softwareflow.EntryObjective) (commandOptions, error) { - if options.runID != "" && entryObjective.TrustedClass != model.ObjectiveAbandoned { +func bindPublicationPreviewParameters(ctx context.Context, repository, deliveryID, host string, options *commandOptions, parameters protocol.Parameters) error { + canonicalRepository, err := filepath.EvalSymlinks(repository) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: resolve publication repository: %w", err) + } + repository = canonicalRepository + configRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "project.json")) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: read publication configuration: %w", err) + } + config, err := protocol.DecodeProjectConfig(configRaw) + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: decode publication configuration: %w", err) + } + resolver, err := plant.NewResolver("") + if err != nil { + return err + } + if host == "" { + host = "cli" + } + invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-publication-preview") + if err != nil { + return err + } + if !strings.HasPrefix(invocation.Ref, "refs/heads/") { + return fmt.Errorf("FLOW_INPUT_REQUIRED: publication requires an attached branch") + } + bodyPath, err := resolveRegularRepositoryFile(repository, filepath.Join(repository, ".boatstack", "evidence", deliveryID+"-pr-body.md"), "publication body") + if err != nil { + return fmt.Errorf("FLOW_INPUT_REQUIRED: bind publication body: %w", err) + } + for name, value := range map[string]string{ + "base_ref": config.Project.DefaultBranch, + "head_ref": strings.TrimPrefix(invocation.Ref, "refs/heads/"), + "body_path": bodyPath, + } { + if err := bindResolvedParameter(options, parameters, name, value); err != nil { + return err + } + } + return nil +} + +func populateProjectConfigFingerprint(options *commandOptions) error { + parameters, err := parseParameters(options.parameters) + if err != nil { + return err + } + if _, exists := parameters.Get("config_sha256"); exists { + return nil + } + path, exists := parameters.Get("config_path") + if !exists { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + _, fingerprint, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return err + } + options.parameters = append(options.parameters, "config_sha256="+fingerprint) + return nil +} + +func bindSelectedPlanRun(options commandOptions, repository, programFingerprint, deliveryID, planFingerprint string) (commandOptions, error) { + if options.activeFlowBound { return options, nil } + repositoryIdentity, err := flowRepositoryIdentity(repository) + if err != nil { + return commandOptions{}, err + } + runID := flowRunID(repositoryIdentity, programFingerprint, options.entryID, deliveryID, planFingerprint) + if options.runID != "" && options.runID != runID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + } + options.runID = runID + return options, nil +} + +func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective softwareflow.EntryObjective) (commandOptions, error) { resolver, err := plant.NewResolver("") if err != nil { return commandOptions{}, err @@ -258,10 +377,11 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity") } if active.TargetID == entryObjective.TargetID && strings.HasPrefix(active.ID, prefix) { - options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID - options.objectiveID, options.targetID, options.trustedObjectiveClass = active.ID, string(active.TargetID), string(active.TrustedObjectiveClass()) - options.activeFlowBound = true - return options, nil + bound, bindErr := bindCommittedActiveRun(options, active, receipt) + if bindErr != nil { + return commandOptions{}, bindErr + } + return bindStateOwnedTransitionParameters(bound, state) } if entryObjective.TrustedClass == model.ObjectiveAbandoned { repositoryIdentity, identityErr := flowRepositoryIdentity(repository) @@ -279,6 +399,46 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_CONFLICT: delivery %q is active under objective %q; abandon it before selecting another inbox plan", active.DeliveryID, active.ID) } +func bindStateOwnedTransitionParameters(options commandOptions, state durable.State) (commandOptions, error) { + parameters, err := parseParameters(options.parameters) + if err != nil { + return commandOptions{}, err + } + bindings := [][2]string{} + switch options.transitionID { + case "workspace.activate", "workspace.sync", "workspace.publish": + bindings = append(bindings, [2]string{"branch", state.WorkspaceBranch}) + case "publication.execute": + bindings = append(bindings, [2]string{"preview_fingerprint", state.PreviewFingerprint}) + case "publication.observe": + bindings = append(bindings, [2]string{"publication_id", state.PublicationID}) + case "publication.reconcile": + bindings = append(bindings, + [2]string{"publication_id", state.PublicationID}, + [2]string{"transaction_id", state.TransactionID}, + ) + } + for _, binding := range bindings { + if binding[1] == "" { + continue + } + if err := bindResolvedParameter(&options, parameters, binding[0], binding[1]); err != nil { + return commandOptions{}, err + } + } + return options, nil +} + +func bindCommittedActiveRun(options commandOptions, active model.Objective, receipt protocol.TransitionReceipt) (commandOptions, error) { + if options.runID != "" && options.runID != receipt.FlowID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the committed active delivery") + } + options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID + options.objectiveID, options.targetID, options.trustedObjectiveClass = active.ID, string(active.TargetID), string(active.TrustedObjectiveClass()) + options.activeFlowBound = true + return options, nil +} + func validateResolvedParameter(parameters protocol.Parameters, name, expected string) error { if actual, exists := parameters.Get(name); exists && actual != expected { return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) @@ -329,6 +489,9 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R request.DelegationBindingFingerprint = bound.delegationBindingFingerprint request.DelegationRequestFingerprint = bound.delegationRequestFingerprint request.DelegatedAuthorities = delegationClasses(bound.delegationAuthorities) + request.WorkInputs = bound.workInputs + request.ControlBundle = bound.controlBundle + request.ControlBundleFingerprint = bound.controlBundleFingerprint return request, nil } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 85c2f4d..a553d99 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -19,6 +19,7 @@ import ( boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" @@ -206,6 +207,10 @@ func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory func writeFlowArtifact(t *testing.T, repository string, document controlprogram.Document, sourcePath string, source []byte, lockPath string, lock []byte) { t.Helper() + projectPath := filepath.Join(repository, ".boatstack", "project.json") + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"]}`)) + } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { t.Fatal(err) @@ -398,6 +403,143 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { } } +func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { + // control-law: workspace-cut-preserves-the-exact-active-control-bundle + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + raw, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + bundle, err := buildRepositoryControlBundle(context.Background(), repository) + if err != nil { + t.Fatal(err) + } + if err := boatstackruntime.VerifyControlBundleRevision(context.Background(), repository, "HEAD", bundle); err != nil { + t.Fatalf("committed control bundle rejected: %v", err) + } + + var skillPath string + for path := range artifact.GeneratedSkills { + skillPath = path + break + } + if skillPath == "" { + t.Fatal("fixture generated no skills") + } + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), []byte("regenerated but uncommitted\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err = buildRepositoryControlBundle(context.Background(), repository) + if err == nil || !strings.Contains(err.Error(), skillPath) { + t.Fatalf("uncommitted generated skill result = %v", err) + } +} + +func TestWorkspaceCutRejectsUncommittedRuntimePinBeforeEffect(t *testing.T) { + // control-law: a runtime pin cannot outrun the committed Flow projection + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin( + boatstackruntime.Identity{Version: "v-test", SHA256: strings.Repeat("a", 64), SourceRevision: "test-revision"}, + strings.Repeat("b", 64), durable.StateSchemaVersion, + )) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "runtime.json"), pinRaw, 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(t.TempDir(), "workspace") + _, _, err = bindControlBundle(context.Background(), repository, "workspace.cut", protocol.Parameters{ + {Name: "base_ref", Value: "HEAD"}, {Name: "branch", Value: "feature/bundle"}, {Name: "destination", Value: destination}, + }) + if err == nil || !strings.Contains(err.Error(), "WORKSPACE_CONTROL_BUNDLE_UNCOMMITTED") || !strings.Contains(err.Error(), ".boatstack/runtime.json") { + t.Fatalf("uncommitted runtime pin result = %v", err) + } + if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) { + t.Fatalf("workspace was created before bundle admission: %v", statErr) + } +} + +func TestWorkspaceCutFreezesMovingBaseReference(t *testing.T) { + // control-law: a moving branch cannot change an already admitted workspace base + repository := flowRepository(t) + repository, err := filepath.EvalSymlinks(repository) + if err != nil { + t.Fatal(err) + } + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.name", "Boatstack Tests") + runFlowGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "control bundle") + want := strings.TrimSpace(runFlowGitOutput(t, repository, "rev-parse", "HEAD")) + contract, _, err := bindControlBundle(context.Background(), repository, "workspace.cut", protocol.Parameters{{Name: "base_ref", Value: "HEAD"}}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, "README.md", []byte("move branch\n")) + runFlowGit(t, repository, "add", "README.md") + runFlowGit(t, repository, "commit", "-q", "-m", "move branch") + if contract.TargetRevision != want { + t.Fatalf("target revision changed with branch: got %s want %s", contract.TargetRevision, want) + } +} + +func TestOneStaleFlowBlocksMultiFlowControlBundle(t *testing.T) { + // control-law: a repository control bundle is complete across every Flow + repository := flowRepository(t) + document := productDeliveryDocument("secondary-delivery") + sourcePath := ".boatstack/flows/secondary-delivery.flow.ts" + source := []byte("secondary source") + lockPath := "package-lock.json" + lockRaw, err := os.ReadFile(filepath.Join(repository, lockPath)) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, sourcePath, source) + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lockRaw) + artifactRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", "secondary-delivery.flow.ir.json")) + if err != nil { + t.Fatal(err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(artifactRaw)) + if err != nil { + t.Fatal(err) + } + for path := range artifact.GeneratedSkills { + writeFixture(t, repository, path, []byte("stale secondary projection\n")) + if _, bundleErr := buildRepositoryControlBundle(context.Background(), repository); bundleErr == nil || !strings.Contains(bundleErr.Error(), path) { + t.Fatalf("stale secondary Flow did not block complete bundle: %v", bundleErr) + } + return + } + t.Fatal("secondary Flow generated no entry skill") +} + func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { // control-law: entry-resolved-inputs-cannot-be-replaced-by-callers for _, surface := range []string{"cli", "rpc"} { @@ -1173,6 +1315,34 @@ func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing } } + projectConfig := []byte(`{"schema_version":2,"project":{"name":"fresh-flow","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex"]}`) + writeFixture(t, repository, ".boatstack/project.json", projectConfig) + installationInitialize := catalog.Transition{ID: "installation.initialize", Parameters: []catalog.ParameterSpec{ + {Name: "config_path", Required: true}, {Name: "config_sha256", Required: true}, {Name: "runtime_version", Required: true}, {Name: "runtime_sha256", Required: true}, {Name: "source_revision", Required: true}, + }} + installationBound, installationChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{ + Kind: supervisor.DecisionCandidate, Transition: &installationInitialize, Candidates: []catalog.TransitionID{"installation.initialize"}, + }}) + if err != nil || !installationChanged || installationBound.transitionID != "installation.initialize" { + t.Fatalf("installation candidate rebind = options=%#v changed=%t err=%v", installationBound, installationChanged, err) + } + installationParameters, err := parseParameters(installationBound.parameters) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"config_path", "config_sha256", "runtime_version", "runtime_sha256", "source_revision"} { + if value, ok := installationParameters.Get(name); !ok || value == "" { + t.Fatalf("installation parameter %q = %q, %t", name, value, ok) + } + } + _, expectedConfigFingerprint, err := protocol.ProjectConfigFingerprint(projectConfig) + if err != nil { + t.Fatal(err) + } + if actual, _ := installationParameters.Get("config_sha256"); actual != expectedConfigFingerprint { + t.Fatalf("installation config fingerprint = %q, want semantic fingerprint %q", actual, expectedConfigFingerprint) + } + for name, decision := range map[string]supervisor.Decision{ "ambiguous": { Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, @@ -1211,6 +1381,93 @@ func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing } } +func TestPublicationPreviewParametersAreRepositoryResolved(t *testing.T) { + repository := t.TempDir() + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "checkout", "-q", "-b", "feature/publication") + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":2,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli"]}`)) + bodyPath := filepath.Join(repository, ".boatstack", "evidence", "delivery-pr-body.md") + writeFixture(t, repository, ".boatstack/evidence/delivery-pr-body.md", []byte("# Pull request\n")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "fixture") + options := commandOptions{repository: repository, transitionID: "publication.preview", host: "cli"} + if err := bindPublicationPreviewParameters(context.Background(), repository, "delivery", "cli", &options, nil); err != nil { + t.Fatal(err) + } + parameters, err := parseParameters(options.parameters) + if err != nil { + t.Fatal(err) + } + canonicalBodyPath, err := filepath.EvalSymlinks(bodyPath) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"base_ref": "main", "head_ref": "feature/publication", "body_path": canonicalBodyPath} { + if got, ok := parameters.Get(name); !ok || got != want { + t.Fatalf("publication parameter %s = %q, present=%t, want %q", name, got, ok, want) + } + } +} + +func TestStateOwnedTransitionParametersDoNotRequireHumanAnswers(t *testing.T) { + state := durable.State{ + WorkspaceBranch: "feat/exact-branch", + PreviewFingerprint: strings.Repeat("a", 64), + PublicationID: "123", + TransactionID: "adm-123", + } + for transition, expected := range map[string][]string{ + "workspace.activate": {"branch=feat/exact-branch"}, + "workspace.sync": {"branch=feat/exact-branch"}, + "workspace.publish": {"branch=feat/exact-branch"}, + "publication.execute": {"preview_fingerprint=" + strings.Repeat("a", 64)}, + "publication.observe": {"publication_id=123"}, + "publication.reconcile": {"publication_id=123", "transaction_id=adm-123"}, + } { + t.Run(transition, func(t *testing.T) { + bound, err := bindStateOwnedTransitionParameters(commandOptions{transitionID: transition}, state) + if err != nil || strings.Join(bound.parameters, "\x00") != strings.Join(expected, "\x00") { + t.Fatalf("state-owned binding = %#v, %v", bound.parameters, err) + } + }) + } + + _, err := bindStateOwnedTransitionParameters(commandOptions{ + transitionID: "workspace.activate", parameters: []string{"branch=feat/other"}, + }, state) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("caller override was not rejected: %v", err) + } +} + +func TestCommittedActiveRunRehydratesExactDeliveryWhenRunIDIsSupplied(t *testing.T) { + // control-law: a resumed run resolves inputs from its committed delivery before selecting work + active := model.Objective{ + ID: "objective-product-delivery-run-delivery-one", TargetID: "published-pr", + TrustedClass: model.ObjectiveOpenPR, DeliveryID: "delivery-one", + } + receipt := protocol.TransitionReceipt{FlowID: "run-committed"} + bound, err := bindCommittedActiveRun(commandOptions{runID: "run-committed"}, active, receipt) + if err != nil { + t.Fatal(err) + } + if !bound.activeFlowBound || bound.deliveryID != "delivery-one" || bound.objectiveID != active.ID || bound.targetID != "published-pr" || bound.trustedObjectiveClass != string(model.ObjectiveOpenPR) { + t.Fatalf("active run was not rehydrated: %#v", bound) + } + if _, err := bindCommittedActiveRun(commandOptions{runID: "run-other"}, active, receipt); err == nil || !strings.Contains(err.Error(), "FLOW_RUN_MISMATCH") { + t.Fatalf("conflicting run identity result = %v", err) + } +} + +func TestCommittedActiveRunIdentitySurvivesApprovedPlanTransformation(t *testing.T) { + // control-law: trusted plan transformation cannot rename the already-committed run + active := commandOptions{entryID: "run", runID: "run-committed", activeFlowBound: true} + bound, err := bindSelectedPlanRun(active, filepath.Join(t.TempDir(), "not-a-repository"), strings.Repeat("a", 64), "delivery", strings.Repeat("b", 64)) + if err != nil || bound.runID != "run-committed" { + t.Fatalf("active run transformation result = %#v err=%v", bound, err) + } +} + func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) { entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"} plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, softwareflow.EntryObjective{ @@ -1602,17 +1859,8 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) } otherWorktree := filepath.Join(t.TempDir(), "other-worktree") runFlowGit(t, repository, "worktree", "add", "-q", "-b", "other-worktree", otherWorktree) - otherBound, err := bindFlowEntry(context.Background(), commandOptions{repository: otherWorktree, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}) - if err != nil { - t.Fatal(err) - } - otherRequest, err := buildRequest(surfaces.OperationResolve, otherBound) - if err != nil { - t.Fatal(err) - } - otherLock, otherSuspension, otherErr := prepareDelegation(context.Background(), &otherRequest) - if otherLock != nil || otherSuspension != nil || otherErr == nil || !strings.Contains(otherErr.Error(), "DELEGATION_CONTEXT_UNAUTHORIZED") { - t.Fatalf("unauthorized worktree = lock=%v response=%#v err=%v", otherLock, otherSuspension, otherErr) + if _, otherErr := bindFlowEntry(context.Background(), commandOptions{repository: otherWorktree, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}); otherErr == nil || !strings.Contains(otherErr.Error(), "DELEGATION_DRIFT") { + t.Fatalf("unauthorized worktree bundle was not rejected: %v", otherErr) } runFlowGit(t, repository, "checkout", "-q", "-b", "changed-ref") refBound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", runID: bound.runID, deliveryID: bound.deliveryID, host: "codex"}) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 798acf8..25f41a0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -68,6 +68,7 @@ type commandOptions struct { acceptProgramChange bool parameters stringList authorityReceipts stringList + trustedAuthorityReceipts []protocol.AuthorityReceipt follow bool host string command string @@ -76,6 +77,16 @@ type commandOptions struct { delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request + workInputs map[string]protocol.WorkInputValue + workID string + workQuestionPrompt string + workQuestionSchemaPath string + workQuestionID string + workAnswerPath string + workBlockReason string + workResultFingerprint string + controlBundle *boatstackruntime.ControlBundleContract + controlBundleFingerprint string } func main() { @@ -132,6 +143,11 @@ func run(arguments []string) error { if err != nil { return err } + if request.ProgramID == "" { + if err := bindTrustedRequestControlBundle(context.Background(), &request); err != nil { + return err + } + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -147,6 +163,9 @@ func run(arguments []string) error { return err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -210,6 +229,11 @@ func runRPC() error { if err != nil { return err } + if request.ProgramID == "" { + if err := bindTrustedRequestControlBundle(context.Background(), &request); err != nil { + return err + } + } delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) if err != nil { return err @@ -227,6 +251,9 @@ func runRPC() error { return err } defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -353,6 +380,13 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)") flags.StringVar(&options.host, "host", options.host, "cli, sdk, cursor, codex, claude, gemini, or mcp") flags.StringVar(&options.command, "command", "", "raw command to classify at the guard boundary") + flags.StringVar(&options.workID, "work-id", "", "foreground work contract identity") + flags.StringVar(&options.workQuestionPrompt, "prompt", "", "bounded foreground work question") + flags.StringVar(&options.workQuestionSchemaPath, "question-schema", "", "JSON Schema path for a foreground work answer") + flags.StringVar(&options.workQuestionID, "question-id", "", "exact foreground work question identity") + flags.StringVar(&options.workAnswerPath, "answer", "", "JSON answer path") + flags.StringVar(&options.workBlockReason, "reason", "", "foreground work blocker") + flags.StringVar(&options.workResultFingerprint, "work-result-fingerprint", "", "exact foreground work result from resolution") if err := flags.Parse(arguments); err != nil { return commandOptions{}, err } @@ -412,7 +446,7 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De } func acquireFlowExecutionLease(request surfaces.Request) (*boatstackruntime.FlowProjectionLease, error) { - if request.ProgramID == "" || (request.Operation != surfaces.OperationApply && request.Operation != surfaces.OperationRecover) { + if request.ProgramID == "" && request.ControlBundle == nil { return &boatstackruntime.FlowProjectionLease{}, nil } return boatstackruntime.AcquireFlowProjectionLease(request.Repository) @@ -556,6 +590,20 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } + authorityParameter, resolveErr := trustedProviderAuthorityParameter(context.Background(), options.transitionID) + if resolveErr != nil { + return surfaces.Request{}, resolveErr + } + if authorityParameter != "" { + fingerprint, ok := parameters.Get(authorityParameter) + if ok && fingerprint != "" { + receipt, resolveErr := resolveGitHubProviderAuthority(context.Background(), options.repository, fingerprint, now) + if resolveErr != nil { + return surfaces.Request{}, resolveErr + } + options.trustedAuthorityReceipts = append(options.trustedAuthorityReceipts, receipt) + } + } authority, err := loadAuthority(options, correlation, objective, parameters, now) if err != nil { return surfaces.Request{}, err @@ -583,11 +631,18 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface ExpectedInstanceID: options.expectedInstanceID, ExpectedStateRevision: options.expectedStateRevision, ExpectedProgramFingerprint: options.expectedProgramFingerprint, ExpectedSnapshotFingerprint: options.expectedSnapshotFingerprint, ExpectedObjectiveBindingFingerprint: options.expectedObjectiveBindingFingerprint, AuthorityFingerprint: options.authorityFingerprint, - }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities}, + }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities, WorkResultFingerprint: options.workResultFingerprint}, RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, DelegationBindingFingerprint: options.delegationBindingFingerprint, DelegationRequestFingerprint: options.delegationRequestFingerprint, DelegatedAuthorities: delegationClasses(options.delegationAuthorities), + WorkInputs: options.workInputs, + WorkID: options.workID, + WorkQuestionPrompt: options.workQuestionPrompt, + WorkQuestionID: options.workQuestionID, + WorkBlockReason: options.workBlockReason, + ControlBundle: options.controlBundle, + ControlBundleFingerprint: options.controlBundleFingerprint, }, nil } @@ -664,6 +719,15 @@ func loadAuthority(options commandOptions, correlation string, objective model.O if err := decoder.Decode(&trailing); err != io.EOF { return protocol.AuthorityBundle{}, fmt.Errorf("authority receipt contains trailing JSON") } + if receipt.Class == catalog.AuthorityProvider { + return protocol.AuthorityBundle{}, fmt.Errorf("PROVIDER_AUTHORITY_UNTRUSTED: external-provider authority must be derived by the trusted provider boundary") + } + bundle.Receipts = append(bundle.Receipts, receipt) + } + for _, receipt := range options.trustedAuthorityReceipts { + if receipt.Class != catalog.AuthorityProvider { + return protocol.AuthorityBundle{}, fmt.Errorf("trusted authority channel accepts only external-provider receipts") + } bundle.Receipts = append(bundle.Receipts, receipt) } if options.humanActor != "" { @@ -760,6 +824,15 @@ func renderResponse(response surfaces.Response, format string) error { response.Prescription.ExpectedStateRevision, response.Prescription.ExpectedProgramFingerprint, response.Prescription.ExpectedSnapshotFingerprint, correlation) } + if response.Work != nil { + fmt.Printf("work=%s status=%s revision=%d staging=%s\n", response.Work.Request.Contract.ID, response.Work.Status, response.Work.Revision, response.Work.Request.StagingRoot) + if response.Work.Question != nil { + fmt.Printf("question=%s %s\n", response.Work.Question.ID, response.Work.Question.Prompt) + } + if response.Work.BlockReason != "" { + fmt.Println("blocker:", response.Work.BlockReason) + } + } if response.Receipt != nil { fmt.Println("receipt:", response.Receipt.ID) } diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 19b27aa..268c986 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "path/filepath" "strings" @@ -91,6 +92,61 @@ func TestTransitionReceiptCannotBeLoadedAsAuthority(t *testing.T) { } } +func TestCallerCannotSupplyExternalProviderAuthority(t *testing.T) { + now := time.Unix(100, 0).UTC() + path := filepath.Join(t.TempDir(), "provider.json") + receipt := `{"id":"provider-forged","class":"external-provider","subject":"github:owner/repository","fingerprint":"` + strings.Repeat("a", 64) + `","issued_at":"` + now.Format(time.RFC3339) + `","expires_at":"` + now.Add(time.Minute).Format(time.RFC3339) + `"}` + if err := os.WriteFile(path, []byte(receipt), 0o600); err != nil { + t.Fatal(err) + } + _, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Objective{}, nil, now) + if err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_UNTRUSTED") { + t.Fatalf("caller-supplied provider authority error = %v", err) + } +} + +func TestPublicationRequestsDeriveTrustedProviderAuthorityFromCatalogBinding(t *testing.T) { + prior := resolveGitHubProviderAuthority + resolveGitHubProviderAuthority = func(_ context.Context, _ string, fingerprint string, now time.Time) (protocol.AuthorityReceipt, error) { + return protocol.AuthorityReceipt{ + ID: "provider-stable", Class: catalog.AuthorityProvider, Subject: "github:owner/repository", Fingerprint: fingerprint, + IssuedAt: now, ExpiresAt: now.Add(2 * time.Minute), + }, nil + } + t.Cleanup(func() { resolveGitHubProviderAuthority = prior }) + for transition, parameter := range map[string]string{ + "publication.execute": "preview_fingerprint=" + strings.Repeat("a", 64), + "publication.correct": "body_sha256=" + strings.Repeat("b", 64), + "publication.reconcile": "publication_id=123", + } { + t.Run(transition, func(t *testing.T) { + options := commandOptions{repository: ".", transitionID: transition, parameters: stringList{parameter}} + one, err := buildRequest(surfaces.OperationResolve, options) + if err != nil { + t.Fatal(err) + } + two, err := buildRequest(surfaces.OperationApply, options) + if err != nil { + t.Fatal(err) + } + oneFingerprint, _ := one.Authority.Fingerprint() + twoFingerprint, _ := two.Authority.Fingerprint() + if len(one.Authority.Receipts) != 1 || one.Authority.Receipts[0].Class != catalog.AuthorityProvider || one.Authority.Receipts[0].Fingerprint != strings.SplitN(parameter, "=", 2)[1] || oneFingerprint != twoFingerprint { + t.Fatalf("provider authority was not catalog-bound and stable: one=%#v two=%#v", one.Authority, two.Authority) + } + }) + } + request, err := buildRequest(surfaces.OperationResolve, commandOptions{ + repository: ".", transitionID: "publication.observe", parameters: stringList{"publication_id=123"}, + }) + if err != nil { + t.Fatal(err) + } + if len(request.Authority.Receipts) != 0 { + t.Fatalf("transition without a trusted provider binding derived authority: %#v", request.Authority) + } +} + func TestHumanPublicationConfirmationBindsExactPreviewFingerprint(t *testing.T) { // control-law: publication-authority-confirms-exact-preview-bytes now := time.Now().UTC() diff --git a/boatstack/cmd/boatstack-helper/work_command.go b/boatstack/cmd/boatstack-helper/work_command.go new file mode 100644 index 0000000..d9dbfa1 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/work_command.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func runFlowWork(arguments []string) error { + if len(arguments) == 0 { + return fmt.Errorf("usage: boatstack flow work [flags]") + } + action := arguments[0] + operations := map[string]surfaces.Operation{ + "show": surfaces.OperationWorkShow, "input-required": surfaces.OperationWorkInputRequired, + "answer": surfaces.OperationWorkAnswer, "complete": surfaces.OperationWorkComplete, "block": surfaces.OperationWorkBlock, + } + operation, ok := operations[action] + if !ok { + return fmt.Errorf("unknown foreground work action %q", action) + } + options, err := parseOptions("flow work "+action, arguments[1:], "", nil) + if err != nil { + return err + } + if options.programID == "" || options.entryID == "" || options.runID == "" || options.workID == "" { + return fmt.Errorf("flow work %s requires --flow, --entry, --run-id, and --work-id", action) + } + bound, err := bindFlowEntry(context.Background(), options) + if err != nil { + return err + } + request, err := buildRequest(operation, bound) + if err != nil { + return err + } + if options.workQuestionSchemaPath != "" { + request.WorkQuestionSchema, err = os.ReadFile(options.workQuestionSchemaPath) + if err != nil { + return err + } + } + if options.workAnswerPath != "" { + request.WorkAnswer, err = os.ReadFile(options.workAnswerPath) + if err != nil { + return err + } + if !json.Valid(request.WorkAnswer) { + return fmt.Errorf("foreground work answer file is not JSON") + } + } + lease, err := acquireFlowExecutionLease(request) + if err != nil { + return err + } + defer lease.Release() + if err := verifyTrustedRequestControlBundle(request); err != nil { + return err + } + kernel, err := standardKernel(context.Background(), request) + if err != nil { + return err + } + response, handleErr := kernel.Handle(context.Background(), request) + if renderErr := renderResponse(response, options.format); renderErr != nil { + return renderErr + } + return handleErr +} diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 49cd4e6..a9ac233 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -15,7 +15,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 1 + ArtifactSchemaRevision = 2 ) type Artifact struct { @@ -28,6 +28,7 @@ type Artifact struct { DependencyLockSHA256 string `json:"dependency_lock_sha256"` ProgramFingerprint string `json:"program_fingerprint"` GeneratedSkills map[string]string `json:"generated_skills"` + Assets map[string]string `json:"assets"` Program Document `json:"program"` } @@ -60,7 +61,7 @@ func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, erro Schema: ArtifactSchemaName, SchemaRevision: ArtifactSchemaRevision, CompilerVersion: input.CompilerVersion, SourcePath: filepath.ToSlash(input.SourcePath), SourceSHA256: digest(input.Source), DependencyLockPath: filepath.ToSlash(input.DependencyLockPath), DependencyLockSHA256: digest(input.DependencyLock), - ProgramFingerprint: compiled.Fingerprint, GeneratedSkills: skills, Program: compiled.Document, + ProgramFingerprint: compiled.Fingerprint, GeneratedSkills: skills, Assets: workAssetBindings(compiled.Document), Program: compiled.Document, } encoded, err := json.MarshalIndent(artifact, "", " ") if err != nil { @@ -86,7 +87,7 @@ func LoadArtifact(source io.Reader) (Artifact, error) { if err := requireEOF(decoder); err != nil { return Artifact{}, err } - if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil { + if artifact.Schema != ArtifactSchemaName || artifact.SchemaRevision != ArtifactSchemaRevision || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil || artifact.Assets == nil { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: artifact envelope is incomplete") } for path, fingerprint := range artifact.GeneratedSkills { @@ -94,6 +95,11 @@ func LoadArtifact(source io.Reader) (Artifact, error) { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill binding") } } + for path, fingerprint := range artifact.Assets { + if !safeRelative(path) || len(fingerprint) != 64 { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid asset binding") + } + } return artifact, nil } @@ -136,6 +142,12 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: %s does not match artifact", check.label) } } + for path, expected := range artifact.Assets { + raw, readErr := readRepositoryFile(repository, path) + if readErr != nil || digest(raw) != expected { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: work asset %s does not match artifact", path) + } + } compiled, err := Compile(artifact.Program, resolver) if err != nil { return Compiled{}, err @@ -170,6 +182,45 @@ func CheckArtifact(repository string, artifact Artifact, compilerVersion string, return compiled, nil } +func workAssetBindings(document Document) map[string]string { + result := map[string]string{} + for _, contract := range document.Work { + result[contract.Instructions.Path] = contract.Instructions.SHA256 + for _, output := range contract.Outputs { + if output.Schema != nil { + result[output.Schema.Path] = output.Schema.SHA256 + } + } + } + return result +} + +// RepositoryAssetResolver resolves exact bounded regular files below one +// canonical repository root. +type RepositoryAssetResolver struct{ Repository string } + +func (r RepositoryAssetResolver) ResolveAsset(path string, maxBytes int64) ([]byte, error) { + if !safeRelative(path) || maxBytes <= 0 { + return nil, fmt.Errorf("asset path or bound is invalid") + } + root, err := filepath.Abs(r.Repository) + if err != nil { + return nil, err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + raw, err := readRepositoryFile(root, path) + if err != nil { + return nil, err + } + if int64(len(raw)) > maxBytes { + return nil, fmt.Errorf("asset exceeds %d bytes", maxBytes) + } + return raw, nil +} + 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, `\\`) { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index dd1c064..dde875f 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -9,6 +9,10 @@ import ( "io" "regexp" "sort" + "strings" + "unicode/utf8" + + "github.com/santhosh-tekuri/jsonschema/v6" ) var semanticID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) @@ -20,7 +24,25 @@ type Compiled struct { Fingerprint string } +const ( + maxInstructionBytes = 256 << 10 + maxSchemaBytes = 1 << 20 + defaultOutputBytes = 1 << 20 + maxOutputBytes = 16 << 20 +) + +// AssetResolver reads one exact repository-owned asset through a trusted, +// symlink-safe boundary. The restricted TypeScript frontend never reads it. +type AssetResolver interface { + ResolveAsset(path string, maxBytes int64) ([]byte, error) +} + func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { + return LoadWithAssets(source, resolver, nil) +} + +// LoadWithAssets strictly decodes raw IR and resolves its declared work assets. +func LoadWithAssets(source io.Reader, resolver BindingResolver, assets AssetResolver) (Compiled, error) { raw, err := readLimited(source, 16<<20, "CONTROL_PROGRAM_INVALID: input exceeds 16 MiB") if err != nil { return Compiled{}, err @@ -37,7 +59,7 @@ func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { if err := requireEOF(decoder); err != nil { return Compiled{}, err } - return Compile(document, resolver) + return compile(document, resolver, assets) } func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error) { @@ -52,6 +74,10 @@ func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error } func Compile(document Document, resolver BindingResolver) (Compiled, error) { + return compile(document, resolver, nil) +} + +func compile(document Document, resolver BindingResolver, assets AssetResolver) (Compiled, error) { if document.Schema != SchemaName || document.SchemaRevision != SchemaRevision { return Compiled{}, invalid("schema", "unsupported schema or revision") } @@ -111,7 +137,11 @@ func Compile(document Document, resolver BindingResolver) (Compiled, error) { if err := normalizeTargetsAndEntries(&document, facets, resolver); err != nil { return Compiled{}, err } - if err := normalizeTransitions(&document, facets, operators); err != nil { + work, err := normalizeWork(&document, assets) + if err != nil { + return Compiled{}, err + } + if err := normalizeTransitions(&document, facets, operators, work); err != nil { return Compiled{}, err } @@ -130,6 +160,111 @@ func Compile(document Document, resolver BindingResolver) (Compiled, error) { return Compiled{Document: document, Canonical: pretty, Fingerprint: fingerprint}, nil } +func normalizeWork(document *Document, assets AssetResolver) (map[string]WorkContract, error) { + entryInputs := map[string]bool{} + for _, entry := range document.Entries { + for _, input := range entry.Inputs { + entryInputs[input.ID] = true + } + } + seen := map[string]WorkContract{} + for i := range document.Work { + contract := &document.Work[i] + if !validID(contract.ID) || seen[contract.ID].ID != "" { + return nil, invalid(fmt.Sprintf("work[%d].id", i), "invalid or duplicate work id") + } + if err := resolveWorkAsset(&contract.Instructions, assets, maxInstructionBytes, "work."+contract.ID+".instructions"); err != nil { + return nil, err + } + if strings.TrimSpace(contract.Instructions.Content) == "" { + return nil, invalid("work."+contract.ID+".instructions", "instruction asset must not be empty") + } + inputIDs := map[string]bool{} + for j := range contract.Inputs { + input := &contract.Inputs[j] + if !validID(input.ID) || !validID(input.EntryInput) || inputIDs[input.ID] || !entryInputs[input.EntryInput] { + return nil, invalid(fmt.Sprintf("work.%s.inputs[%d]", contract.ID, j), "invalid or duplicate work input") + } + inputIDs[input.ID] = true + } + sort.Slice(contract.Inputs, func(i, j int) bool { return contract.Inputs[i].ID < contract.Inputs[j].ID }) + outputIDs, outputPaths := map[string]bool{}, map[string]bool{} + for j := range contract.Outputs { + output := &contract.Outputs[j] + if !validID(output.ID) || !safeRelative(output.Path) || outputIDs[output.ID] || outputPaths[output.Path] { + return nil, invalid(fmt.Sprintf("work.%s.outputs[%d]", contract.ID, j), "invalid or duplicate output id/path") + } + if output.MediaType != "text/markdown" && output.MediaType != "text/plain" && output.MediaType != "application/json" { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".media_type", "must be text/markdown, text/plain, or application/json") + } + if output.MaxBytes == 0 { + output.MaxBytes = defaultOutputBytes + } + 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.Schema != nil { + if output.MediaType != "application/json" { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schemas require application/json") + } + if err := resolveWorkAsset(output.Schema, assets, maxSchemaBytes, "work."+contract.ID+".outputs."+output.ID+".schema"); err != nil { + return nil, err + } + var schemaValue any + decoder := json.NewDecoder(strings.NewReader(output.Schema.Content)) + decoder.UseNumber() + if err := decoder.Decode(&schemaValue); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + if err := requireEOF(decoder); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", "schema contains trailing JSON") + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource(output.Schema.Path, schemaValue); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + if _, err := compiler.Compile(output.Schema.Path); err != nil { + return nil, invalid("work."+contract.ID+".outputs."+output.ID+".schema", err.Error()) + } + } + outputIDs[output.ID], outputPaths[output.Path] = true, true + } + if len(contract.Outputs) == 0 { + return nil, invalid("work."+contract.ID+".outputs", "at least one output is required") + } + sort.Slice(contract.Outputs, func(i, j int) bool { return contract.Outputs[i].ID < contract.Outputs[j].ID }) + seen[contract.ID] = *contract + } + sort.Slice(document.Work, func(i, j int) bool { return document.Work[i].ID < document.Work[j].ID }) + return seen, nil +} + +func resolveWorkAsset(asset *WorkAsset, resolver AssetResolver, limit int64, field string) error { + if !safeRelative(asset.Path) { + return invalid(field+".path", "must be a canonical repository-relative path") + } + compiled := asset.SHA256 != "" || asset.Content != "" + if compiled { + if asset.SHA256 == "" || len(asset.SHA256) != 64 || digest([]byte(asset.Content)) != asset.SHA256 || int64(len(asset.Content)) > limit { + return invalid(field, "compiled asset bytes or fingerprint are invalid") + } + return nil + } + if resolver == nil { + return invalid(field, "unresolved asset requires a trusted repository resolver") + } + raw, err := resolver.ResolveAsset(asset.Path, limit) + if err != nil { + return invalid(field, err.Error()) + } + if !utf8.Valid(raw) { + return invalid(field, "asset must be UTF-8") + } + asset.Content, asset.SHA256 = string(raw), digest(raw) + return nil +} + func normalizeEvidence(document *Document, facets map[string]Facet) error { seen := map[string]bool{} for i := range document.Evidence { @@ -263,8 +398,9 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi return seen, nil } -func normalizeTransitions(document *Document, facets map[string]Facet, operators map[string]Operator) error { +func normalizeTransitions(document *Document, facets map[string]Facet, operators map[string]Operator, work map[string]WorkContract) error { seen := map[string]bool{} + workRefs := map[string]bool{} for i := range document.Transitions { value := &document.Transitions[i] var err error @@ -272,6 +408,12 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators return invalid(fmt.Sprintf("transitions[%d]", i), "invalid transition or operator reference") } seen[value.ID] = true + if value.Work != "" { + if work[value.Work].ID == "" { + return invalid("transitions."+value.ID+".work", "unknown work reference "+value.Work) + } + workRefs[value.Work] = true + } if err := normalizePredicate(&value.Guard, facets); err != nil { return invalid("transitions."+value.ID+".guard", err.Error()) } @@ -286,6 +428,11 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators return invalid("transitions."+value.ID+".requires.authorities", "undeclared "+missing) } } + for id := range work { + if !workRefs[id] { + return invalid("work."+id, "work contract is not referenced by a transition") + } + } if len(seen) == 0 { return invalid("transitions", "at least one transition is required") } @@ -520,6 +667,7 @@ func sortPredicates(values []Predicate) { func stripDescriptions(value Document) Document { value.Facets = append([]Facet(nil), value.Facets...) value.Evidence = append([]Evidence(nil), value.Evidence...) + value.Work = append([]WorkContract(nil), value.Work...) value.Operators = append([]Operator(nil), value.Operators...) value.Transitions = append([]Transition(nil), value.Transitions...) value.Targets = append([]Target(nil), value.Targets...) @@ -531,6 +679,9 @@ func stripDescriptions(value Document) Document { for i := range value.Evidence { value.Evidence[i].Description = "" } + for i := range value.Work { + value.Work[i].Description = "" + } for i := range value.Operators { value.Operators[i].Description = "" } diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index e02a690..a415c17 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -2,6 +2,8 @@ package controlprogram_test import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "os" "path/filepath" @@ -92,6 +94,68 @@ func TestDomainNeutralIncidentProgramCompiles(t *testing.T) { } } +func incidentWorkProgram() controlprogram.Document { + document := incidentProgram() + instructions := "Inspect the incident and produce the declared diagnosis." + digest := sha256.Sum256([]byte(instructions)) + document.Work = []controlprogram.WorkContract{{ + ID: "diagnose", Instructions: controlprogram.WorkAsset{Path: "instructions.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, + Inputs: []controlprogram.WorkInput{{ID: "incident", EntryInput: "incident"}}, + Outputs: []controlprogram.WorkOutput{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", Required: true, MaxBytes: 4096}}, + Description: "presentation only", + }} + document.Transitions[0].Work = "diagnose" + return document +} + +func TestForegroundWorkIsDomainNeutralAndFingerprintBound(t *testing.T) { + // control-law: exact foreground-work semantics contribute to canonical program identity + base, err := controlprogram.Compile(incidentWorkProgram(), nil) + if err != nil { + t.Fatal(err) + } + if len(base.Document.Work) != 1 || base.Document.Transitions[0].Work != "diagnose" { + t.Fatalf("compiled work = %#v", base.Document.Work) + } + description := incidentWorkProgram() + description.Work[0].Description = "different prose" + equivalent, err := controlprogram.Compile(description, nil) + if err != nil || equivalent.Fingerprint != base.Fingerprint { + t.Fatalf("work description changed executable identity: %v %s != %s", err, equivalent.Fingerprint, base.Fingerprint) + } + changed := incidentWorkProgram() + changed.Work[0].Instructions.Content += " Verify recovery." + digest := sha256.Sum256([]byte(changed.Work[0].Instructions.Content)) + changed.Work[0].Instructions.SHA256 = hex.EncodeToString(digest[:]) + semantic, err := controlprogram.Compile(changed, nil) + if err != nil { + t.Fatal(err) + } + if semantic.Fingerprint == base.Fingerprint { + t.Fatal("instruction asset change preserved executable identity") + } +} + +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){ + "unresolved-asset": func(value *controlprogram.Document) { + value.Work[0].Instructions.Content, value.Work[0].Instructions.SHA256 = "", "" + }, + "unknown-entry-input": func(value *controlprogram.Document) { value.Work[0].Inputs[0].EntryInput = "missing" }, + "unreferenced-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "" }, + "unknown-work": func(value *controlprogram.Document) { value.Transitions[0].Work = "missing" }, + } { + t.Run(name, func(t *testing.T) { + document := incidentWorkProgram() + mutate(&document) + if _, err := controlprogram.Compile(document, nil); err == nil { + t.Fatalf("%s was accepted", name) + } + }) + } +} + func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { // control-law: canonical-program-identity-binds-executable-semantics-only base, err := controlprogram.Compile(incidentProgram(), nil) @@ -339,3 +403,39 @@ func TestArtifactRejectsGeneratedPathsOutsideHostSkillRoots(t *testing.T) { t.Fatal("artifact accepted an arbitrary generated deletion path") } } + +func TestArtifactBindsExactForegroundWorkAssets(t *testing.T) { + // control-law: runtime admits only the instruction and schema assets compiled into the artifact + repository := t.TempDir() + document := incidentWorkProgram() + files := map[string][]byte{ + "flow.ts": []byte("source"), + "package-lock.json": []byte("lock"), + "instructions.md": []byte(document.Work[0].Instructions.Content), + } + for path, raw := range files { + if err := os.WriteFile(filepath.Join(repository, path), raw, 0o600); err != nil { + t.Fatal(err) + } + } + compiled, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: files["flow.ts"], + DependencyLockPath: "package-lock.json", DependencyLock: files["package-lock.json"], GeneratedSkills: map[string][]byte{}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "instructions.md"), []byte("different instructions"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, nil); err == nil || !strings.Contains(err.Error(), "work asset") { + t.Fatalf("changed work asset result = %v", err) + } +} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 6d9af69..c4cc884 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -11,7 +11,10 @@ import ( "strings" "testing" + boatstack "github.com/operatorstack/boatstack/boatstack" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/delivery" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" ) @@ -83,6 +86,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { {"product-delivery-a.flow.ts", 1, 1}, {"product-delivery-b.flow.ts", 2, 2}, {"product-delivery-c.flow.ts", 1, 6}, + {"product-delivery-planning-package.flow.ts", 1, 21}, } for _, test := range cases { t.Run(test.fixture, func(t *testing.T) { @@ -91,7 +95,7 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if commandErr != nil { t.Fatalf("compile repository Flow: %v\n%s", commandErr, frontendRaw) } - compiled, compileErr := controlprogram.Load(bytes.NewReader(frontendRaw), resolver) + compiled, compileErr := controlprogram.LoadWithAssets(bytes.NewReader(frontendRaw), resolver, controlprogram.RepositoryAssetResolver{Repository: filepath.Dir(moduleRoot)}) if compileErr != nil { t.Fatal(compileErr) } @@ -106,10 +110,44 @@ func TestRepositoryOwnedSoftwareDeliveryFlowsShareOneRuntime(t *testing.T) { if len(compiled.Document.Entries) != test.entries || len(manifest.Transitions) != test.transitions { t.Fatalf("entries=%d transitions=%d", len(compiled.Document.Entries), len(manifest.Transitions)) } + if test.fixture == "product-delivery-planning-package.flow.ts" { + if _, compileErr := delivery.Compile(context.Background(), delivery.CompileRequest{KernelVersion: boatstack.Version, Core: core.System(), Runtime: definition, Settings: map[string]string{"fixture": test.fixture}}); compileErr != nil { + t.Fatalf("compile repository Flow runtime: %v", compileErr) + } + } }) } } +func TestDomainNeutralFrontendDeclaresForegroundWorkWithoutSoftwareDelivery(t *testing.T) { + // control-law: foreground work is a domain-neutral requirement rather than a delivery effect + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate foreground work fixture") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + repositoryRoot := filepath.Dir(moduleRoot) + frontend := filepath.Join(repositoryRoot, "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if _, err := os.Stat(frontend); err != nil { + t.Skip("Flow frontend dependencies are not installed") + } + source := filepath.Join(moduleRoot, "testdata", "control-programs", "incident-response-work.flow.ts") + raw, err := exec.Command(frontend, source).CombinedOutput() + if err != nil { + t.Fatalf("compile foreground work fixture: %v\n%s", err, raw) + } + compiled, err := controlprogram.LoadWithAssets(bytes.NewReader(raw), nil, controlprogram.RepositoryAssetResolver{Repository: repositoryRoot}) + if err != nil { + t.Fatal(err) + } + if len(compiled.Document.Work) != 1 || compiled.Document.Transitions[0].Work != "diagnose" || compiled.Document.Work[0].Instructions.Content == "" || compiled.Document.Work[0].Outputs[0].Schema.Content == "" { + t.Fatalf("compiled foreground work = %#v", compiled.Document.Work) + } +} + func TestTypeScriptFrontendRejectsRepositoryCodeWithoutExecutingIt(t *testing.T) { // control-law: authoring-frontends-parse-repository-declarations-without-module-execution _, file, _, ok := runtime.Caller(0) diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 68b5b11..86cc7ab 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,21 +7,22 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 2 + SchemaRevision = 3 ) type Document struct { - Schema string `json:"schema"` - SchemaRevision int `json:"schema_revision"` - Program Program `json:"program"` - Declarations Declarations `json:"declarations"` - Facets []Facet `json:"facets"` - Evidence []Evidence `json:"evidence,omitempty"` - Operators []Operator `json:"operators"` - Transitions []Transition `json:"transitions"` - Targets []Target `json:"targets"` - Entries []Entry `json:"entries"` - Description string `json:"description,omitempty"` + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + Program Program `json:"program"` + Declarations Declarations `json:"declarations"` + Facets []Facet `json:"facets"` + Evidence []Evidence `json:"evidence,omitempty"` + Work []WorkContract `json:"work,omitempty"` + Operators []Operator `json:"operators"` + Transitions []Transition `json:"transitions"` + Targets []Target `json:"targets"` + Entries []Entry `json:"entries"` + Description string `json:"description,omitempty"` } type Program struct { @@ -52,6 +53,39 @@ type Evidence struct { Description string `json:"description,omitempty"` } +// WorkAsset is a repository asset declaration in raw IR and an exact +// content-bound asset in compiled IR. The trusted compiler supplies SHA256 and +// Content; repository Flow source may supply only Path. +type WorkAsset struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` + Content string `json:"content,omitempty"` +} + +type WorkInput struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` +} + +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,omitempty"` + Schema *WorkAsset `json:"schema,omitempty"` +} + +// WorkContract declares bounded foreground work. It contains no executable +// code, capabilities, authority, effects, or native handlers. +type WorkContract struct { + ID string `json:"id"` + Instructions WorkAsset `json:"instructions"` + Inputs []WorkInput `json:"inputs"` + Outputs []WorkOutput `json:"outputs"` + Description string `json:"description,omitempty"` +} + // Predicate is a closed AST. Exactly one node variant must be present. type Predicate struct { All []Predicate `json:"all,omitempty"` @@ -122,6 +156,7 @@ type Transition struct { Target Predicate `json:"target"` Priority int `json:"priority"` Requires TransitionRequirements `json:"requires,omitempty"` + Work string `json:"work,omitempty"` Description string `json:"description,omitempty"` } diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index a6c3f65..f9d894b 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/delivery" ) func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { @@ -25,6 +26,24 @@ func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { } } +func TestInstallationInitializationAcceptsExplicitlyDelegatedAutonomy(t *testing.T) { + // control-law: exact run delegation may bootstrap verified local state + manifest, err := core.System().CoreManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, transition := range manifest.Transitions { + if transition.ID != "installation.initialize" { + continue + } + if len(transition.Authority) != 2 || transition.Authority[0] != delivery.AuthorityHuman || transition.Authority[1] != delivery.AuthorityAutonomy { + t.Fatalf("installation.initialize authority alternatives = %v", transition.Authority) + } + return + } + t.Fatal("installation.initialize is absent") +} + func hasPrefix(value string, prefixes ...string) bool { for _, prefix := range prefixes { if strings.HasPrefix(value, prefix) { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 4679de5..6d59897 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -2285,7 +2285,8 @@ "correlation-id" ], "authority": [ - "human" + "human", + "autonomy" ], "required_evidence": [ "invocation-context", diff --git a/boatstack/delivery/control.go b/boatstack/delivery/control.go index 9797d3e..8cccd0a 100644 --- a/boatstack/delivery/control.go +++ b/boatstack/delivery/control.go @@ -40,6 +40,9 @@ type StateEffectKind = catalog.StateEffectKind type StateAssignment = catalog.StateAssignment type StatePrecondition = catalog.StatePrecondition type StateValueReference = catalog.StateValueReference +type WorkContract = catalog.WorkContract +type WorkInput = catalog.WorkInput +type WorkOutput = catalog.WorkOutput type StateFacet = model.StateFacet type TargetID = model.TargetID type ProtocolPhase = model.ProtocolPhase diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index b225932..4073ed7 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -13,6 +13,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -37,6 +38,7 @@ type DeliveryController struct { observer ports.Observer engine engine.Engine clock effects.Clock + work foregroundwork.Manager } // TargetSatisfied reports whether the exact compiled program marks the @@ -65,6 +67,10 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro if err != nil { return DeliveryController{}, err } + workManager, err := foregroundwork.NewManager(resolver, locker, clock, effects.NewRuntimeStore()) + if err != nil { + return DeliveryController{}, err + } journal, err := effects.NewJournal(resolver, clock) if err != nil { return DeliveryController{}, err @@ -84,7 +90,7 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro if err != nil { return DeliveryController{}, err } - return DeliveryController{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock}, nil + return DeliveryController{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock, work: workManager}, nil } func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { @@ -120,7 +126,20 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request switch request.Operation { case surfaces.OperationResolve, surfaces.OperationExplain: explain := request.Operation == surfaces.OperationExplain - resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain}) + resolveRequest := engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Parameters: request.Parameters, Requested: request.TransitionID, Trace: explain, ControlBundle: request.ControlBundle} + resolution, resolveErr := k.engine.Resolve(ctx, resolveRequest) + if !explain && resolveErr == nil && resolution.Decision.Kind == supervisor.DecisionCandidate && resolution.Decision.Transition != nil && resolution.Decision.Transition.Work != nil { + record, workErr := k.work.Ensure(ctx, invocation, request.FlowID, request.ProgramID, request.EntryID, resolution.Objective, resolution.Snapshot, *resolution.Decision.Transition, request.WorkInputs) + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + response.Work = &record + if record.Status == foregroundwork.StatusCompleted && record.Result != nil { + resolveRequest.Work = record.Result + resolution, resolveErr = k.engine.Resolve(ctx, resolveRequest) + } + } response.Objective, response.Decision, response.Trace = resolution.Objective, &resolution.Decision, resolution.Trace if !explain && resolution.Prescription.ID != "" { response.Prescription = &resolution.Prescription @@ -129,10 +148,10 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request if !explain && resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot } - if !explain { + if !explain && response.Work == nil { response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, resolution.Decision) } - if !explain && response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { + if !explain && response.Work == nil && response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { if transition, ok := k.registry.Lookup(resolution.Decision.Candidates[0]); ok { questionDecision := supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &transition} response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, questionDecision) @@ -149,8 +168,22 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request } return response, nil case surfaces.OperationApply, surfaces.OperationRecover: + var work *protocol.WorkEvidence + if transition, ok := k.registry.Lookup(request.TransitionID); ok && transition.Work != nil { + record, workErr := k.work.Show(ctx, invocation, request.FlowID, transition.Work.ID) + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + if record.Status != foregroundwork.StatusCompleted || record.Result == nil { + err := fmt.Errorf("transition %q requires completed foreground work %q", transition.ID, transition.Work.ID) + response.Error = err.Error() + return response, err + } + work, response.Work = record.Result, &record + } result, applyErr := k.engine.Apply(ctx, engine.ApplyRequest{ - ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID}, + ResolveRequest: engine.ResolveRequest{Invocation: invocation, Objective: request.Objective, Authority: request.Authority, Requested: request.TransitionID, Work: work, ControlBundle: request.ControlBundle}, FlowID: request.FlowID, Prescription: request.Prescription, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute, }) response.Prescription = &request.Prescription @@ -176,6 +209,27 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request return response, applyErr } return response, nil + case surfaces.OperationWorkShow, surfaces.OperationWorkInputRequired, surfaces.OperationWorkAnswer, surfaces.OperationWorkComplete, surfaces.OperationWorkBlock: + var record foregroundwork.Record + var workErr error + switch request.Operation { + case surfaces.OperationWorkShow: + record, workErr = k.work.Show(ctx, invocation, request.FlowID, request.WorkID) + case surfaces.OperationWorkInputRequired: + record, workErr = k.work.InputRequired(ctx, invocation, request.FlowID, request.WorkID, request.WorkQuestionPrompt, request.WorkQuestionSchema) + case surfaces.OperationWorkAnswer: + record, workErr = k.work.Answer(ctx, invocation, request.FlowID, request.WorkID, request.WorkQuestionID, request.WorkAnswer) + case surfaces.OperationWorkComplete: + record, workErr = k.work.Complete(ctx, invocation, request.FlowID, request.WorkID) + case surfaces.OperationWorkBlock: + record, workErr = k.work.Block(ctx, invocation, request.FlowID, request.WorkID, request.WorkBlockReason) + } + if workErr != nil { + response.Error = workErr.Error() + return response, workErr + } + response.Work = &record + return response, nil case surfaces.OperationDoctor: summary := k.program.Summary() extensionIDs := make([]string, 0, len(summary.Extensions)) @@ -288,7 +342,7 @@ func (k DeliveryController) deriveRepositoryAuthority(ctx context.Context, invoc if err != nil { return protocol.AuthorityBundle{}, err } - return protocol.DeriveRepositoryAuthority(snapshot, bundle, k.clock.Now()) + return protocol.DeriveRepositoryAuthorityWhenAvailable(snapshot, bundle, k.clock.Now()) } func readEvents(path string) ([]map[string]any, error) { diff --git a/boatstack/flow/skillprojection/bootstrap.go b/boatstack/flow/skillprojection/bootstrap.go index 6ab2788..809e31e 100644 --- a/boatstack/flow/skillprojection/bootstrap.go +++ b/boatstack/flow/skillprojection/bootstrap.go @@ -1,33 +1,31 @@ package skillprojection -import "fmt" - // BootstrapContract is shared by every generated Flow entry skill. It covers // failures that occur before a repository Control Program can be loaded. -func BootstrapContract(installerVersion string) string { - return fmt.Sprintf(`Before starting the Flow, verify that the `+"`boatstack`"+` command is -available (`+"`command -v boatstack`"+` on POSIX or `+"`Get-Command boatstack`"+` in +func BootstrapContract() string { + return `Before starting the Flow, verify that the ` + "`boatstack`" + ` command is +available (` + "`command -v boatstack`" + ` on POSIX or ` + "`Get-Command boatstack`" + ` in PowerShell). If it is absent, read the exact committed -`+"`.boatstack/runtime.json`"+` regular file. Report -`+"`BOATSTACK_LAUNCHER_NOT_FOUND`"+`, the pinned version and SHA-256, and the +` + "`.boatstack/runtime.json`" + ` regular file. Report +` + "`BOATSTACK_LAUNCHER_NOT_FOUND`" + `, the pinned version and SHA-256, and the tag-specific installer command for the current platform: POSIX: -`+"`BOATSTACK_MODE=hydrate BOATSTACK_VERSION= BOATSTACK_EXPECTED_RUNTIME_SHA256= /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.sh)\"`"+` +` + "`BOATSTACK_MODE=hydrate BOATSTACK_VERSION= BOATSTACK_EXPECTED_RUNTIME_SHA256= /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack//install.sh)\"`" + ` PowerShell: -`+"`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION=''; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256=''; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | Invoke-Expression`"+` +` + "`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION=''; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256=''; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack//install.ps1 | Invoke-Expression`" + ` -Replace `+"``"+` and `+"``"+` only with the validated -values in the pin. The installer comes from Boatstack %s, the runtime version -that generated this skill, so it can hydrate older pinned runtime artifacts. -If the pin is absent or invalid, report `+"`BOATSTACK_RUNTIME_PIN_MISSING`"+` or -`+"`BOATSTACK_RUNTIME_PIN_INVALID`"+` and stop without guessing a version or -selecting `+"`latest`"+`. +Replace ` + "``" + ` and ` + "``" + ` only with the validated +values in the pin. The installer tag and runtime identity therefore come from +the same committed repository pin. +If the pin is absent or invalid, report ` + "`BOATSTACK_RUNTIME_PIN_MISSING`" + ` or +` + "`BOATSTACK_RUNTIME_PIN_INVALID`" + ` and stop without guessing a version or +selecting ` + "`latest`" + `. Display the installer command and ask for explicit approval. Never run it or authorize installation on the user's behalf. A bootstrap failure creates no Flow run ID. Preserve any Boatstack bootstrap diagnostic verbatim, including stderr, and resume this same requested entry only after the user has installed -the exact runtime.`, installerVersion, installerVersion, installerVersion) +the exact runtime.` } diff --git a/boatstack/flow/skillprojection/bootstrap_test.go b/boatstack/flow/skillprojection/bootstrap_test.go index dc77022..059ef60 100644 --- a/boatstack/flow/skillprojection/bootstrap_test.go +++ b/boatstack/flow/skillprojection/bootstrap_test.go @@ -7,14 +7,14 @@ import ( func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) { // control-law: generated-skills-report-bootstrap-recovery-without-executing-it - contract := BootstrapContract("v9.9.10") + contract := BootstrapContract() for _, expected := range []string{ "command -v boatstack", "Get-Command boatstack", ".boatstack/runtime.json", "BOATSTACK_LAUNCHER_NOT_FOUND", "BOATSTACK_RUNTIME_PIN_MISSING", "BOATSTACK_RUNTIME_PIN_INVALID", "explicit approval", "Never run it", "creates no\nFlow run ID", "resume this same requested entry", "BOATSTACK_MODE=hydrate", "BOATSTACK_VERSION=", - "BOATSTACK_EXPECTED_RUNTIME_SHA256=", "/v9.9.10/install.sh", + "BOATSTACK_EXPECTED_RUNTIME_SHA256=", "//install.sh", } { if !strings.Contains(contract, expected) { t.Fatalf("bootstrap contract lacks %q", expected) @@ -26,4 +26,7 @@ func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) { if strings.Contains(contract, "BOATSTACK_MODE=update") { t.Fatal("bootstrap contract permits repository mutation during runtime recovery") } + if strings.Contains(contract, "v9.9.10") { + t.Fatal("bootstrap contract depends on the generating runtime version") + } } diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 17f8fd4..db0ff01 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -33,6 +33,13 @@ func NewResolver(ctx context.Context) (Resolver, error) { for _, transition := range manifest.Transitions { transitions[string(transition.ID)] = transition } + planning, err := planningPackageTransitions(transitions) + if err != nil { + return Resolver{}, err + } + for _, transition := range planning { + transitions[string(transition.ID)] = transition + } return Resolver{transitions: transitions}, nil } diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index d7bfd88..8841f6d 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -10,6 +10,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/flow/standard" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + general "github.com/operatorstack/boatstack/boatstack/kernel" ) // Definition is a trusted adapter. Repository IR selects bindings and adds @@ -46,14 +47,20 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim for _, operator := range d.compiled.Document.Operators { operatorByID[operator.ID] = operator } + workByID := map[string]controlprogram.WorkContract{} + for _, work := range d.compiled.Document.Work { + workByID[work.ID] = work + } objectives := map[model.TargetID]EntryObjective{} contracts := map[model.TargetID]delivery.ObjectiveContract{} + entriesByTarget := map[model.TargetID][]controlprogram.Entry{} for _, entry := range d.compiled.Document.Entries { objective, objectiveErr := objectiveContractForEntry(d.compiled, base, entry.ID) if objectiveErr != nil { return delivery.ProgramRuntimeManifest{}, objectiveErr } objectives[objective.TargetID], contracts[objective.TargetID] = objective, objective.Contract + entriesByTarget[objective.TargetID] = append(entriesByTarget[objective.TargetID], entry) } selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions)) @@ -86,6 +93,22 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim transition.TargetConditions = append(transition.TargetConditions, target...) transition.Priority = declaration.Priority transition.ExecutionContext = operator.ExecutionContext + if declaration.Work != "" { + work, exists := workByID[declaration.Work] + if !exists { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q references unknown foreground work %q", declaration.ID, declaration.Work) + } + transition.Work, err = runtimeWorkContract(work) + if err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) + } + if transition.ID == PlanningPackageAdmit { + if err := validatePlanningPackageWorkContract(*transition.Work); err != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q foreground work: %w", declaration.ID, err) + } + } + transition.OwnedResources = append(transition.OwnedResources, "foreground-work-"+transition.Work.ID) + } for _, authority := range declaration.Requires.Authorities { transition.AuthorityAll = append(transition.AuthorityAll, delivery.AuthorityClass(authority)) } @@ -107,6 +130,9 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim transition.SelectionClass = delivery.SelectionProgramProgress } sort.Slice(transition.TargetIDs, func(i, j int) bool { return transition.TargetIDs[i] < transition.TargetIDs[j] }) + if err := requireReachableEntryInputs(transition, entriesByTarget); err != nil { + return delivery.ProgramRuntimeManifest{}, err + } selected = append(selected, transition) } if len(selected) == 0 { @@ -135,6 +161,56 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return base, nil } +func requireReachableEntryInputs(transition delivery.Transition, entriesByTarget map[model.TargetID][]controlprogram.Entry) error { + if transition.Work == nil || len(transition.Work.Inputs) == 0 { + return nil + } + for _, targetID := range transition.TargetIDs { + for _, entry := range entriesByTarget[targetID] { + declared := map[string]bool{} + for _, input := range entry.Inputs { + declared[input.ID] = true + } + for _, input := range transition.Work.Inputs { + if !declared[input.EntryInput] { + return fmt.Errorf("transition %q foreground work %q requires entry input %q, but reachable entry %q does not declare it", transition.ID, transition.Work.ID, input.EntryInput, entry.ID) + } + } + } + } + return nil +} + +func runtimeWorkContract(declaration controlprogram.WorkContract) (*delivery.WorkContract, error) { + work := &delivery.WorkContract{ + ID: declaration.ID, InstructionPath: declaration.Instructions.Path, + InstructionSHA256: declaration.Instructions.SHA256, InstructionContent: declaration.Instructions.Content, + } + for _, input := range declaration.Inputs { + work.Inputs = append(work.Inputs, delivery.WorkInput{ID: input.ID, EntryInput: input.EntryInput}) + } + 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.Schema != nil { + runtimeOutput.SchemaPath, runtimeOutput.SchemaSHA256, runtimeOutput.SchemaContent = output.Schema.Path, output.Schema.SHA256, output.Schema.Content + } + work.Outputs = append(work.Outputs, runtimeOutput) + } + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []delivery.WorkInput `json:"inputs,omitempty"` + Outputs []delivery.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + return nil, err + } + work.Fingerprint = fingerprint + return work, nil +} + func uniqueAuthorities(values []delivery.AuthorityClass) []delivery.AuthorityClass { seen := map[delivery.AuthorityClass]bool{} result := make([]delivery.AuthorityClass, 0, len(values)) diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 11f8dda..ca9fbf7 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -2,6 +2,8 @@ package softwaredelivery_test import ( "context" + "crypto/sha256" + "encoding/hex" "strings" "testing" @@ -237,6 +239,36 @@ func TestRepositoryTransitionMustMatchTrustedBindingIdentity(t *testing.T) { } } +func TestForegroundWorkInputsMustExistOnEveryReachableEntry(t *testing.T) { + // control-law: a selected entry cannot reach work whose inputs it cannot bind + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + document := compiled.Document + document.Entries = []controlprogram.Entry{ + {ID: "run", Target: "published-pr", Inputs: []controlprogram.EntryInput{{ID: "plan", Type: "markdown-file"}}}, + {ID: "retry", Target: "published-pr"}, + } + instructions := "Inspect the exact repository plan." + digest := sha256.Sum256([]byte(instructions)) + document.Work = []controlprogram.WorkContract{{ + ID: "planning", Instructions: controlprogram.WorkAsset{Path: "planning.md", SHA256: hex.EncodeToString(digest[:]), Content: instructions}, + Inputs: []controlprogram.WorkInput{{ID: "plan", EntryInput: "plan"}}, + Outputs: []controlprogram.WorkOutput{{ID: "result", Path: "result.md", MediaType: "text/markdown", Required: true}}, + }} + document.Transitions[0].Work = "planning" + unsafe, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(unsafe, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), `reachable entry "retry" does not declare it`) { + t.Fatalf("reachable missing input result = %v", err) + } +} + func TestRepositoryTransitionCannotWidenTrustedTargetIDs(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) diff --git a/boatstack/flow/softwaredelivery/planning_package.go b/boatstack/flow/softwaredelivery/planning_package.go new file mode 100644 index 0000000..95aca83 --- /dev/null +++ b/boatstack/flow/softwaredelivery/planning_package.go @@ -0,0 +1,138 @@ +package softwaredelivery + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +var planningPackageSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +const ( + PlanningPackageAdmit = "planning.package.admit" + PlanningPackageApprove = "planning.package.approve" + PlanningPackagePromote = "planning.package.promote" +) + +// planningPackageTransitions derives optional trusted mechanisms from the +// standard delivery primitives. Repositories decide whether these transitions +// belong to their Flow and attach their own foreground-work contract to admit. +func planningPackageTransitions(transitions map[string]delivery.Transition) ([]delivery.Transition, error) { + clone := func(id string) (delivery.Transition, error) { + transition, ok := transitions[id] + if !ok { + return delivery.Transition{}, fmt.Errorf("trusted planning-package base %q is unavailable", id) + } + return transition, nil + } + admit, err := clone("plan.create") + if err != nil { + return nil, err + } + admit.ID, admit.Effect = PlanningPackageAdmit, PlanningPackageAdmit + admit.LocalEffects = []delivery.EffectID{PlanningPackageAdmit} + admit.Prescription.Operation = PlanningPackageAdmit + admit.Prescription.ExpectedPostcondition = "a schema-valid planning package is admitted" + admit.SourcePredicate, admit.AdmissionPredicate, admit.TargetPredicate = "planning-package-admit-source", "exact-work-and-transition-admission", "planning-package-valid" + admit.Verifier = "verifier:fresh-observation:" + PlanningPackageAdmit + admit.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-admit"} + admit.TargetConditions = replacePlanCondition(admit.TargetConditions, model.PlanPackageValid) + admit.OwnedResources = []string{"plan", "planning-package"} + admit.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageAdmit + + approve, err := clone("plan.approve") + if err != nil { + return nil, err + } + approve.ID, approve.Effect = PlanningPackageApprove, PlanningPackageApprove + approve.LocalEffects = []delivery.EffectID{PlanningPackageApprove} + approve.Parameters = []delivery.ParameterSpec{{Name: "package_fingerprint", Required: true}} + approve.Prescription.Operation = PlanningPackageApprove + approve.Prescription.ExpectedPostcondition = "the exact planning package is approved" + approve.SourcePredicate, approve.AdmissionPredicate, approve.TargetPredicate = "planning-package-valid", "exact-package-approval", "planning-package-approved" + approve.Verifier = "verifier:fresh-observation:" + PlanningPackageApprove + approve.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-approve"} + approve.SourceConditions = replacePlanCondition(approve.SourceConditions, model.PlanPackageValid) + approve.TargetConditions = replacePlanCondition(approve.TargetConditions, model.PlanPackageApproved) + approve.OwnedResources = []string{"plan", "planning-package-approval"} + approve.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackageApprove + + promote, err := clone("plan.activate") + if err != nil { + return nil, err + } + promote.ID, promote.Effect = PlanningPackagePromote, PlanningPackagePromote + promote.LocalEffects = []delivery.EffectID{PlanningPackagePromote} + promote.Prescription.Operation = PlanningPackagePromote + promote.Prescription.ExpectedPostcondition = "the approved package plan is the canonical delivery plan" + promote.SourcePredicate, promote.AdmissionPredicate, promote.TargetPredicate = "planning-package-approved", "exact-package-promotion", "plan-approved" + promote.Verifier = "verifier:fresh-observation:" + PlanningPackagePromote + promote.StateEffect = delivery.StateEffect{Kind: delivery.StateEffectNative, NativeHandler: "planning-package-promote"} + promote.SourceConditions = replacePlanCondition(promote.SourceConditions, model.PlanPackageApproved) + promote.TargetConditions = replacePlanCondition(promote.TargetConditions, model.PlanApproved) + promote.TargetIDs = append([]model.TargetID(nil), admit.TargetIDs...) + promote.OwnedResources = []string{"plan", "planning-package-promotion"} + promote.Interruption.ResumptionPredicate = "recovery-contract-for:" + PlanningPackagePromote + + return []delivery.Transition{admit, approve, promote}, nil +} + +func replacePlanCondition(values []delivery.FacetCondition, state model.PlanState) []delivery.FacetCondition { + result := append([]delivery.FacetCondition(nil), values...) + for index := range result { + if result[index].Facet == model.FacetPlan { + result[index].Statuses = []model.FactStatus{model.FactKnown} + result[index].Values = []string{string(state)} + } + } + return result +} + +func validatePlanningPackageWorkContract(work delivery.WorkContract) error { + 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" { + planOutput = output + } + } + if planOutput == nil || !planOutput.Required { + return fmt.Errorf("planning-package admission requires a required output named %q", "plan") + } + if path.Clean(planOutput.Path) != planOutput.Path || planOutput.Path == "." { + return fmt.Errorf("planning-package plan output path is not canonical") + } + 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") + } + return projection.Fingerprint, nil +} diff --git a/boatstack/flow/softwaredelivery/planning_package_test.go b/boatstack/flow/softwaredelivery/planning_package_test.go new file mode 100644 index 0000000..d91b62e --- /dev/null +++ b/boatstack/flow/softwaredelivery/planning_package_test.go @@ -0,0 +1,80 @@ +package softwaredelivery + +import ( + "context" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestPlanningPackageWorkRequiresOwnedPlanOutput(t *testing.T) { + tests := []struct { + name string + 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"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validatePlanningPackageWorkContract(delivery.WorkContract{ID: "planning", Outputs: test.outputs}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } +} + +func TestPlanningPackageUsesDistinctPlanStateAndSharedPlanLease(t *testing.T) { + manifest, err := standard.Definition().RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + base := make(map[string]delivery.Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + base[string(transition.ID)] = transition + } + transitions, err := planningPackageTransitions(base) + if err != nil { + t.Fatal(err) + } + for _, transition := range transitions { + if !containsString(transition.OwnedResources, "plan") { + t.Fatalf("%s does not serialize on the canonical plan resource: %v", transition.ID, transition.OwnedResources) + } + } + var got []string + for _, condition := range transitions[0].TargetConditions { + if condition.Facet == model.FacetPlan { + got = condition.Values + } + } + if !containsString(got, string(model.PlanPackageValid)) || containsString(got, string(model.PlanValid)) { + t.Fatalf("planning-package admission target = %v", got) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func TestPlanningPackageWorkAcceptsRequiredPlanAndDomainOutputs(t *testing.T) { + work := delivery.WorkContract{ID: "planning", Outputs: []delivery.WorkOutput{ + {ID: "plan", Path: "plan.md", Required: true}, + {ID: "questions", Path: "questions.md", Required: true}, + }} + if err := validatePlanningPackageWorkContract(work); err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index ece4d32..793a4d2 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -8,7 +8,6 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" "github.com/operatorstack/boatstack/boatstack/flow/skillprojection" - "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" ) func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { @@ -57,6 +56,29 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s supersession := "" delegation := "" diagnostics := "" + workProtocol := "" + publication := "" + 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. + +If human input is required, record the typed suspension with: + +`+"`boatstack flow work input-required --repo . --flow %s --entry %s --run-id --work-id --prompt --host %s --format json`"+` + +Ask the user and wait. Store the answer as bounded JSON, then submit it with +`+"`boatstack flow work answer ... --question-id --answer `"+`. +An answer is evidence, never authority. If work succeeds, run +`+"`boatstack flow work complete ...`"+`; if it cannot continue, run +`+"`boatstack flow work block ... --reason `"+`. Resume the same entry +and run ID afterward. Never edit the work record directly or continue in the +background while a question is open. +`, compiled.Document.Program.ID, entry.ID, host) + } if entry.Diagnostics != nil && entry.Diagnostics.ExplainOnSuspend { diagnostics = fmt.Sprintf(` If Boatstack suspends this run without reaching the target or prescribing an @@ -79,13 +101,22 @@ request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human --host %s`"+` -After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --host %s --format json`"+`. +After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --repository-authority --host %s --format json`"+`. Do not request approval again after a restart or typed suspension. Resume the same run and delegation unless Boatstack reports revocation, expiry, drift, or terminal completion. Never authorize on the user's behalf. `, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) } if entry.Target == "published-pr" { + publication = ` +If Boatstack reports ` + "`WORKSPACE_COMMIT_REQUIRED`" + `, stay in the same +managed worktree and run. Commit only the intended delivery changes on the +current managed branch, excluding generated runtime and publication artifacts +unless they are deliberately part of the delivery, then resume this entry. +Never fabricate an external-provider receipt. Boatstack derives provider +capability through its trusted GitHub boundary and reports a typed blocker when +that capability is unavailable. +` abandonmentSkill, ok := targetEntrySkill(compiled.Document.Program.ID, compiled.Document.Entries, "safely-abandoned") if ok { supersession = fmt.Sprintf(` @@ -109,7 +140,7 @@ Boatstack does not interpret the entry name. %s -Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`. +Start with `+"`boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json`"+`. Preserve the returned program fingerprint, entry, run ID, delivery, repository, worktree, host, actor, authority receipts, prescription, and receipts through every `+"`next`"+`, `+"`apply`"+`, recovery, question, and re-resolution. @@ -121,11 +152,13 @@ background while input is missing. Never synthesize authority. %s %s %s +%s +%s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(), compiled.Document.Program.ID, entry.ID, host, delegation, supersession, diagnostics, workProtocol, publication)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 2ba7555..35d5e09 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -36,8 +36,9 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { } value := string(codex) for _, contract := range []string{ - "--flow product-delivery --entry run", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", + "--flow product-delivery --entry run", "--repository-authority", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", "BOATSTACK_LAUNCHER_NOT_FOUND", ".boatstack/runtime.json", "Never run it", "creates no\nFlow run ID", + "WORKSPACE_COMMIT_REQUIRED", "Commit only the intended delivery changes", "Never fabricate an external-provider receipt", } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) @@ -118,6 +119,29 @@ func TestGeneratedSkillExplanationIsEntryOptInWithHostParity(t *testing.T) { } } +func TestGeneratedSkillsProjectForegroundWorkProtocolWithHostParity(t *testing.T) { + // control-law: every supported agent host projects the same foreground-work boundary + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "incident-response"}, + Work: []controlprogram.WorkContract{{ID: "diagnose"}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + 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) + } + } + if strings.ReplaceAll(codex, "--host codex", "--host HOST") != strings.ReplaceAll(claude, "--host claude", "--host HOST") { + t.Fatal("Codex and Claude foreground-work projections differ") + } +} + func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "product-delivery"}, diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 6418190..c47d897 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -147,7 +147,7 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t t.Errorf("managed writer os.%s escaped effects package in %s", selector.Sel.Name, relative) } if importPath == "os/exec" && (selector.Sel.Name == "Command" || selector.Sel.Name == "CommandContext") { - if relative != "internal/softwaredelivery/effects/command_boundary.go" && relative != "internal/softwaredelivery/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" && relative != "internal/runtime/exec_windows.go" && relative != "internal/runtime/flow_files.go" { + if relative != "internal/softwaredelivery/effects/command_boundary.go" && relative != "internal/softwaredelivery/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" && relative != "internal/runtime/exec_windows.go" && relative != "internal/runtime/flow_files.go" && relative != "internal/runtime/control_bundle.go" { t.Errorf("unclassified command boundary in %s", relative) } } diff --git a/boatstack/internal/runtime/control_bundle.go b/boatstack/internal/runtime/control_bundle.go new file mode 100644 index 0000000..3870494 --- /dev/null +++ b/boatstack/internal/runtime/control_bundle.go @@ -0,0 +1,532 @@ +package runtime + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +const ControlBundleSchemaVersion = 1 + +// ControlBundleFile binds one repository-relative control file to exact bytes. +type ControlBundleFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256,omitempty"` + Absent bool `json:"absent,omitempty"` +} + +// ControlBundleMemberSet binds the complete direct-child membership of one +// executable control directory, not only the files discovered at projection. +type ControlBundleMemberSet struct { + Root string `json:"root"` + Suffix string `json:"suffix"` + Paths []string `json:"paths"` +} + +// ControlBundleSnapshot is the canonical executable control projection at one +// repository root. +type ControlBundleSnapshot struct { + Fingerprint string `json:"fingerprint"` + Files []ControlBundleFile `json:"files"` + MemberSets []ControlBundleMemberSet `json:"member_sets,omitempty"` +} + +// ControlBundleContract binds the source projection and, for execution-context +// advances, the exact projection required at the target root and revision. +type ControlBundleContract struct { + SchemaVersion int `json:"schema_version"` + Fingerprint string `json:"fingerprint"` + Source ControlBundleSnapshot `json:"source"` + Target *ControlBundleSnapshot `json:"target,omitempty"` + TargetRevision string `json:"target_revision,omitempty"` + SourceRuntimePin *Pin `json:"source_runtime_pin,omitempty"` + TargetRuntimePin *Pin `json:"target_runtime_pin,omitempty"` +} + +func NewControlBundleSnapshot(files map[string][]byte) (ControlBundleSnapshot, error) { + return NewControlBundleSnapshotWithAbsent(files, nil) +} + +func NewControlBundleSnapshotWithAbsent(files map[string][]byte, absent []string) (ControlBundleSnapshot, error) { + return NewControlBundleSnapshotWithMemberSets(files, absent, nil) +} + +func NewControlBundleSnapshotWithMemberSets(files map[string][]byte, absent []string, memberSets []ControlBundleMemberSet) (ControlBundleSnapshot, error) { + bindings := make([]ControlBundleFile, 0, len(files)) + for path, raw := range files { + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + digest := sha256.Sum256(raw) + bindings = append(bindings, ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])}) + } + for _, path := range absent { + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + bindings = append(bindings, ControlBundleFile{Path: path, Absent: true}) + } + if len(bindings) == 0 { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: bundle has no files") + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].Path < bindings[j].Path }) + for index := 1; index < len(bindings); index++ { + if bindings[index-1].Path == bindings[index].Path { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate path %q", bindings[index].Path) + } + } + canonicalSets, err := canonicalControlBundleMemberSets(memberSets, bindings) + if err != nil { + return ControlBundleSnapshot{}, err + } + fingerprint, err := controlBundleSnapshotDigest(bindings, canonicalSets) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: bindings, MemberSets: canonicalSets}, nil +} + +// ReplaceControlBundleFile derives a target snapshot without trusting a +// caller-supplied target fingerprint. +func ReplaceControlBundleFile(snapshot ControlBundleSnapshot, path string, raw []byte) (ControlBundleSnapshot, error) { + if err := snapshot.validate(); err != nil { + return ControlBundleSnapshot{}, err + } + if !safeProjectionRelative(path) { + return ControlBundleSnapshot{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe path %q", path) + } + digest := sha256.Sum256(raw) + binding := ControlBundleFile{Path: path, SHA256: hex.EncodeToString(digest[:])} + files := append([]ControlBundleFile(nil), snapshot.Files...) + replaced := false + for index := range files { + if files[index].Path == path { + files[index], replaced = binding, true + break + } + } + if !replaced { + files = append(files, binding) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + } + memberSets, err := canonicalControlBundleMemberSets(snapshot.MemberSets, files) + if err != nil { + return ControlBundleSnapshot{}, err + } + fingerprint, err := controlBundleSnapshotDigest(files, memberSets) + if err != nil { + return ControlBundleSnapshot{}, err + } + return ControlBundleSnapshot{Fingerprint: fingerprint, Files: files, MemberSets: memberSets}, nil +} + +func NewControlBundleContract(source ControlBundleSnapshot, target *ControlBundleSnapshot, targetRevision string) (ControlBundleContract, error) { + return NewControlBundleContractWithPins(source, target, targetRevision, nil, nil) +} + +func NewControlBundleContractWithPins(source ControlBundleSnapshot, target *ControlBundleSnapshot, targetRevision string, sourcePin, targetPin *Pin) (ControlBundleContract, error) { + contract := ControlBundleContract{SchemaVersion: ControlBundleSchemaVersion, Source: source, Target: target, TargetRevision: targetRevision, SourceRuntimePin: sourcePin, TargetRuntimePin: targetPin} + if err := contract.validateFields(); err != nil { + return ControlBundleContract{}, err + } + identity := contract + identity.Fingerprint = "" + fingerprint, err := controlBundleDigest(identity) + if err != nil { + return ControlBundleContract{}, err + } + contract.Fingerprint = fingerprint + return contract, nil +} + +func (c ControlBundleContract) Validate() error { + if err := c.validateFields(); err != nil { + return err + } + identity := c + want := identity.Fingerprint + identity.Fingerprint = "" + got, err := controlBundleDigest(identity) + if err != nil || want == "" || got != want { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: contract fingerprint mismatch") + } + return nil +} + +func (c ControlBundleContract) validateFields() error { + if c.SchemaVersion != ControlBundleSchemaVersion { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: unsupported schema") + } + if err := c.Source.validate(); err != nil { + return err + } + if c.Target != nil { + if err := c.Target.validate(); err != nil { + return err + } + } else if c.TargetRevision != "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision has no target bundle") + } + if err := validateBoundRuntimePin(c.Source, c.SourceRuntimePin); err != nil { + return err + } + if c.Target != nil { + if err := validateBoundRuntimePin(*c.Target, c.TargetRuntimePin); err != nil { + return err + } + } else if c.TargetRuntimePin != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target runtime pin has no target bundle") + } + if c.TargetRevision != "" && !validObjectIdentity(c.TargetRevision) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: target revision is not an exact object identity") + } + return nil +} + +func validateBoundRuntimePin(snapshot ControlBundleSnapshot, pin *Pin) error { + var binding *ControlBundleFile + for index := range snapshot.Files { + if snapshot.Files[index].Path == ".boatstack/runtime.json" { + binding = &snapshot.Files[index] + break + } + } + if binding == nil || binding.Absent { + if pin != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime-pin metadata has no runtime file") + } + return nil + } + if pin == nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime file has no decoded pin metadata") + } + raw, err := EncodePin(*pin) + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: %w", err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != binding.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: runtime-pin metadata does not match bundle bytes") + } + return nil +} + +func (s ControlBundleSnapshot) validate() error { + if !validControlDigest(s.Fingerprint) || len(s.Files) == 0 { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: snapshot is incomplete") + } + prior := "" + for _, file := range s.Files { + if !safeProjectionRelative(file.Path) || file.Path <= prior || (file.Absent && file.SHA256 != "") || (!file.Absent && !validControlDigest(file.SHA256)) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: file bindings are not canonical") + } + prior = file.Path + } + memberSets, err := canonicalControlBundleMemberSets(s.MemberSets, s.Files) + if err != nil || !equalControlBundleMemberSets(memberSets, s.MemberSets) { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: member sets are not canonical") + } + fingerprint, err := controlBundleSnapshotDigest(s.Files, s.MemberSets) + if err != nil || fingerprint != s.Fingerprint { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: snapshot fingerprint mismatch") + } + return nil +} + +func canonicalControlBundleMemberSets(values []ControlBundleMemberSet, files []ControlBundleFile) ([]ControlBundleMemberSet, error) { + sets := make([]ControlBundleMemberSet, len(values)) + copy(sets, values) + bound := make(map[string]bool, len(files)) + for _, file := range files { + if !file.Absent { + bound[file.Path] = true + } + } + for index := range sets { + memberSet := &sets[index] + if !safeProjectionRelative(memberSet.Root) || strings.Contains(memberSet.Suffix, "/") || strings.Contains(memberSet.Suffix, "\\") || memberSet.Suffix == "" { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: unsafe member set") + } + memberSet.Root = strings.TrimSuffix(memberSet.Root, "/") + memberSet.Paths = append([]string(nil), memberSet.Paths...) + sort.Strings(memberSet.Paths) + for pathIndex, memberPath := range memberSet.Paths { + if !safeProjectionRelative(memberPath) || filepath.ToSlash(filepath.Dir(filepath.FromSlash(memberPath))) != memberSet.Root || !strings.HasSuffix(memberPath, memberSet.Suffix) || !bound[memberPath] { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: member set path %q is not a bound direct child", memberPath) + } + if pathIndex > 0 && memberSet.Paths[pathIndex-1] == memberPath { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate member set path %q", memberPath) + } + } + } + sort.Slice(sets, func(i, j int) bool { + if sets[i].Root != sets[j].Root { + return sets[i].Root < sets[j].Root + } + return sets[i].Suffix < sets[j].Suffix + }) + for index := 1; index < len(sets); index++ { + if sets[index-1].Root == sets[index].Root && sets[index-1].Suffix == sets[index].Suffix { + return nil, fmt.Errorf("CONTROL_BUNDLE_INVALID: duplicate member set") + } + } + return sets, nil +} + +func controlBundleSnapshotDigest(files []ControlBundleFile, memberSets []ControlBundleMemberSet) (string, error) { + return controlBundleDigest(struct { + Files []ControlBundleFile `json:"files"` + MemberSets []ControlBundleMemberSet `json:"member_sets,omitempty"` + }{Files: files, MemberSets: memberSets}) +} + +func equalControlBundleMemberSets(left, right []ControlBundleMemberSet) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index].Root != right[index].Root || left[index].Suffix != right[index].Suffix || !equalStrings(left[index].Paths, right[index].Paths) { + return false + } + } + return true +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func rootMemberSet(repository string, memberSet ControlBundleMemberSet) ([]string, error) { + entries, err := os.ReadDir(filepath.Join(repository, filepath.FromSlash(memberSet.Root))) + if os.IsNotExist(err) { + return []string{}, nil + } + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: read member set %s: %w", memberSet.Root, err) + } + paths := []string{} + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), memberSet.Suffix) { + continue + } + info, infoErr := entry.Info() + if infoErr != nil || entry.Type()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: member set path %s/%s is not a regular file", memberSet.Root, entry.Name()) + } + paths = append(paths, memberSet.Root+"/"+entry.Name()) + } + sort.Strings(paths) + return paths, nil +} + +func revisionMemberSet(ctx context.Context, repository, revision string, memberSet ControlBundleMemberSet) ([]string, error) { + command := exec.CommandContext(ctx, "git", "ls-tree", "-r", "-z", revision, "--", memberSet.Root) + command.Dir = repository + output, err := command.Output() + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: inspect revision %s member set %s: %w", revision, memberSet.Root, err) + } + paths := []string{} + for _, record := range bytes.Split(output, []byte{0}) { + if len(record) == 0 { + continue + } + fields := bytes.SplitN(record, []byte{'\t'}, 2) + metadata := strings.Fields(string(fields[0])) + if len(fields) != 2 || len(metadata) < 1 { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: revision member set response is malformed") + } + memberPath := string(fields[1]) + if filepath.ToSlash(filepath.Dir(filepath.FromSlash(memberPath))) != memberSet.Root || !strings.HasSuffix(memberPath, memberSet.Suffix) { + continue + } + if metadata[0] != "100644" && metadata[0] != "100755" { + return nil, fmt.Errorf("CONTROL_BUNDLE_STALE: revision member set path %s is not a regular file", memberPath) + } + paths = append(paths, memberPath) + } + sort.Strings(paths) + return paths, nil +} + +func validControlDigest(value string) bool { + if len(value) != 64 || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func validObjectIdentity(value string) bool { + if (len(value) != 40 && len(value) != 64) || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} + +func VerifyControlBundleRoot(repository string, snapshot ControlBundleSnapshot) error { + if err := snapshot.validate(); err != nil { + return err + } + repository, err := filepath.EvalSymlinks(repository) + if err != nil || !filepath.IsAbs(repository) || filepath.Clean(repository) != repository { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: repository root is not exact") + } + for _, memberSet := range snapshot.MemberSets { + observed, observeErr := rootMemberSet(repository, memberSet) + if observeErr != nil { + return observeErr + } + if !equalStrings(observed, memberSet.Paths) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: member set %s/*%s does not match the admitted bundle", memberSet.Root, memberSet.Suffix) + } + } + for _, file := range snapshot.Files { + if file.Absent { + if _, statErr := os.Lstat(filepath.Join(repository, filepath.FromSlash(file.Path))); !os.IsNotExist(statErr) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s must be absent", file.Path) + } + continue + } + raw, readErr := readBundleFile(repository, file.Path) + if readErr != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s: %w", file.Path, readErr) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != file.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_STALE: %s does not match the admitted bundle", file.Path) + } + } + return nil +} + +func VerifyControlBundleRevision(ctx context.Context, repository, revision string, snapshot ControlBundleSnapshot) error { + if err := snapshot.validate(); err != nil { + return err + } + if repository == "" || !filepath.IsAbs(repository) || revision == "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: revision verification is incomplete") + } + for _, memberSet := range snapshot.MemberSets { + observed, observeErr := revisionMemberSet(ctx, repository, revision, memberSet) + if observeErr != nil { + return observeErr + } + if !equalStrings(observed, memberSet.Paths) { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s member set %s/*%s does not match the admitted bundle", revision, memberSet.Root, memberSet.Suffix) + } + } + for _, file := range snapshot.Files { + if file.Absent { + command := exec.CommandContext(ctx, "git", "cat-file", "-e", revision+":"+file.Path) + command.Dir = repository + if command.Run() == nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s unexpectedly contains %s", revision, file.Path) + } + continue + } + command := exec.CommandContext(ctx, "git", "show", revision+":"+file.Path) + command.Dir = repository + raw, err := command.Output() + if err != nil { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s lacks %s", revision, file.Path) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != file.SHA256 { + return fmt.Errorf("CONTROL_BUNDLE_STALE: revision %s has different %s", revision, file.Path) + } + } + return nil +} + +func ResolveCommitRevision(ctx context.Context, repository, reference string) (string, error) { + command := exec.CommandContext(ctx, "git", "rev-parse", "--verify", reference+"^{commit}") + command.Dir = repository + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("resolve Git reference %q: %w", reference, err) + } + revision := strings.TrimSpace(string(output)) + if (len(revision) != 40 && len(revision) != 64) || strings.ToLower(revision) != revision { + return "", fmt.Errorf("Git reference %q did not resolve to an exact object identity", reference) + } + if _, err := hex.DecodeString(revision); err != nil { + return "", fmt.Errorf("Git reference %q did not resolve to an exact object identity", reference) + } + return revision, nil +} + +// VerifyControlBundleHead binds a newly created execution context to both the +// admitted control bytes and the exact commit resolved before the effect. +func VerifyControlBundleHead(ctx context.Context, repository, revision string, snapshot ControlBundleSnapshot) error { + command := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "HEAD^{commit}") + command.Dir = repository + output, err := command.Output() + if err != nil || strings.TrimSpace(string(output)) != revision { + return fmt.Errorf("CONTROL_BUNDLE_STALE: target HEAD does not match admitted revision %s", revision) + } + return VerifyControlBundleRoot(repository, snapshot) +} + +func EncodeControlBundle(contract ControlBundleContract) ([]byte, error) { + if err := contract.Validate(); err != nil { + return nil, err + } + return json.Marshal(contract) +} + +func DecodeControlBundle(raw []byte) (ControlBundleContract, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var contract ControlBundleContract + if err := decoder.Decode(&contract); err != nil { + return ControlBundleContract{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return ControlBundleContract{}, fmt.Errorf("CONTROL_BUNDLE_INVALID: trailing data") + } + return contract, contract.Validate() +} + +func readBundleFile(repository, relative string) ([]byte, error) { + absolute := filepath.Join(repository, filepath.FromSlash(relative)) + info, err := os.Lstat(absolute) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("control file is not a regular file") + } + resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil || (resolvedParent != repository && !strings.HasPrefix(resolvedParent, repository+string(filepath.Separator))) { + return nil, fmt.Errorf("control file escapes repository") + } + return os.ReadFile(absolute) +} + +func controlBundleDigest(value any) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} diff --git a/boatstack/internal/runtime/control_bundle_test.go b/boatstack/internal/runtime/control_bundle_test.go new file mode 100644 index 0000000..e70f1fb --- /dev/null +++ b/boatstack/internal/runtime/control_bundle_test.go @@ -0,0 +1,119 @@ +package runtime + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestControlBundleCanonicalizesFilesAndBindsAbsence(t *testing.T) { + left, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".boatstack/project.json": []byte("project"), + ".agents/skills/run/SKILL.md": []byte("skill"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + right, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".agents/skills/run/SKILL.md": []byte("skill"), + ".boatstack/project.json": []byte("project"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + if left.Fingerprint != right.Fingerprint { + t.Fatalf("ordering changed fingerprint: %s != %s", left.Fingerprint, right.Fingerprint) + } + replaced, err := ReplaceControlBundleFile(left, ".boatstack/runtime.json", []byte("pin")) + if err != nil { + t.Fatal(err) + } + if replaced.Fingerprint == left.Fingerprint || replaced.Files[2].Absent { + t.Fatalf("runtime pin replacement did not change the canonical bundle: %#v", replaced) + } +} + +func TestControlBundleVerifiesRootRevisionAndExactHead(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + if err := os.MkdirAll(filepath.Join(repository, ".boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "project.json"), []byte("project\n"), 0o644); err != nil { + t.Fatal(err) + } + snapshot, err := NewControlBundleSnapshotWithAbsent(map[string][]byte{ + ".boatstack/project.json": []byte("project\n"), + }, []string{".boatstack/runtime.json"}) + if err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", ".boatstack/project.json") + runBundleGit(t, repository, "commit", "-q", "-m", "bundle") + revision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + if err := VerifyControlBundleRoot(repository, snapshot); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRevision(context.Background(), repository, revision, snapshot); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleHead(context.Background(), repository, revision, snapshot); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".boatstack", "runtime.json"), []byte("unexpected"), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRoot(repository, snapshot); err == nil || !strings.Contains(err.Error(), ".boatstack/runtime.json") { + t.Fatalf("unexpected runtime pin was not rejected: %v", err) + } +} + +func TestControlBundleBindsCompleteExecutableDirectoryMembership(t *testing.T) { + repository := t.TempDir() + runBundleGit(t, repository, "init", "-q") + runBundleGit(t, repository, "config", "user.email", "bundle@example.invalid") + runBundleGit(t, repository, "config", "user.name", "Bundle Test") + flows := filepath.Join(repository, ".boatstack", "flows") + if err := os.MkdirAll(flows, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(flows, "primary.flow.ir.json"), []byte("primary\n"), 0o644); err != nil { + t.Fatal(err) + } + snapshot, err := NewControlBundleSnapshotWithMemberSets(map[string][]byte{ + ".boatstack/flows/primary.flow.ir.json": []byte("primary\n"), + }, nil, []ControlBundleMemberSet{{Root: ".boatstack/flows", Suffix: ".flow.ir.json", Paths: []string{".boatstack/flows/primary.flow.ir.json"}}}) + if err != nil { + t.Fatal(err) + } + runBundleGit(t, repository, "add", ".boatstack/flows") + runBundleGit(t, repository, "commit", "-q", "-m", "primary") + if err := os.WriteFile(filepath.Join(flows, "secondary.flow.ir.json"), []byte("secondary\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := VerifyControlBundleRoot(repository, snapshot); err == nil || !strings.Contains(err.Error(), "member set") { + t.Fatalf("extra executable member was accepted at root: %v", err) + } + runBundleGit(t, repository, "add", ".boatstack/flows/secondary.flow.ir.json") + runBundleGit(t, repository, "commit", "-q", "-m", "secondary") + revision := strings.TrimSpace(runBundleGit(t, repository, "rev-parse", "HEAD")) + if err := VerifyControlBundleRevision(context.Background(), repository, revision, snapshot); err == nil || !strings.Contains(err.Error(), "member set") { + t.Fatalf("extra executable member was accepted at revision: %v", err) + } +} + +func runBundleGit(t *testing.T, directory string, arguments ...string) string { + t.Helper() + command := exec.Command("git", arguments...) + command.Dir = directory + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } + return string(output) +} diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go index 8717de8..77f6375 100644 --- a/boatstack/internal/runtime/flow_files.go +++ b/boatstack/internal/runtime/flow_files.go @@ -30,6 +30,37 @@ func RunFlowFrontend(ctx context.Context, executable, sourceName string, source return output, nil } +// VerifyFlowProjectionAtRevision proves that a workspace base contains the +// exact active Flow inputs and generated outputs before authority transfers to +// a new Git worktree. +func VerifyFlowProjectionAtRevision(ctx context.Context, repository, revision string, paths []string) error { + if !filepath.IsAbs(repository) || filepath.Clean(repository) != repository || revision == "" || len(paths) == 0 { + return fmt.Errorf("Flow revision projection requires an exact repository, revision, and path set") + } + resolvedRepository, err := filepath.EvalSymlinks(repository) + if err != nil || resolvedRepository != repository { + return fmt.Errorf("Flow revision projection repository is not resolved") + } + seen := map[string]bool{} + for _, relative := range paths { + if relative == "" || filepath.IsAbs(relative) || filepath.ToSlash(filepath.Clean(filepath.FromSlash(relative))) != relative || relative == ".." || strings.HasPrefix(relative, "../") || seen[relative] { + return fmt.Errorf("Flow revision projection contains an invalid path %q", relative) + } + seen[relative] = true + current, readErr := os.ReadFile(filepath.Join(repository, filepath.FromSlash(relative))) + if readErr != nil { + return fmt.Errorf("read active Flow projection %s: %w", relative, readErr) + } + command := exec.CommandContext(ctx, "git", "show", revision+":"+relative) + command.Dir = repository + committed, showErr := command.Output() + if showErr != nil || !bytes.Equal(current, committed) { + return fmt.Errorf("Flow projection %s differs from revision %s", relative, revision) + } + } + return nil +} + type ProjectionWrite struct { Path string Content []byte diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go index 3615a6a..0912c93 100644 --- a/boatstack/internal/runtime/flow_files_test.go +++ b/boatstack/internal/runtime/flow_files_test.go @@ -2,6 +2,7 @@ package runtime import ( "bufio" + "context" "os" "os/exec" "path/filepath" @@ -10,6 +11,39 @@ import ( "testing" ) +func TestVerifyFlowProjectionAtRevisionBindsActiveBytesToWorkspaceBase(t *testing.T) { + // control-law: workspace-base-contains-the-active-flow-projection + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"init", "-q"}, {"config", "user.name", "Boatstack Tests"}, {"config", "user.email", "boatstack@example.invalid"}} { + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, commandErr := command.CombinedOutput(); commandErr != nil { + t.Fatalf("git %v: %v\n%s", arguments, commandErr, output) + } + } + path := filepath.Join(repository, "generated.txt") + if err := os.WriteFile(path, []byte("committed\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"add", "generated.txt"}, {"commit", "-q", "-m", "projection"}} { + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, commandErr := command.CombinedOutput(); commandErr != nil { + t.Fatalf("git %v: %v\n%s", arguments, commandErr, output) + } + } + if err := VerifyFlowProjectionAtRevision(context.Background(), repository, "HEAD", []string{"generated.txt"}); err != nil { + t.Fatalf("exact projection rejected: %v", err) + } + if err := os.WriteFile(path, []byte("regenerated\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := VerifyFlowProjectionAtRevision(context.Background(), repository, "HEAD", []string{"generated.txt"}); err == nil || !strings.Contains(err.Error(), "generated.txt differs") { + t.Fatalf("uncommitted projection result = %v", err) + } +} + func resolvedTemporaryRepository(t *testing.T) string { t.Helper() repository, err := filepath.EvalSymlinks(t.TempDir()) diff --git a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go index a3c723d..64b9c83 100644 --- a/boatstack/internal/softwaredelivery/catalog/native_state_handler.go +++ b/boatstack/internal/softwaredelivery/catalog/native_state_handler.go @@ -28,17 +28,20 @@ var nativeStateHandlerContracts = map[string]nativeStateHandlerContract{ ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeNone}, bindsRequestedObjective: true, }, - "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), - "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact), - "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact), - "workspace-reap": standardNative([]EffectID{"workspace.reap"}, ObjectiveScopeBoundExact), - "workspace-reconcile": standardNative([]EffectID{"workspace.reconcile"}, ObjectiveScopeOptionalPreserve), - "gate-build-record": standardNative([]EffectID{"gate.build.record"}, ObjectiveScopeBoundExact), - "gate-test-record": standardNative([]EffectID{"gate.test.record"}, ObjectiveScopeBoundExact), - "gate-review-record": standardNative([]EffectID{"gate.review.record"}, ObjectiveScopeBoundExact), - "gate-change-record": standardNative([]EffectID{"gate.change.record"}, ObjectiveScopeBoundExact), - "gate-journey-record": standardNative([]EffectID{"gate.journey.record"}, ObjectiveScopeBoundExact), - "visual-evidence-attach": standardNative([]EffectID{"evidence.visual.attach"}, ObjectiveScopeBoundExact), + "plan-approve": standardNative([]EffectID{"plan.approve"}, ObjectiveScopeBoundExact), + "planning-package-admit": standardNative([]EffectID{"planning.package.admit"}, ObjectiveScopeBoundExact), + "planning-package-approve": standardNative([]EffectID{"planning.package.approve"}, ObjectiveScopeBoundExact), + "planning-package-promote": standardNative([]EffectID{"planning.package.promote"}, ObjectiveScopeBoundExact), + "abandon-delivery": standardNative([]EffectID{"plan.abandon", "publication.abandon"}, ObjectiveScopeBoundExact), + "workspace-cleanup": standardNative([]EffectID{"workspace.cleanup"}, ObjectiveScopeBoundExact), + "workspace-reap": standardNative([]EffectID{"workspace.reap"}, ObjectiveScopeBoundExact), + "workspace-reconcile": standardNative([]EffectID{"workspace.reconcile"}, ObjectiveScopeOptionalPreserve), + "gate-build-record": standardNative([]EffectID{"gate.build.record"}, ObjectiveScopeBoundExact), + "gate-test-record": standardNative([]EffectID{"gate.test.record"}, ObjectiveScopeBoundExact), + "gate-review-record": standardNative([]EffectID{"gate.review.record"}, ObjectiveScopeBoundExact), + "gate-change-record": standardNative([]EffectID{"gate.change.record"}, ObjectiveScopeBoundExact), + "gate-journey-record": standardNative([]EffectID{"gate.journey.record"}, ObjectiveScopeBoundExact), + "visual-evidence-attach": standardNative([]EffectID{"evidence.visual.attach"}, ObjectiveScopeBoundExact), "publication-observe": { componentIDs: []string{"boatstack.standard"}, effects: []EffectID{"publication.observe", "publication.reconcile"}, ownedFacets: []model.StateFacet{model.StateFacetControl, model.StateFacetProduct}, objectiveScopes: []ObjectiveScope{ObjectiveScopeBoundExact, ObjectiveScopeOptionalPreserve}, diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index 3ec7483..ef7a1c0 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "sort" + "strings" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" @@ -165,6 +166,35 @@ type ParameterSpec struct { Secret bool `json:"secret"` } +// WorkContract is a bounded foreground-work requirement attached to one +// trusted transition. It can request and verify evidence, but it does not own +// domain effects or define a second transition graph. +type WorkContract struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []WorkInput `json:"inputs,omitempty"` + Outputs []WorkOutput `json:"outputs"` +} + +type WorkInput struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` +} + +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"` +} + // StateEffectKind selects the software-delivery domain's durable-state // interpreter. It is domain ABI data; the general kernel continues to own // transition selection, objective scope, capabilities, and facet ownership. @@ -349,6 +379,7 @@ type Transition struct { ExecutionContext string `json:"execution_context,omitempty"` BindsSourceRevision bool `json:"binds_source_revision,omitempty"` AuthorityFingerprintParameter string `json:"authority_fingerprint_parameter,omitempty"` + Work *WorkContract `json:"work,omitempty"` } func (t Transition) Controllable() bool { return t.Class.Controllable() } @@ -640,6 +671,53 @@ func validateTransition(t Transition) error { if t.Priority < 1 { return fmt.Errorf("%s: priority must be positive", t.ID) } + if err := validateWorkContract(t); err != nil { + return err + } + return nil +} + +func validateWorkContract(t Transition) error { + if t.Work == nil { + return nil + } + work := t.Work + if !t.Controllable() || work.ID == "" || !semanticID.MatchString(work.ID) || len(work.Fingerprint) != 64 || + work.InstructionPath == "" || len(work.InstructionSHA256) != 64 || strings.TrimSpace(work.InstructionContent) == "" || len(work.Outputs) == 0 { + return fmt.Errorf("%s: foreground work contract is incomplete", t.ID) + } + if fingerprint, err := 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 []WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}); err != nil || fingerprint != work.Fingerprint { + return fmt.Errorf("%s: foreground work contract fingerprint is invalid", t.ID) + } + inputs := map[string]bool{} + for _, input := range work.Inputs { + if !semanticID.MatchString(input.ID) || !semanticID.MatchString(input.EntryInput) || inputs[input.ID] { + return fmt.Errorf("%s: foreground work inputs must be semantic and unique", t.ID) + } + inputs[input.ID] = true + } + outputs := map[string]bool{} + paths := map[string]bool{} + for _, output := range work.Outputs { + if !semanticID.MatchString(output.ID) || output.Path == "" || outputs[output.ID] || paths[output.Path] || output.MaxBytes < 1 || output.MaxBytes > 16<<20 { + return fmt.Errorf("%s: foreground work outputs must be bounded and unique", t.ID) + } + if output.MediaType != "text/markdown" && output.MediaType != "text/plain" && output.MediaType != "application/json" { + return fmt.Errorf("%s: foreground work output %q has unsupported media type", t.ID, output.ID) + } + hasSchema := output.SchemaPath != "" || output.SchemaSHA256 != "" || output.SchemaContent != "" + if hasSchema && (output.SchemaPath == "" || len(output.SchemaSHA256) != 64 || output.SchemaContent == "" || output.MediaType != "application/json") { + return fmt.Errorf("%s: foreground work output %q has invalid schema binding", t.ID, output.ID) + } + outputs[output.ID], paths[output.Path] = true, true + } return nil } @@ -752,6 +830,12 @@ func cloneTransition(value Transition) Transition { value.Interruption.Points = append([]string(nil), value.Interruption.Points...) value.Interruption.PartialState = append([]string(nil), value.Interruption.PartialState...) value.Policy.ManagedOperations = append([]string(nil), value.Policy.ManagedOperations...) + if value.Work != nil { + work := *value.Work + work.Inputs = append([]WorkInput(nil), value.Work.Inputs...) + work.Outputs = append([]WorkOutput(nil), value.Work.Outputs...) + value.Work = &work + } return value } diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index af753f7..db49135 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -17,31 +17,32 @@ import ( const ( Schema = "run-delegation" - SchemaRevision = 1 + SchemaRevision = 2 ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Request struct { - RunID string `json:"run_id"` - ProgramID string `json:"program_id"` - ProgramFingerprint string `json:"program_fingerprint"` - EntryID string `json:"entry_id"` - TargetID string `json:"target_id"` - ObjectiveID string `json:"objective_id"` - DeliveryID string `json:"delivery_id"` - InputFingerprints []string `json:"input_fingerprints"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - InitialWorktreeID string `json:"initial_worktree_id"` - InitialRef string `json:"initial_ref"` - BindingFingerprint string `json:"binding_fingerprint"` - RequestedAuthorities []string `json:"requested_authorities"` - Description string `json:"description"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + ObjectiveID string `json:"objective_id"` + DeliveryID string `json:"delivery_id"` + InputFingerprints []string `json:"input_fingerprints"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + InitialWorktreeID string `json:"initial_worktree_id"` + InitialRef string `json:"initial_ref"` + BindingFingerprint string `json:"binding_fingerprint"` + RequestedAuthorities []string `json:"requested_authorities"` + Description string `json:"description"` } func (r Request) Fingerprint() (string, error) { - if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || len(r.RequestedAuthorities) == 0 || r.Description == "" { + if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || len(r.RequestedAuthorities) == 0 || r.Description == "" { return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") } r.InputFingerprints = append([]string(nil), r.InputFingerprints...) diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index 3266919..43ac4fc 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -9,7 +9,7 @@ import ( func request() delegation.Request { return delegation.Request{ - RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), EntryID: "run", + RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("c", 64), EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"b", "a"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", BindingFingerprint: strings.Repeat("b", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index d4a6721..f40abed 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -12,7 +12,16 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const StateSchemaVersion = 4 +const ( + StateSchemaVersion = 6 + priorStateSchemaVersion = 4 +) + +// CanReadStateSchema reports the current schema and its single supported +// forward-migration predecessor. Older or future schemas remain fail-closed. +func CanReadStateSchema(version int) bool { + return version == StateSchemaVersion || version == priorStateSchemaVersion +} type GateEvidence struct { Gate string `json:"gate"` @@ -21,56 +30,58 @@ type GateEvidence struct { } type State struct { - SchemaVersion int `json:"schema_version"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - WorktreeID string `json:"worktree_id"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - Revision uint64 `json:"revision"` - Phase model.ProtocolPhase `json:"phase"` - Engagement model.EngagementState `json:"engagement"` - Delivery model.DeliveryState `json:"delivery"` - Workspace model.WorkspaceState `json:"workspace"` - Plan model.PlanState `json:"plan"` - Configuration model.ConfigurationState `json:"configuration"` - Runtime model.RuntimeState `json:"runtime"` - Publication model.PublicationState `json:"publication"` - Verification model.VerificationState `json:"verification"` - Recovery model.RecoveryState `json:"recovery"` - Transaction model.TransactionState `json:"transaction"` - Terminal model.TerminalStatus `json:"terminal"` - Objective model.Objective `json:"objective"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - ConfigFingerprint string `json:"config_fingerprint,omitempty"` - PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` - VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` - ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` - IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` - EnabledHosts []string `json:"enabled_hosts,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSource string `json:"runtime_source_revision,omitempty"` - PlanFingerprint string `json:"plan_fingerprint,omitempty"` - ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` - WorkspaceBranch string `json:"workspace_branch,omitempty"` - WorkspacePath string `json:"workspace_path,omitempty"` - WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` - WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` - WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` - WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` - PublicationID string `json:"publication_id,omitempty"` - PublicationURL string `json:"publication_url,omitempty"` - PreviewFingerprint string `json:"preview_fingerprint,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - TransactionTransition string `json:"transaction_transition,omitempty"` - RecoveryCause string `json:"recovery_cause,omitempty"` - RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` - RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` - RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` - LastTransition catalog.TransitionID `json:"last_transition,omitempty"` - Gates []GateEvidence `json:"gates,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + SchemaVersion int `json:"schema_version"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` + Revision uint64 `json:"revision"` + Phase model.ProtocolPhase `json:"phase"` + Engagement model.EngagementState `json:"engagement"` + Delivery model.DeliveryState `json:"delivery"` + Workspace model.WorkspaceState `json:"workspace"` + Plan model.PlanState `json:"plan"` + Configuration model.ConfigurationState `json:"configuration"` + Runtime model.RuntimeState `json:"runtime"` + Publication model.PublicationState `json:"publication"` + Verification model.VerificationState `json:"verification"` + Recovery model.RecoveryState `json:"recovery"` + Transaction model.TransactionState `json:"transaction"` + Terminal model.TerminalStatus `json:"terminal"` + Objective model.Objective `json:"objective"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + ConfigFingerprint string `json:"config_fingerprint,omitempty"` + PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"` + VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"` + ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` + IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` + EnabledHosts []string `json:"enabled_hosts,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSource string `json:"runtime_source_revision,omitempty"` + PlanFingerprint string `json:"plan_fingerprint,omitempty"` + PlanningPackageFingerprint string `json:"planning_package_fingerprint,omitempty"` + ApprovalFingerprint string `json:"approval_fingerprint,omitempty"` + WorkspaceBranch string `json:"workspace_branch,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"` + WorkspaceSourcePath string `json:"workspace_source_path,omitempty"` + WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"` + WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"` + PublicationID string `json:"publication_id,omitempty"` + PublicationURL string `json:"publication_url,omitempty"` + PreviewFingerprint string `json:"preview_fingerprint,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + TransactionTransition string `json:"transaction_transition,omitempty"` + RecoveryCause string `json:"recovery_cause,omitempty"` + RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"` + RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"` + RecoveryBudget int `json:"recovery_budget_remaining,omitempty"` + LastTransition catalog.TransitionID `json:"last_transition,omitempty"` + Gates []GateEvidence `json:"gates,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } func Default(invocation model.InvocationContext, now time.Time) State { @@ -103,6 +114,15 @@ func (s State) Validate() error { if s.ProgramFingerprint != "" && len(s.ProgramFingerprint) != 64 { return fmt.Errorf("durable state has invalid program fingerprint") } + if s.ControlBundleFingerprint != "" && len(s.ControlBundleFingerprint) != 64 { + return fmt.Errorf("durable state has invalid control-bundle fingerprint") + } + if s.PlanningPackageFingerprint != "" && len(s.PlanningPackageFingerprint) != 64 { + return fmt.Errorf("durable state has invalid planning package fingerprint") + } + if s.Plan == model.PlanPackageApproved && s.PlanningPackageFingerprint == "" { + return fmt.Errorf("approved planning package requires an exact package fingerprint") + } if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.Plan.Valid() || !s.Configuration.Valid() || !s.Runtime.Valid() || !s.Publication.Valid() || !s.Verification.Valid() || !s.Recovery.Valid() || !s.Transaction.Valid() || !s.Terminal.Valid() { @@ -191,6 +211,16 @@ func DecodeState(value []byte) (State, error) { if err := decoder.Decode(&trailing); err != io.EOF { return State{}, fmt.Errorf("durable state contains trailing JSON") } + if state.SchemaVersion == priorStateSchemaVersion { + // The released predecessor is schema 4. Schemas 5 and 6 add planning + // package and control-bundle identity; neither may be smuggled into + // predecessor bytes. The original bytes remain the journal rollback + // source until a transition commits. + if state.PlanningPackageFingerprint != "" || state.ControlBundleFingerprint != "" { + return State{}, fmt.Errorf("durable state schema %d contains later identity fields", priorStateSchemaVersion) + } + state.SchemaVersion = StateSchemaVersion + } if err := state.Validate(); err != nil { return State{}, err } diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index 5413ed1..246365d 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -9,8 +9,9 @@ import ( var stateFieldFacets = map[string]model.StateFacet{ "SchemaVersion": model.StateFacetControl, "RepositoryID": model.StateFacetControl, "GitCommonID": model.StateFacetControl, "WorktreeID": model.StateFacetControl, - "ProgramFingerprint": model.StateFacetProgram, - "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, + "ProgramFingerprint": model.StateFacetProgram, + "ControlBundleFingerprint": model.StateFacetControl, + "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "Plan": model.StateFacetProduct, "Configuration": model.StateFacetControl, "Runtime": model.StateFacetInstallation, @@ -21,7 +22,7 @@ var stateFieldFacets = map[string]model.StateFacet{ "ConfigFingerprint": model.StateFacetControl, "PlanApprovalPolicy": model.StateFacetControl, "VisualEvidencePolicy": model.StateFacetControl, "ExternalEffectPolicy": model.StateFacetControl, "IndependentReview": model.StateFacetControl, "EnabledHosts": model.StateFacetControl, "RuntimeVersion": model.StateFacetInstallation, "RuntimeFingerprint": model.StateFacetInstallation, "RuntimeSource": model.StateFacetInstallation, - "PlanFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, + "PlanFingerprint": model.StateFacetProduct, "PlanningPackageFingerprint": model.StateFacetProduct, "ApprovalFingerprint": model.StateFacetProduct, "WorkspaceBranch": model.StateFacetProduct, "WorkspacePath": model.StateFacetProduct, "WorkspaceBaseRef": model.StateFacetProduct, "WorkspaceSourcePath": model.StateFacetProduct, "WorkspaceSourceID": model.StateFacetProduct, "WorkspaceSourceRef": model.StateFacetProduct, "PublicationID": model.StateFacetProduct, "PublicationURL": model.StateFacetProduct, "PreviewFingerprint": model.StateFacetProduct, diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index bcb820b..96b7def 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -1,6 +1,8 @@ package durable import ( + "bytes" + "encoding/json" "testing" "time" @@ -34,3 +36,55 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test t.Fatalf("legacy approval fingerprint = %q", decoded.ApprovalFingerprint) } } + +func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testing.T) { + // control-law: forward state migration is read-only until a transaction commits + state := State{ + SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, + Plan: model.PlanApproved, Configuration: model.ConfigurationUnsupported, Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, + Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, + Objective: model.Objective{ID: "objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"}, PlanFingerprint: "legacy-plan", UpdatedAt: time.Unix(1, 0).UTC(), + } + current, err := EncodeState(state) + if err != nil { + t.Fatal(err) + } + var legacy map[string]any + if err := json.Unmarshal(current, &legacy); err != nil { + t.Fatal(err) + } + legacy["schema_version"] = float64(priorStateSchemaVersion) + delete(legacy, "control_bundle_fingerprint") + prior, err := json.MarshalIndent(legacy, "", " ") + if err != nil { + t.Fatal(err) + } + prior = append(prior, '\n') + rollback := append([]byte(nil), prior...) + + decoded, err := DecodeState(prior) + if err != nil { + t.Fatal(err) + } + if decoded.SchemaVersion != StateSchemaVersion || decoded.Revision != state.Revision || decoded.Objective != state.Objective || decoded.PlanFingerprint != state.PlanFingerprint { + t.Fatalf("promoted state = %#v", decoded) + } + if !bytes.Equal(prior, rollback) { + t.Fatal("schema promotion changed the prior rollback bytes") + } +} + +func TestDecodeStateRejectsSchemaFourWithLaterIdentityFields(t *testing.T) { + raw := []byte(`{"schema_version":4,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planning_package_fingerprint":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","control_bundle_fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) + if _, err := DecodeState(raw); err == nil { + t.Fatal("schema-4 state smuggled later identity fields") + } +} + +func TestDecodeStateRejectsUnreleasedSchemaFive(t *testing.T) { + raw := []byte(`{"schema_version":5,"repository_id":"repo","git_common_id":"common","worktree_id":"worktree","revision":1,"phase":"ACTIVE","engagement":"active","delivery":"approved","workspace":"absent","plan":"approved","configuration":"unsupported","runtime":"absent","publication":"none","verification":"unverified","recovery":"none","transaction":"none","terminal":"nonterminal","objective":{},"updated_at":"1970-01-01T00:00:01Z"}`) + if _, err := DecodeState(raw); err == nil { + t.Fatal("unreleased schema 5 was accepted as a migration predecessor") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 685d066..9799a4b 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" @@ -36,12 +37,31 @@ func prepareAttachBinding(layout ports.ControllerLayout, admission protocol.Admi } type approvalArtifact struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - PlanFingerprint string `json:"plan_fingerprint"` - Actor string `json:"actor"` - AdmissionID string `json:"admission_id"` - ApprovedAt time.Time `json:"approved_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + PackageFingerprint string `json:"package_fingerprint,omitempty"` + Actor string `json:"actor"` + AdmissionID string `json:"admission_id"` + 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 { @@ -77,14 +97,16 @@ func decodeStrictArtifact(raw []byte, value any) error { } type publicationPreview struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - BaseRef string `json:"base_ref"` - HeadRef string `json:"head_ref"` - BodyPath string `json:"body_path"` - BodySHA256 string `json:"body_sha256"` - Fingerprint string `json:"fingerprint"` - CreatedAt time.Time `json:"created_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + BaseRef string `json:"base_ref"` + HeadRef string `json:"head_ref"` + SourceRevision string `json:"source_revision"` + WorktreeFingerprint string `json:"worktree_fingerprint"` + BodyPath string `json:"body_path"` + BodySHA256 string `json:"body_sha256"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` } func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) { @@ -151,7 +173,112 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio } mutations = append(mutations, approvalMutation) state.PlanFingerprint = fingerprint + state.PlanningPackageFingerprint = "" state.ApprovalFingerprint = "" + case "planning.package.admit": + 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 + } + mutation, mutationErr := mutationFor(destination, []byte(output.Content), 0o644, false, false) + 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 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, "" + case "planning.package.approve": + manifest, manifestRaw, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + 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) + if encodeErr != nil { + return nil, encodeErr + } + mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "planning-packages", deliveryID, "approval.json"), raw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(append(manifestRaw, raw...)) + case "planning.package.promote": + manifest, _, loadErr := loadPlanningPackageManifest(artifactRoot, deliveryID) + if loadErr != nil { + return nil, loadErr + } + approvalPath := filepath.Join(artifactRoot, "planning-packages", deliveryID, "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() { + 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) + if pathErr != nil { + return nil, pathErr + } + planRaw, readErr := readRegularWorkspacePlanArtifact(planArtifact) + if readErr != nil || sha256Bytes(planRaw) != manifest.PlanFingerprint { + return nil, fmt.Errorf("planning package plan changed after approval") + } + planMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "plans", deliveryID+".source"), planRaw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + approvalMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), approvalRaw, 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, planMutation, approvalMutation) + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint = manifest.PlanFingerprint, manifest.Fingerprint, sha256Bytes(approvalRaw) case "plan.validate": path := filepath.Join(artifactRoot, "plans", deliveryID+".source") raw, readErr := os.ReadFile(path) @@ -159,6 +286,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, fmt.Errorf("validate source plan %s: %w", path, readErr) } state.PlanFingerprint = sha256Bytes(raw) + state.PlanningPackageFingerprint = "" case "plan.approve", "plan.approve-amendment": fingerprint, _ := admission.Parameters.Get("plan_fingerprint") actor, _ := admission.Parameters.Get("actor") @@ -258,7 +386,14 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if readErr != nil { return nil, readErr } - preview := publicationPreview{SchemaVersion: 1, DeliveryID: deliveryID, BaseRef: baseRef, HeadRef: headRef, BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: admission.IssuedAt.UTC()} + if admission.SourceRevision == "" || admission.WorktreeFingerprint == "" { + return nil, fmt.Errorf("publication preview requires an exact committed source and worktree identity") + } + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: deliveryID, BaseRef: baseRef, HeadRef: headRef, + SourceRevision: admission.SourceRevision, WorktreeFingerprint: admission.WorktreeFingerprint, + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: admission.IssuedAt.UTC(), + } identity := preview identity.Fingerprint, identity.CreatedAt = "", time.Time{} identityRaw, encodeErr := json.Marshal(identity) @@ -369,6 +504,7 @@ func readRegularWorkspacePlanArtifact(path string) ([]byte, error) { func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { switch id { case "plan.create", "plan.amend", "plan.validate", "plan.approve", "plan.approve-amendment", + "planning.package.admit", "planning.package.approve", "planning.package.promote", "evidence.approval.revoke", "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record", "evidence.visual.attach", "publication.preview", "publication.execute", "publication.correct": @@ -378,6 +514,77 @@ func transitionUsesDeliveryArtifacts(id catalog.TransitionID) bool { } } +func planningPackageOutputPath(root, relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("planning package output path is unsafe") + } + 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) + if err != nil { + return planningPackageManifest{}, nil, fmt.Errorf("read planning package manifest: %w", 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") + } + 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") + } + if err := validatePlanningPackageOutputs(packageRoot, manifest); err != nil { + return planningPackageManifest{}, nil, err + } + return manifest, raw, 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") + } + } + } + if !planFound { + return fmt.Errorf("planning package manifest has no plan output") + } + return nil +} + +func authorityActor(admission protocol.Admission) string { + for _, receipt := range admission.Authority.Receipts { + if receipt.Subject != "" { + return receipt.Subject + } + } + return "authorized-actor" +} + func loadPublicationPreview(path string) (publicationPreview, error) { raw, err := os.ReadFile(path) if err != nil { @@ -387,7 +594,7 @@ func loadPublicationPreview(path string) (publicationPreview, error) { if err := decodeStrictArtifact(raw, &preview); err != nil { return publicationPreview{}, err } - if preview.SchemaVersion != 1 || preview.DeliveryID == "" || preview.BaseRef == "" || preview.HeadRef == "" || preview.BodyPath == "" || preview.BodySHA256 == "" || preview.Fingerprint == "" || preview.CreatedAt.IsZero() { + if preview.SchemaVersion != 2 || preview.DeliveryID == "" || preview.BaseRef == "" || preview.HeadRef == "" || preview.SourceRevision == "" || preview.WorktreeFingerprint == "" || preview.BodyPath == "" || preview.BodySHA256 == "" || preview.Fingerprint == "" || preview.CreatedAt.IsZero() { return publicationPreview{}, fmt.Errorf("invalid publication preview") } if err := protocol.ValidateGitReference(preview.BaseRef); err != nil { @@ -430,6 +637,9 @@ func validatePublicationPreviewForAdmission(layout ports.ControllerLayout, admis if admission.Invocation.Ref != "refs/heads/"+preview.HeadRef { return fmt.Errorf("publication preview head does not match the exact invoking branch") } + if admission.SourceRevision != preview.SourceRevision || admission.WorktreeFingerprint != preview.WorktreeFingerprint { + return fmt.Errorf("publication preview does not match the exact committed HEAD and worktree") + } configRaw, err := os.ReadFile(layout.ConfigPath) if err != nil { return err diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index cbf4b4d..3f01277 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -2,15 +2,19 @@ package effects import ( "context" + "crypto/sha256" "encoding/json" "fmt" + "io" + "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" + "time" + "unicode" - boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -53,11 +57,98 @@ type pullRequestObservation struct { IsCrossRepository bool `json:"isCrossRepository"` } +type githubAuthorityObservation struct { + NameWithOwner string `json:"nameWithOwner"` + URL string `json:"url"` + ViewerPermission string `json:"viewerPermission"` +} + +// ResolveGitHubProviderAuthority derives short-lived provider capability from +// the trusted GitHub CLI boundary. It is capability evidence, not human +// approval; run delegation remains the independent approval source. +func (b NativeBoundary) ResolveGitHubProviderAuthority(ctx context.Context, repository, authorityBinding string, now time.Time) (protocol.AuthorityReceipt, error) { + if authorityBinding == "" || len(authorityBinding) > 256 || strings.TrimSpace(authorityBinding) != authorityBinding || strings.IndexFunc(authorityBinding, unicode.IsControl) >= 0 { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: authority binding must be non-empty, bounded, and free of control characters") + } + remoteOutput, err := b.runner.CombinedOutput(ctx, repository, "git", "remote", "get-url", "--push", "origin") + if err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_UNAVAILABLE: origin push repository identity is unavailable") + } + remoteRepository, err := githubRepositoryFromRemote(strings.TrimSpace(string(remoteOutput))) + if err != nil { + return protocol.AuthorityReceipt{}, err + } + output, err := b.runner.CombinedOutput(ctx, repository, "gh", "repo", "view", remoteRepository, "--json", "nameWithOwner,viewerPermission,url") + if err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_UNAVAILABLE: GitHub repository identity or authenticated access is unavailable") + } + var observed githubAuthorityObservation + decoder := json.NewDecoder(strings.NewReader(string(output))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&observed); err != nil { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority response is invalid") + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority response contains trailing JSON") + } + switch observed.ViewerPermission { + case "ADMIN", "MAINTAIN", "WRITE": + default: + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_DENIED: GitHub identity lacks write permission for the repository") + } + if observed.NameWithOwner == "" || observed.URL == "" { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub repository identity is incomplete") + } + if !strings.EqualFold(observed.NameWithOwner, remoteRepository) { + return protocol.AuthorityReceipt{}, fmt.Errorf("PROVIDER_AUTHORITY_INVALID: GitHub authority does not match the origin push repository") + } + issued := now.UTC() + subject := "github:" + observed.NameWithOwner + digest := sha256.Sum256([]byte(subject + "\x00" + authorityBinding)) + return protocol.AuthorityReceipt{ + ID: "provider-" + fmt.Sprintf("%x", digest[:8]), Class: catalog.AuthorityProvider, + Subject: subject, Fingerprint: authorityBinding, IssuedAt: issued, ExpiresAt: issued.Add(2 * time.Minute), + }, nil +} + +func githubRepositoryFromRemote(remote string) (string, error) { + path := "" + switch { + case strings.HasPrefix(remote, "git@github.com:"): + path = strings.TrimPrefix(remote, "git@github.com:") + default: + parsed, err := url.Parse(remote) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") || (parsed.Scheme != "https" && parsed.Scheme != "ssh" && parsed.Scheme != "git") { + return "", fmt.Errorf("PROVIDER_AUTHORITY_INVALID: origin push remote is not an exact GitHub repository") + } + path = strings.TrimPrefix(parsed.Path, "/") + } + path = strings.TrimSuffix(path, ".git") + segments := strings.Split(path, "/") + if len(segments) != 2 || segments[0] == "" || segments[1] == "" || strings.ContainsAny(path, "\x00\r\n\t ") { + return "", fmt.Errorf("PROVIDER_AUTHORITY_INVALID: origin push remote is not an exact GitHub repository") + } + return path, nil +} + func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state *durable.State) error { if err := protocol.ValidateEffectCapabilities(admission, transition); err != nil { return err } switch transition.ID { + case "publication.preview": + output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "status", "--porcelain=v1", "-z", "--untracked-files=all") + if err != nil { + return fmt.Errorf("WORKSPACE_COMMIT_REQUIRED: inspect worktree before publication preview") + } + if publicationProductStatus(string(output)) != "" { + return fmt.Errorf("WORKSPACE_COMMIT_REQUIRED: commit the intended delivery changes before publication preview") + } + head, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "rev-parse", "--verify", "HEAD^{commit}") + if err != nil || strings.TrimSpace(string(head)) != admission.SourceRevision { + return fmt.Errorf("WORKSPACE_HEAD_CHANGED: publication preview is not bound to the exact committed HEAD") + } case "publication.observe", "publication.reconcile": publicationID, _ := admission.Parameters.Get("publication_id") if state.PublicationID != "" && state.PublicationID != publicationID { @@ -165,6 +256,10 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if err := protocol.ValidateGitReference(baseRef); err != nil { return settled, err } + if admission.ControlBundle == nil || admission.ControlBundle.Target == nil || admission.ControlBundle.TargetRevision == "" { + return settled, fmt.Errorf("CONTROL_BUNDLE_REQUIRED: workspace.cut has no exact target revision") + } + baseRevision := admission.ControlBundle.TargetRevision absolute, err := canonicalWorkspaceDestination(admission) if err != nil || absolute == layout.RepositoryRoot { return settled, fmt.Errorf("workspace destination must be an explicit non-primary path") @@ -174,7 +269,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio } else if !os.IsNotExist(err) { return settled, err } - baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRef+":.boatstack/project.json") + baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRevision+":.boatstack/project.json") if err != nil { return settled, fmt.Errorf("workspace base does not contain the verified Boatstack configuration: %w", err) } @@ -188,7 +283,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "check-ref-format", "--branch", branch); err != nil { return settled, fmt.Errorf("invalid workspace branch: %s: %w", strings.TrimSpace(string(output)), err) } - arguments := []string{"worktree", "add", "-b", branch, absolute, baseRef} + arguments := []string{"-c", "core.autocrlf=false", "-c", "core.eol=lf", "worktree", "add", "-b", branch, absolute, baseRevision} if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", arguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } @@ -206,13 +301,6 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio neutralDirectory := filepath.Dir(state.WorkspacePath) gitPrefix := []string{"--git-dir", layout.GitCommonRoot} removeArguments := append(gitPrefix, "worktree", "remove") - managedOnly, managedErr := b.workspaceHasOnlyManagedRuntimePin(ctx, state) - if managedErr != nil { - return settled, managedErr - } - if managedOnly { - removeArguments = append(removeArguments, "--force") - } removeArguments = append(removeArguments, state.WorkspacePath) if output, err := b.runner.CombinedOutput(ctx, neutralDirectory, "git", removeArguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil @@ -236,7 +324,8 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio if err := validatePublicationPreviewForAdmission(layout, admission, preview); err != nil { return settled, err } - if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "--set-upstream", "origin", preview.HeadRef); err != nil { + refspec := admission.SourceRevision + ":refs/heads/" + preview.HeadRef + if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "origin", refspec); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--fill-first", "--body-file", preview.BodyPath); err != nil { @@ -268,33 +357,39 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio return settled, nil } -func (b NativeBoundary) workspaceHasOnlyManagedRuntimePin(ctx context.Context, state durable.State) (bool, error) { - output, err := b.runner.CombinedOutput(ctx, state.WorkspacePath, "git", "status", "--porcelain", "--untracked-files=all") - if err != nil { - return false, fmt.Errorf("inspect workspace before cleanup: %s: %w", strings.TrimSpace(string(output)), err) - } - status := strings.TrimSpace(string(output)) - if status == "" { - return false, nil - } - if status != "?? .boatstack/runtime.json" { - return false, fmt.Errorf("workspace cleanup refuses product or unmanaged changes: %s", status) - } - raw, err := os.ReadFile(boatstackruntime.PinPath(state.WorkspacePath)) - if err != nil { - return false, err - } - pin, err := boatstackruntime.DecodePin(raw) - if err != nil { - return false, err +func publicationProductStatus(status string) string { + records := strings.Split(status, "\x00") + kept := make([]string, 0, len(records)) + for index := 0; index < len(records); index++ { + record := records[index] + if len(record) < 4 { + continue + } + code, name := record[:2], filepath.ToSlash(record[3:]) + prior := "" + if (code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C') && index+1 < len(records) { + index++ + prior = filepath.ToSlash(records[index]) + } + if publicationGeneratedPath(name) && (prior == "" || publicationGeneratedPath(prior)) { + continue + } + kept = append(kept, record) + if prior != "" { + kept = append(kept, prior) + } } - want := boatstackruntime.NewPin( - boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource}, - state.ProgramFingerprint, - durable.StateSchemaVersion, - ) - if pin != want { - return false, fmt.Errorf("workspace runtime pin does not match governed state") + return strings.Join(kept, "\x00") +} + +func publicationGeneratedPath(name string) bool { + for _, prefix := range []string{ + ".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", + ".boatstack/plans/", ".boatstack/publication/", + } { + if strings.HasPrefix(name, prefix) { + return true + } } - return true, nil + return false } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index c8287a8..19116ee 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -22,9 +22,11 @@ type boundaryRunner struct { calls int err error output []byte + outputs [][]byte directory string name string arguments []string + history [][]string } func (r *boundaryRunner) CombinedOutput(_ context.Context, directory, name string, arguments ...string) ([]byte, error) { @@ -32,6 +34,10 @@ func (r *boundaryRunner) CombinedOutput(_ context.Context, directory, name strin r.directory = directory r.name = name r.arguments = append([]string(nil), arguments...) + r.history = append(r.history, append([]string{name}, arguments...)) + if len(r.outputs) >= r.calls { + return append([]byte(nil), r.outputs[r.calls-1]...), r.err + } if r.output != nil { return append([]byte(nil), r.output...), r.err } @@ -57,6 +63,99 @@ func boundaryAdmission(transition catalog.Transition) protocol.Admission { return protocol.Admission{RequiredCapabilities: required, EffectiveCapabilities: required} } +func TestGitHubProviderAuthorityIsDerivedFromWriteCapability(t *testing.T) { + now := time.Unix(100, 0).UTC() + observation := []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`) + runner := &boundaryRunner{outputs: [][]byte{[]byte("git@github.com:owner/repository.git\n"), observation, []byte("git@github.com:owner/repository.git\n"), observation}} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + fingerprint := strings.Repeat("a", 64) + receipt, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), fingerprint, now) + if err != nil { + t.Fatal(err) + } + if receipt.Class != catalog.AuthorityProvider || receipt.Subject != "github:owner/repository" || receipt.Fingerprint != fingerprint || !receipt.ExpiresAt.Equal(now.Add(2*time.Minute)) { + t.Fatalf("provider receipt = %#v", receipt) + } + if runner.name != "gh" || strings.Join(runner.arguments, " ") != "repo view owner/repository --json nameWithOwner,viewerPermission,url" || strings.Join(runner.history[0], " ") != "git remote get-url --push origin" { + t.Fatalf("provider observation command = %s %v", runner.name, runner.arguments) + } + renewed, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), fingerprint, now.Add(time.Minute)) + if err != nil || renewed.ID != receipt.ID { + t.Fatalf("provider identity changed across renewal: first=%q renewed=%q err=%v", receipt.ID, renewed.ID, err) + } +} + +func TestGitHubProviderAuthorityAcceptsOpaqueTrustedTransitionBinding(t *testing.T) { + runner := &boundaryRunner{outputs: [][]byte{[]byte("https://github.com/owner/repository.git\n"), []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"WRITE"}`)}} + boundary, _ := NewNativeBoundaryWithRunner(runner) + receipt, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), "123", time.Unix(100, 0).UTC()) + if err != nil { + t.Fatal(err) + } + if receipt.Fingerprint != "123" { + t.Fatalf("provider binding = %q", receipt.Fingerprint) + } +} + +func TestGitHubProviderAuthorityRejectsUnsafeBinding(t *testing.T) { + boundary, _ := NewNativeBoundaryWithRunner(&boundaryRunner{}) + for _, binding := range []string{"", " publication", "publication\n"} { + if _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), binding, time.Unix(100, 0).UTC()); err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_INVALID") { + t.Fatalf("unsafe binding %q error = %v", binding, err) + } + } +} + +func TestGitHubProviderAuthorityRejectsRepositoryDifferentFromOrigin(t *testing.T) { + runner := &boundaryRunner{outputs: [][]byte{ + []byte("git@github.com:owner/destination.git\n"), + []byte(`{"nameWithOwner":"owner/ambient","url":"https://github.com/owner/ambient","viewerPermission":"WRITE"}`), + }} + boundary, _ := NewNativeBoundaryWithRunner(runner) + _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), "binding", time.Unix(100, 0).UTC()) + if err == nil || !strings.Contains(err.Error(), "does not match the origin") || runner.calls != 2 { + t.Fatalf("mismatched provider authority error=%v calls=%d", err, runner.calls) + } +} + +func TestGitHubProviderAuthorityRejectsReadOnlyIdentity(t *testing.T) { + runner := &boundaryRunner{outputs: [][]byte{[]byte("git@github.com:owner/repository.git\n"), []byte(`{"nameWithOwner":"owner/repository","url":"https://github.com/owner/repository","viewerPermission":"READ"}`)}} + boundary, _ := NewNativeBoundaryWithRunner(runner) + _, err := boundary.ResolveGitHubProviderAuthority(context.Background(), t.TempDir(), strings.Repeat("a", 64), time.Unix(100, 0).UTC()) + if err == nil || !strings.Contains(err.Error(), "PROVIDER_AUTHORITY_DENIED") { + t.Fatalf("read-only provider authority error = %v", err) + } +} + +func TestPublicationPreviewRequiresCommittedProductWorktree(t *testing.T) { + transition, _ := testprogram.StandardRegistry().Lookup("publication.preview") + admission := boundaryAdmission(transition) + admission.SourceRevision = "revision" + runner := &boundaryRunner{output: []byte(" M product.go\x00")} + boundary, _ := NewNativeBoundaryWithRunner(runner) + err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &durable.State{}) + if err == nil || !strings.Contains(err.Error(), "WORKSPACE_COMMIT_REQUIRED") || runner.calls != 1 { + t.Fatalf("dirty publication preview error=%v calls=%d", err, runner.calls) + } +} + +func TestPublicationPreviewAcceptsExactCleanCommittedHead(t *testing.T) { + transition, _ := testprogram.StandardRegistry().Lookup("publication.preview") + admission := boundaryAdmission(transition) + admission.SourceRevision = "revision" + runner := &boundaryRunner{outputs: [][]byte{[]byte("?? .boatstack/publication/delivery.md\x00"), []byte("revision\n")}} + boundary, _ := NewNativeBoundaryWithRunner(runner) + if err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &durable.State{}); err != nil { + t.Fatal(err) + } + if runner.calls != 2 { + t.Fatalf("publication preflight calls = %d", runner.calls) + } +} + func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) { runner := &boundaryRunner{err: errors.New("exit status 1")} boundary, err := NewNativeBoundaryWithRunner(runner) @@ -152,7 +251,8 @@ func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing t.Fatal(err) } preview := publicationPreview{ - SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "revision", WorktreeFingerprint: "worktree", BodyPath: bodyPath, BodySHA256: sha256Bytes([]byte("reviewed body")), CreatedAt: time.Unix(10, 0).UTC(), } identity := preview @@ -189,7 +289,11 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { if err := os.WriteFile(bodyPath, body, 0o600); err != nil { t.Fatal(err) } - preview := publicationPreview{SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC()} + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "revision", WorktreeFingerprint: "worktree", + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC(), + } identity := preview identity.CreatedAt = time.Time{} raw, err := json.Marshal(identity) @@ -209,9 +313,8 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { t.Fatal(err) } admission := protocol.Admission{ - Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, - Objective: model.Objective{DeliveryID: "delivery"}, - Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision", WorktreeFingerprint: "worktree", + Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, } admission.RequiredCapabilities = catalog.RequiredCapabilities(transition) admission.EffectiveCapabilities = admission.RequiredCapabilities @@ -222,6 +325,34 @@ func TestPublicationExecutionUsesBoundBodyAndNoninteractiveTitle(t *testing.T) { if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") { t.Fatalf("publication command = %s %q, want gh %q", runner.name, runner.arguments, want) } + if len(runner.history) != 2 || strings.Join(runner.history[0], " ") != "git push origin revision:refs/heads/feature" { + t.Fatalf("publication push history = %v", runner.history) + } +} + +func TestPublicationExecutionRejectsCommittedHeadDrift(t *testing.T) { + layout := writeBoundaryConfig(t, "go test ./...") + bodyPath := filepath.Join(layout.RepositoryRoot, "body.md") + body := []byte("reviewed body") + if err := os.WriteFile(bodyPath, body, 0o600); err != nil { + t.Fatal(err) + } + preview := publicationPreview{ + SchemaVersion: 2, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature", + SourceRevision: "old-revision", WorktreeFingerprint: "worktree", + BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: time.Unix(10, 0).UTC(), + } + identity := preview + identity.CreatedAt = time.Time{} + raw, _ := json.Marshal(identity) + preview.Fingerprint = sha256Bytes(raw) + admission := protocol.Admission{ + Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "new-revision", WorktreeFingerprint: "worktree", + Objective: model.Objective{DeliveryID: "delivery"}, Parameters: protocol.Parameters{{Name: "preview_fingerprint", Value: preview.Fingerprint}}, + } + if err := validatePublicationPreviewForAdmission(layout, admission, preview); err == nil || !strings.Contains(err.Error(), "exact committed HEAD") { + t.Fatalf("committed-head drift error = %v", err) + } } func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index f4b49a2..38f05a5 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -69,6 +69,11 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if currentInvocation.RepositoryID != admission.Invocation.RepositoryID || currentInvocation.GitCommonID != admission.Invocation.GitCommonID || currentInvocation.WorktreeID != admission.Invocation.WorktreeID { return nil, fmt.Errorf("effect invocation identity changed before preparation") } + if admission.ControlBundle != nil { + if err := boatstackruntime.VerifyControlBundleRoot(layout.RepositoryRoot, admission.ControlBundle.Source); err != nil { + return nil, err + } + } if transition.ID == "recovery.resume" || transition.ID == "recovery.rollback" || transition.ID == "workspace.reconcile" { prepared, prepareErr := d.prepareRecoveryReplay(ctx, layout, admission, transition) if prepareErr != nil { @@ -109,10 +114,21 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err := d.verifyClearedWorkspaceDestination(ctx, state, admission, transition); err != nil { return nil, err } + if admission.ControlBundle != nil && admission.ControlBundle.Target != nil && (transition.ID == "workspace.cleanup" || transition.ID == "workspace.reap") { + if err := boatstackruntime.VerifyControlBundleRoot(state.WorkspaceSourcePath, *admission.ControlBundle.Target); err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_STALE: preserved source checkout: %w", err) + } + } if err := verifyRuntimeParameters(admission, transition); err != nil { return nil, err } next := state + if admission.ControlBundle != nil { + next.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + next.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } if next.ProgramFingerprint == "" { next.ProgramFingerprint = admission.ExpectedProgramFingerprint } @@ -182,12 +198,6 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return nil, pinErr } mutations = append(mutations, pinMutation) - } else if transition.ID == "workspace.cut" { - pinMutation, pinErr := prepareRuntimePinMutation(next.WorkspacePath, next) - if pinErr != nil { - return nil, pinErr - } - mutations = append(mutations, pinMutation) } statePath := layout.StatePath stateInstallLast := true @@ -294,6 +304,23 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } mutations = annotateStateFacetMutations(mutations, changedFacets) prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changedFacets} + if admission.ControlBundle != nil && admission.ControlBundle.Target != nil { + targetRoot := layout.RepositoryRoot + switch transition.ID { + case "workspace.cut": + targetRoot = next.WorkspacePath + case "workspace.cleanup", "workspace.reap": + targetRoot = state.WorkspaceSourcePath + } + target := *admission.ControlBundle.Target + targetRevision := admission.ControlBundle.TargetRevision + prepared.postVerify = func(ctx context.Context) error { + if transition.ID == "workspace.cut" { + return boatstackruntime.VerifyControlBundleHead(ctx, targetRoot, targetRevision, target) + } + return boatstackruntime.VerifyControlBundleRoot(targetRoot, target) + } + } if err := bindPreparedCapabilities(prepared, admission, transition); err != nil { return nil, err } diff --git a/boatstack/internal/softwaredelivery/effects/host_skills.go b/boatstack/internal/softwaredelivery/effects/host_skills.go index e6e9935..86b4e3a 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills.go +++ b/boatstack/internal/softwaredelivery/effects/host_skills.go @@ -130,6 +130,21 @@ func desiredHostSkillFiles(hosts []string) map[string][]byte { return desired } +// ProjectedHostSkillFiles returns the exact runtime-owned host projection and +// manifest bytes without mutating a repository. +func ProjectedHostSkillFiles(hosts []string) (map[string][]byte, []byte, error) { + desired := desiredHostSkillFiles(hosts) + manifest := hostSkillManifest{SchemaVersion: hostSkillManifestSchema, Files: map[string]string{}} + for path, raw := range desired { + manifest.Files[path] = sha256Bytes(raw) + } + manifestRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, nil, err + } + return desired, append(manifestRaw, '\n'), nil +} + func prepareHostSkillMutations(repository string, hosts []string) ([]ports.ResourceMutation, error) { desired := desiredHostSkillFiles(hosts) manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 4d7c576..d2eacd1 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -20,6 +20,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/flow/standard" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -61,6 +62,7 @@ func testProgram() delivery.ControlProgram { func prescribeEngine(t *testing.T, ctx context.Context, kernel engine.Engine, request engine.ApplyRequest) engine.ApplyRequest { t.Helper() + request.ControlBundle = testControlBundle(t, request.Invocation.InvokingPath, request.Requested, request.Parameters) resolve := request.ResolveRequest resolve.Parameters = request.Parameters resolution, err := kernel.Resolve(ctx, resolve) @@ -76,6 +78,8 @@ func prescribeEngine(t *testing.T, ctx context.Context, kernel engine.Engine, re func prescribeSurface(t *testing.T, ctx context.Context, kernel boatstack.DeliveryController, request surfaces.Request) surfaces.Request { t.Helper() + request.ControlBundle = testControlBundle(t, request.Repository, request.TransitionID, request.Parameters) + request.ControlBundleFingerprint = request.ControlBundle.Source.Fingerprint resolve := request resolve.Operation = surfaces.OperationResolve resolve.FlowID = "" @@ -91,6 +95,57 @@ func prescribeSurface(t *testing.T, ctx context.Context, kernel boatstack.Delive return request } +func testControlBundle(t *testing.T, repository string, transitionID catalog.TransitionID, parameters protocol.Parameters) *boatstackruntime.ControlBundleContract { + t.Helper() + raw, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + files := map[string][]byte{"README.md": raw} + var sourcePin *boatstackruntime.Pin + if pinRaw, readErr := os.ReadFile(boatstackruntime.PinPath(repository)); readErr == nil { + files[".boatstack/runtime.json"] = pinRaw + pin, decodeErr := boatstackruntime.DecodePin(pinRaw) + if decodeErr != nil { + t.Fatal(decodeErr) + } + sourcePin = &pin + } else if !os.IsNotExist(readErr) { + t.Fatal(readErr) + } + source, err := boatstackruntime.NewControlBundleSnapshot(files) + if err != nil { + t.Fatal(err) + } + var target *boatstackruntime.ControlBundleSnapshot + targetRevision := "" + switch transitionID { + case "workspace.cut": + baseRef, _ := parameters.Get("base_ref") + command := exec.Command("git", "rev-parse", "--verify", baseRef+"^{commit}") + command.Dir = repository + output, resolveErr := command.Output() + if resolveErr != nil { + t.Fatal(resolveErr) + } + targetRevision = strings.TrimSpace(string(output)) + copy := source + target = © + case "workspace.cleanup", "workspace.reap", "workspace.reconcile": + copy := source + target = © + } + var targetPin *boatstackruntime.Pin + if target != nil { + targetPin = sourcePin + } + contract, err := boatstackruntime.NewControlBundleContractWithPins(source, target, targetRevision, sourcePin, targetPin) + if err != nil { + t.Fatal(err) + } + return &contract +} + func (c fixedClock) Now() time.Time { return c.value } func installTestRuntime(t *testing.T, executable string, raw []byte) string { @@ -127,6 +182,78 @@ func testRepository(t *testing.T) string { return repository } +func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { + // control-law: control bundle mismatch is a pre-effect blocker + ctx := context.Background() + repository := testRepository(t) + externalRoot := t.TempDir() + kernel, err := boatstack.NewDeliveryController(externalRoot, testProgram()) + if err != nil { + t.Fatal(err) + } + readme, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + snapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{"README.md": readme}) + if err != nil { + t.Fatal(err) + } + contract, err := boatstackruntime.NewControlBundleContract(snapshot, nil, "") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("changed after binding\n"), 0o644); err != nil { + t.Fatal(err) + } + executable, _ := os.Executable() + executable, _ = filepath.Abs(executable) + executable, _ = filepath.EvalSymlinks(executable) + runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) + configPath := filepath.Join(t.TempDir(), "project.json") + configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") + if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + request := surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, Host: "cli", CorrelationID: "stale-bundle", + FlowID: "flow-stale-bundle", TransitionID: "installation.initialize", + Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "human", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "human-proof", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}}, + Parameters: protocol.Parameters{ + {Name: "source_revision", Value: "fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, + }, + ControlBundle: &contract, ControlBundleFingerprint: contract.Source.Fingerprint, + } + response, handleErr := kernel.Handle(ctx, request) + if handleErr != nil { + t.Fatal(handleErr) + } + if response.Decision == nil || response.Decision.Kind != supervisor.DecisionUnresolved || !strings.Contains(response.Decision.Reason, "CONTROL_BUNDLE_STALE") { + t.Fatalf("stale bundle decision = %#v", response.Decision) + } + resolver, err := plant.NewResolver(externalRoot) + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "stale-bundle-check") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(layout.StatePath); !os.IsNotExist(statErr) { + t.Fatalf("stale bundle created managed state: %v", statErr) + } + if _, statErr := os.Stat(boatstackruntime.PinPath(repository)); !os.IsNotExist(statErr) { + t.Fatalf("stale bundle created runtime pin: %v", statErr) + } +} + func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { // control-law: request-to-boundary-to-effect-to-verified-receipt ctx := context.Background() @@ -282,6 +409,10 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing ID: "external-config-human", Class: catalog.AuthorityHuman, Subject: "integration", Fingerprint: "explicit-human", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), }}} + autonomy := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ + ID: "external-config-autonomy", Class: catalog.AuthorityAutonomy, Subject: "integration", Fingerprint: "explicit-delegation", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} apply := func(id catalog.TransitionID, authority protocol.AuthorityBundle, repositoryAuthority bool, parameters protocol.Parameters) surfaces.Response { t.Helper() request := surfaces.Request{ @@ -305,7 +436,11 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) } - apply("installation.initialize", human, false, protocol.Parameters{ + // A continuation may request repository authority before fresh-state + // initialization. The controller must preserve delegated autonomy without + // fabricating repository authority, then derive repository authority from + // the configuration evidence committed by this transition on later steps. + apply("installation.initialize", autonomy, true, protocol.Parameters{ {Name: "source_revision", Value: "external-config-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: initialPath}, {Name: "config_sha256", Value: configFingerprint(t, initialConfig)}, }) @@ -503,6 +638,42 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if !bytes.Equal(afterSuccess, afterReplay) { t.Fatal("rejected repeated reconciliation mutated durable state") } + priorState, err := durable.DecodeState(afterSuccess) + if err != nil { + t.Fatal(err) + } + var legacyState map[string]any + if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { + t.Fatal(err) + } + legacyState["schema_version"] = float64(durable.StateSchemaVersion - 2) + delete(legacyState, "planning_package_fingerprint") + delete(legacyState, "control_bundle_fingerprint") + legacyRaw, err := json.MarshalIndent(legacyState, "", " ") + if err != nil { + t.Fatal(err) + } + legacyRaw = append(legacyRaw, '\n') + if err := os.WriteFile(layout.StatePath, legacyRaw, 0o600); err != nil { + t.Fatal(err) + } + legacyPinPath := boatstackruntime.PinPath(repository) + legacyPinRaw, err := os.ReadFile(legacyPinPath) + if err != nil { + t.Fatal(err) + } + legacyPin, err := boatstackruntime.DecodePin(legacyPinRaw) + if err != nil { + t.Fatal(err) + } + legacyPin.StateSchemaVersion = durable.StateSchemaVersion - 2 + legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPinPath, legacyPinRaw, 0o600); err != nil { + t.Fatal(err) + } updateRequest := surfaces.Request{ SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-current-update", FlowID: "flow-program-drift", Objective: model.Objective{ID: "ignored-command-objective", TargetID: model.ObjectiveOpenPR, DeliveryID: "ignored"}, @@ -518,6 +689,31 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if updated.Snapshot == nil || updated.Snapshot.Objective.Status != model.FactAbsent || updated.Receipt == nil || updated.Receipt.ObjectiveStatus != model.FactAbsent || updated.Receipt.ObjectiveID != "" { t.Fatalf("reconcile to update composition invented product intent: %#v", updated) } + updatedRaw, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + updatedState, err := durable.DecodeState(updatedRaw) + if err != nil { + t.Fatal(err) + } + if updatedState.SchemaVersion != durable.StateSchemaVersion || updatedState.Objective != priorState.Objective || updatedState.Engagement != priorState.Engagement || + updatedState.Delivery != priorState.Delivery || updatedState.Workspace != priorState.Workspace || updatedState.Plan != priorState.Plan || + updatedState.Configuration != priorState.Configuration || updatedState.Publication != priorState.Publication || updatedState.Verification != priorState.Verification || + updatedState.Terminal != priorState.Terminal || updatedState.PlanFingerprint != priorState.PlanFingerprint || updatedState.ApprovalFingerprint != priorState.ApprovalFingerprint { + t.Fatalf("schema-4 update changed existing product facets: before=%#v after=%#v", priorState, updatedState) + } + updatedPinRaw, err := os.ReadFile(legacyPinPath) + if err != nil { + t.Fatal(err) + } + updatedPin, err := boatstackruntime.DecodePin(updatedPinRaw) + if err != nil { + t.Fatal(err) + } + if updatedPin.StateSchemaVersion != durable.StateSchemaVersion { + t.Fatalf("runtime update left prior state schema in the pin: %#v", updatedPin) + } } func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) { @@ -851,7 +1047,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) {Name: "config_path", Value: configSource}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) apply(sourceInvocation, "objective.bind", human, protocol.Parameters{{Name: "target_id", Value: string(objective.TargetID)}, {Name: "delivery_id", Value: objective.DeliveryID}}) - run(t, repository, "git", "add", ".boatstack/project.json") + run(t, repository, "git", "add", ".boatstack/project.json", ".boatstack/runtime.json") run(t, repository, "git", "commit", "-q", "-m", "install Boatstack configuration") repositoryAuthority := func(path string) protocol.AuthorityBundle { raw, readErr := os.ReadFile(filepath.Join(path, ".boatstack", "project.json")) diff --git a/boatstack/internal/softwaredelivery/effects/journal.go b/boatstack/internal/softwaredelivery/effects/journal.go index 87e7e98..ef11793 100644 --- a/boatstack/internal/softwaredelivery/effects/journal.go +++ b/boatstack/internal/softwaredelivery/effects/journal.go @@ -144,6 +144,20 @@ func readJournal(path string) (journalRecord, error) { receipt.DeliveryID != admission.Objective.DeliveryID || receipt.ObjectiveScope != admission.ObjectiveScope || receipt.ObjectiveStatus != admission.ObjectiveStatus { return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its exact admission", path) } + if err := validateReceiptWorkRelation(admission, *receipt); err != nil { + return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) + } + if admission.ControlBundle != nil { + targetFingerprint := admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + targetFingerprint = admission.ControlBundle.Target.Fingerprint + } + if receipt.ControlBundleSourceFingerprint != admission.ControlBundle.Source.Fingerprint || receipt.ControlBundleTargetFingerprint != targetFingerprint { + return journalRecord{}, fmt.Errorf("committed transition fact in %s does not match its admitted control bundle", path) + } + } else if receipt.ControlBundleSourceFingerprint != "" || receipt.ControlBundleTargetFingerprint != "" { + return journalRecord{}, fmt.Errorf("committed transition fact in %s invents a control bundle", path) + } if err := validateCommittedMutationFacts(record.TransitionClass, record.Mutations, receipt.ChangedStateFacets, receipt.CommittedEffects); err != nil { return journalRecord{}, fmt.Errorf("committed transition fact in %s: %w", path, err) } @@ -154,6 +168,17 @@ func readJournal(path string) (journalRecord, error) { return record, nil } +func validateReceiptWorkRelation(admission protocol.Admission, receipt protocol.TransitionReceipt) error { + admittedFingerprint := "" + if admission.Work != nil { + admittedFingerprint = admission.Work.ResultFingerprint + } + if receipt.WorkResultFingerprint != admittedFingerprint { + return fmt.Errorf("foreground-work identity does not match its exact admission") + } + return nil +} + func validateCommittedMutationFacts(class catalog.EventClass, mutations []ports.ResourceMutation, receiptFacets []model.StateFacet, facts []protocol.EffectFact) error { var mutationFacets []model.StateFacet for _, mutation := range mutations { diff --git a/boatstack/internal/softwaredelivery/effects/journal_work_test.go b/boatstack/internal/softwaredelivery/effects/journal_work_test.go new file mode 100644 index 0000000..f2445ed --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/journal_work_test.go @@ -0,0 +1,35 @@ +package effects + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestCommittedReceiptWorkIdentityMatchesExactAdmission(t *testing.T) { + one := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + two := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + tests := []struct { + name string + admission protocol.Admission + receipt protocol.TransitionReceipt + wantErr bool + }{ + {name: "neither carries work"}, + {name: "exact work result", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, receipt: protocol.TransitionReceipt{WorkResultFingerprint: one}}, + {name: "receipt invents work", receipt: protocol.TransitionReceipt{WorkResultFingerprint: one}, wantErr: true}, + {name: "receipt omits admitted work", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, wantErr: true}, + {name: "receipt substitutes work", admission: protocol.Admission{Work: &protocol.WorkEvidence{ResultFingerprint: one}}, receipt: protocol.TransitionReceipt{WorkResultFingerprint: two}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateReceiptWorkRelation(test.admission, test.receipt) + if test.wantErr && err == nil { + t.Fatal("mismatched work identity was accepted") + } + if !test.wantErr && err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/planning_package_test.go b/boatstack/internal/softwaredelivery/effects/planning_package_test.go new file mode 100644 index 0000000..9b788c9 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/planning_package_test.go @@ -0,0 +1,139 @@ +package effects + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestPlanningPackageAdmitApprovePromoteUsesExactWorkEvidence(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" + 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}, + }, + } + 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} + if err := applyStateTransition(&state, admission, admit); err != nil { + t.Fatal(err) + } + mutations, err := prepareArtifacts(layout, admission, admit, &state) + if err != nil { + t.Fatal(err) + } + installFixtureMutations(t, mutations) + manifest, _, err := loadPlanningPackageManifest(filepath.Join(repository, ".boatstack"), "delivery") + 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) + } + + 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 { + t.Fatal(err) + } + mutations, err = prepareArtifacts(layout, admission, 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) + } + + 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 { + t.Fatal(err) + } + mutations, err = prepareArtifacts(layout, admission, promote, &state) + if err != nil { + t.Fatal(err) + } + installFixtureMutations(t, mutations) + canonicalPlan, err := os.ReadFile(filepath.Join(repository, ".boatstack", "plans", "delivery.source")) + if err != nil { + t.Fatal(err) + } + if state.Plan != model.PlanApproved || string(canonicalPlan) != plan { + t.Fatalf("promoted state=%#v plan=%q", state, canonicalPlan) + } +} + +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 { + 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 { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestRoot, "plan.md"), plan, 0o600); err != nil { + t.Fatal(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) + } +} + +func installFixtureMutations(t *testing.T, mutations []ports.ResourceMutation) { + t.Helper() + for _, mutation := range mutations { + if mutation.Delete { + _ = os.Remove(mutation.Path) + continue + } + 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 { + t.Fatal(err) + } + } +} diff --git a/boatstack/internal/softwaredelivery/effects/prepared.go b/boatstack/internal/softwaredelivery/effects/prepared.go index 9926dfb..61d1fa0 100644 --- a/boatstack/internal/softwaredelivery/effects/prepared.go +++ b/boatstack/internal/softwaredelivery/effects/prepared.go @@ -19,6 +19,7 @@ type boundaryCall func(context.Context) (ports.EffectResult, error) type preparedEffect struct { mutations []ports.ResourceMutation boundary boundaryCall + postVerify func(context.Context) error verifyInvocation *model.InvocationContext applied []ports.ResourceMutation boundarySettled bool @@ -139,6 +140,11 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error } p.applied = append(p.applied, mutation) } + if p.postVerify != nil { + if err := p.postVerify(ctx); err != nil { + return result, err + } + } return result, nil } diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 61ddcaf..cbdafa9 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -103,36 +103,60 @@ func scanCommittedReceipts(layout ports.ControllerLayout, visit func(journalReco // identity for the current objective. Projected receipt files are deliberately // not used because projection is best effort. func FindLatestCommittedFlowForObjective(layout ports.ControllerLayout, invocation model.InvocationContext, objective model.Objective, maximumRevision uint64) (protocol.TransitionReceipt, bool, error) { + records := []journalRecord{} + if err := scanCommittedReceipts(layout, func(record journalRecord) error { + records = append(records, record) + return nil + }); err != nil { + return protocol.TransitionReceipt{}, false, err + } + return findLatestCommittedFlowForObjective(records, invocation, objective, maximumRevision) +} + +func findLatestCommittedFlowForObjective(records []journalRecord, invocation model.InvocationContext, objective model.Objective, maximumRevision uint64) (protocol.TransitionReceipt, bool, error) { var found protocol.TransitionReceipt - err := scanCommittedReceipts(layout, func(record journalRecord) error { + for _, record := range records { receipt := *record.Receipt - if !sameStateLineage(record.Admission.Invocation, invocation) { - return nil + if !matchesObjectiveBinding(receipt, objective, maximumRevision) { + continue + } + bindingInvocation := record.Admission.Invocation + authorized := sameStateLineage(bindingInvocation, invocation) + if !authorized && bindingInvocation.ControllerID == invocation.ControllerID { + var err error + authorized, err = invocationAuthorizedByRecords(records, receipt.FlowID, bindingInvocation, invocation) + if err != nil { + return protocol.TransitionReceipt{}, false, err + } } - if matchesObjectiveBinding(receipt, objective, maximumRevision) && (found.ID == "" || receipt.ResultingStateRevision > found.ResultingStateRevision) { + if authorized && (found.ID == "" || receipt.ResultingStateRevision > found.ResultingStateRevision) { found = receipt } - return nil - }) - return found, found.ID != "", err + } + return found, found.ID != "", nil } // InvocationAuthorizedByFlow reconstructs worktree lineage only from valid, // committed transition receipts. Mutable delegation records cannot invent a // context transfer. func InvocationAuthorizedByFlow(layout ports.ControllerLayout, flowID string, initial, current model.InvocationContext) (bool, error) { - receipts := []protocol.TransitionReceipt{} + records := []journalRecord{} if err := scanCommittedReceipts(layout, func(record journalRecord) error { - if record.Receipt != nil && record.Receipt.FlowID == flowID { - if err := record.Receipt.Validate(); err != nil { - return err - } - receipts = append(receipts, *record.Receipt) - } + records = append(records, record) return nil }); err != nil { return false, err } + return invocationAuthorizedByRecords(records, flowID, initial, current) +} + +func invocationAuthorizedByRecords(records []journalRecord, flowID string, initial, current model.InvocationContext) (bool, error) { + receipts := []protocol.TransitionReceipt{} + for _, record := range records { + if record.Receipt != nil && record.Receipt.FlowID == flowID { + receipts = append(receipts, *record.Receipt) + } + } sort.Slice(receipts, func(i, j int) bool { return receipts[i].Sequence < receipts[j].Sequence }) contextKey := func(invocation model.InvocationContext) string { return invocation.WorktreeID + "\x00" + invocation.Ref diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go index e55e4c9..4c5fec5 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts_test.go +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -43,3 +43,42 @@ func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { t.Fatal("different controller lineage was accepted for the current durable state") } } + +func TestActiveFlowIdentityFollowsCommittedWorkspaceTransfer(t *testing.T) { + objective := model.Objective{ID: "objective-product-delivery-run-one", TargetID: "published-pr", TrustedClass: model.ObjectiveOpenPR, DeliveryID: "one"} + source := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "source", Ref: "detached:base", ControllerID: "controller"} + destination := source + destination.WorktreeID = "destination" + destination.Ref = "refs/heads/feature" + + binding := protocol.TransitionReceipt{ + ID: "binding", FlowID: "run-original", Sequence: 1, TransitionID: "objective.bind", + ObjectiveID: objective.ID, TargetID: objective.TargetID, TrustedClass: objective.TrustedClass, DeliveryID: objective.DeliveryID, + ResultingStateRevision: 4, + } + cut := protocol.TransitionReceipt{ + ID: "cut", FlowID: "run-original", Sequence: 2, TransitionID: "workspace.cut", + ExecutionContext: "advance", PriorInvocation: &source, ResultingInvocation: &destination, + } + records := []journalRecord{ + {Admission: protocol.Admission{Invocation: source}, Receipt: &binding}, + {Admission: protocol.Admission{Invocation: source}, Receipt: &cut}, + } + + found, ok, err := findLatestCommittedFlowForObjective(records, destination, objective, 16) + if err != nil || !ok || found.FlowID != "run-original" { + t.Fatalf("transferred active Flow identity = %#v, %t, %v", found, ok, err) + } + + unchained := destination + unchained.WorktreeID = "unchained" + if found, ok, err := findLatestCommittedFlowForObjective(records, unchained, objective, 16); err != nil || ok { + t.Fatalf("unchained worktree inherited Flow identity = %#v, %t, %v", found, ok, err) + } + + otherController := destination + otherController.ControllerID = "other-controller" + if found, ok, err := findLatestCommittedFlowForObjective(records, otherController, objective, 16); err != nil || ok { + t.Fatalf("other controller inherited Flow identity = %#v, %t, %v", found, ok, err) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/recovery.go b/boatstack/internal/softwaredelivery/effects/recovery.go index 2a1da05..987a55b 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery.go +++ b/boatstack/internal/softwaredelivery/effects/recovery.go @@ -9,6 +9,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -85,6 +86,12 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po resume := false var verificationInvocation *model.InvocationContext if _, statErr := os.Stat(destination); statErr == nil { + if record.Admission.ControlBundle == nil || record.Admission.ControlBundle.Target == nil || record.Admission.ControlBundle.TargetRevision == "" { + return nil, fmt.Errorf("workspace reconciliation has no exact interrupted target bundle") + } + if verifyErr := boatstackruntime.VerifyControlBundleHead(ctx, destination, record.Admission.ControlBundle.TargetRevision, *record.Admission.ControlBundle.Target); verifyErr != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_STALE: partially created workspace: %w", verifyErr) + } current, resolveErr := d.resolver.ResolveInvocation(ctx, destination, admission.Invocation.Host, admission.Invocation.Correlation) if resolveErr != nil { return nil, fmt.Errorf("resolve partially created workspace: %w", resolveErr) @@ -139,7 +146,15 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po return nil, err } mutations = annotateStateFacetMutations(mutations, changed) - return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed}, nil + prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation, changedStateFacets: changed} + if record.Admission.ControlBundle != nil && record.Admission.ControlBundle.Target != nil { + target := *record.Admission.ControlBundle.Target + targetRevision := record.Admission.ControlBundle.TargetRevision + prepared.postVerify = func(ctx context.Context) error { + return boatstackruntime.VerifyControlBundleHead(ctx, destination, targetRevision, target) + } + } + return prepared, nil } func recoveryStateFacets(record journalRecord, recovery catalog.TransitionID, invocation model.InvocationContext, mutations []ports.ResourceMutation) ([]model.StateFacet, error) { @@ -169,6 +184,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p if mutation.Delete && mutation.TargetLink == "" && filepath.Clean(mutation.Path) == filepath.Clean(layout.StatePath) { state := durable.Default(admission.Invocation, d.clock.Now()) state.ProgramFingerprint = admission.ExpectedProgramFingerprint + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.Revision = resultingRevision state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() @@ -189,6 +210,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p continue } state.Revision = resultingRevision + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() mutation.Target, err = durable.EncodeState(state) @@ -208,6 +235,12 @@ func (d Driver) advanceRecoveredState(layout ports.ControllerLayout, admission p return nil, fmt.Errorf("recovery state revision changed after admission") } state.Revision = resultingRevision + if admission.ControlBundle != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + state.ControlBundleFingerprint = admission.ControlBundle.Target.Fingerprint + } + } state.LastTransition = transition state.UpdatedAt = d.clock.Now().UTC() raw, err := durable.EncodeState(state) @@ -237,6 +270,22 @@ func loadInterruptedJournal(layout ports.ControllerLayout, transactionID string) return record, path, nil } +// InterruptedWorkspaceTarget exposes only the immutable target bundle needed +// to admit workspace reconciliation. Journal mutation details remain private +// to the effect boundary. +func InterruptedWorkspaceTarget(layout ports.ControllerLayout, transactionID string) (boatstackruntime.ControlBundleSnapshot, string, error) { + record, _, err := loadInterruptedJournal(layout, transactionID) + if err != nil { + return boatstackruntime.ControlBundleSnapshot{}, "", err + } + if record.TransitionID != "workspace.cut" || record.Admission.ControlBundle == nil || record.Admission.ControlBundle.Target == nil || record.Admission.ControlBundle.TargetRevision == "" { + return boatstackruntime.ControlBundleSnapshot{}, "", fmt.Errorf("workspace reconciliation has no exact interrupted target bundle") + } + target := *record.Admission.ControlBundle.Target + target.Files = append([]boatstackruntime.ControlBundleFile(nil), target.Files...) + return target, record.Admission.ControlBundle.TargetRevision, nil +} + func prepareJournalClosure(layout ports.ControllerLayout, transactionID, outcome string, now time.Time, excludeAdmissionID string) ([]ports.ResourceMutation, error) { var journals []struct { path string diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index 833b784..164006c 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -120,15 +120,31 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_version", Value: runtimeIdentity.Version}, {Name: "runtime_sha256", Value: sha256Bytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint}, } + controlRaw, err := os.ReadFile(filepath.Join(repository, "README.md")) + if err != nil { + t.Fatal(err) + } + controlSnapshot, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{"README.md": controlRaw}) + if err != nil { + t.Fatal(err) + } + controlBundle, err := boatstackruntime.NewControlBundleContract(controlSnapshot, nil, "") + if err != nil { + t.Fatal(err) + } + projectedBundle, err := protocol.ProjectControlBundle(initial, transition, parameters, &controlBundle) + if err != nil { + t.Fatal(err) + } capabilities, err := protocol.ProjectCapabilities(initial, transition, authority, clock.Now()) if err != nil { t.Fatal(err) } - prescription, err := protocol.NewPrescription(initial, transition, capabilities) + prescription, err := protocol.NewPrescriptionWithWorkAndBundle(initial, transition, capabilities, nil, projectedBundle) if err != nil { t.Fatal(err) } - admission, err := protocol.NewAdmission(initial, objective, transition, prescription, authority, parameters, clock.Now(), time.Minute) + admission, err := protocol.NewAdmissionWithWorkAndBundle(initial, objective, transition, prescription, authority, parameters, nil, projectedBundle, clock.Now(), time.Minute) if err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/runtime_store.go b/boatstack/internal/softwaredelivery/effects/runtime_store.go new file mode 100644 index 0000000..3bab78d --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/runtime_store.go @@ -0,0 +1,51 @@ +package effects + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" +) + +type runtimeStore struct{} + +func NewRuntimeStore() ports.RuntimeStore { return runtimeStore{} } + +func (runtimeStore) EnsureDirectory(path string, mode uint32) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("runtime store directory must be canonical and absolute") + } + return os.MkdirAll(path, os.FileMode(mode)) +} + +func (runtimeStore) WriteAtomic(path string, raw []byte, mode uint32) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("runtime store path must be canonical and absolute") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".runtime-record-*.tmp") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(os.FileMode(mode)); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(raw); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} diff --git a/boatstack/internal/softwaredelivery/effects/runtime_store_test.go b/boatstack/internal/softwaredelivery/effects/runtime_store_test.go new file mode 100644 index 0000000..8c9c6c2 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/runtime_store_test.go @@ -0,0 +1,29 @@ +package effects + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRuntimeStoreOwnsAtomicForegroundRecordMutation(t *testing.T) { + // control-law: runtime records mutate only through the effects-owned atomic store + store := NewRuntimeStore() + path := filepath.Join(t.TempDir(), "work", "record.json") + if err := store.EnsureDirectory(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := store.WriteAtomic(path, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := store.WriteAtomic(path, []byte("second\n"), 0o600); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil || string(raw) != "second\n" { + t.Fatalf("runtime record = %q err=%v", raw, err) + } + if err := store.WriteAtomic("relative/record.json", []byte("invalid"), 0o600); err == nil { + t.Fatal("relative runtime record path was accepted") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index df72e81..a31048e 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -85,6 +85,9 @@ var nativeStateHandlers = map[string]nativeStateHandler{ "catalog-reconcile": applyCatalogReconcile, "objective-bind": applyObjectiveBind, "plan-approve": applyPlanApprove, + "planning-package-admit": applyPlanningPackageAdmit, + "planning-package-approve": applyPlanningPackageApprove, + "planning-package-promote": applyPlanningPackagePromote, "abandon-delivery": applyAbandonDelivery, "workspace-cleanup": applyWorkspaceCleanup, "workspace-reap": applyWorkspaceReap, @@ -372,7 +375,7 @@ func resetDeliveryState(state *durable.State) { state.Delivery, state.Plan = model.DeliveryUninitialized, model.PlanAbsent state.Workspace = model.WorkspaceAbsent state.Publication, state.Verification = model.PublicationNone, model.VerificationUnverified - state.PlanFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "" + state.PlanFingerprint, state.PlanningPackageFingerprint, state.ApprovalFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "", "", "" state.WorkspaceBranch, state.WorkspacePath, state.WorkspaceBaseRef = "", "", "" state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = "", "", "" state.Gates = nil @@ -386,6 +389,24 @@ func applyPlanApprove(state *durable.State, admission protocol.Admission, _ cata return nil } +func applyPlanningPackageAdmit(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageValid, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal + return nil +} + +func applyPlanningPackageApprove(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanPackageApproved, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal + return nil +} + +func applyPlanningPackagePromote(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { + state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive + if admission.Objective.TrustedObjectiveClass() == model.ObjectiveApprovedPlan { + establishTerminal(state, model.PhaseTerminal) + } + return nil +} + func applyAbandonDelivery(state *durable.State, _ protocol.Admission, _ catalog.Transition) error { state.Delivery = model.DeliveryDiscarded if state.Workspace != model.WorkspaceAbsent { diff --git a/boatstack/internal/softwaredelivery/engine/engine.go b/boatstack/internal/softwaredelivery/engine/engine.go index 6dc1486..f183312 100644 --- a/boatstack/internal/softwaredelivery/engine/engine.go +++ b/boatstack/internal/softwaredelivery/engine/engine.go @@ -7,6 +7,7 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -39,12 +40,14 @@ func (e Engine) canonicalize(observation model.Observation) (model.Snapshot, err } type ResolveRequest struct { - Invocation model.InvocationContext - Objective model.Objective - Authority protocol.AuthorityBundle - Parameters protocol.Parameters - Requested catalog.TransitionID - Trace bool + Invocation model.InvocationContext + Objective model.Objective + Authority protocol.AuthorityBundle + Parameters protocol.Parameters + Requested catalog.TransitionID + Trace bool + Work *protocol.WorkEvidence + ControlBundle *boatstackruntime.ControlBundleContract } type Resolution struct { @@ -120,6 +123,28 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution decision.Transition = nil } } else { + if decision.Transition.Work != nil { + if request.Work == nil { + decision.Kind = supervisor.DecisionCandidate + decision.Reason = fmt.Sprintf("transition %q requires foreground work %q", decision.Transition.ID, decision.Transition.Work.ID) + decision.Candidates = []catalog.TransitionID{decision.Transition.ID} + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + if workErr := request.Work.ValidateCurrent(snapshot, *decision.Transition); workErr != nil { + decision.Kind = supervisor.DecisionRefused + decision.Reason = workErr.Error() + decision.Transition = nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } + } else if request.Work != nil { + decision.Kind = supervisor.DecisionRefused + decision.Reason = fmt.Sprintf("transition %q does not accept foreground work evidence", decision.Transition.ID) + decision.Transition = nil + updateDecisionTrace(decisionTrace, decision) + return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil + } capabilities, capabilityErr := protocol.ProjectCapabilities(snapshot, *decision.Transition, request.Authority, now) if capabilityErr != nil { decision.Kind = supervisor.DecisionRefused @@ -128,7 +153,11 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - prescription, prescriptionErr := protocol.NewPrescription(snapshot, *decision.Transition, capabilities) + bundle, bundleErr := protocol.ProjectControlBundle(snapshot, *decision.Transition, request.Parameters, request.ControlBundle) + if bundleErr != nil { + return Resolution{}, bundleErr + } + prescription, prescriptionErr := protocol.NewPrescriptionWithWorkAndBundle(snapshot, *decision.Transition, capabilities, request.Work, bundle) if prescriptionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = prescriptionErr.Error() @@ -136,7 +165,7 @@ func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution updateDecisionTrace(decisionTrace, decision) return Resolution{Snapshot: snapshot, Objective: objective, Decision: decision, Trace: decisionTrace}, nil } - admission, admissionErr := protocol.NewAdmission(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, now, 2*time.Minute) + admission, admissionErr := protocol.NewAdmissionWithWorkAndBundle(snapshot, objective, *decision.Transition, prescription, request.Authority, request.Parameters, request.Work, bundle, now, 2*time.Minute) if admissionErr != nil { decision.Kind = supervisor.DecisionUnresolved decision.Reason = admissionErr.Error() @@ -304,7 +333,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check supplied idempotency key: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, request.ControlBundle); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: request.Authority.GrantedCapabilities(e.clock.Now())}) @@ -354,7 +383,11 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe } transition := *resolution.Decision.Transition now := e.clock.Now() - admission, err := protocol.NewAdmission(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, now, request.AdmissionLifetime) + bundle, err := protocol.ProjectControlBundle(resolution.Snapshot, transition, request.Parameters, request.ControlBundle) + if err != nil { + return result, err + } + admission, err := protocol.NewAdmissionWithWorkAndBundle(resolution.Snapshot, request.Objective, transition, request.Prescription, request.Authority, request.Parameters, request.Work, bundle, now, request.AdmissionLifetime) if err != nil { return result, err } @@ -368,7 +401,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check idempotency: %w", err) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, admission.ControlBundle); err != nil { return result, err } observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation, Capabilities: admission.GrantedCapabilities}) @@ -417,7 +450,7 @@ func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyRe return result, fmt.Errorf("check locked idempotency: %w", findErr) } if ok { - if err := validateReplayRequest(prior, request, e.program.Fingerprint); err != nil { + if err := validateReplayRequest(prior, request, e.program.Fingerprint, admission.ControlBundle); err != nil { return result, err } if err := validateReplayObjectiveState(prior, lockedSnapshot); err != nil { @@ -561,7 +594,7 @@ func validatePrescriptionCurrent(prescription protocol.Prescription, snapshot mo } } -func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string) error { +func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest, programFingerprint string, bundle *boatstackruntime.ControlBundleContract) error { if prior.Program.Fingerprint != programFingerprint { return fmt.Errorf("idempotency receipt belongs to a different control program") } @@ -579,6 +612,35 @@ func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyReques if request.Requested != "" && prior.TransitionID != request.Requested { return fmt.Errorf("idempotency receipt belongs to transition %q, not %q", prior.TransitionID, request.Requested) } + workFingerprint := "" + if request.Work != nil { + workFingerprint = request.Work.ResultFingerprint + } + if prior.WorkResultFingerprint != workFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different foreground work result") + } + if bundle == nil { + if prior.ControlBundleSourceFingerprint != "" || prior.ControlBundleTargetFingerprint != "" { + return fmt.Errorf("idempotency receipt belongs to a repository control bundle") + } + } else { + if prior.ControlBundleTargetFingerprint == "" { + if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint || bundle.Target != nil { + return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") + } + return nil + } + if prior.ControlBundleSourceFingerprint != bundle.Source.Fingerprint && prior.ControlBundleTargetFingerprint != bundle.Source.Fingerprint { + return fmt.Errorf("idempotency receipt belongs to a different repository control bundle") + } + if bundle.Target == nil { + return nil + } + targetFingerprint := bundle.Target.Fingerprint + if prior.ControlBundleTargetFingerprint != targetFingerprint { + return fmt.Errorf("idempotency receipt belongs to a different repository control-bundle target: receipt %s request %s", prior.ControlBundleTargetFingerprint, targetFingerprint) + } + } return nil } diff --git a/boatstack/internal/softwaredelivery/engine/engine_test.go b/boatstack/internal/softwaredelivery/engine/engine_test.go index 5ee2b7b..89dab5b 100644 --- a/boatstack/internal/softwaredelivery/engine/engine_test.go +++ b/boatstack/internal/softwaredelivery/engine/engine_test.go @@ -2,6 +2,8 @@ package engine import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "path/filepath" @@ -360,6 +362,96 @@ func TestResolutionDoesNotPrescribeBeforeRequiredParametersAreBound(t *testing.T } } +func mustWorkContextFingerprint(t *testing.T, snapshot model.Snapshot) string { + t.Helper() + fingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + t.Fatal(err) + } + return fingerprint +} + +func TestResolutionBindsForegroundWorkBeforeTrustedAdmission(t *testing.T) { + // control-law: foreground-work-produces-evidence-but-cannot-bypass-trusted-admission + now := time.Unix(30, 0).UTC() + transitions := testRegistry(t).All() + var work *catalog.WorkContract + for index := range transitions { + if transitions[index].ID != "test.advance" { + continue + } + instructionDigest := sha256.Sum256([]byte("Inspect the incident.")) + work = &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: hex.EncodeToString(instructionDigest[:]), InstructionContent: "Inspect the incident.", Outputs: []catalog.WorkOutput{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", Required: true, MaxBytes: 1024}}} + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []catalog.WorkInput `json:"inputs,omitempty"` + Outputs []catalog.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + t.Fatal(err) + } + work.Fingerprint = fingerprint + transitions[index].Work = work + transitions[index].OwnedResources = append(transitions[index].OwnedResources, "foreground-work-diagnose") + } + registry, err := catalog.New(transitions) + if err != nil { + t.Fatal(err) + } + observer := &sequenceObserver{items: []model.Observation{ + observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), + }} + kernel, err := New(registry, syntheticObjectiveContracts(t), syntheticProgram, observer, fixedClock{now}, fakeLocker{&fakeLock{}}, &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}) + if err != nil { + t.Fatal(err) + } + req := request(t, now).ResolveRequest + candidate, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if candidate.Decision.Kind != supervisor.DecisionCandidate || candidate.Decision.Transition == nil || candidate.Prescription.ID != "" { + t.Fatalf("workless resolution = %#v", candidate) + } + content := "Cause: overload." + contentDigest := sha256.Sum256([]byte(content)) + evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ + SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: "work-request", RequestFingerprint: strings.Repeat("e", 64), + 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, + Outputs: []protocol.WorkOutputEvidence{{ID: "diagnosis", Path: "diagnosis.md", MediaType: "text/markdown", SHA256: hex.EncodeToString(contentDigest[:]), Size: int64(len(content)), Content: content}}, + }) + if err != nil { + t.Fatal(err) + } + req.Work = &evidence + prescribed, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if prescribed.Decision.Kind != supervisor.DecisionPrescribed || prescribed.Prescription.WorkResultFingerprint != evidence.ResultFingerprint || prescribed.Admission.Work == nil { + t.Fatalf("work-bound resolution = %#v", prescribed) + } + stale := evidence + stale.ContextFingerprint = strings.Repeat("f", 64) + stale, err = protocol.SealWorkEvidence(stale) + if err != nil { + t.Fatal(err) + } + req.Work = &stale + refused, err := kernel.Resolve(context.Background(), req) + if err != nil { + t.Fatal(err) + } + if refused.Decision.Kind != supervisor.DecisionRefused || refused.Prescription.ID != "" { + t.Fatalf("stale work resolution = %#v", refused) + } +} + func TestResolutionDoesNotPrescribeAnEffectThatDeterministicPreflightRejects(t *testing.T) { // control-law: effect preparation cannot introduce a deterministic apply-only refusal now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go index 6d5e9e3..3b4b0ea 100644 --- a/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go +++ b/boatstack/internal/softwaredelivery/engine/maintenance_replay_test.go @@ -3,11 +3,51 @@ package engine import ( "testing" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) +func TestMaintenanceReplayAcceptsCurrentCommittedControlBundleTarget(t *testing.T) { + before, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("before")}) + if err != nil { + t.Fatal(err) + } + after, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("after")}) + if err != nil { + t.Fatal(err) + } + committed, err := boatstackruntime.NewControlBundleContract(before, &after, "") + if err != nil { + t.Fatal(err) + } + rebuilt, err := boatstackruntime.NewControlBundleContract(after, &after, "") + if err != nil { + t.Fatal(err) + } + request := ApplyRequest{FlowID: "flow"} + receipt := protocol.TransitionReceipt{ + FlowID: "flow", Program: syntheticProgram, + ControlBundleSourceFingerprint: committed.Source.Fingerprint, + ControlBundleTargetFingerprint: committed.Target.Fingerprint, + } + if err := validateReplayRequest(receipt, request, syntheticProgramFingerprint, &rebuilt); err != nil { + t.Fatalf("current committed bundle could not replay prior source-to-target receipt: %v", err) + } + other, err := boatstackruntime.NewControlBundleSnapshot(map[string][]byte{".boatstack/project.json": []byte("other")}) + if err != nil { + t.Fatal(err) + } + drifted, err := boatstackruntime.NewControlBundleContract(other, &other, "") + if err != nil { + t.Fatal(err) + } + if err := validateReplayRequest(receipt, request, syntheticProgramFingerprint, &drifted); err == nil { + t.Fatal("unrelated bundle replay was accepted") + } +} + func TestMaintenanceReplayBindsDurableObjectiveState(t *testing.T) { // control-law: maintenance-replay-preserves-verified-objective-state configured := model.Objective{ID: "configured", TargetID: model.ObjectiveOpenPR, DeliveryID: "delivery"} @@ -29,7 +69,7 @@ func TestMaintenanceReplayBindsDurableObjectiveState(t *testing.T) { t.Run(test.name, func(t *testing.T) { test.receipt.FlowID = request.FlowID test.receipt.Program = syntheticProgram - if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint); err != nil { + if err := validateReplayRequest(test.receipt, request, syntheticProgramFingerprint, nil); err != nil { t.Fatalf("command objective affected maintenance replay identity: %v", err) } err := validateReplayObjectiveState(test.receipt, model.Snapshot{Observation: model.Observation{Objective: test.fact}}) diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager.go b/boatstack/internal/softwaredelivery/foregroundwork/manager.go new file mode 100644 index 0000000..e193274 --- /dev/null +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager.go @@ -0,0 +1,536 @@ +package foregroundwork + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + general "github.com/operatorstack/boatstack/boatstack/kernel" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const RecordSchemaVersion = 2 + +type Status string + +const ( + StatusRequested Status = "requested" + StatusInputRequired Status = "input-required" + StatusCompleted Status = "completed" + StatusBlocked Status = "blocked" + StatusInvalidated Status = "invalidated" +) + +type InputBinding struct { + ID string `json:"id"` + EntryInput string `json:"entry_input"` + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` +} + +type Question struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Schema json.RawMessage `json:"schema,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Answer struct { + QuestionID string `json:"question_id"` + Value json.RawMessage `json:"value"` + Fingerprint string `json:"fingerprint"` + AnsweredAt time.Time `json:"answered_at"` +} + +type Event struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint,omitempty"` + At time.Time `json:"at"` +} + +type Request struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + EntryID string `json:"entry_id"` + Objective model.Objective `json:"objective"` + TransitionID catalog.TransitionID `json:"transition_id"` + Contract catalog.WorkContract `json:"contract"` + Inputs []InputBinding `json:"inputs,omitempty"` + RepositoryID string `json:"repository_id"` + GitCommonID string `json:"git_common_id"` + WorktreeID string `json:"worktree_id"` + Ref string `json:"ref"` + ProgramFingerprint string `json:"program_fingerprint"` + ContextFingerprint string `json:"context_fingerprint"` + StateRevision uint64 `json:"state_revision"` + InstructionContent string `json:"instruction_content"` + StagingRoot string `json:"staging_root"` + CreatedAt time.Time `json:"created_at"` +} + +type Record struct { + SchemaVersion int `json:"schema_version"` + Revision uint64 `json:"revision"` + Status Status `json:"status"` + Request Request `json:"request"` + Question *Question `json:"question,omitempty"` + Answers []Answer `json:"answers,omitempty"` + Result *protocol.WorkEvidence `json:"result,omitempty"` + BlockReason string `json:"block_reason,omitempty"` + Events []Event `json:"events"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Manager struct { + resolver ports.InvocationResolver + locker ports.Locker + clock ports.Clock + store ports.RuntimeStore +} + +func NewManager(resolver ports.InvocationResolver, locker ports.Locker, clock ports.Clock, store ports.RuntimeStore) (Manager, error) { + if resolver == nil || locker == nil || clock == nil || store == nil { + return Manager{}, fmt.Errorf("foreground work manager requires resolver, locker, clock, and runtime store") + } + return Manager{resolver: resolver, locker: locker, clock: clock, store: store}, nil +} + +func (m Manager) Ensure(ctx context.Context, invocation model.InvocationContext, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]protocol.WorkInputValue) (Record, error) { + if transition.Work == nil || runID == "" || programID == "" || entryID == "" { + return Record{}, fmt.Errorf("foreground work request requires run, program, entry, and contract") + } + return m.mutate(ctx, invocation, runID, transition.Work.ID, func(current *Record, layout ports.ControllerLayout) (Record, error) { + request, err := newRequest(m.store, layout, m.clock.Now(), runID, programID, entryID, objective, snapshot, transition, values) + if err != nil { + return Record{}, err + } + if current != nil && current.Request.Fingerprint == request.Fingerprint { + return *current, nil + } + now := m.clock.Now().UTC() + events := []Event{{Kind: "work.requested", Fingerprint: request.Fingerprint, At: now}} + if current != nil { + events = append(append([]Event(nil), current.Events...), Event{Kind: "work.invalidated", Fingerprint: current.Request.Fingerprint, At: now}, events[0]) + } + return Record{SchemaVersion: RecordSchemaVersion, Revision: nextRevision(current), Status: StatusRequested, Request: request, Events: events, UpdatedAt: now}, nil + }) +} + +func (m Manager) Show(ctx context.Context, invocation model.InvocationContext, runID, workID string) (Record, error) { + layout, _, err := m.resolver.ResolveLayout(ctx, invocation) + if err != nil { + return Record{}, err + } + return load(recordPath(layout, runID, workID)) +} + +func (m Manager) InputRequired(ctx context.Context, invocation model.InvocationContext, runID, workID, prompt string, schema json.RawMessage) (Record, error) { + prompt = strings.TrimSpace(prompt) + if prompt == "" || len(prompt) > 64<<10 { + return Record{}, fmt.Errorf("foreground work question requires a bounded prompt") + } + if len(schema) != 0 { + if err := validateJSONSchema(schema); err != nil { + return Record{}, fmt.Errorf("foreground work question schema: %w", err) + } + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusRequested && record.Status != StatusInputRequired { + return fmt.Errorf("foreground work question is not admissible from status %q", record.Status) + } + identity, err := general.Fingerprint(struct{ Request, Prompt string }{record.Request.Fingerprint, prompt}) + if err != nil { + return err + } + now := m.clock.Now().UTC() + record.Status, record.Question = StatusInputRequired, &Question{ID: "question-" + identity[:24], Prompt: prompt, Schema: append(json.RawMessage(nil), schema...), CreatedAt: now} + record.Events = append(record.Events, Event{Kind: "work.input-required", Fingerprint: identity, At: now}) + return nil + }) +} + +func (m Manager) Answer(ctx context.Context, invocation model.InvocationContext, runID, workID, questionID string, value json.RawMessage) (Record, error) { + if len(value) == 0 || len(value) > 1<<20 || !json.Valid(value) { + return Record{}, fmt.Errorf("foreground work answer must be bounded JSON") + } + canonical, err := normalizedJSON(value) + if err != nil { + return Record{}, fmt.Errorf("foreground work answer must be bounded JSON: %w", err) + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusInputRequired || record.Question == nil || record.Question.ID != questionID { + return fmt.Errorf("foreground work answer does not match the current question") + } + if len(record.Question.Schema) != 0 { + if err := validateJSON(record.Question.Schema, canonical); err != nil { + return fmt.Errorf("foreground work answer: %w", err) + } + } + fingerprint := digest(canonical) + now := m.clock.Now().UTC() + record.Answers = append(record.Answers, Answer{QuestionID: questionID, Value: canonical, Fingerprint: fingerprint, AnsweredAt: now}) + record.Status, record.Question = StatusRequested, nil + record.Events = append(record.Events, Event{Kind: "work.answered", Fingerprint: fingerprint, At: now}) + return nil + }) +} + +func (m Manager) Complete(ctx context.Context, invocation model.InvocationContext, runID, workID string) (Record, error) { + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status != StatusRequested { + return fmt.Errorf("foreground work completion is not admissible from status %q", record.Status) + } + outputs, err := verifyOutputs(record.Request) + if err != nil { + return err + } + evidence, err := protocol.SealWorkEvidence(protocol.WorkEvidence{ + SchemaVersion: protocol.WorkEvidenceSchemaVersion, RequestID: record.Request.ID, RequestFingerprint: record.Request.Fingerprint, + 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, + }) + if err != nil { + return err + } + now := m.clock.Now().UTC() + record.Status, record.Result = StatusCompleted, &evidence + record.Events = append(record.Events, Event{Kind: "work.completed", Fingerprint: evidence.ResultFingerprint, At: now}) + return nil + }) +} + +func (m Manager) Block(ctx context.Context, invocation model.InvocationContext, runID, workID, reason string) (Record, error) { + reason = strings.TrimSpace(reason) + if reason == "" || len(reason) > 64<<10 { + return Record{}, fmt.Errorf("foreground work blocker requires a bounded reason") + } + return m.update(ctx, invocation, runID, workID, func(record *Record) error { + if record.Status == StatusCompleted || record.Status == StatusInvalidated { + return fmt.Errorf("foreground work blocker is not admissible from status %q", record.Status) + } + now := m.clock.Now().UTC() + record.Status, record.BlockReason, record.Question = StatusBlocked, reason, nil + record.Events = append(record.Events, Event{Kind: "work.blocked", Fingerprint: digest([]byte(reason)), At: now}) + return nil + }) +} + +func (m Manager) update(ctx context.Context, invocation model.InvocationContext, runID, workID string, change func(*Record) error) (Record, error) { + return m.mutate(ctx, invocation, runID, workID, func(current *Record, _ ports.ControllerLayout) (Record, error) { + if current == nil { + return Record{}, fmt.Errorf("foreground work request does not exist") + } + result := *current + result.Answers, result.Events = append([]Answer(nil), current.Answers...), append([]Event(nil), current.Events...) + if current.Question != nil { + question := *current.Question + question.Schema = append(json.RawMessage(nil), current.Question.Schema...) + result.Question = &question + } + if err := change(&result); err != nil { + return Record{}, err + } + result.Revision, result.UpdatedAt = current.Revision+1, m.clock.Now().UTC() + return result, nil + }) +} + +func (m Manager) mutate(ctx context.Context, invocation model.InvocationContext, runID, workID string, change func(*Record, ports.ControllerLayout) (Record, error)) (Record, error) { + if !segment(runID) || !segment(workID) { + return Record{}, fmt.Errorf("foreground work requires semantic run and work identities") + } + lockName := "foreground-work-" + workID + lock, err := m.locker.Acquire(ctx, invocation, []string{lockName}) + if err != nil { + return Record{}, err + } + defer lock.Release() + layout, _, err := m.resolver.ResolveLayout(ctx, invocation) + if err != nil { + return Record{}, err + } + path := recordPath(layout, runID, workID) + current, err := load(path) + if err != nil && !os.IsNotExist(err) { + return Record{}, err + } + var prior *Record + if err == nil { + prior = ¤t + } + next, err := change(prior, layout) + if err != nil { + return Record{}, err + } + if err := save(m.store, path, next); err != nil { + return Record{}, err + } + return next, nil +} + +func newRequest(store ports.RuntimeStore, layout ports.ControllerLayout, now time.Time, runID, programID, entryID string, objective model.Objective, snapshot model.Snapshot, transition catalog.Transition, values map[string]protocol.WorkInputValue) (Request, error) { + work := *transition.Work + bindings := make([]InputBinding, 0, len(work.Inputs)) + for _, input := range work.Inputs { + value, ok := values[input.EntryInput] + if !ok || value.Validate() != nil { + return Request{}, fmt.Errorf("foreground work input %q is not bound by entry input %q", input.ID, input.EntryInput) + } + bindings = append(bindings, InputBinding{ID: input.ID, EntryInput: input.EntryInput, Value: value.Value, Fingerprint: value.Fingerprint}) + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) + contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + return Request{}, fmt.Errorf("foreground work context fingerprint: %w", err) + } + request := Request{ + RunID: runID, ProgramID: programID, EntryID: entryID, Objective: objective, TransitionID: transition.ID, Contract: work, Inputs: bindings, + RepositoryID: snapshot.Invocation.RepositoryID, GitCommonID: snapshot.Invocation.GitCommonID, WorktreeID: snapshot.Invocation.WorktreeID, Ref: snapshot.Invocation.Ref, + ProgramFingerprint: snapshot.ProgramFingerprint, ContextFingerprint: contextFingerprint, StateRevision: snapshot.StateRevision, + InstructionContent: work.InstructionContent, CreatedAt: now.UTC(), + } + identity := request + identity.ID, identity.Fingerprint, identity.StagingRoot, identity.CreatedAt = "", "", "", time.Time{} + fingerprint, err := general.Fingerprint(identity) + if err != nil { + return Request{}, err + } + request.Fingerprint, request.ID = fingerprint, "work-"+fingerprint[:24] + staging := filepath.Join(layout.FlowRoot, "work", runID, work.ID, "requests", fingerprint, "staging") + request.StagingRoot = staging + if err := store.EnsureDirectory(staging, 0o700); err != nil { + return Request{}, err + } + return request, nil +} + +func verifyOutputs(request Request) ([]protocol.WorkOutputEvidence, error) { + var result []protocol.WorkOutputEvidence + for _, output := range request.Contract.Outputs { + path, err := safeStagingFile(request.StagingRoot, output.Path) + if os.IsNotExist(err) && !output.Required { + continue + } + if err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + if int64(len(raw)) > output.MaxBytes || !utf8.Valid(raw) { + return nil, fmt.Errorf("foreground work output %q exceeds its bound or is not UTF-8", output.ID) + } + if output.MediaType == "application/json" { + if !json.Valid(raw) { + return nil, fmt.Errorf("foreground work output %q is not JSON", output.ID) + } + if output.SchemaContent != "" { + if err := validateJSON([]byte(output.SchemaContent), raw); err != nil { + return nil, fmt.Errorf("foreground work output %q: %w", output.ID, err) + } + } + } + result = append(result, protocol.WorkOutputEvidence{ID: output.ID, Path: output.Path, MediaType: output.MediaType, SHA256: digest(raw), Size: int64(len(raw)), Content: string(raw)}) + } + return protocol.CanonicalWorkOutputs(result), nil +} + +func validateJSONSchema(raw []byte) error { + var document any + if err := json.Unmarshal(raw, &document); err != nil { + return err + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource("schema.json", document); err != nil { + return err + } + _, err := compiler.Compile("schema.json") + return err +} + +func validateJSON(schemaRaw, valueRaw []byte) error { + var document any + if err := json.Unmarshal(schemaRaw, &document); err != nil { + return err + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + if err := compiler.AddResource("schema.json", document); err != nil { + return err + } + schema, err := compiler.Compile("schema.json") + if err != nil { + return err + } + var value any + if err := json.Unmarshal(valueRaw, &value); err != nil { + return err + } + return schema.Validate(value) +} + +func safeStagingFile(root, relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe output path") + } + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + path := filepath.Join(root, relative) + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("output is not a regular file") + } + parent, err := filepath.EvalSymlinks(filepath.Dir(path)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, filepath.Join(parent, filepath.Base(path))) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("output escapes staging root") + } + return filepath.Join(parent, filepath.Base(path)), nil +} + +func recordPath(layout ports.ControllerLayout, runID, workID string) string { + return filepath.Join(layout.FlowRoot, "work", runID, workID, "record.json") +} + +func load(path string) (Record, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Record{}, err + } + var record Record + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&record); err != nil { + return Record{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return Record{}, fmt.Errorf("foreground work record has trailing input") + } + if record.SchemaVersion != RecordSchemaVersion || record.Revision == 0 || record.Request.Fingerprint == "" || record.UpdatedAt.IsZero() { + return Record{}, fmt.Errorf("foreground work record has invalid identity") + } + if !validStatus(record) { + return Record{}, fmt.Errorf("foreground work record has invalid status state") + } + identity := record.Request + wantID, wantFingerprint := identity.ID, identity.Fingerprint + identity.ID, identity.Fingerprint, identity.StagingRoot, identity.CreatedAt = "", "", "", time.Time{} + fingerprint, err := general.Fingerprint(identity) + expectedStaging := filepath.Join(filepath.Dir(path), "requests", fingerprint, "staging") + if err != nil || fingerprint != wantFingerprint || wantID != "work-"+fingerprint[:24] || record.Request.StagingRoot != expectedStaging { + 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 { + return Record{}, fmt.Errorf("foreground work result is invalid: %v", err) + } + } + for _, answer := range record.Answers { + canonical, err := normalizedJSON(answer.Value) + if !segment(answer.QuestionID) || err != nil || answer.Fingerprint != digest(canonical) || answer.AnsweredAt.IsZero() { + return Record{}, fmt.Errorf("foreground work answer evidence is invalid") + } + } + for _, event := range record.Events { + if !validEventKind(event.Kind) || event.At.IsZero() { + return Record{}, fmt.Errorf("foreground work event is invalid") + } + } + return record, nil +} + +func normalizedJSON(raw []byte) (json.RawMessage, error) { + var normalized bytes.Buffer + if err := json.Compact(&normalized, raw); err != nil { + return nil, err + } + return json.RawMessage(append([]byte(nil), normalized.Bytes()...)), nil +} + +func validStatus(record Record) bool { + switch record.Status { + case StatusRequested, StatusInvalidated: + return record.Question == nil && record.Result == nil && record.BlockReason == "" + case StatusInputRequired: + return record.Question != nil && segment(record.Question.ID) && strings.TrimSpace(record.Question.Prompt) != "" && record.Result == nil && record.BlockReason == "" && record.Question.CreatedAt.IsZero() == false + case StatusCompleted: + return record.Question == nil && record.Result != nil && record.BlockReason == "" + case StatusBlocked: + return record.Question == nil && record.Result == nil && strings.TrimSpace(record.BlockReason) != "" + default: + return false + } +} + +func validEventKind(kind string) bool { + switch kind { + case "work.requested", "work.invalidated", "work.input-required", "work.answered", "work.completed", "work.blocked": + return true + default: + return false + } +} + +func save(store ports.RuntimeStore, path string, record Record) error { + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return store.WriteAtomic(path, raw, 0o600) +} + +func nextRevision(current *Record) uint64 { + if current == nil { + return 1 + } + return current.Revision + 1 +} + +func digest(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func segment(value string) bool { + if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\\`) { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' && char != '.' { + return false + } + } + return true +} diff --git a/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go new file mode 100644 index 0000000..506f525 --- /dev/null +++ b/boatstack/internal/softwaredelivery/foregroundwork/manager_test.go @@ -0,0 +1,265 @@ +package foregroundwork_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + general "github.com/operatorstack/boatstack/boatstack/kernel" +) + +type resolver struct{ layout ports.ControllerLayout } + +func (r resolver) ResolveInvocation(context.Context, string, string, string) (model.InvocationContext, error) { + return invocation(), nil +} +func (r resolver) ResolveLayout(context.Context, model.InvocationContext) (ports.ControllerLayout, model.InvocationContext, error) { + return r.layout, invocation(), nil +} + +type clock struct{ at time.Time } + +func (c clock) Now() time.Time { return c.at } + +func invocation() model.InvocationContext { + return model.InvocationContext{RepositoryID: "repository", GitCommonID: "common", WorktreeID: "worktree", Ref: "refs/heads/main"} +} + +func workInputs(value, fingerprint string) map[string]protocol.WorkInputValue { + return map[string]protocol.WorkInputValue{"incident": {Value: value, Fingerprint: fingerprint}} +} + +func fixture(t *testing.T) (foregroundwork.Manager, model.Snapshot, catalog.Transition, string) { + t.Helper() + root := t.TempDir() + resolved := resolver{layout: ports.ControllerLayout{FlowRoot: filepath.Join(root, "flow"), LockRoot: filepath.Join(root, "locks")}} + locker, err := effects.NewLocker(resolved) + if err != nil { + t.Fatal(err) + } + manager, err := foregroundwork.NewManager(resolved, locker, clock{at: time.Unix(100, 0).UTC()}, effects.NewRuntimeStore()) + if err != nil { + t.Fatal(err) + } + outputs := []catalog.WorkOutput{{ + ID: "diagnosis", Path: "diagnosis.json", MediaType: "application/json", Required: true, MaxBytes: 1024, + SchemaPath: "schema.json", SchemaSHA256: strings.Repeat("b", 64), + SchemaContent: `{"type":"object","properties":{"cause":{"type":"string"}},"required":["cause"],"additionalProperties":false}`, + }} + work := &catalog.WorkContract{ID: "diagnose", InstructionPath: "instructions.md", InstructionSHA256: strings.Repeat("a", 64), InstructionContent: "Diagnose.", Inputs: []catalog.WorkInput{{ID: "incident", EntryInput: "incident"}}, Outputs: outputs} + fingerprint, err := general.Fingerprint(struct { + ID string `json:"id"` + InstructionPath string `json:"instruction_path"` + InstructionSHA256 string `json:"instruction_sha256"` + InstructionContent string `json:"instruction_content"` + Inputs []catalog.WorkInput `json:"inputs,omitempty"` + Outputs []catalog.WorkOutput `json:"outputs"` + }{work.ID, work.InstructionPath, work.InstructionSHA256, work.InstructionContent, work.Inputs, work.Outputs}) + if err != nil { + t.Fatal(err) + } + work.Fingerprint = fingerprint + transition := catalog.Transition{ID: "incident.diagnose", Work: work} + snapshot := model.Snapshot{Observation: model.Observation{ProgramFingerprint: strings.Repeat("c", 64), StateRevision: 4, Invocation: invocation()}, Fingerprint: strings.Repeat("d", 64)} + return manager, snapshot, transition, root +} + +func TestForegroundWorkQuestionCompletionAndDrift(t *testing.T) { + // control-law: questions suspend one work request and drift invalidates its evidence + manager, snapshot, transition, _ := 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 record.Status != foregroundwork.StatusRequested || record.Request.InstructionContent != "Diagnose." { + t.Fatalf("request = %#v", record) + } + record, err = manager.InputRequired(ctx, invocation(), "run-1", "diagnose", "Which service?", []byte(`{"type":"string","minLength":1}`)) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Answer(ctx, invocation(), "run-1", "diagnose", record.Question.ID, []byte(`""`)); err == nil { + t.Fatal("schema-invalid answer was accepted") + } + record, err = manager.Answer(ctx, invocation(), "run-1", "diagnose", record.Question.ID, []byte(" \"api\"\n")) + if err != nil || record.Status != foregroundwork.StatusRequested { + t.Fatalf("answer = %#v err=%v", record, err) + } + record, err = manager.Show(ctx, invocation(), "run-1", "diagnose") + if err != nil || string(record.Answers[0].Value) != `"api"` { + t.Fatalf("persisted canonical answer = %#v err=%v", record.Answers, 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 || record.Result == nil || record.Result.Outputs[0].Content != `{"cause":"overload"}` { + t.Fatalf("completion = %#v err=%v", record, err) + } + snapshot.StateRevision++ + record, err = manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))) + if err != nil || record.Status != foregroundwork.StatusRequested || len(record.Events) < 4 || record.Events[len(record.Events)-2].Kind != "work.invalidated" { + t.Fatalf("drift reset = %#v err=%v", record, err) + } +} + +func TestForegroundWorkSurvivesInvocationLocalRestartIdentity(t *testing.T) { + // control-law: restart-local driver identity cannot invalidate otherwise-current work + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + first, 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) + } + restarted := snapshot + restarted.Invocation.ControllerID = "controller-after-restart" + restarted.Invocation.Host = "claude" + restarted.Invocation.Correlation = "correlation-after-restart" + restarted.Invocation.RuntimePath = "/different/immutable/runtime/location" + restarted.Fingerprint = strings.Repeat("e", 64) + second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, workInputs("incident.json", strings.Repeat("e", 64))) + if err != nil { + t.Fatal(err) + } + if second.Request.Fingerprint != first.Request.Fingerprint || second.Revision != first.Revision || len(second.Events) != len(first.Events) { + t.Fatalf("restart invalidated stable work: before=%#v after=%#v", first.Request, second.Request) + } + restarted.Invocation.Ref = "refs/heads/other" + third, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, restarted, transition, workInputs("incident.json", strings.Repeat("e", 64))) + if err != nil { + t.Fatal(err) + } + if third.Request.Fingerprint == first.Request.Fingerprint || third.Events[len(third.Events)-2].Kind != "work.invalidated" { + t.Fatalf("repository context drift did not invalidate work: %#v", third) + } +} + +func TestForegroundWorkInputFingerprintInvalidatesOutputsAndStaging(t *testing.T) { + // control-law: same-locator input changes cannot reuse foreground-work outputs + manager, snapshot, transition, _ := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + first, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("1", 64))) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(first.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":"first input"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err != nil { + t.Fatal(err) + } + second, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("2", 64))) + if err != nil { + t.Fatal(err) + } + if second.Status != foregroundwork.StatusRequested || second.Request.Fingerprint == first.Request.Fingerprint || second.Request.StagingRoot == first.Request.StagingRoot { + t.Fatalf("input change reused request identity or staging: first=%#v second=%#v", first.Request, second.Request) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatalf("invalidated output completed new request: %v", err) + } +} + +func TestForegroundWorkRejectsMissingInvalidAndEscapingOutputs(t *testing.T) { + // control-law: only declared bounded regular staged outputs become work evidence + 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 := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("missing required output was accepted") + } + if err := os.WriteFile(filepath.Join(record.Request.StagingRoot, "diagnosis.json"), []byte(`{"cause":1}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("schema-invalid output was accepted") + } + outside := filepath.Join(root, "outside.json") + if err := os.WriteFile(outside, []byte(`{"cause":"outside"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(record.Request.StagingRoot, "diagnosis.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(record.Request.StagingRoot, "diagnosis.json")); err != nil { + t.Fatal(err) + } + if _, err := manager.Complete(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("symlink output was accepted") + } +} + +func TestForegroundWorkConcurrentCompletionIsSerialized(t *testing.T) { + // control-law: one work request has at most one successful completion mutation + manager, snapshot, transition, _ := 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) + } + var wait sync.WaitGroup + errors := make(chan error, 2) + for i := 0; i < 2; i++ { + wait.Add(1) + go func() { + defer wait.Done() + _, completeErr := manager.Complete(ctx, invocation(), "run-1", "diagnose") + errors <- completeErr + }() + } + wait.Wait() + close(errors) + successes := 0 + for err := range errors { + if err == nil { + successes++ + } + } + if successes != 1 { + t.Fatalf("concurrent completions succeeded %d times", successes) + } +} + +func TestForegroundWorkRejectsTamperedRuntimeRecord(t *testing.T) { + // control-law: runtime work state must preserve typed status and event evidence + manager, snapshot, transition, root := fixture(t) + ctx := context.Background() + objective := model.Objective{ID: "incident-1", TargetID: "mitigated", DeliveryID: "incident-1"} + if _, err := manager.Ensure(ctx, invocation(), "run-1", "incident-response", "respond", objective, snapshot, transition, workInputs("incident.json", strings.Repeat("e", 64))); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "flow", "work", "run-1", "diagnose", "record.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + tampered := strings.Replace(string(raw), `"status": "requested"`, `"status": "completed"`, 1) + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatal(err) + } + if _, err := manager.Show(ctx, invocation(), "run-1", "diagnose"); err == nil { + t.Fatal("tampered status was accepted") + } +} diff --git a/boatstack/internal/softwaredelivery/model/state.go b/boatstack/internal/softwaredelivery/model/state.go index 19ab993..d2fa472 100644 --- a/boatstack/internal/softwaredelivery/model/state.go +++ b/boatstack/internal/softwaredelivery/model/state.go @@ -11,6 +11,8 @@ import ( const SnapshotSchemaVersion = 5 +const foregroundWorkContextIdentity = "foreground-work-context" + type ProtocolPhase string const ( @@ -130,6 +132,8 @@ const ( PlanAbsent PlanState = "absent" PlanDraft PlanState = "draft" PlanValid PlanState = "valid" + PlanPackageValid PlanState = "package-valid" + PlanPackageApproved PlanState = "package-approved" PlanApproved PlanState = "approved" PlanLocked PlanState = "locked" PlanStale PlanState = "stale" @@ -139,7 +143,7 @@ const ( func (s PlanState) Valid() bool { switch s { - case PlanAbsent, PlanDraft, PlanValid, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: + case PlanAbsent, PlanDraft, PlanValid, PlanPackageValid, PlanPackageApproved, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired: return true default: return false @@ -590,6 +594,29 @@ func Canonicalize(observation Observation) (Snapshot, error) { } } snapshot := Snapshot{Observation: observation} + fingerprint, err := observationFingerprint(observation) + if err != nil { + return Snapshot{}, err + } + snapshot.Fingerprint = fingerprint + return snapshot, nil +} + +// ForegroundWorkContextFingerprint binds long-running work to the durable and +// admission-relevant observation while excluding invocation-local driver IDs. +// A restart may change controller, host, correlation, or the executable's +// installation path without changing the repository, runtime, program, or +// control state that the work was authorized to inspect. +func ForegroundWorkContextFingerprint(snapshot Snapshot) (string, error) { + projection := snapshot.Observation + projection.Invocation.ControllerID = foregroundWorkContextIdentity + projection.Invocation.Host = foregroundWorkContextIdentity + projection.Invocation.Correlation = foregroundWorkContextIdentity + projection.Invocation.RuntimePath = "" + return observationFingerprint(projection) +} + +func observationFingerprint(observation Observation) (string, error) { projection := observation projection.ObservedAt = time.Time{} zeroEvidenceTimes(&projection.Program) @@ -619,11 +646,10 @@ func Canonicalize(observation Observation) (Snapshot, error) { } raw, err := json.Marshal(Snapshot{Observation: projection}) if err != nil { - return Snapshot{}, fmt.Errorf("snapshot: canonical encoding: %w", err) + return "", fmt.Errorf("snapshot: canonical encoding: %w", err) } digest := sha256.Sum256(raw) - snapshot.Fingerprint = hex.EncodeToString(digest[:]) - return snapshot, nil + return hex.EncodeToString(digest[:]), nil } func zeroEvidenceTimes[T any](fact *Fact[T]) { diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 0f5541e..34bac4d 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -110,7 +110,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) return model.Observation{}, readPinErr } pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) - if decodePinErr == nil && pin.StateSchemaVersion == durable.StateSchemaVersion && + if decodePinErr == nil && durable.CanReadStateSchema(pin.StateSchemaVersion) && pin.Version == current.RuntimeVersion && pin.SHA256 == current.RuntimeFingerprint { home, homeErr := boatstackruntime.Home("") if homeErr != nil { @@ -144,7 +144,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) } pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) identity := boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource} - if decodePinErr != nil || pin.Identity() != identity || pin.ProgramFingerprint != state.ProgramFingerprint || pin.StateSchemaVersion != durable.StateSchemaVersion { + if decodePinErr != nil || pin.Identity() != identity || pin.ProgramFingerprint != state.ProgramFingerprint || !durable.CanReadStateSchema(pin.StateSchemaVersion) { runtimeState = model.RuntimeConflicting } else { home, homeErr := boatstackruntime.Home("") @@ -251,6 +251,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) verificationEvidence := append(append([]model.Evidence(nil), deliveryEvidence...), artifactEvidence...) planEvidence = append(append([]model.Evidence(nil), stateEvidence...), planEvidence...) terminalEvidence := append(append([]model.Evidence(nil), stateEvidence...), artifactEvidence...) + publication := currentPublicationState(layout, state, head, worktreeFingerprint) configurationEvidence := stateEvidence if configEvidence.Source != "" { configurationEvidence = append(append([]model.Evidence(nil), stateEvidence...), configEvidence) @@ -269,7 +270,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) Configuration: model.Fact[model.ConfigurationState]{Status: model.FactKnown, Value: configuration, Evidence: configurationEvidence}, ConfigurationPolicy: configurationPolicy, Runtime: model.Fact[model.RuntimeState]{Status: model.FactKnown, Value: runtimeState, Evidence: runtimeEvidence}, - Publication: model.Fact[model.PublicationState]{Status: model.FactKnown, Value: state.Publication, Evidence: stateEvidence}, + Publication: model.Fact[model.PublicationState]{Status: model.FactKnown, Value: publication, Evidence: stateEvidence}, Verification: model.Fact[model.VerificationState]{Status: model.FactKnown, Value: verification, Evidence: verificationEvidence}, Recovery: recoveryFact, Transaction: transactionFact, @@ -281,6 +282,38 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) }, nil } +type observedPublicationPreview struct { + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + SourceRevision string `json:"source_revision"` + WorktreeFingerprint string `json:"worktree_fingerprint"` +} + +func currentPublicationState(layout ports.ControllerLayout, state durable.State, head, worktreeFingerprint string) model.PublicationState { + if state.Publication != model.PublicationCandidate { + return state.Publication + } + deliveryID := state.Objective.DeliveryID + if deliveryID == "" || filepath.Base(deliveryID) != deliveryID || deliveryID == "." || deliveryID == ".." { + return model.PublicationNone + } + raw, err := os.ReadFile(filepath.Join(layout.RepositoryRoot, ".boatstack", "publication", deliveryID+".preview.json")) + if err != nil { + return model.PublicationNone + } + var preview observedPublicationPreview + decoder := json.NewDecoder(bytes.NewReader(raw)) + if decoder.Decode(&preview) != nil || preview.SchemaVersion != 2 || preview.DeliveryID != deliveryID || + preview.SourceRevision != head || preview.WorktreeFingerprint != worktreeFingerprint { + return model.PublicationNone + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return model.PublicationNone + } + return state.Publication +} + func (o Observer) highRiskChange(ctx context.Context, repository, defaultBranch string, patterns []string) (bool, error) { if len(patterns) == 0 { return false, nil @@ -429,7 +462,7 @@ func canonicalProductStatus(status string) string { func generatedBoatstackPath(name string) bool { name = strings.TrimPrefix(filepath.ToSlash(name), "./") - for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/plans/", ".boatstack/publication/"} { + for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/planning-packages/", ".boatstack/plans/", ".boatstack/publication/"} { if strings.HasPrefix(name, prefix) { return true } @@ -460,12 +493,31 @@ func sameConfigurationPolicy(one, two model.ConfigurationPolicy) bool { } type observedApproval struct { - SchemaVersion int `json:"schema_version"` - DeliveryID string `json:"delivery_id"` - PlanFingerprint string `json:"plan_fingerprint"` - Actor string `json:"actor"` - AdmissionID string `json:"admission_id"` - ApprovedAt time.Time `json:"approved_at"` + SchemaVersion int `json:"schema_version"` + DeliveryID string `json:"delivery_id"` + PlanFingerprint string `json:"plan_fingerprint"` + PackageFingerprint string `json:"package_fingerprint,omitempty"` + Actor string `json:"actor"` + AdmissionID string `json:"admission_id"` + 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 { @@ -507,7 +559,17 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } deliveryID := state.Objective.DeliveryID - if state.Plan != model.PlanAbsent { + packagePlan := state.Plan == model.PlanPackageValid || state.Plan == model.PlanPackageApproved + if packagePlan { + evidence, valid, err := observePlanningPackage(layout, state, now) + if err != nil { + return plan, verification, terminal, nil, nil, err + } + planEvidence = append(planEvidence, evidence...) + if !valid { + plan, terminal = model.PlanStale, model.TerminalStale + } + } else if state.Plan != model.PlanAbsent { path := filepath.Join(layout.RepositoryRoot, ".boatstack", "plans", deliveryID+".source") evidence, fingerprint, exists, err := fileEvidence(path, "plan", now) if err != nil { @@ -534,6 +596,7 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta 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 !valid { @@ -607,6 +670,83 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta return plan, verification, terminal, planEvidence, verificationEvidence, nil } +func observePlanningPackage(layout ports.ControllerLayout, state durable.State, now time.Time) ([]model.Evidence, bool, error) { + root := filepath.Join(layout.RepositoryRoot, ".boatstack", "planning-packages", state.Objective.DeliveryID) + manifestPath := filepath.Join(root, "manifest.json") + manifestEvidence, manifestFileFingerprint, exists, err := fileEvidence(manifestPath, "planning-package", now) + evidence := []model.Evidence{manifestEvidence} + if err != nil || !exists { + return evidence, false, err + } + manifestRaw, err := os.ReadFile(manifestPath) + 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 + } + 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...)) + } + return evidence, valid, nil +} + type pendingJournalHeader struct { SchemaVersion int `json:"schema_version"` TransitionID string `json:"transition_id"` diff --git a/boatstack/internal/softwaredelivery/plant/observer_test.go b/boatstack/internal/softwaredelivery/plant/observer_test.go index f99fda8..3d86e14 100644 --- a/boatstack/internal/softwaredelivery/plant/observer_test.go +++ b/boatstack/internal/softwaredelivery/plant/observer_test.go @@ -21,6 +21,63 @@ type observerClock struct{ now time.Time } func (c observerClock) Now() time.Time { return c.now } +func TestObserverValidatesAdmittedPlanningPackageWithoutPrematurePlanPromotion(t *testing.T) { + // control-law: an admitted package is verified from its exact manifest while the canonical approved plan remains absent + repository := t.TempDir() + deliveryID := "delivery" + 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, "", " ") + if err != nil { + t.Fatal(err) + } + manifest.Fingerprint = hashBytes(append(identityRaw, '\n')) + manifestRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + manifestRaw = append(manifestRaw, '\n') + if err := os.WriteFile(filepath.Join(root, "plan.md"), plan, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "feature-spec.md"), feature, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), manifestRaw, 0o600); 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 { + 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 { + t.Fatal(err) + } + if _, valid, err := observePlanningPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(101, 0).UTC()); err != nil || valid { + t.Fatalf("tampered planning package valid=%t err=%v", valid, err) + } +} + func runGit(t *testing.T, directory string, arguments ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", directory}, arguments...)...) @@ -441,3 +498,33 @@ func mustRead(t *testing.T, path string) []byte { } return value } + +func TestPublicationCandidateRequiresCurrentCommittedPreviewIdentity(t *testing.T) { + repository := t.TempDir() + layout := ports.ControllerLayout{RepositoryRoot: repository} + state := durable.State{ + Publication: model.PublicationCandidate, + Objective: model.Objective{DeliveryID: "delivery"}, + } + path := filepath.Join(repository, ".boatstack", "publication", "delivery.preview.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + oldPreview := []byte(`{"schema_version":1,"delivery_id":"delivery","source_revision":"head","worktree_fingerprint":"worktree"}`) + if err := os.WriteFile(path, oldPreview, 0o600); err != nil { + t.Fatal(err) + } + if got := currentPublicationState(layout, state, "head", "worktree"); got != model.PublicationNone { + t.Fatalf("old preview projected as %s", got) + } + currentPreview := []byte(`{"schema_version":2,"delivery_id":"delivery","source_revision":"head","worktree_fingerprint":"worktree"}`) + if err := os.WriteFile(path, currentPreview, 0o600); err != nil { + t.Fatal(err) + } + if got := currentPublicationState(layout, state, "head", "worktree"); got != model.PublicationCandidate { + t.Fatalf("current preview projected as %s", got) + } + if got := currentPublicationState(layout, state, "new-head", "worktree"); got != model.PublicationNone { + t.Fatalf("stale source preview projected as %s", got) + } +} diff --git a/boatstack/internal/softwaredelivery/ports/ports.go b/boatstack/internal/softwaredelivery/ports/ports.go index 4763b63..b32fd68 100644 --- a/boatstack/internal/softwaredelivery/ports/ports.go +++ b/boatstack/internal/softwaredelivery/ports/ports.go @@ -52,6 +52,14 @@ type Locker interface { Acquire(context.Context, model.InvocationContext, []string) (Lock, error) } +// RuntimeStore is the effects-owned mutation boundary for runtime records that +// are not product effects. Callers decide record content; only this port may +// create directories or atomically replace bytes. +type RuntimeStore interface { + EnsureDirectory(string, uint32) error + WriteAtomic(string, []byte, uint32) error +} + type Journal interface { Begin(context.Context, protocol.Admission, catalog.Transition) error Stage(context.Context, string, []ResourceMutation) error diff --git a/boatstack/internal/softwaredelivery/protocol/admission.go b/boatstack/internal/softwaredelivery/protocol/admission.go index 31463f8..1c862af 100644 --- a/boatstack/internal/softwaredelivery/protocol/admission.go +++ b/boatstack/internal/softwaredelivery/protocol/admission.go @@ -5,44 +5,55 @@ import ( "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const AdmissionSchemaVersion = 6 +const AdmissionSchemaVersion = 8 type Admission struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - PrescriptionID string `json:"prescription_id"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - ExpectedStateRevision uint64 `json:"expected_state_revision"` - ExpectedProgramFingerprint string `json:"expected_program_fingerprint"` - PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` - ExpectedSnapshotFingerprint string `json:"expected_snapshot_fingerprint"` - ExpectedObjectiveBindingFingerprint string `json:"expected_objective_binding_fingerprint"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - SourcePhase model.ProtocolPhase `json:"source_phase"` - Invocation model.InvocationContext `json:"invocation"` - Objective model.Objective `json:"objective"` - ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` - ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` - Authority AuthorityBundle `json:"authority"` - AuthorityFingerprint string `json:"authority_fingerprint"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` - Parameters Parameters `json:"parameters,omitempty"` - Evidence []string `json:"evidence"` - IdempotencyKey string `json:"idempotency_key"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + PrescriptionID string `json:"prescription_id"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + ExpectedStateRevision uint64 `json:"expected_state_revision"` + ExpectedProgramFingerprint string `json:"expected_program_fingerprint"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ExpectedSnapshotFingerprint string `json:"expected_snapshot_fingerprint"` + ExpectedObjectiveBindingFingerprint string `json:"expected_objective_binding_fingerprint"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + SourcePhase model.ProtocolPhase `json:"source_phase"` + Invocation model.InvocationContext `json:"invocation"` + Objective model.Objective `json:"objective"` + ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` + Authority AuthorityBundle `json:"authority"` + AuthorityFingerprint string `json:"authority_fingerprint"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + Parameters Parameters `json:"parameters,omitempty"` + Evidence []string `json:"evidence"` + IdempotencyKey string `json:"idempotency_key"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Work *WorkEvidence `json:"work,omitempty"` + ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` } func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) { + return NewAdmissionWithWork(snapshot, objective, transition, prescription, authority, parameters, nil, now, lifetime) +} + +func NewAdmissionWithWork(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, now time.Time, lifetime time.Duration) (Admission, error) { + return NewAdmissionWithWorkAndBundle(snapshot, objective, transition, prescription, authority, parameters, work, nil, now, lifetime) +} + +func NewAdmissionWithWorkAndBundle(snapshot model.Snapshot, objective model.Objective, transition catalog.Transition, prescription Prescription, authority AuthorityBundle, parameters Parameters, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract, now time.Time, lifetime time.Duration) (Admission, error) { var err error objective, err = ObjectiveForTransition(snapshot, objective, transition) if err != nil { @@ -61,6 +72,22 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition if err := prescription.ValidateCurrent(snapshot, transition, capabilities); err != nil { return Admission{}, err } + if err := prescription.ValidateWork(work); err != nil { + return Admission{}, err + } + if err := prescription.ValidateControlBundle(bundle, transition); err != nil { + return Admission{}, err + } + if transition.Work != nil { + if work == nil { + return Admission{}, fmt.Errorf("transition %q requires foreground work evidence", transition.ID) + } + if err := work.ValidateCurrent(snapshot, transition); err != nil { + return Admission{}, err + } + } else if work != nil { + return Admission{}, fmt.Errorf("transition %q does not accept foreground work evidence", transition.ID) + } sourceRevision, worktreeFingerprint := gitBinding(snapshot) a := Admission{ SchemaVersion: AdmissionSchemaVersion, PrescriptionID: prescription.ID, TransitionID: transition.ID, TransitionVersion: transition.Version, @@ -72,6 +99,31 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition GrantedCapabilities: capabilities.Granted, EffectiveCapabilities: capabilities.Effective, Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), } + if work != nil { + copy := *work + copy.Outputs = append([]WorkOutputEvidence(nil), work.Outputs...) + a.Work = © + } + if bundle != nil { + copy := *bundle + copy.Source.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Source.Files...) + copy.Source.MemberSets = cloneControlBundleMemberSets(bundle.Source.MemberSets) + if bundle.SourceRuntimePin != nil { + pin := *bundle.SourceRuntimePin + copy.SourceRuntimePin = &pin + } + if bundle.Target != nil { + target := *bundle.Target + target.Files = append([]boatstackruntime.ControlBundleFile(nil), bundle.Target.Files...) + target.MemberSets = cloneControlBundleMemberSets(bundle.Target.MemberSets) + copy.Target = &target + } + if bundle.TargetRuntimePin != nil { + pin := *bundle.TargetRuntimePin + copy.TargetRuntimePin = &pin + } + a.ControlBundle = © + } if transition.Policy.ObjectiveScope == catalog.ObjectiveScopeOptionalPreserve { a.ObjectiveStatus = snapshot.Objective.Status } @@ -89,7 +141,8 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition Invocation model.InvocationContext `json:"invocation"` Objective model.Objective `json:"objective"` Parameters Parameters `json:"parameters"` - }{transition.ID, snapshot.Fingerprint, snapshot.Invocation, objective, parameters.Canonical()}) + Work string `json:"work,omitempty"` + }{transition.ID, snapshot.Fingerprint, snapshot.Invocation, objective, parameters.Canonical(), prescription.WorkResultFingerprint}) if err != nil { return Admission{}, err } @@ -103,6 +156,15 @@ func NewAdmission(snapshot model.Snapshot, objective model.Objective, transition return a, nil } +func cloneControlBundleMemberSets(values []boatstackruntime.ControlBundleMemberSet) []boatstackruntime.ControlBundleMemberSet { + result := make([]boatstackruntime.ControlBundleMemberSet, len(values)) + for index := range values { + result[index] = values[index] + result[index].Paths = append([]string(nil), values[index].Paths...) + } + return result +} + // ObjectiveForTransition binds maintenance to verified durable objective state. // Command-scoped product intent is deliberately irrelevant to maintenance. func ObjectiveForTransition(snapshot model.Snapshot, requested model.Objective, transition catalog.Transition) (model.Objective, error) { @@ -206,6 +268,16 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, objective model.Obje if a.ExpectedProgramFingerprint != snapshot.ProgramFingerprint { return fmt.Errorf("admission %q is bound to a different control program", a.ID) } + if transition.Work != nil { + if a.Work == nil { + return fmt.Errorf("admission %q is missing foreground work evidence", a.ID) + } + if err := a.Work.ValidateCurrent(snapshot, transition); err != nil { + return fmt.Errorf("admission %q foreground work changed: %w", a.ID, err) + } + } else if a.Work != nil { + return fmt.Errorf("admission %q carries unexpected foreground work evidence", a.ID) + } expectedPrior := "" if snapshot.RecordedProgramFingerprint != "" && snapshot.RecordedProgramFingerprint != snapshot.ProgramFingerprint { expectedPrior = snapshot.RecordedProgramFingerprint @@ -376,6 +448,11 @@ func (a Admission) ValidateIdentity() error { if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.PrescriptionID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.ExpectedStateRevision == 0 || len(a.ExpectedProgramFingerprint) != 64 || len(a.ExpectedSnapshotFingerprint) != 64 || len(a.ExpectedObjectiveBindingFingerprint) != 64 || a.AuthorityFingerprint == "" || len(a.RequiredCapabilities) == 0 || len(a.EffectiveCapabilities) == 0 || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } + if a.ControlBundle != nil { + if err := a.ControlBundle.Validate(); err != nil { + return err + } + } fingerprint, err := a.Authority.Fingerprint() if err != nil || fingerprint != a.AuthorityFingerprint { return fmt.Errorf("admission has invalid authority identity") @@ -413,6 +490,11 @@ func (a Admission) ValidateIdentity() error { if err := a.Invocation.Validate(true); err != nil { return err } + if a.Work != nil { + if err := a.Work.Validate(); err != nil { + return err + } + } if !a.ObjectiveScope.Valid() { return fmt.Errorf("admission has invalid objective scope %q", a.ObjectiveScope) } diff --git a/boatstack/internal/softwaredelivery/protocol/authority.go b/boatstack/internal/softwaredelivery/protocol/authority.go index 11fb160..1c768f0 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority.go +++ b/boatstack/internal/softwaredelivery/protocol/authority.go @@ -83,6 +83,19 @@ func (b AuthorityBundle) GrantedCapabilities(now time.Time) []catalog.Capability return catalog.AuthorityCapabilities(b.Set(now)).Sorted() } +// DeriveRepositoryAuthorityWhenAvailable treats repository authority as a +// requested continuation capability, never as caller-supplied evidence. A +// fresh or drifted repository preserves the existing authority bundle so +// trusted bootstrap and repair transitions can establish verified +// configuration. Transitions that require repository authority remain +// inadmissible until a later continuation step can derive it exactly. +func DeriveRepositoryAuthorityWhenAvailable(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { + if snapshot.Configuration.Status != model.FactKnown || snapshot.Configuration.Value != model.ConfigurationVerified { + return bundle, nil + } + return DeriveRepositoryAuthority(snapshot, bundle, now) +} + func DeriveRepositoryAuthority(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { diff --git a/boatstack/internal/softwaredelivery/protocol/authority_test.go b/boatstack/internal/softwaredelivery/protocol/authority_test.go new file mode 100644 index 0000000..4edf4f0 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/authority_test.go @@ -0,0 +1,41 @@ +package protocol + +import ( + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func TestRepositoryAuthorityWaitsForVerifiedConfiguration(t *testing.T) { + // control-law: an authority request never fabricates repository evidence + now := time.Unix(100, 0).UTC() + autonomy := AuthorityBundle{Receipts: []AuthorityReceipt{{ + ID: "delegated-autonomy", Class: catalog.AuthorityAutonomy, Subject: "operator", Fingerprint: "delegation", + IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour), + }}} + snapshot := model.Snapshot{Observation: model.Observation{ + Invocation: model.InvocationContext{RepositoryID: "repository", GitCommonID: "git-common"}, + Configuration: model.Known(model.ConfigurationStale, model.Evidence{Source: "configuration:project.json", Fingerprint: "stale"}), + }} + + result, err := DeriveRepositoryAuthorityWhenAvailable(snapshot, autonomy, now) + if err != nil { + t.Fatal(err) + } + set := result.Set(now) + if !set[catalog.AuthorityAutonomy] || set[catalog.AuthorityRepository] { + t.Fatalf("fresh authority projection = %v", set) + } + + snapshot.Configuration = model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:project.json", Fingerprint: "verified"}) + result, err = DeriveRepositoryAuthorityWhenAvailable(snapshot, autonomy, now) + if err != nil { + t.Fatal(err) + } + set = result.Set(now) + if !set[catalog.AuthorityAutonomy] || !set[catalog.AuthorityRepository] { + t.Fatalf("verified authority projection = %v", set) + } +} diff --git a/boatstack/internal/softwaredelivery/protocol/control_bundle.go b/boatstack/internal/softwaredelivery/protocol/control_bundle.go new file mode 100644 index 0000000..4db4c37 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/control_bundle.go @@ -0,0 +1,82 @@ +package protocol + +import ( + "fmt" + + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +func RequiresControlBundle(transition catalog.Transition) bool { + if transition.ExecutionContext == "advance" { + return true + } + switch transition.ID { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", + "installation.initialize", "installation.update", "installation.reconcile-update", "catalog.reconcile": + return true + default: + return false + } +} + +func ProjectControlBundle(snapshot model.Snapshot, transition catalog.Transition, parameters Parameters, bundle *boatstackruntime.ControlBundleContract) (*boatstackruntime.ControlBundleContract, error) { + if bundle == nil || !RequiresControlBundle(transition) || transition.ExecutionContext == "advance" { + return bundle, nil + } + identity := boatstackruntime.Identity{} + if transition.ID == "catalog.reconcile" { + if bundle.SourceRuntimePin == nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: catalog reconciliation has no trusted runtime pin") + } + identity = bundle.SourceRuntimePin.Identity() + } else { + version, versionOK := parameters.Get("runtime_version") + sha256, shaOK := parameters.Get("runtime_sha256") + source, sourceOK := parameters.Get("source_revision") + if !versionOK || !shaOK || !sourceOK { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: runtime mutation lacks exact candidate identity") + } + identity = boatstackruntime.Identity{Version: version, SHA256: sha256, SourceRevision: source} + } + targetPin := boatstackruntime.NewPin( + identity, + snapshot.ProgramFingerprint, + durable.StateSchemaVersion, + ) + pinRaw, err := boatstackruntime.EncodePin(targetPin) + if err != nil { + return nil, fmt.Errorf("CONTROL_BUNDLE_TARGET_INVALID: %w", err) + } + base := bundle.Source + if bundle.Target != nil { + base = *bundle.Target + } + target, err := boatstackruntime.ReplaceControlBundleFile(base, ".boatstack/runtime.json", pinRaw) + if err != nil { + return nil, err + } + projected, err := boatstackruntime.NewControlBundleContractWithPins(bundle.Source, &target, bundle.TargetRevision, bundle.SourceRuntimePin, &targetPin) + if err != nil { + return nil, err + } + return &projected, nil +} + +func ValidateControlBundleForTransition(bundle *boatstackruntime.ControlBundleContract, transition catalog.Transition) error { + if bundle == nil { + if RequiresControlBundle(transition) { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q requires an exact repository control bundle", transition.ID) + } + return nil + } + if err := bundle.Validate(); err != nil { + return err + } + if RequiresControlBundle(transition) && bundle.Target == nil { + return fmt.Errorf("CONTROL_BUNDLE_REQUIRED: transition %q requires a target bundle", transition.ID) + } + return nil +} diff --git a/boatstack/internal/softwaredelivery/protocol/prescription.go b/boatstack/internal/softwaredelivery/protocol/prescription.go index 4967b62..8a6c840 100644 --- a/boatstack/internal/softwaredelivery/protocol/prescription.go +++ b/boatstack/internal/softwaredelivery/protocol/prescription.go @@ -3,12 +3,13 @@ package protocol import ( "fmt" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const PrescriptionSchemaVersion = 4 +const PrescriptionSchemaVersion = 6 // Prescription is the immutable compare-and-swap binding emitted by // resolution and required by apply. It carries no reusable authority or @@ -18,11 +19,21 @@ type Prescription struct { ID string `json:"id"` TransitionID catalog.TransitionID `json:"transition_id"` general.Freshness - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + EffectiveCapabilities []catalog.Capability `json:"effective_capabilities"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` } func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection) (Prescription, error) { + return NewPrescriptionWithWork(snapshot, transition, capabilities, nil) +} + +func NewPrescriptionWithWork(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence) (Prescription, error) { + return NewPrescriptionWithWorkAndBundle(snapshot, transition, capabilities, work, nil) +} + +func NewPrescriptionWithWorkAndBundle(snapshot model.Snapshot, transition catalog.Transition, capabilities CapabilityProjection, work *WorkEvidence, bundle *boatstackruntime.ControlBundleContract) (Prescription, error) { objectiveBindingFingerprint, err := ObjectiveBindingFingerprint(snapshot) if err != nil { return Prescription{}, err @@ -38,6 +49,18 @@ func NewPrescription(snapshot model.Snapshot, transition catalog.Transition, cap RequiredCapabilities: append([]catalog.Capability(nil), capabilities.Required...), EffectiveCapabilities: append([]catalog.Capability(nil), capabilities.Effective...), } + if err := ValidateControlBundleForTransition(bundle, transition); err != nil { + return Prescription{}, err + } + if bundle != nil { + prescription.ControlBundleFingerprint = bundle.Fingerprint + } + if work != nil { + if err := work.ValidateCurrent(snapshot, transition); err != nil { + return Prescription{}, err + } + prescription.WorkResultFingerprint = work.ResultFingerprint + } if err := prescription.validateFields(); err != nil { return Prescription{}, err } @@ -69,7 +92,7 @@ func (p Prescription) Validate() error { func (p Prescription) validateFields() error { if p.SchemaVersion != PrescriptionSchemaVersion || p.TransitionID == "" || p.Freshness.Validate() != nil || - len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 { + len(p.RequiredCapabilities) == 0 || len(p.EffectiveCapabilities) == 0 || (p.ControlBundleFingerprint != "" && len(p.ControlBundleFingerprint) != 64) { return fmt.Errorf("prescription has invalid schema, transition, state revision, program, or snapshot identity") } return nil @@ -101,6 +124,33 @@ func (p Prescription) ValidateCurrent(snapshot model.Snapshot, transition catalo return nil } +func (p Prescription) ValidateWork(work *WorkEvidence) error { + if p.WorkResultFingerprint == "" { + if work != nil { + return fmt.Errorf("prescription is not bound to foreground work") + } + return nil + } + if work == nil || work.ResultFingerprint != p.WorkResultFingerprint { + return fmt.Errorf("prescription is bound to a different foreground work result") + } + return nil +} + +func (p Prescription) ValidateControlBundle(bundle *boatstackruntime.ControlBundleContract, transition catalog.Transition) error { + if err := ValidateControlBundleForTransition(bundle, transition); err != nil { + return err + } + fingerprint := "" + if bundle != nil { + fingerprint = bundle.Fingerprint + } + if p.ControlBundleFingerprint != fingerprint { + return fmt.Errorf("prescription is bound to a different repository control bundle") + } + return nil +} + // ObjectiveBindingFingerprint projects only the durable binding status and // value. Observation evidence may be refreshed without changing the binding. func ObjectiveBindingFingerprint(snapshot model.Snapshot) (string, error) { diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 4f81602..d0b23cf 100644 --- a/boatstack/internal/softwaredelivery/protocol/receipt.go +++ b/boatstack/internal/softwaredelivery/protocol/receipt.go @@ -12,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ReceiptSchemaVersion = 10 +const ReceiptSchemaVersion = 12 type TransitionFactKind string @@ -90,50 +90,53 @@ func (v VerificationFact) Validate() error { // TransitionReceipt is the immutable fact for one committed transition. It is // not a request, prescription, admission, refusal, or recovery authorization. type TransitionReceipt struct { - SchemaVersion int `json:"schema_version"` - Kind TransitionFactKind `json:"kind"` - ID string `json:"id"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - Program ProgramIdentity `json:"program"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` - ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` - ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` - RuntimeVersion string `json:"runtime_version,omitempty"` - RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` - RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` - PrescriptionID string `json:"prescription_id"` - AdmissionID string `json:"admission_id"` - PriorStateRevision uint64 `json:"prior_state_revision"` - ResultingStateRevision uint64 `json:"resulting_state_revision"` - ObjectiveID string `json:"objective_id"` - TargetID model.TargetID `json:"target_id"` - TrustedClass model.TargetID `json:"trusted_class,omitempty"` - DeliveryID string `json:"delivery_id"` - ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` - ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` - ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - AuthorityFingerprint string `json:"authority_fingerprint"` - AuthoritySources []AuthoritySource `json:"authority_sources"` - RequiredCapabilities []catalog.Capability `json:"required_capabilities"` - GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` - ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` - CommittedEffects []EffectFact `json:"committed_effects"` - ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` - Verification VerificationFact `json:"verification"` - IdempotencyKey string `json:"idempotency_key"` - Recovery catalog.TransitionID `json:"recovery,omitempty"` - Terminal model.TerminalStatus `json:"terminal"` - StartedAt time.Time `json:"started_at"` - CommittedAt time.Time `json:"committed_at"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - ExecutionContext string `json:"execution_context,omitempty"` - PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` - ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` + SchemaVersion int `json:"schema_version"` + Kind TransitionFactKind `json:"kind"` + ID string `json:"id"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + Program ProgramIdentity `json:"program"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` + PrescriptionID string `json:"prescription_id"` + AdmissionID string `json:"admission_id"` + PriorStateRevision uint64 `json:"prior_state_revision"` + ResultingStateRevision uint64 `json:"resulting_state_revision"` + ObjectiveID string `json:"objective_id"` + TargetID model.TargetID `json:"target_id"` + TrustedClass model.TargetID `json:"trusted_class,omitempty"` + DeliveryID string `json:"delivery_id"` + ObjectiveScope catalog.ObjectiveScope `json:"objective_scope,omitempty"` + ObjectiveStatus model.FactStatus `json:"objective_status,omitempty"` + ObjectiveBindingFingerprint string `json:"objective_binding_fingerprint"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + AuthorityFingerprint string `json:"authority_fingerprint"` + AuthoritySources []AuthoritySource `json:"authority_sources"` + RequiredCapabilities []catalog.Capability `json:"required_capabilities"` + GrantedCapabilities []catalog.Capability `json:"granted_capabilities"` + ExercisedCapabilities []catalog.Capability `json:"exercised_capabilities,omitempty"` + CommittedEffects []EffectFact `json:"committed_effects"` + ChangedStateFacets []model.StateFacet `json:"changed_state_facets"` + Verification VerificationFact `json:"verification"` + IdempotencyKey string `json:"idempotency_key"` + Recovery catalog.TransitionID `json:"recovery,omitempty"` + Terminal model.TerminalStatus `json:"terminal"` + StartedAt time.Time `json:"started_at"` + CommittedAt time.Time `json:"committed_at"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + ExecutionContext string `json:"execution_context,omitempty"` + PriorInvocation *model.InvocationContext `json:"prior_invocation,omitempty"` + ResultingInvocation *model.InvocationContext `json:"resulting_invocation,omitempty"` + WorkResultFingerprint string `json:"work_result_fingerprint,omitempty"` + ControlBundleSourceFingerprint string `json:"control_bundle_source_fingerprint,omitempty"` + ControlBundleTargetFingerprint string `json:"control_bundle_target_fingerprint,omitempty"` } type AuthoritySource struct { @@ -199,6 +202,17 @@ func NewReceipt(flowID string, sequence uint64, program ProgramIdentity, admissi IdempotencyKey: admission.IdempotencyKey, Recovery: transition.Interruption.Recovery, Terminal: terminal, StartedAt: startedAt.UTC(), CommittedAt: committedAt.UTC(), DurationNanoseconds: committedAt.Sub(startedAt).Nanoseconds(), } + if admission.Work != nil { + receipt.WorkResultFingerprint = admission.Work.ResultFingerprint + } + if admission.ControlBundle != nil { + receipt.ControlBundleSourceFingerprint = admission.ControlBundle.Source.Fingerprint + if admission.ControlBundle.Target != nil { + receipt.ControlBundleTargetFingerprint = admission.ControlBundle.Target.Fingerprint + } else { + receipt.ControlBundleTargetFingerprint = admission.ControlBundle.Source.Fingerprint + } + } if transition.ExecutionContext == "advance" { prior, resulting := admission.Invocation, target.Invocation if err := prior.Validate(true); err != nil { @@ -235,6 +249,10 @@ func (r TransitionReceipt) Validate() error { if r.SchemaVersion != ReceiptSchemaVersion || r.Kind != TransitionCommitted || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.PrescriptionID == "" || r.AdmissionID == "" || r.PriorStateRevision == 0 || r.PriorStateRevision == ^uint64(0) || r.ResultingStateRevision != r.PriorStateRevision+1 || !validSHA256(r.SourceFingerprint) || !validSHA256(r.TargetFingerprint) || !validSHA256(r.ObjectiveBindingFingerprint) || r.AuthorityFingerprint == "" || len(r.RequiredCapabilities) == 0 || r.IdempotencyKey == "" || len(r.CommittedEffects) == 0 || len(r.ChangedStateFacets) == 0 { return fmt.Errorf("receipt has incomplete committed-transition identity or evidence") } + if (r.ControlBundleSourceFingerprint == "") != (r.ControlBundleTargetFingerprint == "") || + (r.ControlBundleSourceFingerprint != "" && (len(r.ControlBundleSourceFingerprint) != 64 || len(r.ControlBundleTargetFingerprint) != 64)) { + return fmt.Errorf("receipt has incomplete repository control-bundle identity") + } if r.ExecutionContext != "" { if r.ExecutionContext != "advance" || r.PriorInvocation == nil || r.ResultingInvocation == nil { return fmt.Errorf("receipt has invalid execution context lineage") @@ -248,6 +266,9 @@ func (r TransitionReceipt) Validate() error { } else if r.PriorInvocation != nil || r.ResultingInvocation != nil { return fmt.Errorf("receipt has invocation lineage without an execution context advance") } + if r.WorkResultFingerprint != "" && !validSHA256(r.WorkResultFingerprint) { + return fmt.Errorf("receipt has invalid foreground work identity") + } canonicalFacets, err := model.NormalizeStateFacets("receipt.changed_state_facets", r.ChangedStateFacets) if err != nil || !slices.Equal(canonicalFacets, r.ChangedStateFacets) { return fmt.Errorf("receipt changed state facets are invalid or non-canonical: %v", err) diff --git a/boatstack/internal/softwaredelivery/protocol/work.go b/boatstack/internal/softwaredelivery/protocol/work.go new file mode 100644 index 0000000..339e271 --- /dev/null +++ b/boatstack/internal/softwaredelivery/protocol/work.go @@ -0,0 +1,131 @@ +package protocol + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + general "github.com/operatorstack/boatstack/boatstack/kernel" +) + +const WorkEvidenceSchemaVersion = 2 + +// WorkInputValue binds the value presented to foreground work to the exact +// bytes selected by the trusted entry-input resolver. Value is an ergonomic +// locator or label; Fingerprint is the immutable execution identity. +type WorkInputValue struct { + Value string `json:"value"` + Fingerprint string `json:"fingerprint"` +} + +func (v WorkInputValue) Validate() error { + if v.Value == "" || !validSHA256(v.Fingerprint) { + return fmt.Errorf("foreground work input requires a value and exact fingerprint") + } + return nil +} + +// WorkOutputEvidence is a verified, bounded foreground-work output. Content is +// carried into admission so effects never trust a mutable staging path. +type WorkOutputEvidence struct { + ID string `json:"id"` + Path string `json:"path"` + MediaType string `json:"media_type"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Content string `json:"content"` +} + +// WorkEvidence is result evidence, not authority and not a domain mutation. +// It is exact to one program, transition, stable context, repository, and +// worktree. Invocation-local driver IDs do not break restart continuity. +type WorkEvidence 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 catalog.TransitionID `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 []WorkOutputEvidence `json:"outputs"` +} + +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.ProgramFingerprint) || !validSHA256(e.ContextFingerprint) || e.StateRevision == 0 || e.RepositoryID == "" || e.WorktreeID == "" || len(e.Outputs) == 0 { + return fmt.Errorf("foreground work evidence has incomplete identity") + } + seen := map[string]bool{} + for _, output := range e.Outputs { + contentDigest := sha256.Sum256([]byte(output.Content)) + if output.ID == "" || output.Path == "" || output.MediaType == "" || !validSHA256(output.SHA256) || hex.EncodeToString(contentDigest[:]) != output.SHA256 || output.Size < 0 || int64(len(output.Content)) != output.Size || seen[output.ID] { + return fmt.Errorf("foreground work output evidence is incomplete or duplicated") + } + seen[output.ID] = true + } + canonical := e + canonical.ResultFingerprint = "" + want, err := general.Fingerprint(canonical) + if err != nil || want != e.ResultFingerprint { + return fmt.Errorf("foreground work result fingerprint is invalid") + } + return nil +} + +func (e WorkEvidence) ValidateCurrent(snapshot model.Snapshot, transition catalog.Transition) error { + if err := e.Validate(); err != nil { + return err + } + contextFingerprint, err := model.ForegroundWorkContextFingerprint(snapshot) + if err != nil { + return fmt.Errorf("foreground work context fingerprint: %w", err) + } + if transition.Work == nil || e.ContractID != transition.Work.ID || e.ContractFingerprint != transition.Work.Fingerprint || + e.TransitionID != transition.ID || e.ProgramFingerprint != snapshot.ProgramFingerprint || e.ContextFingerprint != contextFingerprint || + e.StateRevision != snapshot.StateRevision || e.RepositoryID != snapshot.Invocation.RepositoryID || e.WorktreeID != snapshot.Invocation.WorktreeID { + return fmt.Errorf("foreground work evidence is stale or belongs to a different transition context") + } + declared := map[string]catalog.WorkOutput{} + for _, output := range transition.Work.Outputs { + declared[output.ID] = output + } + for _, output := range e.Outputs { + contract, ok := declared[output.ID] + if !ok || contract.Path != output.Path || contract.MediaType != output.MediaType || output.Size > contract.MaxBytes { + return fmt.Errorf("foreground work output %q does not match the trusted contract", output.ID) + } + delete(declared, output.ID) + } + for _, output := range declared { + if output.Required { + return fmt.Errorf("foreground work result is missing required output %q", output.ID) + } + } + return nil +} + +func CanonicalWorkOutputs(outputs []WorkOutputEvidence) []WorkOutputEvidence { + result := append([]WorkOutputEvidence(nil), outputs...) + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func SealWorkEvidence(e WorkEvidence) (WorkEvidence, error) { + e.Outputs = CanonicalWorkOutputs(e.Outputs) + e.ResultFingerprint = "" + fingerprint, err := general.Fingerprint(e) + if err != nil { + return WorkEvidence{}, err + } + e.ResultFingerprint = fingerprint + return e, e.Validate() +} diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 50fc15b..de791fe 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -3,38 +3,47 @@ package surfaces import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "regexp" "strings" "time" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 8 +const SchemaVersion = 10 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Operation string const ( - OperationResolve Operation = "resolve" - OperationExplain Operation = "explain" - OperationApply Operation = "apply" - OperationRecover Operation = "recover" - OperationDoctor Operation = "doctor" - OperationCatalog Operation = "catalog" - OperationEvents Operation = "events" - OperationGuard Operation = "guard" + OperationResolve Operation = "resolve" + OperationExplain Operation = "explain" + OperationApply Operation = "apply" + OperationRecover Operation = "recover" + OperationDoctor Operation = "doctor" + OperationCatalog Operation = "catalog" + OperationEvents Operation = "events" + OperationGuard Operation = "guard" + OperationWorkShow Operation = "work-show" + OperationWorkInputRequired Operation = "work-input-required" + OperationWorkAnswer Operation = "work-answer" + OperationWorkComplete Operation = "work-complete" + OperationWorkBlock Operation = "work-block" ) func (o Operation) Valid() bool { switch o { - case OperationResolve, OperationExplain, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard: + case OperationResolve, OperationExplain, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard, + OperationWorkShow, OperationWorkInputRequired, OperationWorkAnswer, OperationWorkComplete, OperationWorkBlock: return true default: return false @@ -42,26 +51,35 @@ func (o Operation) Valid() bool { } type Request struct { - SchemaVersion int `json:"schema_version"` - Operation Operation `json:"operation"` - Repository string `json:"repository"` - Host string `json:"host"` - CorrelationID string `json:"correlation_id"` - ProgramID string `json:"program_id,omitempty"` - ProgramFingerprint string `json:"program_fingerprint,omitempty"` - EntryID string `json:"entry_id,omitempty"` - FlowID string `json:"flow_id,omitempty"` - Objective model.Objective `json:"objective,omitempty"` - TransitionID catalog.TransitionID `json:"transition_id,omitempty"` - Prescription protocol.Prescription `json:"prescription,omitempty"` - Authority protocol.AuthorityBundle `json:"authority,omitempty"` - RepositoryAuthority bool `json:"repository_authority,omitempty"` - Parameters protocol.Parameters `json:"parameters,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - Command string `json:"command,omitempty"` - DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` - DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` - DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` + SchemaVersion int `json:"schema_version"` + Operation Operation `json:"operation"` + Repository string `json:"repository"` + Host string `json:"host"` + CorrelationID string `json:"correlation_id"` + ProgramID string `json:"program_id,omitempty"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + EntryID string `json:"entry_id,omitempty"` + FlowID string `json:"flow_id,omitempty"` + Objective model.Objective `json:"objective,omitempty"` + TransitionID catalog.TransitionID `json:"transition_id,omitempty"` + Prescription protocol.Prescription `json:"prescription,omitempty"` + Authority protocol.AuthorityBundle `json:"authority,omitempty"` + RepositoryAuthority bool `json:"repository_authority,omitempty"` + Parameters protocol.Parameters `json:"parameters,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Command string `json:"command,omitempty"` + DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` + DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` + DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` + WorkInputs map[string]protocol.WorkInputValue `json:"work_inputs,omitempty"` + WorkID string `json:"work_id,omitempty"` + WorkQuestionPrompt string `json:"work_question_prompt,omitempty"` + WorkQuestionSchema []byte `json:"work_question_schema,omitempty"` + WorkQuestionID string `json:"work_question_id,omitempty"` + WorkAnswer []byte `json:"work_answer,omitempty"` + WorkBlockReason string `json:"work_block_reason,omitempty"` + ControlBundle *boatstackruntime.ControlBundleContract `json:"control_bundle,omitempty"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` } func (r Request) Validate(now time.Time) error { @@ -80,6 +98,16 @@ func (r Request) Validate(now time.Time) error { if r.ProgramID == "" && r.ProgramFingerprint != "" { return fmt.Errorf("surface request cannot carry a program fingerprint without a program") } + if r.ControlBundle != nil { + if err := r.ControlBundle.Validate(); err != nil { + return err + } + if r.ControlBundleFingerprint != r.ControlBundle.Source.Fingerprint { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request fingerprint does not match trusted source bundle") + } + } else if r.ControlBundleFingerprint != "" { + return fmt.Errorf("CONTROL_BUNDLE_INVALID: request fingerprint has no trusted bundle") + } if len(r.DelegatedAuthorities) != 0 && (r.ProgramID == "" || len(r.DelegationBindingFingerprint) != 64 || len(r.DelegationRequestFingerprint) != 64) { return fmt.Errorf("surface delegated Flow request requires exact binding and request fingerprints") } @@ -88,6 +116,14 @@ func (r Request) Validate(now time.Time) error { return fmt.Errorf("surface delegated Flow request has invalid authority %q", authority) } } + for id, input := range r.WorkInputs { + if !flowContextIdentity.MatchString(id) { + return fmt.Errorf("surface foreground work input has invalid identity %q", id) + } + if err := input.Validate(); err != nil { + return fmt.Errorf("surface foreground work input %q: %w", id, err) + } + } if r.Operation != OperationCatalog { knownHost := false for _, host := range CanonicalHostNames() { @@ -117,6 +153,29 @@ func (r Request) Validate(now time.Time) error { if r.Operation == OperationGuard && (strings.TrimSpace(r.Command) == "" || len(r.Command) > 1<<20) { return fmt.Errorf("guard operation requires a bounded command") } + if strings.HasPrefix(string(r.Operation), "work-") { + if r.FlowID == "" || !flowContextIdentity.MatchString(r.WorkID) { + return fmt.Errorf("foreground work operation requires semantic run and work identity") + } + switch r.Operation { + case OperationWorkShow, OperationWorkComplete: + if r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work %s cannot carry mutation payload", r.Operation) + } + case OperationWorkInputRequired: + if strings.TrimSpace(r.WorkQuestionPrompt) == "" || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work input-required requires only a question prompt and optional schema") + } + case OperationWorkAnswer: + if !flowContextIdentity.MatchString(r.WorkQuestionID) || len(r.WorkAnswer) == 0 || !json.Valid(r.WorkAnswer) || r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkBlockReason != "" { + return fmt.Errorf("foreground work answer requires the exact question and bounded JSON answer") + } + case OperationWorkBlock: + if strings.TrimSpace(r.WorkBlockReason) == "" || r.WorkQuestionPrompt != "" || len(r.WorkQuestionSchema) != 0 || r.WorkQuestionID != "" || len(r.WorkAnswer) != 0 { + return fmt.Errorf("foreground work block requires only a reason") + } + } + } if r.IdempotencyKey != "" && !strings.HasPrefix(r.IdempotencyKey, "idem-") { return fmt.Errorf("surface idempotency key has invalid identity") } @@ -177,6 +236,7 @@ type Response struct { Guard *supervisor.GuardDecision `json:"guard,omitempty"` Error string `json:"error,omitempty"` Delegation *DelegationRequired `json:"delegation,omitempty"` + Work *foregroundwork.Record `json:"work,omitempty"` } type DelegationRequired struct { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index 338a46e..db79106 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -1,6 +1,7 @@ package surfaces import ( + "encoding/json" "testing" "time" @@ -31,6 +32,55 @@ func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { } } +func TestForegroundWorkSurfaceRejectsAmbiguousMutationPayloads(t *testing.T) { + // control-law: each foreground-work mutation crosses one typed operation boundary + base := Request{ + SchemaVersion: SchemaVersion, Repository: "/repository", Host: "cli", CorrelationID: "work", + ProgramID: "incident-response", ProgramFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + EntryID: "respond", FlowID: "run-1", WorkID: "diagnose", + } + valid := []Request{ + func() Request { value := base; value.Operation = OperationWorkShow; return value }(), + func() Request { value := base; value.Operation = OperationWorkComplete; return value }(), + func() Request { + value := base + value.Operation = OperationWorkInputRequired + value.WorkQuestionPrompt = "Which service?" + value.WorkQuestionSchema = []byte(`{"type":"string"}`) + return value + }(), + func() Request { + value := base + value.Operation = OperationWorkAnswer + value.WorkQuestionID = "question-1" + value.WorkAnswer = json.RawMessage(`"api"`) + return value + }(), + func() Request { + value := base + value.Operation = OperationWorkBlock + value.WorkBlockReason = "input unavailable" + return value + }(), + } + for _, request := range valid { + if err := request.Validate(time.Now()); err != nil { + t.Fatalf("valid %s request: %v", request.Operation, err) + } + } + invalid := []Request{valid[0], valid[1], valid[2], valid[3], valid[4]} + invalid[0].WorkBlockReason = "hidden mutation" + invalid[1].WorkAnswer = []byte(`true`) + invalid[2].WorkQuestionPrompt = "" + invalid[3].WorkQuestionID = "" + invalid[4].WorkQuestionID = "question-1" + for _, request := range invalid { + if err := request.Validate(time.Now()); err == nil { + t.Fatalf("ambiguous %s request was accepted", request.Operation) + } + } +} + func TestExplainRequestRejectsMutationArtifacts(t *testing.T) { now := time.Now().UTC() request := Request{SchemaVersion: SchemaVersion, Operation: OperationExplain, Repository: "/repo", Host: "cli", CorrelationID: "explain"} diff --git a/boatstack/internal/softwaredelivery/surfaces/render.go b/boatstack/internal/softwaredelivery/surfaces/render.go index 898f776..2d19e4d 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render.go +++ b/boatstack/internal/softwaredelivery/surfaces/render.go @@ -37,6 +37,9 @@ func PrescriptionCommand(transition catalog.Transition, prescription protocol.Pr if programID != "" && entryID != "" { arguments = append(arguments, "--flow", programID, "--entry", entryID) } + if prescription.WorkResultFingerprint != "" { + arguments = append(arguments, "--work-result-fingerprint", prescription.WorkResultFingerprint) + } for _, capability := range prescription.RequiredCapabilities { arguments = append(arguments, "--required-capability", string(capability)) } diff --git a/boatstack/internal/softwaredelivery/surfaces/render_test.go b/boatstack/internal/softwaredelivery/surfaces/render_test.go index ce1fe85..d6761c8 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/render_test.go @@ -26,10 +26,11 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { ID: "prx-fixture", Freshness: general.Freshness{ExpectedInstanceID: "repo-fixture", ExpectedStateRevision: 41, ExpectedProgramFingerprint: strings.Repeat("a", 64), ExpectedSnapshotFingerprint: strings.Repeat("b", 64), ExpectedObjectiveBindingFingerprint: strings.Repeat("c", 64), AuthorityFingerprint: "auth-fixture"}, RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, + WorkResultFingerprint: strings.Repeat("d", 64), } command := PrescriptionCommand(transition, prescription, "corr-1", "/repo with space", objective, "run-1", "product-delivery", "run", parameters) joined := strings.Join(command.Arguments, " ") - for _, binding := range []string{"--correlation corr-1", "--prescription-id prx-fixture", "--expected-instance-id repo-fixture", "--expected-state-revision 41", "--expected-program-fingerprint", "--expected-snapshot-fingerprint", "--expected-objective-binding-fingerprint", "--authority-fingerprint auth-fixture", "--required-capability repository.write", "--required-capability command.execute", "--effective-capability repository.write", "--effective-capability command.execute"} { + for _, binding := range []string{"--correlation corr-1", "--prescription-id prx-fixture", "--expected-instance-id repo-fixture", "--expected-state-revision 41", "--expected-program-fingerprint", "--expected-snapshot-fingerprint", "--expected-objective-binding-fingerprint", "--authority-fingerprint auth-fixture", "--required-capability repository.write", "--required-capability command.execute", "--effective-capability repository.write", "--effective-capability command.execute", "--work-result-fingerprint " + strings.Repeat("d", 64)} { if !strings.Contains(joined, binding) { t.Fatalf("prescription command omitted CAS binding %q: %s", binding, joined) } diff --git a/boatstack/testdata/control-programs/assets/diagnose.md b/boatstack/testdata/control-programs/assets/diagnose.md new file mode 100644 index 0000000..1d397e5 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/diagnose.md @@ -0,0 +1,4 @@ +# Diagnose the incident + +Inspect the bound incident input. Produce only the declared diagnosis artifact. +If required information is missing, ask one typed question and wait for its answer. diff --git a/boatstack/testdata/control-programs/assets/diagnosis.schema.json b/boatstack/testdata/control-programs/assets/diagnosis.schema.json new file mode 100644 index 0000000..089d2a0 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/diagnosis.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "cause": { "type": "string", "minLength": 1 }, + "safe_to_restart": { "type": "boolean" } + }, + "required": ["cause", "safe_to_restart"], + "additionalProperties": false +} diff --git a/boatstack/testdata/control-programs/assets/planning-list.schema.json b/boatstack/testdata/control-programs/assets/planning-list.schema.json new file mode 100644 index 0000000..5408a2b --- /dev/null +++ b/boatstack/testdata/control-programs/assets/planning-list.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { "type": "object" } + } + }, + "required": ["items"], + "additionalProperties": false +} diff --git a/boatstack/testdata/control-programs/assets/planning-package.md b/boatstack/testdata/control-programs/assets/planning-package.md new file mode 100644 index 0000000..ab25d76 --- /dev/null +++ b/boatstack/testdata/control-programs/assets/planning-package.md @@ -0,0 +1,8 @@ +# Compile a delivery planning package + +Read the bound repository plan and inspect only the code needed to resolve its unknowns. +Ask the user focused questions when a consequential product or implementation choice is missing. +After every required answer is available, produce the exact declared artifacts. + +The package must make scope, behavior, tasks, tests, user journeys, gaps, evidence, and allowed autonomy explicit. +Do not implement the feature during this work requirement. diff --git a/boatstack/testdata/control-programs/incident-response-work.flow.ts b/boatstack/testdata/control-programs/incident-response-work.flow.ts new file mode 100644 index 0000000..fe1060d --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response-work.flow.ts @@ -0,0 +1,82 @@ +import { + defineFlow, + entry, + entryInput, + fact, + facet, + foregroundWork, + instructionAsset, + marked, + operator, + schemaAsset, + transition, + workArtifact, +} from "@operatorstack/boatstack"; + +const diagnosis = foregroundWork({ + id: "diagnose", + instructions: instructionAsset("boatstack/testdata/control-programs/assets/diagnose.md"), + inputs: [entryInput("incident")], + outputs: [ + workArtifact({ + id: "diagnosis", + path: "diagnosis.json", + media_type: "application/json", + required: true, + max_bytes: 65536, + schema: schemaAsset("boatstack/testdata/control-programs/assets/diagnosis.schema.json"), + }), + ], +}); + +export default defineFlow({ + id: "incident-response-work", + version: "1", + declarations: { + capabilities: ["service.restart"], + authorities: ["incident-commander"], + effects: ["service.restart"], + verifiers: ["healthcheck"], + input_resolvers: ["incident.input"], + }, + facets: [facet("incident", "enum", ["open", "mitigated"])], + work: [diagnosis], + operators: [ + operator("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: [ + transition("restart", "restart", { + guard: fact("incident", ["open"]), + target: fact("incident", ["mitigated"]), + priority: 10, + work: "diagnose", + }), + ], + targets: [marked("mitigated", fact("incident", ["mitigated"]))], + entries: [ + entry({ + id: "respond", + target: "mitigated", + inputs: [ + { + id: "incident", + type: "json", + required: true, + resolver: "incident.input", + config: {}, + }, + ], + }), + ], +}); diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 8d00baa..0827b3a 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,6 +1,6 @@ { "schema": "control-program", - "schema_revision": 2, + "schema_revision": 3, "program": { "id": "incident-response", "version": "1" diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts new file mode 100644 index 0000000..5864148 --- /dev/null +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -0,0 +1,111 @@ +import { + all, + defineFlow, + entry, + entryInput, + fact, + foregroundWork, + instructionAsset, + marked, + schemaAsset, + workArtifact, +} from "@operatorstack/boatstack"; +import { + inbox, + planInboxResolver, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + softwareDeliveryEvidence, + softwareDeliveryFacets, + trustedDelegation, + trustedOperators, + trustedTransition, +} from "@operatorstack/boatstack-software-delivery"; + +const planning = foregroundWork({ + id: "planning-package", + instructions: instructionAsset("boatstack/testdata/control-programs/assets/planning-package.md"), + inputs: [entryInput("plan")], + outputs: [ + workArtifact({ id: "plan", path: "plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "feature-spec", path: "feature-spec.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "questions", path: "questions.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + workArtifact({ id: "test-plan", path: "test-plan.md", media_type: "text/markdown", required: true, max_bytes: 262144 }), + workArtifact({ id: "gaps", path: "gaps.md", media_type: "text/markdown", required: false, max_bytes: 131072 }), + workArtifact({ id: "autonomy", path: "autonomy.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + workArtifact({ id: "tasks", path: "compiled/tasks.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "test-matrix", path: "compiled/test-matrix.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "journey-oracles", path: "compiled/journey-oracles.json", media_type: "application/json", required: true, max_bytes: 262144, schema: schemaAsset("boatstack/testdata/control-programs/assets/planning-list.schema.json") }), + workArtifact({ id: "evidence", path: "compiled/evidence.md", media_type: "text/markdown", required: true, max_bytes: 131072 }), + ], +}); + +const lifecycle = [ + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + { id: "plan.activate", priority: 50 }, + { id: "workspace.cut", priority: 52 }, + { id: "workspace.activate", priority: 53 }, + { id: "workspace.sync", priority: 58 }, + { id: "gate.build.record", priority: 61 }, + { id: "gate.test.record", priority: 62 }, + { id: "gate.review.record", priority: 63 }, + { id: "gate.change.record", priority: 64 }, + { id: "gate.journey.record", priority: 64 }, + { id: "evidence.visual.attach", priority: 66 }, + { id: "delivery.slice.advance", priority: 68 }, + { id: "publication.preview", priority: 72 }, + { id: "workspace.publish", priority: 75 }, + { id: "publication.execute", priority: 76 }, + { id: "publication.observe", priority: 77 }, + { id: "publication.correct", priority: 80 }, + { id: "workspace.reconcile", priority: 2 }, + { id: "publication.reconcile", priority: 1 }, +]; + +export default defineFlow({ + id: "product-delivery-planning-package", + version: "1", + declarations: { input_resolvers: [planInboxResolver] }, + facets: softwareDeliveryFacets, + evidence: softwareDeliveryEvidence, + work: [planning], + operators: trustedOperators(lifecycle), + transitions: [ + trustedTransition(planningPackageAdmit, { work: planning }), + trustedTransition(planningPackageApprove), + trustedTransition(planningPackagePromote), + trustedTransition({ id: "plan.activate", priority: 50 }), + trustedTransition({ id: "workspace.cut", priority: 52 }), + trustedTransition({ id: "workspace.activate", priority: 53 }), + trustedTransition({ id: "workspace.sync", priority: 58 }), + trustedTransition({ id: "gate.build.record", priority: 61 }), + trustedTransition({ id: "gate.test.record", priority: 62 }), + trustedTransition({ id: "gate.review.record", priority: 63 }), + trustedTransition({ id: "gate.change.record", priority: 64 }), + trustedTransition({ id: "gate.journey.record", priority: 64 }), + trustedTransition({ id: "evidence.visual.attach", priority: 66 }), + trustedTransition({ id: "delivery.slice.advance", priority: 68 }), + trustedTransition({ id: "publication.preview", priority: 72 }), + trustedTransition({ id: "workspace.publish", priority: 75 }), + trustedTransition({ id: "publication.execute", priority: 76 }), + trustedTransition({ id: "publication.observe", priority: 77 }), + trustedTransition({ id: "publication.correct", priority: 80 }), + trustedTransition({ id: "workspace.reconcile", priority: 2 }), + trustedTransition({ id: "publication.reconcile", priority: 1 }), + ], + targets: [marked("published-pr", all( + fact("verification", ["current"]), + fact("configuration", ["verified"]), + fact("runtime", ["verified"]), + fact("publication", ["open"]), + ))], + entries: [entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + delegation: trustedDelegation("autonomy"), + })], +}); diff --git a/docs/architecture/boatstack-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md index aeb8088..7b61b75 100644 --- a/docs/architecture/boatstack-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -7,39 +7,39 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Required capabilities | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| -| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | -| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | -| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | -| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `repository.write` | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | | `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | -| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | -| `engagement.release` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | -| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | | `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | -| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | -| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | -| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | -| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | -| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | -| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | -| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | -| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | -| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | -| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | -| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | -| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | -| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | +| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | +| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | +| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | +| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | | `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | | `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | | `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | | `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | | `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | -| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | -| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | -| `installation.update` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | -| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | -| `objective.bind` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `objective.bind` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `target_id*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | | `plan.activate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | | `plan.amend` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | @@ -54,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | | `publication.preview` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | | `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | -| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | -| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | -| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | -| `repository.attach` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | -| `repository.detach` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | -| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | -| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`753278c89b6b249319a2be379fbea290d8f026ffb4fb13a5ec61fb93e632e1b7` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | `product.mutate`, `repository.write` | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`759d44bc5e3502ebe3f8627836b2afb4634389fe568405fafa2cb6877ea7faca` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | | `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | | `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | | `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index a2bb784..b9091c1 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,9 +6,10 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema at revision `2` is domain-neutral. It declares typed facets, -evidence relations, predicate ASTs, operators, capabilities, authority, -effects, verification, recovery, transitions, marked targets, and entries. +The `control-program` schema at revision `3` is domain-neutral. It declares +typed facets, evidence relations, predicate ASTs, operators, capabilities, +authority, effects, verification, recovery, bounded foreground work, +transitions, marked targets, and entries. Software terms such as plans, tests, Git, and pull requests belong to `@operatorstack/boatstack-software-delivery`, not the base SDK. @@ -70,10 +71,43 @@ 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, -trusted operator fingerprints, canonical program fingerprint, and generated -skill hashes. Unknown fields, duplicate declarations, invalid references, +foreground-work instruction 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 -drift fail closed. A source or lock change during compilation also fails closed. +drift fail closed. A source, lock, instruction, or schema change during +compilation also fails closed. + +## Foreground work + +A Flow may require bounded human or agent work before a trusted transition can +be prescribed. The repository declares an instruction asset, exact entry +inputs, and an output manifest. Boatstack resolves the assets during compile, +creates a runtime-owned work request for the selected transition, and verifies +the staged outputs before it admits the trusted operator. + +Foreground work cannot change Flow state, grant authority, or install an +effect handler. Its result is immutable evidence bound to one run, program, +transition, state revision, repository, and worktree. Questions suspend the +same run; answers are evidence rather than authority. A program or state change +invalidates the result before any trusted effect. + +The foreground-work commands are explicit and foreground-only: + +```sh +boatstack flow work show --repo . --flow --entry \ + --run-id --work-id --format json +boatstack flow work input-required ... --prompt "" +boatstack flow work answer ... --question-id --answer +boatstack flow work complete ... +boatstack flow work block ... --reason "" +``` + +`complete` reads only the declared regular files below the request's staging +root. It checks paths, media types, size limits, JSON syntax and declared JSON +Schemas, then seals the exact bytes into the work result. The following +`next` call can prescribe the transition only with that exact result +fingerprint. Trusted software-delivery bindings fix capabilities, authority, effects, verifiers, recovery, and state effects. A repository may select and order those diff --git a/docs/getting-started.md b/docs/getting-started.md index e126650..479f517 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -154,13 +154,15 @@ Publication uses `publication.preview`, `publication.execute`, and `publication.observe`. The external execute step requires both: - human or autonomy authority; and -- an unexpired `external-provider` authority receipt supplied with - `--authority-receipt`. - -Boatstack does not infer provider authority from `gh` being installed or -authenticated. The provider receipt fingerprint must equal the exact -`preview_fingerprint` returned by `publication.preview`; correction receipts -likewise bind the admitted body SHA-256. It never merges a pull request. +- a current GitHub identity with write, maintain, or admin permission. + +For a repository Flow continuation, Boatstack derives a short-lived provider +receipt through its trusted `gh repo view` boundary. Callers cannot supply an +`external-provider` receipt as JSON. The receipt and publication preview bind +the exact committed HEAD, clean product worktree, and preview fingerprint. +Boatstack stops with `WORKSPACE_COMMIT_REQUIRED` before preview when intended +delivery changes are uncommitted. Correction receipts likewise bind the +admitted body SHA-256. Boatstack never merges a pull request. ## Inspect receipts diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md index 2f930c0..c83d669 100644 --- a/docs/product-delivery/authority-and-delegation.md +++ b/docs/product-delivery/authority-and-delegation.md @@ -24,3 +24,11 @@ entry. It does not authorize the run. Boatstack presents an exact run-bound request at runtime, and only a trusted human/host boundary can authorize it. Revocation, expiry, incompatible drift, or an unauthorized execution context ends or suspends that delegation. External-provider authority remains separate. +For repository Flow continuation, the trusted GitHub boundary derives that +provider capability from the current repository identity and authenticated +write permission. This is capability evidence, not another human approval. +Repository files and `--authority-receipt` cannot create provider authority. + +Publication is admitted only after the product worktree is clean and the +preview binds its exact committed HEAD. A changed HEAD or worktree invalidates +the preview before the external effect. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 06553c1..0dc3f55 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -4,30 +4,68 @@ A repository Flow chooses trusted operations, their priorities, terminal targets, and named entries. It does not provide executable operator handlers. ```ts -import { all, defineFlow, entry, fact, marked } from "@operatorstack/boatstack"; +import { + all, + defineFlow, + entry, + entryInput, + fact, + foregroundWork, + instructionAsset, + marked, + workArtifact, +} from "@operatorstack/boatstack"; import { inbox, planInboxResolver, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, softwareDeliveryEvidence, softwareDeliveryFacets, trustedDelegation, trustedOperators, + trustedTransition, trustedTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; const lifecycle = [ - { id: "publication.observe", priority: 77 }, + planningPackageAdmit, + planningPackageApprove, + planningPackagePromote, + // Add the repository's trusted execution, gate, and publication steps here. ] satisfies TrustedStep[]; +const planning = foregroundWork({ + id: "planning-package", + instructions: instructionAsset(".boatstack/flows/assets/planning.md"), + inputs: [entryInput("plan")], + outputs: [ + workArtifact({ + id: "plan", + path: "plan.md", + media_type: "text/markdown", + required: true, + max_bytes: 262144, + }), + ], +}); + export default defineFlow({ id: "product-delivery", version: "1", declarations: { input_resolvers: [planInboxResolver] }, facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, + work: [planning], operators: trustedOperators(lifecycle), - transitions: trustedTransitions(lifecycle), + transitions: [ + trustedTransition(planningPackageAdmit, { work: planning }), + trustedTransition(planningPackageApprove), + trustedTransition(planningPackagePromote), + // Add trustedTransitions(...) for the remaining lifecycle here. + ], targets: [ marked( "published-pr", @@ -60,3 +98,11 @@ Compilation lowers this source to raw IR. Boatstack then validates references, resolves trusted bindings, canonicalizes executable semantics, and fingerprints the program. Runtime commands load the committed IR artifact; they do not execute this source file. + +The planning-package operations are optional trusted mechanisms. A repository +that includes them owns the planning instruction, output contract, transition +order, approval policy, target, and entry. `planning.package.admit` atomically +stores the verified package, `planning.package.approve` binds approval to its +exact manifest, and `planning.package.promote` publishes the approved canonical +plan. Repositories that omit these operations retain the existing plan path; +StandardFlow does not add them automatically. diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 6c838e4..a9760bf 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -20,6 +20,7 @@ import { type OperatorDefinition, type TransitionDefinition, type DelegationBindingDefinition, + type WorkContract, } from "@operatorstack/boatstack"; const bindingPrefix = "software-delivery/"; @@ -76,6 +77,22 @@ export interface TrustedStep { priority: number; } +/** Admits a completed planning package into the delivery lifecycle. */ +export const planningPackageAdmit: TrustedStep = { + id: "planning.package.admit", + priority: 43, +}; +/** Records approval of the exact admitted planning package. */ +export const planningPackageApprove: TrustedStep = { + id: "planning.package.approve", + priority: 44, +}; +/** Promotes an approved planning package into the active delivery plan. */ +export const planningPackagePromote: TrustedStep = { + id: "planning.package.promote", + priority: 45, +}; + /** * Repository-owned strengthening applied to a trusted transition. * @@ -85,6 +102,8 @@ export interface TrustedStep { */ export interface TrustedTransitionOptions { requires?: { authorities?: string[] }; + /** Foreground work that must complete before this transition is admitted. */ + work?: WorkContract; } /** @@ -154,6 +173,7 @@ export function trustedTransition( target: always, priority: step.priority, ...(options.requires ? { requires: options.requires } : {}), + ...(options.work ? { work: options.work.id } : {}), }); } diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index ee3f636..24c94f1 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -11,7 +11,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 = 2 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 3 as const; /** * A declarative condition over runtime state facts. @@ -100,6 +100,49 @@ export interface OperatorDefinition { description?: string; } +/** + * A repository asset resolved and fingerprinted by the trusted compiler. + * + * Flow source supplies only {@link path}. Compiled artifacts also contain the + * exact UTF-8 bytes and SHA-256 used by the runtime; live runs never reread a + * mutable instruction or schema file. + */ +export interface WorkAssetDefinition { + path: string; + sha256?: string; + content?: string; +} + +/** Binds one foreground-work input to a declared entry input. */ +export interface WorkInputDefinition { + id: string; + entry_input: string; +} + +/** Declares one bounded staged output of foreground work. */ +export interface WorkArtifactDefinition { + id: string; + path: string; + media_type: string; + required: boolean; + max_bytes?: number; + schema?: WorkAssetDefinition; +} + +/** + * A bounded foreground-work requirement used by one or more transitions. + * + * This contract contains data only. It cannot grant authority, execute code, + * install a handler, or advance Flow state independently. + */ +export interface WorkContract { + id: string; + instructions: WorkAssetDefinition; + inputs: WorkInputDefinition[]; + outputs: WorkArtifactDefinition[]; + description?: string; +} + /** * Selects an operator when its guard is true and records its intended target * relation. Higher priority controls deterministic selection among admissible @@ -112,6 +155,7 @@ export interface TransitionDefinition { target: Predicate; priority: number; requires?: { authorities?: string[] }; + work?: string; description?: string; } @@ -167,6 +211,7 @@ export interface FlowDefinition { }; facets: FacetDefinition[]; evidence?: EvidenceDefinition[]; + work?: WorkContract[]; operators: OperatorDefinition[]; transitions: TransitionDefinition[]; targets: TargetDefinition[]; @@ -181,6 +226,7 @@ export interface ControlProgramIR { declarations: NonNullable; facets: FacetDefinition[]; evidence: EvidenceDefinition[]; + work: WorkContract[]; operators: OperatorDefinition[]; transitions: TransitionDefinition[]; targets: TargetDefinition[]; @@ -221,6 +267,7 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { declarations: definition.declarations ?? {}, facets: definition.facets, evidence: definition.evidence ?? [], + work: definition.work ?? [], operators: definition.operators, transitions: definition.transitions, targets: definition.targets, @@ -229,6 +276,39 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { }; } +/** Declares a repository-owned UTF-8 instruction asset. */ +export function instructionAsset(path: string): WorkAssetDefinition { + return { path }; +} + +/** Declares a repository-owned strict JSON Schema asset. */ +export function schemaAsset(path: string): WorkAssetDefinition { + return { path }; +} + +/** Binds a foreground-work input to one entry input ID. */ +export function entryInput(id: string): WorkInputDefinition { + return { id, entry_input: id }; +} + +/** Declares one bounded foreground-work output artifact. */ +export function workArtifact( + definition: WorkArtifactDefinition, +): WorkArtifactDefinition { + return { ...definition }; +} + +/** + * Declares one foreground-work contract inside a Flow. + * + * {@link defineFlow} remains the complete controller. This helper only defines + * bounded work that a selected transition may require before its trusted + * operator can be admitted. + */ +export function foregroundWork(definition: WorkContract): WorkContract { + return { ...definition }; +} + /** * Declares a typed state facet. * diff --git a/release-notes/2026-08-15-atomic-control-bundles.md b/release-notes/2026-08-15-atomic-control-bundles.md new file mode 100644 index 0000000..b0ab6bf --- /dev/null +++ b/release-notes/2026-08-15-atomic-control-bundles.md @@ -0,0 +1,11 @@ +### Bind execution contexts to one repository control bundle + +Boatstack now fingerprints the runtime pin, project configuration, host skills, +Flow sources, locks, assets, artifacts, and generated entry skills as one +control bundle. Runtime changes and worktree transfers verify the complete +source and target bundles before committing state or lineage. + +Workspace creation now resolves its base to one commit and checks out that +exact revision. Generated entry skills also use the repository-pinned release +tag, so changing only the Boatstack build version no longer changes skill +bytes. diff --git a/release-notes/2026-08-15-foreground-work.md b/release-notes/2026-08-15-foreground-work.md new file mode 100644 index 0000000..b00594d --- /dev/null +++ b/release-notes/2026-08-15-foreground-work.md @@ -0,0 +1,24 @@ +### Let repository Flows require bounded foreground work + +Flows can now declare exact instruction assets, entry inputs, and staged output +contracts for human or agent work. Boatstack suspends and resumes the same run, +validates the result as evidence, and still admits effects only through trusted +operators. Input fingerprints and request-specific staging prevent changed +inputs from reusing stale work. The software-delivery adapter also provides +optional planning-package admission, approval, and promotion operations. + +The runtime performs a one-step, transactional schema-4 state upgrade and +verifies every declared planning-package output before approval. Trusted +software-delivery lowering also rejects work inputs that any reachable entry +cannot bind. + +Fresh delegated runs now bootstrap verified runtime and configuration state +before deriving repository-policy authority. An exact autonomy delegation may +perform that local initialization, while transitions requiring repository +authority remain unavailable until verified configuration evidence exists. + +Published-PR entries now stop until intended delivery changes are committed, +bind the preview and push to that exact commit, and derive short-lived GitHub +provider capability through each trusted transition's declared fingerprint +binding, including correction and recovery. Caller-provided provider receipts +are rejected.