From f32e4a3d1dd3ff2dd120bcf7c55746808bdaa318 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 11:59:22 +0100 Subject: [PATCH 1/6] feat: add repository-owned run delegation --- .../boatstack-update/agents/openai.yaml | 2 +- .gitattributes | 6 +- .github/tests/test_detached_supervision.py | 6 +- .github/tests/test_repository_contract.py | 14 +- README.md | 6 +- .../boatstack-helper/delegation_command.go | 255 ++++++++++++++++++ .../boatstack-helper/delegation_runtime.go | 149 ++++++++++ .../cmd/boatstack-helper/flow_command.go | 13 +- .../cmd/boatstack-helper/flow_runtime.go | 60 ++++- .../cmd/boatstack-helper/flow_runtime_test.go | 126 ++++++++- boatstack/cmd/boatstack-helper/main.go | 53 +++- boatstack/controlprogram/artifact.go | 12 +- boatstack/controlprogram/canonical.go | 82 +++++- boatstack/controlprogram/canonical_test.go | 95 ++++++- .../frontend_conformance_test.go | 2 +- boatstack/controlprogram/ir.go | 105 +++++--- boatstack/delivery/program_manifest.go | 2 + boatstack/flow/softwaredelivery/bindings.go | 58 +++- boatstack/flow/softwaredelivery/definition.go | 18 ++ .../flow/softwaredelivery/definition_test.go | 56 +++- boatstack/flow/softwaredelivery/skills.go | 19 +- boatstack/flow/standard/completeness_test.go | 2 +- boatstack/flow/standard/historical_test.go | 4 +- boatstack/flow/standard/transitions.json | 4 + .../softwaredelivery/catalog/transition.go | 4 + .../softwaredelivery/delegation/record.go | 117 ++++++++ .../delegation/record_test.go | 43 +++ .../softwaredelivery/effects/artifacts.go | 2 +- .../effects/command_boundary.go | 2 +- .../effects/delegation_record.go | 44 +++ .../effects/exclusive_lock.go | 41 +++ .../softwaredelivery/effects/host_skills.go | 2 +- .../effects/integration_test.go | 12 +- .../internal/softwaredelivery/effects/io.go | 4 +- .../softwaredelivery/effects/receipts.go | 34 +++ .../internal/softwaredelivery/model/state.go | 2 +- .../softwaredelivery/plant/observer.go | 4 +- .../softwaredelivery/plant/resolver.go | 12 +- .../softwaredelivery/protocol/authority.go | 2 +- .../softwaredelivery/protocol/config.go | 10 +- .../softwaredelivery/protocol/receipt.go | 110 +++++--- .../surfaces/artifacts_external_test.go | 10 +- .../softwaredelivery/surfaces/locus_render.go | 6 +- .../softwaredelivery/surfaces/protocol.go | 54 ++-- .../softwaredelivery/surfaces/render_test.go | 2 +- boatstack/references/artifacts.md | 2 +- boatstack/references/config-schema.md | 2 +- boatstack/references/failure-moves.md | 4 +- boatstack/references/workflow.md | 2 +- boatstack/sdk/sdk_test.go | 2 +- .../incident-response.flow.ts | 5 +- .../incident-response.raw.json | 6 +- .../product-delivery-a.flow.ts | 8 +- .../product-delivery-b.flow.ts | 4 +- .../product-delivery-c.flow.ts | 2 +- .../historical.json | 0 ...-report.md => boatstack-closure-report.md} | 20 +- ...stack-v2-kernel.md => boatstack-kernel.md} | 56 ++-- ...ess.json => boatstack-locus-liveness.json} | 4 +- ...afety.json => boatstack-locus-safety.json} | 4 +- ...log.md => boatstack-transition-catalog.md} | 60 ++--- ...g.mmd => boatstack-transition-catalog.mmd} | 0 .../boatstack-v1-authority-inventory.md | 2 +- docs/configuration.md | 6 +- docs/control-program-ir.md | 19 +- docs/generated-files.md | 27 +- docs/getting-started.md | 4 +- docs/public-claims.json | 20 +- docs/public-surface.md | 4 +- docs/safety.md | 2 +- docs/troubleshooting.md | 2 +- install.ps1 | 8 +- install.sh | 6 +- .../boatstack-software-delivery/src/index.ts | 23 +- packages/boatstack/src/index.ts | 28 +- ...6-08-13-repository-owned-run-delegation.md | 2 + 76 files changed, 1661 insertions(+), 339 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/delegation_command.go create mode 100644 boatstack/cmd/boatstack-helper/delegation_runtime.go create mode 100644 boatstack/internal/softwaredelivery/delegation/record.go create mode 100644 boatstack/internal/softwaredelivery/delegation/record_test.go create mode 100644 boatstack/internal/softwaredelivery/effects/delegation_record.go create mode 100644 boatstack/internal/softwaredelivery/effects/exclusive_lock.go rename boatstack/testdata/{v2-scenarios => scenarios}/historical.json (100%) rename docs/architecture/{boatstack-v2-closure-report.md => boatstack-closure-report.md} (88%) rename docs/architecture/{boatstack-v2-kernel.md => boatstack-kernel.md} (97%) rename docs/architecture/{boatstack-v2-locus-liveness.json => boatstack-locus-liveness.json} (99%) rename docs/architecture/{boatstack-v2-locus-safety.json => boatstack-locus-safety.json} (99%) rename docs/architecture/{boatstack-v2-transition-catalog.md => boatstack-transition-catalog.md} (86%) rename docs/architecture/{boatstack-v2-transition-catalog.mmd => boatstack-transition-catalog.mmd} (100%) create mode 100644 release-notes/2026-08-13-repository-owned-run-delegation.md diff --git a/.agents/skills/boatstack-update/agents/openai.yaml b/.agents/skills/boatstack-update/agents/openai.yaml index 496a003..451e8dd 100644 --- a/.agents/skills/boatstack-update/agents/openai.yaml +++ b/.agents/skills/boatstack-update/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Boatstack Update" short_description: "Apply a checksum-verified Boatstack update." - default_prompt: "Use $boatstack-update to follow the authority-preserving Boatstack V2 driver." + default_prompt: "Use $boatstack-update to follow the authority-preserving Boatstack driver." policy: allow_implicit_invocation: false diff --git a/.gitattributes b/.gitattributes index bb66a06..fe6e033 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -docs/architecture/boatstack-v2-*.md text eol=lf -docs/architecture/boatstack-v2-*.mmd text eol=lf -docs/architecture/boatstack-v2-*.json text eol=lf +docs/architecture/boatstack-*.md text eol=lf +docs/architecture/boatstack-*.mmd text eol=lf +docs/architecture/boatstack-*.json text eol=lf docs/architecture/boatstack-standard-flow.mmd text eol=lf diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index de89958..b21fc30 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -1,4 +1,4 @@ -"""End-to-end tests for the V2 detached identity and guard boundary.""" +"""End-to-end tests for the detached identity and guard boundary.""" from __future__ import annotations @@ -136,7 +136,7 @@ def test_attach_and_detach_transfer_only_controller_authority(self) -> None: self.assertEqual(self.porcelain(), before) self.assertEqual(attached["snapshot"]["invocation"]["topology"], "detached") self.assertEqual(attached["receipt"]["transition_id"], "repository.attach") - bindings = list((self.state_root / "boatstack" / "v2").rglob("binding.json")) + bindings = list((self.state_root / "boatstack").rglob("binding.json")) self.assertEqual(len(bindings), 1) binding = json.loads(bindings[0].read_text()) self.assertEqual(binding["topology"], "detached") @@ -166,7 +166,7 @@ def test_two_clones_never_share_a_detached_binding_alias(self) -> None: two = self.attach(clone) self.assertEqual(one["snapshot"]["invocation"]["repository_id"], two["snapshot"]["invocation"]["repository_id"]) self.assertNotEqual(one["snapshot"]["invocation"]["git_common_id"], two["snapshot"]["invocation"]["git_common_id"]) - bindings = list((self.state_root / "boatstack" / "v2").rglob("binding.json")) + bindings = list((self.state_root / "boatstack").rglob("binding.json")) self.assertEqual(len(bindings), 2) def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> None: diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 2b9a149..9b8a200 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -677,9 +677,9 @@ def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> None: attributes = (REPO / ".gitattributes").read_text().splitlines() for pattern in ( - "docs/architecture/boatstack-v2-*.md text eol=lf", - "docs/architecture/boatstack-v2-*.mmd text eol=lf", - "docs/architecture/boatstack-v2-*.json text eol=lf", + "docs/architecture/boatstack-*.md text eol=lf", + "docs/architecture/boatstack-*.mmd text eol=lf", + "docs/architecture/boatstack-*.json text eol=lf", "docs/architecture/boatstack-standard-flow.mmd text eol=lf", ): self.assertIn(pattern, attributes) @@ -705,11 +705,11 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> ).stdout self.assertEqual( markdown, - (REPO / "docs" / "architecture" / "boatstack-v2-transition-catalog.md").read_text(), + (REPO / "docs" / "architecture" / "boatstack-transition-catalog.md").read_text(), ) self.assertEqual( mermaid, - (REPO / "docs" / "architecture" / "boatstack-v2-transition-catalog.mmd").read_text(), + (REPO / "docs" / "architecture" / "boatstack-transition-catalog.mmd").read_text(), ) self.assertEqual( standard_flow, @@ -717,8 +717,8 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> ) self.assertEqual(standard_flow.count("
"), 30) for name, rendered in ( - ("boatstack-v2-locus-safety.json", locus_safety), - ("boatstack-v2-locus-liveness.json", locus_liveness), + ("boatstack-locus-safety.json", locus_safety), + ("boatstack-locus-liveness.json", locus_liveness), ): checked = (REPO / "docs" / "architecture" / name).read_text() self.assertEqual(rendered, checked) diff --git a/README.md b/README.md index 3376092..6db0b11 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ coding-agent concepts live in this domain layer, not in the general kernel. Boatstack currently compiles 63 registered transitions into one executable control graph. The complete list is generated from the registry in the -[transition catalog](docs/architecture/boatstack-v2-transition-catalog.md). +[transition catalog](docs/architecture/boatstack-transition-catalog.md). ### Kernel @@ -275,9 +275,9 @@ boatstack/sdk/ public Go protocol client docs/architecture/ executable contracts and generated evidence ``` -Start with the [architecture specification](docs/architecture/boatstack-v2-kernel.md) +Start with the [architecture specification](docs/architecture/boatstack-kernel.md) for the full internal model. The generated [StandardFlow graph](docs/architecture/boatstack-standard-flow.mmd) -and [Mermaid catalog](docs/architecture/boatstack-v2-transition-catalog.mmd) +and [Mermaid catalog](docs/architecture/boatstack-transition-catalog.mmd) come from the same executable registry used at runtime. ## Develop diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go new file mode 100644 index 0000000..e805586 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" + "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" +) + +func runFlowAuthorize(arguments []string) error { + flags := flag.NewFlagSet("flow authorize", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + options := commandOptions{repository: ".", host: "cli"} + requestFingerprint := "" + expiresIn := time.Duration(0) + flags.StringVar(&options.repository, "repo", options.repository, "repository or worktree") + flags.StringVar(&options.programID, "flow", "", "repository Control Program identity") + flags.StringVar(&options.entryID, "entry", "", "named Flow entry") + flags.StringVar(&options.runID, "run-id", "", "exact run identity") + flags.StringVar(&requestFingerprint, "request-fingerprint", "", "exact delegation request fingerprint") + flags.StringVar(&options.humanActor, "human", "", "authorizing human actor") + flags.StringVar(&options.host, "host", options.host, "trusted host identity") + flags.DurationVar(&expiresIn, "expires-in", 0, "optional delegation lifetime") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 || options.runID == "" || requestFingerprint == "" || options.humanActor == "" { + return fmt.Errorf("flow authorize requires --flow, --entry, --run-id, --request-fingerprint, and --human") + } + bound, err := bindFlowEntry(context.Background(), options) + if err != nil { + return err + } + if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID { + return fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request") + } + resolver, err := plant.NewResolver("") + if err != nil { + return err + } + invocation, err := resolver.ResolveInvocation(context.Background(), bound.repository, bound.host, "flow-authorize") + if err != nil { + return err + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + return err + } + lockPath, err := delegation.LockPath(layout.LockRoot, bound.runID) + if err != nil { + return err + } + lock, err := effects.AcquireExclusivePath(context.Background(), lockPath) + if err != nil { + return err + } + defer lock.Release() + recordPath, err := delegation.Path(layout.FlowRoot, bound.runID) + if err != nil { + return err + } + if existing, loadErr := delegation.Load(recordPath); loadErr == nil { + if existing.RequestFingerprint == requestFingerprint && existing.Actor == options.humanActor && existing.Status == "active" { + return printDelegationRecord(existing) + } + return fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") + } else if !os.IsNotExist(loadErr) { + return loadErr + } + now := time.Now().UTC() + receiptDigest := sha256.Sum256([]byte(requestFingerprint + "\x00" + options.humanActor)) + record := delegation.Record{ + Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, + Request: bound.delegationRequest, RequestFingerprint: requestFingerprint, + ReceiptID: "authorization-" + hex.EncodeToString(receiptDigest[:12]), Actor: options.humanActor, + AuthorizedAt: now, Revision: 1, Status: "active", + } + if expiresIn < 0 { + return fmt.Errorf("flow authorize --expires-in cannot be negative") + } + if expiresIn > 0 { + record.ExpiresAt = now.Add(expiresIn) + } + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + return err + } + return printDelegationRecord(record) +} + +func runFlowRevoke(arguments []string) error { + flags := flag.NewFlagSet("flow revoke", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + repository, runID, actor, host := ".", "", "", "cli" + flags.StringVar(&repository, "repo", repository, "repository or worktree") + flags.StringVar(&runID, "run-id", "", "exact run identity") + flags.StringVar(&actor, "human", "", "revoking human actor") + flags.StringVar(&host, "host", host, "trusted host identity") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 || runID == "" || actor == "" { + return fmt.Errorf("flow revoke requires --run-id and --human") + } + resolver, err := plant.NewResolver("") + if err != nil { + return err + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, host, "flow-revoke") + if err != nil { + return err + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + return err + } + lockPath, err := delegation.LockPath(layout.LockRoot, runID) + if err != nil { + return err + } + lock, err := effects.AcquireExclusivePath(context.Background(), lockPath) + if err != nil { + return err + } + defer lock.Release() + recordPath, err := delegation.Path(layout.FlowRoot, runID) + if err != nil { + return err + } + record, err := delegation.Load(recordPath) + if err != nil { + return err + } + if record.Actor != actor { + return fmt.Errorf("DELEGATION_CONFLICT: revocation actor does not match the authorizing actor") + } + if record.Status == "revoked" { + return printDelegationRecord(record) + } + if record.Status != "active" { + return fmt.Errorf("DELEGATION_CONFLICT: delegation is %s", record.Status) + } + record.Status, record.Revision, record.RevokedAt = "revoked", record.Revision+1, time.Now().UTC() + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + return err + } + return printDelegationRecord(record) +} + +func printDelegationRecord(record delegation.Record) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(record) +} + +func runFlowContinuation(arguments []string) error { + options, err := parseOptions("flow run", arguments, "", nil) + if err != nil { + return err + } + if options.programID == "" || options.entryID == "" { + return fmt.Errorf("flow run requires --flow and --entry") + } + var response surfaces.Response + for step := 0; step < 256; step++ { + response, err = executeContinuationStep(context.Background(), options) + if err != nil { + return err + } + if response.RunID != "" { + options.runID = response.RunID + } + if response.Objective.ID != "" { + options.objectiveID = response.Objective.ID + options.targetID = string(response.Objective.TargetID) + options.trustedObjectiveClass = string(response.Objective.TrustedObjectiveClass()) + options.deliveryID = response.Objective.DeliveryID + } + if response.Delegation != nil || response.Prescription == nil || response.Receipt == nil { + return renderResponse(response, options.format) + } + } + return fmt.Errorf("FLOW_RUN_SUSPENDED: continuation step limit reached") +} + +func executeContinuationStep(ctx context.Context, options commandOptions) (surfaces.Response, error) { + bound, err := bindFlowEntry(ctx, options) + if err != nil { + return surfaces.Response{}, err + } + resolveRequest, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + return surfaces.Response{}, err + } + _, delegationResponse, err := prepareDelegation(ctx, &resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + if delegationResponse != nil { + return *delegationResponse, nil + } + kernel, err := standardKernel(ctx, resolveRequest) + if err != nil { + return surfaces.Response{}, err + } + resolved, err := kernel.Handle(ctx, resolveRequest) + if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved); settleErr != nil && err == nil { + err = settleErr + } + if err != nil || resolved.Prescription == nil { + return resolved, err + } + applyRequest := resolveRequest + applyRequest.Operation = surfaces.OperationApply + applyRequest.TransitionID = resolved.Prescription.TransitionID + applyRequest.Prescription = *resolved.Prescription + if resolved.Admission != nil { + applyRequest.IdempotencyKey = resolved.Admission.IdempotencyKey + } + delegationLock, delegationResponse, err := prepareDelegation(ctx, &applyRequest) + if err != nil { + return surfaces.Response{}, err + } + if delegationLock != nil { + defer delegationLock.Release() + } + if delegationResponse != nil { + return *delegationResponse, nil + } + lease, err := acquireFlowExecutionLease(applyRequest) + if err != nil { + return surfaces.Response{}, err + } + defer lease.Release() + applied, err := kernel.Handle(ctx, applyRequest) + if err != nil { + return applied, err + } + // Mark the response as a completed internal continuation step. The next + // iteration resolves again from the committed receipt and durable state. + if applied.Prescription == nil { + applied.Prescription = &protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: "continued", TransitionID: catalog.TransitionID(applyRequest.TransitionID)} + } + return applied, nil +} diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go new file mode 100644 index 0000000..92ed3bd --- /dev/null +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { + if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 { + return nil, nil, nil + } + resolver, err := plant.NewResolver("") + if err != nil { + return nil, nil, err + } + invocation, err := resolver.ResolveInvocation(ctx, request.Repository, request.Host, request.CorrelationID) + if err != nil { + return nil, nil, err + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return nil, nil, err + } + var lock ports.Lock + if request.Operation == surfaces.OperationApply || request.Operation == surfaces.OperationRecover { + lockPath, lockErr := delegation.LockPath(layout.LockRoot, request.FlowID) + if lockErr != nil { + return nil, nil, lockErr + } + lock, err = effects.AcquireExclusivePath(ctx, lockPath) + if err != nil { + return nil, nil, err + } + } + releaseOnError := func() { + if lock != nil { + _ = lock.Release() + } + } + recordPath, err := delegation.Path(layout.FlowRoot, request.FlowID) + if err != nil { + releaseOnError() + return nil, nil, err + } + record, err := delegation.Load(recordPath) + if os.IsNotExist(err) { + releaseOnError() + return nil, &surfaces.Response{ + SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Objective: request.Objective, + Delegation: &surfaces.DelegationRequired{Code: "DELEGATION_REQUIRED", RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, Authorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run"}, + }, nil + } + if err != nil { + 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 { + releaseOnError() + return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") + } + initial := invocation + initial.WorktreeID, initial.Ref = record.Request.InitialWorktreeID, record.Request.InitialRef + authorizedContext, lineageErr := effects.InvocationAuthorizedByFlow(layout, request.FlowID, initial, invocation) + if lineageErr != nil { + releaseOnError() + return nil, nil, fmt.Errorf("DELEGATION_LINEAGE_INVALID: %w", lineageErr) + } + if !authorizedContext { + releaseOnError() + return nil, nil, fmt.Errorf("DELEGATION_CONTEXT_UNAUTHORIZED: current worktree is not in the verified run lineage") + } + if record.Status != "active" { + releaseOnError() + return nil, nil, fmt.Errorf("DELEGATION_REVOKED: run authorization is %s", record.Status) + } + if !record.ExpiresAt.IsZero() && !time.Now().UTC().Before(record.ExpiresAt) { + releaseOnError() + return nil, nil, fmt.Errorf("DELEGATION_EXPIRED: run authorization expired") + } + filtered := request.Authority.Receipts[:0] + for _, receipt := range request.Authority.Receipts { + if len(receipt.ID) < len("delegation-") || receipt.ID[:len("delegation-")] != "delegation-" { + filtered = append(filtered, receipt) + } + } + request.Authority.Receipts = filtered + for _, authority := range request.DelegatedAuthorities { + receiptDigest := sha256.Sum256([]byte(record.ReceiptID + "\x00" + string(authority))) + request.Authority.Receipts = append(request.Authority.Receipts, protocol.AuthorityReceipt{ + ID: "delegation-" + hex.EncodeToString(receiptDigest[:8]), Class: authority, + Subject: record.Actor, Fingerprint: record.RequestFingerprint, IssuedAt: record.AuthorizedAt, ExpiresAt: record.ExpiresAt, + }) + } + return lock, nil, nil +} + +func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response) error { + if len(request.DelegatedAuthorities) == 0 || response.Decision == nil || response.Decision.Kind != supervisor.DecisionTerminal { + return nil + } + resolver, err := plant.NewResolver("") + if err != nil { + return err + } + invocation, err := resolver.ResolveInvocation(ctx, request.Repository, request.Host, request.CorrelationID) + if err != nil { + return err + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return err + } + lockPath, err := delegation.LockPath(layout.LockRoot, request.FlowID) + if err != nil { + return err + } + lock, err := effects.AcquireExclusivePath(ctx, lockPath) + if err != nil { + return err + } + defer lock.Release() + recordPath, err := delegation.Path(layout.FlowRoot, request.FlowID) + if err != nil { + return err + } + record, err := delegation.Load(recordPath) + if err != nil { + return err + } + if record.Status != "active" { + return nil + } + record.Status, record.Revision = "completed", record.Revision+1 + record.EndedAt, record.EndReason = time.Now().UTC(), "target-met" + return effects.StoreDelegationRecord(recordPath, record) +} diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index fb63ff0..6f2ddeb 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/v1.compiler.1" +const flowCompilerVersion = "control-program.compiler.1" type flowCommandOptions struct { repository string @@ -33,9 +33,18 @@ 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" { + return runFlowAuthorize(arguments[1:]) + } + if action == "revoke" { + return runFlowRevoke(arguments[1:]) + } + if action == "run" { + return runFlowContinuation(arguments[1:]) + } flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) flags.SetOutput(os.Stderr) options := flowCommandOptions{} diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 40b5254..bbc9f7e 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/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" @@ -116,6 +117,58 @@ 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 entry.Delegation != nil { + contextResolver, resolverErr := plant.NewResolver("") + if resolverErr != nil { + return commandOptions{}, resolverErr + } + host := options.host + if host == "" { + host = "cli" + } + invocation, invocationErr := contextResolver.ResolveInvocation(ctx, repository, host, "flow-delegation-request") + if invocationErr != nil { + return commandOptions{}, invocationErr + } + description := entry.Description + if description == "" { + description = fmt.Sprintf("Run %s/%s to %s", options.programID, options.entryID, objective.TargetID) + } + delegationRequest := delegation.Request{ + RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, + 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...), + Description: description, + } + layout, _, layoutErr := contextResolver.ResolveLayout(ctx, invocation) + if layoutErr != nil { + return commandOptions{}, layoutErr + } + recordPath, pathErr := delegation.Path(layout.FlowRoot, options.runID) + if pathErr != nil { + return commandOptions{}, pathErr + } + 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") + } + delegationRequest = bound + } else if !os.IsNotExist(loadErr) { + return commandOptions{}, loadErr + } + fingerprint, fingerprintErr := delegationRequest.Fingerprint() + if fingerprintErr != nil { + return commandOptions{}, fingerprintErr + } + options.delegationBindingFingerprint = entry.Delegation.Fingerprint + options.delegationRequestFingerprint = fingerprint + options.delegationAuthorities = append(options.delegationAuthorities[:0], entry.Delegation.Authorities...) + options.delegationDescription = description + options.delegationRequest = delegationRequest + } parameters, err := parseParameters(options.parameters) if err != nil { return commandOptions{}, err @@ -168,7 +221,7 @@ func bindActiveFlowContext(ctx context.Context, repository string, options comma if err != nil { common, commonErr := flowRepositoryIdentity(repository) if commonErr == nil { - if _, stateErr := os.Stat(filepath.Join(common, "boatstack", "v2")); os.IsNotExist(stateErr) { + if _, stateErr := os.Stat(filepath.Join(common, "boatstack")); os.IsNotExist(stateErr) { return options, nil } } @@ -273,6 +326,9 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R request.Objective.TrustedClass = model.TargetID(bound.trustedObjectiveClass) request.Objective.DeliveryID = bound.deliveryID request.Parameters = parameters + request.DelegationBindingFingerprint = bound.delegationBindingFingerprint + request.DelegationRequestFingerprint = bound.delegationRequestFingerprint + request.DelegatedAuthorities = delegationClasses(bound.delegationAuthorities) return request, nil } @@ -280,7 +336,7 @@ func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObject if options.activeFlowBound && entryObjective.TrustedClass == model.ObjectiveAbandoned { return "", options.deliveryID, nil } - if options.runID == "" && options.deliveryID == "" { + if options.deliveryID == "" { return resolvePlanInput(repository, entry) } if !flowSegment.MatchString(options.deliveryID) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index f5267b1..19e6853 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -5,15 +5,21 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "runtime" "strings" "testing" + "time" "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/delegation" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -34,6 +40,14 @@ func flowRepository(t *testing.T) string { return repository } +func runFlowGit(t *testing.T, repository string, arguments ...string) { + t.Helper() + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } +} + func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory string) { t.Helper() if err := os.Remove(filepath.Join(repository, ".git")); err != nil { @@ -84,9 +98,9 @@ func productDeliveryDocument(programID string) controlprogram.Document { truth := true config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) return controlprogram.Document{ - SchemaVersion: controlprogram.SchemaVersion, - Program: controlprogram.Program{ID: programID, Version: "1"}, - Declarations: controlprogram.Declarations{InputResolvers: []string{"software-delivery.plan-inbox"}}, + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: programID, Version: "1"}, + Declarations: controlprogram.Declarations{InputResolvers: []string{"software-delivery.plan-inbox"}}, Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, @@ -1013,7 +1027,7 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { writeFixture(t, repository, retainedPath, retained) writeFixture(t, repository, retiredPath, retired) artifact := controlprogram.Artifact{ - SchemaVersion: controlprogram.ArtifactSchemaVersion, CompilerVersion: flowCompilerVersion, + Schema: controlprogram.ArtifactSchemaName, SchemaRevision: controlprogram.ArtifactSchemaRevision, CompilerVersion: flowCompilerVersion, SourcePath: ".boatstack/flows/program.flow.ts", SourceSHA256: strings.Repeat("a", 64), DependencyLockPath: "package-lock.json", DependencyLockSHA256: strings.Repeat("b", 64), ProgramFingerprint: strings.Repeat("c", 64), @@ -1061,3 +1075,107 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { t.Fatalf("interrupted retirement was not retryable: %v, %v", paths, err) } } + +func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) { + // control-law: repository-declaration-cannot-self-grant-and-apply-reloads-revocation-before-effects + repository := t.TempDir() + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.email", "test@example.com") + runFlowGit(t, repository, "config", "user.name", "Test User") + document := productDeliveryDocument("product-delivery") + document.Entries[0].Delegation = &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"} + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, dependencyLock := []byte("flow source"), []byte("lock") + writeFixture(t, repository, sourcePath, source) + writeFixture(t, repository, lockPath, dependencyLock) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.md", []byte("# Delivery\n")) + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, dependencyLock) + writeFixture(t, repository, "README.md", []byte("fixture\n")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + request, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + t.Fatal(err) + } + lock, suspension, err := prepareDelegation(context.Background(), &request) + if err != nil || lock != nil || suspension == nil || suspension.Delegation == nil || suspension.Delegation.Code != "DELEGATION_REQUIRED" || suspension.Delegation.RequestFingerprint != bound.delegationRequestFingerprint { + t.Fatalf("delegation suspension = lock=%v response=%#v err=%v", lock, suspension, err) + } + + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "authorize-test") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + recordPath, err := delegation.Path(layout.FlowRoot, bound.runID) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + record := delegation.Record{Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: bound.delegationRequest, RequestFingerprint: bound.delegationRequestFingerprint, ReceiptID: "authorization-test", Actor: "human@example.com", AuthorizedAt: now, Revision: 1, Status: "active"} + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + t.Fatal(err) + } + lock, suspension, err = prepareDelegation(context.Background(), &request) + if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("authorized resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) + } + 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) + } + 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"}) + if err != nil { + t.Fatal(err) + } + refRequest, err := buildRequest(surfaces.OperationResolve, refBound) + if err != nil { + t.Fatal(err) + } + refLock, refSuspension, refErr := prepareDelegation(context.Background(), &refRequest) + if refLock != nil || refSuspension != nil || refErr == nil || !strings.Contains(refErr.Error(), "DELEGATION_CONTEXT_UNAUTHORIZED") { + t.Fatalf("unauthorized ref = lock=%v response=%#v err=%v", refLock, refSuspension, refErr) + } + runFlowGit(t, repository, "checkout", "-q", strings.TrimPrefix(record.Request.InitialRef, "refs/heads/")) + + request.Operation = surfaces.OperationApply + lock, suspension, err = prepareDelegation(context.Background(), &request) + if err != nil || lock == nil || suspension != nil { + t.Fatalf("authorized apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) + } + if err := lock.Release(); err != nil { + t.Fatal(err) + } + record.Status, record.Revision, record.RevokedAt = "revoked", 2, time.Now().UTC() + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + t.Fatal(err) + } + lock, suspension, err = prepareDelegation(context.Background(), &request) + if lock != nil || suspension != nil || err == nil || !strings.Contains(err.Error(), "DELEGATION_REVOKED") { + t.Fatalf("revoked apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) + } +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 5d96583..8cba925 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -23,6 +23,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" 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/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" @@ -69,6 +70,11 @@ type commandOptions struct { follow bool host string command string + delegationBindingFingerprint string + delegationRequestFingerprint string + delegationAuthorities stringList + delegationDescription string + delegationRequest delegation.Request } func main() { @@ -120,6 +126,16 @@ func run(arguments []string) error { if err != nil { return err } + delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) + if err != nil { + return err + } + if delegationLock != nil { + defer delegationLock.Release() + } + if delegationResponse != nil { + return renderResponse(*delegationResponse, options.format) + } lease, err := acquireFlowExecutionLease(request) if err != nil { return err @@ -152,6 +168,9 @@ func run(arguments []string) error { request.Prescription = *resolved.Prescription } response, handleErr := kernel.Handle(context.Background(), request) + if settleErr := settleDelegationAtTarget(context.Background(), request, response); settleErr != nil && handleErr == nil { + handleErr = settleErr + } if command == "events" && options.follow { if options.format != "jsonl" { return fmt.Errorf("events --follow requires --format jsonl") @@ -173,16 +192,28 @@ func runRPC() error { decoder.DisallowUnknownFields() var request surfaces.Request if err := decoder.Decode(&request); err != nil { - return fmt.Errorf("decode V2 RPC request: %w", err) + return fmt.Errorf("decode Boatstack RPC request: %w", err) } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("V2 RPC request contains trailing JSON") + return fmt.Errorf("Boatstack RPC request contains trailing JSON") } request, err := bindRPCFlowEntry(context.Background(), request) if err != nil { return err } + delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) + if err != nil { + return err + } + if delegationLock != nil { + defer delegationLock.Release() + } + if delegationResponse != nil { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(delegationResponse) + } lease, err := acquireFlowExecutionLease(request) if err != nil { return err @@ -193,6 +224,9 @@ func runRPC() error { return err } response, handleErr := kernel.Handle(context.Background(), request) + if settleErr := settleDelegationAtTarget(context.Background(), request, response); settleErr != nil && handleErr == nil { + handleErr = settleErr + } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") if err := encoder.Encode(response); err != nil { @@ -296,7 +330,7 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.Var(&options.effectiveCapabilities, "effective-capability", "effective capability from resolution (repeatable)") flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay") flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor") - flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration") + flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from Boatstack project configuration") flags.BoolVar(&options.acceptProgramChange, "accept-program-change", false, "explicitly accept the exact prior-to-candidate control-program delta during update") flags.Var(&options.parameters, "param", "transition parameter name=value (repeatable)") flags.Var(&options.authorityReceipts, "authority-receipt", "authority receipt JSON path (repeatable)") @@ -406,7 +440,7 @@ func populateInitParameters(options *commandOptions) error { return err } if _, ok := parameters.Get("config_path"); !ok { - return fmt.Errorf("init requires --param config_path=") + return fmt.Errorf("init requires --param config_path=") } configPath, _ := parameters.Get("config_path") configRaw, err := os.ReadFile(configPath) @@ -535,9 +569,20 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface AuthorityFingerprint: options.authorityFingerprint, }, RequiredCapabilities: requiredCapabilities, EffectiveCapabilities: effectiveCapabilities}, RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, + DelegationBindingFingerprint: options.delegationBindingFingerprint, + DelegationRequestFingerprint: options.delegationRequestFingerprint, + DelegatedAuthorities: delegationClasses(options.delegationAuthorities), }, nil } +func delegationClasses(values []string) []catalog.AuthorityClass { + result := make([]catalog.AuthorityClass, len(values)) + for index, value := range values { + result[index] = catalog.AuthorityClass(value) + } + return result +} + func parseCapabilities(field string, values []string) ([]catalog.Capability, error) { capabilities := make([]catalog.Capability, len(values)) for index, value := range values { diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 1a6e13d..076a390 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -13,10 +13,14 @@ import ( "strings" ) -const ArtifactSchemaVersion = 1 +const ( + ArtifactSchemaName = "control-program-artifact" + ArtifactSchemaRevision = 1 +) type Artifact struct { - SchemaVersion int `json:"schema_version"` + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` CompilerVersion string `json:"compiler_version"` SourcePath string `json:"source_path"` SourceSHA256 string `json:"source_sha256"` @@ -53,7 +57,7 @@ func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, erro skills[filepath.ToSlash(path)] = digest(raw) } artifact := Artifact{ - SchemaVersion: ArtifactSchemaVersion, CompilerVersion: input.CompilerVersion, + 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, @@ -82,7 +86,7 @@ func LoadArtifact(source io.Reader) (Artifact, error) { if err := requireEOF(decoder); err != nil { return Artifact{}, err } - if artifact.SchemaVersion != ArtifactSchemaVersion || 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 { return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: artifact envelope is incomplete") } for path, fingerprint := range artifact.GeneratedSkills { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index 5ffb243..a645be3 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -52,8 +52,8 @@ func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error } func Compile(document Document, resolver BindingResolver) (Compiled, error) { - if document.SchemaVersion != SchemaVersion { - return Compiled{}, invalid("schema_version", "unsupported schema") + if document.Schema != SchemaName || document.SchemaRevision != SchemaRevision { + return Compiled{}, invalid("schema", "unsupported schema or revision") } if !validID(document.Program.ID) || document.Program.Version == "" { return Compiled{}, invalid("program", "id and version are required") @@ -108,10 +108,10 @@ func Compile(document Document, resolver BindingResolver) (Compiled, error) { if err != nil { return Compiled{}, err } - if err := normalizeTransitions(&document, facets, operators); err != nil { + if err := normalizeTargetsAndEntries(&document, facets, resolver); err != nil { return Compiled{}, err } - if err := normalizeTargetsAndEntries(&document, facets); err != nil { + if err := normalizeTransitions(&document, facets, operators); err != nil { return Compiled{}, err } @@ -173,35 +173,48 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi if op.Binding.Fingerprint != resolved.Fingerprint { return nil, invalid("operators."+op.ID+".binding", "binding fingerprint drift") } - expected := Operator{ID: op.ID, Binding: &OperatorBinding{Reference: op.Binding.Reference, Version: op.Binding.Version, Fingerprint: resolved.Fingerprint}, Capabilities: resolved.Capabilities, Authority: resolved.Authority, Effects: resolved.Effects, Verifier: resolved.Verifier, Recovery: resolved.Recovery, StateEffect: &resolved.StateEffect} + expected := Operator{ID: op.ID, Binding: &OperatorBinding{Reference: op.Binding.Reference, Version: op.Binding.Version, Fingerprint: resolved.Fingerprint}, Capabilities: resolved.Capabilities, Authority: resolved.Authority, Effects: resolved.Effects, Verifier: resolved.Verifier, Recovery: resolved.Recovery, StateEffect: &resolved.StateEffect, ExecutionContext: resolved.ExecutionContext} expectedBinding = &expected } else { op.Binding.Fingerprint = resolved.Fingerprint op.Capabilities, op.Authority, op.Effects = resolved.Capabilities, resolved.Authority, resolved.Effects - op.Verifier, op.Recovery, op.StateEffect = resolved.Verifier, resolved.Recovery, &resolved.StateEffect + op.Verifier, op.Recovery, op.StateEffect, op.ExecutionContext = resolved.Verifier, resolved.Recovery, &resolved.StateEffect, resolved.ExecutionContext } } var err error if op.Capabilities, err = normalizedReferenceSet("operators."+op.ID+".capabilities", op.Capabilities); err != nil { return nil, err } - if op.Authority, err = normalizedReferenceSet("operators."+op.ID+".authority", op.Authority); err != nil { + if op.Authority.AnyOf, err = normalizedReferenceSet("operators."+op.ID+".authority.any_of", op.Authority.AnyOf); err != nil { + return nil, err + } + if op.Authority.AllOf, err = normalizedReferenceSet("operators."+op.ID+".authority.all_of", op.Authority.AllOf); err != nil { return nil, err } + if op.ExecutionContext != "preserve" && op.ExecutionContext != "advance" { + return nil, invalid("operators."+op.ID+".execution_context", "must be preserve or advance") + } + if op.Binding == nil && op.ExecutionContext == "advance" { + return nil, invalid("operators."+op.ID+".execution_context", "only trusted bindings may advance execution context") + } if op.Effects, err = normalizedReferenceSet("operators."+op.ID+".effects", op.Effects); err != nil { return nil, err } if op.Binding != nil { document.Declarations.Capabilities = union(document.Declarations.Capabilities, op.Capabilities) - document.Declarations.Authorities = union(document.Declarations.Authorities, op.Authority) + document.Declarations.Authorities = union(document.Declarations.Authorities, op.Authority.AnyOf) + document.Declarations.Authorities = union(document.Declarations.Authorities, op.Authority.AllOf) document.Declarations.Effects = union(document.Declarations.Effects, op.Effects) document.Declarations.Verifiers = union(document.Declarations.Verifiers, []string{op.Verifier}) } if missing := firstUndeclared(op.Capabilities, document.Declarations.Capabilities); missing != "" { return nil, invalid("operators."+op.ID+".capabilities", "undeclared "+missing) } - if missing := firstUndeclared(op.Authority, document.Declarations.Authorities); missing != "" { - return nil, invalid("operators."+op.ID+".authority", "undeclared "+missing) + if missing := firstUndeclared(op.Authority.AnyOf, document.Declarations.Authorities); missing != "" { + return nil, invalid("operators."+op.ID+".authority.any_of", "undeclared "+missing) + } + if missing := firstUndeclared(op.Authority.AllOf, document.Declarations.Authorities); missing != "" { + return nil, invalid("operators."+op.ID+".authority.all_of", "undeclared "+missing) } if missing := firstUndeclared(op.Effects, document.Declarations.Effects); missing != "" { return nil, invalid("operators."+op.ID+".effects", "undeclared "+missing) @@ -220,7 +233,8 @@ func normalizeOperators(document *Document, facets map[string]Facet, resolver Bi } if expectedBinding != nil { expectedBinding.Capabilities, _ = normalizedReferenceSet("binding.capabilities", expectedBinding.Capabilities) - expectedBinding.Authority, _ = normalizedReferenceSet("binding.authority", expectedBinding.Authority) + expectedBinding.Authority.AnyOf, _ = normalizedReferenceSet("binding.authority.any_of", expectedBinding.Authority.AnyOf) + expectedBinding.Authority.AllOf, _ = normalizedReferenceSet("binding.authority.all_of", expectedBinding.Authority.AllOf) expectedBinding.Effects, _ = normalizedReferenceSet("binding.effects", expectedBinding.Effects) _ = normalizeStateEffect(expectedBinding.StateEffect, facets) if !sameOperatorSemantics(*op, *expectedBinding) { @@ -253,6 +267,7 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators seen := map[string]bool{} for i := range document.Transitions { value := &document.Transitions[i] + var err error if !validID(value.ID) || seen[value.ID] || operators[value.Operator].ID == "" { return invalid(fmt.Sprintf("transitions[%d]", i), "invalid transition or operator reference") } @@ -263,6 +278,13 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators if err := normalizePredicate(&value.Target, facets); err != nil { return invalid("transitions."+value.ID+".target", err.Error()) } + value.Requires.Authorities, err = normalizedReferenceSet("transitions."+value.ID+".requires.authorities", value.Requires.Authorities) + if err != nil { + return err + } + if missing := firstUndeclared(value.Requires.Authorities, document.Declarations.Authorities); missing != "" { + return invalid("transitions."+value.ID+".requires.authorities", "undeclared "+missing) + } } if len(seen) == 0 { return invalid("transitions", "at least one transition is required") @@ -276,7 +298,7 @@ func normalizeTransitions(document *Document, facets map[string]Facet, operators return nil } -func normalizeTargetsAndEntries(document *Document, facets map[string]Facet) error { +func normalizeTargetsAndEntries(document *Document, facets map[string]Facet, resolver BindingResolver) error { targets := map[string]bool{} for i := range document.Targets { value := &document.Targets[i] @@ -299,6 +321,29 @@ func normalizeTargetsAndEntries(document *Document, facets map[string]Facet) err return invalid(fmt.Sprintf("entries[%d]", i), "invalid entry or target reference") } entries[entry.ID] = true + if entry.Delegation != nil { + if resolver == nil { + return invalid("entries."+entry.ID+".delegation", "no binding resolver is available") + } + compiledBinding := entry.Delegation.Fingerprint != "" || len(entry.Delegation.Authorities) != 0 + resolved, resolveErr := resolver.ResolveDelegation(entry.Delegation.Reference, entry.Delegation.Version) + if resolveErr != nil { + return invalid("entries."+entry.ID+".delegation", resolveErr.Error()) + } + if !resolved.Delegable || len(resolved.Fingerprint) != 64 { + return invalid("entries."+entry.ID+".delegation", "binding is not delegable") + } + resolved.Authorities, resolveErr = normalizedReferenceSet("entries."+entry.ID+".delegation.authorities", resolved.Authorities) + if resolveErr != nil || len(resolved.Authorities) == 0 { + return invalid("entries."+entry.ID+".delegation", "binding grants no valid authority") + } + if compiledBinding && (entry.Delegation.Fingerprint != resolved.Fingerprint || !equalStrings(entry.Delegation.Authorities, resolved.Authorities)) { + return invalid("entries."+entry.ID+".delegation", "binding semantics drift") + } + entry.Delegation.Fingerprint = resolved.Fingerprint + entry.Delegation.Authorities = append([]string(nil), resolved.Authorities...) + document.Declarations.Authorities = union(document.Declarations.Authorities, resolved.Authorities) + } inputs := map[string]bool{} for j := range entry.Inputs { input := &entry.Inputs[j] @@ -502,7 +547,7 @@ func stripDescriptions(value Document) Document { } func hasInlineSemantics(value Operator) bool { - return len(value.Capabilities) != 0 || len(value.Authority) != 0 || len(value.Effects) != 0 || value.Verifier != "" || value.Recovery != "" || value.StateEffect != nil + return len(value.Capabilities) != 0 || len(value.Authority.AnyOf) != 0 || len(value.Authority.AllOf) != 0 || len(value.Effects) != 0 || value.Verifier != "" || value.Recovery != "" || value.StateEffect != nil || value.ExecutionContext != "" } func sameOperatorSemantics(left, right Operator) bool { left.Description, right.Description = "", "" @@ -511,6 +556,17 @@ func sameOperatorSemantics(left, right Operator) bool { return leftErr == nil && rightErr == nil && bytes.Equal(leftRaw, rightRaw) } func validID(value string) bool { return semanticID.MatchString(value) } +func equalStrings(left, right []string) bool { + return len(left) == len(right) && containsAllStrings(left, right) +} +func containsAllStrings(left, right []string) bool { + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} func contains(values []string, wanted string) bool { i := sort.SearchStrings(values, wanted) return i < len(values) && values[i] == wanted diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 273a17c..5723555 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -11,12 +11,29 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" ) +type delegationResolver struct { + fingerprint string + authorities []string + delegable bool +} + +func (r delegationResolver) ResolveOperator(string, string) (controlprogram.ResolvedOperator, error) { + return controlprogram.ResolvedOperator{}, nil +} + +func (r delegationResolver) ResolveDelegation(reference, version string) (controlprogram.ResolvedDelegation, error) { + if reference != "incident/delegation/autonomy" || version != "1" { + return controlprogram.ResolvedDelegation{}, os.ErrNotExist + } + return controlprogram.ResolvedDelegation{Fingerprint: r.fingerprint, Authorities: r.authorities, Delegable: r.delegable}, nil +} + func incidentProgram() controlprogram.Document { mitigated := "mitigated" return controlprogram.Document{ - SchemaVersion: controlprogram.SchemaVersion, - Program: controlprogram.Program{ID: "incident-response", Version: "1", Description: "human text"}, - Description: "incident control program", + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "incident-response", Version: "1", Description: "human text"}, + Description: "incident control program", Declarations: controlprogram.Declarations{ Capabilities: []string{"service.restart"}, Authorities: []string{"incident-commander"}, Effects: []string{"service.restart"}, Verifiers: []string{"healthcheck"}, InputResolvers: []string{"incident.input"}, @@ -27,9 +44,9 @@ func incidentProgram() controlprogram.Document { }, Evidence: []controlprogram.Evidence{{ID: "healthcheck", Subject: "service", Kind: "observation", Description: "observed health"}}, Operators: []controlprogram.Operator{{ - ID: "restart", Capabilities: []string{"service.restart"}, Authority: []string{"incident-commander"}, + ID: "restart", Capabilities: []string{"service.restart"}, Authority: controlprogram.AuthorityRequirement{AnyOf: []string{"incident-commander"}}, Effects: []string{"service.restart"}, Verifier: "healthcheck", Recovery: "restart", - Description: "restart the service", + Description: "restart the service", ExecutionContext: "preserve", StateEffect: &controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &mitigated}}}, }}, Transitions: []controlprogram.Transition{{ @@ -106,19 +123,83 @@ func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { } } +func TestDelegationBindingIsResolvedAndFingerprintBound(t *testing.T) { + // control-law: repository-source-can-request-but-cannot-grant-authority + document := incidentProgram() + document.Entries[0].Delegation = &controlprogram.DelegationBinding{Reference: "incident/delegation/autonomy", Version: "1"} + resolver := delegationResolver{fingerprint: strings.Repeat("d", 64), authorities: []string{"autonomy"}, delegable: true} + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + binding := compiled.Document.Entries[0].Delegation + if binding.Fingerprint != resolver.fingerprint || len(binding.Authorities) != 1 || binding.Authorities[0] != "autonomy" { + t.Fatalf("resolved delegation = %#v", binding) + } + if _, err := controlprogram.Compile(compiled.Document, delegationResolver{fingerprint: strings.Repeat("e", 64), authorities: []string{"autonomy"}, delegable: true}); err == nil || !strings.Contains(err.Error(), "drift") { + t.Fatalf("delegation drift result = %v", err) + } + if _, err := controlprogram.Compile(document, delegationResolver{fingerprint: strings.Repeat("d", 64), authorities: []string{"autonomy"}, delegable: false}); err == nil || !strings.Contains(err.Error(), "not delegable") { + t.Fatalf("nondelegable result = %v", err) + } +} + +func TestAuthorityAlgebraAndRepositoryStrengtheningAreExecutableSemantics(t *testing.T) { + // control-law: alternatives-and-mandatory-authority-never-flatten + base := incidentProgram() + base.Declarations.Authorities = []string{"autonomy", "external-provider", "human", "incident-commander"} + base.Operators[0].Authority = controlprogram.AuthorityRequirement{AnyOf: []string{"human", "autonomy"}, AllOf: []string{"external-provider"}} + base.Transitions[0].Requires.Authorities = []string{"incident-commander"} + compiled, err := controlprogram.Compile(base, nil) + if err != nil { + t.Fatal(err) + } + if len(compiled.Document.Operators[0].Authority.AnyOf) != 2 || len(compiled.Document.Operators[0].Authority.AllOf) != 1 || len(compiled.Document.Transitions[0].Requires.Authorities) != 1 { + t.Fatalf("authority semantics = %#v %#v", compiled.Document.Operators[0].Authority, compiled.Document.Transitions[0].Requires) + } + changed := clone(t, base) + changed.Transitions[0].Requires.Authorities = nil + withoutStrengthening, err := controlprogram.Compile(changed, nil) + if err != nil { + t.Fatal(err) + } + if compiled.Fingerprint == withoutStrengthening.Fingerprint { + t.Fatal("repository authority strengthening did not change executable fingerprint") + } +} + +func TestOnlyTrustedBindingsMayAdvanceExecutionContext(t *testing.T) { + document := incidentProgram() + document.Operators[0].ExecutionContext = "advance" + if _, err := controlprogram.Compile(document, nil); err == nil || !strings.Contains(err.Error(), "trusted bindings") { + t.Fatalf("untrusted execution advance result = %v", err) + } +} + func TestStrictLoaderRejectsUnknownAndDuplicateFields(t *testing.T) { // control-law: only-the-closed-ir-schema-crosses-the-compiler-boundary valid, _ := json.Marshal(incidentProgram()) - unknown := bytes.Replace(valid, []byte(`"schema_version"`), []byte(`"unknown":true,"schema_version"`), 1) + unknown := bytes.Replace(valid, []byte(`"schema"`), []byte(`"unknown":true,"schema"`), 1) if _, err := controlprogram.Load(bytes.NewReader(unknown), nil); err == nil { t.Fatal("unknown field was accepted") } - duplicate := bytes.Replace(valid, []byte(`"schema_version"`), []byte(`"schema_version":"control-program/v1","schema_version"`), 1) + duplicate := bytes.Replace(valid, []byte(`"schema"`), []byte(`"schema":"control-program","schema"`), 1) if _, err := controlprogram.Load(bytes.NewReader(duplicate), nil); err == nil { t.Fatal("duplicate field was accepted") } } +func TestGenerationLabelledControlProgramAndArtifactFormatsAreRejected(t *testing.T) { + legacyProgram := []byte(`{"schema_version":"control-program/v1"}`) + if _, err := controlprogram.Load(bytes.NewReader(legacyProgram), nil); err == nil { + t.Fatal("generation-labelled Control Program format was accepted") + } + legacyArtifact := []byte(`{"schema_version":1,"compiler_version":"control-program/v1.compiler.1"}`) + if _, err := controlprogram.LoadArtifact(bytes.NewReader(legacyArtifact)); err == nil { + t.Fatal("generation-labelled artifact format was accepted") + } +} + func TestStrictLoadersRejectOversizedTrailingInput(t *testing.T) { // control-law: size-limited-loaders-never-treat-truncation-as-eof documentRaw, err := json.Marshal(incidentProgram()) diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 7050489..6d9af69 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -129,7 +129,7 @@ func TestTypeScriptFrontendRejectsRepositoryCodeWithoutExecutingIt(t *testing.T) source := filepath.Join(directory, "unsafe.flow.ts") content := "import { writeFileSync } from 'node:fs';\n" + "writeFileSync(" + strconv.Quote(sentinel) + ", 'unsafe');\n" + - "export default { schema_version: 'control-program/v1' };\n" + "export default { schema: 'control-program', schema_revision: 1 };\n" if err := os.WriteFile(source, []byte(content), 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 7753215..9d306b6 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -5,19 +5,23 @@ package controlprogram import "encoding/json" -const SchemaVersion = "control-program/v1" +const ( + SchemaName = "control-program" + SchemaRevision = 1 +) type Document struct { - SchemaVersion string `json:"schema_version"` - 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"` + Operators []Operator `json:"operators"` + Transitions []Transition `json:"transitions"` + Targets []Target `json:"targets"` + Entries []Entry `json:"entries"` + Description string `json:"description,omitempty"` } type Program struct { @@ -69,16 +73,22 @@ type OperatorBinding struct { Fingerprint string `json:"fingerprint,omitempty"` } +type AuthorityRequirement struct { + AnyOf []string `json:"any_of,omitempty"` + AllOf []string `json:"all_of,omitempty"` +} + type Operator struct { - ID string `json:"id"` - Binding *OperatorBinding `json:"binding,omitempty"` - Capabilities []string `json:"capabilities,omitempty"` - Authority []string `json:"authority,omitempty"` - Effects []string `json:"effects,omitempty"` - Verifier string `json:"verifier,omitempty"` - Recovery string `json:"recovery,omitempty"` - StateEffect *StateEffect `json:"state_effect,omitempty"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Binding *OperatorBinding `json:"binding,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Authority AuthorityRequirement `json:"authority"` + Effects []string `json:"effects,omitempty"` + Verifier string `json:"verifier,omitempty"` + Recovery string `json:"recovery,omitempty"` + StateEffect *StateEffect `json:"state_effect,omitempty"` + ExecutionContext string `json:"execution_context"` + Description string `json:"description,omitempty"` } type StateEffect struct { @@ -106,12 +116,17 @@ type ValueReference struct { } type Transition struct { - ID string `json:"id"` - Operator string `json:"operator"` - Guard Predicate `json:"guard"` - Target Predicate `json:"target"` - Priority int `json:"priority"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Operator string `json:"operator"` + Guard Predicate `json:"guard"` + Target Predicate `json:"target"` + Priority int `json:"priority"` + Requires TransitionRequirements `json:"requires,omitempty"` + Description string `json:"description,omitempty"` +} + +type TransitionRequirements struct { + Authorities []string `json:"authorities,omitempty"` } type Target struct { @@ -121,10 +136,18 @@ type Target struct { } type Entry struct { - ID string `json:"id"` - Target string `json:"target"` - Inputs []EntryInput `json:"inputs,omitempty"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Target string `json:"target"` + Inputs []EntryInput `json:"inputs,omitempty"` + Delegation *DelegationBinding `json:"delegation,omitempty"` + Description string `json:"description,omitempty"` +} + +type DelegationBinding struct { + Reference string `json:"reference"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint,omitempty"` + Authorities []string `json:"authorities,omitempty"` } type EntryInput struct { @@ -140,14 +163,22 @@ type EntryInput struct { // and binds their exact fingerprint. type BindingResolver interface { ResolveOperator(reference, version string) (ResolvedOperator, error) + ResolveDelegation(reference, version string) (ResolvedDelegation, error) } type ResolvedOperator struct { - Fingerprint string - Capabilities []string - Authority []string - Effects []string - Verifier string - Recovery string - StateEffect StateEffect + Fingerprint string + Capabilities []string + Authority AuthorityRequirement + Effects []string + Verifier string + Recovery string + StateEffect StateEffect + ExecutionContext string +} + +type ResolvedDelegation struct { + Fingerprint string + Authorities []string + Delegable bool } diff --git a/boatstack/delivery/program_manifest.go b/boatstack/delivery/program_manifest.go index e11bfad..140831b 100644 --- a/boatstack/delivery/program_manifest.go +++ b/boatstack/delivery/program_manifest.go @@ -82,6 +82,7 @@ type ProgramTransition struct { Priority int `json:"priority"` AllowsIdentityRebind bool `json:"allows_identity_rebind,omitempty"` AllowsWorktreeTransfer bool `json:"allows_worktree_transfer,omitempty"` + ExecutionContext string `json:"execution_context,omitempty"` BindsSourceRevision bool `json:"binds_source_revision,omitempty"` AuthorityFingerprintParameter string `json:"authority_fingerprint_parameter,omitempty"` } @@ -348,6 +349,7 @@ func (value ProgramTransition) runtimeTransition() Transition { PrivacyClassification: value.PrivacyClassification, TelemetryClassification: value.TelemetryClassification, CostClass: value.CostClass, Policy: value.Policy, Priority: value.Priority, AllowsIdentityRebind: value.AllowsIdentityRebind, AllowsWorktreeTransfer: value.AllowsWorktreeTransfer, + ExecutionContext: value.ExecutionContext, BindsSourceRevision: value.BindsSourceRevision, AuthorityFingerprintParameter: value.AuthorityFingerprintParameter, } } diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go index 0967ad0..17f8fd4 100644 --- a/boatstack/flow/softwaredelivery/bindings.go +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -18,6 +18,7 @@ import ( ) const BindingPrefix = "software-delivery/" +const DelegationPrefix = BindingPrefix + "delegation/" type Resolver struct { transitions map[string]delivery.Transition @@ -47,6 +48,7 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res if version != strconv.Itoa(transition.Version) { return controlprogram.ResolvedOperator{}, fmt.Errorf("operator %q requires binding version %d", id, transition.Version) } + transition.ExecutionContext = executionContextFor(transition) fingerprint, err := transitionFingerprint(transition) if err != nil { return controlprogram.ResolvedOperator{}, err @@ -55,17 +57,8 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res for index, value := range transition.RequiredCapabilities { capabilities[index] = string(value) } - authoritySet := map[string]bool{} - for _, values := range [][]delivery.AuthorityClass{transition.Authority, transition.AuthorityAll} { - for _, value := range values { - authoritySet[string(value)] = true - } - } - authority := make([]string, 0, len(authoritySet)) - for value := range authoritySet { - authority = append(authority, value) - } - sort.Strings(authority) + anyOf := authorityStrings(transition.Authority) + allOf := authorityStrings(transition.AuthorityAll) effectSet := map[string]bool{} if transition.Effect != "" { effectSet[string(transition.Effect)] = true @@ -81,11 +74,50 @@ func (r Resolver) ResolveOperator(reference, version string) (controlprogram.Res } sort.Strings(effects) return controlprogram.ResolvedOperator{ - Fingerprint: fingerprint, Capabilities: capabilities, Authority: authority, Effects: effects, + Fingerprint: fingerprint, Capabilities: capabilities, + Authority: controlprogram.AuthorityRequirement{AnyOf: anyOf, AllOf: allOf}, Effects: effects, Verifier: transition.Verifier, Recovery: string(transition.Interruption.Recovery), StateEffect: projectStateEffect(transition.StateEffect), + ExecutionContext: executionContextFor(transition), }, nil } +func (r Resolver) ResolveDelegation(reference, version string) (controlprogram.ResolvedDelegation, error) { + authority, ok := strings.CutPrefix(reference, DelegationPrefix) + if !ok || authority != string(delivery.AuthorityAutonomy) || version != "1" { + return controlprogram.ResolvedDelegation{}, fmt.Errorf("unknown or nondelegable software-delivery delegation %q", reference) + } + payload := struct { + Reference string `json:"reference"` + Version string `json:"version"` + Authorities []string `json:"authorities"` + }{Reference: reference, Version: version, Authorities: []string{authority}} + encoded, err := json.Marshal(payload) + if err != nil { + return controlprogram.ResolvedDelegation{}, err + } + digest := sha256.Sum256(encoded) + return controlprogram.ResolvedDelegation{Fingerprint: hex.EncodeToString(digest[:]), Authorities: payload.Authorities, Delegable: true}, nil +} + +func authorityStrings(values []delivery.AuthorityClass) []string { + result := make([]string, len(values)) + result = result[:0] + for _, value := range values { + if value != delivery.AuthorityNone { + result = append(result, string(value)) + } + } + sort.Strings(result) + return result +} + +func executionContextFor(transition delivery.Transition) string { + if transition.ExecutionContext == "advance" { + return "advance" + } + return "preserve" +} + func (r Resolver) Transition(reference string) (delivery.Transition, bool) { id, ok := strings.CutPrefix(reference, BindingPrefix) if !ok { @@ -96,6 +128,8 @@ func (r Resolver) Transition(reference string) (delivery.Transition, bool) { transition.SourceConditions = append([]delivery.FacetCondition(nil), transition.SourceConditions...) transition.TargetConditions = append([]delivery.FacetCondition(nil), transition.TargetConditions...) transition.RequiredCapabilities = append([]delivery.Capability(nil), transition.RequiredCapabilities...) + transition.Authority = append([]delivery.AuthorityClass(nil), transition.Authority...) + transition.AuthorityAll = append([]delivery.AuthorityClass(nil), transition.AuthorityAll...) return transition, ok } diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index b7721eb..d7bfd88 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -85,6 +85,11 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim transition.SourceConditions = append(transition.SourceConditions, guard...) transition.TargetConditions = append(transition.TargetConditions, target...) transition.Priority = declaration.Priority + transition.ExecutionContext = operator.ExecutionContext + for _, authority := range declaration.Requires.Authorities { + transition.AuthorityAll = append(transition.AuthorityAll, delivery.AuthorityClass(authority)) + } + transition.AuthorityAll = uniqueAuthorities(transition.AuthorityAll) trustedObjectives := append([]model.TargetID(nil), transition.TargetIDs...) transition.TargetIDs = transition.TargetIDs[:0] for targetID, objective := range objectives { @@ -130,6 +135,19 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim return base, nil } +func uniqueAuthorities(values []delivery.AuthorityClass) []delivery.AuthorityClass { + seen := map[delivery.AuthorityClass]bool{} + result := make([]delivery.AuthorityClass, 0, len(values)) + for _, value := range values { + if !seen[value] { + seen[value] = true + result = append(result, value) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + func ObjectiveForEntry(ctx context.Context, compiled controlprogram.Compiled, resolver Resolver, entryID string) (EntryObjective, error) { base, err := standard.Definition().RuntimeManifest(ctx) if err != nil { diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index c14c430..11f8dda 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -20,8 +20,8 @@ func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram. } truth := true document := controlprogram.Document{ - SchemaVersion: controlprogram.SchemaVersion, - Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, @@ -106,6 +106,54 @@ func TestRepositoryGuardCanOnlyStrengthenTrustedBinding(t *testing.T) { } } +func TestRepositoryAuthorityRequirementIsConjunctive(t *testing.T) { + // control-law: repository-policy-can-add-a-gate-but-cannot-create-an-authority-alternative + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + document := compiled.Document + document.Declarations.Authorities = append(document.Declarations.Authorities, "human") + document.Transitions[0].Requires.Authorities = []string{"human"} + strengthened, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(strengthened, resolver) + if err != nil { + t.Fatal(err) + } + manifest, err := definition.RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + transition := manifest.Transitions[0] + if len(transition.AuthorityAll) != 1 || transition.AuthorityAll[0] != delivery.AuthorityHuman { + t.Fatalf("mandatory authority = %#v; alternatives = %#v", transition.AuthorityAll, transition.Authority) + } +} + +func TestPublicationBindingPreservesProviderAsMandatory(t *testing.T) { + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + resolved, err := resolver.ResolveOperator("software-delivery/publication.execute", "1") + if err != nil { + t.Fatal(err) + } + if !contains(resolved.Authority.AnyOf, "human") || !contains(resolved.Authority.AnyOf, "autonomy") || !contains(resolved.Authority.AllOf, "external-provider") { + t.Fatalf("publication authority = %#v", resolved.Authority) + } +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + func TestRepositoryTargetMustBeImpliedByTrustedPostcondition(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) @@ -216,8 +264,8 @@ func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) t.Fatal(err) } document := controlprogram.Document{ - SchemaVersion: controlprogram.SchemaVersion, - Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, + Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, + Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 4964695..c3aa837 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -49,6 +49,22 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s } description += " Use only when the user explicitly selects this repository Flow entry." supersession := "" + delegation := "" + if entry.Delegation != nil { + delegation = fmt.Sprintf(` +The first `+"`next`"+` returns a typed `+"`DELEGATION_REQUIRED`"+` response before +managed state changes. Display its exact run ID, request fingerprint, requested +authorities, and description. Obtain one explicit human approval for that exact +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`"+`. +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" { abandonmentSkill, ok := targetEntrySkill(compiled.Document.Program.ID, compiled.Document.Entries, "safely-abandoned") if ok { @@ -81,11 +97,12 @@ parameters. A question suspends this run: ask the user, submit only the typed answer evidence, and resume the same run ID. Nothing continues in the background while input is missing. Never synthesize authority. %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, compiled.Document.Program.ID, entry.ID, host, supersession)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, delegation, supersession)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index bb46b9a..6418190 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -114,7 +114,7 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t } relative = filepath.ToSlash(relative) if !classifiedProductionFile(relative) { - t.Errorf("production source %s has no V2 ownership classification", relative) + t.Errorf("production source %s has no Boatstack ownership classification", relative) } classifiedFiles++ parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) diff --git a/boatstack/flow/standard/historical_test.go b/boatstack/flow/standard/historical_test.go index b2419a5..74f0ad2 100644 --- a/boatstack/flow/standard/historical_test.go +++ b/boatstack/flow/standard/historical_test.go @@ -54,7 +54,7 @@ type historicalFixture struct { func loadHistoricalCorpus(t *testing.T) historicalCorpus { t.Helper() - raw, err := os.ReadFile("../../testdata/v2-scenarios/historical.json") + raw, err := os.ReadFile("../../testdata/scenarios/historical.json") if err != nil { t.Fatal(err) } @@ -208,7 +208,7 @@ func TestHistoricalCorpusCoversEveryPRFrom172Through185(t *testing.T) { } for number := 172; number <= 185; number++ { if !covered[number] { - t.Errorf("PR #%d has no historical V2 fixture", number) + t.Errorf("PR #%d has no historical Boatstack fixture", number) } } } diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index f9eeed3..8b4fe5b 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -1999,6 +1999,7 @@ }, "priority": 52, "allows_worktree_transfer": true, + "execution_context": "advance", "owned_facets": [ "control", "product" @@ -2929,6 +2930,7 @@ }, "priority": 92, "allows_worktree_transfer": true, + "execution_context": "advance", "owned_facets": [ "control", "product" @@ -3147,6 +3149,7 @@ }, "priority": 98, "allows_worktree_transfer": true, + "execution_context": "advance", "owned_facets": [ "control", "product" @@ -3560,6 +3563,7 @@ }, "priority": 2, "allows_worktree_transfer": true, + "execution_context": "advance", "owned_facets": [ "control", "product" diff --git a/boatstack/internal/softwaredelivery/catalog/transition.go b/boatstack/internal/softwaredelivery/catalog/transition.go index a4a84a2..a3620b1 100644 --- a/boatstack/internal/softwaredelivery/catalog/transition.go +++ b/boatstack/internal/softwaredelivery/catalog/transition.go @@ -320,6 +320,7 @@ type Transition struct { Priority int `json:"priority"` AllowsIdentityRebind bool `json:"allows_identity_rebind,omitempty"` AllowsWorktreeTransfer bool `json:"allows_worktree_transfer,omitempty"` + ExecutionContext string `json:"execution_context,omitempty"` BindsSourceRevision bool `json:"binds_source_revision,omitempty"` AuthorityFingerprintParameter string `json:"authority_fingerprint_parameter,omitempty"` } @@ -477,6 +478,9 @@ func validateTransition(t Transition) error { if t.Version < 1 || len(t.SourcePhases) == 0 || len(t.TargetPhases) == 0 { return fmt.Errorf("%s: version, source phases, and target phases are required", t.ID) } + if t.ExecutionContext != "" && t.ExecutionContext != "preserve" && t.ExecutionContext != "advance" { + return fmt.Errorf("%s: invalid execution context effect %q", t.ID, t.ExecutionContext) + } for _, phase := range append(append([]model.ProtocolPhase(nil), t.SourcePhases...), t.TargetPhases...) { if !phase.Valid() { return fmt.Errorf("%s: invalid phase %q", t.ID, phase) diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go new file mode 100644 index 0000000..af753f7 --- /dev/null +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -0,0 +1,117 @@ +// Package delegation owns run-bound authority records outside repositories. +package delegation + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "time" +) + +const ( + Schema = "run-delegation" + SchemaRevision = 1 +) + +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"` +} + +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 == "" { + return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") + } + r.InputFingerprints = append([]string(nil), r.InputFingerprints...) + r.RequestedAuthorities = append([]string(nil), r.RequestedAuthorities...) + sort.Strings(r.InputFingerprints) + sort.Strings(r.RequestedAuthorities) + for index, value := range r.RequestedAuthorities { + if value == "" || (index > 0 && r.RequestedAuthorities[index-1] == value) { + return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: authorities are empty or duplicated") + } + } + encoded, err := json.Marshal(r) + if err != nil { + return "", err + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]), nil +} + +type Record struct { + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + Request Request `json:"request"` + RequestFingerprint string `json:"request_fingerprint"` + ReceiptID string `json:"receipt_id"` + Actor string `json:"actor"` + AuthorizedAt time.Time `json:"authorized_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Revision uint64 `json:"revision"` + Status string `json:"status"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + EndReason string `json:"end_reason,omitempty"` +} + +func Path(flowRoot, runID string) (string, error) { + if !identity.MatchString(runID) { + return "", fmt.Errorf("DELEGATION_RUN_INVALID: invalid run identity") + } + return filepath.Join(flowRoot, "delegations", runID+".json"), nil +} + +func LockPath(lockRoot, runID string) (string, error) { + if !identity.MatchString(runID) { + return "", fmt.Errorf("DELEGATION_RUN_INVALID: invalid run identity") + } + return filepath.Join(lockRoot, "delegation-"+runID+".lock"), nil +} + +func Load(path string) (Record, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Record{}, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var record Record + if err := decoder.Decode(&record); err != nil { + return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: trailing JSON") + } + if record.Schema != Schema || record.SchemaRevision != SchemaRevision || record.Revision == 0 || record.Status == "" || record.Actor == "" || record.ReceiptID == "" { + return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: record is incomplete") + } + fingerprint, err := record.Request.Fingerprint() + if err != nil || fingerprint != record.RequestFingerprint { + return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: request fingerprint mismatch") + } + return record, nil +} diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go new file mode 100644 index 0000000..3266919 --- /dev/null +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -0,0 +1,43 @@ +package delegation_test + +import ( + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" +) + +func request() delegation.Request { + return delegation.Request{ + RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 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", + } +} + +func TestRequestFingerprintCanonicalizesSetsAndBindsSemantics(t *testing.T) { + left := request() + right := request() + right.InputFingerprints[0], right.InputFingerprints[1] = right.InputFingerprints[1], right.InputFingerprints[0] + right.RequestedAuthorities[0], right.RequestedAuthorities[1] = right.RequestedAuthorities[1], right.RequestedAuthorities[0] + leftFingerprint, err := left.Fingerprint() + if err != nil { + t.Fatal(err) + } + rightFingerprint, err := right.Fingerprint() + if err != nil { + t.Fatal(err) + } + if leftFingerprint != rightFingerprint { + t.Fatalf("equivalent request fingerprints differ: %s != %s", leftFingerprint, rightFingerprint) + } + right.TargetID = "other" + changed, err := right.Fingerprint() + if err != nil { + t.Fatal(err) + } + if changed == leftFingerprint { + t.Fatal("semantic request change preserved fingerprint") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 98f2d7e..7ba02d2 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -23,7 +23,7 @@ func prepareAttachBinding(layout ports.ControllerLayout, admission protocol.Admi if topology != model.TopologyDetached && topology != model.TopologyHybrid { return ports.ResourceMutation{}, fmt.Errorf("repository.attach topology must be detached or hybrid") } - controllerID := sha256Bytes([]byte("v2:" + admission.Invocation.RepositoryID + ":" + admission.Invocation.GitCommonID + ":" + topologyValue + ":" + configAuthority))[:20] + controllerID := sha256Bytes([]byte("controller:" + admission.Invocation.RepositoryID + ":" + admission.Invocation.GitCommonID + ":" + topologyValue + ":" + configAuthority))[:20] binding := durable.Binding{ SchemaVersion: durable.BindingSchemaVersion, RepositoryID: admission.Invocation.RepositoryID, GitCommonID: admission.Invocation.GitCommonID, Topology: topology, ControllerID: controllerID, ConfigAuthority: configAuthority, CreatedAt: admission.IssuedAt.UTC(), diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary.go b/boatstack/internal/softwaredelivery/effects/command_boundary.go index 866b369..b23fc29 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary.go @@ -176,7 +176,7 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio } baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRef+":.boatstack/project.json") if err != nil { - return settled, fmt.Errorf("workspace base does not contain the verified V2 configuration: %w", err) + return settled, fmt.Errorf("workspace base does not contain the verified Boatstack configuration: %w", err) } _, baseFingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(baseConfig) if fingerprintErr != nil { diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record.go b/boatstack/internal/softwaredelivery/effects/delegation_record.go new file mode 100644 index 0000000..bee402f --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/delegation_record.go @@ -0,0 +1,44 @@ +package effects + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/delegation" +) + +// StoreDelegationRecord is the single mutation boundary for runtime-owned +// delegation authority. The caller must hold the corresponding run lock. +func StoreDelegationRecord(path string, record delegation.Record) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + temporary, err := os.CreateTemp(filepath.Dir(path), ".delegation-*.tmp") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); 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/exclusive_lock.go b/boatstack/internal/softwaredelivery/effects/exclusive_lock.go new file mode 100644 index 0000000..d347047 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/exclusive_lock.go @@ -0,0 +1,41 @@ +package effects + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" +) + +// AcquireExclusivePath acquires one runtime-owned lock without implicitly +// acquiring the controller lock. Callers use it for authority records that +// must be revalidated before the controller and effect locks are acquired. +func AcquireExclusivePath(ctx context.Context, path string) (ports.Lock, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, err + } + for { + err = lockFile(file) + if err == nil { + return &heldLocks{values: []heldLock{{path: path, file: file}}}, nil + } + if !errors.Is(err, errLockHeld) { + _ = file.Close() + return nil, fmt.Errorf("acquire lock %s: %w", path, err) + } + select { + case <-ctx.Done(): + _ = file.Close() + return nil, ctx.Err() + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/boatstack/internal/softwaredelivery/effects/host_skills.go b/boatstack/internal/softwaredelivery/effects/host_skills.go index e396a38..e6e9935 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills.go +++ b/boatstack/internal/softwaredelivery/effects/host_skills.go @@ -105,7 +105,7 @@ func renderOpenAIMetadata(mode hostSkillMode) []byte { default_prompt: %q policy: allow_implicit_invocation: false -`, mode.DisplayName, mode.Description, "Use $"+mode.Slug+" to follow the authority-preserving Boatstack V2 driver.")) +`, mode.DisplayName, mode.Description, "Use $"+mode.Slug+" to follow the authority-preserving Boatstack driver.")) } func desiredHostSkillFiles(hosts []string) map[string][]byte { diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 904b0e0..4d7c576 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -186,7 +186,7 @@ func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) { } for _, path := range []string{layout.StatePath, layout.ReceiptPath, layout.EventPath} { if _, err := os.Stat(path); err != nil { - t.Errorf("expected V2 artifact %s: %v", path, err) + t.Errorf("expected Boatstack artifact %s: %v", path, err) } } } @@ -852,7 +852,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) }) 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", "commit", "-q", "-m", "install V2 configuration") + 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")) if readErr != nil { @@ -871,7 +871,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) } canonicalDestination := filepath.Join(destinationParent, filepath.Base(destination)) cut := apply(sourceInvocation, "workspace.cut", human, protocol.Parameters{ - {Name: "branch", Value: "feature/v2-workspace-transfer"}, {Name: "base_ref", Value: "HEAD"}, {Name: "destination", Value: destination}, + {Name: "branch", Value: "feature/workspace-transfer"}, {Name: "base_ref", Value: "HEAD"}, {Name: "destination", Value: destination}, }) if cut.Target.Invocation.InvokingPath != canonicalDestination || cut.Target.Invocation.WorktreeID == sourceInvocation.WorktreeID || cut.Target.Workspace.Value != model.WorkspaceCut { t.Fatalf("workspace authority did not transfer to destination: %#v", cut.Target) @@ -910,7 +910,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) if destinationObservation.Workspace.Value != model.WorkspaceCut || destinationObservation.Objective.Value != objective { t.Fatalf("destination did not receive exact controller state: %#v", destinationObservation) } - activated := apply(destinationInvocation, "workspace.activate", repositoryAuthority(canonicalDestination), protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}}) + activated := apply(destinationInvocation, "workspace.activate", repositoryAuthority(canonicalDestination), protocol.Parameters{{Name: "branch", Value: "feature/workspace-transfer"}}) if activated.Target.Workspace.Value != model.WorkspaceActive || activated.Target.Invocation.WorktreeID != destinationInvocation.WorktreeID { t.Fatalf("destination activation failed: %#v", activated.Target) } @@ -919,11 +919,11 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) } objective = model.Objective{ID: "objective-workspace-abandon", TargetID: model.ObjectiveAbandoned, DeliveryID: "delivery-workspace"} apply(destinationInvocation, "objective.bind", human, protocol.Parameters{{Name: "target_id", Value: string(objective.TargetID)}, {Name: "delivery_id", Value: objective.DeliveryID}}) - abandoned := apply(destinationInvocation, "workspace.abandon", human, protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}}) + abandoned := apply(destinationInvocation, "workspace.abandon", human, protocol.Parameters{{Name: "branch", Value: "feature/workspace-transfer"}}) if abandoned.Target.Terminal.Value != model.TerminalEstablished || abandoned.Target.Workspace.Value != model.WorkspaceAbandoned { t.Fatalf("workspace abandonment did not establish its configured terminal: %#v", abandoned.Target) } - cleaned := apply(destinationInvocation, "workspace.cleanup", human, protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}}) + cleaned := apply(destinationInvocation, "workspace.cleanup", human, protocol.Parameters{{Name: "branch", Value: "feature/workspace-transfer"}}) if cleaned.Target.Invocation.WorktreeID != sourceInvocation.WorktreeID || cleaned.Target.Workspace.Value != model.WorkspaceAbsent || cleaned.Target.Phase.Value != model.PhaseAbandoned { t.Fatalf("cleanup did not return verified authority to source checkout: %#v", cleaned.Target) } diff --git a/boatstack/internal/softwaredelivery/effects/io.go b/boatstack/internal/softwaredelivery/effects/io.go index e8701ad..a815272 100644 --- a/boatstack/internal/softwaredelivery/effects/io.go +++ b/boatstack/internal/softwaredelivery/effects/io.go @@ -31,7 +31,7 @@ func atomicWrite(path string, value []byte, mode os.FileMode) error { if err := os.MkdirAll(directory, 0o700); err != nil { return err } - temporary, err := os.CreateTemp(directory, ".boatstack-v2-stage-*") + temporary, err := os.CreateTemp(directory, ".boatstack-stage-*") if err != nil { return err } @@ -67,7 +67,7 @@ func atomicSymlink(path, target string) error { if err := os.MkdirAll(directory, 0o700); err != nil { return err } - temporary, err := os.CreateTemp(directory, ".boatstack-v2-link-*") + temporary, err := os.CreateTemp(directory, ".boatstack-link-*") if err != nil { return err } diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 2c05756..61ddcaf 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -117,6 +117,40 @@ func FindLatestCommittedFlowForObjective(layout ports.ControllerLayout, invocati return found, found.ID != "", err } +// 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{} + 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) + } + return nil + }); err != nil { + return false, err + } + 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 + } + authorized := map[string]bool{contextKey(initial): true} + for _, receipt := range receipts { + if receipt.ExecutionContext != "advance" { + continue + } + prior, resulting := receipt.PriorInvocation, receipt.ResultingInvocation + if prior == nil || resulting == nil || prior.RepositoryID != initial.RepositoryID || prior.GitCommonID != initial.GitCommonID || resulting.RepositoryID != initial.RepositoryID || resulting.GitCommonID != initial.GitCommonID || !authorized[contextKey(*prior)] { + return false, fmt.Errorf("delegation receipt lineage is invalid at %s", receipt.ID) + } + authorized[contextKey(*resulting)] = true + } + return current.RepositoryID == initial.RepositoryID && current.GitCommonID == initial.GitCommonID && authorized[contextKey(current)], nil +} + func sameStateLineage(left, right model.InvocationContext) bool { return left.RepositoryID == right.RepositoryID && left.GitCommonID == right.GitCommonID && left.WorktreeID == right.WorktreeID && left.ControllerID == right.ControllerID diff --git a/boatstack/internal/softwaredelivery/model/state.go b/boatstack/internal/softwaredelivery/model/state.go index 7a7d616..19ab993 100644 --- a/boatstack/internal/softwaredelivery/model/state.go +++ b/boatstack/internal/softwaredelivery/model/state.go @@ -165,7 +165,7 @@ func (s ConfigurationState) Valid() bool { } } -// ConfigurationPolicy is the control-relevant projection of the strict V2 +// ConfigurationPolicy is the control-relevant projection of the strict Boatstack // project document. Keeping it in the canonical snapshot prevents policy bytes // from being validated but then ignored by admission or terminal logic. type ConfigurationPolicy struct { diff --git a/boatstack/internal/softwaredelivery/plant/observer.go b/boatstack/internal/softwaredelivery/plant/observer.go index 5ea58cc..0ef2f12 100644 --- a/boatstack/internal/softwaredelivery/plant/observer.go +++ b/boatstack/internal/softwaredelivery/plant/observer.go @@ -66,7 +66,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) return model.Observation{}, err } configuration := state.Configuration - configurationPolicy := model.Absent[model.ConfigurationPolicy]("no valid V2 configuration policy", configEvidence) + configurationPolicy := model.Absent[model.ConfigurationPolicy]("no valid Boatstack configuration policy", configEvidence) if !configExists { configuration = model.ConfigurationUnsupported } else if configRaw, readErr := os.ReadFile(layout.ConfigPath); readErr != nil { @@ -255,7 +255,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) if configEvidence.Source != "" { configurationEvidence = append(append([]model.Evidence(nil), stateEvidence...), configEvidence) } - objectiveFact := model.Absent[model.Objective]("no configured V2 objective", stateEvidence...) + objectiveFact := model.Absent[model.Objective]("no configured Boatstack objective", stateEvidence...) if state.Objective.Validate() == nil { objectiveFact = model.Fact[model.Objective]{Status: model.FactKnown, Value: state.Objective, Evidence: stateEvidence} } diff --git a/boatstack/internal/softwaredelivery/plant/resolver.go b/boatstack/internal/softwaredelivery/plant/resolver.go index a2b95e2..e5df26b 100644 --- a/boatstack/internal/softwaredelivery/plant/resolver.go +++ b/boatstack/internal/softwaredelivery/plant/resolver.go @@ -81,11 +81,11 @@ func externalStateRoot(explicit string) (string, error) { if err != nil { return "", err } - return filepath.Join(absolute, "boatstack", "v2"), nil + return filepath.Join(absolute, "boatstack"), nil } if runtime.GOOS == "windows" { if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" { - return filepath.Join(base, "boatstack", "v2"), nil + return filepath.Join(base, "boatstack"), nil } } if runtime.GOOS == "darwin" { @@ -93,16 +93,16 @@ func externalStateRoot(explicit string) (string, error) { if err != nil { return "", err } - return filepath.Join(home, "Library", "Application Support", "boatstack", "v2"), nil + return filepath.Join(home, "Library", "Application Support", "boatstack"), nil } if base := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); base != "" { - return filepath.Join(base, "boatstack", "v2"), nil + return filepath.Join(base, "boatstack"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".local", "state", "boatstack", "v2"), nil + return filepath.Join(home, ".local", "state", "boatstack"), nil } func canonicalExisting(path string) (string, error) { @@ -257,7 +257,7 @@ func (r Resolver) ResolveLayout(ctx context.Context, invocation model.Invocation return ports.ControllerLayout{}, current, err } bindingPath := filepath.Join(r.externalRoot, "repositories", current.RepositoryID, current.GitCommonID, "binding.json") - embeddedSharedRoot := filepath.Join(commonRoot, "boatstack", "v2") + embeddedSharedRoot := filepath.Join(commonRoot, "boatstack") embeddedStateRoot := filepath.Join(embeddedSharedRoot, "worktrees", current.WorktreeID) externalSharedRoot := filepath.Join(r.externalRoot, "repositories", current.RepositoryID, current.GitCommonID) externalStateRoot := filepath.Join(externalSharedRoot, "worktrees", current.WorktreeID) diff --git a/boatstack/internal/softwaredelivery/protocol/authority.go b/boatstack/internal/softwaredelivery/protocol/authority.go index ce5201f..11fb160 100644 --- a/boatstack/internal/softwaredelivery/protocol/authority.go +++ b/boatstack/internal/softwaredelivery/protocol/authority.go @@ -86,7 +86,7 @@ func (b AuthorityBundle) GrantedCapabilities(now time.Time) []catalog.Capability func DeriveRepositoryAuthority(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { - return AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by the V2 kernel") + return AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by Boatstack kernel") } } if snapshot.Configuration.Status != model.FactKnown || snapshot.Configuration.Value != model.ConfigurationVerified { diff --git a/boatstack/internal/softwaredelivery/protocol/config.go b/boatstack/internal/softwaredelivery/protocol/config.go index 5b2fb3d..15a37aa 100644 --- a/boatstack/internal/softwaredelivery/protocol/config.go +++ b/boatstack/internal/softwaredelivery/protocol/config.go @@ -74,11 +74,11 @@ func DecodeProjectConfig(value []byte) (ProjectConfig, error) { decoder := json.NewDecoder(bytes.NewReader(value)) decoder.DisallowUnknownFields() if err := decoder.Decode(&config); err != nil { - return ProjectConfig{}, fmt.Errorf("decode V2 project configuration: %w", err) + return ProjectConfig{}, fmt.Errorf("decode Boatstack project configuration: %w", err) } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { - return ProjectConfig{}, fmt.Errorf("V2 project configuration contains trailing JSON") + return ProjectConfig{}, fmt.Errorf("Boatstack project configuration contains trailing JSON") } if err := config.Validate(); err != nil { return ProjectConfig{}, err @@ -125,7 +125,7 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { } encoded, err := json.Marshal(canonical) if err != nil { - return ProjectConfig{}, "", fmt.Errorf("encode canonical V2 project configuration: %w", err) + return ProjectConfig{}, "", fmt.Errorf("encode canonical Boatstack project configuration: %w", err) } digest := sha256.Sum256(encoded) return config, hex.EncodeToString(digest[:]), nil @@ -133,7 +133,7 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { func (c ProjectConfig) Validate() error { if c.SchemaVersion != ConfigSchemaVersion || c.Project.Name == "" || c.Project.DefaultBranch == "" || c.Project.Commands == nil { - return fmt.Errorf("V2 project configuration requires schema 2, project name, default branch, and commands") + return fmt.Errorf("Boatstack project configuration requires schema 2, project name, default branch, and commands") } if err := ValidateGitBranch(c.Project.DefaultBranch); err != nil { return fmt.Errorf("invalid default branch: %w", err) @@ -163,7 +163,7 @@ func (c ProjectConfig) Validate() error { seen[host] = true } if !seen["cli"] { - return fmt.Errorf("V2 project configuration must enable the canonical CLI surface") + return fmt.Errorf("Boatstack project configuration must enable the canonical CLI surface") } seenExtensions := map[string]bool{} for _, extension := range c.Extensions { diff --git a/boatstack/internal/softwaredelivery/protocol/receipt.go b/boatstack/internal/softwaredelivery/protocol/receipt.go index 83d0d79..4f81602 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 = 9 +const ReceiptSchemaVersion = 10 type TransitionFactKind string @@ -90,47 +90,50 @@ 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"` + 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"` } type AuthoritySource struct { @@ -196,6 +199,16 @@ 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 transition.ExecutionContext == "advance" { + prior, resulting := admission.Invocation, target.Invocation + if err := prior.Validate(true); err != nil { + return TransitionReceipt{}, fmt.Errorf("receipt prior invocation: %w", err) + } + if err := resulting.Validate(true); err != nil { + return TransitionReceipt{}, fmt.Errorf("receipt resulting invocation: %w", err) + } + receipt.ExecutionContext, receipt.PriorInvocation, receipt.ResultingInvocation = "advance", &prior, &resulting + } receipt.PriorProgramFingerprint = admission.PriorProgramFingerprint receipt.ProgramDeltaFingerprint = admission.ProgramDeltaFingerprint if accepted, ok := admission.Parameters.Get("accept_obligation_change"); ok && accepted == "true" { @@ -222,6 +235,19 @@ 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.ExecutionContext != "" { + if r.ExecutionContext != "advance" || r.PriorInvocation == nil || r.ResultingInvocation == nil { + return fmt.Errorf("receipt has invalid execution context lineage") + } + if err := r.PriorInvocation.Validate(true); err != nil { + return fmt.Errorf("receipt prior invocation: %w", err) + } + if err := r.ResultingInvocation.Validate(true); err != nil { + return fmt.Errorf("receipt resulting invocation: %w", err) + } + } else if r.PriorInvocation != nil || r.ResultingInvocation != nil { + return fmt.Errorf("receipt has invocation lineage without an execution context advance") + } 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/surfaces/artifacts_external_test.go b/boatstack/internal/softwaredelivery/surfaces/artifacts_external_test.go index 6736519..7741e90 100644 --- a/boatstack/internal/softwaredelivery/surfaces/artifacts_external_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/artifacts_external_test.go @@ -18,9 +18,9 @@ func TestCheckedArchitectureArtifactsMatchCompiledStandardProgram(t *testing.T) } transitions := program.Transitions() checks := map[string]string{ - "boatstack-v2-transition-catalog.md": surfaces.RenderCatalogMarkdown(transitions), - "boatstack-v2-transition-catalog.mmd": surfaces.RenderCatalogMermaid(transitions), - "boatstack-standard-flow.mmd": surfaces.RenderStandardFlowMermaid(transitions), + "boatstack-transition-catalog.md": surfaces.RenderCatalogMarkdown(transitions), + "boatstack-transition-catalog.mmd": surfaces.RenderCatalogMermaid(transitions), + "boatstack-standard-flow.mmd": surfaces.RenderStandardFlowMermaid(transitions), } safety, err := surfaces.RenderCatalogLocusSafety(transitions) if err != nil { @@ -30,8 +30,8 @@ func TestCheckedArchitectureArtifactsMatchCompiledStandardProgram(t *testing.T) if err != nil { t.Fatal(err) } - checks["boatstack-v2-locus-safety.json"] = safety - checks["boatstack-v2-locus-liveness.json"] = liveness + checks["boatstack-locus-safety.json"] = safety + checks["boatstack-locus-liveness.json"] = liveness _, file, _, ok := runtime.Caller(0) if !ok { diff --git a/boatstack/internal/softwaredelivery/surfaces/locus_render.go b/boatstack/internal/softwaredelivery/surfaces/locus_render.go index 49be866..dc262a5 100644 --- a/boatstack/internal/softwaredelivery/surfaces/locus_render.go +++ b/boatstack/internal/softwaredelivery/surfaces/locus_render.go @@ -84,11 +84,11 @@ func renderCatalogLocus(transitions []catalog.Transition, safety bool) (string, } result := locusModel{ SchemaVersion: 1, - ID: "boatstack-v2-executable-catalog-liveness-v1", + ID: "boatstack-executable-catalog-liveness-v1", Subject: "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", Evidence: []locusEvidence{ {Path: "boatstack/delivery/delivery.go", Note: "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry."}, - {Path: "docs/architecture/boatstack-v2-transition-catalog.md", Note: "Generated readable projection from the same runtime registry."}, + {Path: "docs/architecture/boatstack-transition-catalog.md", Note: "Generated readable projection from the same runtime registry."}, {Path: "boatstack/internal/softwaredelivery/protocol/admission.go", Note: "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."}, {Path: "boatstack/internal/softwaredelivery/engine/engine.go", Note: "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."}, {Path: "boatstack/internal/softwaredelivery/effects/state_reducer.go", Note: "Admitted native effects reduce every controllable Standard distribution transition through one state adapter."}, @@ -127,7 +127,7 @@ func renderCatalogLocus(transitions []catalog.Transition, safety bool) (string, } } if safety { - result.ID = "boatstack-v2-executable-catalog-safety-v1" + result.ID = "boatstack-executable-catalog-safety-v1" result.States = append(result.States, locusState{ID: "UNADMITTED_EFFECT"}) result.Transitions = append(result.Transitions, locusTransition{ From: "DORMANT", Event: "publication.execute", To: "UNADMITTED_EFFECT", Guard: "exact-admission", Evidence: []int{2, 3, 6}, Basis: "inferred", diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 61b8a89..68c3cdd 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -40,23 +40,26 @@ 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"` + 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"` } func (r Request) Validate(now time.Time) error { @@ -75,6 +78,14 @@ 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 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") + } + for _, authority := range r.DelegatedAuthorities { + if !authority.Valid() || authority == catalog.AuthorityNone { + return fmt.Errorf("surface delegated Flow request has invalid authority %q", authority) + } + } if r.Operation != OperationCatalog { knownHost := false for _, host := range CanonicalHostNames() { @@ -159,6 +170,15 @@ type Response struct { ProgramChange *ProgramChange `json:"program_change,omitempty"` Guard *supervisor.GuardDecision `json:"guard,omitempty"` Error string `json:"error,omitempty"` + Delegation *DelegationRequired `json:"delegation,omitempty"` +} + +type DelegationRequired struct { + Code string `json:"code"` + RunID string `json:"run_id"` + RequestFingerprint string `json:"request_fingerprint"` + Authorities []catalog.AuthorityClass `json:"authorities"` + Description string `json:"description"` } // Question is a typed suspension, not a background task. Supplying its diff --git a/boatstack/internal/softwaredelivery/surfaces/render_test.go b/boatstack/internal/softwaredelivery/surfaces/render_test.go index 67bb544..6d8267e 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/render_test.go @@ -191,7 +191,7 @@ func TestGuardPreservesConstitutionalDestructionFloor(t *testing.T) { `gcloud sql instances delete primary`, `aws ec2 terminate-instances --instance-ids i-1`, `gh pr merge 123 --squash`, - `printf '{}' > .git/boatstack/v2/worktrees/x/state.json`, + `printf '{}' > .git/boatstack/worktrees/x/state.json`, `Remove-Item -Recurse -Force .boatstack/evidence`, } for _, command := range destructive { diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 38ab0b3..d99bf99 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -1,4 +1,4 @@ -# V2 artifact ownership +# Boatstack artifact ownership Repository artifacts live below `.boatstack/` and are written only by registered effects: diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index 2346051..a11dca8 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -1,6 +1,6 @@ # Configuration schema -Boatstack V2 accepts only `.boatstack/project.json` schema version 2. The +Boatstack accepts only `.boatstack/project.json` schema version 2. The normative Go decoder is `internal/softwaredelivery/protocol.DecodeProjectConfig`; the public example is `project.example.json`. diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 5058114..2a6fcc4 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -1,8 +1,8 @@ -# V2 failure moves +# Boatstack failure moves Use the failure class, not the latest symptom: -| Failure class | V2 move | +| Failure class | Boatstack move | |---|---| | stale snapshot or prescription | discard it; re-resolve; execute no effects | | ambiguous identity | preserve resources; supply exact invocation | diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index f425a2f..3748d57 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -1,4 +1,4 @@ -# V2 workflow reference +# Boatstack workflow reference The executable catalog is the authority. Generate the full inventory with: diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go index e993ff4..b8d9b61 100644 --- a/boatstack/sdk/sdk_test.go +++ b/boatstack/sdk/sdk_test.go @@ -20,7 +20,7 @@ func TestPublicProtocolCanBeConstructedWithoutInternalPackages(t *testing.T) { Objective: sdk.Objective{ID: "objective", TargetID: sdk.ObjectiveVerified, DeliveryID: "delivery"}, } if request.Objective.TargetID != sdk.ObjectiveVerified || request.Operation != sdk.OperationResolve { - t.Fatalf("public V2 aliases lost protocol identity: %#v", request) + t.Fatalf("public Boatstack aliases lost protocol identity: %#v", request) } } diff --git a/boatstack/testdata/control-programs/incident-response.flow.ts b/boatstack/testdata/control-programs/incident-response.flow.ts index 50594a2..ce555aa 100644 --- a/boatstack/testdata/control-programs/incident-response.flow.ts +++ b/boatstack/testdata/control-programs/incident-response.flow.ts @@ -26,10 +26,11 @@ export default defineFlow({ operators: [ operator("restart", { capabilities: ["service.restart"], - authority: ["incident-commander"], + authority: { any_of: ["incident-commander"] }, effects: ["service.restart"], verifier: "healthcheck", recovery: "restart", + execution_context: "preserve", state_effect: { kind: "assignments", assignments: [{ facet: "incident", value: "mitigated" }], @@ -44,5 +45,5 @@ export default defineFlow({ }), ], targets: [marked("mitigated", fact("incident", ["mitigated"]))], - entries: [entry("respond", "mitigated")], + entries: [entry({ id: "respond", target: "mitigated" })], }); diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index f19ef0a..81de4d7 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,5 +1,6 @@ { - "schema_version": "control-program/v1", + "schema": "control-program", + "schema_revision": 1, "program": { "id": "incident-response", "version": "1" @@ -21,10 +22,11 @@ { "id": "restart", "capabilities": ["service.restart"], - "authority": ["incident-commander"], + "authority": { "any_of": ["incident-commander"] }, "effects": ["service.restart"], "verifier": "healthcheck", "recovery": "restart", + "execution_context": "preserve", "state_effect": { "kind": "assignments", "assignments": [{ "facet": "incident", "value": "mitigated" }] diff --git a/boatstack/testdata/control-programs/product-delivery-a.flow.ts b/boatstack/testdata/control-programs/product-delivery-a.flow.ts index 1a84f46..5c4978b 100644 --- a/boatstack/testdata/control-programs/product-delivery-a.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-a.flow.ts @@ -5,6 +5,7 @@ import { softwareDeliveryEvidence, softwareDeliveryFacets, trustedOperators, + trustedDelegation, trustedTransitions, type TrustedStep, } from "@operatorstack/boatstack-software-delivery"; @@ -27,5 +28,10 @@ export default defineFlow({ fact("runtime", ["verified"]), fact("publication", ["open"]), ))], - entries: [entry("run", "published-pr", [inbox(".boatstack/plans/inbox")])], + entries: [entry({ + id: "run", + target: "published-pr", + inputs: [inbox(".boatstack/plans/inbox")], + delegation: trustedDelegation("autonomy"), + })], }); diff --git a/boatstack/testdata/control-programs/product-delivery-b.flow.ts b/boatstack/testdata/control-programs/product-delivery-b.flow.ts index 641ada9..91cd646 100644 --- a/boatstack/testdata/control-programs/product-delivery-b.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-b.flow.ts @@ -35,7 +35,7 @@ export default defineFlow({ )), ], entries: [ - entry("deliver", "published-pr", [inbox(".boatstack/plans/inbox")]), - entry("cancel", "safely-abandoned", [inbox(".boatstack/plans/inbox")]), + entry({ id: "deliver", target: "published-pr", inputs: [inbox(".boatstack/plans/inbox")] }), + entry({ id: "cancel", target: "safely-abandoned", inputs: [inbox(".boatstack/plans/inbox")] }), ], }); diff --git a/boatstack/testdata/control-programs/product-delivery-c.flow.ts b/boatstack/testdata/control-programs/product-delivery-c.flow.ts index a6b974d..12219ae 100644 --- a/boatstack/testdata/control-programs/product-delivery-c.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-c.flow.ts @@ -32,5 +32,5 @@ export default defineFlow({ fact("runtime", ["verified"]), fact("publication", ["open"]), ))], - entries: [entry("run", "published-pr", [inbox(".boatstack/plans/inbox")])], + entries: [entry({ id: "run", target: "published-pr", inputs: [inbox(".boatstack/plans/inbox")] })], }); diff --git a/boatstack/testdata/v2-scenarios/historical.json b/boatstack/testdata/scenarios/historical.json similarity index 100% rename from boatstack/testdata/v2-scenarios/historical.json rename to boatstack/testdata/scenarios/historical.json diff --git a/docs/architecture/boatstack-v2-closure-report.md b/docs/architecture/boatstack-closure-report.md similarity index 88% rename from docs/architecture/boatstack-v2-closure-report.md rename to docs/architecture/boatstack-closure-report.md index 5280ee6..9916692 100644 --- a/docs/architecture/boatstack-v2-closure-report.md +++ b/docs/architecture/boatstack-closure-report.md @@ -1,13 +1,13 @@ -# Boatstack V2 replacement closure +# Boatstack replacement closure > Historical replacement evidence for PR #186. The normative current > architecture and executable counts are defined by -> [Boatstack programmable delivery control architecture](boatstack-v2-kernel.md). +> [Boatstack programmable delivery control architecture](boatstack-kernel.md). Base revision: `c5b5e10cdcf4d97b645d705cb164e762acf93ff1` Replacement mode: flag day; no V1 compatibility or state migration -This report binds the V2 implementation to the frozen V1 inventory. It is not a +This report binds Boatstack implementation to the frozen V1 inventory. It is not a claim that old APIs remain available. ## ZCA translation and value @@ -16,7 +16,7 @@ The rewrite ships two logical slices together. Slice 1 is one authoritative kernel over one canonical snapshot and transition catalog. Slice 2 is the CLI, hook, SDK/MCP, shell, and host projection of that same kernel. The immediate value is the removal of independently reconstructed lifecycle and effect -authority while keeping the product workflows available through V2 semantics. +authority while keeping the product workflows available through Boatstack semantics. ## Deleted authority @@ -32,7 +32,7 @@ rewrite deletes: coexistence, state-repair, host-state-machine, and fallback code. The conservative removed V1 managed-effect surface is therefore 120 sites. -V2's static source inventory fails if an `os` writer exists outside +Boatstack's static source inventory fails if an `os` writer exists outside `internal/softwaredelivery/effects`, if a command boundary exists outside the exact plant/effect allowlist, if a production file is unclassified, or if the deleted shadow controller is imported or recreated. @@ -49,10 +49,10 @@ The runtime has 17 controlling facets and 61 semantic transitions: | recovery | 7 | | observed-external | 13 | -The [generated table](boatstack-v2-transition-catalog.md), -[generated Mermaid graph](boatstack-v2-transition-catalog.mmd), -[Locus safety model](boatstack-v2-locus-safety.json), and -[Locus liveness model](boatstack-v2-locus-liveness.json) come from the same +The [generated table](boatstack-transition-catalog.md), +[generated Mermaid graph](boatstack-transition-catalog.mmd), +[Locus safety model](boatstack-locus-safety.json), and +[Locus liveness model](boatstack-locus-liveness.json) come from the same registry used by the supervisor and engine. Golden tests reject byte drift and require both formal alphabets to equal all 61 executable transitions. @@ -71,7 +71,7 @@ required-visual terminal until revision-bound evidence exists. The historical corpus contains 22 typed fixtures. It covers every PR from #172 through #185 and the additional ambiguity, interruption, stale-runtime, publication, workspace, configuration, and objective-terminal failure classes named -in the V2 specification. +in Boatstack specification. Live integration tests exercise embedded and detached installation, attach and detach, two-clone identity separation, exact runtime update, linked-worktree diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-kernel.md similarity index 97% rename from docs/architecture/boatstack-v2-kernel.md rename to docs/architecture/boatstack-kernel.md index 6cdbecd..c92e6e8 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-kernel.md @@ -1,20 +1,20 @@ # Boatstack programmable delivery control architecture Status: normative implementation specification -Base revision: `f7a5c9d1f2d15057f484371f348ee57311c0155e` (`origin/main`, after the V2 kernel replacement) +Base revision: `f7a5c9d1f2d15057f484371f348ee57311c0155e` (`origin/main`, after Boatstack kernel replacement) Implementation branch: `feat/control-program-and-standard-flow` Scope: separate mechanism, system capabilities, primary delivery flow, optional extensions, and product surfaces in one final pull request; no merge is authorized by this document -> Boatstack V2 is a flag-day replacement. Existing machine-local state may be +> Boatstack is a flag-day replacement. Existing machine-local state may be > discarded and regenerated. No V1 runtime remains after cutover. -This document is the source of truth for the Boatstack implementation. If code and this +This document is the source of truth for Boatstack implementation. If code and this document disagree, the discrepancy is a release blocker: either the code must be corrected or this document must be deliberately amended with matching tests. -The [replacement closure report](boatstack-v2-closure-report.md) binds its frozen -V1 counts to the implemented V2 evidence. +The [replacement closure report](boatstack-closure-report.md) binds its frozen +V1 counts to the implemented Boatstack evidence. ## ZCA projection and decisions @@ -47,7 +47,7 @@ atomic replacement. A user decision is required only if a new transition would change who may authorize an effect or what counts as a delivery terminal. Value emerges at the compilation boundary: the smallest valuable change is not -a second workflow engine, but one deterministic program that preserves the V2 +a second workflow engine, but one deterministic program that preserves Boatstack effect protocol while moving delivery policy out of the mechanism. The two jointly shipped slices are therefore (1) program compilation and Kernel execution, and (2) standard distribution and surface projection. @@ -282,7 +282,7 @@ Observable behavior is classified only as follows: terminals, visual evidence, safety hooks, configuration, cleanup/reap, abandonment, portable host guidance, evidence, receipts, and passive retrospectives. -- **NORMALIZE:** every preserved behavior crosses the V2 observation, resolution, +- **NORMALIZE:** every preserved behavior crosses Boatstack observation, resolution, admission, effect, verification, and receipt contracts. Commands and output text may change. Machine state, schemas, file layouts, Go APIs, and adapter protocols may change without compatibility shims. Visual capture is the @@ -294,10 +294,10 @@ Observable behavior is classified only as follows: insight/capture writers, and every other accidental or unsafe V1 behavior. There is deliberately no backward-compatibility promise. Historical behavior is -evidence about product value and failure classes, not a language or API that V2 +evidence about product value and failure classes, not a language or API that Boatstack must refine. Existing repositories may be reinstalled or reattached. Committed plans, specifications, approvals, evidence, PR briefs, configuration, and policy -are read as product inputs when they satisfy V2 schemas; accidental V1 machine +are read as product inputs when they satisfy Boatstack schemas; accidental V1 machine state is discarded. ## 2. Historical failure synthesis @@ -310,12 +310,12 @@ The history through PR #185 converges on one structural diagnosis: Local repairs repeatedly added a distinction or precedence rule to one resolver while another resolver, renderer, writer, or host retained a different model. -The V2 class-eliminating change is not another precedence rule. It is one runtime +The Boatstack class-eliminating change is not another precedence rule. It is one runtime snapshot, one transition registry, one supervisor, one admission path, one effect boundary, and one independently verified receipt protocol. The detailed episode inventory and fixture mapping are in Appendix A. The -structural classes carried into V2 are: +structural classes carried into Boatstack are: - control-insufficient state projection; - split transition, identity, configuration, and completion authority; @@ -478,7 +478,7 @@ They are one of: - `observed-external` (`Sigma_u`): the plant changed outside Boatstack; - `recovery` (`Sigma_c`): a bounded resume, rollback, reconcile, escalation, or abandonment event. External effects that have no proven inverse reconcile or - escalate; V2 does not register a generic fake compensation; + escalate; Boatstack does not register a generic fake compensation; - `query`: a read-only surface operation that cannot alter kernel state and is not counted as a managed transition. @@ -519,8 +519,8 @@ and reachability. CLI verbs and handlers map to IDs; they are not IDs. POSIX, PowerShell, SDK/MCP, and host instructions are renderings of the same typed prescription. The registry is executable runtime authority, not a shadow model. -The checked [catalog table](boatstack-v2-transition-catalog.md) and -[Mermaid graph](boatstack-v2-transition-catalog.mmd) are deterministic +The checked [catalog table](boatstack-transition-catalog.md) and +[Mermaid graph](boatstack-transition-catalog.mmd) are deterministic projections of this registry. Golden tests reject either artifact when it drifts. The checked [StandardFlow graph](boatstack-standard-flow.mmd) filters that same compiled registry by control-program origin and contains exactly 30 transitions; @@ -700,7 +700,7 @@ cannot resume the flow without a new objective or registered correction transiti ## 14. Package and dependency architecture -All V2 implementation lives below `boatstack/`; the top-level `boatstack` package +All Boatstack implementation lives below `boatstack/`; the top-level `boatstack` package is a product facade with no independent durable state or decision law. Dependencies point downward in this table and are acyclic. @@ -826,13 +826,13 @@ the catalog to code. They are theorem-only or advisory, not live-system proof. | `control.supervisory-rw` | full-observation internal model controllable | theorem-only | bind internal events to catalog | | `control.diagnosability` | partial surface projection diagnosable | theorem-only | consumer parity fixtures | | `control.supervisory-rw` on partial observation | refused because unobservable events make that operator inapplicable | correct refusal | diagnosability is the applicable surface claim | -| `verification.conservative-feature-extension` | refused: `intentional-redesign` | correct refusal | none; V2 has no compatibility obligation | -| `verification.trace-refinement` | corrected abstract protocol refines a minimal control envelope | non-normative theorem-only | not a V2 release gate or V1 compatibility claim | +| `verification.conservative-feature-extension` | refused: `intentional-redesign` | correct refusal | none; Boatstack has no compatibility obligation | +| `verification.trace-refinement` | corrected abstract protocol refines a minimal control envelope | non-normative theorem-only | not a Boatstack release gate or V1 compatibility claim | Derivation `drv-bbc6258499be4e1739a9d344f1d211682476da18be46c4bcee80227ed55f7d82` has current claim `theorem-only`. The explicit `verified` frontier terminates as `work-remaining`; rank 1 is -`discharge-obligation:control.nonblockingness:event-completeness`. V2 therefore +`discharge-obligation:control.nonblockingness:event-completeness`. Boatstack therefore cannot claim verified liveness until the real reader/writer/event/surface inventory is bound and accepted. @@ -850,8 +850,8 @@ Capability analysis records three separate dispositions without modifying Locus: ### Locus postimplementation disposition The executable registry now deterministically generates the checked -[safety model](boatstack-v2-locus-safety.json) and -[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 63 +[safety model](boatstack-locus-safety.json) and +[liveness model](boatstack-locus-liveness.json). Both contain exactly the 63 runtime events. The liveness abstraction expands the declared phase predicates to 496 inferred stable-phase edges over eight reachable phases; the safety model adds one guarded counterfactual edge and `UNADMITTED_EFFECT` state. @@ -885,7 +885,7 @@ branches, arbitrary third-party extension executables, fresh coding-host execution, operating-system interruption behavior, and external-provider truth remain executable integration evidence rather than whole-host formal proof. -## 18. Complete V2 replacement work order +## 18. Complete Boatstack replacement work order This is one atomic branch and one final PR. The order controls build safety, not rollout compatibility. @@ -910,7 +910,7 @@ rollout compatibility. 10. Update public docs and one release note, verify the exact pushed head, and open one concise PR. Do not merge. -Both logical slices must be present before any V2 runtime is publishable. No +Both logical slices must be present before any Boatstack runtime is publishable. No partial package rollout, feature flag, fallback, shadow execution, or second PR is permitted. @@ -931,7 +931,7 @@ The final tree must delete, not retain “just in case”: - effect authority or direct managed writers in activation, planning, plan, delivery, mutation, configuration, runtime, publication, update, safety, recovery, attach/detach, init/provision, visual publication, and helper command - paths; pure algorithms may survive only behind V2 ports; + paths; pure algorithms may survive only behind Boatstack ports; - direct workflow dispatch in `cmd/boatstack-helper`; - handwritten host/shell prescriptions that duplicate registry knowledge; - path-only effect APIs, first-match alias resolution, ambient engagement, saved- @@ -945,7 +945,7 @@ product operation needs an adapter, it targets the new facade/protocol directly. ## 20. Completion criteria -V2 is complete only when all criteria are evidenced at the exact final head. +Boatstack is complete only when all criteria are evidenced at the exact final head. Architecture: one runtime kernel, catalog, observer, explicit identity, admission path, receipt model, recovery model, and objective model own their respective @@ -962,7 +962,7 @@ without progress/recovery/frontier/safe terminal; zero consumer prescription disagreements; zero accepted failed postconditions or mixed epochs; zero default cleanup of unpublished/unresolved work; zero stale prescription admissions. -Behavior: valuable workflows remain possible through V2; safety is equal or +Behavior: valuable workflows remain possible through Boatstack; safety is equal or stronger; the historical corpus passes; adapted full Go and repository contract tests pass; race tests pass; Windows/macOS/Linux compile/check jobs pass; POSIX and PowerShell are semantically equivalent; all hosts consume kernel decisions. @@ -970,11 +970,11 @@ and PowerShell are semantically equivalent; all hosts consume kernel decisions. Formal closure: the executable catalog is the checked model; event, writer, and consumer inventories are complete; Locus safety and live coreachability results are supported by observed code evidence; the explicit `verified` frontier is -`target-met` or any remaining action is proved outside the declared V2 target. +`target-met` or any remaining action is proved outside the declared Boatstack target. Documentation and delivery: this specification matches code; diagrams are generated from the registry; public claims bind to tests; one release note -describes V2; one final PR has exact-head green CI; the PR is not automatically +describes Boatstack; one final PR has exact-head green CI; the PR is not automatically merged. ## Appendix A. Historical control-law episodes and regression corpus @@ -984,7 +984,7 @@ expected admitted transition, expected postcondition, forbidden transition, source provenance, and failure class. Rows may share a stronger class fixture, but every cited PR has an explicit provenance edge. -| Episode/provenance | Symptom and missing distinction | Split/mis-owned authority | V2 structural repair | Required fixture / removed accident | +| Episode/provenance | Symptom and missing distinction | Split/mis-owned authority | Boatstack structural repair | Required fixture / removed accident | | --- | --- | --- | --- | --- | | Initialization and repair, PRs #35-#37 | Partial initialization and repair could leave mixed or misleading state | Filesystem writes vs installed binding/runtime | Journaled staged initialization with binding last and verified receipt | Fail every write boundary; remove repair-by-presence | | Run/recovery, PRs #38-#39 | Interrupted commands could strand progress | Command success vs recovery state | Recovery is catalog state with bounded resume/rollback | Restart at each interruption; remove exception-path recovery | diff --git a/docs/architecture/boatstack-v2-locus-liveness.json b/docs/architecture/boatstack-locus-liveness.json similarity index 99% rename from docs/architecture/boatstack-v2-locus-liveness.json rename to docs/architecture/boatstack-locus-liveness.json index 7fa6766..3b365b1 100644 --- a/docs/architecture/boatstack-v2-locus-liveness.json +++ b/docs/architecture/boatstack-locus-liveness.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "id": "boatstack-v2-executable-catalog-liveness-v1", + "id": "boatstack-executable-catalog-liveness-v1", "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { @@ -8,7 +8,7 @@ "note": "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry." }, { - "path": "docs/architecture/boatstack-v2-transition-catalog.md", + "path": "docs/architecture/boatstack-transition-catalog.md", "note": "Generated readable projection from the same runtime registry." }, { diff --git a/docs/architecture/boatstack-v2-locus-safety.json b/docs/architecture/boatstack-locus-safety.json similarity index 99% rename from docs/architecture/boatstack-v2-locus-safety.json rename to docs/architecture/boatstack-locus-safety.json index cd3fa9e..8750c47 100644 --- a/docs/architecture/boatstack-v2-locus-safety.json +++ b/docs/architecture/boatstack-locus-safety.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "id": "boatstack-v2-executable-catalog-safety-v1", + "id": "boatstack-executable-catalog-safety-v1", "subject": "Finite stable-phase abstraction generated from the compiled Boatstack ControlProgram registry. It contains one event for every runtime entry and expands each declared source and target phase set. The 18-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.", "evidence": [ { @@ -8,7 +8,7 @@ "note": "Compiler combines exact CoreSystem, ProgramRuntime, extension, contract, and ownership declarations into one immutable runtime registry." }, { - "path": "docs/architecture/boatstack-v2-transition-catalog.md", + "path": "docs/architecture/boatstack-transition-catalog.md", "note": "Generated readable projection from the same runtime registry." }, { diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-transition-catalog.md similarity index 86% rename from docs/architecture/boatstack-v2-transition-catalog.md rename to docs/architecture/boatstack-transition-catalog.md index eaa9046..aeb8088 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-transition-catalog.md @@ -11,12 +11,12 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `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` | -| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | +| `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` | -| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | +| `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` | @@ -30,30 +30,30 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `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` | -| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | +| `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` | -| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | -| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | -| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | -| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | +| `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` | +| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `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` | @@ -62,13 +62,13 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `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` | -| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `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` | -| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | -| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | -| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`67b063fc0720a3a4d82d2e97639d473d13737922f0f799b11ab5790fc3dc6980` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `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` | +| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`7f08104fde178fbe5d03bdbe7f242ff4c627c647ac296139e2ea17e3f405fdbe` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-transition-catalog.mmd similarity index 100% rename from docs/architecture/boatstack-v2-transition-catalog.mmd rename to docs/architecture/boatstack-transition-catalog.mmd diff --git a/docs/architecture/boatstack-v1-authority-inventory.md b/docs/architecture/boatstack-v1-authority-inventory.md index eda61f8..ff7881d 100644 --- a/docs/architecture/boatstack-v1-authority-inventory.md +++ b/docs/architecture/boatstack-v1-authority-inventory.md @@ -4,7 +4,7 @@ Frozen against `c5b5e10cdcf4d97b645d705cb164e762acf93ff1`. This file is deletion The inventory uses conservative syntactic definitions so its counts are reproducible: -- **Direct lifecycle/completion decision declarations:** every function or method declaration in the nine files named by the V2 deletion contract as independent lifecycle/completion owners. +- **Direct lifecycle/completion decision declarations:** every function or method declaration in the nine files named by Boatstack deletion contract as independent lifecycle/completion owners. - **Supporting control-authority declarations:** every declaration in the additional authority-owning files named by that contract. - **Direct filesystem mutation sites:** every production call to the listed `os` mutation primitives. - **External-effect sites:** the generic command boundaries plus explicit Git mutation intents. Read-only Git observations are excluded; the generic boundaries are included because their arguments could request effects. diff --git a/docs/configuration.md b/docs/configuration.md index 9b90f2a..87f0f7e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ -# Boatstack V2 configuration +# Boatstack configuration -`.boatstack/project.json` is the repository-owned policy input. V2 accepts only +`.boatstack/project.json` is the repository-owned policy input. Boatstack accepts only schema version 2. Unknown top-level fields, unsupported policy values, duplicate hosts, trailing JSON, and missing required fields fail closed. @@ -127,4 +127,4 @@ boatstack attach --repo . --human alice \ ``` V1 configuration schemas are intentionally unsupported. Reinstall or supply a -new V2 document; no compatibility conversion runs. +new Boatstack document; no compatibility conversion runs. diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index b7ad0a4..07f54dc 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,12 +6,18 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program/v1` IR is domain-neutral. It declares typed facets, +The `control-program` schema at revision `1` is domain-neutral. It declares typed facets, evidence relations, predicate ASTs, operators, capabilities, authority, effects, verification, recovery, transitions, marked targets, and entries. Software terms such as plans, tests, Git, and pull requests belong to `@operatorstack/boatstack-software-delivery`, not the base SDK. +Trusted operator authority remains algebraic: `any_of` lists alternatives and +`all_of` lists mandatory classes. Repository transitions may add mandatory +authorities through `requires.authorities`; they cannot add alternatives or +grant authority. Entries may request a trusted delegation binding, but only the +runtime-owned authorization record can grant it for an exact run. + ## Compile and check Install the TypeScript frontend in the repository, resolve its absolute path, @@ -24,6 +30,17 @@ boatstack flow check --repo . boatstack next --repo . --flow product-delivery --entry run ``` +If the entry requests delegation, `next` returns `DELEGATION_REQUIRED` with an +exact run and request fingerprint before managed state changes. A human can +authorize that exact request and continue it: + +```sh +boatstack flow authorize --repo . --flow product-delivery --entry run \ + --run-id --request-fingerprint --human +boatstack flow run --repo . --flow product-delivery --entry run --run-id +boatstack flow revoke --repo . --run-id --human +``` + Compilation sends the exact source bytes to a restricted TypeScript frontend. The frontend path is explicit authority: Boatstack never selects or executes a repository `node_modules/.bin` program automatically. diff --git a/docs/generated-files.md b/docs/generated-files.md index 1491822..4c5f9a4 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -1,4 +1,4 @@ -# V2 files and ownership +# Boatstack files and ownership ## Committed product evidence @@ -6,7 +6,7 @@ Boatstack may create these reviewable paths through registered effects: | Path | Owner | Meaning | |---|---|---| -| `.boatstack/project.json` | repository policy | strict V2 configuration | +| `.boatstack/project.json` | repository policy | strict Boatstack configuration | | `.boatstack/plans/.source` | plan effect | exact source plan bytes | | `.boatstack/approvals/.json` | approval effect | plan-fingerprint and actor receipt | | `.boatstack/evidence//*.json` | gate/evidence effects | revision-bound build, test, review, journey, change, or visual evidence | @@ -43,6 +43,19 @@ a parent-directory symlink swap cannot redirect them outside the repository. ## Machine-local controller state +Boatstack uses only these canonical roots: + +| Context | Root | +|---|---| +| embedded | `/boatstack` | +| explicit state base | `/boatstack` | +| macOS | `~/Library/Application Support/boatstack` | +| XDG | `$XDG_STATE_HOME/boatstack` | + +It does not read or migrate generation-labelled roots. A run delegation record +lives under the external repository and Git-common Flow root. It is bound to +one run and protected by its own lock before controller or effect locks. + Embedded worktree state is partitioned under the Git common directory. Detached and hybrid state is partitioned under the platform state directory. Clone-family journals, locks, receipts, and event streams use a repository ID @@ -53,7 +66,7 @@ Workspace transfer writes both the parked source state and destination state in one staged manifest. Recovery journals are clone-shared so interruption remains discoverable even if a worktree was removed. -All V1 machine state is unsupported and may be deleted. V2 never searches for or +All V1 machine state is unsupported and may be deleted. Boatstack never searches for or falls back to it. ## Generated architecture evidence @@ -63,11 +76,11 @@ architecture artifacts: | Artifact | Regeneration command | |---|---| -| `docs/architecture/boatstack-v2-transition-catalog.md` | `boatstack-helper catalog --format markdown` | -| `docs/architecture/boatstack-v2-transition-catalog.mmd` | `boatstack-helper catalog --format mermaid` | +| `docs/architecture/boatstack-transition-catalog.md` | `boatstack-helper catalog --format markdown` | +| `docs/architecture/boatstack-transition-catalog.mmd` | `boatstack-helper catalog --format mermaid` | | `docs/architecture/boatstack-standard-flow.mmd` | `boatstack-helper catalog --format standard-flow-mermaid` | -| `docs/architecture/boatstack-v2-locus-safety.json` | `boatstack-helper catalog --format locus-safety` | -| `docs/architecture/boatstack-v2-locus-liveness.json` | `boatstack-helper catalog --format locus-liveness` | +| `docs/architecture/boatstack-locus-safety.json` | `boatstack-helper catalog --format locus-safety` | +| `docs/architecture/boatstack-locus-liveness.json` | `boatstack-helper catalog --format locus-liveness` | Repository and Go tests compare every checked byte with a fresh render and require both Locus alphabets to equal all 63 executable catalog transitions. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e14f34..e126650 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,4 +1,4 @@ -# Getting started with Boatstack V2 +# Getting started with Boatstack ## Install once @@ -131,7 +131,7 @@ exact manifest for that same revision with `evidence.visual.attach`, ## Use a linked worktree -`workspace-cut` verifies that the base contains the current V2 configuration, +`workspace-cut` verifies that the base contains the current Boatstack configuration, creates the worktree, transfers controller state to its exact identity, and parks the source checkout: diff --git a/docs/public-claims.json b/docs/public-claims.json index 8157a42..449d1a0 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -8,9 +8,9 @@ "claims": [ { "id": "one-authoritative-kernel", - "public_claim": "Every V2 lifecycle decision and managed effect crosses one executable transition registry and engine.", + "public_claim": "Every Boatstack lifecycle decision and managed effect crosses one executable transition registry and engine.", "status": "verified", - "readable_evidence": "architecture/boatstack-v2-kernel.md#14-package-and-dependency-architecture", + "readable_evidence": "architecture/boatstack-kernel.md#14-package-and-dependency-architecture", "implementation": [ "../boatstack/internal/softwaredelivery/engine/engine.go", "../boatstack/delivery/control.go", @@ -39,7 +39,7 @@ }, { "id": "transactional-recovery", - "public_claim": "V2 stages local resources, installs authority last, verifies a fresh target, and exposes interrupted work through a restart-safe journal.", + "public_claim": "Boatstack stages local resources, installs authority last, verifies a fresh target, and exposes interrupted work through a restart-safe journal.", "status": "verified", "readable_evidence": "safety.md#transaction-boundary", "implementation": [ @@ -57,7 +57,7 @@ "id": "consumer-parity", "public_claim": "CLI, Cursor, Codex, Claude Code, Gemini CLI, MCP, and the Go SDK project the same transition prescription.", "status": "verified", - "readable_evidence": "architecture/boatstack-v2-kernel.md#15-cli-hook-sdk-mcp-and-host-adapter-contracts", + "readable_evidence": "architecture/boatstack-kernel.md#15-cli-hook-sdk-mcp-and-host-adapter-contracts", "implementation": [ "../boatstack/internal/softwaredelivery/surfaces/protocol.go", "../boatstack/internal/softwaredelivery/surfaces/render.go", @@ -89,7 +89,7 @@ "id": "revision-bound-gate-proof", "public_claim": "Verified delivery requires current build, test, and review evidence; build and test execute guarded repository commands and generated proof cannot invalidate itself.", "status": "verified", - "readable_evidence": "architecture/boatstack-v2-kernel.md#11-verification-and-receipt-model", + "readable_evidence": "architecture/boatstack-kernel.md#11-verification-and-receipt-model", "implementation": [ "../boatstack/internal/softwaredelivery/protocol/admission.go", "../boatstack/internal/softwaredelivery/effects/artifacts.go", @@ -107,7 +107,7 @@ "id": "privacy-safe-process-events", "public_claim": "The passive JSONL stream derives from real transition receipts and excludes prompts, source, diffs, documents, command output, and secrets.", "status": "verified", - "readable_evidence": "architecture/boatstack-v2-kernel.md#16-process-telemetry-contract", + "readable_evidence": "architecture/boatstack-kernel.md#16-process-telemetry-contract", "implementation": [ "../boatstack/internal/softwaredelivery/effects/receipts.go", "../boatstack/delivery_controller.go" @@ -140,11 +140,11 @@ "id": "formal-live-system-closure", "public_claim": "The generated 63-event stable-phase abstraction satisfies the checked safety and liveness properties; executable tests separately bind catalog completeness, facets, reducer branches, operating-system behavior, and provider outcomes.", "status": "advisory", - "readable_evidence": "architecture/boatstack-v2-kernel.md#17-test-and-formal-property-strategy", + "readable_evidence": "architecture/boatstack-kernel.md#17-test-and-formal-property-strategy", "implementation": [ - "architecture/boatstack-v2-transition-catalog.md", - "architecture/boatstack-v2-locus-safety.json", - "architecture/boatstack-v2-locus-liveness.json", + "architecture/boatstack-transition-catalog.md", + "architecture/boatstack-locus-safety.json", + "architecture/boatstack-locus-liveness.json", "../boatstack/delivery/control.go", "../boatstack/core/transitions.json", "../boatstack/flow/standard/transitions.json" diff --git a/docs/public-surface.md b/docs/public-surface.md index 5062eac..c915b82 100644 --- a/docs/public-surface.md +++ b/docs/public-surface.md @@ -6,7 +6,7 @@ A new user should understand three facts before implementation detail: 1. Boatstack is one delivery controller, not a collection of host prompts. 2. Managed effects need exact evidence and authority. -3. V2 is a flag-day replacement with no V1 compatibility path. +3. Boatstack is a flag-day replacement with no V1 compatibility path. ## Required public evidence @@ -32,5 +32,5 @@ screenshots. ## Accessibility Public SVG assets keep a title, description, and `role="img"`. Command examples -must use registered V2 verbs and flags. Links and JSON examples must validate in +must use registered Boatstack verbs and flags. Links and JSON examples must validate in the repository contract. diff --git a/docs/safety.md b/docs/safety.md index 09a75f8..1e7ae58 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -1,4 +1,4 @@ -# Boatstack V2 safety +# Boatstack safety ## Evidence status diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d3790ff..afd151d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,4 +1,4 @@ -# Troubleshooting Boatstack V2 +# Troubleshooting Boatstack ## Doctor reports configuration drift diff --git a/install.ps1 b/install.ps1 index 1cdbf8f..42de744 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = "Stop" -# Boatstack V2 bootstrap trust boundary. The script installs a verified runtime; +# Boatstack bootstrap trust boundary. The script installs a verified runtime; # the kernel owns every subsequent repository mutation. $Repository = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Location).Path } @@ -10,7 +10,7 @@ $Actor = if ($env:BOATSTACK_ACTOR) { $env:BOATSTACK_ACTOR } elseif ($env:USERNAM $InstallDir = if ($env:BOATSTACK_INSTALL_DIR) { $env:BOATSTACK_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Boatstack\bin" } $BoatstackHome = if ($env:BOATSTACK_HOME) { $env:BOATSTACK_HOME } else { Join-Path $env:LOCALAPPDATA "Boatstack" } -if ($Mode -notin @("install", "update")) { throw "Boatstack V2 supports BOATSTACK_MODE=install or update" } +if ($Mode -notin @("install", "update")) { throw "Boatstack supports BOATSTACK_MODE=install or update" } $RepositoryOutput = & git -C $Repository rev-parse --show-toplevel $RepositoryStatus = $LASTEXITCODE if ($RepositoryStatus -ne 0 -or -not $RepositoryOutput) { throw "Boatstack installation requires a Git repository" } @@ -34,7 +34,7 @@ $Architecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSA default { throw "unsupported architecture" } } $Asset = "boatstack-helper_windows_$Architecture.exe" -$Temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("boatstack-v2-" + [guid]::NewGuid().ToString("N")) +$Temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("boatstack-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $Temporary | Out-Null try { @@ -116,7 +116,7 @@ try { $StagedLauncher = Join-Path $InstallDir (".boatstack-" + [guid]::NewGuid().ToString("N") + ".exe") Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher Move-Item -LiteralPath $StagedLauncher -Destination $Launcher -Force - Write-Host "Boatstack V2 installed at $Runtime" + Write-Host "Boatstack installed at $Runtime" Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host skills" Write-Host "Run: $Launcher doctor --repo `"$Repository`" --format text" } finally { diff --git a/install.sh b/install.sh index 4127ad6..fbe2934 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Boatstack V2 bootstrap trust boundary. This script installs a checksum-bound +# Boatstack bootstrap trust boundary. This script installs a checksum-bound # runtime transport. Every repository mutation is then performed by the # installation.initialize or installation.update kernel transition. @@ -15,7 +15,7 @@ config_source="${BOATSTACK_CONFIG:-}" case "$mode" in install|update) ;; - *) echo "Boatstack V2 supports BOATSTACK_MODE=install or update" >&2; exit 2 ;; + *) echo "Boatstack supports BOATSTACK_MODE=install or update" >&2; exit 2 ;; esac repository="$(git -C "$repository" rev-parse --show-toplevel)" @@ -127,6 +127,6 @@ launcher_staged="$install_dir/.boatstack.$$" install -m 0755 "$candidate" "$launcher_staged" mv -f "$launcher_staged" "$install_dir/boatstack" -echo "Boatstack V2 installed at $runtime" +echo "Boatstack installed at $runtime" echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host skills" echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 1754eb9..fe35c77 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -8,6 +8,7 @@ import { type FacetDefinition, type OperatorDefinition, type TransitionDefinition, + type DelegationBindingDefinition, } from "@operatorstack/boatstack"; const bindingPrefix = "software-delivery/"; @@ -60,6 +61,19 @@ export interface TrustedStep { priority: number; } +export interface TrustedTransitionOptions { + requires?: { authorities?: string[] }; +} + +export function trustedDelegation( + authority: "autonomy", +): DelegationBindingDefinition { + return { + reference: `${bindingPrefix}delegation/${authority}`, + version: "1", + }; +} + export function inbox(path: string): EntryInputDefinition { return { id: "plan", @@ -80,16 +94,21 @@ export function trustedOperators(steps: TrustedStep[]): OperatorDefinition[] { return steps.map(trustedOperator); } -export function trustedTransition(step: TrustedStep): TransitionDefinition { +export function trustedTransition( + step: TrustedStep, + options: TrustedTransitionOptions = {}, +): TransitionDefinition { return transition(step.id, step.id, { guard: always, target: always, priority: step.priority, + ...(options.requires ? { requires: options.requires } : {}), }); } export function trustedTransitions( steps: TrustedStep[], + options: TrustedTransitionOptions = {}, ): TransitionDefinition[] { - return steps.map(trustedTransition); + return steps.map((step) => trustedTransition(step, options)); } diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index 94796dd..91563ce 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -1,4 +1,5 @@ -export const CONTROL_PROGRAM_SCHEMA_VERSION = "control-program/v1" as const; +export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 1 as const; export type Predicate = | { true: boolean } @@ -54,11 +55,12 @@ export interface OperatorDefinition { id: string; binding?: { reference: string; version: string }; capabilities?: string[]; - authority?: string[]; + authority?: { any_of?: string[]; all_of?: string[] }; effects?: string[]; verifier?: string; recovery?: string; state_effect?: StateEffectDefinition; + execution_context?: "preserve" | "advance"; description?: string; } @@ -68,6 +70,7 @@ export interface TransitionDefinition { guard: Predicate; target: Predicate; priority: number; + requires?: { authorities?: string[] }; description?: string; } @@ -89,9 +92,15 @@ export interface EntryDefinition { id: string; target: string; inputs?: EntryInputDefinition[]; + delegation?: DelegationBindingDefinition; description?: string; } +export interface DelegationBindingDefinition { + reference: string; + version: string; +} + export interface FlowDefinition { id: string; version: string; @@ -112,7 +121,8 @@ export interface FlowDefinition { } export interface ControlProgramIR { - schema_version: typeof CONTROL_PROGRAM_SCHEMA_VERSION; + schema: typeof CONTROL_PROGRAM_SCHEMA; + schema_revision: typeof CONTROL_PROGRAM_SCHEMA_REVISION; program: { id: string; version: string; description?: string }; declarations: NonNullable; facets: FacetDefinition[]; @@ -126,7 +136,8 @@ export interface ControlProgramIR { export function defineFlow(definition: FlowDefinition): ControlProgramIR { return { - schema_version: CONTROL_PROGRAM_SCHEMA_VERSION, + schema: CONTROL_PROGRAM_SCHEMA, + schema_revision: CONTROL_PROGRAM_SCHEMA_REVISION, program: { id: definition.id, version: definition.version, @@ -184,13 +195,8 @@ export function marked( return { id, predicate, ...(description ? { description } : {}) }; } -export function entry( - id: string, - target: string, - inputs: EntryInputDefinition[] = [], - description?: string, -): EntryDefinition { - return { id, target, inputs, ...(description ? { description } : {}) }; +export function entry(definition: EntryDefinition): EntryDefinition { + return { ...definition, inputs: definition.inputs ?? [] }; } export const always: Predicate = { true: true }; diff --git a/release-notes/2026-08-13-repository-owned-run-delegation.md b/release-notes/2026-08-13-repository-owned-run-delegation.md new file mode 100644 index 0000000..f1badb9 --- /dev/null +++ b/release-notes/2026-08-13-repository-owned-run-delegation.md @@ -0,0 +1,2 @@ +### Repository-owned run delegation +Repository Flows can request one human-approved autonomy grant for an exact run. Boatstack preserves mandatory provider authority and checks revocation, expiry, context, and program identity before each effect. From ef7dd8b7ae22a7826344bdf5eab99acae00945a7 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 12:11:08 +0100 Subject: [PATCH 2/6] fix: preserve generated skill bytes across worktrees --- boatstack/cmd/boatstack-helper/flow_runtime_test.go | 5 +++-- boatstack/controlprogram/artifact.go | 3 +++ boatstack/flow/softwaredelivery/skills.go | 6 +++++- boatstack/flow/softwaredelivery/skills_test.go | 13 +++++++++---- docs/generated-files.md | 4 ++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 19e6853..da034e7 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -412,7 +412,7 @@ func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { if err != nil { t.Fatal(err) } - if len(artifact.GeneratedSkills) != 3 { + if len(artifact.GeneratedSkills) != 5 { t.Fatalf("generated skills = %v", artifact.GeneratedSkills) } for path := range artifact.GeneratedSkills { @@ -1082,13 +1082,14 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) runFlowGit(t, repository, "init", "-q") runFlowGit(t, repository, "config", "user.email", "test@example.com") runFlowGit(t, repository, "config", "user.name", "Test User") + runFlowGit(t, repository, "config", "core.autocrlf", "true") document := productDeliveryDocument("product-delivery") document.Entries[0].Delegation = &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"} sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" source, dependencyLock := []byte("flow source"), []byte("lock") writeFixture(t, repository, sourcePath, source) writeFixture(t, repository, lockPath, dependencyLock) - writeFixture(t, repository, ".boatstack/plans/inbox/delivery.md", []byte("# Delivery\n")) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.md", []byte("# Delivery")) writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, dependencyLock) writeFixture(t, repository, "README.md", []byte("fixture\n")) runFlowGit(t, repository, "add", ".") diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 076a390..49cd4e6 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -102,6 +102,9 @@ func safeGeneratedSkillPath(value string) bool { return false } parts := strings.Split(value, "/") + if len(parts) == 4 && (parts[0] == ".agents" || parts[0] == ".claude") && parts[1] == "skills" && validID(parts[2]) && parts[3] == ".gitattributes" { + return true + } if len(parts) == 4 && parts[0] == ".agents" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "SKILL.md" { return true } diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index c3aa837..78fdfd0 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -11,6 +11,7 @@ import ( func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { result := map[string][]byte{} + const exactCheckoutAttributes = "** -text" for _, entry := range compiled.Document.Entries { slug := flowSkillSlug(compiled.Document.Program.ID, entry.ID) if slug == "boatstack-update" { @@ -21,10 +22,13 @@ func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[strin switch host { case "codex": root := filepath.ToSlash(filepath.Join(".agents", "skills", slug)) + result[root+"/.gitattributes"] = []byte(exactCheckoutAttributes) result[root+"/SKILL.md"] = skill result[root+"/agents/openai.yaml"] = []byte(fmt.Sprintf("interface:\n display_name: %q\n short_description: %q\n default_prompt: %q\npolicy:\n allow_implicit_invocation: false\n", title(slug), entry.Description, "Use $"+slug+" to run the repository-owned Boatstack Flow entry.")) case "claude": - result[filepath.ToSlash(filepath.Join(".claude", "skills", slug, "SKILL.md"))] = skill + root := filepath.ToSlash(filepath.Join(".claude", "skills", slug)) + result[root+"/.gitattributes"] = []byte(exactCheckoutAttributes) + result[root+"/SKILL.md"] = skill default: return nil, fmt.Errorf("unsupported generated Flow skill host %q", host) } diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index 8dc5369..d23a04d 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -21,8 +21,13 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { if err != nil { t.Fatal(err) } - if len(files) != 3 { - t.Fatalf("generated file count = %d, want 3", len(files)) + if len(files) != 5 { + t.Fatalf("generated file count = %d, want 5", len(files)) + } + for _, path := range []string{".agents/skills/product-delivery-run/.gitattributes", ".claude/skills/product-delivery-run/.gitattributes"} { + if string(files[path]) != "** -text" { + t.Fatalf("%s does not protect exact generated bytes", path) + } } codex := files[".agents/skills/product-delivery-run/SKILL.md"] claude := files[".claude/skills/product-delivery-run/SKILL.md"] @@ -87,8 +92,8 @@ func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testin if err != nil { t.Fatal(err) } - if len(files) != 6 { - t.Fatalf("generated file count = %d, want 6", len(files)) + if len(files) != 10 { + t.Fatalf("generated file count = %d, want 10", len(files)) } run := string(files[".agents/skills/product-delivery-run/SKILL.md"]) for _, contract := range []string{"never retarget this run", "$product-delivery-cancel", "abandonment receipt", "starting a new run"} { diff --git a/docs/generated-files.md b/docs/generated-files.md index 4c5f9a4..494b0ab 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -23,8 +23,10 @@ committed runtime inputs: | Path | Owner | Meaning | |---|---|---| | `.boatstack/flows/.flow.ir.json` | Flow compiler | canonical IR plus source, lock, binding, and generated-file hashes | +| `.agents/skills/-/.gitattributes` | Flow compiler | preserves exact Codex projection bytes across Git checkouts | | `.agents/skills/-/SKILL.md` | Flow compiler | Codex entry projection | | `.agents/skills/-/agents/openai.yaml` | Flow compiler | Codex skill metadata | +| `.claude/skills/-/.gitattributes` | Flow compiler | preserves exact Claude projection bytes across Git checkouts | | `.claude/skills/-/SKILL.md` | Flow compiler | Claude entry projection | Skill identities are injective across program and entry pairs: hyphens in the @@ -34,6 +36,8 @@ kernel maintenance and cannot be generated by a repository Flow. `boatstack flow check` rejects stale sources, dependency locks, trusted bindings, program fingerprints, or skills. Runtime commands load only the checked IR artifact; they do not execute the TypeScript source. +Each generated skill directory disables Git text conversion for its owned +files, so a later Windows worktree preserves the canonical artifact bytes. Compilation also rejects repository module imports, concurrent projection, and source or lock changes observed through projection commit. It requires an explicit absolute frontend path, revalidates retirement authorization under the From 0b3b015b635b4c8a25800899a40a4d85a4bc57f7 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 12:25:12 +0100 Subject: [PATCH 3/6] fix: renew expired run delegation --- .../boatstack-helper/delegation_command.go | 63 ++++++++++++++----- .../cmd/boatstack-helper/flow_runtime_test.go | 28 ++++++++- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index e805586..3ff3a4b 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -38,6 +38,9 @@ func runFlowAuthorize(arguments []string) error { if flags.NArg() != 0 || options.runID == "" || requestFingerprint == "" || options.humanActor == "" { return fmt.Errorf("flow authorize requires --flow, --entry, --run-id, --request-fingerprint, and --human") } + if expiresIn < 0 { + return fmt.Errorf("flow authorize --expires-in cannot be negative") + } bound, err := bindFlowEntry(context.Background(), options) if err != nil { return err @@ -70,32 +73,62 @@ func runFlowAuthorize(arguments []string) error { if err != nil { return err } - if existing, loadErr := delegation.Load(recordPath); loadErr == nil { - if existing.RequestFingerprint == requestFingerprint && existing.Actor == options.humanActor && existing.Status == "active" { - return printDelegationRecord(existing) - } - return fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") + var existing *delegation.Record + if loaded, loadErr := delegation.Load(recordPath); loadErr == nil { + existing = &loaded } else if !os.IsNotExist(loadErr) { return loadErr } now := time.Now().UTC() - receiptDigest := sha256.Sum256([]byte(requestFingerprint + "\x00" + options.humanActor)) + record, changed, err := authorizeDelegation(existing, bound.delegationRequest, requestFingerprint, options.humanActor, expiresIn, now) + if err != nil { + return err + } + if changed { + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + return err + } + } + return printDelegationRecord(record) +} + +func authorizeDelegation(existing *delegation.Record, request delegation.Request, requestFingerprint, actor string, expiresIn time.Duration, now time.Time) (delegation.Record, bool, error) { + if expiresIn < 0 { + return delegation.Record{}, false, fmt.Errorf("flow authorize --expires-in cannot be negative") + } + if existing != nil { + if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.Status != "active" { + return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") + } + if existing.ExpiresAt.IsZero() || now.Before(existing.ExpiresAt) { + return *existing, false, nil + } + record := *existing + record.Revision++ + record.AuthorizedAt = now + record.ExpiresAt = time.Time{} + if expiresIn > 0 { + record.ExpiresAt = now.Add(expiresIn) + } + record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, record.Revision, now) + record.RevokedAt, record.EndedAt, record.EndReason = time.Time{}, time.Time{}, "" + return record, true, nil + } record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, - Request: bound.delegationRequest, RequestFingerprint: requestFingerprint, - ReceiptID: "authorization-" + hex.EncodeToString(receiptDigest[:12]), Actor: options.humanActor, + Request: request, RequestFingerprint: requestFingerprint, + ReceiptID: authorizationReceiptID(requestFingerprint, actor, 1, now), Actor: actor, AuthorizedAt: now, Revision: 1, Status: "active", } - if expiresIn < 0 { - return fmt.Errorf("flow authorize --expires-in cannot be negative") - } if expiresIn > 0 { record.ExpiresAt = now.Add(expiresIn) } - if err := effects.StoreDelegationRecord(recordPath, record); err != nil { - return err - } - return printDelegationRecord(record) + return record, true, nil +} + +func authorizationReceiptID(requestFingerprint, actor string, revision uint64, authorizedAt time.Time) string { + receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", requestFingerprint, actor, revision, authorizedAt.UTC().Format(time.RFC3339Nano)))) + return "authorization-" + hex.EncodeToString(receiptDigest[:12]) } func runFlowRevoke(arguments []string) error { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index da034e7..e68f236 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1134,6 +1134,32 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("authorized resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) } + record.ExpiresAt = now.Add(-time.Second) + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + t.Fatal(err) + } + if expiredLock, expiredSuspension, expiredErr := prepareDelegation(context.Background(), &request); expiredLock != nil || expiredSuspension != nil || expiredErr == nil || !strings.Contains(expiredErr.Error(), "DELEGATION_EXPIRED") { + t.Fatalf("expired delegation = lock=%v response=%#v err=%v", expiredLock, expiredSuspension, expiredErr) + } + renewedAt := time.Now().UTC() + renewed, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt) + if err != nil || !changed || renewed.Revision != record.Revision+1 || renewed.ReceiptID == record.ReceiptID || !renewed.ExpiresAt.Equal(renewedAt.Add(time.Hour)) { + t.Fatalf("renewed delegation = record=%#v changed=%v err=%v", renewed, changed, err) + } + if idempotent, changedAgain, idempotentErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, record.Actor, time.Hour, renewedAt.Add(time.Second)); idempotentErr != nil || changedAgain || idempotent.ReceiptID != renewed.ReceiptID { + t.Fatalf("idempotent renewal = record=%#v changed=%v err=%v", idempotent, changedAgain, idempotentErr) + } + if _, _, conflictErr := authorizeDelegation(&renewed, bound.delegationRequest, bound.delegationRequestFingerprint, "other-actor", time.Hour, renewedAt); conflictErr == nil || !strings.Contains(conflictErr.Error(), "DELEGATION_CONFLICT") { + t.Fatalf("conflicting renewal = %v", conflictErr) + } + if err := effects.StoreDelegationRecord(recordPath, renewed); err != nil { + t.Fatal(err) + } + record = renewed + lock, suspension, err = prepareDelegation(context.Background(), &request) + if err != nil || lock != nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("renewed resolve = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) + } 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"}) @@ -1171,7 +1197,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err := lock.Release(); err != nil { t.Fatal(err) } - record.Status, record.Revision, record.RevokedAt = "revoked", 2, time.Now().UTC() + record.Status, record.Revision, record.RevokedAt = "revoked", record.Revision+1, time.Now().UTC() if err := effects.StoreDelegationRecord(recordPath, record); err != nil { t.Fatal(err) } From a82f98c6bf8fa74fdab3fef17ca37afab40db73e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 12:34:06 +0100 Subject: [PATCH 4/6] fix: settle delegated run context --- .../boatstack-helper/delegation_command.go | 34 ++++++++++++- .../boatstack-helper/delegation_runtime.go | 30 +++++++----- .../cmd/boatstack-helper/flow_runtime_test.go | 48 +++++++++++++++++++ boatstack/cmd/boatstack-helper/main.go | 4 +- boatstack/delivery_controller.go | 8 ++++ 5 files changed, 110 insertions(+), 14 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 3ff3a4b..43e733c 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -219,6 +219,9 @@ func runFlowContinuation(arguments []string) error { options.trustedObjectiveClass = string(response.Objective.TrustedObjectiveClass()) options.deliveryID = response.Objective.DeliveryID } + if err := advanceContinuation(&options, response); err != nil { + return err + } if response.Delegation != nil || response.Prescription == nil || response.Receipt == nil { return renderResponse(response, options.format) } @@ -247,7 +250,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa return surfaces.Response{}, err } resolved, err := kernel.Handle(ctx, resolveRequest) - if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved); settleErr != nil && err == nil { + if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } if err != nil || resolved.Prescription == nil { @@ -286,3 +289,32 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } return applied, nil } + +func advanceContinuation(options *commandOptions, response surfaces.Response) error { + if response.Receipt == nil { + return nil + } + if response.Receipt.ExecutionContext == "advance" { + if response.Receipt.ResultingInvocation == nil { + return fmt.Errorf("FLOW_RUN_SUSPENDED: advancing receipt has no resulting invocation") + } + resulting := *response.Receipt.ResultingInvocation + if err := resulting.Validate(true); err != nil { + return fmt.Errorf("FLOW_RUN_SUSPENDED: invalid resulting invocation: %w", err) + } + options.repository = resulting.InvokingPath + } + options.transitionID = "" + options.parameters = nil + options.prescriptionID = "" + options.expectedInstanceID = "" + options.expectedStateRevision = 0 + options.expectedProgramFingerprint = "" + options.expectedSnapshotFingerprint = "" + options.expectedObjectiveBindingFingerprint = "" + options.authorityFingerprint = "" + options.requiredCapabilities = nil + options.effectiveCapabilities = nil + options.idempotencyKey = "" + return nil +} diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 92ed3bd..a95f82c 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -107,31 +107,39 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return lock, nil, nil } -func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response) error { - if len(request.DelegatedAuthorities) == 0 || response.Decision == nil || response.Decision.Kind != supervisor.DecisionTerminal { +func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response, targetSatisfied, lockHeld bool) error { + terminalDecision := response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal + committedTarget := response.Receipt != nil && targetSatisfied + if len(request.DelegatedAuthorities) == 0 || (!terminalDecision && !committedTarget) { return nil } resolver, err := plant.NewResolver("") if err != nil { return err } - invocation, err := resolver.ResolveInvocation(ctx, request.Repository, request.Host, request.CorrelationID) - if err != nil { - return err + repository := request.Repository + if committedTarget && response.Snapshot != nil { + repository = response.Snapshot.Invocation.InvokingPath } - layout, _, err := resolver.ResolveLayout(ctx, invocation) + invocation, err := resolver.ResolveInvocation(ctx, repository, request.Host, request.CorrelationID) if err != nil { return err } - lockPath, err := delegation.LockPath(layout.LockRoot, request.FlowID) + layout, _, err := resolver.ResolveLayout(ctx, invocation) if err != nil { return err } - lock, err := effects.AcquireExclusivePath(ctx, lockPath) - if err != nil { - return err + if !lockHeld { + lockPath, err := delegation.LockPath(layout.LockRoot, request.FlowID) + if err != nil { + return err + } + lock, err := effects.AcquireExclusivePath(ctx, lockPath) + if err != nil { + return err + } + defer lock.Release() } - defer lock.Release() recordPath, err := delegation.Path(layout.FlowRoot, request.FlowID) if err != nil { return err diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index e68f236..d13a2a8 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -239,6 +239,27 @@ func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { if sourcePath, ok := parameters.Get("source_path"); !ok || sourcePath != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { t.Fatalf("destination plan binding = %q, %t", sourcePath, ok) } + + continuation := commandOptions{ + repository: source, transitionID: "workspace.cut", + parameters: []string{"base_ref=HEAD", "destination=" + destination}, + prescriptionID: "prescription-cut", expectedInstanceID: "instance-source", + expectedStateRevision: 7, expectedProgramFingerprint: strings.Repeat("a", 64), + expectedSnapshotFingerprint: strings.Repeat("b", 64), expectedObjectiveBindingFingerprint: strings.Repeat("c", 64), + authorityFingerprint: strings.Repeat("d", 64), requiredCapabilities: []string{"workspace.write"}, + effectiveCapabilities: []string{"workspace.write"}, idempotencyKey: "idem-cut", + } + resulting := model.InvocationContext{ + RepositoryID: "repository", GitCommonID: "git-common", WorktreeID: "destination", Ref: "refs/heads/feature", + ControllerID: "controller", InvokingPath: destination, RuntimeVersion: "runtime", RuntimePath: destination, + RuntimeFingerprint: strings.Repeat("e", 64), Topology: model.TopologyEmbedded, Host: "codex", Correlation: "continuation", + } + if err := advanceContinuation(&continuation, surfaces.Response{Receipt: &protocol.TransitionReceipt{ExecutionContext: "advance", ResultingInvocation: &resulting}}); err != nil { + t.Fatal(err) + } + if continuation.repository != destination || continuation.transitionID != "" || len(continuation.parameters) != 0 || continuation.prescriptionID != "" || continuation.idempotencyKey != "" || len(continuation.requiredCapabilities) != 0 || len(continuation.effectiveCapabilities) != 0 { + t.Fatalf("continuation retained source context or transition parameters: %#v", continuation) + } } func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { @@ -1205,4 +1226,31 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if lock != nil || suspension != nil || err == nil || !strings.Contains(err.Error(), "DELEGATION_REVOKED") { t.Fatalf("revoked apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) } + record.Status, record.Revision, record.RevokedAt = "active", record.Revision+1, time.Time{} + if err := effects.StoreDelegationRecord(recordPath, record); err != nil { + t.Fatal(err) + } + committed := surfaces.Response{Receipt: &protocol.TransitionReceipt{ID: "target-receipt"}} + delegationLockPath, err := delegation.LockPath(layout.LockRoot, bound.runID) + if err != nil { + t.Fatal(err) + } + heldDelegationLock, err := effects.AcquireExclusivePath(context.Background(), delegationLockPath) + if err != nil { + t.Fatal(err) + } + if err := settleDelegationAtTarget(context.Background(), request, committed, true, true); err != nil { + t.Fatal(err) + } + if err := heldDelegationLock.Release(); err != nil { + t.Fatal(err) + } + completed, err := delegation.Load(recordPath) + if err != nil || completed.Status != "completed" || completed.EndReason != "target-met" || completed.Revision != record.Revision+1 { + t.Fatalf("target settlement = record=%#v err=%v", completed, err) + } + lock, suspension, err = prepareDelegation(context.Background(), &request) + if lock != nil || suspension != nil || err == nil || !strings.Contains(err.Error(), "DELEGATION_REVOKED") { + t.Fatalf("post-target apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) + } } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 8cba925..0b25e19 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -168,7 +168,7 @@ func run(arguments []string) error { request.Prescription = *resolved.Prescription } response, handleErr := kernel.Handle(context.Background(), request) - if settleErr := settleDelegationAtTarget(context.Background(), request, response); settleErr != nil && handleErr == nil { + if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr } if command == "events" && options.follow { @@ -224,7 +224,7 @@ func runRPC() error { return err } response, handleErr := kernel.Handle(context.Background(), request) - if settleErr := settleDelegationAtTarget(context.Background(), request, response); settleErr != nil && handleErr == nil { + if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr } encoder := json.NewEncoder(os.Stdout) diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index 2b0243d..ebca244 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -39,6 +39,14 @@ type DeliveryController struct { clock effects.Clock } +// TargetSatisfied reports whether the exact compiled program marks the +// objective in the supplied canonical snapshot. Callers use this after a +// committed apply so runtime-owned authorization can end without requiring a +// second resolve. +func (k DeliveryController) TargetSatisfied(snapshot *model.Snapshot, objective model.Objective) bool { + return snapshot != nil && k.program.RuntimeObjectiveContracts().Matches(*snapshot, objective) +} + func NewDeliveryController(externalStateRoot string, program delivery.ControlProgram) (DeliveryController, error) { if program.Fingerprint() == "" || program.TransitionCount() == 0 { return DeliveryController{}, fmt.Errorf("DeliveryController requires an immutable compiled ControlProgram") From d48809acb22a33f88779897d996b08869e63b188 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 12:34:33 +0100 Subject: [PATCH 5/6] fix: settle continuous run target --- boatstack/cmd/boatstack-helper/delegation_command.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 43e733c..d1f6ef6 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -279,6 +279,9 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } defer lease.Release() applied, err := kernel.Handle(ctx, applyRequest) + if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective), delegationLock != nil); settleErr != nil && err == nil { + err = settleErr + } if err != nil { return applied, err } From 86a2965bd8e8677077526a107a888d2b2f0145bc Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 13 Aug 2026 12:51:32 +0100 Subject: [PATCH 6/6] fix: close delegated run at target --- .../boatstack-helper/delegation_command.go | 15 +++++++++++++-- .../boatstack-helper/delegation_runtime.go | 19 ++++++++++++------- .../cmd/boatstack-helper/flow_runtime_test.go | 6 ++++++ .../effects/command_boundary_test.go | 13 +++++++++++++ .../softwaredelivery/supervisor/classify.go | 1 + .../softwaredelivery/surfaces/render_test.go | 4 ++++ 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index d1f6ef6..a322c5a 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -15,6 +15,7 @@ import ( "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/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -222,7 +223,7 @@ func runFlowContinuation(arguments []string) error { if err := advanceContinuation(&options, response); err != nil { return err } - if response.Delegation != nil || response.Prescription == nil || response.Receipt == nil { + if response.Delegation != nil || response.Prescription == nil || response.Receipt == nil || (response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal) { return renderResponse(response, options.format) } } @@ -279,12 +280,22 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } defer lease.Release() applied, err := kernel.Handle(ctx, applyRequest) - if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective), delegationLock != nil); settleErr != nil && err == nil { + targetSatisfied := kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective) + if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { err = settleErr } if err != nil { return applied, err } + if targetSatisfied { + snapshotFingerprint := "" + if applied.Snapshot != nil { + snapshotFingerprint = applied.Snapshot.Fingerprint + } + applied.Decision = &supervisor.Decision{Kind: supervisor.DecisionTerminal, SnapshotFingerprint: snapshotFingerprint, Reason: "marked Flow target is established"} + applied.Prescription = nil + return applied, nil + } // Mark the response as a completed internal continuation step. The next // iteration resolves again from the committed receipt and durable state. if applied.Prescription == nil { diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index a95f82c..0ea3723 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -82,6 +82,18 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_CONTEXT_UNAUTHORIZED: current worktree is not in the verified run lineage") } + filtered := request.Authority.Receipts[:0] + for _, receipt := range request.Authority.Receipts { + if len(receipt.ID) < len("delegation-") || receipt.ID[:len("delegation-")] != "delegation-" { + filtered = append(filtered, receipt) + } + } + request.Authority.Receipts = filtered + if record.Status == "completed" && request.Operation == surfaces.OperationResolve { + // A completed delegation carries no authority, but resolving the exact + // bound run remains safe and lets restarts replay its terminal state. + return nil, nil, nil + } if record.Status != "active" { releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_REVOKED: run authorization is %s", record.Status) @@ -90,13 +102,6 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_EXPIRED: run authorization expired") } - filtered := request.Authority.Receipts[:0] - for _, receipt := range request.Authority.Receipts { - if len(receipt.ID) < len("delegation-") || receipt.ID[:len("delegation-")] != "delegation-" { - filtered = append(filtered, receipt) - } - } - request.Authority.Receipts = filtered for _, authority := range request.DelegatedAuthorities { receiptDigest := sha256.Sum256([]byte(record.ReceiptID + "\x00" + string(authority))) request.Authority.Receipts = append(request.Authority.Receipts, protocol.AuthorityReceipt{ diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index d13a2a8..3324212 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1249,6 +1249,12 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || completed.Status != "completed" || completed.EndReason != "target-met" || completed.Revision != record.Revision+1 { t.Fatalf("target settlement = record=%#v err=%v", completed, err) } + request.Operation = surfaces.OperationResolve + lock, suspension, err = prepareDelegation(context.Background(), &request) + if lock != nil || suspension != nil || err != nil || request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { + t.Fatalf("completed resolve replay = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) + } + request.Operation = surfaces.OperationApply lock, suspension, err = prepareDelegation(context.Background(), &request) if lock != nil || suspension != nil || err == nil || !strings.Contains(err.Error(), "DELEGATION_REVOKED") { t.Fatalf("post-target apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index fa67eb9..2cc694d 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -86,6 +86,19 @@ func TestConfiguredBuildCommandCannotCrossConstitutionalGuard(t *testing.T) { } } +func TestConfiguredBuildCommandCannotMintDelegation(t *testing.T) { + runner := &boundaryRunner{} + boundary, err := NewNativeBoundaryWithRunner(runner) + if err != nil { + t.Fatal(err) + } + transition, _ := testprogram.StandardRegistry().Lookup("gate.build.record") + _, err = boundary.Execute(context.Background(), boundaryAdmission(transition), transition, writeBoundaryConfig(t, "boatstack flow authorize --run-id forged --request-fingerprint forged --human victim"), durable.State{}) + if err == nil || !strings.Contains(err.Error(), "delegation.authorize") || runner.calls != 0 { + t.Fatalf("repository command minted delegation: err=%v calls=%d", err, runner.calls) + } +} + func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) { runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/7","number":7,"mergedAt":null,"baseRefName":"main","headRefName":"feature","headRefOid":"revision","isCrossRepository":false}`)} boundary, err := NewNativeBoundaryWithRunner(runner) diff --git a/boatstack/internal/softwaredelivery/supervisor/classify.go b/boatstack/internal/softwaredelivery/supervisor/classify.go index 16f4fe9..15de4f9 100644 --- a/boatstack/internal/softwaredelivery/supervisor/classify.go +++ b/boatstack/internal/softwaredelivery/supervisor/classify.go @@ -17,6 +17,7 @@ type commandPattern struct { // receives the same result. Raw command text is fingerprinted but never // persisted in a receipt or process event. var destructiveCommandPatterns = []commandPattern{ + {"delegation.authorize", regexp.MustCompile(`(?i)\bboatstack(?:-helper)?\s+flow\s+authorize\b`)}, {"database.reset", regexp.MustCompile(`(?i)\b(?:supabase\s+db\s+reset|prisma\s+migrate\s+reset|rails\s+db:(?:drop|reset)|django-admin\s+flush|manage\.py\s+flush|alembic\s+downgrade\s+base|pg_restore\b[^\n]*\s--clean\b)`)}, {"external-resource.delete", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+delete\b`)}, {"external-lifecycle.weaken", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+update\b[^\n;&|]*(?:--persistent(?:=|[\s"',()]+)false\b|--no-persistent\b)`)}, diff --git a/boatstack/internal/softwaredelivery/surfaces/render_test.go b/boatstack/internal/softwaredelivery/surfaces/render_test.go index 6d8267e..ce1fe85 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/render_test.go @@ -165,6 +165,10 @@ func TestGuardClassifierIsShellNeutralAndPrivacyBounded(t *testing.T) { if managed.Class != supervisor.IntentManagedBypass || managed.Operation != "publication.create" || managed.Transition != "" { t.Fatalf("managed intent = %#v", managed) } + authorize := ClassifyCommandIntent("boatstack flow authorize --run-id run --request-fingerprint fingerprint --human victim") + if authorize.Class != supervisor.IntentDestructive || authorize.Operation != "delegation.authorize" || authorize.Transition != "" { + t.Fatalf("delegation authorization intent = %#v", authorize) + } ordinary := ClassifyCommandIntent("go test ./...") if ordinary.Class != supervisor.IntentOrdinary || ordinary.Transition != "" { t.Fatalf("ordinary intent = %#v", ordinary)