From df5b6b16ca0e6173f3c66f1fda20052b4bf558ba Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 19:55:33 +0100 Subject: [PATCH 1/2] Add entry activation authority to Control Programs --- .../boatstack-helper/delegation_command.go | 33 ++++++--- .../boatstack-helper/delegation_runtime.go | 50 +++++++++---- .../cmd/boatstack-helper/flow_command.go | 6 +- .../cmd/boatstack-helper/flow_runtime.go | 48 +++++++++--- .../cmd/boatstack-helper/flow_runtime_test.go | 56 ++++++++------ .../boatstack-helper/human_identity_test.go | 8 +- boatstack/cmd/boatstack-helper/main.go | 20 ++--- .../product_delivery_flow_e2e_test.go | 22 +++--- boatstack/controlprogram/artifact.go | 2 +- boatstack/controlprogram/canonical.go | 8 ++ boatstack/controlprogram/canonical_test.go | 32 ++++++++ boatstack/controlprogram/ir.go | 15 ++-- .../flow/softwaredelivery/projections.go | 27 ++++--- .../flow/softwaredelivery/projections_test.go | 5 +- .../softwaredelivery/delegation/record.go | 73 ++++++++++++------- .../delegation/record_test.go | 38 +++++++++- .../effects/delegation_record_test.go | 2 +- .../softwaredelivery/surfaces/protocol.go | 28 ++++--- .../surfaces/protocol_test.go | 28 +++++++ .../incident-response.raw.json | 2 +- ...t-delivery-planning-package-manual.flow.ts | 1 + .../product-delivery-planning-package.flow.ts | 1 + ...product-delivery-planning-package.raw.json | 2 +- docs/control-program-ir.md | 26 +++++-- docs/generated-files.md | 7 +- docs/getting-started.md | 6 ++ .../authority-and-delegation.md | 14 +++- docs/product-delivery/targets-and-entries.md | 6 ++ docs/product-delivery/writing-a-flow.md | 1 + .../boatstack-software-delivery/src/index.ts | 1 + .../test/software-delivery.test.mjs | 6 +- packages/boatstack/src/index.ts | 12 ++- .../2026-08-17-entry-activation-authority.md | 6 ++ 33 files changed, 437 insertions(+), 155 deletions(-) create mode 100644 release-notes/2026-08-17-entry-activation-authority.md diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 8e7f188..8ae4804 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -84,7 +84,7 @@ func runFlowAuthorize(arguments []string) error { if err != nil { return err } - programChange, err := preflightDelegatedProgramChange(context.Background(), resolveRequest) + programChange, err := preflightFlowAuthorizationProgramChange(context.Background(), resolveRequest) if err != nil { return err } @@ -183,6 +183,19 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request return record, true, nil } if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityRole != request.HumanIdentityRole || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" { + if existing.RequestFingerprint == requestFingerprint && existing.Actor == actor && existing.ActorIdentityRole == request.HumanIdentityRole && existing.ActorIdentityProviderFingerprint == identityProviderFingerprint && existing.Status == "revoked" { + reauthorized := *existing + reauthorized.Revision++ + reauthorized.AuthorizedAt = now + reauthorized.ExpiresAt = time.Time{} + if expiresIn > 0 { + reauthorized.ExpiresAt = now.Add(expiresIn) + } + reauthorized.ReceiptID = authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, reauthorized.Revision, now) + reauthorized.Status = "active" + reauthorized.RevokedAt, reauthorized.EndedAt, reauthorized.EndReason = time.Time{}, time.Time{}, "" + return reauthorized, true, nil + } 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) { @@ -326,7 +339,7 @@ func runFlowContinuationOptions(options commandOptions) error { if err := advanceContinuation(&options, response); err != nil { return err } - if response.Delegation != nil || response.CommitRequired != nil || response.Prescription == nil || response.Receipt == nil || (response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal) { + if response.Authorization != nil || response.CommitRequired != nil || response.Prescription == nil || response.Receipt == nil || (response.Decision != nil && response.Decision.Kind == supervisor.DecisionTerminal) { return renderResponse(response, options.format) } } @@ -342,14 +355,14 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } - programChangeResponse, err := preflightDelegatedProgramChange(ctx, resolveRequest) + programChangeResponse, err := preflightFlowAuthorizationProgramChange(ctx, resolveRequest) if err != nil { return surfaces.Response{}, err } if programChangeResponse != nil { return *programChangeResponse, nil } - _, delegationResponse, err := prepareDelegation(ctx, &resolveRequest) + _, delegationResponse, err := prepareFlowAuthorization(ctx, &resolveRequest) if err != nil { return surfaces.Response{}, err } @@ -370,7 +383,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } resolved, err := handleWithHumanIdentity(ctx, kernel, resolveRequest) resolveLease.Release() - if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { + if settleErr := settleFlowAuthorizationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } if err == nil { @@ -397,14 +410,14 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if err != nil { return surfaces.Response{}, err } - programChangeResponse, err = preflightDelegatedProgramChange(ctx, resolveRequest) + programChangeResponse, err = preflightFlowAuthorizationProgramChange(ctx, resolveRequest) if err != nil { return surfaces.Response{}, err } if programChangeResponse != nil { return *programChangeResponse, nil } - _, delegationResponse, err = prepareDelegation(ctx, &resolveRequest) + _, delegationResponse, err = prepareFlowAuthorization(ctx, &resolveRequest) if err != nil { return surfaces.Response{}, err } @@ -425,7 +438,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } resolved, err = handleWithHumanIdentity(ctx, kernel, resolveRequest) resolveLease.Release() - if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { + if settleErr := settleFlowAuthorizationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil { err = settleErr } if err != nil || resolved.Prescription == nil { @@ -452,7 +465,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa if applyRequest.InputRequest != nil { return surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, ProgramID: applyRequest.ProgramID, EntryID: applyRequest.EntryID, RunID: applyRequest.FlowID, InputRequest: applyRequest.InputRequest}, nil } - delegationLock, delegationResponse, err := prepareDelegation(ctx, &applyRequest) + delegationLock, delegationResponse, err := prepareFlowAuthorization(ctx, &applyRequest) if err != nil { return surfaces.Response{}, err } @@ -467,7 +480,7 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa } applied, err := handleWithHumanIdentity(ctx, kernel, applyRequest) targetSatisfied := kernel.TargetSatisfied(applied.Snapshot, applyRequest.Objective) - if settleErr := settleDelegationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { + if settleErr := settleFlowAuthorizationAtTarget(ctx, applyRequest, applied, targetSatisfied, delegationLock != nil); settleErr != nil && err == nil { err = settleErr } if err != nil { diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index e81a4d1..a76f2ea 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -26,7 +26,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo prior.TargetID != current.TargetID || prior.ObjectiveID != current.ObjectiveID || prior.DeliveryID != current.DeliveryID || prior.RepositoryID != current.RepositoryID || prior.GitCommonID != current.GitCommonID || prior.BindingFingerprint != current.BindingFingerprint || prior.Description != current.Description || - !sameStringSet(prior.InputFingerprints, current.InputFingerprints) || !sameStringSet(prior.RequestedAuthorities, current.RequestedAuthorities) { + !equalEntryInputFingerprints(prior.InputFingerprints, current.InputFingerprints) || !sameStringSet(prior.EntryActivationAuthorities, current.EntryActivationAuthorities) || !sameStringSet(prior.RequestedAuthorities, current.RequestedAuthorities) { return false, nil } initial := invocation @@ -69,8 +69,8 @@ func sameStringSet(left, right []string) bool { return slices.Equal(left, right) } -func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { - if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 { +func prepareFlowAuthorization(ctx context.Context, request *surfaces.Request) (ports.Lock, *surfaces.Response, error) { + if request.ProgramID == "" || (len(request.EntryActivationAuthorities) == 0 && len(request.DelegatedAuthorities) == 0) { return nil, nil, nil } presentation, err := humanIdentityPresentationForRequest(*request) @@ -123,20 +123,21 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - response, responseErr := delegationRequiredResponse(*request) + response, responseErr := flowAuthorizationRequiredResponse(*request) return nil, response, responseErr } 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.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || record.Request.HumanIdentityRole != presentation.Role || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityRole != presentation.Role || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint { + if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.ControlBundleFingerprint != request.ControlBundleFingerprint || record.Request.EntryID != request.EntryID || record.Request.TargetID != string(request.Objective.TargetID) || record.Request.ObjectiveID != request.Objective.ID || record.Request.DeliveryID != request.Objective.DeliveryID || record.Request.RepositoryID != invocation.RepositoryID || record.Request.GitCommonID != invocation.GitCommonID || record.Request.BindingFingerprint != request.DelegationBindingFingerprint || !sameAuthorityClasses(record.Request.EntryActivationAuthorities, request.EntryActivationAuthorities) || record.Request.HumanIdentityRole != presentation.Role || record.Request.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || record.ActorIdentityRole != presentation.Role || record.ActorIdentityProviderFingerprint != presentation.ProviderFingerprint { current := record.Request current.RunID, current.ProgramID, current.ProgramFingerprint, current.ControlBundleFingerprint = request.FlowID, request.ProgramID, request.ProgramFingerprint, request.ControlBundleFingerprint current.EntryID, current.TargetID, current.ObjectiveID, current.DeliveryID = request.EntryID, string(request.Objective.TargetID), request.Objective.ID, request.Objective.DeliveryID current.RepositoryID, current.GitCommonID = invocation.RepositoryID, invocation.GitCommonID current.InitialWorktreeID, current.InitialRef = invocation.WorktreeID, invocation.Ref current.BindingFingerprint, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.Role, presentation.ProviderFingerprint + current.EntryActivationAuthorities = authorityStrings(request.EntryActivationAuthorities) current.RequestedAuthorities = make([]string, len(request.DelegatedAuthorities)) for index, authority := range request.DelegatedAuthorities { current.RequestedAuthorities[index] = string(authority) @@ -157,7 +158,7 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - response, responseErr := delegationRequiredResponse(*request) + response, responseErr := flowAuthorizationRequiredResponse(*request) return nil, response, responseErr } return nil, nil, fmt.Errorf("DELEGATION_DRIFT: authorization does not match the current run context") @@ -183,6 +184,10 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } + if record.Status == "revoked" { + response, responseErr := flowAuthorizationRequiredResponse(*request) + return nil, response, responseErr + } return nil, nil, fmt.Errorf("DELEGATION_REVOKED: run authorization is %s", record.Status) } if !record.ExpiresAt.IsZero() && !time.Now().UTC().Before(record.ExpiresAt) { @@ -190,7 +195,8 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo if request.Operation == surfaces.OperationExplain { return nil, nil, nil } - return nil, nil, fmt.Errorf("DELEGATION_EXPIRED: run authorization expired") + response, responseErr := flowAuthorizationRequiredResponse(*request) + return nil, response, responseErr } for _, authority := range request.DelegatedAuthorities { receiptDigest := sha256.Sum256([]byte(record.ReceiptID + "\x00" + string(authority))) @@ -202,23 +208,39 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo return lock, nil, nil } -func delegationRequiredResponse(request surfaces.Request) (*surfaces.Response, error) { +func flowAuthorizationRequiredResponse(request surfaces.Request) (*surfaces.Response, error) { presentation, err := humanIdentityPresentationForRequest(request) if err != nil { return nil, err } + code := "DELEGATION_REQUIRED" + if len(request.EntryActivationAuthorities) != 0 { + code = "ENTRY_ACTIVATION_AUTHORITY_REQUIRED" + } return &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", HumanIdentity: presentation}, + Authorization: &surfaces.FlowAuthorizationRequired{Code: code, RunID: request.FlowID, RequestFingerprint: request.DelegationRequestFingerprint, EntryActivationAuthorities: append([]catalog.AuthorityClass(nil), request.EntryActivationAuthorities...), DelegatedAuthorities: append([]catalog.AuthorityClass(nil), request.DelegatedAuthorities...), Description: "Explicitly authorize " + request.ProgramID + "/" + request.EntryID + " for this exact run", HumanIdentity: presentation}, }, nil } -// preflightDelegatedProgramChange observes the selected program before any +func authorityStrings(values []catalog.AuthorityClass) []string { + result := make([]string, len(values)) + for index, value := range values { + result[index] = string(value) + } + return result +} + +func sameAuthorityClasses(left []string, right []catalog.AuthorityClass) bool { + return sameStringSet(left, authorityStrings(right)) +} + +// preflightFlowAuthorizationProgramChange observes the selected program before any // product delegation is requested. Reconciliation changes the control bundle, // so authorizing against the prior bundle would create an authorization that // must be rejected immediately after the accepted maintenance transition. -func preflightDelegatedProgramChange(ctx context.Context, request surfaces.Request) (*surfaces.Response, error) { - if request.ProgramID == "" || len(request.DelegatedAuthorities) == 0 || request.Operation == surfaces.OperationExplain { +func preflightFlowAuthorizationProgramChange(ctx context.Context, request surfaces.Request) (*surfaces.Response, error) { + if request.ProgramID == "" || (len(request.EntryActivationAuthorities) == 0 && len(request.DelegatedAuthorities) == 0) || request.Operation == surfaces.OperationExplain { return nil, nil } probe := request @@ -263,10 +285,10 @@ func isExactProgramChangeSuspension(response surfaces.Response) bool { response.ProgramChange.AcceptanceFlag == "--accept-program-change" } -func settleDelegationAtTarget(ctx context.Context, request surfaces.Request, response surfaces.Response, targetSatisfied, lockHeld bool) error { +func settleFlowAuthorizationAtTarget(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) { + if (len(request.EntryActivationAuthorities) == 0 && len(request.DelegatedAuthorities) == 0) || (!terminalDecision && !committedTarget) { return nil } resolver, err := plant.NewResolver("") diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 636f0ad..eeb2b41 100644 --- a/boatstack/cmd/boatstack-helper/flow_command.go +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -23,7 +23,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) -const flowCompilerVersion = "control-program.compiler.7" +const flowCompilerVersion = "control-program.compiler.8" type flowCommandOptions struct { repository string @@ -345,8 +345,8 @@ func validateDeclarativeFlow(compiled controlprogram.Compiled) error { facets[facet.ID] = facet } for _, entry := range compiled.Document.Entries { - if entry.Delegation != nil || entry.Diagnostics != nil { - return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative entries do not support delegation or domain diagnostics") + if entry.Delegation != nil || entry.Diagnostics != nil || len(entry.Requires.Authorities) != 0 { + return fmt.Errorf("FLOW_RUNTIME_INVALID: declarative entries do not support activation authority, delegation, or domain diagnostics") } } for _, work := range compiled.Document.Work { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index b89e8d1..9d12b02 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" @@ -238,9 +239,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, // Installation authority transitions must not consume or create product // delegation. Their accepted effects establish or change the exact bundle // to which later product delegation is bound. + authorizationRequired := len(entry.Requires.Authorities) != 0 || entry.Delegation != nil delegationRecordPresent := false - if entry.Delegation != nil && options.transitionID == "" && !options.delegationRequestProjection { - delegationRecordPresent, err = flowDelegationRecordPresent(ctx, repository, options) + if authorizationRequired && options.transitionID == "" && !options.delegationRequestProjection { + delegationRecordPresent, err = flowAuthorizationRecordPresent(ctx, repository, options) if err != nil { return commandOptions{}, err } @@ -248,7 +250,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, // Resolve an unbound frontier before creating product delegation. This lets // installation authority establish the exact control bundle first; the // subsequent product candidate then receives delegation bound to that bundle. - if entry.Delegation != nil && (repositoryTransition || acceptedBundleRevision != "" || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { + if authorizationRequired && (repositoryTransition || acceptedBundleRevision != "" || options.delegationRequestProjection || delegationRecordPresent) && !installationAuthority { contextResolver, resolverErr := plant.NewResolver("") if resolverErr != nil { return commandOptions{}, resolverErr @@ -269,13 +271,20 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if presentationErr != nil { return commandOptions{}, presentationErr } + bindingFingerprint := "" + delegatedAuthorities := []string(nil) + if entry.Delegation != nil { + bindingFingerprint = entry.Delegation.Fingerprint + delegatedAuthorities = append(delegatedAuthorities, entry.Delegation.Authorities...) + } delegationRequest := delegation.Request{ RunID: options.runID, ProgramID: options.programID, ProgramFingerprint: compiled.Fingerprint, ControlBundleFingerprint: bundleFingerprint, EntryID: options.entryID, TargetID: string(objective.TargetID), ObjectiveID: options.objectiveID, DeliveryID: deliveryID, - InputFingerprints: []string{planFingerprint}, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, + InputFingerprints: entryInputFingerprints(options.workInputs), RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, - BindingFingerprint: entry.Delegation.Fingerprint, RequestedAuthorities: append([]string(nil), entry.Delegation.Authorities...), + EntryActivationAuthorities: append([]string(nil), entry.Requires.Authorities...), + BindingFingerprint: bindingFingerprint, RequestedAuthorities: delegatedAuthorities, HumanIdentityRole: presentation.Role, HumanIdentityProviderFingerprint: presentation.ProviderFingerprint, Description: description, @@ -290,8 +299,8 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, } if record, loadErr := delegation.Load(recordPath); loadErr == nil { bound := record.Request - inputDrift := !options.activeFlowBound && strings.Join(bound.InputFingerprints, "\x00") != strings.Join(delegationRequest.InputFingerprints, "\x00") - if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || bound.HumanIdentityRole != delegationRequest.HumanIdentityRole || bound.HumanIdentityProviderFingerprint != delegationRequest.HumanIdentityProviderFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + inputDrift := !options.activeFlowBound && !equalEntryInputFingerprints(bound.InputFingerprints, delegationRequest.InputFingerprints) + if bound.RunID != delegationRequest.RunID || bound.ProgramID != delegationRequest.ProgramID || bound.ProgramFingerprint != delegationRequest.ProgramFingerprint || bound.ControlBundleFingerprint != delegationRequest.ControlBundleFingerprint || bound.EntryID != delegationRequest.EntryID || bound.TargetID != delegationRequest.TargetID || bound.ObjectiveID != delegationRequest.ObjectiveID || bound.DeliveryID != delegationRequest.DeliveryID || inputDrift || bound.RepositoryID != delegationRequest.RepositoryID || bound.GitCommonID != delegationRequest.GitCommonID || bound.BindingFingerprint != delegationRequest.BindingFingerprint || bound.HumanIdentityRole != delegationRequest.HumanIdentityRole || bound.HumanIdentityProviderFingerprint != delegationRequest.HumanIdentityProviderFingerprint || strings.Join(bound.EntryActivationAuthorities, "\x00") != strings.Join(delegationRequest.EntryActivationAuthorities, "\x00") || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { reprojected, reprojectErr := canReprojectDelegation(layout, invocation, bound, delegationRequest) if reprojectErr != nil { return commandOptions{}, reprojectErr @@ -310,9 +319,10 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if fingerprintErr != nil { return commandOptions{}, fingerprintErr } - options.delegationBindingFingerprint = entry.Delegation.Fingerprint + options.delegationBindingFingerprint = bindingFingerprint options.delegationRequestFingerprint = fingerprint - options.delegationAuthorities = append(options.delegationAuthorities[:0], entry.Delegation.Authorities...) + options.entryActivationAuthorities = append(options.entryActivationAuthorities[:0], entry.Requires.Authorities...) + options.delegationAuthorities = append(options.delegationAuthorities[:0], delegatedAuthorities...) options.delegationDescription = description options.delegationRequest = delegationRequest } @@ -525,7 +535,7 @@ func newFlowCommitRequiredError(options commandOptions, revision, bundleFingerpr } } -func flowDelegationRecordPresent(ctx context.Context, repository string, options commandOptions) (bool, error) { +func flowAuthorizationRecordPresent(ctx context.Context, repository string, options commandOptions) (bool, error) { resolver, err := plant.NewResolver("") if err != nil { return false, err @@ -555,6 +565,23 @@ func flowDelegationRecordPresent(ctx context.Context, repository string, options } } +func entryInputFingerprints(inputs map[string]protocol.WorkInputValue) []delegation.InputFingerprint { + result := make([]delegation.InputFingerprint, 0, len(inputs)) + for id, input := range inputs { + result = append(result, delegation.InputFingerprint{ID: id, Fingerprint: input.Fingerprint}) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func equalEntryInputFingerprints(left, right []delegation.InputFingerprint) bool { + left = append([]delegation.InputFingerprint(nil), left...) + right = append([]delegation.InputFingerprint(nil), right...) + sort.Slice(left, func(i, j int) bool { return left[i].ID < left[j].ID }) + sort.Slice(right, func(i, j int) bool { return right[i].ID < right[j].ID }) + return slices.Equal(left, right) +} + func bindInternalFlowContextParameters(ctx context.Context, options *commandOptions) error { manifest, err := core.System().CoreManifest(ctx) if err != nil { @@ -1090,6 +1117,7 @@ func bindRPCFlowEntryWithMaintenance(ctx context.Context, request surfaces.Reque request.Parameters = parameters request.DelegationBindingFingerprint = bound.delegationBindingFingerprint request.DelegationRequestFingerprint = bound.delegationRequestFingerprint + request.EntryActivationAuthorities = delegationClasses(bound.entryActivationAuthorities) request.DelegatedAuthorities = delegationClasses(bound.delegationAuthorities) request.WorkInputs = bound.workInputs request.ControlBundle = bound.controlBundle diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index d017fb2..89a0a3a 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -2999,13 +2999,16 @@ func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedProjections(t *testing.T) } func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) { - // control-law: repository-declaration-cannot-self-grant-and-apply-reloads-revocation-before-effects + // control-law: one event may bind activation and delegation, but only the + // delegation scope can materialize a reusable authority receipt. 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") runFlowGit(t, repository, "config", "core.autocrlf", "true") document := productDeliveryDocument("product-delivery") + document.Declarations.Authorities = append(document.Declarations.Authorities, "human") + document.Entries[0].Requires.Authorities = []string{"human"} 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") @@ -3027,12 +3030,12 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) 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 { + lock, suspension, err := prepareFlowAuthorization(context.Background(), &request) + if err != nil || lock != nil || suspension == nil || suspension.Authorization == nil || suspension.Authorization.Code != "ENTRY_ACTIVATION_AUTHORITY_REQUIRED" || suspension.Authorization.RequestFingerprint != bound.delegationRequestFingerprint || !reflect.DeepEqual(suspension.Authorization.EntryActivationAuthorities, []catalog.AuthorityClass{catalog.AuthorityHuman}) || !reflect.DeepEqual(suspension.Authorization.DelegatedAuthorities, []catalog.AuthorityClass{catalog.AuthorityAutonomy}) { t.Fatalf("delegation suspension = lock=%v response=%#v err=%v", lock, suspension, err) } - if suspension.Delegation.HumanIdentity.Descriptor.Kind != "literal" || suspension.Delegation.HumanIdentity.Descriptor.Value != "operator" || suspension.Delegation.HumanIdentity.ProviderFingerprint != bound.delegationRequest.HumanIdentityProviderFingerprint { - t.Fatalf("delegation human identity = %#v, request = %#v", suspension.Delegation.HumanIdentity, bound.delegationRequest) + if suspension.Authorization.HumanIdentity.Descriptor.Kind != "literal" || suspension.Authorization.HumanIdentity.Descriptor.Value != "operator" || suspension.Authorization.HumanIdentity.ProviderFingerprint != bound.delegationRequest.HumanIdentityProviderFingerprint { + t.Fatalf("delegation human identity = %#v, request = %#v", suspension.Authorization.HumanIdentity, bound.delegationRequest) } authority := request.Authority.Set(time.Now().UTC()) for _, forbidden := range []catalog.AuthorityClass{catalog.AuthorityHuman, catalog.AuthorityAutonomy, catalog.AuthorityProvider} { @@ -3044,15 +3047,15 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil { t.Fatal(err) } - _, restartedSuspension, err := prepareDelegation(context.Background(), &restartedRequest) - if err != nil || restartedSuspension == nil || restartedSuspension.Delegation == nil || !reflect.DeepEqual(restartedSuspension.Delegation.HumanIdentity, suspension.Delegation.HumanIdentity) || restartedSuspension.Delegation.RequestFingerprint != suspension.Delegation.RequestFingerprint { - t.Fatalf("restart changed delegation identity: first=%#v restarted=%#v err=%v", suspension.Delegation, restartedSuspension, err) + _, restartedSuspension, err := prepareFlowAuthorization(context.Background(), &restartedRequest) + if err != nil || restartedSuspension == nil || restartedSuspension.Authorization == nil || !reflect.DeepEqual(restartedSuspension.Authorization.HumanIdentity, suspension.Authorization.HumanIdentity) || restartedSuspension.Authorization.RequestFingerprint != suspension.Authorization.RequestFingerprint { + t.Fatalf("restart changed delegation identity: first=%#v restarted=%#v err=%v", suspension.Authorization, restartedSuspension, err) } explainRequest, err := buildRequest(surfaces.OperationExplain, bound) if err != nil { t.Fatal(err) } - explainLock, explainSuspension, err := prepareDelegation(context.Background(), &explainRequest) + explainLock, explainSuspension, err := prepareFlowAuthorization(context.Background(), &explainRequest) if err != nil || explainLock != nil || explainSuspension != nil || explainRequest.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("explain created missing delegation authority: lock=%v response=%#v authority=%#v err=%v", explainLock, explainSuspension, explainRequest.Authority, err) } @@ -3094,7 +3097,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil { t.Fatal(err) } - explainLock, explainSuspension, err = prepareDelegation(context.Background(), &explainRequest) + explainLock, explainSuspension, err = prepareFlowAuthorization(context.Background(), &explainRequest) if err != nil || explainLock != nil || explainSuspension != nil || !explainRequest.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("explain did not project existing delegation: lock=%v response=%#v authority=%#v err=%v", explainLock, explainSuspension, explainRequest.Authority, err) } @@ -3102,10 +3105,13 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil || !bytes.Equal(recordBeforeExplain, recordAfterExplain) { t.Fatalf("explain changed delegation record: %v", err) } - lock, suspension, err = prepareDelegation(context.Background(), &request) + lock, suspension, err = prepareFlowAuthorization(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) } + if request.Authority.Set(time.Now().UTC())[catalog.AuthorityHuman] { + t.Fatalf("entry activation approval leaked human authority: %#v", request.Authority) + } refreshedApply, _, err := refreshFlowInvocation(context.Background(), surfaces.OperationApply, request, bound) if err != nil { t.Fatal(err) @@ -3131,7 +3137,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatal(err) } replayedExplain.Authority.Receipts = append(replayedExplain.Authority.Receipts, replayedReceipt) - replayedLock, replayedSuspension, err := prepareDelegation(context.Background(), &replayedExplain) + replayedLock, replayedSuspension, err := prepareFlowAuthorization(context.Background(), &replayedExplain) if err != nil || replayedLock != nil || replayedSuspension != nil || replayedExplain.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] { t.Fatalf("missing-record explain replayed delegation authority: lock=%v response=%#v authority=%#v err=%v", replayedLock, replayedSuspension, replayedExplain.Authority, err) } @@ -3153,7 +3159,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) 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") { + if expiredLock, expiredSuspension, expiredErr := prepareFlowAuthorization(context.Background(), &request); expiredLock != nil || expiredSuspension == nil || expiredSuspension.Authorization == nil || expiredErr != nil { t.Fatalf("expired delegation = lock=%v response=%#v err=%v", expiredLock, expiredSuspension, expiredErr) } renewedAt := time.Now().UTC() @@ -3174,7 +3180,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatal(err) } record = renewed - lock, suspension, err = prepareDelegation(context.Background(), &request) + lock, suspension, err = prepareFlowAuthorization(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) } @@ -3193,14 +3199,14 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil { t.Fatal(err) } - refLock, refSuspension, refErr := prepareDelegation(context.Background(), &refRequest) + refLock, refSuspension, refErr := prepareFlowAuthorization(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) + lock, suspension, err = prepareFlowAuthorization(context.Background(), &request) if err != nil || lock == nil || suspension != nil { t.Fatalf("authorized apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) } @@ -3211,10 +3217,14 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) 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") { + lock, suspension, err = prepareFlowAuthorization(context.Background(), &request) + if lock != nil || suspension == nil || suspension.Authorization == nil || err != nil { t.Fatalf("revoked apply preflight = lock=%v response=%#v err=%v", lock, suspension, err) } + reauthorized, changed, err := authorizeDelegation(&record, bound.delegationRequest, bound.delegationRequestFingerprint, record.ActorIdentityProviderFingerprint, record.Actor, time.Hour, time.Now().UTC(), false) + if err != nil || !changed || reauthorized.Status != "active" || reauthorized.Revision != record.Revision+1 || reauthorized.ReceiptID == record.ReceiptID { + t.Fatalf("revoked exact request reauthorization = %#v changed=%t err=%v", reauthorized, changed, err) + } record.Status, record.Revision, record.RevokedAt = "active", record.Revision+1, time.Time{} if err := effects.StoreDelegationRecord(recordPath, record); err != nil { t.Fatal(err) @@ -3228,7 +3238,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) if err != nil { t.Fatal(err) } - if err := settleDelegationAtTarget(context.Background(), request, committed, true, true); err != nil { + if err := settleFlowAuthorizationAtTarget(context.Background(), request, committed, true, true); err != nil { t.Fatal(err) } if err := heldDelegationLock.Release(); err != nil { @@ -3239,12 +3249,12 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatalf("target settlement = record=%#v err=%v", completed, err) } request.Operation = surfaces.OperationResolve - lock, suspension, err = prepareDelegation(context.Background(), &request) + lock, suspension, err = prepareFlowAuthorization(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) + lock, suspension, err = prepareFlowAuthorization(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) } @@ -3255,7 +3265,7 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi // explicitly authorized post-installation request creates new authority. prior := delegation.Request{ RunID: "run-example", ProgramID: "product-delivery", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), - EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"plan"}, + EntryID: "run", TargetID: "published-pr", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []delegation.InputFingerprint{{ID: "plan", Fingerprint: strings.Repeat("1", 64)}}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", BindingFingerprint: strings.Repeat("c", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("f", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", } @@ -3292,7 +3302,7 @@ func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { RepositoryID: "repository", GitCommonID: "common", HumanIdentityRole: "developer", } changedInput := request - changedInput.InputFingerprints = []string{"changed-plan"} + changedInput.InputFingerprints = []delegation.InputFingerprint{{ID: "plan", Fingerprint: strings.Repeat("2", 64)}} admitted, err := canReprojectDelegation(ports.ControllerLayout{}, model.InvocationContext{}, request, changedInput) if err != nil || admitted { t.Fatalf("unchanged-bundle reprojection admitted=%t err=%v", admitted, err) diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 284dcda..072925a 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -183,9 +183,9 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test if err != nil { t.Fatal(err) } - response := surfaces.Response{Delegation: &surfaces.DelegationRequired{ + response := surfaces.Response{Authorization: &surfaces.FlowAuthorizationRequired{ Code: "DELEGATION_REQUIRED", RunID: "run-example", RequestFingerprint: strings.Repeat("a", 64), - Authorities: []catalog.AuthorityClass{catalog.AuthorityAutonomy}, Description: "authorize exact run", HumanIdentity: presentation, + DelegatedAuthorities: []catalog.AuthorityClass{catalog.AuthorityAutonomy}, Description: "authorize exact run", HumanIdentity: presentation, }} raw, err := json.Marshal(response) if err != nil { @@ -195,8 +195,8 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatal(err) } - if decoded.Delegation == nil || decoded.Delegation.HumanIdentity.Descriptor.Command != "touch" || !reflect.DeepEqual(decoded.Delegation.HumanIdentity.Descriptor.Args, []string{marker}) { - t.Fatalf("structured JSON lost command argv: %#v", decoded.Delegation) + if decoded.Authorization == nil || decoded.Authorization.HumanIdentity.Descriptor.Command != "touch" || !reflect.DeepEqual(decoded.Authorization.HumanIdentity.Descriptor.Args, []string{marker}) { + t.Fatalf("structured JSON lost command argv: %#v", decoded.Authorization) } output, err := captureStdout(t, func() error { return renderResponse(response, "text") }) expectedCommand := "human_identity_command=" + strconv.Quote("touch") + " " + strconv.Quote(marker) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index cee96c9..5817a35 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -80,6 +80,7 @@ type commandOptions struct { command string delegationBindingFingerprint string delegationRequestFingerprint string + entryActivationAuthorities stringList delegationAuthorities stringList delegationDescription string delegationRequest delegation.Request @@ -163,7 +164,7 @@ func run(arguments []string) error { return err } } - programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) + programChangeResponse, err := preflightFlowAuthorizationProgramChange(context.Background(), request) if err != nil { if suspended, ok := flowCommitRequiredResponse(err, operation); ok { return renderResponse(suspended, options.format) @@ -173,7 +174,7 @@ func run(arguments []string) error { if programChangeResponse != nil { return renderResponse(*programChangeResponse, options.format) } - delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) + delegationLock, delegationResponse, err := prepareFlowAuthorization(context.Background(), &request) if err != nil { return err } @@ -237,7 +238,7 @@ func run(arguments []string) error { response, handleErr = suspended, nil } if operation != surfaces.OperationExplain { - if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { + if settleErr := settleFlowAuthorizationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr } } @@ -285,7 +286,7 @@ func runRPC() error { return err } } - programChangeResponse, err := preflightDelegatedProgramChange(context.Background(), request) + programChangeResponse, err := preflightFlowAuthorizationProgramChange(context.Background(), request) if err != nil { if suspended, ok := flowCommitRequiredResponse(err, request.Operation); ok { encoder := json.NewEncoder(os.Stdout) @@ -299,7 +300,7 @@ func runRPC() error { encoder.SetIndent("", " ") return encoder.Encode(programChangeResponse) } - delegationLock, delegationResponse, err := prepareDelegation(context.Background(), &request) + delegationLock, delegationResponse, err := prepareFlowAuthorization(context.Background(), &request) if err != nil { return err } @@ -347,7 +348,7 @@ func runRPC() error { response, handleErr = suspended, nil } if request.Operation != surfaces.OperationExplain { - if settleErr := settleDelegationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { + if settleErr := settleFlowAuthorizationAtTarget(context.Background(), request, response, kernel.TargetSatisfied(response.Snapshot, request.Objective), delegationLock != nil); settleErr != nil && handleErr == nil { handleErr = settleErr } } @@ -806,6 +807,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command, DelegationBindingFingerprint: options.delegationBindingFingerprint, DelegationRequestFingerprint: options.delegationRequestFingerprint, + EntryActivationAuthorities: delegationClasses(options.entryActivationAuthorities), DelegatedAuthorities: delegationClasses(options.delegationAuthorities), WorkInputs: options.workInputs, WorkID: options.workID, @@ -999,9 +1001,9 @@ func renderResponse(response surfaces.Response, format string) error { } return nil } - if response.Delegation != nil { - fmt.Printf("SUSPENDED: %s run=%s request=%s authorities=%v\n%s\n", response.Delegation.Code, response.Delegation.RunID, response.Delegation.RequestFingerprint, response.Delegation.Authorities, response.Delegation.Description) - renderHumanIdentity(response.Delegation.HumanIdentity) + if response.Authorization != nil { + fmt.Printf("SUSPENDED: %s run=%s request=%s activation=%v delegation=%v\n%s\n", response.Authorization.Code, response.Authorization.RunID, response.Authorization.RequestFingerprint, response.Authorization.EntryActivationAuthorities, response.Authorization.DelegatedAuthorities, response.Authorization.Description) + renderHumanIdentity(response.Authorization.HumanIdentity) return nil } if response.Question != nil { diff --git a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go index cdf0018..f28c0e4 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "sort" "strings" "testing" @@ -122,7 +123,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if err := json.Unmarshal(first, &installationQuestion); err != nil { t.Fatal(err) } - if installationQuestion.Question == nil || installationQuestion.Question.TransitionID != "installation.initialize" || len(installationQuestion.Question.Authority) != 2 || installationQuestion.RunID == "" || installationQuestion.Delegation != nil || installationQuestion.Receipt != nil { + if installationQuestion.Question == nil || installationQuestion.Question.TransitionID != "installation.initialize" || len(installationQuestion.Question.Authority) != 2 || installationQuestion.RunID == "" || installationQuestion.Authorization != nil || installationQuestion.Receipt != nil { t.Fatalf("installation authority response = %#v\n%s", installationQuestion, first) } runID := installationQuestion.RunID @@ -141,7 +142,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if err := json.Unmarshal(install, &commitSuspension); err != nil { t.Fatal(err) } - if commitSuspension.CommitRequired == nil || commitSuspension.CommitRequired.Code != controlBundleCommitRequiredCode || commitSuspension.CommitRequired.RunID != runID || commitSuspension.CommitRequired.ControlBundleFingerprint == "" || commitSuspension.Delegation != nil || commitSuspension.Work != nil { + if commitSuspension.CommitRequired == nil || commitSuspension.CommitRequired.Code != controlBundleCommitRequiredCode || commitSuspension.CommitRequired.RunID != runID || commitSuspension.CommitRequired.ControlBundleFingerprint == "" || commitSuspension.Authorization != nil || commitSuspension.Work != nil { t.Fatalf("control-bundle commit suspension = %#v\n%s", commitSuspension, install) } if !strings.Contains(string(generatedSkill), controlBundleCommitRequiredCode) || !strings.Contains(string(generatedSkill), "stay in the source\nrepository") { @@ -187,13 +188,16 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if err := json.Unmarshal(delegationOutput, &delegated); err != nil { t.Fatal(err) } - if delegated.Delegation == nil || delegated.Delegation.RunID == "" { + if delegated.Authorization == nil || delegated.Authorization.RunID == "" { t.Fatalf("delegation response = %#v\n%s", delegated, delegationOutput) } - if delegated.Delegation.RunID != runID { - t.Fatalf("delegation run = %s, want %s", delegated.Delegation.RunID, runID) + if delegated.Authorization.RunID != runID { + t.Fatalf("authorization run = %s, want %s", delegated.Authorization.RunID, runID) } - t.Logf("SUSPENSION delegation run=%s request=%s authorities=%v", runID, delegated.Delegation.RequestFingerprint, delegated.Delegation.Authorities) + if delegated.Authorization.Code != "ENTRY_ACTIVATION_AUTHORITY_REQUIRED" || !slices.Equal(delegated.Authorization.EntryActivationAuthorities, []catalog.AuthorityClass{catalog.AuthorityHuman}) || !slices.Equal(delegated.Authorization.DelegatedAuthorities, []catalog.AuthorityClass{catalog.AuthorityAutonomy}) { + t.Fatalf("combined authorization = %#v", delegated.Authorization) + } + t.Logf("SUSPENSION authorization run=%s request=%s activation=%v delegation=%v", runID, delegated.Authorization.RequestFingerprint, delegated.Authorization.EntryActivationAuthorities, delegated.Authorization.DelegatedAuthorities) if delegated.Work != nil { t.Fatalf("product work crossed delegation: %#v", delegated.Work) } @@ -204,14 +208,14 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T if _, err := captureStdout(t, func() error { return runFlowAuthorize([]string{ "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, - "--request-fingerprint", delegated.Delegation.RequestFingerprint, - "--human-identity-provider-fingerprint", delegated.Delegation.HumanIdentity.ProviderFingerprint, + "--request-fingerprint", delegated.Authorization.RequestFingerprint, + "--human-identity-provider-fingerprint", delegated.Authorization.HumanIdentity.ProviderFingerprint, "--human", "operator", "--host", "codex", }) }); err != nil { t.Fatal(err) } - t.Logf("AUTHORITY accepted class=autonomy actor=operator request=%s", delegated.Delegation.RequestFingerprint) + t.Logf("AUTHORIZATION accepted activation=human delegation=autonomy actor=operator request=%s", delegated.Authorization.RequestFingerprint) workOutput, err := captureStdout(t, func() error { return runFlowContinuation([]string{"--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", runID, "--repository-authority", "--human", "operator", "--host", "codex", "--format", "json"}) diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index a71c13e..680f2f3 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -17,7 +17,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 5 + ArtifactSchemaRevision = 6 ) type Artifact struct { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index ef7647e..6de7944 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -500,6 +500,14 @@ func normalizeTargetsAndEntries(document *Document, facets map[string]Facet, res return invalid(fmt.Sprintf("entries[%d]", i), "invalid entry or target reference") } entries[entry.ID] = true + var err error + entry.Requires.Authorities, err = normalizedReferenceSet("entries."+entry.ID+".requires.authorities", entry.Requires.Authorities) + if err != nil { + return err + } + if missing := firstUndeclared(entry.Requires.Authorities, document.Declarations.Authorities); missing != "" { + return invalid("entries."+entry.ID+".requires.authorities", "undeclared "+missing) + } if entry.Delegation != nil { if resolver == nil { return invalid("entries."+entry.ID+".delegation", "no binding resolver is available") diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index 3396cb5..c3146ef 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -584,6 +584,38 @@ func TestEntryDiagnosticsAreArtifactBoundButNotExecutableControlLaw(t *testing.T } } +func TestEntryAuthorityRequirementsAreCanonicalExecutableSemantics(t *testing.T) { + document := incidentProgram() + document.Declarations.Authorities = append(document.Declarations.Authorities, "human", "autonomy") + document.Entries[0].Requires.Authorities = []string{"human", "autonomy"} + compiled, err := controlprogram.Compile(document, nil) + if err != nil { + t.Fatal(err) + } + if got := compiled.Document.Entries[0].Requires.Authorities; !reflect.DeepEqual(got, []string{"autonomy", "human"}) { + t.Fatalf("canonical entry authorities = %v", got) + } + without := clone(t, document) + without.Entries[0].Requires.Authorities = nil + unprotected, err := controlprogram.Compile(without, nil) + if err != nil { + t.Fatal(err) + } + if unprotected.Fingerprint == compiled.Fingerprint { + t.Fatal("entry activation requirement did not change ProgramFingerprint") + } + duplicate := clone(t, document) + duplicate.Entries[0].Requires.Authorities = []string{"human", "human"} + if _, err := controlprogram.Compile(duplicate, nil); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("duplicate entry authority result = %v", err) + } + undeclared := clone(t, document) + undeclared.Entries[0].Requires.Authorities = []string{"release-manager"} + if _, err := controlprogram.Compile(undeclared, nil); err == nil || !strings.Contains(err.Error(), "undeclared") { + t.Fatalf("undeclared entry authority result = %v", err) + } +} + func TestDelegationBindingIsResolvedAndFingerprintBound(t *testing.T) { // control-law: repository-source-can-request-but-cannot-grant-authority document := incidentProgram() diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 9d0b2ae..74b25cf 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 5 + SchemaRevision = 6 ) type Document struct { @@ -272,12 +272,13 @@ type Target struct { } type Entry struct { - ID string `json:"id"` - Target string `json:"target"` - Inputs []EntryInput `json:"inputs,omitempty"` - Delegation *DelegationBinding `json:"delegation,omitempty"` - Diagnostics *EntryDiagnostics `json:"diagnostics,omitempty"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Target string `json:"target"` + Requires TransitionRequirements `json:"requires,omitempty"` + Inputs []EntryInput `json:"inputs,omitempty"` + Delegation *DelegationBinding `json:"delegation,omitempty"` + Diagnostics *EntryDiagnostics `json:"diagnostics,omitempty"` + Description string `json:"description,omitempty"` } // EntryDiagnostics controls generated host UX only. It is excluded from the diff --git a/boatstack/flow/softwaredelivery/projections.go b/boatstack/flow/softwaredelivery/projections.go index 1a7b793..8087b9e 100644 --- a/boatstack/flow/softwaredelivery/projections.go +++ b/boatstack/flow/softwaredelivery/projections.go @@ -124,7 +124,7 @@ external-provider authority, and provider authentication never satisfies human authority. ` startCommand := fmt.Sprintf("boatstack next --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) - if entry.Delegation != nil { + if entry.Delegation != nil || len(entry.Requires.Authorities) != 0 { startCommand = fmt.Sprintf("boatstack flow run --repo . --flow %s --entry %s --repository-authority --host %s --format json", compiled.Document.Program.ID, entry.ID, host) } if declarativeProgram(compiled.Document.Operators) { @@ -289,9 +289,9 @@ Boatstack. An explanation is not authority: never grant authority, fabricate a run ID, reconstruct the transition graph, or act on a rejected candidate. `, compiled.Document.Program.ID, entry.ID, host) } - if entry.Delegation != nil { + if entry.Delegation != nil || len(entry.Requires.Authorities) != 0 { delegation = fmt.Sprintf(` -Before product delegation, Boatstack may select `+"`installation.initialize`"+` +Before Flow authorization, Boatstack may select `+"`installation.initialize`"+` for an installed repository whose controller state is fresh. Display that exact installation-authority question and obtain explicit human approval. Resume the same Flow command with `+"`--human `"+`; do not invoke an update operation @@ -305,16 +305,25 @@ then resume the same Flow command. This is an installation boundary, not managed product-workspace work; do not switch worktrees or exclude generated bundle files. After internal preconditions are committed, Boatstack returns a typed -`+"`DELEGATION_REQUIRED`"+` response bound to the resulting control bundle. -Display its exact run ID, request fingerprint, requested authorities, and -description. Obtain one explicit human approval for that exact request, then run: +`+"`authorization`"+` response. Its code is +`+"`ENTRY_ACTIVATION_AUTHORITY_REQUIRED`"+` when entry activation is included, +or `+"`DELEGATION_REQUIRED`"+` for delegation-only entries. Invocation alone is +not approval. Display the exact Flow, entry, target, run ID, role, proposed actor, +provider fingerprint, request fingerprint, `+"`entry_activation_authorities`"+`, +`+"`delegated_authorities`"+`, and description. Explain that activation consent +does not grant transition, provider, bootstrap, merge, deploy, later-human-transition, +or unrelated-run authority. Obtain one explicit human approval covering each +displayed scope for that exact request, then run: `+"`boatstack flow authorize --repo . --flow %s --entry %s --run-id --request-fingerprint --human-identity-provider-fingerprint --human --host %s`"+` After authorization, use `+"`boatstack flow run --repo . --flow %s --entry %s --run-id --repository-authority --host %s --format json`"+`. -Do not request approval again after a restart or typed suspension. Resume the -same run and delegation unless Boatstack reports revocation, expiry, drift, or -terminal completion. Never authorize on the user's behalf. +Do not request approval again after a restart or typed suspension while the +same accepted request remains current. Resume the same run; if Boatstack reports +revocation, expiry, or drift and returns a fresh authorization request, discard +the prior approval and ask once for the new exact scopes. Never authorize on the +user's behalf. The accepted activation scope is not an authority receipt; only +the separately displayed delegation scope can create run-scoped delegation receipts. `, compiled.Document.Program.ID, entry.ID, host, compiled.Document.Program.ID, entry.ID, host) } if entry.Target == "published-pr" { diff --git a/boatstack/flow/softwaredelivery/projections_test.go b/boatstack/flow/softwaredelivery/projections_test.go index 204402c..8cf8366 100644 --- a/boatstack/flow/softwaredelivery/projections_test.go +++ b/boatstack/flow/softwaredelivery/projections_test.go @@ -22,6 +22,7 @@ func TestGeneratedProjectionsProjectOnlyDeclaredEntriesWithHostParity(t *testing Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{True: &truth}}}, Entries: []controlprogram.Entry{{ ID: "run", Target: "published-pr", Description: "Publish the reviewed change", + Requires: controlprogram.TransitionRequirements{Authorities: []string{"human"}}, Delegation: &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"}, }}, }} @@ -47,7 +48,7 @@ func TestGeneratedProjectionsProjectOnlyDeclaredEntriesWithHostParity(t *testing "boatstack flow run --repo . --flow product-delivery --entry run --repository-authority", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", "BOATSTACK_LAUNCHER_NOT_FOUND", ".boatstack/runtime.json", "Never run it", "creates no\nFlow run ID", "WORKSPACE_COMMIT_REQUIRED", "Commit only the intended delivery changes", "Never fabricate an external-provider receipt", - "Before product delegation", "do not invoke an update operation", "committed project configuration", + "Before Flow authorization", "do not invoke an update operation", "committed project configuration", "CONTROL_BUNDLE_COMMIT_REQUIRED", "stay in the source\nrepository", "do not switch worktrees or exclude generated bundle files", "installation-authority\nsuspension before product work", "installation.reconcile-update", "--accept-program-change", "boatstack reconcile-update --repo . --flow product-delivery --entry run --run-id ", @@ -64,6 +65,8 @@ func TestGeneratedProjectionsProjectOnlyDeclaredEntriesWithHostParity(t *testing "at most 1024 bytes", "proposed actor", "ask that\nconcrete actor for explicit approval", "Identity resolution never counts as approval", "never infer one from the operating system, Git, host", "--human-identity-provider-fingerprint ", + "`authorization` response", "ENTRY_ACTIVATION_AUTHORITY_REQUIRED", "entry_activation_authorities", "delegated_authorities", + "Invocation alone is\nnot approval", "does not grant transition, provider, bootstrap, merge, deploy", "accepted activation scope is not an authority receipt", } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index 2f9b801..5eb91d0 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -19,44 +19,67 @@ import ( const ( Schema = "run-delegation" - SchemaRevision = 4 + SchemaRevision = 5 ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var fingerprint = regexp.MustCompile(`^[0-9a-f]{64}$`) type Request struct { - RunID string `json:"run_id"` - ProgramID string `json:"program_id"` - ProgramFingerprint string `json:"program_fingerprint"` - ControlBundleFingerprint string `json:"control_bundle_fingerprint"` - EntryID string `json:"entry_id"` - TargetID string `json:"target_id"` - ObjectiveID string `json:"objective_id"` - DeliveryID string `json:"delivery_id"` - InputFingerprints []string `json:"input_fingerprints"` - RepositoryID string `json:"repository_id"` - GitCommonID string `json:"git_common_id"` - InitialWorktreeID string `json:"initial_worktree_id"` - InitialRef string `json:"initial_ref"` - BindingFingerprint string `json:"binding_fingerprint"` - HumanIdentityRole string `json:"human_identity_role"` - HumanIdentityProviderFingerprint string `json:"human_identity_provider_fingerprint"` - RequestedAuthorities []string `json:"requested_authorities"` - Description string `json:"description"` + RunID string `json:"run_id"` + ProgramID string `json:"program_id"` + ProgramFingerprint string `json:"program_fingerprint"` + ControlBundleFingerprint string `json:"control_bundle_fingerprint"` + EntryID string `json:"entry_id"` + TargetID string `json:"target_id"` + ObjectiveID string `json:"objective_id"` + DeliveryID string `json:"delivery_id"` + InputFingerprints []InputFingerprint `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"` + EntryActivationAuthorities []string `json:"entry_activation_authorities,omitempty"` + BindingFingerprint string `json:"binding_fingerprint,omitempty"` + HumanIdentityRole string `json:"human_identity_role"` + HumanIdentityProviderFingerprint string `json:"human_identity_provider_fingerprint"` + RequestedAuthorities []string `json:"requested_authorities,omitempty"` + Description string `json:"description"` +} + +// InputFingerprint binds authorization to one exact, named entry input. +type InputFingerprint struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` } func (r Request) Fingerprint() (string, error) { - if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || humanidentity.ValidateRole(r.HumanIdentityRole) != nil || !fingerprint.MatchString(r.HumanIdentityProviderFingerprint) || len(r.RequestedAuthorities) == 0 || r.Description == "" { + delegationPresent := len(r.RequestedAuthorities) != 0 + activationPresent := len(r.EntryActivationAuthorities) != 0 + if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || !fingerprint.MatchString(r.ProgramFingerprint) || !fingerprint.MatchString(r.ControlBundleFingerprint) || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || (!activationPresent && !delegationPresent) || (delegationPresent != fingerprint.MatchString(r.BindingFingerprint)) || humanidentity.ValidateRole(r.HumanIdentityRole) != nil || !fingerprint.MatchString(r.HumanIdentityProviderFingerprint) || r.Description == "" { return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") } - r.InputFingerprints = append([]string(nil), r.InputFingerprints...) + r.InputFingerprints = append([]InputFingerprint(nil), r.InputFingerprints...) + r.EntryActivationAuthorities = append([]string(nil), r.EntryActivationAuthorities...) r.RequestedAuthorities = append([]string(nil), r.RequestedAuthorities...) - sort.Strings(r.InputFingerprints) + sort.Slice(r.InputFingerprints, func(i, j int) bool { return r.InputFingerprints[i].ID < r.InputFingerprints[j].ID }) + sort.Strings(r.EntryActivationAuthorities) 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") + for index, value := range r.InputFingerprints { + if !identity.MatchString(value.ID) || !fingerprint.MatchString(value.Fingerprint) || (index > 0 && r.InputFingerprints[index-1].ID == value.ID) { + return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: input fingerprints are invalid or duplicated") + } + } + for _, authorities := range [][]string{r.EntryActivationAuthorities, r.RequestedAuthorities} { + for index, value := range authorities { + if value == "" || (index > 0 && authorities[index-1] == value) { + return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: authorities are empty or duplicated") + } + } + } + for _, value := range r.EntryActivationAuthorities { + if value != "human" { + return "", fmt.Errorf("ENTRY_ACTIVATION_AUTHORITY_UNSUPPORTED: no trusted producer exists for %q", value) } } encoded, err := json.Marshal(r) diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index b042be1..4fbf379 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -14,7 +14,7 @@ import ( func request() delegation.Request { return delegation.Request{ RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("c", 64), EntryID: "run", - TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"b", "a"}, + TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []delegation.InputFingerprint{{ID: "second", Fingerprint: strings.Repeat("b", 64)}, {ID: "first", Fingerprint: strings.Repeat("a", 64)}}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", BindingFingerprint: strings.Repeat("b", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", } @@ -60,6 +60,42 @@ func TestRequestFingerprintCanonicalizesSetsAndBindsSemantics(t *testing.T) { } } +func TestRequestSupportsExactAuthorizationShapesAndRejectsUnsupportedActivation(t *testing.T) { + delegationOnly := request() + delegationFingerprint, err := delegationOnly.Fingerprint() + if err != nil { + t.Fatal(err) + } + activationOnly := request() + activationOnly.BindingFingerprint = "" + activationOnly.RequestedAuthorities = nil + activationOnly.EntryActivationAuthorities = []string{"human"} + activationFingerprint, err := activationOnly.Fingerprint() + if err != nil { + t.Fatal(err) + } + combined := request() + combined.EntryActivationAuthorities = []string{"human"} + combinedFingerprint, err := combined.Fingerprint() + if err != nil { + t.Fatal(err) + } + if delegationFingerprint == activationFingerprint || activationFingerprint == combinedFingerprint || delegationFingerprint == combinedFingerprint { + t.Fatal("distinct authorization scopes produced the same request fingerprint") + } + unprotected := request() + unprotected.BindingFingerprint = "" + unprotected.RequestedAuthorities = nil + if _, err := unprotected.Fingerprint(); err == nil { + t.Fatal("unprotected entry manufactured an authorization record") + } + unsupported := activationOnly + unsupported.EntryActivationAuthorities = []string{"external-provider"} + if _, err := unsupported.Fingerprint(); err == nil || !strings.Contains(err.Error(), "ENTRY_ACTIVATION_AUTHORITY_UNSUPPORTED") { + t.Fatalf("unsupported entry activation result = %v", err) + } +} + func TestRecordRejectsPriorSchemaAndIdentityProvenanceMismatch(t *testing.T) { value := request() requestFingerprint, err := value.Fingerprint() diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go index 201246b..983e697 100644 --- a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go +++ b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go @@ -13,7 +13,7 @@ import ( func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { request := delegation.Request{ RunID: "run-example", ProgramID: "program", ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), - EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []string{"input"}, + EntryID: "run", TargetID: "done", ObjectiveID: "objective", DeliveryID: "delivery", InputFingerprints: []delegation.InputFingerprint{{ID: "plan", Fingerprint: strings.Repeat("e", 64)}}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", BindingFingerprint: strings.Repeat("c", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", } diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index 9979479..0d6bc7b 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -20,7 +20,7 @@ import ( general "github.com/operatorstack/boatstack/boatstack/kernel" ) -const SchemaVersion = 15 +const SchemaVersion = 16 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var gitObjectIdentity = regexp.MustCompile(`^[0-9a-f]{40,64}$`) @@ -73,6 +73,7 @@ type Request struct { Command string `json:"command,omitempty"` DelegationBindingFingerprint string `json:"delegation_binding_fingerprint,omitempty"` DelegationRequestFingerprint string `json:"delegation_request_fingerprint,omitempty"` + EntryActivationAuthorities []catalog.AuthorityClass `json:"entry_activation_authorities,omitempty"` DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities,omitempty"` WorkInputs map[string]protocol.WorkInputValue `json:"work_inputs,omitempty"` WorkID string `json:"work_id,omitempty"` @@ -130,9 +131,17 @@ func (r Request) Validate(now time.Time) error { if r.ControlBundleRevision != "" && (r.ControlBundle == nil || !gitObjectIdentity.MatchString(r.ControlBundleRevision)) { return fmt.Errorf("CONTROL_BUNDLE_INVALID: request revision has no trusted bundle or is not a Git object identity") } + if len(r.EntryActivationAuthorities) != 0 && (r.ProgramID == "" || len(r.DelegationRequestFingerprint) != 64) { + return fmt.Errorf("surface entry activation request requires an exact request fingerprint") + } 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.EntryActivationAuthorities { + if authority != catalog.AuthorityHuman { + return fmt.Errorf("ENTRY_ACTIVATION_AUTHORITY_UNSUPPORTED: no trusted producer exists for %q", authority) + } + } for _, authority := range r.DelegatedAuthorities { if !authority.Valid() || authority == catalog.AuthorityNone { return fmt.Errorf("surface delegated Flow request has invalid authority %q", authority) @@ -258,20 +267,21 @@ 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"` + Authorization *FlowAuthorizationRequired `json:"authorization,omitempty"` CommitRequired *CommitRequired `json:"commit_required,omitempty"` Work *foregroundwork.Record `json:"work,omitempty"` InputRequest *invocation.InputRequest `json:"input_request,omitempty"` Invocation *invocation.Evidence `json:"invocation_evidence,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"` - HumanIdentity humanidentity.Presentation `json:"human_identity"` +type FlowAuthorizationRequired struct { + Code string `json:"code"` + RunID string `json:"run_id"` + RequestFingerprint string `json:"request_fingerprint"` + EntryActivationAuthorities []catalog.AuthorityClass `json:"entry_activation_authorities"` + DelegatedAuthorities []catalog.AuthorityClass `json:"delegated_authorities"` + Description string `json:"description"` + HumanIdentity humanidentity.Presentation `json:"human_identity"` } // CommitRequired is a typed suspension at a repository revision boundary. diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index 69787cd..bc8bf9a 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -122,6 +122,34 @@ func TestExplainRequestRejectsMutationArtifacts(t *testing.T) { } } +func TestFlowAuthorizationSurfaceCoversAllEntryShapesAndFailsClosed(t *testing.T) { + base := Request{ + SchemaVersion: SchemaVersion, Operation: OperationResolve, Repository: "/repo", Host: "cli", CorrelationID: "authorization", + ProgramID: "product-delivery", ProgramFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", EntryID: "run", FlowID: "run-1", + } + unprotected := base + activationOnly := base + activationOnly.DelegationRequestFingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + activationOnly.EntryActivationAuthorities = []catalog.AuthorityClass{catalog.AuthorityHuman} + delegationOnly := base + delegationOnly.DelegationRequestFingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + delegationOnly.DelegationBindingFingerprint = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + delegationOnly.DelegatedAuthorities = []catalog.AuthorityClass{catalog.AuthorityAutonomy} + combined := activationOnly + combined.DelegationBindingFingerprint = delegationOnly.DelegationBindingFingerprint + combined.DelegatedAuthorities = append([]catalog.AuthorityClass(nil), delegationOnly.DelegatedAuthorities...) + for name, request := range map[string]Request{"unprotected": unprotected, "activation-only": activationOnly, "delegation-only": delegationOnly, "combined": combined} { + if err := request.Validate(time.Now()); err != nil { + t.Fatalf("%s request: %v", name, err) + } + } + unsupported := activationOnly + unsupported.EntryActivationAuthorities = []catalog.AuthorityClass{catalog.AuthorityProvider} + if err := unsupported.Validate(time.Now()); err == nil { + t.Fatal("unsupported entry activation authority was synthesized") + } +} + func TestQuestionSuspendsAndBindsOneRunSnapshot(t *testing.T) { // control-law: human-input-suspension-cannot-cross-run-or-snapshot transition := catalog.Transition{ diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 3a43387..1dde105 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,6 +1,6 @@ { "schema": "control-program", - "schema_revision": 5, + "schema_revision": 6, "program": { "id": "incident-response", "version": "1" diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts index b964807..fa77b88 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts @@ -92,6 +92,7 @@ export default defineFlow({ entry({ id: "run", target: "published-pr", + requires: { authorities: ["human"] }, inputs: [inbox(".boatstack/plans/inbox")], delegation: trustedDelegation("autonomy"), }), diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts index da5ad25..57924d7 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -84,6 +84,7 @@ export default defineFlow(softwareDelivery({ entry({ id: "run", target: "published-pr", + requires: { authorities: ["human"] }, inputs: [inbox(".boatstack/plans/inbox")], delegation: trustedDelegation("autonomy"), }), diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json index 1e76a8f..7931c5a 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.raw.json @@ -1 +1 @@ -{"schema":"control-program","schema_revision":5,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"declarations":{"input_resolvers":["software-delivery.plan-inbox"]},"facets":[{"id":"phase","kind":"string"},{"id":"program","kind":"string"},{"id":"engagement","kind":"string"},{"id":"objective","kind":"string"},{"id":"delivery","kind":"string"},{"id":"workspace","kind":"string"},{"id":"plan","kind":"string"},{"id":"configuration","kind":"string"},{"id":"configuration-policy","kind":"string"},{"id":"runtime","kind":"string"},{"id":"publication","kind":"string"},{"id":"verification","kind":"string"},{"id":"recovery","kind":"string"},{"id":"recovery-info","kind":"string"},{"id":"transaction","kind":"string"},{"id":"terminal","kind":"string"},{"id":"recovery_budget","kind":"string"},{"id":"recovery_cause","kind":"string"},{"id":"recovery_resumption","kind":"string"},{"id":"recovery_source_phase","kind":"string"},{"id":"source_revision","kind":"string"},{"id":"preview_fingerprint","kind":"string"},{"id":"publication_id","kind":"string"},{"id":"recovery_transaction_id","kind":"string"},{"id":"transaction_id","kind":"string"},{"id":"transaction_transition","kind":"string"},{"id":"workspace_base_ref","kind":"string"},{"id":"workspace_branch","kind":"string"},{"id":"workspace_path","kind":"string"},{"id":"workspace_source_id","kind":"string"},{"id":"workspace_source_path","kind":"string"},{"id":"workspace_source_ref","kind":"string"},{"id":"worktree_fingerprint","kind":"string"}],"evidence":[{"id":"plan-evidence","subject":"plan","kind":"artifact"},{"id":"publication-evidence","subject":"publication","kind":"provider-observation"}],"work":[{"id":"planning-package","instructions":{"path":"boatstack/testdata/control-programs/assets/planning-package.md"},"inputs":[{"id":"plan","entry_input":"plan"}],"outputs":[{"id":"plan","path":"plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"feature-spec","path":"feature-spec.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"questions","path":"questions.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"test-plan","path":"test-plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"gaps","path":"gaps.md","media_type":"text/markdown","required":false,"max_bytes":131072},{"id":"autonomy","path":"autonomy.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"tasks","path":"compiled/tasks.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"test-matrix","path":"compiled/test-matrix.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"journey-oracles","path":"compiled/journey-oracles.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"evidence","path":"compiled/evidence.md","media_type":"text/markdown","required":true,"max_bytes":131072}]}],"operators":[{"id":"planning.package.admit","binding":{"reference":"software-delivery/planning.package.admit","version":"1"}},{"id":"planning.package.approve","binding":{"reference":"software-delivery/planning.package.approve","version":"1"}},{"id":"planning.package.promote","binding":{"reference":"software-delivery/planning.package.promote","version":"1"}},{"id":"plan.abandon","binding":{"reference":"software-delivery/plan.abandon","version":"1"}},{"id":"plan.activate","binding":{"reference":"software-delivery/plan.activate","version":"1"}},{"id":"workspace.cut","binding":{"reference":"software-delivery/workspace.cut","version":"1"}},{"id":"workspace.activate","binding":{"reference":"software-delivery/workspace.activate","version":"1"}},{"id":"workspace.sync","binding":{"reference":"software-delivery/workspace.sync","version":"1"}},{"id":"gate.build.record","binding":{"reference":"software-delivery/gate.build.record","version":"1"}},{"id":"gate.test.record","binding":{"reference":"software-delivery/gate.test.record","version":"1"}},{"id":"gate.review.record","binding":{"reference":"software-delivery/gate.review.record","version":"1"}},{"id":"gate.change.record","binding":{"reference":"software-delivery/gate.change.record","version":"1"}},{"id":"gate.journey.record","binding":{"reference":"software-delivery/gate.journey.record","version":"1"}},{"id":"evidence.visual.attach","binding":{"reference":"software-delivery/evidence.visual.attach","version":"1"}},{"id":"delivery.slice.advance","binding":{"reference":"software-delivery/delivery.slice.advance","version":"1"}},{"id":"publication.preview","binding":{"reference":"software-delivery/publication.preview","version":"1"}},{"id":"workspace.publish","binding":{"reference":"software-delivery/workspace.publish","version":"1"}},{"id":"publication.execute","binding":{"reference":"software-delivery/publication.execute","version":"1"}},{"id":"publication.observe","binding":{"reference":"software-delivery/publication.observe","version":"1"}},{"id":"publication.correct","binding":{"reference":"software-delivery/publication.correct","version":"1"}},{"id":"workspace.reconcile","binding":{"reference":"software-delivery/workspace.reconcile","version":"1"}},{"id":"publication.reconcile","binding":{"reference":"software-delivery/publication.reconcile","version":"1"}}],"transitions":[{"id":"planning.package.admit","operator":"planning.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"planning.package.approve","operator":"planning.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-planning-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45},{"id":"plan.abandon","operator":"plan.abandon","guard":{"true":true},"target":{"true":true},"priority":31},{"id":"plan.activate","operator":"plan.activate","guard":{"true":true},"target":{"true":true},"priority":50},{"id":"workspace.cut","operator":"workspace.cut","guard":{"true":true},"target":{"true":true},"priority":52,"parameters":[{"parameter":"branch","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/delivery-branch","version":"1"}}},{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"destination","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/managed-worktree-destination","version":"1"}}}]},{"id":"workspace.activate","operator":"workspace.activate","guard":{"true":true},"target":{"true":true},"priority":53,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"workspace.sync","operator":"workspace.sync","guard":{"true":true},"target":{"true":true},"priority":58,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"gate.build.record","operator":"gate.build.record","guard":{"true":true},"target":{"true":true},"priority":61,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/build","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/build","version":"1"}}}]},{"id":"gate.test.record","operator":"gate.test.record","guard":{"true":true},"target":{"true":true},"priority":62,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/test","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/test","version":"1"}}}]},{"id":"gate.review.record","operator":"gate.review.record","guard":{"true":true},"target":{"true":true},"priority":63,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/review","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/review","version":"1"}}}]},{"id":"gate.change.record","operator":"gate.change.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/change","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/change","version":"1"}}}]},{"id":"gate.journey.record","operator":"gate.journey.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/journey","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/journey","version":"1"}}}]},{"id":"evidence.visual.attach","operator":"evidence.visual.attach","guard":{"true":true},"target":{"true":true},"priority":66,"parameters":[{"parameter":"manifest_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-manifest-path","version":"1"}}},{"parameter":"privacy_receipt","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-privacy-receipt","version":"1"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"delivery.slice.advance","operator":"delivery.slice.advance","guard":{"true":true},"target":{"true":true},"priority":68,"parameters":[{"parameter":"slice_id","producer":{"kind":"host-input","request":{"id":"delivery-slice","description":"Select the next bounded delivery slice.","authorities":["human","autonomy"],"scope":"transition"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"publication.preview","operator":"publication.preview","guard":{"true":true},"target":{"true":true},"priority":72,"parameters":[{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"head_ref","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}}]},{"id":"workspace.publish","operator":"workspace.publish","guard":{"true":true},"target":{"true":true},"priority":75,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"publication.execute","operator":"publication.execute","guard":{"true":true},"target":{"true":true},"priority":76,"parameters":[{"parameter":"preview_fingerprint","producer":{"kind":"state","facet":"preview_fingerprint","available_when":{"fact":{"facet":"preview_fingerprint","statuses":["known"],"values":[]}}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"state-or-receipt","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}},"transition":"publication.execute","field":"publication_id"}}]},{"id":"publication.correct","operator":"publication.correct","guard":{"true":true},"target":{"true":true},"priority":80,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}},{"parameter":"body_sha256","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-sha256","version":"1"}}}]},{"id":"workspace.reconcile","operator":"workspace.reconcile","guard":{"true":true},"target":{"true":true},"priority":2,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]}],"targets":[{"id":"published-pr","predicate":{"all":[{"fact":{"facet":"verification","statuses":["known"],"values":["current"]}},{"fact":{"facet":"configuration","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"runtime","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"publication","statuses":["known"],"values":["open"]}}]}},{"id":"safely-abandoned","predicate":{"all":[{"fact":{"facet":"delivery","statuses":["known"],"values":["discarded"]}},{"fact":{"facet":"workspace","statuses":["known"],"values":["abandoned","absent"]}}]}}],"entries":[{"id":"run","target":"published-pr","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}],"delegation":{"reference":"software-delivery/delegation/autonomy","version":"1"}},{"id":"abandon","target":"safely-abandoned","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}]}]} +{"schema":"control-program","schema_revision":6,"program":{"id":"product-delivery","version":"1","human_identity":"developer"},"declarations":{"input_resolvers":["software-delivery.plan-inbox"]},"facets":[{"id":"phase","kind":"string"},{"id":"program","kind":"string"},{"id":"engagement","kind":"string"},{"id":"objective","kind":"string"},{"id":"delivery","kind":"string"},{"id":"workspace","kind":"string"},{"id":"plan","kind":"string"},{"id":"configuration","kind":"string"},{"id":"configuration-policy","kind":"string"},{"id":"runtime","kind":"string"},{"id":"publication","kind":"string"},{"id":"verification","kind":"string"},{"id":"recovery","kind":"string"},{"id":"recovery-info","kind":"string"},{"id":"transaction","kind":"string"},{"id":"terminal","kind":"string"},{"id":"recovery_budget","kind":"string"},{"id":"recovery_cause","kind":"string"},{"id":"recovery_resumption","kind":"string"},{"id":"recovery_source_phase","kind":"string"},{"id":"source_revision","kind":"string"},{"id":"preview_fingerprint","kind":"string"},{"id":"publication_id","kind":"string"},{"id":"recovery_transaction_id","kind":"string"},{"id":"transaction_id","kind":"string"},{"id":"transaction_transition","kind":"string"},{"id":"workspace_base_ref","kind":"string"},{"id":"workspace_branch","kind":"string"},{"id":"workspace_path","kind":"string"},{"id":"workspace_source_id","kind":"string"},{"id":"workspace_source_path","kind":"string"},{"id":"workspace_source_ref","kind":"string"},{"id":"worktree_fingerprint","kind":"string"}],"evidence":[{"id":"plan-evidence","subject":"plan","kind":"artifact"},{"id":"publication-evidence","subject":"publication","kind":"provider-observation"}],"work":[{"id":"planning-package","instructions":{"path":"boatstack/testdata/control-programs/assets/planning-package.md"},"inputs":[{"id":"plan","entry_input":"plan"}],"outputs":[{"id":"plan","path":"plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"feature-spec","path":"feature-spec.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"questions","path":"questions.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"test-plan","path":"test-plan.md","media_type":"text/markdown","required":true,"max_bytes":262144},{"id":"gaps","path":"gaps.md","media_type":"text/markdown","required":false,"max_bytes":131072},{"id":"autonomy","path":"autonomy.md","media_type":"text/markdown","required":true,"max_bytes":131072},{"id":"tasks","path":"compiled/tasks.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"test-matrix","path":"compiled/test-matrix.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"journey-oracles","path":"compiled/journey-oracles.json","media_type":"application/json","required":true,"max_bytes":262144,"schema":{"path":"boatstack/testdata/control-programs/assets/planning-list.schema.json"}},{"id":"evidence","path":"compiled/evidence.md","media_type":"text/markdown","required":true,"max_bytes":131072}]}],"operators":[{"id":"planning.package.admit","binding":{"reference":"software-delivery/planning.package.admit","version":"1"}},{"id":"planning.package.approve","binding":{"reference":"software-delivery/planning.package.approve","version":"1"}},{"id":"planning.package.promote","binding":{"reference":"software-delivery/planning.package.promote","version":"1"}},{"id":"plan.abandon","binding":{"reference":"software-delivery/plan.abandon","version":"1"}},{"id":"plan.activate","binding":{"reference":"software-delivery/plan.activate","version":"1"}},{"id":"workspace.cut","binding":{"reference":"software-delivery/workspace.cut","version":"1"}},{"id":"workspace.activate","binding":{"reference":"software-delivery/workspace.activate","version":"1"}},{"id":"workspace.sync","binding":{"reference":"software-delivery/workspace.sync","version":"1"}},{"id":"gate.build.record","binding":{"reference":"software-delivery/gate.build.record","version":"1"}},{"id":"gate.test.record","binding":{"reference":"software-delivery/gate.test.record","version":"1"}},{"id":"gate.review.record","binding":{"reference":"software-delivery/gate.review.record","version":"1"}},{"id":"gate.change.record","binding":{"reference":"software-delivery/gate.change.record","version":"1"}},{"id":"gate.journey.record","binding":{"reference":"software-delivery/gate.journey.record","version":"1"}},{"id":"evidence.visual.attach","binding":{"reference":"software-delivery/evidence.visual.attach","version":"1"}},{"id":"delivery.slice.advance","binding":{"reference":"software-delivery/delivery.slice.advance","version":"1"}},{"id":"publication.preview","binding":{"reference":"software-delivery/publication.preview","version":"1"}},{"id":"workspace.publish","binding":{"reference":"software-delivery/workspace.publish","version":"1"}},{"id":"publication.execute","binding":{"reference":"software-delivery/publication.execute","version":"1"}},{"id":"publication.observe","binding":{"reference":"software-delivery/publication.observe","version":"1"}},{"id":"publication.correct","binding":{"reference":"software-delivery/publication.correct","version":"1"}},{"id":"workspace.reconcile","binding":{"reference":"software-delivery/workspace.reconcile","version":"1"}},{"id":"publication.reconcile","binding":{"reference":"software-delivery/publication.reconcile","version":"1"}}],"transitions":[{"id":"planning.package.admit","operator":"planning.package.admit","guard":{"true":true},"target":{"true":true},"priority":43,"work":"planning-package"},{"id":"planning.package.approve","operator":"planning.package.approve","guard":{"true":true},"target":{"true":true},"priority":44,"parameters":[{"parameter":"package_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/admitted-planning-package-fingerprint","version":"1"}}}]},{"id":"planning.package.promote","operator":"planning.package.promote","guard":{"true":true},"target":{"true":true},"priority":45},{"id":"plan.abandon","operator":"plan.abandon","guard":{"true":true},"target":{"true":true},"priority":31},{"id":"plan.activate","operator":"plan.activate","guard":{"true":true},"target":{"true":true},"priority":50},{"id":"workspace.cut","operator":"workspace.cut","guard":{"true":true},"target":{"true":true},"priority":52,"parameters":[{"parameter":"branch","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/delivery-branch","version":"1"}}},{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"destination","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/managed-worktree-destination","version":"1"}}}]},{"id":"workspace.activate","operator":"workspace.activate","guard":{"true":true},"target":{"true":true},"priority":53,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"workspace.sync","operator":"workspace.sync","guard":{"true":true},"target":{"true":true},"priority":58,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"gate.build.record","operator":"gate.build.record","guard":{"true":true},"target":{"true":true},"priority":61,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/build","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/build","version":"1"}}}]},{"id":"gate.test.record","operator":"gate.test.record","guard":{"true":true},"target":{"true":true},"priority":62,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/test","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/test","version":"1"}}}]},{"id":"gate.review.record","operator":"gate.review.record","guard":{"true":true},"target":{"true":true},"priority":63,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/review","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/review","version":"1"}}}]},{"id":"gate.change.record","operator":"gate.change.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/change","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/change","version":"1"}}}]},{"id":"gate.journey.record","operator":"gate.journey.record","guard":{"true":true},"target":{"true":true},"priority":64,"parameters":[{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}},{"parameter":"evidence_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-path/journey","version":"1"}}},{"parameter":"evidence_fingerprint","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/gate-evidence-fingerprint/journey","version":"1"}}}]},{"id":"evidence.visual.attach","operator":"evidence.visual.attach","guard":{"true":true},"target":{"true":true},"priority":66,"parameters":[{"parameter":"manifest_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-manifest-path","version":"1"}}},{"parameter":"privacy_receipt","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/visual-evidence-privacy-receipt","version":"1"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"delivery.slice.advance","operator":"delivery.slice.advance","guard":{"true":true},"target":{"true":true},"priority":68,"parameters":[{"parameter":"slice_id","producer":{"kind":"host-input","request":{"id":"delivery-slice","description":"Select the next bounded delivery slice.","authorities":["human","autonomy"],"scope":"transition"}}},{"parameter":"source_revision","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/current-source-revision","version":"1"}}}]},{"id":"publication.preview","operator":"publication.preview","guard":{"true":true},"target":{"true":true},"priority":72,"parameters":[{"parameter":"base_ref","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/repository-default-branch","version":"1"}}},{"parameter":"head_ref","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}}]},{"id":"workspace.publish","operator":"workspace.publish","guard":{"true":true},"target":{"true":true},"priority":75,"parameters":[{"parameter":"branch","producer":{"kind":"state","facet":"workspace_branch","available_when":{"fact":{"facet":"workspace_branch","statuses":["known"],"values":[]}}}}]},{"id":"publication.execute","operator":"publication.execute","guard":{"true":true},"target":{"true":true},"priority":76,"parameters":[{"parameter":"preview_fingerprint","producer":{"kind":"state","facet":"preview_fingerprint","available_when":{"fact":{"facet":"preview_fingerprint","statuses":["known"],"values":[]}}}}]},{"id":"publication.observe","operator":"publication.observe","guard":{"true":true},"target":{"true":true},"priority":77,"parameters":[{"parameter":"publication_id","producer":{"kind":"state-or-receipt","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}},"transition":"publication.execute","field":"publication_id"}}]},{"id":"publication.correct","operator":"publication.correct","guard":{"true":true},"target":{"true":true},"priority":80,"parameters":[{"parameter":"publication_id","producer":{"kind":"state","facet":"publication_id","available_when":{"fact":{"facet":"publication_id","statuses":["known"],"values":[]}}}},{"parameter":"body_path","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-path","version":"1"}}},{"parameter":"body_sha256","producer":{"kind":"trusted-resolver","binding":{"reference":"software-delivery/publication-body-sha256","version":"1"}}}]},{"id":"workspace.reconcile","operator":"workspace.reconcile","guard":{"true":true},"target":{"true":true},"priority":2,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]},{"id":"publication.reconcile","operator":"publication.reconcile","guard":{"true":true},"target":{"true":true},"priority":1,"parameters":[{"parameter":"transaction_id","producer":{"kind":"state","facet":"recovery_transaction_id","available_when":{"fact":{"facet":"recovery_transaction_id","statuses":["known"],"values":[]}}}}]}],"targets":[{"id":"published-pr","predicate":{"all":[{"fact":{"facet":"verification","statuses":["known"],"values":["current"]}},{"fact":{"facet":"configuration","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"runtime","statuses":["known"],"values":["verified"]}},{"fact":{"facet":"publication","statuses":["known"],"values":["open"]}}]}},{"id":"safely-abandoned","predicate":{"all":[{"fact":{"facet":"delivery","statuses":["known"],"values":["discarded"]}},{"fact":{"facet":"workspace","statuses":["known"],"values":["abandoned","absent"]}}]}}],"entries":[{"id":"run","target":"published-pr","requires":{"authorities":["human"]},"inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}],"delegation":{"reference":"software-delivery/delegation/autonomy","version":"1"}},{"id":"abandon","target":"safely-abandoned","inputs":[{"id":"plan","type":"markdown-file","required":true,"resolver":"software-delivery.plan-inbox","config":{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}}]}]} diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md index da26f0b..8f7688a 100644 --- a/docs/control-program-ir.md +++ b/docs/control-program-ir.md @@ -6,7 +6,7 @@ Boatstack separates authoring languages from executable semantics: TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel ``` -The `control-program` schema at revision `3` is domain-neutral. It declares +The `control-program` schema at revision `6` is domain-neutral. It declares typed facets, evidence relations, predicate ASTs, operators, capabilities, authority, effects, verification, recovery, bounded foreground work, transitions, marked targets, and entries. @@ -16,8 +16,10 @@ Software terms such as plans, tests, Git, and pull requests belong to 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. +grant authority. Entries may independently require activation authority and +request a trusted delegation binding. Boatstack records one exact human event +when both scopes are present, but only the delegation scope may materialize +run-scoped authority receipts. ## Compile and check @@ -31,11 +33,14 @@ 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, request fingerprint, and repository-selected human identity -descriptor before managed state changes. The host resolves a proposed actor, -shows that actor and the exact request, and asks for explicit approval. A human -can then authorize that exact request and continue it: +If the entry requires human activation, `next` returns an `authorization` +suspension with code `ENTRY_ACTIVATION_AUTHORITY_REQUIRED`. Delegation-only +entries use `DELEGATION_REQUIRED`. The response separately lists +`entry_activation_authorities` and `delegated_authorities`, while binding both +scopes to one exact run, input set, request fingerprint, and repository-selected +human identity descriptor. The host resolves a proposed actor, displays the +Flow, entry, target, run, role, actor, scopes, and fingerprints, then asks once +for explicit approval. A human can authorize that exact request and continue: ```sh boatstack flow authorize --repo . --flow product-delivery --entry run \ @@ -46,6 +51,11 @@ boatstack flow run --repo . --flow product-delivery --entry run --run-id --human ``` +Entry activation consent is not a transition receipt. It cannot satisfy a +later human-only transition, provider operation, bootstrap, or another run. +Invocation by itself is not approval. Revocation, expiry, or bound-context +drift requires a fresh exact authorization request. + An entry may opt its generated Codex and Claude projections into factual diagnosis when a run suspends: diff --git a/docs/generated-files.md b/docs/generated-files.md index 494b0ab..c1a1306 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -56,9 +56,10 @@ Boatstack uses only these canonical roots: | 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. +It does not read or migrate generation-labelled roots. A run authorization +record lives under the external repository and Git-common Flow root. It binds +entry activation and optional delegation scopes to one run and is 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. diff --git a/docs/getting-started.md b/docs/getting-started.md index fd50294..780918e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,6 +37,12 @@ The first response returns an opaque run ID. Preserve the program, entry, run ID, objective, delivery, authority, and prescription through every subsequent `next`, `apply`, question, and recovery call. +Product Delivery's `run` entry then presents one exact authorization request +for human entry activation and autonomy delegation. Display both scopes and the +repository-selected identity, ask that actor once, run the supplied +`boatstack flow authorize` command, and resume the same run. That approval does +not grant later human-transition or GitHub provider authority. + ## Configure one exact objective manually Every managed delivery has a stable objective ID, delivery ID, and terminal kind. diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md index e5fa50e..4b8a360 100644 --- a/docs/product-delivery/authority-and-delegation.md +++ b/docs/product-delivery/authority-and-delegation.md @@ -20,8 +20,10 @@ trustedTransition( ``` `trustedDelegation("autonomy")` requests a trusted delegation mechanism for an -entry. It does not authorize the run. Boatstack presents an exact run-bound -request at runtime, and only a trusted human/host boundary can authorize it. +entry. Separately, `requires: { authorities: ["human"] }` requires explicit +human activation of that entry. Neither declaration authorizes the run. +Boatstack presents one exact run-bound request when both scopes are present, +and only a trusted human/host boundary can accept it. Revocation, expiry, incompatible drift, or an unauthorized execution context ends or suspends that delegation. External-provider authority remains separate. For repository Flow continuation, the trusted GitHub boundary derives that @@ -29,6 +31,14 @@ provider capability from the current repository identity and authenticated write permission. This is capability evidence, not another human approval. Repository files and `--authority-receipt` cannot create provider authority. +The persisted event lists entry activation and delegated authorities +separately. Activation is checked before engagement but never injected into the +transition authority bundle. Only delegated authorities receive run-scoped +receipts. Therefore the same approval cannot satisfy a later mandatory-human +transition, GitHub provider capability, bootstrap, another entry, or another +run. Revocation or expiry yields a fresh request rather than silently restoring +authority. + The repository declares `identity.default` and explicit `identity.roles` in `.boatstack/project.json`; each software-delivery Flow selects one role through `humanIdentity`. Boatstack exposes that role, its literal or structured command diff --git a/docs/product-delivery/targets-and-entries.md b/docs/product-delivery/targets-and-entries.md index 7f20ad8..8258315 100644 --- a/docs/product-delivery/targets-and-entries.md +++ b/docs/product-delivery/targets-and-entries.md @@ -17,6 +17,12 @@ An entry may also declare typed inputs. The software-delivery `inbox` helper requires exactly one eligible Markdown plan. Input resolution happens before a managed run is created; zero or multiple plans stop with a typed blocker. +An entry may declare `requires.authorities`. Product Delivery's `run` entry +requires `human` activation and separately requests autonomy delegation. The +runtime combines their explicit consent prompt, binds it to the exact input +fingerprints, and preserves the two scopes; an unprotected entry adds neither a +prompt nor authority. + Targets are predicates over declared facets. They do not select a hard-coded Boatstack mode. The supervisor chooses admissible, target-coreachable transitions from the repository's declared relation. diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 9b4b83d..3d8899c 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -69,6 +69,7 @@ export default defineFlow(softwareDelivery({ entry({ id: "run", target: "published-pr", + requires: { authorities: ["human"] }, inputs: [inbox(".boatstack/plans/inbox")], delegation: trustedDelegation("autonomy"), diagnostics: { explain_on_suspend: true }, diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 4af3194..c627c46 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -479,6 +479,7 @@ export function standardSoftwareDeliveryParameters(step: TrustedStep): Record ({ id, instructions: { path: `${id}.md` }, diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index d478c08..c45be13 100644 --- a/packages/boatstack/src/index.ts +++ b/packages/boatstack/src/index.ts @@ -11,7 +11,7 @@ /** Canonical schema name emitted by {@link defineFlow}. */ export const CONTROL_PROGRAM_SCHEMA = "control-program" as const; /** Current revision of the canonical Control Program schema. */ -export const CONTROL_PROGRAM_SCHEMA_REVISION = 5 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 6 as const; /** * A declarative condition over runtime state facts. @@ -170,6 +170,11 @@ export interface TransitionParameterBinding { producer: ParameterProducer; } +/** Domain-neutral authority requirements shared by transitions and entries. */ +export interface AuthorityRequirements { + authorities?: string[]; +} + /** * A repository asset resolved and fingerprinted by the trusted compiler. * @@ -224,7 +229,7 @@ export interface TransitionDefinition { guard: Predicate; target: Predicate; priority: number; - requires?: { authorities?: string[] }; + requires?: AuthorityRequirements; work?: string; parameters?: TransitionParameterBinding[]; description?: string; @@ -256,6 +261,7 @@ export interface EntryInputDefinition { export interface EntryDefinition { id: string; target: string; + requires?: AuthorityRequirements; inputs?: EntryInputDefinition[]; delegation?: DelegationBindingDefinition; diagnostics?: { explain_on_suspend?: boolean }; @@ -515,7 +521,7 @@ export function marked( * * @example * ```ts - * entry({ id: "run", target: "published-pr" }) + * entry({ id: "run", target: "published-pr", requires: { authorities: ["human"] } }) * ``` */ export function entry(definition: EntryDefinition): EntryDefinition { diff --git a/release-notes/2026-08-17-entry-activation-authority.md b/release-notes/2026-08-17-entry-activation-authority.md new file mode 100644 index 0000000..de6b19e --- /dev/null +++ b/release-notes/2026-08-17-entry-activation-authority.md @@ -0,0 +1,6 @@ +### Require explicit Product Delivery entry activation + +Control Programs can now require authority to activate an entry. Product +Delivery's `run` entry combines explicit human activation and autonomy +delegation into one exact, run-bound consent event while keeping their scopes +separate: activation approval never becomes transition or provider authority. From 55fedcd8bf7c98220688e9ced2734a098c52c2b5 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 20:53:39 +0100 Subject: [PATCH 2/2] Repair entry authorization recovery --- .../boatstack-helper/delegation_command.go | 2 +- .../boatstack-helper/delegation_runtime.go | 15 +++++++++++---- .../cmd/boatstack-helper/flow_runtime_test.go | 19 +++++++++++++++++-- boatstack/flow/softwaredelivery/definition.go | 7 +++++++ .../flow/softwaredelivery/definition_test.go | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 8ae4804..049ef15 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -183,7 +183,7 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request return record, true, nil } if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityRole != request.HumanIdentityRole || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" { - if existing.RequestFingerprint == requestFingerprint && existing.Actor == actor && existing.ActorIdentityRole == request.HumanIdentityRole && existing.ActorIdentityProviderFingerprint == identityProviderFingerprint && existing.Status == "revoked" { + if existing.RequestFingerprint == requestFingerprint && existing.Actor == actor && existing.ActorIdentityRole == request.HumanIdentityRole && existing.ActorIdentityProviderFingerprint == identityProviderFingerprint && (existing.Status == "revoked" || existing.Status == "completed") { reauthorized := *existing reauthorized.Revision++ reauthorized.AuthorizedAt = now diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index a76f2ea..bac087d 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -174,10 +174,17 @@ func prepareFlowAuthorization(ctx context.Context, request *surfaces.Request) (p releaseOnError() return nil, nil, fmt.Errorf("DELEGATION_CONTEXT_UNAUTHORIZED: current worktree is not in the verified run lineage") } - if record.Status == "completed" && (request.Operation == surfaces.OperationResolve || request.Operation == surfaces.OperationExplain) { - // A completed delegation carries no authority, but resolving the exact - // bound run remains safe and lets restarts replay its terminal state. - return nil, nil, nil + if record.Status == "completed" { + if request.Operation == surfaces.OperationResolve || request.Operation == surfaces.OperationExplain { + // A completed authorization carries no authority, but resolving the + // exact bound run remains safe and lets restarts replay its terminal + // state. If current evidence makes the target nonterminal again, the + // resulting apply will require a fresh exact authorization below. + return nil, nil, nil + } + releaseOnError() + response, responseErr := flowAuthorizationRequiredResponse(*request) + return nil, response, responseErr } if record.Status != "active" { releaseOnError() diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 89a0a3a..c7adbfd 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -3255,8 +3255,23 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) } request.Operation = surfaces.OperationApply lock, suspension, err = prepareFlowAuthorization(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) + if lock != nil || suspension == nil || suspension.Authorization == nil || suspension.Authorization.RequestFingerprint != bound.delegationRequestFingerprint || err != nil { + t.Fatalf("post-target fresh authorization = lock=%v response=%#v err=%v", lock, suspension, err) + } + reauthorizedAfterTarget, changed, err := authorizeDelegation(&completed, bound.delegationRequest, bound.delegationRequestFingerprint, completed.ActorIdentityProviderFingerprint, completed.Actor, time.Hour, time.Now().UTC(), false) + if err != nil || !changed || reauthorizedAfterTarget.Status != "active" || reauthorizedAfterTarget.Revision != completed.Revision+1 || reauthorizedAfterTarget.ReceiptID == completed.ReceiptID || !reauthorizedAfterTarget.EndedAt.IsZero() || reauthorizedAfterTarget.EndReason != "" { + t.Fatalf("post-target reauthorization = record=%#v changed=%t err=%v", reauthorizedAfterTarget, changed, err) + } + if err := effects.StoreDelegationRecord(recordPath, reauthorizedAfterTarget); err != nil { + t.Fatal(err) + } + request.Authority.Receipts = nil + lock, suspension, err = prepareFlowAuthorization(context.Background(), &request) + if err != nil || lock == nil || suspension != nil || !request.Authority.Set(time.Now().UTC())[catalog.AuthorityAutonomy] || request.Authority.Set(time.Now().UTC())[catalog.AuthorityHuman] { + t.Fatalf("post-target authorized continuation = lock=%v response=%#v authority=%#v err=%v", lock, suspension, request.Authority, err) + } + if err := lock.Release(); err != nil { + t.Fatal(err) } } diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 648519e..f91f64e 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -30,6 +30,13 @@ type EntryObjective struct { } func NewDefinition(compiled controlprogram.Compiled, resolver Resolver) (Definition, error) { + for _, entry := range compiled.Document.Entries { + for _, authority := range entry.Requires.Authorities { + if authority != "human" { + return Definition{}, fmt.Errorf("entry %q activation authority %q has no trusted software-delivery producer", entry.ID, authority) + } + } + } if _, err := ObjectiveForEntry(context.Background(), compiled, resolver, compiled.Document.Entries[0].ID); err != nil { return Definition{}, err } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 3276afe..4e7d246 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -141,6 +141,21 @@ func TestRepositoryAuthorityRequirementIsConjunctive(t *testing.T) { } } +func TestEntryActivationRequiresTrustedSoftwareDeliveryProducer(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + document := compiled.Document + document.Declarations.Authorities = append(document.Declarations.Authorities, "autonomy") + document.Entries[0].Requires.Authorities = []string{"autonomy"} + unsupported, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + if _, err := softwareflow.NewDefinition(unsupported, resolver); err == nil || !strings.Contains(err.Error(), "no trusted software-delivery producer") { + t.Fatalf("unsupported entry activation result = %v", err) + } +} + func TestPublicationBindingPreservesProviderAsMandatory(t *testing.T) { resolver, err := softwareflow.NewResolver(context.Background()) if err != nil {