From 72a456ef06b580341b53827ea4c80dc90e4c1b98 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 16:15:03 +0100 Subject: [PATCH 1/3] Add named human identity roles --- .github/tests/test_detached_supervision.py | 12 +- .../cmd/boatstack-helper/declarative_flow.go | 14 +- .../boatstack-helper/declarative_flow_test.go | 15 ++- .../boatstack-helper/delegation_command.go | 14 +- .../boatstack-helper/delegation_runtime.go | 8 +- .../cmd/boatstack-helper/flow_command.go | 41 ++++-- .../cmd/boatstack-helper/flow_runtime.go | 127 +++++++++++++++++- .../cmd/boatstack-helper/flow_runtime_test.go | 114 ++++++++++++++-- .../cmd/boatstack-helper/human_identity.go | 28 +++- .../boatstack-helper/human_identity_test.go | 24 +++- .../cmd/boatstack-helper/input_command.go | 12 +- boatstack/cmd/boatstack-helper/main.go | 7 +- boatstack/cmd/boatstack-helper/main_test.go | 30 +++++ .../product_delivery_flow_e2e_test.go | 2 +- boatstack/controlprogram/artifact.go | 2 +- boatstack/controlprogram/canonical.go | 6 + boatstack/controlprogram/canonical_test.go | 27 ++++ .../frontend_conformance_test.go | 2 + boatstack/controlprogram/ir.go | 9 +- boatstack/delivery_controller.go | 26 +++- boatstack/distribution/standard_test.go | 4 +- boatstack/flow/softwaredelivery/definition.go | 5 +- .../flow/softwaredelivery/projections.go | 26 ++-- .../flow/softwaredelivery/projections_test.go | 7 +- .../softwaredelivery/delegation/record.go | 8 +- .../delegation/record_test.go | 4 +- .../softwaredelivery/durable/state.go | 18 ++- .../softwaredelivery/durable/state_facet.go | 1 + .../durable/state_schema_test.go | 6 +- .../effects/cas_integration_test.go | 4 +- .../effects/command_boundary_test.go | 2 +- .../effects/delegation_record_test.go | 4 +- .../softwaredelivery/effects/driver.go | 44 +++++- .../effects/human_identity_role_test.go | 41 ++++++ .../effects/integration_test.go | 28 ++-- .../softwaredelivery/effects/receipts.go | 8 +- .../softwaredelivery/effects/recovery_test.go | 2 +- .../humanidentity/identity.go | 38 +++++- .../humanidentity/identity_test.go | 28 +++- .../humanidentitybinding/binding.go | 123 ++++++++++++----- .../humanidentitybinding/binding_test.go | 30 +++-- .../softwaredelivery/protocol/config.go | 25 +++- .../softwaredelivery/protocol/config_test.go | 96 ++++++++++--- .../softwaredelivery/surfaces/protocol.go | 2 +- boatstack/invocation/invocation.go | 6 +- boatstack/program_effects_test.go | 2 +- boatstack/references/config-schema.md | 20 +-- boatstack/sdk/human_identity_test.go | 2 +- .../incident-response-invocation.flow.ts | 1 + .../incident-response.raw.json | 111 ++++++++++++--- ...t-delivery-planning-package-manual.flow.ts | 1 + .../product-delivery-planning-package.flow.ts | 1 + ...product-delivery-planning-package.raw.json | 2 +- docs/configuration.md | 52 ++++--- docs/getting-started.md | 4 +- .../authority-and-delegation.md | 9 +- docs/product-delivery/writing-a-flow.md | 1 + install.ps1 | 4 +- install.sh | 2 +- .../boatstack-software-delivery/src/index.ts | 12 ++ .../test/software-delivery.test.mjs | 12 ++ packages/boatstack/src/index.ts | 8 +- project.example.json | 13 +- .../2026-08-17-named-human-identity-roles.md | 8 ++ 64 files changed, 1079 insertions(+), 266 deletions(-) create mode 100644 boatstack/internal/softwaredelivery/effects/human_identity_role_test.go create mode 100644 release-notes/2026-08-17-named-human-identity-roles.md diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 339d2bc..6e2251c 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -175,8 +175,8 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No config.write_text( json.dumps( { - "schema_version": 4, - "identity": {"human": {"kind": "literal", "value": "contract"}}, + "schema_version": 5, + "identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}}, "project": {"name": "fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"], @@ -250,8 +250,8 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - config.write_text( json.dumps( { - "schema_version": 4, - "identity": {"human": {"kind": "literal", "value": "contract"}}, + "schema_version": 5, + "identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}}, "project": {"name": "driver-fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, "hosts": ["cli", "codex"], @@ -352,8 +352,8 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali config.write_text( json.dumps( { - "schema_version": 4, - "identity": {"human": {"kind": "literal", "value": "contract"}}, + "schema_version": 5, + "identity": {"default": "developer", "roles": {"developer": {"kind": "literal", "value": "contract"}}}, "project": { "name": "retained-authority-fixture", "default_branch": "main", diff --git a/boatstack/cmd/boatstack-helper/declarative_flow.go b/boatstack/cmd/boatstack-helper/declarative_flow.go index d290cfd..5389250 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow.go @@ -182,11 +182,14 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o } requiresHumanAuthority := declarativeRequiresHumanAuthority(transition, operator) if requiresHumanAuthority || transitionUsesHostInput(transition) { - presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", runtimeContext.controlBundle, nil) + presentation, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-suspension", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil) + if identityErr != nil { + return identityErr + } + runtimeContext.authorityContextFingerprint, identityErr = presentation.BindingFingerprint() if identityErr != nil { return identityErr } - runtimeContext.authorityContextFingerprint = presentation.ProviderFingerprint runtimeContext.humanIdentity = &presentation } result, materializationContext, err := materializeDeclarativeInvocation(runtimeContext, transition, operator) @@ -234,12 +237,13 @@ func runDeclarativeFlow(ctx context.Context, compiled controlprogram.Compiled, o return err } if runtimeContext.humanIdentity != nil { - current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", runtimeContext.controlBundle, nil) + current, identityErr := humanIdentityPresentationForRepositoryBound(ctx, repository, options.host, "declarative-commit", compiled.Document.Program.HumanIdentity, runtimeContext.controlBundle, nil) if identityErr != nil { return identityErr } - if current.ProviderFingerprint != runtimeContext.authorityContextFingerprint { - return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity provider changed before declarative state commit") + currentBinding, bindingErr := current.BindingFingerprint() + if bindingErr != nil || currentBinding != runtimeContext.authorityContextFingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified identity role or provider changed before declarative state commit") } } fresh, err := invocation.Materialize(operator.Parameters, transition.Parameters, materializationContext, nil) diff --git a/boatstack/cmd/boatstack-helper/declarative_flow_test.go b/boatstack/cmd/boatstack-helper/declarative_flow_test.go index 861b9b7..b297599 100644 --- a/boatstack/cmd/boatstack-helper/declarative_flow_test.go +++ b/boatstack/cmd/boatstack-helper/declarative_flow_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" ) func declarativeInvocationDocument() controlprogram.Document { @@ -17,7 +18,7 @@ func declarativeInvocationDocument() controlprogram.Document { mitigated := "mitigated" return controlprogram.Document{ Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, - Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"}, + Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"}, Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}}, Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}}, Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}}, @@ -68,7 +69,7 @@ func twoStepDeclarativeDocument() controlprogram.Document { contained, mitigated := "contained", "mitigated" return controlprogram.Document{ Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, - Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1"}, + Program: controlprogram.Program{ID: "incident-response-invocation", Version: "1", HumanIdentity: "developer"}, Declarations: controlprogram.Declarations{Authorities: []string{"human"}, Verifiers: []string{"state-effect"}}, Facets: []controlprogram.Facet{{ID: "incident", Kind: "enum", Values: []string{"open", "contained", "mitigated"}}}, Evidence: []controlprogram.Evidence{{ID: "state-effect", Subject: "incident", Kind: "state-observation"}}, @@ -255,7 +256,15 @@ func TestDeclarativeIdentityRotationSupersedesInputAndAuthoritySuspensions(t *te requestFingerprintA, _ := requestA["fingerprint"].(string) authorityContextA, _ := requestA["authority_context_fingerprint"].(string) identityA := start["human_identity"].(map[string]any) - if len(authorityContextA) != 64 || authorityContextA != identityA["provider_fingerprint"] { + presentationA, err := humanidentity.NewPresentation("developer", humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}) + if err != nil { + t.Fatal(err) + } + bindingA, err := presentationA.BindingFingerprint() + if err != nil { + t.Fatal(err) + } + if len(authorityContextA) != 64 || authorityContextA != bindingA || identityA["role"] != "developer" || identityA["provider_fingerprint"] != presentationA.ProviderFingerprint { t.Fatalf("provider A suspension is not bound: %s", startRaw) } diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index 1fd15a4..cb5169b 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -161,7 +161,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: requestFingerprint, - ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, existing.Revision+1, now), Actor: actor, + ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, existing.Revision+1, now), Actor: actor, + ActorIdentityRole: request.HumanIdentityRole, ActorIdentityProviderFingerprint: identityProviderFingerprint, AuthorizedAt: now, Revision: existing.Revision + 1, Status: "active", } @@ -170,7 +171,7 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request } return record, true, nil } - if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" { + if existing.RequestFingerprint != requestFingerprint || existing.Actor != actor || existing.ActorIdentityRole != request.HumanIdentityRole || existing.ActorIdentityProviderFingerprint != identityProviderFingerprint || existing.Status != "active" { return delegation.Record{}, false, fmt.Errorf("DELEGATION_CONFLICT: run already has a different authorization, actor, or status") } if existing.ExpiresAt.IsZero() || now.Before(existing.ExpiresAt) { @@ -183,14 +184,15 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request if expiresIn > 0 { record.ExpiresAt = now.Add(expiresIn) } - record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, record.Revision, now) + record.ReceiptID = authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, record.Revision, now) record.RevokedAt, record.EndedAt, record.EndReason = time.Time{}, time.Time{}, "" return record, true, nil } record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: requestFingerprint, - ReceiptID: authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint, 1, now), Actor: actor, + ReceiptID: authorizationReceiptID(requestFingerprint, actor, request.HumanIdentityRole, identityProviderFingerprint, 1, now), Actor: actor, + ActorIdentityRole: request.HumanIdentityRole, ActorIdentityProviderFingerprint: identityProviderFingerprint, AuthorizedAt: now, Revision: 1, Status: "active", } @@ -200,8 +202,8 @@ func authorizeDelegation(existing *delegation.Record, request delegation.Request return record, true, nil } -func authorizationReceiptID(requestFingerprint, actor, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string { - receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano)))) +func authorizationReceiptID(requestFingerprint, actor, identityRole, identityProviderFingerprint string, revision uint64, authorizedAt time.Time) string { + receiptDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", requestFingerprint, actor, identityRole, identityProviderFingerprint, revision, authorizedAt.UTC().Format(time.RFC3339Nano)))) return "authorization-" + hex.EncodeToString(receiptDigest[:12]) } diff --git a/boatstack/cmd/boatstack-helper/delegation_runtime.go b/boatstack/cmd/boatstack-helper/delegation_runtime.go index 5c47c08..e81a4d1 100644 --- a/boatstack/cmd/boatstack-helper/delegation_runtime.go +++ b/boatstack/cmd/boatstack-helper/delegation_runtime.go @@ -39,7 +39,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo prior, current, func() (bool, error) { - return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint) + return effects.ConfigurationReprojectionAdmits(layout, current.RunID, invocation, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint, current.ControlBundleFingerprint) }, func() (bool, error) { return effects.InstallationReprojectionAdmits(layout, current.RunID, invocation, current.ControlBundleFingerprint) @@ -48,7 +48,7 @@ func canReprojectDelegation(layout ports.ControllerLayout, invocation model.Invo } func admittedDelegationReprojection(prior, current delegation.Request, configurationAdmits, installationAdmits func() (bool, error)) (bool, error) { - configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint + configurationChanged := prior.ControlBundleFingerprint != current.ControlBundleFingerprint || prior.HumanIdentityRole != current.HumanIdentityRole || prior.HumanIdentityProviderFingerprint != current.HumanIdentityProviderFingerprint if prior.ProgramFingerprint == current.ProgramFingerprint && configurationChanged { admitted, err := configurationAdmits() if err != nil || admitted { @@ -130,13 +130,13 @@ func prepareDelegation(ctx context.Context, request *surfaces.Request) (ports.Lo releaseOnError() return nil, nil, err } - if record.RequestFingerprint != request.DelegationRequestFingerprint || record.Request.RunID != request.FlowID || record.Request.ProgramID != request.ProgramID || record.Request.ProgramFingerprint != request.ProgramFingerprint || record.Request.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.HumanIdentityProviderFingerprint != presentation.ProviderFingerprint || 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 || 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.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.ProviderFingerprint + current.BindingFingerprint, current.HumanIdentityRole, current.HumanIdentityProviderFingerprint = request.DelegationBindingFingerprint, presentation.Role, presentation.ProviderFingerprint current.RequestedAuthorities = make([]string, len(request.DelegatedAuthorities)) for index, authority := range request.DelegatedAuthorities { current.RequestedAuthorities[index] = string(authority) diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go index 712f6f9..636f0ad 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.6" +const flowCompilerVersion = "control-program.compiler.7" type flowCommandOptions struct { repository string @@ -107,7 +107,7 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err != nil { return err } - configPath, configRaw, _, projections, err := loadProjectProjectionSelection(options.repository) + configPath, configRaw, _, config, projections, err := loadProjectProjectionSelection(options.repository) if err != nil { return err } @@ -143,6 +143,9 @@ func compileFlow(ctx context.Context, options flowCommandOptions) error { if err := validateCompiledFlow(ctx, options.repository, compiled, resolver); err != nil { return err } + if err := validateProgramHumanIdentity(compiled, config); err != nil { + return err + } artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID) if err != nil { return err @@ -623,32 +626,50 @@ func renderFlowResult(status, artifactPath string, artifact controlprogram.Artif }) } -func loadProjectProjectionSelection(repository string) (string, []byte, string, []hostprojection.ID, error) { +func loadProjectProjectionSelection(repository string) (string, []byte, string, protocol.ProjectConfig, []hostprojection.ID, error) { path, err := exactRepositoryPath(repository, filepath.Join(".boatstack", "project.json")) if err != nil { - return "", nil, "", nil, err + return "", nil, "", protocol.ProjectConfig{}, nil, err } raw, err := os.ReadFile(path) if err != nil { - return "", nil, "", nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err) + return "", nil, "", protocol.ProjectConfig{}, nil, fmt.Errorf("PROJECT_PROJECTIONS_REQUIRED: read project configuration: %w", err) } config, fingerprint, err := protocol.ProjectConfigFingerprint(raw) if err != nil { - return "", nil, "", nil, err + return "", nil, "", protocol.ProjectConfig{}, nil, err } projections, err := config.ProjectionIDs() if err != nil { - return "", nil, "", nil, err + return "", nil, "", protocol.ProjectConfig{}, nil, err } - return path, raw, fingerprint, projections, nil + return path, raw, fingerprint, config, projections, nil } func checkArtifactForCurrentProject(repository string, artifact controlprogram.Artifact, resolver controlprogram.BindingResolver) (controlprogram.Compiled, error) { - _, _, _, projections, err := loadProjectProjectionSelection(repository) + _, _, _, config, projections, err := loadProjectProjectionSelection(repository) + if err != nil { + return controlprogram.Compiled{}, err + } + compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections) if err != nil { return controlprogram.Compiled{}, err } - return controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, projections, generateSoftwareFlowProjections) + if err := validateProgramHumanIdentity(compiled, config); err != nil { + return controlprogram.Compiled{}, err + } + return compiled, nil +} + +func validateProgramHumanIdentity(compiled controlprogram.Compiled, config protocol.ProjectConfig) error { + role := compiled.Document.Program.HumanIdentity + if role == "" { + return nil + } + if _, ok := config.Identity.Roles[role]; !ok { + return fmt.Errorf("CONTROL_PROGRAM_HUMAN_IDENTITY_UNBOUND: program %q requires identity role %q, but the current project configuration does not define it", compiled.Document.Program.ID, role) + } + return nil } func entryIDs(entries []controlprogram.Entry) []string { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 1990695..31b6135 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -23,6 +23,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -91,7 +92,7 @@ func bindFlowCommitRequiredOperation(err error, operation surfaces.Operation) er func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, error) { if options.programID == "" && options.entryID == "" { - return options, nil + return bindStandaloneTransientHumanIdentity(ctx, options) } if !flowSegment.MatchString(options.programID) || !flowSegment.MatchString(options.entryID) { return commandOptions{}, fmt.Errorf("FLOW_ENTRY_INVALID: --flow and --entry require semantic identifiers") @@ -264,7 +265,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if description == "" { description = fmt.Sprintf("Run %s/%s to %s", options.programID, options.entryID, objective.TargetID) } - presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, repository, host, "flow-delegation-request", bundle.Source, nil) + presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, repository, host, "flow-delegation-request", compiled.Document.Program.HumanIdentity, bundle.Source, nil) if presentationErr != nil { return commandOptions{}, presentationErr } @@ -275,6 +276,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, InputFingerprints: []string{planFingerprint}, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, InitialWorktreeID: invocation.WorktreeID, InitialRef: invocation.Ref, BindingFingerprint: entry.Delegation.Fingerprint, RequestedAuthorities: append([]string(nil), entry.Delegation.Authorities...), + HumanIdentityRole: presentation.Role, HumanIdentityProviderFingerprint: presentation.ProviderFingerprint, Description: description, } @@ -289,7 +291,7 @@ 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.HumanIdentityProviderFingerprint != delegationRequest.HumanIdentityProviderFingerprint || strings.Join(bound.RequestedAuthorities, "\x00") != strings.Join(delegationRequest.RequestedAuthorities, "\x00") || bound.Description != delegationRequest.Description { + 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 { reprojected, reprojectErr := canReprojectDelegation(layout, invocation, bound, delegationRequest) if reprojectErr != nil { return commandOptions{}, reprojectErr @@ -343,9 +345,121 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, options.invocationEvidence = &boundEvidence } } + if options.humanActor != "" { + presentation, presentationErr := transientHumanIdentityPresentation(ctx, compiled, options) + if presentationErr != nil { + return commandOptions{}, presentationErr + } + options.humanIdentityRole = presentation.Role + options.humanIdentityProviderFingerprint = presentation.ProviderFingerprint + } return options, nil } +func bindStandaloneTransientHumanIdentity(ctx context.Context, options commandOptions) (commandOptions, error) { + if options.humanActor == "" { + return options, nil + } + switch options.transitionID { + case "installation.initialize", "configuration.initialize": + // True bootstrap has no trusted identity source. The explicit actor is + // intentionally the only provenance at this boundary. + return options, nil + case "configuration.mutate", "configuration.reconcile", "installation.update", "installation.reconcile-update": + default: + return options, nil + } + repository, err := filepath.Abs(options.repository) + if err != nil { + return commandOptions{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return commandOptions{}, err + } + host := options.host + if host == "" { + host = "cli" + } + var presentation humanidentity.Presentation + switch options.transitionID { + case "configuration.mutate", "configuration.reconcile": + presentation, err = humanIdentityPresentationForCurrentRepositoryDefault(ctx, repository, host, "transient-maintenance-human-authority") + case "installation.update", "installation.reconcile-update": + presentation, err = humanIdentityPresentationForCurrentProgramChange(ctx, repository, host, "transient-program-change-authority", options.transitionID) + } + if err != nil { + return commandOptions{}, err + } + options.repository = repository + options.humanIdentityRole = presentation.Role + options.humanIdentityProviderFingerprint = presentation.ProviderFingerprint + return options, nil +} + +func transientHumanIdentityPresentation(ctx context.Context, compiled controlprogram.Compiled, options commandOptions) (humanidentity.Presentation, error) { + if options.controlBundle == nil { + return humanidentity.Presentation{}, nil + } + host := options.host + if host == "" { + host = "cli" + } + switch options.transitionID { + case "installation.initialize", "configuration.initialize": + // True bootstrap has no admitted role or verified default. The explicit + // actor remains the only authority provenance at this boundary. + return humanidentity.Presentation{}, nil + case "configuration.mutate", "configuration.reconcile": + return humanIdentityPresentationForRepositoryDefault(ctx, options.repository, host, "transient-human-authority", options.controlBundle.Source, nil) + case "installation.update", "installation.reconcile-update": + return humanIdentityPresentationForProgramChange(ctx, options.repository, host, "transient-program-change-authority", compiled.Document.Program.ID, options.transitionID, options.controlBundle.Source) + default: + if compiled.Document.Program.HumanIdentity == "" { + return humanidentity.Presentation{}, nil + } + if options.transitionID == "" { + hasState, stateErr := repositoryHasDurableState(ctx, options.repository, host) + if stateErr != nil { + return humanidentity.Presentation{}, stateErr + } + if !hasState { + // An unbound Flow command can only select bootstrap work before + // durable state exists. Preserve its explicit actor; once state + // exists, ordinary Flow authority must resolve the admitted role. + return humanidentity.Presentation{}, nil + } + } + return humanIdentityPresentationForRepositoryBound(ctx, options.repository, host, "transient-flow-authority", compiled.Document.Program.HumanIdentity, options.controlBundle.Source, nil) + } +} + +func repositoryHasDurableState(ctx context.Context, repository, host string) (bool, error) { + resolver, err := plant.NewResolver("") + if err != nil { + return false, err + } + invocation, err := resolver.ResolveInvocation(ctx, repository, host, "transient-human-authority-state") + if err != nil { + return false, err + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return false, err + } + info, err := os.Lstat(layout.StatePath) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: durable state is not a regular file") + } + return true, nil +} + // requireCommittedAcceptedFlowBundle makes an accepted installation result a // hard suspension boundary. Once durable state binds the exact current bundle, // no unbound resolution may select or create product state until those bytes @@ -579,11 +693,14 @@ func materializeFlowInvocation(ctx context.Context, compiled controlprogram.Comp if bundle == nil || len(bundleFingerprint) != 64 { return commandOptions{}, fmt.Errorf("FLOW_INPUT_UNBOUND: host input requires an exact verified control bundle") } - presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, options.repository, host, "flow-input-"+options.runID+"-"+transition.ID, bundle.Source, nil) + presentation, presentationErr := humanIdentityPresentationForRepositoryBound(ctx, options.repository, host, "flow-input-"+options.runID+"-"+transition.ID, compiled.Document.Program.HumanIdentity, bundle.Source, nil) + if presentationErr != nil { + return commandOptions{}, presentationErr + } + authorityContextFingerprint, presentationErr = presentation.BindingFingerprint() if presentationErr != nil { return commandOptions{}, presentationErr } - authorityContextFingerprint = presentation.ProviderFingerprint } contextFingerprint, err := general.Fingerprint(struct { Invocation model.InvocationContext `json:"invocation"` diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 3578b86..ef6e634 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -168,7 +168,7 @@ func TestTrustedControlBundleRejectsHeadDriftWithMatchingWorkingTree(t *testing. func TestProjectionSelectionChangesProjectAndControlBundleNotProgram(t *testing.T) { // control-law: projection-files-never-become-executable-control-authority config := func(projections string) []byte { - return []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude","cursor","gemini"],"projections":` + projections + `}`) + return []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude","cursor","gemini"],"projections":` + projections + `}`) } codexRaw, allRaw := config(`["codex"]`), config(`["codex","claude","cursor","gemini"]`) codexConfig, codexProjectFingerprint, err := protocol.ProjectConfigFingerprint(codexRaw) @@ -303,7 +303,7 @@ func TestFreshFlowInitializationRejectsDirtyCanonicalConfigurationBeforeEffects( runFlowGit(t, repository, "add", ".") runFlowGit(t, repository, "commit", "-q", "-m", "fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"dirty","default_branch":"main","commands":{"test":"false"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) questionRaw, err := captureRunOutput(t, "flow", "run", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--repository-authority", "--host", "codex", "--format", "json", @@ -400,6 +400,7 @@ func writeAdmittedFlowProgramState(t *testing.T, repository, programFingerprint } state := durable.Default(invoking, time.Now().UTC()) state.ProgramFingerprint = programFingerprint + state.ProgramHumanIdentityRole = "developer" state.ControlBundleFingerprint = bundleFingerprint configRaw, err := os.ReadFile(layout.ConfigPath) if err != nil { @@ -449,6 +450,26 @@ func writeVerifiedFlowConfigurationState(t *testing.T, repository string) { t.Fatal(err) } state := durable.Default(invoking, time.Now().UTC()) + artifacts, err := os.ReadDir(filepath.Join(repository, ".boatstack", "flows")) + if err != nil { + t.Fatal(err) + } + for _, entry := range artifacts { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".flow.ir.json") { + continue + } + raw, readErr := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", entry.Name())) + if readErr != nil { + t.Fatal(readErr) + } + artifact, loadErr := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if loadErr != nil { + t.Fatal(loadErr) + } + state.ProgramFingerprint = artifact.ProgramFingerprint + state.ProgramHumanIdentityRole = artifact.Program.Program.HumanIdentity + break + } policy := config.ControlPolicy() state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, configFingerprint state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy = policy.PlanApproval, policy.VisualEvidence, policy.ExternalEffectAuthority @@ -647,7 +668,7 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. t.Helper() projectPath := filepath.Join(repository, ".boatstack", "project.json") if _, err := os.Stat(projectPath); os.IsNotExist(err) { - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) } resolver, err := softwareflow.NewResolver(context.Background()) if err != nil { @@ -676,7 +697,7 @@ func writeFlowArtifact(t *testing.T, repository string, document controlprogram. func writeProjectionProjectConfig(t *testing.T, repository string) { t.Helper() - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) } func writeMaintenanceProjectionFixture(t *testing.T, repository string) { @@ -718,7 +739,7 @@ func productDeliveryDocument(programID string) controlprogram.Document { config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) return controlprogram.Document{ Schema: controlprogram.SchemaName, SchemaRevision: controlprogram.SchemaRevision, - Program: controlprogram.Program{ID: programID, Version: "1"}, + Program: controlprogram.Program{ID: programID, Version: "1", HumanIdentity: "developer"}, Declarations: controlprogram.Declarations{InputResolvers: []string{"software-delivery.plan-inbox"}}, Facets: []controlprogram.Facet{ {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, @@ -1217,7 +1238,7 @@ func TestWorkspaceCutRejectsControlBundleThatIsNotInBaseRevision(t *testing.T) { func TestInstallationInitializeRejectsArtifactsFromDifferentCandidateProjectionSelection(t *testing.T) { // control-law: initialization-target-artifacts-match-the-candidate-project-selection repository := flowRepository(t) - candidateRaw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}`) + candidateRaw := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}`) candidatePath := filepath.Join(t.TempDir(), "project.json") if err := os.WriteFile(candidatePath, candidateRaw, 0o600); err != nil { t.Fatal(err) @@ -1456,7 +1477,7 @@ func TestFlowRefreshPreservesTrustedMaintenanceParameters(t *testing.T) { options, err := bindFlowEntry(context.Background(), commandOptions{ repository: repository, programID: "product-delivery", entryID: "run", host: "codex", transitionID: "installation.reconcile-update", parameters: parameters, - maintenanceParameterSurface: true, humanActor: "operator", + maintenanceParameterSurface: true, }) if err != nil { t.Fatal(err) @@ -1647,7 +1668,7 @@ func TestFlowCompileRejectsProjectSelectionChangedDuringFrontend(t *testing.T) { t.Fatal(err) } writeFixture(t, repository, "raw-ir.json", documentRaw) - changed := `{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}` + changed := `{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex","claude"],"projections":["codex"]}` writeFixture(t, repository, "changed-project.json", []byte(changed)) frontend := filepath.Join(repository, "frontend.sh") script := []byte("#!/bin/sh\ncat >/dev/null\ncp '" + filepath.Join(repository, "changed-project.json") + "' '" + filepath.Join(repository, ".boatstack", "project.json") + "'\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") @@ -1730,6 +1751,71 @@ func TestFlowCompileNamesDefaultArtifactFromProgramID(t *testing.T) { } } +func TestFlowCompileAndCheckRejectUnboundHumanIdentityBeforePublication(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + setup := func(t *testing.T, includeRole bool) (string, flowCommandOptions) { + t.Helper() + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + document := productDeliveryDocument("product-delivery") + document.Program.HumanIdentity = "release-manager" + documentRaw, _ := json.Marshal(document) + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("declarative source")) + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + roles := `"developer":{"kind":"literal","value":"operator"}` + if includeRole { + roles += `,"release-manager":{"kind":"literal","value":"release-operator"}` + } + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{`+roles+`}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`)) + return repository, flowCommandOptions{repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend} + } + + t.Run("compile", func(t *testing.T) { + repository, options := setup(t, false) + err := compileFlow(context.Background(), options) + if err == nil || !strings.Contains(err.Error(), "CONTROL_PROGRAM_HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("unbound compile result = %v", err) + } + for _, path := range []string{".boatstack/flows/product-delivery.flow.ir.json", ".agents/skills/product-delivery-run/SKILL.md"} { + if _, statErr := os.Stat(filepath.Join(repository, filepath.FromSlash(path))); !os.IsNotExist(statErr) { + t.Fatalf("unbound compile published %s: %v", path, statErr) + } + } + }) + + t.Run("check", func(t *testing.T) { + repository, options := setup(t, true) + if err := compileFlow(context.Background(), options); err != nil { + t.Fatal(err) + } + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + before, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`)) + err = checkFlow(context.Background(), flowCommandOptions{repository: repository}) + if err == nil || !strings.Contains(err.Error(), "CONTROL_PROGRAM_HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("unbound check result = %v", err) + } + after, readErr := os.ReadFile(artifactPath) + if readErr != nil || !bytes.Equal(before, after) { + t.Fatalf("failed check changed artifact: %v", readErr) + } + }) +} + func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { // control-law: every valid IR entry identity has an injective artifact-valid skill path if runtime.GOOS == "windows" { @@ -2810,7 +2896,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: bound.delegationRequest, RequestFingerprint: bound.delegationRequestFingerprint, - ReceiptID: "authorization-test", Actor: "operator", ActorIdentityProviderFingerprint: bound.delegationRequest.HumanIdentityProviderFingerprint, + ReceiptID: "authorization-test", Actor: "operator", ActorIdentityRole: bound.delegationRequest.HumanIdentityRole, ActorIdentityProviderFingerprint: bound.delegationRequest.HumanIdentityProviderFingerprint, AuthorizedAt: now, Revision: 1, Status: "active", } if err := effects.StoreDelegationRecord(recordPath, record); err != nil { @@ -2870,7 +2956,7 @@ func TestDelegationIsRequiredAndRevocationWinsBetweenNextAndApply(t *testing.T) t.Fatal(err) } replayedResponse, err := replayedKernel.Handle(context.Background(), replayedExplain) - if err != nil || replayedResponse.Decision == nil || replayedResponse.Decision.Kind != supervisor.DecisionFrontier { + if err != nil || replayedResponse.Decision == nil || replayedResponse.Decision.Kind != supervisor.DecisionUnresolved || !strings.Contains(replayedResponse.Decision.Reason, "program drift") { t.Fatalf("missing-record explain decision = %#v, err=%v", replayedResponse.Decision, err) } if _, err := os.Stat(recordPath); !os.IsNotExist(err) { @@ -2987,7 +3073,7 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi 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"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("c", 64), HumanIdentityProviderFingerprint: strings.Repeat("f", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", + BindingFingerprint: strings.Repeat("c", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("f", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run product delivery", } priorFingerprint, err := prior.Fingerprint() if err != nil { @@ -2995,7 +3081,7 @@ func TestExplicitAuthorizationCanReplaceRevokedPreReconciliationRequest(t *testi } existing := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: prior, RequestFingerprint: priorFingerprint, - ReceiptID: "authorization-prior", Actor: "operator", ActorIdentityProviderFingerprint: prior.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 3, Status: "revoked", + ReceiptID: "authorization-prior", Actor: "operator", ActorIdentityRole: prior.HumanIdentityRole, ActorIdentityProviderFingerprint: prior.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 3, Status: "revoked", } current := prior current.ProgramFingerprint, current.ControlBundleFingerprint = strings.Repeat("d", 64), strings.Repeat("e", 64) @@ -3019,7 +3105,7 @@ func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { request := 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", - RepositoryID: "repository", GitCommonID: "common", + RepositoryID: "repository", GitCommonID: "common", HumanIdentityRole: "developer", } changedInput := request changedInput.InputFingerprints = []string{"changed-plan"} @@ -3039,7 +3125,7 @@ func TestDelegationReprojectionRejectsUnadmittedContextChanges(t *testing.T) { func TestConfigurationReprojectionPrecedesInstallationForChangedRepositoryBundle(t *testing.T) { prior := delegation.Request{ ProgramFingerprint: strings.Repeat("a", 64), ControlBundleFingerprint: strings.Repeat("b", 64), - HumanIdentityProviderFingerprint: strings.Repeat("c", 64), + HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("c", 64), } current := prior current.ControlBundleFingerprint = strings.Repeat("d", 64) diff --git a/boatstack/cmd/boatstack-helper/human_identity.go b/boatstack/cmd/boatstack-helper/human_identity.go index aa455bc..2e43982 100644 --- a/boatstack/cmd/boatstack-helper/human_identity.go +++ b/boatstack/cmd/boatstack-helper/human_identity.go @@ -5,6 +5,7 @@ import ( "fmt" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentitybinding" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" @@ -26,8 +27,29 @@ func humanIdentityPresentationForRequest(request surfaces.Request) (humanidentit return humanidentitybinding.PresentationForRequest(context.Background(), "", request, nil) } -func humanIdentityPresentationForRepositoryBound(ctx context.Context, repository, host, correlation string, bundle boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { - return humanidentitybinding.PresentationForRepository(ctx, "", repository, host, correlation, &bundle, observed) +func humanIdentityPresentationForRepositoryBound(ctx context.Context, repository, host, correlation, role string, bundle boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForRepository(ctx, "", repository, host, correlation, role, &bundle, observed) +} + +func humanIdentityPresentationForRepositoryDefault(ctx context.Context, repository, host, correlation string, bundle boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForRepositoryDefault(ctx, "", repository, host, correlation, &bundle, observed) +} + +func humanIdentityPresentationForCurrentRepositoryDefault(ctx context.Context, repository, host, correlation string) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForRepositoryDefault(ctx, "", repository, host, correlation, nil, nil) +} + +func humanIdentityPresentationForProgramChange(ctx context.Context, repository, host, correlation, programID, transitionID string, bundle boatstackruntime.ControlBundleSnapshot) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForProgramChange(ctx, "", surfaces.Request{ + Repository: repository, Host: host, CorrelationID: correlation, ProgramID: programID, TransitionID: catalog.TransitionID(transitionID), + ControlBundle: &boatstackruntime.ControlBundleContract{Source: bundle}, + }, nil) +} + +func humanIdentityPresentationForCurrentProgramChange(ctx context.Context, repository, host, correlation, transitionID string) (humanidentity.Presentation, error) { + return humanidentitybinding.PresentationForProgramChange(ctx, "", surfaces.Request{ + Repository: repository, Host: host, CorrelationID: correlation, TransitionID: catalog.TransitionID(transitionID), + }, nil) } func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) error { @@ -35,7 +57,7 @@ func attachHumanIdentity(request surfaces.Request, response *surfaces.Response) } func renderHumanIdentity(presentation humanidentity.Presentation) { - fmt.Printf("human_identity_provider=%s kind=%s\n", presentation.ProviderFingerprint, presentation.Descriptor.Kind) + fmt.Printf("human_identity_role=%s human_identity_provider=%s kind=%s\n", presentation.Role, presentation.ProviderFingerprint, presentation.Descriptor.Kind) if presentation.Descriptor.Kind == humanidentity.KindLiteral { fmt.Printf("suggested_human_actor=%s\n", presentation.Descriptor.Value) return diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 34dab01..7c5a45c 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -35,7 +35,7 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) } - configRaw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) + configRaw := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]},"release-manager":{"kind":"literal","value":"release-approver"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) configPath := filepath.Join(repository, ".boatstack", "project.json") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) @@ -60,6 +60,8 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { t.Fatal(err) } state := durable.Default(invocation, time.Now().UTC()) + state.ProgramFingerprint = strings.Repeat("a", 64) + state.ProgramHumanIdentityRole = "release-manager" policy := config.ControlPolicy() state.Configuration, state.ConfigFingerprint = model.ConfigurationVerified, configFingerprint state.PlanApprovalPolicy, state.VisualEvidencePolicy, state.ExternalEffectPolicy = policy.PlanApproval, policy.VisualEvidence, policy.ExternalEffectAuthority @@ -99,9 +101,21 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { PriorProgramFingerprint: strings.Repeat("a", 64), CandidateProgramFingerprint: strings.Repeat("b", 64), ProgramDeltaFingerprint: strings.Repeat("c", 64), RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", }}}, request) - if err != nil || programChange.ProgramChange.HumanIdentity == nil || !reflect.DeepEqual(*programChange.ProgramChange.HumanIdentity, presentation) { + if err != nil || programChange.ProgramChange.HumanIdentity == nil || programChange.ProgramChange.HumanIdentity.Role != "release-manager" || programChange.ProgramChange.HumanIdentity.Descriptor.Value != "release-approver" { t.Fatalf("program-change identity = %#v, err=%v", programChange.ProgramChange.HumanIdentity, err) } + maintenance, err := bindStandaloneTransientHumanIdentity(context.Background(), commandOptions{ + repository: repository, host: "cli", transitionID: "configuration.mutate", humanActor: "operator", + }) + if err != nil || maintenance.humanIdentityRole != "developer" || maintenance.humanIdentityProviderFingerprint != presentation.ProviderFingerprint { + t.Fatalf("configuration mutation identity = role %q provider %q, err=%v", maintenance.humanIdentityRole, maintenance.humanIdentityProviderFingerprint, err) + } + replacement, err := bindStandaloneTransientHumanIdentity(context.Background(), commandOptions{ + repository: repository, host: "cli", transitionID: "installation.reconcile-update", humanActor: "operator", + }) + if err != nil || replacement.humanIdentityRole != "release-manager" || replacement.humanIdentityProviderFingerprint != programChange.ProgramChange.HumanIdentity.ProviderFingerprint { + t.Fatalf("program replacement identity = role %q provider %q, err=%v", replacement.humanIdentityRole, replacement.humanIdentityProviderFingerprint, err) + } if err := os.WriteFile(configPath, []byte(strings.ReplaceAll(string(configRaw), ".login", ".name")), 0o600); err != nil { t.Fatal(err) @@ -159,7 +173,7 @@ func TestUnverifiedConfigurationRepairPreservesExplicitActorQuestion(t *testing. func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *testing.T) { marker := filepath.Join(t.TempDir(), "executed") descriptor := humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "touch", Args: []string{marker}} - presentation, err := humanidentity.NewPresentation(descriptor) + presentation, err := humanidentity.NewPresentation("developer", descriptor) if err != nil { t.Fatal(err) } @@ -199,8 +213,8 @@ func TestHumanIdentityRenderingPreservesStructuredArgvWithoutExecutingIt(t *test func TestAuthorizationReceiptIdentityBindsIdentityProvider(t *testing.T) { now := time.Unix(1_700_000_000, 0).UTC() requestFingerprint := strings.Repeat("a", 64) - first := authorizationReceiptID(requestFingerprint, "operator", strings.Repeat("b", 64), 1, now) - second := authorizationReceiptID(requestFingerprint, "operator", strings.Repeat("c", 64), 1, now) + first := authorizationReceiptID(requestFingerprint, "operator", "developer", strings.Repeat("b", 64), 1, now) + second := authorizationReceiptID(requestFingerprint, "operator", "developer", strings.Repeat("c", 64), 1, now) if first == second { t.Fatal("identity provider change preserved authorization receipt ID") } diff --git a/boatstack/cmd/boatstack-helper/input_command.go b/boatstack/cmd/boatstack-helper/input_command.go index 0ec6ff3..bb43e68 100644 --- a/boatstack/cmd/boatstack-helper/input_command.go +++ b/boatstack/cmd/boatstack-helper/input_command.go @@ -95,8 +95,9 @@ func runFlowInput(arguments []string) error { if request.ControlBundleFingerprint != runtimeContext.controlBundle.Fingerprint { return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request bundle %s does not match current verified bundle %s", request.ControlBundleFingerprint, runtimeContext.controlBundle.Fingerprint) } - if request.AuthorityContextFingerprint != runtimeContext.humanIdentity.ProviderFingerprint { - return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request authority context %s does not match current verified identity provider %s", request.AuthorityContextFingerprint, runtimeContext.humanIdentity.ProviderFingerprint) + bindingFingerprint, bindingErr := runtimeContext.humanIdentity.BindingFingerprint() + if bindingErr != nil || request.AuthorityContextFingerprint != bindingFingerprint { + return fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request authority context does not match current verified identity role and provider") } if action == "show" { receipts, loadErr := store.LoadReceipts(options.runID, request.TransitionID) @@ -208,7 +209,7 @@ func loadFlowInputContext(ctx context.Context, options flowInputOptions) (contro if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } - presentation, err := humanIdentityPresentationForRepositoryBound(ctx, options.repository, options.host, "flow-input-"+options.runID, controlBundle.Source, nil) + presentation, err := humanIdentityPresentationForRepositoryBound(ctx, options.repository, options.host, "flow-input-"+options.runID, compiled.Document.Program.HumanIdentity, controlBundle.Source, nil) if err != nil { return controlprogram.Compiled{}, invocation.Store{}, flowInputRuntimeContext{}, err } @@ -253,8 +254,9 @@ func recordFlowInputAnswers(store invocation.Store, compiled controlprogram.Comp if runtimeContext.executionScopeFingerprint != request.ExecutionScopeFingerprint { return nil, fmt.Errorf("FLOW_INPUT_REQUEST_MISMATCH: execution scope changed after suspension") } - if runtimeContext.humanIdentity.ProviderFingerprint != request.AuthorityContextFingerprint { - return nil, fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request identity provider changed after suspension") + bindingFingerprint, bindingErr := runtimeContext.humanIdentity.BindingFingerprint() + if bindingErr != nil || bindingFingerprint != request.AuthorityContextFingerprint { + return nil, fmt.Errorf("HUMAN_IDENTITY_DRIFT: input request identity role or provider changed after suspension") } transition, ok := findCompiledTransition(compiled.Document.Transitions, request.TransitionID) if !ok { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 734cf4d..cee96c9 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -66,6 +66,8 @@ type commandOptions struct { effectiveCapabilities stringList idempotencyKey string humanActor string + humanIdentityRole string + humanIdentityProviderFingerprint string repositoryPolicy bool acceptProgramChange bool parameters stringList @@ -910,7 +912,10 @@ func loadAuthority(options commandOptions, correlation string, objective model.O if err != nil { return protocol.AuthorityBundle{}, err } - fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor, string(parameterRaw)}, "\x00"))) + if (options.humanIdentityRole == "") != (options.humanIdentityProviderFingerprint == "") { + return protocol.AuthorityBundle{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: transient human authority has incomplete role provenance") + } + fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor, options.humanIdentityRole, options.humanIdentityProviderFingerprint, string(parameterRaw)}, "\x00"))) bundle.Receipts = append(bundle.Receipts, protocol.AuthorityReceipt{ ID: "human-" + fingerprint[:16], Class: catalog.AuthorityHuman, Subject: options.humanActor, Fingerprint: fingerprint, IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute), diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index f352606..30eb06b 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -165,6 +165,36 @@ func TestHumanPublicationConfirmationBindsExactPreviewFingerprint(t *testing.T) } } +func TestHumanAuthorityReceiptBindsRoleAndProvider(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + base := commandOptions{ + humanActor: "reviewer", transitionID: "plan.approve", humanIdentityRole: "developer", + humanIdentityProviderFingerprint: strings.Repeat("a", 64), + } + developer, err := loadAuthority(base, "correlation", model.Objective{ID: "objective"}, nil, now) + if err != nil { + t.Fatal(err) + } + otherRole := base + otherRole.humanIdentityRole = "release-manager" + release, err := loadAuthority(otherRole, "correlation", model.Objective{ID: "objective"}, nil, now) + if err != nil { + t.Fatal(err) + } + otherProvider := base + otherProvider.humanIdentityProviderFingerprint = strings.Repeat("b", 64) + rotated, err := loadAuthority(otherProvider, "correlation", model.Objective{ID: "objective"}, nil, now) + if err != nil { + t.Fatal(err) + } + if developer.Receipts[0].Fingerprint == release.Receipts[0].Fingerprint || developer.Receipts[0].Fingerprint == rotated.Receipts[0].Fingerprint { + t.Fatal("role or provider drift reused transient human authority") + } + if _, err := loadAuthority(commandOptions{humanActor: "reviewer", humanIdentityRole: "developer"}, "correlation", model.Objective{ID: "objective"}, nil, now); err == nil { + t.Fatal("partial transient identity provenance was accepted") + } +} + func TestHumanAuthorityNeverFallsBackToEnvironmentOrGitIdentity(t *testing.T) { t.Setenv("USER", "implicit-user") t.Setenv("LOGNAME", "implicit-logname") 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 7c40694..cdf0018 100644 --- a/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go +++ b/boatstack/cmd/boatstack-helper/product_delivery_flow_e2e_test.go @@ -57,7 +57,7 @@ func TestExactProductDeliveryFlowReachesPublishedPRWithFakeProvider(t *testing.T runFlowGit(t, repository, "init", "-q", "-b", "main") runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") runFlowGit(t, repository, "config", "user.name", "Fixture") - writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) + writeFixture(t, repository, ".boatstack/project.json", []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"todo","default_branch":"main","commands":{"build":"true","test":"true"}},"policy":{"plan_approval":"human-or-autonomy","visual_evidence":"optional","external_effect_authority":"human-or-autonomy-plus-provider","independent_review_for_high_risk":false},"hosts":["cli","codex","claude"],"projections":["codex","claude"]}`)) writeFixture(t, repository, ".boatstack/plans/inbox/todo.md", []byte("# Add one todo\n")) for path, content := range assets { writeFixture(t, repository, path, content) diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go index 234cd9e..a71c13e 100644 --- a/boatstack/controlprogram/artifact.go +++ b/boatstack/controlprogram/artifact.go @@ -17,7 +17,7 @@ import ( const ( ArtifactSchemaName = "control-program-artifact" - ArtifactSchemaRevision = 4 + ArtifactSchemaRevision = 5 ) type Artifact struct { diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go index a9a37da..ef7647e 100644 --- a/boatstack/controlprogram/canonical.go +++ b/boatstack/controlprogram/canonical.go @@ -12,6 +12,7 @@ import ( "strings" "unicode/utf8" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -84,6 +85,11 @@ func compile(document Document, resolver BindingResolver, assets AssetResolver) if !validID(document.Program.ID) || document.Program.Version == "" { return Compiled{}, invalid("program", "id and version are required") } + if document.Program.HumanIdentity != "" { + if err := humanidentity.ValidateRole(document.Program.HumanIdentity); err != nil { + return Compiled{}, invalid("program.human_identity", err.Error()) + } + } var err error if document.Declarations.Capabilities, err = normalizedReferenceSet("declarations.capabilities", document.Declarations.Capabilities); err != nil { return Compiled{}, err diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go index a2a4c08..3396cb5 100644 --- a/boatstack/controlprogram/canonical_test.go +++ b/boatstack/controlprogram/canonical_test.go @@ -127,6 +127,33 @@ func parameterProgram() controlprogram.Document { return document } +func TestHumanIdentityRoleIsOptionalGenericProgramSemantics(t *testing.T) { + withoutRole, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + withRoleDocument := incidentProgram() + withRoleDocument.Program.HumanIdentity = "incident-commander" + withRole, err := controlprogram.Compile(withRoleDocument, nil) + if err != nil { + t.Fatal(err) + } + changedRoleDocument := withRoleDocument + changedRoleDocument.Program.HumanIdentity = "release-manager" + changedRole, err := controlprogram.Compile(changedRoleDocument, nil) + if err != nil { + t.Fatal(err) + } + if withoutRole.Fingerprint == withRole.Fingerprint || withRole.Fingerprint == changedRole.Fingerprint { + t.Fatal("human identity role did not participate in ProgramFingerprint") + } + invalid := incidentProgram() + invalid.Program.HumanIdentity = "Release Manager" + if _, err := controlprogram.Compile(invalid, nil); err == nil || !strings.Contains(err.Error(), "program.human_identity") { + t.Fatalf("invalid role result = %v", err) + } +} + func TestInvocationCompletenessRequiresExactlyOneAdmissibleProducer(t *testing.T) { // control-law: required-transition-parameters-have-exactly-one-admissible-producer-before-publication if _, err := controlprogram.Compile(parameterProgram(), nil); err != nil { diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go index 99bf5f5..371ac5b 100644 --- a/boatstack/controlprogram/frontend_conformance_test.go +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -413,6 +413,7 @@ import { softwareDelivery } from "@operatorstack/boatstack-software-delivery"; export default defineFlow(softwareDelivery({ id: "unknown-resolver", version: "1", + humanIdentity: "developer", lifecycle: [{ id: "plan.abandon", priority: 31 }], targets: [{ id: "done", predicate: { true: true } }], entries: [{ id: "run", target: "done", inputs: [{ id: "value", type: "text", required: true, resolver: "unknown.resolver" }] }], @@ -468,6 +469,7 @@ const implementation = foregroundWork({ export default defineFlow(softwareDelivery({ id: "additional-work", version: "1", + humanIdentity: "developer", lifecycle: [{ id: "plan.activate", priority: 50, work: "implementation" }], work: [implementation], targets: [{ id: "done", predicate: { true: true } }], diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go index 73d4c80..9d0b2ae 100644 --- a/boatstack/controlprogram/ir.go +++ b/boatstack/controlprogram/ir.go @@ -7,7 +7,7 @@ import "encoding/json" const ( SchemaName = "control-program" - SchemaRevision = 4 + SchemaRevision = 5 ) type Document struct { @@ -26,9 +26,10 @@ type Document struct { } type Program struct { - ID string `json:"id"` - Version string `json:"version"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Version string `json:"version"` + HumanIdentity string `json:"human_identity,omitempty"` + Description string `json:"description,omitempty"` } type Declarations struct { diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index 611d4ca..e17f15a 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -15,6 +15,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/engine" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/foregroundwork" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" @@ -80,7 +81,11 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro if err != nil { return DeliveryController{}, err } - baseDriver, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership()) + programHumanIdentityRole, err := compiledProgramHumanIdentityRole(program) + if err != nil { + return DeliveryController{}, err + } + baseDriver, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership(), programHumanIdentityRole) if err != nil { return DeliveryController{}, err } @@ -94,6 +99,25 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro return DeliveryController{program: program, registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock, work: workManager}, nil } +func compiledProgramHumanIdentityRole(program delivery.ControlProgram) (string, error) { + settings := program.ProgramRuntime().Manifest.Settings + if len(settings) == 0 { + return "", nil + } + var value struct { + Role string `json:"human_identity_role"` + } + if err := json.Unmarshal(settings, &value); err != nil { + return "", fmt.Errorf("decode compiled program human identity role: %w", err) + } + if value.Role != "" { + if err := humanidentity.ValidateRole(value.Role); err != nil { + return "", fmt.Errorf("compiled program human identity role: %w", err) + } + } + return value.Role, nil +} + func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID, Invocation: request.InvocationEvidence} if err := request.Validate(k.clock.Now()); err != nil { diff --git a/boatstack/distribution/standard_test.go b/boatstack/distribution/standard_test.go index 5b1ea19..639adea 100644 --- a/boatstack/distribution/standard_test.go +++ b/boatstack/distribution/standard_test.go @@ -61,7 +61,7 @@ func TestRepositoryScopedProgramsAreIndependentUnderConcurrency(t *testing.T) { x, y := extensions("fixture.echo"), extensions("fixture.second") candidateConfig := protocol.ProjectConfig{ SchemaVersion: protocol.ConfigSchemaVersion, - Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, + Identity: protocol.IdentitySettings{Default: "developer", Roles: map[string]humanidentity.Descriptor{"developer": {Kind: humanidentity.KindLiteral, Value: "operator"}}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli"}, Projections: []string{}, Extensions: []protocol.SubprocessExtensionSettings{x}, @@ -191,7 +191,7 @@ func repositoryFixture(t *testing.T, extensions []protocol.SubprocessExtensionSe if extensions != nil { configuration := protocol.ProjectConfig{ SchemaVersion: protocol.ConfigSchemaVersion, - Identity: protocol.IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}}, + Identity: protocol.IdentitySettings{Default: "developer", Roles: map[string]humanidentity.Descriptor{"developer": {Kind: humanidentity.KindLiteral, Value: "operator"}}}, Project: protocol.ProjectSettings{Name: "fixture", DefaultBranch: "main", Commands: map[string]string{}}, Policy: protocol.PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli"}, Projections: []string{}, Extensions: extensions, diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index 988e6f5..648519e 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -152,7 +152,10 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim } sort.Slice(supported, func(i, j int) bool { return supported[i] < supported[j] }) sort.Slice(objectiveContracts, func(i, j int) bool { return objectiveContracts[i].TargetID < objectiveContracts[j].TargetID }) - settings, _ := json.Marshal(map[string]string{"flow_id": d.compiled.Document.Program.ID, "flow_fingerprint": d.compiled.Fingerprint}) + settings, _ := json.Marshal(map[string]string{ + "flow_id": d.compiled.Document.Program.ID, "flow_fingerprint": d.compiled.Fingerprint, + "human_identity_role": d.compiled.Document.Program.HumanIdentity, + }) base.Version = standard.Version + "+flow." + d.compiled.Fingerprint[:12] base.SupportedTargets = supported base.ObjectiveContracts = objectiveContracts diff --git a/boatstack/flow/softwaredelivery/projections.go b/boatstack/flow/softwaredelivery/projections.go index f875e2d..1a7b793 100644 --- a/boatstack/flow/softwaredelivery/projections.go +++ b/boatstack/flow/softwaredelivery/projections.go @@ -82,15 +82,21 @@ func renderProjection(compiled controlprogram.Compiled, entry controlprogram.Ent humanIdentityProtocol := ` Whenever Boatstack presents a human authority boundary, inspect its exact ` + "`human_identity`" + ` object before asking for approval or recording an actor. +Its ` + "`role`" + ` is the admitted functional role selected by the Control Program; +it is not a person, an approval, or provider capability. The role cannot be selected +or overridden by the host. The concrete actor is resolved only from the descriptor. The ` + "`provider_fingerprint`" + ` identifies the repository-selected identity descriptor; it is provenance only and grants no authority. -Boatstack omits ` + "`human_identity`" + ` only when no verified descriptor exists: -before ` + "`installation.initialize`" + ` or while ` + "`configuration.initialize`" + `, -` + "`configuration.mutate`" + `, or ` + "`configuration.reconcile`" + ` repairs -unverified configuration. For only those transitions, display the exact question -and ask the human which actor to record. Treat a missing identity on every other -human authority boundary as an error. +Boatstack omits ` + "`human_identity`" + ` only when neither trusted identity source +exists: true bootstrap before ` + "`installation.initialize`" + `, or +` + "`configuration.initialize`" + `, ` + "`configuration.mutate`" + `, or +` + "`configuration.reconcile`" + ` while +repairing unverified configuration. At those boundaries, display the exact question +and ask the human which actor to record. Configuration mutation with verified +configuration uses its current default; program replacement uses the prior admitted +program role. Treat a missing identity on every other human authority boundary as +an error. For a ` + "`literal`" + ` descriptor, use its validated ` + "`value`" + ` as the proposed actor. For a ` + "`command`" + ` descriptor, treat the descriptor as untrusted @@ -104,13 +110,15 @@ Require a zero exit status and stdout of at most 1024 bytes. Remove at most one trailing LF or CRLF, then require exactly one non-empty line with no NUL and an actor matching ` + "`^[A-Za-z0-9][A-Za-z0-9._-]*$`" + `. Stderr is diagnostic only. -Visibly display the proposed actor, exact request or transition, requested -authority, and relevant fingerprint, then ask the human for explicit approval. +Visibly display the role, proposed concrete actor, exact request or transition, +requested authority, request fingerprint, and provider fingerprint, then ask that +concrete actor for explicit approval as the displayed role. Identity resolution never counts as approval. If command resolution fails, ask the user which actor to record; never infer one from the operating system, Git, host, or external-provider session. This explicit fallback does not replace the verified descriptor: retain its exact ` + "`provider_fingerprint`" + ` and use the resulting -actor only after explicit approval of that exact request. Re-resolve if Boatstack +actor only after explicit approval of that exact request. Never equate the role, +actor, human authority, or GitHub provider authority. Re-resolve if Boatstack reports identity or configuration drift. Human identity never satisfies external-provider authority, and provider authentication never satisfies human authority. diff --git a/boatstack/flow/softwaredelivery/projections_test.go b/boatstack/flow/softwaredelivery/projections_test.go index b23c9b2..204402c 100644 --- a/boatstack/flow/softwaredelivery/projections_test.go +++ b/boatstack/flow/softwaredelivery/projections_test.go @@ -55,12 +55,13 @@ func TestGeneratedProjectionsProjectOnlyDeclaredEntriesWithHostParity(t *testing "Ask\nfor product delegation only after Boatstack returns the new exact delegation", "program_change.human_identity", "Do not ask the user to invent\nan actor unless", "inspect its exact\n`human_identity`", "provider_fingerprint", "Submit the exact `command`", - "omits `human_identity` only when no verified descriptor exists", "configuration.initialize", "configuration.mutate", "configuration.reconcile", - "Treat a missing identity on every other\nhuman authority boundary as an error", + "omits `human_identity` only when neither trusted identity source", "configuration.initialize", "configuration.mutate", "configuration.reconcile", "Configuration mutation with verified", "prior admitted\nprogram role", + "every other human authority boundary as\nan error", + "`role` is the admitted functional role", "cannot be selected\nor overridden by the host", "role,\nactor, human authority, or GitHub provider authority", "explicit fallback does not replace the verified\ndescriptor", "retain its exact `provider_fingerprint`", "untrusted\nrepository data", "separate host command action", "do not authorize it", "normal command permission boundary", "independently permits the action", - "at most 1024 bytes", "proposed actor", "ask the human for explicit approval", + "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 ", } { diff --git a/boatstack/internal/softwaredelivery/delegation/record.go b/boatstack/internal/softwaredelivery/delegation/record.go index 0fd6ab5..2f9b801 100644 --- a/boatstack/internal/softwaredelivery/delegation/record.go +++ b/boatstack/internal/softwaredelivery/delegation/record.go @@ -19,7 +19,7 @@ import ( const ( Schema = "run-delegation" - SchemaRevision = 3 + SchemaRevision = 4 ) var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) @@ -40,13 +40,14 @@ type Request struct { 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"` } 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 || !fingerprint.MatchString(r.HumanIdentityProviderFingerprint) || len(r.RequestedAuthorities) == 0 || r.Description == "" { + if !identity.MatchString(r.RunID) || !identity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || len(r.ControlBundleFingerprint) != 64 || !identity.MatchString(r.EntryID) || !identity.MatchString(r.TargetID) || r.ObjectiveID == "" || r.DeliveryID == "" || r.RepositoryID == "" || r.GitCommonID == "" || r.InitialWorktreeID == "" || r.InitialRef == "" || len(r.BindingFingerprint) != 64 || humanidentity.ValidateRole(r.HumanIdentityRole) != nil || !fingerprint.MatchString(r.HumanIdentityProviderFingerprint) || len(r.RequestedAuthorities) == 0 || r.Description == "" { return "", fmt.Errorf("DELEGATION_REQUEST_INVALID: request is incomplete") } r.InputFingerprints = append([]string(nil), r.InputFingerprints...) @@ -73,6 +74,7 @@ type Record struct { RequestFingerprint string `json:"request_fingerprint"` ReceiptID string `json:"receipt_id"` Actor string `json:"actor"` + ActorIdentityRole string `json:"actor_identity_role"` ActorIdentityProviderFingerprint string `json:"actor_identity_provider_fingerprint"` AuthorizedAt time.Time `json:"authorized_at"` ExpiresAt time.Time `json:"expires_at,omitempty"` @@ -124,7 +126,7 @@ func Load(path string) (Record, error) { if record.Schema != Schema || record.SchemaRevision != SchemaRevision || record.Revision == 0 || record.Status == "" || record.Actor == "" || record.ReceiptID == "" || !fingerprint.MatchString(record.ActorIdentityProviderFingerprint) { return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: record is incomplete") } - if err := humanidentity.ValidateActor(record.Actor); err != nil || record.ActorIdentityProviderFingerprint != record.Request.HumanIdentityProviderFingerprint { + if err := humanidentity.ValidateActor(record.Actor); err != nil || record.ActorIdentityRole != record.Request.HumanIdentityRole || record.ActorIdentityProviderFingerprint != record.Request.HumanIdentityProviderFingerprint { return Record{}, fmt.Errorf("DELEGATION_RECORD_INVALID: actor identity provenance is invalid") } fingerprint, err := record.Request.Fingerprint() diff --git a/boatstack/internal/softwaredelivery/delegation/record_test.go b/boatstack/internal/softwaredelivery/delegation/record_test.go index 210b391..b042be1 100644 --- a/boatstack/internal/softwaredelivery/delegation/record_test.go +++ b/boatstack/internal/softwaredelivery/delegation/record_test.go @@ -16,7 +16,7 @@ func request() 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"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("b", 64), HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", + BindingFingerprint: strings.Repeat("b", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"human", "autonomy"}, Description: "Run the program", } } @@ -69,7 +69,7 @@ func TestRecordRejectsPriorSchemaAndIdentityProvenanceMismatch(t *testing.T) { record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: value, RequestFingerprint: requestFingerprint, ReceiptID: "authorization-example", - Actor: "operator", ActorIdentityProviderFingerprint: value.HumanIdentityProviderFingerprint, + Actor: "operator", ActorIdentityRole: value.HumanIdentityRole, ActorIdentityProviderFingerprint: value.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "active", } write := func(value delegation.Record) string { diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index bd4b660..ba2e88e 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -9,11 +9,12 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) const ( - StateSchemaVersion = 6 + StateSchemaVersion = 7 priorStateSchemaVersion = 4 ) @@ -35,6 +36,7 @@ type State struct { GitCommonID string `json:"git_common_id"` WorktreeID string `json:"worktree_id"` ProgramFingerprint string `json:"program_fingerprint,omitempty"` + ProgramHumanIdentityRole string `json:"program_human_identity_role,omitempty"` ControlBundleFingerprint string `json:"control_bundle_fingerprint,omitempty"` Revision uint64 `json:"revision"` Phase model.ProtocolPhase `json:"phase"` @@ -114,6 +116,14 @@ func (s State) Validate() error { if s.ProgramFingerprint != "" && len(s.ProgramFingerprint) != 64 { return fmt.Errorf("durable state has invalid program fingerprint") } + if s.ProgramHumanIdentityRole != "" { + if s.ProgramFingerprint == "" { + return fmt.Errorf("durable state human identity role requires a program fingerprint") + } + if err := humanidentity.ValidateRole(s.ProgramHumanIdentityRole); err != nil { + return fmt.Errorf("durable state has invalid program human identity role: %w", err) + } + } if s.ControlBundleFingerprint != "" && len(s.ControlBundleFingerprint) != 64 { return fmt.Errorf("durable state has invalid control-bundle fingerprint") } @@ -242,11 +252,7 @@ func DecodeState(value []byte) (State, error) { return State{}, fmt.Errorf("durable state contains trailing JSON") } if state.SchemaVersion == priorStateSchemaVersion { - // The released predecessor is schema 4. Schemas 5 and 6 add planning - // package and control-bundle identity; neither may be smuggled into - // predecessor bytes. The original bytes remain the journal rollback - // source until a transition commits. - if state.PlanningPackageFingerprint != "" || state.ControlBundleFingerprint != "" { + if state.PlanningPackageFingerprint != "" || state.ControlBundleFingerprint != "" || state.ProgramHumanIdentityRole != "" { return State{}, fmt.Errorf("durable state schema %d contains later identity fields", priorStateSchemaVersion) } state.SchemaVersion = StateSchemaVersion diff --git a/boatstack/internal/softwaredelivery/durable/state_facet.go b/boatstack/internal/softwaredelivery/durable/state_facet.go index 246365d..105b293 100644 --- a/boatstack/internal/softwaredelivery/durable/state_facet.go +++ b/boatstack/internal/softwaredelivery/durable/state_facet.go @@ -10,6 +10,7 @@ import ( var stateFieldFacets = map[string]model.StateFacet{ "SchemaVersion": model.StateFacetControl, "RepositoryID": model.StateFacetControl, "GitCommonID": model.StateFacetControl, "WorktreeID": model.StateFacetControl, "ProgramFingerprint": model.StateFacetProgram, + "ProgramHumanIdentityRole": model.StateFacetProgram, "ControlBundleFingerprint": model.StateFacetControl, "Revision": model.StateFacetControl, "Phase": model.StateFacetControl, "Engagement": model.StateFacetProduct, "Delivery": model.StateFacetProduct, "Workspace": model.StateFacetProduct, "Plan": model.StateFacetProduct, diff --git a/boatstack/internal/softwaredelivery/durable/state_schema_test.go b/boatstack/internal/softwaredelivery/durable/state_schema_test.go index 96b7def..9516f26 100644 --- a/boatstack/internal/softwaredelivery/durable/state_schema_test.go +++ b/boatstack/internal/softwaredelivery/durable/state_schema_test.go @@ -38,7 +38,6 @@ func TestStateSchemaPermitsLegacyApprovedStateWithoutApprovalFingerprint(t *test } func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testing.T) { - // control-law: forward state migration is read-only until a transaction commits state := State{ SchemaVersion: StateSchemaVersion, RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree", Revision: 7, Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryApproved, Workspace: model.WorkspaceAbsent, @@ -62,16 +61,15 @@ func TestDecodeStatePromotesReleasedSchemaFourWithoutChangingPriorBytes(t *testi } prior = append(prior, '\n') rollback := append([]byte(nil), prior...) - decoded, err := DecodeState(prior) if err != nil { t.Fatal(err) } - if decoded.SchemaVersion != StateSchemaVersion || decoded.Revision != state.Revision || decoded.Objective != state.Objective || decoded.PlanFingerprint != state.PlanFingerprint { + if decoded.SchemaVersion != StateSchemaVersion || decoded.Revision != state.Revision || decoded.Objective != state.Objective { t.Fatalf("promoted state = %#v", decoded) } if !bytes.Equal(prior, rollback) { - t.Fatal("schema promotion changed the prior rollback bytes") + t.Fatal("schema promotion changed prior rollback bytes") } } diff --git a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go index 4ce6af3..e6d6590 100644 --- a/boatstack/internal/softwaredelivery/effects/cas_integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/cas_integration_test.go @@ -50,7 +50,7 @@ func TestConcurrentApplyConsumesOneRevisionExactlyOnce(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestProgramChangeInvalidatesPriorPrescriptionBeforeEffects(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"program-cas\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go index 6890ad6..e45f5e6 100644 --- a/boatstack/internal/softwaredelivery/effects/command_boundary_test.go +++ b/boatstack/internal/softwaredelivery/effects/command_boundary_test.go @@ -51,7 +51,7 @@ func writeBoundaryConfig(t *testing.T, command string) ports.ControllerLayout { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } - raw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + raw := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go index 76af698..201246b 100644 --- a/boatstack/internal/softwaredelivery/effects/delegation_record_test.go +++ b/boatstack/internal/softwaredelivery/effects/delegation_record_test.go @@ -15,7 +15,7 @@ func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { 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"}, RepositoryID: "repository", GitCommonID: "common", InitialWorktreeID: "worktree", InitialRef: "refs/heads/main", - BindingFingerprint: strings.Repeat("c", 64), HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", + BindingFingerprint: strings.Repeat("c", 64), HumanIdentityRole: "developer", HumanIdentityProviderFingerprint: strings.Repeat("d", 64), RequestedAuthorities: []string{"autonomy"}, Description: "Run the program", } fingerprint, err := request.Fingerprint() if err != nil { @@ -23,7 +23,7 @@ func TestDelegationSupersessionArchiveIsImmutableAndIdempotent(t *testing.T) { } record := delegation.Record{ Schema: delegation.Schema, SchemaRevision: delegation.SchemaRevision, Request: request, RequestFingerprint: fingerprint, - ReceiptID: "authorization-one", Actor: "operator", ActorIdentityProviderFingerprint: request.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "revoked", + ReceiptID: "authorization-one", Actor: "operator", ActorIdentityRole: request.HumanIdentityRole, ActorIdentityProviderFingerprint: request.HumanIdentityProviderFingerprint, AuthorizedAt: time.Unix(1_700_000_000, 0).UTC(), Revision: 1, Status: "revoked", } path := filepath.Join(t.TempDir(), "prior.json") if err := ArchiveDelegationRecord(path, record); err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index 353e2f5..c86e557 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -23,10 +23,11 @@ type CommandBoundary interface { } type Driver struct { - resolver ports.InvocationResolver - clock ports.Clock - boundary CommandBoundary - resourceOwnership map[string]string + resolver ports.InvocationResolver + clock ports.Clock + boundary CommandBoundary + resourceOwnership map[string]string + programHumanIdentityRole string } func NewDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary) (Driver, error) { @@ -36,7 +37,7 @@ func NewDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary Co return Driver{resolver: resolver, clock: clock, boundary: boundary}, nil } -func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary, ownership map[string]string) (Driver, error) { +func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary, ownership map[string]string, programHumanIdentityRole string) (Driver, error) { driver, err := NewDriver(resolver, clock, boundary) if err != nil { return Driver{}, err @@ -48,6 +49,7 @@ func NewProgramDriver(resolver ports.InvocationResolver, clock ports.Clock, boun for resource, owner := range ownership { driver.resourceOwnership[resource] = owner } + driver.programHumanIdentityRole = programHumanIdentityRole return driver, nil } @@ -115,6 +117,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if state.Revision != admission.ExpectedStateRevision { return nil, fmt.Errorf("durable state revision changed after admission") } + if err := verifyCandidateConfigurationPreservesAdmittedRole(state, admission, transition); err != nil { + return nil, err + } resultingRevision, err := durable.NextRevision(state.Revision) if err != nil { return nil, err @@ -122,6 +127,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if state.ProgramFingerprint != "" && state.ProgramFingerprint != admission.ExpectedProgramFingerprint && !transition.Policy.ReconcilesProgram { return nil, fmt.Errorf("compiled control program drifted; explicit program reconciliation is required") } + if state.ProgramFingerprint == admission.ExpectedProgramFingerprint && state.ProgramHumanIdentityRole != d.programHumanIdentityRole { + return nil, fmt.Errorf("HUMAN_IDENTITY_DRIFT: admitted program role %q does not match compiled role %q", state.ProgramHumanIdentityRole, d.programHumanIdentityRole) + } if transition.ID == "catalog.reconcile" && (state.RuntimeVersion != admission.Invocation.RuntimeVersion || state.RuntimeFingerprint != admission.Invocation.RuntimeFingerprint) { return nil, fmt.Errorf("catalog reconciliation cannot activate a different runtime; use installation.reconcile-update") } @@ -148,6 +156,7 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans } if next.ProgramFingerprint == "" { next.ProgramFingerprint = admission.ExpectedProgramFingerprint + next.ProgramHumanIdentityRole = d.programHumanIdentityRole } if err := d.boundary.PrepareObservation(ctx, admission, transition, layout, &next); err != nil { return nil, err @@ -155,6 +164,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err := applyStateTransition(&next, admission, transition); err != nil { return nil, err } + if transition.Policy.ReconcilesProgram && next.ProgramFingerprint == admission.ExpectedProgramFingerprint { + next.ProgramHumanIdentityRole = d.programHumanIdentityRole + } next.Revision = resultingRevision next.UpdatedAt = d.clock.Now().UTC() var verificationInvocation *model.InvocationContext @@ -349,6 +361,28 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return prepared, nil } +func verifyCandidateConfigurationPreservesAdmittedRole(state durable.State, admission protocol.Admission, transition catalog.Transition) error { + if transition.ID != "configuration.mutate" || state.ProgramHumanIdentityRole == "" { + return nil + } + path, ok := admission.Parameters.Get("config_path") + if !ok { + return fmt.Errorf("PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND: configuration mutation is missing config_path") + } + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read candidate configuration for admitted human identity: %w", err) + } + config, _, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { + return err + } + if _, ok := config.Identity.Roles[state.ProgramHumanIdentityRole]; !ok { + return fmt.Errorf("PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND: candidate configuration removes admitted program role %q", state.ProgramHumanIdentityRole) + } + return nil +} + func prepareConfigurationAuthorityTransfer(layout ports.ControllerLayout, admission protocol.Admission, state durable.State, attaching bool) ([]ports.ResourceMutation, error) { target := "" if attaching { diff --git a/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go b/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go new file mode 100644 index 0000000..a576554 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go @@ -0,0 +1,41 @@ +package effects + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestConfigurationMutationPreservesAdmittedProgramRole(t *testing.T) { + write := func(name, identity string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name+".json") + raw := []byte(`{"schema_version":5,"identity":` + identity + `,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path + } + state := durable.State{ProgramHumanIdentityRole: "release-manager"} + transition := catalog.Transition{ID: "configuration.mutate"} + for _, identity := range []string{ + `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"},"release-manager":{"kind":"literal","value":"release-operator"}}}`, + `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"},"release-manager":{"kind":"literal","value":"rotated-release-operator"}}}`, + } { + path := write("allowed", identity) + admission := protocol.Admission{Parameters: protocol.Parameters{{Name: "config_path", Value: path}}} + if err := verifyCandidateConfigurationPreservesAdmittedRole(state, admission, transition); err != nil { + t.Fatalf("descriptor-preserving candidate rejected: %v", err) + } + } + removed := write("removed", `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}}`) + err := verifyCandidateConfigurationPreservesAdmittedRole(state, protocol.Admission{Parameters: protocol.Parameters{{Name: "config_path", Value: removed}}}, transition) + if err == nil || !strings.Contains(err.Error(), "PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND") { + t.Fatalf("removed role result = %v", err) + } +} diff --git a/boatstack/internal/softwaredelivery/effects/integration_test.go b/boatstack/internal/softwaredelivery/effects/integration_test.go index 928702f..def01bc 100644 --- a/boatstack/internal/softwaredelivery/effects/integration_test.go +++ b/boatstack/internal/softwaredelivery/effects/integration_test.go @@ -237,7 +237,7 @@ func TestStaleControlBundleStopsBeforeManagedStateOrRuntimePin(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"bundle\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -302,7 +302,7 @@ func TestControllerRejectsExactBundleRevisionDriftWithMatchingWorkingBytes(t *te runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"revision\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -585,7 +585,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) - initialConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + initialConfig := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") initialPath := filepath.Join(t.TempDir(), "initial.json") if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) @@ -616,7 +616,7 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing t.Fatalf("detached layout did not select external config: %#v", detachedLayout) } apply("engagement.begin", protocol.AuthorityBundle{}, true, nil) - updatedConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"updated-operator\"}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + updatedConfig := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"updated-operator\"}}},\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") updatedPath := filepath.Join(t.TempDir(), "updated.json") if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil { t.Fatal(err) @@ -626,14 +626,14 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing if err != nil { t.Fatal(err) } - updatedProvider, err := updated.Identity.Human.Fingerprint() + updatedProvider, err := updated.Identity.Roles[updated.Identity.Default].Fingerprint() if err != nil { t.Fatal(err) } - if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updatedProvider, configResult.Receipt.ControlBundleTargetFingerprint); err != nil || !admitted { + if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updated.Identity.Default, updatedProvider, configResult.Receipt.ControlBundleTargetFingerprint); err != nil || !admitted { t.Fatalf("accepted external identity reprojection admitted=%t err=%v", admitted, err) } - if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, strings.Repeat("f", 64), configResult.Receipt.ControlBundleTargetFingerprint); err != nil || admitted { + if admitted, err := effects.ConfigurationReprojectionAdmits(detachedLayout, "flow-external-config", detachedInvocation, updated.Identity.Default, strings.Repeat("f", 64), configResult.Receipt.ControlBundleTargetFingerprint); err != nil || admitted { t.Fatalf("foreign identity provider admitted=%t err=%v", admitted, err) } repositoryConfigPath := filepath.Join(repository, ".boatstack", "project.json") @@ -685,7 +685,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -814,7 +814,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err := json.Unmarshal(afterSuccess, &legacyState); err != nil { t.Fatal(err) } - legacyState["schema_version"] = float64(durable.StateSchemaVersion - 2) + legacyState["schema_version"] = float64(4) delete(legacyState, "planning_package_fingerprint") delete(legacyState, "control_bundle_fingerprint") legacyRaw, err := json.MarshalIndent(legacyState, "", " ") @@ -834,7 +834,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if err != nil { t.Fatal(err) } - legacyPin.StateSchemaVersion = durable.StateSchemaVersion - 2 + legacyPin.StateSchemaVersion = 4 legacyPinRaw, err = boatstackruntime.EncodePin(legacyPin) if err != nil { t.Fatal(err) @@ -940,7 +940,7 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1072,7 +1072,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } @@ -1085,7 +1085,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndObjectiveTerminals(t *tes apply(approvedObjective, "engagement.begin", authority(catalog.AuthorityRepository), nil) updatedConfigPath := filepath.Join(t.TempDir(), "project-v2-updated.json") - updatedConfig := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + updatedConfig := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(updatedConfigPath, updatedConfig, 0o600); err != nil { t.Fatal(err) } @@ -1206,7 +1206,7 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) runtimeRaw, _ := os.ReadFile(executable) runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configSource := filepath.Join(t.TempDir(), "project-v2.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configSource, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index 6a40667..06895ca 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -245,7 +245,7 @@ func installationReprojectionAdmits(records []journalRecord, flowID string, invo // committed configuration mutation in this Flow lineage and remain the // verified durable state. It permits a fresh delegation request only; prior // authority is never carried forward. -func ConfigurationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, providerFingerprint, controlBundleFingerprint string) (bool, error) { +func ConfigurationReprojectionAdmits(layout ports.ControllerLayout, flowID string, invocation model.InvocationContext, identityRole, providerFingerprint, controlBundleFingerprint string) (bool, error) { configRaw, err := os.ReadFile(layout.ConfigPath) if err != nil { return false, err @@ -254,7 +254,11 @@ func ConfigurationReprojectionAdmits(layout ports.ControllerLayout, flowID strin if err != nil { return false, err } - actualProvider, err := config.Identity.Human.Fingerprint() + descriptor, ok := config.Identity.Roles[identityRole] + if !ok { + return false, nil + } + actualProvider, err := descriptor.Fingerprint() if err != nil || actualProvider != providerFingerprint { return false, nil } diff --git a/boatstack/internal/softwaredelivery/effects/recovery_test.go b/boatstack/internal/softwaredelivery/effects/recovery_test.go index 545bb4d..66cf5d9 100644 --- a/boatstack/internal/softwaredelivery/effects/recovery_test.go +++ b/boatstack/internal/softwaredelivery/effects/recovery_test.go @@ -108,7 +108,7 @@ func TestRestartRecoveryRestoresPriorStateAndCommitsRecoveryRevision(t *testing. t.Fatal(err) } configPath := filepath.Join(t.TempDir(), "project.json") - configRaw := []byte("{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"operator\"}},\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") + configRaw := []byte("{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"operator\"}}},\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"],\"projections\":[]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } diff --git a/boatstack/internal/softwaredelivery/humanidentity/identity.go b/boatstack/internal/softwaredelivery/humanidentity/identity.go index 9e761b9..b1d4df8 100644 --- a/boatstack/internal/softwaredelivery/humanidentity/identity.go +++ b/boatstack/internal/softwaredelivery/humanidentity/identity.go @@ -15,6 +15,7 @@ import ( const ( MaxActorBytes = 1024 + MaxRoleBytes = 128 MaxCommandBytes = 256 MaxArgumentCount = 32 MaxArgumentBytes = 1024 @@ -28,6 +29,7 @@ const ( ) var actorPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +var rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]*$`) // Descriptor is the closed, domain-neutral human identity provider contract. // Args must be present for command descriptors, including when it is empty. @@ -105,10 +107,18 @@ func (d Descriptor) MarshalJSON() ([]byte, error) { // Presentation is safe host-facing provenance. It proposes an actor-selection // mechanism but grants no authority and proves no provider capability. type Presentation struct { + Role string `json:"role"` ProviderFingerprint string `json:"provider_fingerprint"` Descriptor Descriptor `json:"descriptor"` } +func ValidateRole(role string) error { + if len(role) == 0 || len(role) > MaxRoleBytes || !rolePattern.MatchString(role) { + return fmt.Errorf("HUMAN_IDENTITY_ROLE_INVALID: role must be 1-%d bytes and match %s", MaxRoleBytes, rolePattern) + } + return nil +} + func ValidateActor(actor string) error { if len(actor) == 0 || len(actor) > MaxActorBytes || !actorPattern.MatchString(actor) { return fmt.Errorf("HUMAN_ACTOR_INVALID: actor must be 1-%d bytes and match %s", MaxActorBytes, actorPattern) @@ -179,15 +189,21 @@ func (d Descriptor) Fingerprint() (string, error) { return hex.EncodeToString(digest[:]), nil } -func NewPresentation(descriptor Descriptor) (Presentation, error) { +func NewPresentation(role string, descriptor Descriptor) (Presentation, error) { + if err := ValidateRole(role); err != nil { + return Presentation{}, err + } fingerprint, err := descriptor.Fingerprint() if err != nil { return Presentation{}, err } - return Presentation{ProviderFingerprint: fingerprint, Descriptor: descriptor}, nil + return Presentation{Role: role, ProviderFingerprint: fingerprint, Descriptor: descriptor}, nil } func (p Presentation) Validate() error { + if err := ValidateRole(p.Role); err != nil { + return fmt.Errorf("HUMAN_IDENTITY_PRESENTATION_INVALID: %w", err) + } fingerprint, err := p.Descriptor.Fingerprint() if err != nil || p.ProviderFingerprint != fingerprint { return fmt.Errorf("HUMAN_IDENTITY_PRESENTATION_INVALID: provider fingerprint does not match descriptor") @@ -195,6 +211,24 @@ func (p Presentation) Validate() error { return nil } +// BindingFingerprint binds a semantic role to its descriptor-only provider +// fingerprint without redefining either identity. +func (p Presentation) BindingFingerprint() (string, error) { + if err := p.Validate(); err != nil { + return "", err + } + raw, err := json.Marshal(struct { + SchemaVersion int `json:"schema_version"` + Role string `json:"role"` + ProviderFingerprint string `json:"provider_fingerprint"` + }{SchemaVersion: 1, Role: p.Role, ProviderFingerprint: p.ProviderFingerprint}) + if err != nil { + return "", err + } + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]), nil +} + // InterpretCommandOutput applies the host contract to already captured output. // It does not start or resolve an executable. func InterpretCommandOutput(exitStatus int, stdout []byte) (string, error) { diff --git a/boatstack/internal/softwaredelivery/humanidentity/identity_test.go b/boatstack/internal/softwaredelivery/humanidentity/identity_test.go index 65b75f7..8e51fd0 100644 --- a/boatstack/internal/softwaredelivery/humanidentity/identity_test.go +++ b/boatstack/internal/softwaredelivery/humanidentity/identity_test.go @@ -20,8 +20,8 @@ func TestLiteralAndCommandDescriptorsHaveDeterministicDistinctFingerprints(t *te if err != nil || first != second || len(first) != 64 { t.Fatalf("nondeterministic fingerprint first=%q second=%q err=%v", first, second, err) } - presentation, err := humanidentity.NewPresentation(descriptor) - if err != nil || presentation.ProviderFingerprint != first || presentation.Validate() != nil { + presentation, err := humanidentity.NewPresentation("developer", descriptor) + if err != nil || presentation.Role != "developer" || presentation.ProviderFingerprint != first || presentation.Validate() != nil { t.Fatalf("presentation = %#v, err=%v", presentation, err) } } @@ -33,6 +33,30 @@ func TestLiteralAndCommandDescriptorsHaveDeterministicDistinctFingerprints(t *te } } +func TestRoleValidationAndBindingFingerprint(t *testing.T) { + for _, role := range []string{"developer", "release-manager", "team.one_operator"} { + if err := humanidentity.ValidateRole(role); err != nil { + t.Fatalf("valid role %q: %v", role, err) + } + } + for _, role := range []string{"", "Developer", "1developer", "developer role", strings.Repeat("x", humanidentity.MaxRoleBytes+1)} { + if err := humanidentity.ValidateRole(role); err == nil { + t.Fatalf("invalid role %q was accepted", role) + } + } + descriptor := humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"} + developer, _ := humanidentity.NewPresentation("developer", descriptor) + release, _ := humanidentity.NewPresentation("release-manager", descriptor) + if developer.ProviderFingerprint != release.ProviderFingerprint { + t.Fatal("role changed descriptor-only provider fingerprint") + } + developerBinding, _ := developer.BindingFingerprint() + releaseBinding, _ := release.BindingFingerprint() + if developerBinding == releaseBinding { + t.Fatal("different roles shared a binding fingerprint") + } +} + func TestCommandDescriptorJSONPreservesExplicitEmptyArgv(t *testing.T) { raw, err := json.Marshal(humanidentity.Descriptor{Kind: humanidentity.KindCommand, Command: "company-identity", Args: []string{}}) if err != nil { diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go index 20a212d..2d689d5 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -41,6 +41,9 @@ func Attach(ctx context.Context, externalStateRoot string, request surfaces.Requ return nil } programChangeRequiresHuman := response.ProgramChange != nil + if programChangeRequiresHuman && programChangeUsesExplicitActor(request, response) { + programChangeRequiresHuman = false + } questionRequiresIdentity := response.Question != nil && questionRequiresHuman(*response.Question) if questionRequiresIdentity && questionUsesExplicitActor(response) { questionRequiresIdentity = false @@ -48,7 +51,13 @@ func Attach(ctx context.Context, externalStateRoot string, request surfaces.Requ if !programChangeRequiresHuman && !questionRequiresIdentity { return nil } - presentation, err := PresentationForRequest(ctx, externalStateRoot, request, response.Snapshot) + var presentation humanidentity.Presentation + var err error + if response.ProgramChange != nil { + presentation, err = PresentationForProgramChange(ctx, externalStateRoot, request, response.Snapshot) + } else { + presentation, err = PresentationForRequest(ctx, externalStateRoot, request, response.Snapshot) + } if err != nil { return err } @@ -64,27 +73,61 @@ func Attach(ctx context.Context, externalStateRoot string, request surfaces.Requ // PresentationForRequest resolves the descriptor selected by the exact // configuration authority for this invocation. func PresentationForRequest(ctx context.Context, externalStateRoot string, request surfaces.Request, observed *model.Snapshot) (humanidentity.Presentation, error) { - if request.TransitionID == "installation.initialize" { - if request.ControlBundle == nil || request.ControlBundle.Target == nil { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no target control bundle") - } - configPath, ok := request.Parameters.Get("config_path") - if !ok { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: initialization has no configuration path") - } - return PresentationFromBoundConfig(configPath, *request.ControlBundle.Target) + var bundle *boatstackruntime.ControlBundleSnapshot + if request.ControlBundle != nil { + bundle = &request.ControlBundle.Source + } + if request.ProgramID != "" && !maintenanceUsesDefault(request.TransitionID) { + return presentationForVerifiedRepository(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed, func(_ protocol.ProjectConfig, state *durable.State) (string, error) { + if state == nil || state.ProgramHumanIdentityRole == "" { + return "", fmt.Errorf("HUMAN_IDENTITY_UNBOUND: Flow has no admitted human identity role") + } + return state.ProgramHumanIdentityRole, nil + }) } + return PresentationForRepositoryDefault(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed) +} + +// PresentationForProgramChange always selects the prior admitted program role. +// Candidate program bytes cannot choose their own approving identity. +func PresentationForProgramChange(ctx context.Context, externalStateRoot string, request surfaces.Request, observed *model.Snapshot) (humanidentity.Presentation, error) { var bundle *boatstackruntime.ControlBundleSnapshot if request.ControlBundle != nil { bundle = &request.ControlBundle.Source } - return PresentationForRepository(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed) + return presentationForVerifiedRepository(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed, func(config protocol.ProjectConfig, state *durable.State) (string, error) { + if state != nil && state.ProgramHumanIdentityRole != "" { + return state.ProgramHumanIdentityRole, nil + } + if request.ProgramID == "" { + // Generic maintenance programs may omit a role. Their program update + // is a non-Flow maintenance surface and uses the current verified + // default; a candidate program still cannot select its approver. + return config.Identity.Default, nil + } + return "", fmt.Errorf("HUMAN_IDENTITY_UNBOUND: no prior admitted program role can approve program replacement") + }) } // PresentationForRepository resolves the controller layout before reading // configuration. Repository and external configuration authority therefore // select the same source used by observation and effects. -func PresentationForRepository(ctx context.Context, externalStateRoot, repository, host, correlation string, bundle *boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { +func PresentationForRepository(ctx context.Context, externalStateRoot, repository, host, correlation, role string, bundle *boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + return presentationForVerifiedRepository(ctx, externalStateRoot, repository, host, correlation, bundle, observed, func(protocol.ProjectConfig, *durable.State) (string, error) { + if err := humanidentity.ValidateRole(role); err != nil { + return "", err + } + return role, nil + }) +} + +func PresentationForRepositoryDefault(ctx context.Context, externalStateRoot, repository, host, correlation string, bundle *boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot) (humanidentity.Presentation, error) { + return presentationForVerifiedRepository(ctx, externalStateRoot, repository, host, correlation, bundle, observed, func(config protocol.ProjectConfig, _ *durable.State) (string, error) { + return config.Identity.Default, nil + }) +} + +func presentationForVerifiedRepository(ctx context.Context, externalStateRoot, repository, host, correlation string, bundle *boatstackruntime.ControlBundleSnapshot, observed *model.Snapshot, selectRole func(protocol.ProjectConfig, *durable.State) (string, error)) (humanidentity.Presentation, error) { if repository == "" { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: repository is required") } @@ -112,6 +155,7 @@ func PresentationForRepository(ctx context.Context, externalStateRoot, repositor } trusted := false + var verifiedState *durable.State if layout.ConfigAuthority == "repository" && bundle != nil { if !bundleBindsRawConfig(*bundle, raw) { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") @@ -129,14 +173,8 @@ func PresentationForRepository(ctx context.Context, externalStateRoot, repositor } trusted = true } - if !trusted { - stateRaw, readErr := os.ReadFile(layout.StatePath) - if readErr != nil { - if os.IsNotExist(readErr) { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: authoritative configuration has no verified state") - } - return humanidentity.Presentation{}, readErr - } + stateRaw, readErr := os.ReadFile(layout.StatePath) + if readErr == nil { state, decodeErr := durable.DecodeState(stateRaw) if decodeErr != nil { return humanidentity.Presentation{}, decodeErr @@ -144,25 +182,30 @@ func PresentationForRepository(ctx context.Context, externalStateRoot, repositor if state.RepositoryID != current.RepositoryID || state.GitCommonID != current.GitCommonID || state.WorktreeID != current.WorktreeID { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: verified durable state belongs to a different invocation") } - if state.Configuration != model.ConfigurationVerified || state.ConfigFingerprint != fingerprint { + verifiedState = &state + } else if !os.IsNotExist(readErr) { + return humanidentity.Presentation{}, readErr + } + if !trusted { + if readErr != nil { + if os.IsNotExist(readErr) { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: authoritative configuration has no verified state") + } + return humanidentity.Presentation{}, readErr + } + if verifiedState.Configuration != model.ConfigurationVerified || verifiedState.ConfigFingerprint != fingerprint { return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: authoritative configuration does not match verified durable state") } } - return humanidentity.NewPresentation(config.Identity.Human) -} - -// PresentationFromBoundConfig verifies an exact candidate configuration -// against a control-bundle snapshot. This is used for initialization, before a -// controller layout can select an installed authority. -func PresentationFromBoundConfig(configPath string, snapshot boatstackruntime.ControlBundleSnapshot) (humanidentity.Presentation, error) { - config, raw, _, err := readConfig(configPath) + role, err := selectRole(config, verifiedState) if err != nil { return humanidentity.Presentation{}, err } - if !bundleBindsRawConfig(snapshot, raw) { - return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_DRIFT: project configuration does not match the verified control bundle") + descriptor, ok := config.Identity.Roles[role] + if !ok { + return humanidentity.Presentation{}, fmt.Errorf("HUMAN_IDENTITY_UNBOUND: verified project configuration does not define role %q", role) } - return humanidentity.NewPresentation(config.Identity.Human) + return humanidentity.NewPresentation(role, descriptor) } func readConfig(configPath string) (protocol.ProjectConfig, []byte, string, error) { @@ -228,3 +271,19 @@ func questionUsesExplicitActor(response *surfaces.Response) bool { return false } } + +func programChangeUsesExplicitActor(request surfaces.Request, response *surfaces.Response) bool { + if response == nil || response.ProgramChange == nil { + return false + } + return request.TransitionID == "installation.initialize" && (response.Snapshot == nil || response.Snapshot.Configuration.Status != model.FactKnown || response.Snapshot.Configuration.Value != model.ConfigurationVerified) +} + +func maintenanceUsesDefault(id catalog.TransitionID) bool { + switch id { + case "configuration.initialize", "configuration.mutate", "configuration.reconcile": + return true + default: + return false + } +} diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go index d6e8098..326ffe7 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -14,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing.T) { @@ -26,7 +27,7 @@ func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing. t.Fatal(err) } externalBase := t.TempDir() - if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-unverified", &bundle, nil); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-unverified", "developer", &bundle, nil); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_UNBOUND") { t.Fatalf("bundle bytes were treated as accepted configuration: %v", err) } resolver, err := plant.NewResolver(externalBase) @@ -42,12 +43,12 @@ func TestPresentationUsesRepositoryConfigurationBoundByControlBundle(t *testing. t.Fatal(err) } observed := &model.Snapshot{Observation: model.Observation{Invocation: invocation, Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:repository", Fingerprint: fingerprint})}} - presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-config", &bundle, observed) + presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-config", "developer", &bundle, observed) if err != nil || presentation.Descriptor.Value != "repository-actor" { t.Fatalf("presentation = %#v, err=%v", presentation, err) } writeIdentityFile(t, filepath.Join(repository, ".boatstack", "project.json"), identityConfig("changed-actor")) - if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-drift", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "repository-drift", "developer", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { t.Fatalf("unbound repository config change was accepted: %v", err) } } @@ -94,18 +95,18 @@ func TestPresentationUsesExternalConfigurationAuthorityAndVerifiedState(t *testi t.Fatal(err) } observed := &model.Snapshot{Observation: model.Observation{Invocation: detached, Configuration: model.Known(model.ConfigurationVerified, model.Evidence{Source: "configuration:external", Fingerprint: fingerprint})}} - presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-observed", &bundle, observed) + presentation, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-observed", "developer", &bundle, observed) if err != nil || presentation.Descriptor.Value != "external-actor" { t.Fatalf("external presentation = %#v, err=%v", presentation, err) } stale := *observed stale.Configuration.Value = model.ConfigurationStale - if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-stale", &bundle, &stale); err == nil || !strings.Contains(err.Error(), "not verified") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-stale", "developer", &bundle, &stale); err == nil || !strings.Contains(err.Error(), "not verified") { t.Fatalf("stale configuration selected a human identity: %v", err) } wrongInvocation := *observed wrongInvocation.Invocation.WorktreeID = "wt-different" - if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-wrong-invocation", &bundle, &wrongInvocation); err == nil || !strings.Contains(err.Error(), "different invocation") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-wrong-invocation", "developer", &bundle, &wrongInvocation); err == nil || !strings.Contains(err.Error(), "different invocation") { t.Fatalf("cross-invocation evidence was accepted: %v", err) } @@ -123,13 +124,24 @@ func TestPresentationUsesExternalConfigurationAuthorityAndVerifiedState(t *testi t.Fatal(err) } writeIdentityFile(t, layout.StatePath, stateRaw) - presentation, err = PresentationForRepository(ctx, externalBase, repository, "sdk", "external-durable", &bundle, nil) + presentation, err = PresentationForRepository(ctx, externalBase, repository, "sdk", "external-durable", "developer", &bundle, nil) if err != nil || presentation.Descriptor.Value != "external-actor" { t.Fatalf("durable external presentation = %#v, err=%v", presentation, err) } + genericReplacement, err := PresentationForProgramChange(ctx, externalBase, surfaces.Request{ + Repository: repository, Host: "sdk", CorrelationID: "generic-program-change", ControlBundle: &boatstackruntime.ControlBundleContract{Source: bundle}, + }, nil) + if err != nil || genericReplacement.Role != "developer" { + t.Fatalf("generic program replacement presentation = %#v, err=%v", genericReplacement, err) + } + if _, err := PresentationForProgramChange(ctx, externalBase, surfaces.Request{ + Repository: repository, Host: "sdk", CorrelationID: "flow-program-change", ProgramID: "product-delivery", ControlBundle: &boatstackruntime.ControlBundleContract{Source: bundle}, + }, nil); err == nil || !strings.Contains(err.Error(), "no prior admitted program role") { + t.Fatalf("Flow program replacement used the generic default: %v", err) + } writeIdentityFile(t, filepath.Join(sharedRoot, "project.json"), identityConfig("changed-external-actor")) - if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-drift", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { + if _, err := PresentationForRepository(ctx, externalBase, repository, "sdk", "external-drift", "developer", &bundle, observed); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { t.Fatalf("external authority drift was accepted: %v", err) } } @@ -145,7 +157,7 @@ func identityRepository(t *testing.T) string { } func identityConfig(actor string) []byte { - return []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"` + actor + `"}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) + return []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"` + actor + `"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) } func writeIdentityFile(t *testing.T, path string, raw []byte) { diff --git a/boatstack/internal/softwaredelivery/protocol/config.go b/boatstack/internal/softwaredelivery/protocol/config.go index a1823f9..302e080 100644 --- a/boatstack/internal/softwaredelivery/protocol/config.go +++ b/boatstack/internal/softwaredelivery/protocol/config.go @@ -16,7 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" ) -const ConfigSchemaVersion = 4 +const ConfigSchemaVersion = 5 type ProjectSettings struct { Name string `json:"name"` @@ -48,7 +48,8 @@ type SubprocessExtensionSettings struct { } type IdentitySettings struct { - Human humanidentity.Descriptor `json:"human"` + Default string `json:"default"` + Roles map[string]humanidentity.Descriptor `json:"roles"` } type ProjectConfig struct { @@ -108,7 +109,7 @@ func DecodeProjectConfig(value []byte) (ProjectConfig, error) { return config, nil } -// ProjectConfigFingerprint binds configuration authority to strict schema-4 +// ProjectConfigFingerprint binds configuration authority to strict schema-5 // semantics rather than checkout-specific JSON bytes. Formatting, object-key // order, line endings, the defaulted external-effect policy, and host ordering // therefore cannot make an otherwise identical configuration stale. @@ -157,11 +158,25 @@ func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) { func (c ProjectConfig) Validate() error { if c.SchemaVersion != ConfigSchemaVersion || c.Project.Name == "" || c.Project.DefaultBranch == "" || c.Project.Commands == nil { - return fmt.Errorf("Boatstack project configuration requires schema 4, project name, default branch, commands, and human identity") + return fmt.Errorf("Boatstack project configuration requires schema 5, project name, default branch, commands, and named human identities") } - if err := c.Identity.Human.Validate(); err != nil { + if len(c.Identity.Roles) == 0 { + return fmt.Errorf("Boatstack project configuration requires at least one human identity role") + } + if err := humanidentity.ValidateRole(c.Identity.Default); err != nil { return err } + if _, ok := c.Identity.Roles[c.Identity.Default]; !ok { + return fmt.Errorf("HUMAN_IDENTITY_ROLE_UNBOUND: default role %q is not defined", c.Identity.Default) + } + for role, descriptor := range c.Identity.Roles { + if err := humanidentity.ValidateRole(role); err != nil { + return err + } + if err := descriptor.Validate(); err != nil { + return fmt.Errorf("human identity role %q: %w", role, err) + } + } if err := ValidateGitBranch(c.Project.DefaultBranch); err != nil { return fmt.Errorf("invalid default branch: %w", err) } diff --git a/boatstack/internal/softwaredelivery/protocol/config_test.go b/boatstack/internal/softwaredelivery/protocol/config_test.go index 5dbc0d6..019d3f9 100644 --- a/boatstack/internal/softwaredelivery/protocol/config_test.go +++ b/boatstack/internal/softwaredelivery/protocol/config_test.go @@ -10,25 +10,25 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/humanidentity" ) -const literalIdentityJSON = `"identity":{"human":{"kind":"literal","value":"operator"}},` +const literalIdentityJSON = `"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},` func literalIdentity() IdentitySettings { - return IdentitySettings{Human: humanidentity.Descriptor{Kind: humanidentity.KindLiteral, Value: "operator"}} + return IdentitySettings{Default: "developer", Roles: map[string]humanidentity.Descriptor{"developer": {Kind: humanidentity.KindLiteral, Value: "operator"}}} } func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) { - valid := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) + valid := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"],"projections":["codex"]}`) if _, err := DecodeProjectConfig(valid); err != nil { t.Fatal(err) } invalid := [][]byte{ - []byte(`{"schema_version":3,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[],"legacy":true}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":null}`), + []byte(`{"schema_version":4,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[],"legacy":true}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":null}`), } for _, value := range invalid { if _, err := DecodeProjectConfig(value); err == nil { @@ -96,8 +96,8 @@ func TestRepositorySubprocessExtensionsAreStrictAndSemanticallyFingerprinted(t * } func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { - one := []byte("{\n \"schema_version\": 4,\n \"identity\": {\"human\": {\"kind\": \"literal\", \"value\": \"operator\"}},\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"],\n \"projections\": [\"codex\"]\n}\n") - two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"projections\":[\"codex\"],\r\n\"identity\":{\"human\":{\"value\":\"operator\",\"kind\":\"literal\"}},\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":4\r\n}\r\n") + one := []byte("{\n \"schema_version\": 5,\n \"identity\": {\"default\": \"developer\", \"roles\": {\"developer\": {\"kind\": \"literal\", \"value\": \"operator\"}}},\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"],\n \"projections\": [\"codex\"]\n}\n") + two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"projections\":[\"codex\"],\r\n\"identity\":{\"roles\":{\"developer\":{\"value\":\"operator\",\"kind\":\"literal\"}},\"default\":\"developer\"},\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":5\r\n}\r\n") _, oneFingerprint, err := ProjectConfigFingerprint(one) if err != nil { t.Fatal(err) @@ -110,7 +110,7 @@ func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) { t.Fatalf("representation changed semantic fingerprint: %s != %s", oneFingerprint, twoFingerprint) } - changed := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"operator"}},"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"],"projections":["codex"]}`) + changed := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"],"projections":["codex"]}`) _, changedFingerprint, err := ProjectConfigFingerprint(changed) if err != nil { t.Fatal(err) @@ -199,8 +199,8 @@ func TestProjectProjectionsAreExplicitCanonicalAndNonsemantic(t *testing.T) { } func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { - literal := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"example-operator"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) - command := []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + literal := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"example-operator"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + command := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"command","command":"gh","args":["api","user","--jq",".login"]}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) literalConfig, literalFingerprint, err := ProjectConfigFingerprint(literal) if err != nil { t.Fatal(err) @@ -209,17 +209,17 @@ func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { if err != nil { t.Fatal(err) } - if literalConfig.Identity.Human.Kind != humanidentity.KindLiteral || commandConfig.Identity.Human.Kind != humanidentity.KindCommand { + if literalConfig.Identity.Roles["developer"].Kind != humanidentity.KindLiteral || commandConfig.Identity.Roles["developer"].Kind != humanidentity.KindCommand { t.Fatalf("decoded identities literal=%#v command=%#v", literalConfig.Identity, commandConfig.Identity) } if literalFingerprint == commandFingerprint { t.Fatal("identity descriptor change preserved project configuration fingerprint") } for _, invalid := range [][]byte{ - []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh"}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"command","command":"gh","args":null}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"actor","command":""}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), - []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"actor","unknown":true}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"command","command":"gh"}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"command","command":"gh","args":null}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"actor","command":""}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), + []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"actor","unknown":true}}},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`), } { if _, err := DecodeProjectConfig(invalid); err == nil { t.Fatalf("invalid identity config was accepted: %s", invalid) @@ -227,6 +227,62 @@ func TestProjectConfigurationBindsHumanIdentityDescriptor(t *testing.T) { } } +func TestProjectConfigurationRequiresExplicitNamedRoles(t *testing.T) { + base := ProjectConfig{ + SchemaVersion: ConfigSchemaVersion, + Identity: IdentitySettings{Default: "developer", Roles: map[string]humanidentity.Descriptor{ + "developer": {Kind: humanidentity.KindLiteral, Value: "operator"}, + "release-manager": {Kind: humanidentity.KindLiteral, Value: "release-operator"}, + }}, + Project: ProjectSettings{Name: "product", DefaultBranch: "main", Commands: map[string]string{}}, + Policy: PolicySettings{PlanApproval: "human", VisualEvidence: "optional"}, Hosts: []string{"cli"}, Projections: []string{}, + } + raw, _ := json.Marshal(base) + decoded, fingerprint, err := ProjectConfigFingerprint(raw) + if err != nil || decoded.Identity.Default != "developer" || len(fingerprint) != 64 { + t.Fatalf("named roles = %#v fingerprint=%q err=%v", decoded.Identity, fingerprint, err) + } + reordered := base + reordered.Identity.Roles = map[string]humanidentity.Descriptor{ + "release-manager": base.Identity.Roles["release-manager"], + "developer": base.Identity.Roles["developer"], + } + reorderedRaw, _ := json.Marshal(reordered) + _, reorderedFingerprint, err := ProjectConfigFingerprint(reorderedRaw) + if err != nil || reorderedFingerprint != fingerprint { + t.Fatalf("role map order changed fingerprint: %q != %q, %v", reorderedFingerprint, fingerprint, err) + } + changedDefault := base + changedDefault.Identity.Default = "release-manager" + changedRaw, _ := json.Marshal(changedDefault) + changedConfig, changedFingerprint, err := ProjectConfigFingerprint(changedRaw) + if err != nil || changedFingerprint == fingerprint { + t.Fatalf("changed default fingerprint=%q err=%v", changedFingerprint, err) + } + if !reflect.DeepEqual(decoded.ControlPolicy(), changedConfig.ControlPolicy()) { + t.Fatal("human identity roles changed runtime host policy") + } + + for name, identity := range map[string]IdentitySettings{ + "missing default": {Roles: base.Identity.Roles}, + "unknown default": {Default: "unknown", Roles: base.Identity.Roles}, + "empty roles": {Default: "developer", Roles: map[string]humanidentity.Descriptor{}}, + "null roles": {Default: "developer", Roles: nil}, + "invalid role": {Default: "Developer", Roles: map[string]humanidentity.Descriptor{"Developer": base.Identity.Roles["developer"]}}, + } { + candidate := base + candidate.Identity = identity + raw, _ := json.Marshal(candidate) + if _, err := DecodeProjectConfig(raw); err == nil { + t.Fatalf("%s identity was accepted: %s", name, raw) + } + } + unknown := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}},"implicit":true},"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + if _, err := DecodeProjectConfig(unknown); err == nil { + t.Fatal("unknown identity field was accepted") + } +} + func TestGitReferencesRejectOptionsAndRevisionExpressions(t *testing.T) { for _, value := range []string{"main", "feature/v2", "HEAD", "0123456789abcdef"} { if err := ValidateGitReference(value); err != nil { diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index f16a3f0..9979479 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 = 14 +const SchemaVersion = 15 var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) var gitObjectIdentity = regexp.MustCompile(`^[0-9a-f]{40,64}$`) diff --git a/boatstack/invocation/invocation.go b/boatstack/invocation/invocation.go index 31c2b38..ce3a7b4 100644 --- a/boatstack/invocation/invocation.go +++ b/boatstack/invocation/invocation.go @@ -17,11 +17,11 @@ import ( const ( EvidenceSchema = "transition-invocation" - EvidenceSchemaRevision = 3 + EvidenceSchemaRevision = 4 RequestSchema = "transition-input-request" - RequestSchemaRevision = 3 + RequestSchemaRevision = 4 ReceiptSchema = "transition-input-receipt" - ReceiptSchemaRevision = 3 + ReceiptSchemaRevision = 4 ) type Context struct { diff --git a/boatstack/program_effects_test.go b/boatstack/program_effects_test.go index f1d810e..5032241 100644 --- a/boatstack/program_effects_test.go +++ b/boatstack/program_effects_test.go @@ -241,7 +241,7 @@ func TestProgramRuntimeProtocolCommitsDeclaredStateEffectBeforeReceipt(t *testin locker, _ := effects.NewLocker(resolver) journal, _ := effects.NewJournal(resolver, clock) receipts, _ := effects.NewReceiptStore(resolver, clock) - base, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership()) + base, err := effects.NewProgramDriver(resolver, clock, effects.NewNativeBoundary(), program.ResourceOwnership(), "") if err != nil { t.Fatal(err) } diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index b11c15a..e0e9137 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -1,12 +1,12 @@ # Configuration schema -Boatstack accepts only `.boatstack/project.json` schema version 3. The +Boatstack accepts only `.boatstack/project.json` schema version 5. The normative Go decoder is `internal/softwaredelivery/protocol.DecodeProjectConfig`; the public example is `project.example.json`. -Top-level keys are `schema_version`, `identity`, `project`, `policy`, `hosts`, and optional -`extensions`. +Top-level keys are `schema_version`, `identity`, `project`, `policy`, `hosts`, +`projections`, and optional `extensions`. Unknown keys and trailing JSON fail. Hosts are selected from `claude`, `cli`, `codex`, `cursor`, `gemini`, `mcp`, and `sdk`; `cli` is mandatory. @@ -17,12 +17,14 @@ stdout, and stderr limits. Repository configuration cannot replace the primary flow. A subprocess extension is a trusted executable boundary, not an OS sandbox. -`identity.human` is required. It is either a bounded `literal` actor or a -structured `command` plus exact `args`. Boatstack fingerprints and exposes the -descriptor but never executes it. Identity resolution proposes an actor; it -does not grant human or external-provider authority. +`identity.default` and nonempty `identity.roles` are required. Role IDs match +`^[a-z][a-z0-9._-]*$` and are at most 128 bytes. Each descriptor is either a +bounded `literal` actor or a structured `command` plus exact `args`. Boatstack +fingerprints and exposes the descriptor but never executes it. Identity +resolution proposes an actor; a role or actor does not grant human or +external-provider authority. Configuration changes use `configuration.mutate` with `config_path` and -`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-4 +`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-5 value in canonical JSON form, not the source file's raw bytes; the CLI derives it -when omitted. Never hand-edit controller state or reuse a V1 schema. +when omitted. Never hand-edit controller state or reuse a prior schema. diff --git a/boatstack/sdk/human_identity_test.go b/boatstack/sdk/human_identity_test.go index bea1072..d5169e6 100644 --- a/boatstack/sdk/human_identity_test.go +++ b/boatstack/sdk/human_identity_test.go @@ -28,7 +28,7 @@ func TestSDKResponseBoundaryAttachesVerifiedHumanIdentity(t *testing.T) { if output, err := exec.Command("git", "init", "-q", repository).CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) } - raw := []byte(`{"schema_version":4,"identity":{"human":{"kind":"literal","value":"sdk-operator"}},"project":{"name":"sdk-fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) + raw := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"sdk-operator"}}},"project":{"name":"sdk-fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","sdk"],"projections":[]}`) configPath := filepath.Join(repository, ".boatstack", "project.json") if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { t.Fatal(err) diff --git a/boatstack/testdata/control-programs/incident-response-invocation.flow.ts b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts index 1e49803..e9bfbba 100644 --- a/boatstack/testdata/control-programs/incident-response-invocation.flow.ts +++ b/boatstack/testdata/control-programs/incident-response-invocation.flow.ts @@ -15,6 +15,7 @@ import { export default defineFlow({ id: "incident-response-invocation", version: "1", + human_identity: "developer", declarations: { authorities: ["human"], verifiers: ["state-effect"], diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json index 0ddf31f..3a43387 100644 --- a/boatstack/testdata/control-programs/incident-response.raw.json +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -1,35 +1,75 @@ { "schema": "control-program", - "schema_revision": 4, + "schema_revision": 5, "program": { "id": "incident-response", "version": "1" }, "declarations": { - "capabilities": ["service.restart"], - "authorities": ["incident-commander"], - "effects": ["service.restart"], - "verifiers": ["healthcheck"] + "capabilities": [ + "service.restart" + ], + "authorities": [ + "incident-commander" + ], + "effects": [ + "service.restart" + ], + "verifiers": [ + "healthcheck" + ] }, "facets": [ - { "id": "incident", "kind": "enum", "values": ["open", "mitigated"] }, - { "id": "service", "kind": "enum", "values": ["degraded", "healthy"] } + { + "id": "incident", + "kind": "enum", + "values": [ + "open", + "mitigated" + ] + }, + { + "id": "service", + "kind": "enum", + "values": [ + "degraded", + "healthy" + ] + } ], "evidence": [ - { "id": "healthcheck", "subject": "service", "kind": "observation" } + { + "id": "healthcheck", + "subject": "service", + "kind": "observation" + } ], + "work": [], "operators": [ { "id": "restart", - "capabilities": ["service.restart"], - "authority": { "any_of": ["incident-commander"] }, - "effects": ["service.restart"], + "capabilities": [ + "service.restart" + ], + "authority": { + "any_of": [ + "incident-commander" + ] + }, + "effects": [ + "service.restart" + ], "verifier": "healthcheck", "recovery": "restart", "execution_context": "preserve", "state_effect": { "kind": "assignments", - "assignments": [{ "facet": "incident", "value": "mitigated" }] + "assignments": [ + { + "facet": "incident", + "value": "mitigated" + } + ] } } ], @@ -37,13 +77,52 @@ { "id": "restart", "operator": "restart", - "guard": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["open"] } }, - "target": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["mitigated"] } }, + "guard": { + "fact": { + "facet": "incident", + "statuses": [ + "known" + ], + "values": [ + "open" + ] + } + }, + "target": { + "fact": { + "facet": "incident", + "statuses": [ + "known" + ], + "values": [ + "mitigated" + ] + } + }, "priority": 10 } ], "targets": [ - { "id": "mitigated", "predicate": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["mitigated"] } } } + { + "id": "mitigated", + "predicate": { + "fact": { + "facet": "incident", + "statuses": [ + "known" + ], + "values": [ + "mitigated" + ] + } + } + } ], - "entries": [{ "id": "respond", "target": "mitigated", "inputs": [] }] + "entries": [ + { + "id": "respond", + "target": "mitigated", + "inputs": [] + } + ] } diff --git a/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts b/boatstack/testdata/control-programs/product-delivery-planning-package-manual.flow.ts index 74f982e..b964807 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 @@ -69,6 +69,7 @@ const lifecycle = [ export default defineFlow({ id: "product-delivery", version: "1", + human_identity: "developer", declarations: { input_resolvers: [planInboxResolver] }, facets: softwareDeliveryFacets, evidence: softwareDeliveryEvidence, 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 e0d1786..da5ad25 100644 --- a/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts +++ b/boatstack/testdata/control-programs/product-delivery-planning-package.flow.ts @@ -65,6 +65,7 @@ const lifecycle = [ export default defineFlow(softwareDelivery({ id: "product-delivery", version: "1", + humanIdentity: "developer", lifecycle: lifecycle, planningPackageWork: planning, targets: [ 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 fae7581..1e76a8f 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":4,"program":{"id":"product-delivery","version":"1"},"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":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"}}]}]} diff --git a/docs/configuration.md b/docs/configuration.md index e5fecfa..92407eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,17 +1,21 @@ # Boatstack configuration `.boatstack/project.json` is the repository-owned policy input. Boatstack accepts only -schema version 4. Unknown top-level fields, unsupported policy values, duplicate +schema version 5. Unknown fields, unsupported policy values, duplicate hosts or projections, trailing JSON, and missing required fields fail closed. ```json { - "schema_version": 4, + "schema_version": 5, "identity": { - "human": { - "kind": "command", - "command": "gh", - "args": ["api", "user", "--jq", ".login"] + "default": "developer", + "roles": { + "developer": { + "kind": "command", + "command": "gh", + "args": ["api", "user", "--jq", ".login"] + }, + "release-manager": {"kind": "literal", "value": "release-operator"} } }, "project": { @@ -38,7 +42,8 @@ hosts or projections, trailing JSON, and missing required fields fail closed. ## Required values - `project.name`, `project.default_branch`, and `project.commands`; -- `identity.human`, as either a literal or structured command descriptor; +- `identity.default` and a nonempty `identity.roles` object whose keys match + `^[a-z][a-z0-9._-]*$` and are at most 128 bytes; - `policy.plan_approval`: `human` or `human-or-autonomy`; - `policy.visual_evidence`: `off`, `optional`, or `required`; - at least the `cli` host. @@ -55,10 +60,13 @@ The only accepted external-effect authority policy is `human-or-autonomy-plus-provider`. Provider authority is an independent mandatory clause; it cannot be replaced by a human receipt. -## Human actor identity +## Named human identity roles -`identity.human` tells a host how to obtain the proposed actor label for a -human-authority request. It does not grant authority. A literal descriptor is: +Each role tells a host how to obtain the proposed actor for a human-authority +request. `identity.default` is used only by non-Flow maintenance. A Flow selects +its own role explicitly through `human_identity`; Boatstack never substitutes +the default. Roles, actors, approvals, and provider capabilities are distinct. +A literal descriptor is: ```json {"kind": "literal", "value": "alice"} @@ -82,19 +90,25 @@ must not infer an operating-system or Git identity. This explicit fallback does not replace the verified descriptor. The host retains its exact provider fingerprint and still requires separate approval of the exact authority request. -The host displays the resolved actor, exact request, and requested authority, +The host displays the selected role, resolved actor, exact request, and requested authority, then asks for explicit approval. The authorization command still requires `--human `. The descriptor fingerprint records how the actor was proposed. It does not prove approval, identity ownership, provider permission, or external-provider authority. In particular, resolving an actor through `gh` does not create a GitHub provider receipt. -Before initialization, or while `configuration.initialize`, -`configuration.mutate`, or `configuration.reconcile` repairs unverified -configuration, no trusted descriptor is available. Boatstack preserves the -human authority question but omits `human_identity`. The host must ask for an -explicit actor and must not infer one. A missing identity on any other human -authority boundary is an error. +When neither trusted source exists—at true bootstrap before +`installation.initialize`, or while `configuration.initialize`, +`configuration.mutate`, or +`configuration.reconcile` repairs unverified configuration—Boatstack preserves +the human authority question but omits `human_identity`. The host must ask for +an explicit actor and must not infer one. A missing identity on any other human +authority boundary is an error. Configuration mutation with verified +configuration uses the current default. Program replacement uses the persisted +role admitted with the prior program, so candidate code cannot select its own +approver. Removing that role from a candidate configuration is rejected; +changing its descriptor is allowed and invalidates prior authorization through +configuration and bundle drift. The canonical snapshot carries this policy projection as controlling evidence. `human` plan approval rejects autonomy receipts. `human-or-autonomy` accepts @@ -111,7 +125,7 @@ high-risk derivation fails closed whenever that policy is active. Host and projection selection changes use the governed configuration boundary: -1. Write a candidate schema-4 configuration with the desired `hosts` and +1. Write a candidate schema-5 configuration with the desired `hosts` and `projections`. 2. Apply it through `configuration.mutate`. Boatstack installs that exact config and its selected maintenance projections atomically. @@ -178,7 +192,7 @@ document; an arbitrary fingerprint string is insufficient. To change configuration, write a candidate file elsewhere, then request `configuration.mutate`. The CLI derives `config_sha256` from the strict decoded -schema-4 value in canonical JSON form. Formatting, object-key order, and LF/CRLF +schema-5 value in canonical JSON form. Formatting, object-key order, and LF/CRLF checkout conversion therefore retain the same authority, while any controlling value change produces a new fingerprint. The kernel still copies the exact candidate bytes, installs state last, re-observes the tracked file, and accepts diff --git a/docs/getting-started.md b/docs/getting-started.md index 6184ff4..fd50294 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,7 +13,7 @@ boatstack doctor --repo . --format text Windows users run `install.ps1` in PowerShell. The kernel creates `.boatstack/project.json`; review and commit that file before feature work. `BOATSTACK_ACTOR` is explicit installation authority and becomes the default -literal `identity.human` in a generated configuration. The installer never +literal `developer` role and `identity.default` in a generated configuration. The installer never infers an actor from the operating system. Replace the literal descriptor with a structured command descriptor when the repository should ask its host to resolve the proposed actor. @@ -30,6 +30,8 @@ boatstack next --repo . --flow product-delivery --entry run --format json Repository authors can keep software-delivery policy explicit without repeating its canonical wiring by using the composition shape documented in [Writing a Flow](product-delivery/writing-a-flow.md). +Software-delivery Flows must set `humanIdentity` to one role declared by the +exact current project configuration. The first response returns an opaque run ID. Preserve the program, entry, run ID, objective, delivery, authority, and prescription through every subsequent diff --git a/docs/product-delivery/authority-and-delegation.md b/docs/product-delivery/authority-and-delegation.md index d9343f2..e5fa50e 100644 --- a/docs/product-delivery/authority-and-delegation.md +++ b/docs/product-delivery/authority-and-delegation.md @@ -29,13 +29,16 @@ 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 repository also declares `identity.human` in `.boatstack/project.json`. -Boatstack exposes that literal or structured command descriptor and its -fingerprint to the host; it does not execute the command. The host resolves and +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 +descriptor, and its fingerprint to the host; it does not execute the command. The host resolves and visibly presents a proposed actor, then asks for explicit approval of the exact request. Resolution alone creates no human or autonomy authority. A command that uses `gh` still creates no external-provider authority. Conversely, a current provider receipt does not authorize a human delegation. +The selected role is not an actor or authority, and candidate program code +cannot choose the role that approves its own replacement. Publication is admitted only after the product worktree is clean and the preview binds its exact committed HEAD. A changed HEAD or worktree invalidates diff --git a/docs/product-delivery/writing-a-flow.md b/docs/product-delivery/writing-a-flow.md index 135fad0..9b4b83d 100644 --- a/docs/product-delivery/writing-a-flow.md +++ b/docs/product-delivery/writing-a-flow.md @@ -51,6 +51,7 @@ const planning = foregroundWork({ export default defineFlow(softwareDelivery({ id: "product-delivery", version: "1", + humanIdentity: "developer", lifecycle: lifecycle, planningPackageWork: planning, targets: [ diff --git a/install.ps1 b/install.ps1 index 07605e6..89031c0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -116,8 +116,8 @@ try { if (-not $ConfigSource) { $ConfigSource = Join-Path $Temporary "project.json" $Config = [ordered]@{ - schema_version = 4 - identity = [ordered]@{ human = [ordered]@{ kind = "literal"; value = $Actor } } + schema_version = 5 + identity = [ordered]@{ default = "developer"; roles = [ordered]@{ developer = [ordered]@{ kind = "literal"; value = $Actor } } } project = [ordered]@{ name = "repository"; default_branch = $DefaultBranch; commands = [ordered]@{} } policy = [ordered]@{ plan_approval = "human"; visual_evidence = "optional" } hosts = @("cli", "cursor", "codex", "claude", "gemini", "mcp") diff --git a/install.sh b/install.sh index 7a4fbf1..e7b10f8 100755 --- a/install.sh +++ b/install.sh @@ -143,7 +143,7 @@ if [[ "$mode" == install ]]; then config_source="$temporary/project.json" json_default_branch="${default_branch//\\/\\\\}" json_default_branch="${json_default_branch//\"/\\\"}" - printf '%s\n' "{\"schema_version\":4,\"identity\":{\"human\":{\"kind\":\"literal\",\"value\":\"$actor\"}},\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"],\"projections\":[\"codex\",\"claude\",\"cursor\",\"gemini\"]}" > "$config_source" + printf '%s\n' "{\"schema_version\":5,\"identity\":{\"default\":\"developer\",\"roles\":{\"developer\":{\"kind\":\"literal\",\"value\":\"$actor\"}}},\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"],\"projections\":[\"codex\",\"claude\",\"cursor\",\"gemini\"]}" > "$config_source" fi "$runtime" init --repo "$repository" --human "$actor" --param "config_path=$config_source" --format text elif [[ "$mode" == update ]]; then diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index dcd334d..4af3194 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -118,6 +118,8 @@ export interface SoftwareDeliveryFlowDefinition { id: string; /** Repository-selected Control Program version. */ version: string; + /** Named repository identity role used for this program's human interactions. */ + humanIdentity: string; /** Optional human-readable description passed through unchanged. */ description?: string; /** Explicit trusted lifecycle membership and repository-selected priorities. */ @@ -135,6 +137,15 @@ export interface SoftwareDeliveryFlowDefinition { function validateSoftwareDeliveryDefinition( definition: SoftwareDeliveryFlowDefinition, ): void { + if ( + typeof definition.humanIdentity !== "string" || + new TextEncoder().encode(definition.humanIdentity).length > 128 || + !/^[a-z][a-z0-9._-]*$/.test(definition.humanIdentity) + ) { + throw new Error( + "SOFTWARE_DELIVERY_HUMAN_IDENTITY_INVALID: humanIdentity must be 1-128 bytes and match ^[a-z][a-z0-9._-]*$", + ); + } const lifecycleIDs = new Set(); for (const step of definition.lifecycle) { if (step.id.trim().length === 0) { @@ -258,6 +269,7 @@ export function softwareDelivery( return { id: definition.id, version: definition.version, + human_identity: definition.humanIdentity, ...(definition.description === undefined ? {} : { description: definition.description }), diff --git a/packages/boatstack-software-delivery/test/software-delivery.test.mjs b/packages/boatstack-software-delivery/test/software-delivery.test.mjs index 830d8a2..ad478fe 100644 --- a/packages/boatstack-software-delivery/test/software-delivery.test.mjs +++ b/packages/boatstack-software-delivery/test/software-delivery.test.mjs @@ -21,6 +21,7 @@ function definition(overrides = {}) { return { id: "example", version: "1", + humanIdentity: "developer", lifecycle: [{ id: "plan.activate", priority: 50 }], targets: [target], entries: [entry], @@ -42,6 +43,7 @@ test("composes canonical domain wiring without hidden policy", () => { assert.equal(result.id, "example"); assert.equal(result.version, "1"); assert.equal(result.description, "Example"); + assert.equal(result.human_identity, "developer"); assert.deepEqual(result.facets, softwareDeliveryFacets); assert.deepEqual(result.evidence, softwareDeliveryEvidence); assert.deepEqual( @@ -162,6 +164,15 @@ test("rejects duplicate work IDs including repeated planning work", () => { ); }); +for (const humanIdentity of [undefined, "", "Developer", "1developer", "developer role", "x".repeat(129)]) { + test(`rejects invalid human identity ${String(humanIdentity)}`, () => { + assert.throws( + () => softwareDelivery(definition({ humanIdentity })), + /SOFTWARE_DELIVERY_HUMAN_IDENTITY_INVALID/, + ); + }); +} + test("rejects planning work without its admit step", () => { assert.throws( () => @@ -232,6 +243,7 @@ test("does not mutate inputs or expose mutable canonical arrays", () => { const input = Object.freeze({ id: "example", version: "1", + humanIdentity: "developer", lifecycle, targets, entries, diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts index febfd1d..d478c08 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 = 4 as const; +export const CONTROL_PROGRAM_SCHEMA_REVISION = 5 as const; /** * A declarative condition over runtime state facts. @@ -272,6 +272,7 @@ export interface DelegationBindingDefinition { export interface FlowDefinition { id: string; version: string; + human_identity?: string; description?: string; declarations?: { capabilities?: string[]; @@ -293,7 +294,7 @@ export interface FlowDefinition { export interface ControlProgramIR { schema: typeof CONTROL_PROGRAM_SCHEMA; schema_revision: typeof CONTROL_PROGRAM_SCHEMA_REVISION; - program: { id: string; version: string; description?: string }; + program: { id: string; version: string; human_identity?: string; description?: string }; declarations: NonNullable; facets: FacetDefinition[]; evidence: EvidenceDefinition[]; @@ -331,6 +332,9 @@ export function defineFlow(definition: FlowDefinition): ControlProgramIR { program: { id: definition.id, version: definition.version, + ...(definition.human_identity + ? { human_identity: definition.human_identity } + : {}), ...(definition.description ? { description: definition.description } : {}), diff --git a/project.example.json b/project.example.json index d3812c7..736348a 100644 --- a/project.example.json +++ b/project.example.json @@ -1,10 +1,13 @@ { - "schema_version": 4, + "schema_version": 5, "identity": { - "human": { - "kind": "command", - "command": "gh", - "args": ["api", "user", "--jq", ".login"] + "default": "developer", + "roles": { + "developer": { + "kind": "command", + "command": "gh", + "args": ["api", "user", "--jq", ".login"] + } } }, "project": { diff --git a/release-notes/2026-08-17-named-human-identity-roles.md b/release-notes/2026-08-17-named-human-identity-roles.md new file mode 100644 index 0000000..1375e76 --- /dev/null +++ b/release-notes/2026-08-17-named-human-identity-roles.md @@ -0,0 +1,8 @@ +### Bind human approval to named repository roles + +Boatstack project configuration schema 5 replaces `identity.human` with an +explicit default and named role descriptors. Software-delivery Flows select one +role in their Control Program, and Boatstack persists that admitted role so a +candidate program cannot choose its own approver. Role or descriptor drift now +invalidates pending inputs, delegation, and human authorization while remaining +strictly separate from GitHub provider capability. From 50e3a5931fd48561bcb794d4bc212339c3d3effe Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 17:35:32 +0100 Subject: [PATCH 2/3] Allow first Flow admission after standard install --- .../cmd/boatstack-helper/flow_runtime_test.go | 91 +++++++++++++++++++ .../humanidentitybinding/binding.go | 14 +-- .../humanidentitybinding/binding_test.go | 7 +- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index ef6e634..332eb40 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1526,6 +1526,97 @@ func TestProgramChangePreflightRequiresExactTypedRecoverySurface(t *testing.T) { } } +func TestRolelessStandardInstallationCanAdmitFirstRoleBoundFlow(t *testing.T) { + // control-law: a roleless Standard installation has no prior Flow identity + // authority; its first Flow admission uses the verified configuration default + // and atomically persists the admitted Flow role. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + runtimeHome := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, runtimeHome) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runtimeRaw, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()}); err != nil { + t.Fatal(err) + } + repository := flowRepository(t) + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + + output, err := captureRunOutput(t, + "init", "--repo", repository, + "--param", "config_path="+filepath.Join(repository, ".boatstack", "project.json"), "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("initialize Standard program: %v\n%s", err, output) + } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "commit initialized control bundle") + + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "roleless-standard-state") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + stateRaw, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + state, err := durable.DecodeState(stateRaw) + if err != nil { + t.Fatal(err) + } + if state.ProgramHumanIdentityRole != "" { + t.Fatalf("Standard installation unexpectedly admitted Flow role %q", state.ProgramHumanIdentityRole) + } + + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + output, err = captureRunOutput(t, + "reconcile-update", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, + "--accept-program-change", "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("admit first role-bound Flow: %v\n%s", err, output) + } + var reconciled surfaces.Response + if err := json.Unmarshal(output, &reconciled); err != nil { + t.Fatal(err) + } + if reconciled.Receipt == nil || reconciled.Receipt.TransitionID != "installation.reconcile-update" { + t.Fatalf("first Flow reconciliation response = %#v", reconciled) + } + stateRaw, err = os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + state, err = durable.DecodeState(stateRaw) + if err != nil { + t.Fatal(err) + } + if state.ProgramHumanIdentityRole != "developer" || state.ProgramFingerprint == "" || state.ProgramFingerprint != reconciled.Receipt.Program.Fingerprint { + t.Fatalf("first Flow admission did not persist receipt-bound role and program: role=%q fingerprint=%q receipt=%q", state.ProgramHumanIdentityRole, state.ProgramFingerprint, reconciled.Receipt.Program.Fingerprint) + } +} + func TestAcceptedProgramReconciliationReprojectsSameFlowRun(t *testing.T) { // control-law: an accepted program mutation is a hard reprojection boundary; // the next product resolution uses the new program and preserves the run. diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go index 2d689d5..85153ba 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding.go @@ -88,8 +88,10 @@ func PresentationForRequest(ctx context.Context, externalStateRoot string, reque return PresentationForRepositoryDefault(ctx, externalStateRoot, request.Repository, request.Host, request.CorrelationID, bundle, observed) } -// PresentationForProgramChange always selects the prior admitted program role. -// Candidate program bytes cannot choose their own approving identity. +// PresentationForProgramChange selects the prior admitted program role when one +// exists. A roleless admitted program has no identity authority of its own, so +// its first role-bound replacement uses the independently verified repository +// default. Candidate program bytes cannot choose their own approving identity. func PresentationForProgramChange(ctx context.Context, externalStateRoot string, request surfaces.Request, observed *model.Snapshot) (humanidentity.Presentation, error) { var bundle *boatstackruntime.ControlBundleSnapshot if request.ControlBundle != nil { @@ -99,13 +101,7 @@ func PresentationForProgramChange(ctx context.Context, externalStateRoot string, if state != nil && state.ProgramHumanIdentityRole != "" { return state.ProgramHumanIdentityRole, nil } - if request.ProgramID == "" { - // Generic maintenance programs may omit a role. Their program update - // is a non-Flow maintenance surface and uses the current verified - // default; a candidate program still cannot select its approver. - return config.Identity.Default, nil - } - return "", fmt.Errorf("HUMAN_IDENTITY_UNBOUND: no prior admitted program role can approve program replacement") + return config.Identity.Default, nil }) } diff --git a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go index 326ffe7..2b86680 100644 --- a/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go +++ b/boatstack/internal/softwaredelivery/humanidentitybinding/binding_test.go @@ -134,10 +134,11 @@ func TestPresentationUsesExternalConfigurationAuthorityAndVerifiedState(t *testi if err != nil || genericReplacement.Role != "developer" { t.Fatalf("generic program replacement presentation = %#v, err=%v", genericReplacement, err) } - if _, err := PresentationForProgramChange(ctx, externalBase, surfaces.Request{ + firstFlowReplacement, err := PresentationForProgramChange(ctx, externalBase, surfaces.Request{ Repository: repository, Host: "sdk", CorrelationID: "flow-program-change", ProgramID: "product-delivery", ControlBundle: &boatstackruntime.ControlBundleContract{Source: bundle}, - }, nil); err == nil || !strings.Contains(err.Error(), "no prior admitted program role") { - t.Fatalf("Flow program replacement used the generic default: %v", err) + }, nil) + if err != nil || firstFlowReplacement.Role != "developer" || firstFlowReplacement.Descriptor.Value != "external-actor" { + t.Fatalf("first Flow replacement presentation = %#v, err=%v", firstFlowReplacement, err) } writeIdentityFile(t, filepath.Join(sharedRoot, "project.json"), identityConfig("changed-external-actor")) From 2eea3b92c7f3bbdbafc6696719f425af8ad103bc Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 17 Aug 2026 17:59:33 +0100 Subject: [PATCH 3/3] Close identity admission and recovery gaps --- .../boatstack-helper/delegation_command.go | 11 +++ .../cmd/boatstack-helper/flow_runtime.go | 11 ++- .../cmd/boatstack-helper/flow_runtime_test.go | 93 +++++++++++++++++++ .../boatstack-helper/human_identity_test.go | 6 ++ .../softwaredelivery/effects/artifacts.go | 8 ++ .../softwaredelivery/effects/driver.go | 19 +--- .../effects/human_identity_role_test.go | 45 +++++++-- 7 files changed, 164 insertions(+), 29 deletions(-) diff --git a/boatstack/cmd/boatstack-helper/delegation_command.go b/boatstack/cmd/boatstack-helper/delegation_command.go index cb5169b..8e7f188 100644 --- a/boatstack/cmd/boatstack-helper/delegation_command.go +++ b/boatstack/cmd/boatstack-helper/delegation_command.go @@ -80,6 +80,17 @@ func runFlowAuthorize(arguments []string) error { } return err } + resolveRequest, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + return err + } + programChange, err := preflightDelegatedProgramChange(context.Background(), resolveRequest) + if err != nil { + return err + } + if programChange != nil { + return fmt.Errorf("DELEGATION_PROGRAM_UNADMITTED: reconcile the exact candidate program before authorizing product delegation") + } if bound.delegationRequestFingerprint == "" || requestFingerprint != bound.delegationRequestFingerprint || bound.runID != options.runID { return fmt.Errorf("DELEGATION_REQUEST_MISMATCH: authorization does not match the exact current request") } diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index 31b6135..b89e8d1 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -383,8 +383,13 @@ func bindStandaloneTransientHumanIdentity(ctx context.Context, options commandOp } var presentation humanidentity.Presentation switch options.transitionID { - case "configuration.mutate", "configuration.reconcile": + case "configuration.mutate": presentation, err = humanIdentityPresentationForCurrentRepositoryDefault(ctx, repository, host, "transient-maintenance-human-authority") + case "configuration.reconcile": + // Reconciliation exists specifically because the authoritative + // configuration is not verified. Preserve the explicit actor rather than + // treating the candidate bytes being repaired as identity provenance. + return options, nil case "installation.update", "installation.reconcile-update": presentation, err = humanIdentityPresentationForCurrentProgramChange(ctx, repository, host, "transient-program-change-authority", options.transitionID) } @@ -410,8 +415,10 @@ func transientHumanIdentityPresentation(ctx context.Context, compiled controlpro // True bootstrap has no admitted role or verified default. The explicit // actor remains the only authority provenance at this boundary. return humanidentity.Presentation{}, nil - case "configuration.mutate", "configuration.reconcile": + case "configuration.mutate": return humanIdentityPresentationForRepositoryDefault(ctx, options.repository, host, "transient-human-authority", options.controlBundle.Source, nil) + case "configuration.reconcile": + return humanidentity.Presentation{}, nil case "installation.update", "installation.reconcile-update": return humanIdentityPresentationForProgramChange(ctx, options.repository, host, "transient-program-change-authority", compiled.Document.Program.ID, options.transitionID, options.controlBundle.Source) default: diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 332eb40..d017fb2 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -1617,6 +1617,99 @@ func TestRolelessStandardInstallationCanAdmitFirstRoleBoundFlow(t *testing.T) { } } +func TestDelegationAuthorizationRequiresAdmittedCandidateProgram(t *testing.T) { + // control-law: delegation bound to a candidate Flow cannot be authorized + // before installation reconciliation durably admits that candidate. + t.Setenv("BOATSTACK_STATE_ROOT", t.TempDir()) + runtimeHome := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, runtimeHome) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runtimeRaw, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if _, err := boatstackruntime.InstallExecutable(executable, runtimeHome, boatstackruntime.Identity{Version: buildinfo.Version, SHA256: hash(runtimeRaw), SourceRevision: buildRevision()}); err != nil { + t.Fatal(err) + } + repository := flowRepository(t) + document := productDeliveryDocument("product-delivery") + document.Entries[0].Delegation = &controlprogram.DelegationBinding{Reference: "software-delivery/delegation/autonomy", Version: "1"} + writeFlowArtifact(t, repository, document, ".boatstack/flows/product-delivery.flow.ts", []byte("flow source"), "package-lock.json", []byte("lock")) + runFlowGit(t, repository, "init", "-q") + runFlowGit(t, repository, "config", "user.email", "fixture@example.invalid") + runFlowGit(t, repository, "config", "user.name", "Fixture") + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "fixture") + output, err := captureRunOutput(t, + "init", "--repo", repository, + "--param", "config_path="+filepath.Join(repository, ".boatstack", "project.json"), "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("initialize Standard program: %v\n%s", err, output) + } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "commit initialized control bundle") + + candidate, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex", delegationRequestProjection: true}) + if err != nil { + t.Fatal(err) + } + authorize := func(bound commandOptions) error { + return runFlowAuthorize([]string{ + "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", bound.runID, + "--request-fingerprint", bound.delegationRequestFingerprint, + "--human-identity-provider-fingerprint", bound.delegationRequest.HumanIdentityProviderFingerprint, + "--human", "operator", "--host", "codex", + }) + } + if err := authorize(candidate); err == nil || !strings.Contains(err.Error(), "DELEGATION_PROGRAM_UNADMITTED") { + t.Fatalf("pre-admission delegation authorization = %v", err) + } + resolver, err := plant.NewResolver("") + if err != nil { + t.Fatal(err) + } + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "codex", "pre-admission-delegation") + if err != nil { + t.Fatal(err) + } + layout, _, err := resolver.ResolveLayout(context.Background(), invocation) + if err != nil { + t.Fatal(err) + } + recordPath, err := delegation.Path(layout.FlowRoot, candidate.runID) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(recordPath); !os.IsNotExist(err) { + t.Fatalf("pre-admission authorization created delegation record: %v", err) + } + + output, err = captureRunOutput(t, + "reconcile-update", "--repo", repository, "--flow", "product-delivery", "--entry", "run", "--run-id", candidate.runID, + "--accept-program-change", "--human", "operator", "--host", "codex", "--format", "json", + ) + if err != nil { + t.Fatalf("admit delegated Flow: %v\n%s", err, output) + } + runFlowGit(t, repository, "add", ".") + runFlowGit(t, repository, "commit", "-q", "-m", "commit admitted Flow bundle") + admitted, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", runID: candidate.runID, host: "codex", delegationRequestProjection: true}) + if err != nil { + t.Fatal(err) + } + if _, err := captureStdout(t, func() error { return authorize(admitted) }); err != nil { + t.Fatalf("post-admission delegation authorization: %v", err) + } + if _, err := delegation.Load(recordPath); err != nil { + t.Fatalf("post-admission authorization did not store delegation: %v", err) + } +} + func TestAcceptedProgramReconciliationReprojectsSameFlowRun(t *testing.T) { // control-law: an accepted program mutation is a hard reprojection boundary; // the next product resolution uses the new program and preserves the run. diff --git a/boatstack/cmd/boatstack-helper/human_identity_test.go b/boatstack/cmd/boatstack-helper/human_identity_test.go index 7c5a45c..284dcda 100644 --- a/boatstack/cmd/boatstack-helper/human_identity_test.go +++ b/boatstack/cmd/boatstack-helper/human_identity_test.go @@ -123,6 +123,12 @@ func TestHumanIdentityPresentationIsBoundToVerifiedConfiguration(t *testing.T) { if _, err := humanIdentityPresentationForRequest(request); err == nil || !strings.Contains(err.Error(), "HUMAN_IDENTITY_DRIFT") { t.Fatalf("changed configuration was not rejected: %v", err) } + recovery, err := bindStandaloneTransientHumanIdentity(context.Background(), commandOptions{ + repository: repository, host: "cli", transitionID: "configuration.reconcile", humanActor: "operator", + }) + if err != nil || recovery.humanIdentityRole != "" || recovery.humanIdentityProviderFingerprint != "" { + t.Fatalf("stale configuration reconciliation did not preserve explicit actor fallback: role=%q provider=%q err=%v", recovery.humanIdentityRole, recovery.humanIdentityProviderFingerprint, err) + } } func TestHumanIdentityIsAttachedOnlyToHumanAuthorityQuestions(t *testing.T) { diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index 6797a82..a764cd4 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -137,6 +137,14 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio if actual != expected { return nil, fmt.Errorf("configuration fingerprint mismatch: got %s", actual) } + if transition.ID == "configuration.mutate" { + // Role preservation is checked against the same hash-bound bytes used + // to construct the mutation below. No second source read can race this + // decision and installation. + if err := verifyCandidateConfigurationPreservesAdmittedRole(*state, config); err != nil { + return nil, err + } + } mutation, mutationErr := mutationFor(layout.ConfigPath, raw, 0o644, false, false) if mutationErr != nil { return nil, mutationErr diff --git a/boatstack/internal/softwaredelivery/effects/driver.go b/boatstack/internal/softwaredelivery/effects/driver.go index c86e557..9fe3441 100644 --- a/boatstack/internal/softwaredelivery/effects/driver.go +++ b/boatstack/internal/softwaredelivery/effects/driver.go @@ -117,9 +117,6 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if state.Revision != admission.ExpectedStateRevision { return nil, fmt.Errorf("durable state revision changed after admission") } - if err := verifyCandidateConfigurationPreservesAdmittedRole(state, admission, transition); err != nil { - return nil, err - } resultingRevision, err := durable.NextRevision(state.Revision) if err != nil { return nil, err @@ -361,22 +358,10 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans return prepared, nil } -func verifyCandidateConfigurationPreservesAdmittedRole(state durable.State, admission protocol.Admission, transition catalog.Transition) error { - if transition.ID != "configuration.mutate" || state.ProgramHumanIdentityRole == "" { +func verifyCandidateConfigurationPreservesAdmittedRole(state durable.State, config protocol.ProjectConfig) error { + if state.ProgramHumanIdentityRole == "" { return nil } - path, ok := admission.Parameters.Get("config_path") - if !ok { - return fmt.Errorf("PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND: configuration mutation is missing config_path") - } - raw, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read candidate configuration for admitted human identity: %w", err) - } - config, _, err := protocol.ProjectConfigFingerprint(raw) - if err != nil { - return err - } if _, ok := config.Identity.Roles[state.ProgramHumanIdentityRole]; !ok { return fmt.Errorf("PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND: candidate configuration removes admitted program role %q", state.ProgramHumanIdentityRole) } diff --git a/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go b/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go index a576554..8a18e2e 100644 --- a/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go +++ b/boatstack/internal/softwaredelivery/effects/human_identity_role_test.go @@ -8,34 +8,59 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" ) func TestConfigurationMutationPreservesAdmittedProgramRole(t *testing.T) { - write := func(name, identity string) string { + candidate := func(identity string) protocol.ProjectConfig { t.Helper() - path := filepath.Join(t.TempDir(), name+".json") raw := []byte(`{"schema_version":5,"identity":` + identity + `,"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) - if err := os.WriteFile(path, raw, 0o600); err != nil { + config, _, err := protocol.ProjectConfigFingerprint(raw) + if err != nil { t.Fatal(err) } - return path + return config } state := durable.State{ProgramHumanIdentityRole: "release-manager"} - transition := catalog.Transition{ID: "configuration.mutate"} for _, identity := range []string{ `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"},"release-manager":{"kind":"literal","value":"release-operator"}}}`, `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"},"release-manager":{"kind":"literal","value":"rotated-release-operator"}}}`, } { - path := write("allowed", identity) - admission := protocol.Admission{Parameters: protocol.Parameters{{Name: "config_path", Value: path}}} - if err := verifyCandidateConfigurationPreservesAdmittedRole(state, admission, transition); err != nil { + if err := verifyCandidateConfigurationPreservesAdmittedRole(state, candidate(identity)); err != nil { t.Fatalf("descriptor-preserving candidate rejected: %v", err) } } - removed := write("removed", `{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}}`) - err := verifyCandidateConfigurationPreservesAdmittedRole(state, protocol.Admission{Parameters: protocol.Parameters{{Name: "config_path", Value: removed}}}, transition) + removed := candidate(`{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}}`) + err := verifyCandidateConfigurationPreservesAdmittedRole(state, removed) if err == nil || !strings.Contains(err.Error(), "PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND") { t.Fatalf("removed role result = %v", err) } } + +func TestConfigurationMutationChecksRoleOnHashBoundInstalledBytes(t *testing.T) { + repository := t.TempDir() + source := filepath.Join(t.TempDir(), "candidate.json") + withRole := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"},"release-manager":{"kind":"literal","value":"release-operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + withoutRole := []byte(`{"schema_version":5,"identity":{"default":"developer","roles":{"developer":{"kind":"literal","value":"operator"}}},"project":{"name":"fixture","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"projections":[]}`) + if err := os.WriteFile(source, withRole, 0o600); err != nil { + t.Fatal(err) + } + _, expected, err := protocol.ProjectConfigFingerprint(withoutRole) + if err != nil { + t.Fatal(err) + } + // Reproduce the old split-read race: the role-preserving bytes are visible + // first, then the admitted hash-bound bytes replace them before artifact + // preparation. The single-snapshot implementation must reject the latter. + if err := os.WriteFile(source, withoutRole, 0o600); err != nil { + t.Fatal(err) + } + state := durable.State{ProgramHumanIdentityRole: "release-manager"} + mutations, err := prepareArtifacts(ports.ControllerLayout{RepositoryRoot: repository, ConfigPath: filepath.Join(repository, ".boatstack", "project.json")}, protocol.Admission{ + Parameters: protocol.Parameters{{Name: "config_path", Value: source}, {Name: "config_sha256", Value: expected}}, + }, catalog.Transition{ID: "configuration.mutate"}, &state) + if err == nil || !strings.Contains(err.Error(), "PROJECT_CONFIG_ADMITTED_HUMAN_IDENTITY_UNBOUND") || len(mutations) != 0 { + t.Fatalf("hash-bound role-removing candidate = mutations=%#v err=%v", mutations, err) + } +}