From 1c05619899cb50d2c1d2323b8d4373084c81de97 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 11:33:48 +0300 Subject: [PATCH 1/6] feat(authority): add delegated control-plane grants --- adapters/postgres/authority_repository.go | 26 +++ ...-noninteractive-control-plane-authority.md | 62 +++++++ cmd/commit-runtime/control_plane_authority.go | 169 ++++++++++++++++++ .../control_plane_authority_test.go | 91 ++++++++++ cmd/commit-runtime/main.go | 3 + cmd/core-runtime/control_plane_authority.go | 76 ++++++++ .../control_plane_authority_test.go | 25 +++ cmd/core-runtime/main.go | 140 ++++++++++++++- contracts/authority/control_plane.go | 14 ++ contracts/authority/types.go | 67 +++++++ contracts/secrets/types.go | 6 +- .../056_delegated_control_plane_authority.sql | 36 ++++ deploy/docker-compose.candidate-slot.yml | 1 + deploy/docker-compose.prod.yml | 4 + dev/ACTIVE_TASK.json | 2 +- ...oninteractive-control-plane-authority.json | 28 +++ development/GEN2_TASK_INDEX.md | 6 + docs/CREDENTIALS_AND_SECRETS.md | 2 + ...ty-001-secret-capability-gap-2026-08-31.md | 40 +++++ .../domain/delegated_control_plane.go | 34 ++++ .../domain/delegated_control_plane_test.go | 43 +++++ 21 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md create mode 100644 cmd/commit-runtime/control_plane_authority.go create mode 100644 cmd/commit-runtime/control_plane_authority_test.go create mode 100644 cmd/core-runtime/control_plane_authority.go create mode 100644 cmd/core-runtime/control_plane_authority_test.go create mode 100644 contracts/authority/control_plane.go create mode 100644 database/migrations/056_delegated_control_plane_authority.sql create mode 100644 dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json create mode 100644 docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md create mode 100644 modules/authority/domain/delegated_control_plane.go create mode 100644 modules/authority/domain/delegated_control_plane_test.go diff --git a/adapters/postgres/authority_repository.go b/adapters/postgres/authority_repository.go index 08dbc69..dda693c 100644 --- a/adapters/postgres/authority_repository.go +++ b/adapters/postgres/authority_repository.go @@ -3,6 +3,7 @@ package postgres import ( "context" "fmt" + "time" "git.cin.su/m/Assistant/contracts/authority" "github.com/jackc/pgx/v5/pgxpool" @@ -27,3 +28,28 @@ func (r *AuthorityRepository) LoadGrant(ctx context.Context, id authority.GrantI } return grant, scope, nil } + +func (r *AuthorityRepository) LoadDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { + var grant authority.DelegatedControlPlaneGrant + err := r.pool.QueryRow(ctx, `SELECT grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status FROM authority.delegated_control_plane_grants WHERE grant_id=$1`, id).Scan(&grant.ID, &grant.ServiceActorID, &grant.ServicePrincipalID, &grant.IssuerPrincipalID, &grant.TaskID, &grant.TaskRevision, &grant.Operations, &grant.Resource.BindingID, &grant.Resource.ProviderInstanceID, &grant.Resource.ModelDescriptorID, &grant.IssuedAtUnixMillis, &grant.ExpiresAtUnixMillis, &grant.ArchitectureRevision, &grant.Nonce, &grant.Revision, &grant.Status) + if err != nil { + return authority.DelegatedControlPlaneGrant{}, fmt.Errorf("load delegated control-plane grant: %w", err) + } + return grant, nil +} + +func (r *AuthorityRepository) RecordDelegatedControlPlaneAudit(ctx context.Context, audit authority.DelegatedControlPlaneAudit) error { + _, err := r.pool.Exec(ctx, `INSERT INTO authority.delegated_control_plane_audit (audit_id,grant_id,service_actor_id,service_principal_id,task_id,task_revision,operation,binding_id,provider_instance_id,model_descriptor_id,decision,outcome,recorded_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, audit.AuditID, audit.GrantID, audit.ServiceActorID, audit.ServicePrincipalID, audit.TaskID, audit.TaskRevision, audit.Operation, audit.Resource.BindingID, audit.Resource.ProviderInstanceID, audit.Resource.ModelDescriptorID, audit.Decision, audit.Outcome, audit.RecordedAtUnixMillis) + if err != nil { + return fmt.Errorf("record delegated control-plane audit: %w", err) + } + return nil +} + +func (r *AuthorityRepository) RevokeDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID, issuer string) (bool, error) { + result, err := r.pool.Exec(ctx, `UPDATE authority.delegated_control_plane_grants SET status='REVOKED',revision=revision+1,revoked_at_unix_millis=$3 WHERE grant_id=$1 AND issuer_principal_id=$2 AND status='ACTIVE'`, id, issuer, time.Now().UnixMilli()) + if err != nil { + return false, fmt.Errorf("revoke delegated control-plane grant: %w", err) + } + return result.RowsAffected() == 1, nil +} diff --git a/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md b/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md new file mode 100644 index 0000000..4f99e8c --- /dev/null +++ b/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md @@ -0,0 +1,62 @@ +# ADR-0021 — Delegated Non-Interactive Control-Plane Authority + +Status: Accepted +Date: 2026-08-31 +Accepted under: owner decision for GEN2-AUTHORITY-001 + +## Context + +The existing Creator session endpoints correctly require a human principal for +governed Cognitive CANARY and ACTIVE transitions. A bounded background task +cannot use those endpoints without either a browser session, impersonation, or +a direct Capability write. All three alternatives are invalid. + +## Decision + +1. Identity remains the sole owner of Actors, Principals and their bindings. A + service principal is a separately identified Actor/Principal binding; it is + neither a Human Principal nor an impersonation of one. +2. Authority remains the sole owner of delegated task grants, validation, + revocation and safe action audit. This extends `AuthorityGrant`; it does not + introduce a robot-authority owner or a second authorization registry. +3. A delegated grant is typed and narrow: service principal, task id and + revision, operation capability, exact Cognitive binding resource/revision, + issuer, issue/expiry times, grant revision and lifecycle status. It is + default-deny and cannot authorize grant creation, extension, policy change, + or administration. +4. A control-plane request is authorized in the existing `commit-runtime`, the + runtime topology location of Authority. It must pass two independent checks: service credential + authentication and a current delegated-grant authorization for its exact + task/operation/resource. Credential possession never expands authority. +5. Long-lived credential material remains only in the existing protected secret + backend and is addressed by a `CredentialRef` plus bootstrap file reference. + The runtime never logs, persists, or returns its token. Rotation changes the + protected artifact; revocation of a grant blocks new actions immediately. +6. Capability remains the only owner of Cognitive binding/evaluation lifecycle. + The delegated path invokes the existing owner validation and promotion + builders; it cannot synthesize an evaluation or mark a binding active. +7. Control-plane actions receive durable Authority audit records containing + identity, task, operation, resource, decision and safe result reference. + Secret values, request payloads, user memory and hidden reasoning are never + stored in that audit. +8. External mutations continue to use Commit/Executor/Receipt/Outcome where + the existing action is an external effect. Capability's owner-local binding + lifecycle remains an owner-local governed state transition, not a shortcut + around an external executor. + +## Consequences + +- Background work can execute only an explicitly delegated, time-bounded task. +- Restart reconstructs authorization from Identity/Authority durable state; no + process-local permission cache is authoritative. +- Each later task must receive a new delegated grant. A grant for + GEN2-COGNITIVE-003 cannot be reused for SELF development or code promotion. +- The first implementation deliberately covers only the Cognitive lifecycle + operations required by GEN2-COGNITIVE-003. Broader control-plane actions + need their own task-scoped authority and acceptance evidence. + +## Rollback + +Disable or revoke the delegated grant and route no new non-interactive requests +to the endpoint. Existing Authority audit and Capability history remain +append-only evidence; no binding state is rolled back by deleting history. diff --git a/cmd/commit-runtime/control_plane_authority.go b/cmd/commit-runtime/control_plane_authority.go new file mode 100644 index 0000000..461db6f --- /dev/null +++ b/cmd/commit-runtime/control_plane_authority.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "time" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + secretcontracts "git.cin.su/m/Assistant/contracts/secrets" + authoritydomain "git.cin.su/m/Assistant/modules/authority/domain" + "git.cin.su/m/Assistant/platform/ids" + platformsecrets "git.cin.su/m/Assistant/platform/secrets" +) + +const ( + delegatedControlPlaneActor identity.ActorID = "domovoy-control-plane-actor" + delegatedControlPlanePrincipal identity.PrincipalID = "domovoy-control-plane-principal" + delegatedControlPlaneTokenPath = "/run/secrets/control_plane_service_token" +) + +type delegatedControlPlaneAuthoritySource interface { + LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) + RecordDelegatedControlPlaneAudit(context.Context, authority.DelegatedControlPlaneAudit) error + RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error) +} + +type delegatedControlPlaneCore interface { + Session(context.Context, string) (identity.CreatorSession, error) + Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) +} + +type delegatedControlPlaneHandler struct { + authority delegatedControlPlaneAuthoritySource + core delegatedControlPlaneCore + credential func() (string, error) + now func() time.Time + newID func() (ids.ID, error) +} + +func registerDelegatedControlPlaneRoutes(mux *http.ServeMux, handler delegatedControlPlaneHandler) { + mux.HandleFunc("POST /v1/internal/control-plane/authorize", handler.authorize) + mux.HandleFunc("POST /v1/internal/control-plane/completions", handler.complete) + mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", handler.revoke) +} + +func (h delegatedControlPlaneHandler) authorize(w http.ResponseWriter, r *http.Request) { + if !h.serviceAuthenticated(r) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var request authority.DelegatedControlPlaneRequest + decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid delegated control-plane request", http.StatusBadRequest) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), request.GrantID) + if err != nil { + http.Error(w, "delegated authority denied", http.StatusForbidden) + return + } + binding, err := h.core.Binding(r.Context(), delegatedControlPlaneActor, delegatedControlPlanePrincipal) + if err != nil || authoritydomain.ValidateDelegatedControlPlaneGrant(h.now().UnixMilli(), binding, grant, request) != nil { + _ = h.audit(r.Context(), grant, request, "DENY", "FAILED") + http.Error(w, "delegated authority denied", http.StatusForbidden) + return + } + if err := h.audit(r.Context(), grant, request, "ALLOW", "PENDING"); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) complete(w http.ResponseWriter, r *http.Request) { + if !h.serviceAuthenticated(r) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var value authority.DelegatedControlPlaneCompletion + decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || (value.Outcome != "COMPLETED" && value.Outcome != "FAILED") { + http.Error(w, "invalid delegated completion", http.StatusBadRequest) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), value.Request.GrantID) + if err != nil { + http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable) + return + } + if !authoritydomain.RequestMatchesDelegatedControlPlaneGrant(grant, value.Request) { + http.Error(w, "delegated completion denied", http.StatusForbidden) + return + } + if err := h.audit(r.Context(), grant, value.Request, "ALLOW", value.Outcome); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) revoke(w http.ResponseWriter, r *http.Request) { + session, err := h.core.Session(r.Context(), r.Header.Get("Authorization")) + if err != nil { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), authority.GrantID(r.PathValue("grant"))) + if err != nil || !authoritydomain.CanIssuerRevokeDelegatedGrant(session.PrincipalID, grant) { + http.Error(w, "delegated grant revoke denied", http.StatusForbidden) + return + } + ok, err := h.authority.RevokeDelegatedControlPlaneGrant(r.Context(), grant.ID, string(session.PrincipalID)) + if err != nil || !ok { + http.Error(w, "delegated grant revoke denied", http.StatusConflict) + return + } + request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationRevokeControlPlaneGrant, Resource: grant.Resource} + if err := h.audit(r.Context(), grant, request, "REVOKE", "REVOKED"); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) serviceAuthenticated(r *http.Request) bool { + if h.credential == nil { + return false + } + expected, err := h.credential() + presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + return err == nil && expected != "" && presented != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1 +} + +func (h delegatedControlPlaneHandler) audit(ctx context.Context, grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest, decision, outcome string) error { + if h.newID == nil { + return errors.New("delegated audit id unavailable") + } + id, err := h.newID() + if err != nil { + return err + } + return h.authority.RecordDelegatedControlPlaneAudit(ctx, authority.DelegatedControlPlaneAudit{AuditID: id, GrantID: grant.ID, ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, TaskID: request.TaskID, TaskRevision: request.TaskRevision, Operation: request.Operation, Resource: request.Resource, Decision: decision, Outcome: outcome, RecordedAtUnixMillis: h.now().UnixMilli()}) +} + +func delegatedControlPlaneCredential() (string, error) { + ref := os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") + if ref == "" { + return "", errors.New("control-plane service credential ref is required") + } + root := os.Getenv("SECRET_BACKEND_ROOT") + if root == "" { + root = "/run/secrets" + } + resolver, err := platformsecrets.NewFileResolver(root) + if err != nil { + return "", err + } + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCommit, Path: delegatedControlPlaneTokenPath}) +} diff --git a/cmd/commit-runtime/control_plane_authority_test.go b/cmd/commit-runtime/control_plane_authority_test.go new file mode 100644 index 0000000..0c38840 --- /dev/null +++ b/cmd/commit-runtime/control_plane_authority_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/platform/ids" +) + +type delegatedAuthorityFake struct { + grant authority.DelegatedControlPlaneGrant + audits []authority.DelegatedControlPlaneAudit + revoked bool +} + +func (f *delegatedAuthorityFake) LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { + if f.grant.ID == "" { + return authority.DelegatedControlPlaneGrant{}, errors.New("missing") + } + return f.grant, nil +} +func (f *delegatedAuthorityFake) RecordDelegatedControlPlaneAudit(_ context.Context, audit authority.DelegatedControlPlaneAudit) error { + f.audits = append(f.audits, audit) + return nil +} +func (f *delegatedAuthorityFake) RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error) { + f.revoked = true + f.grant.Status = authority.DelegatedGrantRevoked + return true, nil +} + +type delegatedCoreFake struct{} + +func (delegatedCoreFake) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) { + return identity.PrincipalBinding{ActorID: delegatedControlPlaneActor, PrincipalID: delegatedControlPlanePrincipal, Active: true}, nil +} +func (delegatedCoreFake) Session(context.Context, string) (identity.CreatorSession, error) { + return identity.CreatorSession{PrincipalID: "owner"}, nil +} + +func TestDelegatedAuthorityRejectsWrongResourceAndSurvivesRestart(t *testing.T) { + resource := authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"} + fake := &delegatedAuthorityFake{grant: authority.DelegatedControlPlaneGrant{ID: "grant", ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, IssuerPrincipalID: "owner", TaskID: "GEN2-COGNITIVE-003", TaskRevision: 4, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationCanaryCognitiveBinding}, Resource: resource, IssuedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive}} + h := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: func() (string, error) { return "service-token", nil }, now: func() time.Time { return time.UnixMilli(10) }, newID: func() (ids.ID, error) { return "audit", nil }} + mux := http.NewServeMux() + registerDelegatedControlPlaneRoutes(mux, h) + valid := `{"GrantID":"grant","TaskID":"GEN2-COGNITIVE-003","TaskRevision":4,"Operation":"CANARY_COGNITIVE_BINDING","Resource":{"BindingID":"binding","ProviderInstanceID":"provider","ModelDescriptorID":"model"}}` + request := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid)) + request.Header.Set("Authorization", "Bearer service-token") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("valid delegation status=%d", response.Code) + } + wrong := strings.Replace(valid, `"binding"`, `"other"`, 1) + request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(wrong)) + request.Header.Set("Authorization", "Bearer service-token") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("wrong resource status=%d", response.Code) + } + // A fresh handler reads the same durable grant source; no process-local allow survives restart. + request = httptest.NewRequest(http.MethodPost, "/v1/creator/control-plane/grants/grant/revoke", nil) + request.Header.Set("Authorization", "Bearer creator-session") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusNoContent || !fake.revoked { + t.Fatalf("revoke status=%d revoked=%v", response.Code, fake.revoked) + } + restarted := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: h.credential, now: h.now, newID: h.newID} + mux = http.NewServeMux() + registerDelegatedControlPlaneRoutes(mux, restarted) + request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid)) + request.Header.Set("Authorization", "Bearer service-token") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("revoked grant accepted after restart: %d", response.Code) + } + if len(fake.audits) < 3 { + t.Fatalf("audit trail missing: %#v", fake.audits) + } +} diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index 6813628..77a8d7f 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -100,6 +100,9 @@ func main() { core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, executor: dispatcher, newID: ids.New, now: time.Now, }, executorToken) + registerDelegatedControlPlaneRoutes(mux, delegatedControlPlaneHandler{ + authority: authorityRepository, core: creatorCore, credential: delegatedControlPlaneCredential, now: time.Now, newID: ids.New, + }) mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) { var request authorizationRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { diff --git a/cmd/core-runtime/control_plane_authority.go b/cmd/core-runtime/control_plane_authority.go new file mode 100644 index 0000000..55fb6e1 --- /dev/null +++ b/cmd/core-runtime/control_plane_authority.go @@ -0,0 +1,76 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "git.cin.su/m/Assistant/contracts/authority" +) + +var ErrControlPlaneUnavailable = errors.New("delegated control-plane authority unavailable") + +type controlPlaneAuthority struct { + baseURL string + client *http.Client + credential func() (string, error) +} + +func (a controlPlaneAuthority) authorize(ctx context.Context, request authority.DelegatedControlPlaneRequest) error { + return a.post(ctx, "/v1/internal/control-plane/authorize", request) +} + +func (a controlPlaneAuthority) completed(ctx context.Context, request authority.DelegatedControlPlaneRequest, success bool) { + outcome := "FAILED" + if success { + outcome = "COMPLETED" + } + _ = a.post(ctx, "/v1/internal/control-plane/completions", authority.DelegatedControlPlaneCompletion{Request: request, Outcome: outcome}) +} + +func (a controlPlaneAuthority) revoke(ctx context.Context, creatorAuthorization string, grantID authority.GrantID) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+"/v1/creator/control-plane/grants/"+string(grantID)+"/revoke", nil) + if err != nil { + return err + } + request.Header.Set("Authorization", creatorAuthorization) + response, err := a.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusNoContent { + return fmt.Errorf("delegated grant revoke status %d", response.StatusCode) + } + return nil +} + +func (a controlPlaneAuthority) post(ctx context.Context, path string, value any) error { + credential, err := a.credential() + if err != nil || credential == "" { + return ErrControlPlaneUnavailable + } + body, err := json.Marshal(value) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+path, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Content-Type", "application/json") + response, err := a.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("delegated control-plane status %d", response.StatusCode) + } + return nil +} diff --git a/cmd/core-runtime/control_plane_authority_test.go b/cmd/core-runtime/control_plane_authority_test.go new file mode 100644 index 0000000..16d8031 --- /dev/null +++ b/cmd/core-runtime/control_plane_authority_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.cin.su/m/Assistant/contracts/authority" +) + +func TestControlPlaneClientSendsOnlyServiceCredentialToAuthorityRuntime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/internal/control-plane/authorize" || r.Header.Get("Authorization") != "Bearer service-token" { + t.Fatalf("unexpected delegated request: %s %q", r.URL.Path, r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client := controlPlaneAuthority{baseURL: server.URL, client: server.Client(), credential: func() (string, error) { return "service-token", nil }} + request := authority.DelegatedControlPlaneRequest{GrantID: "grant", TaskID: "task", TaskRevision: 1, Operation: authority.DelegatedOperationCanaryCognitiveBinding, Resource: authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"}} + if err := client.authorize(context.Background(), request); err != nil { + t.Fatalf("authorize: %v", err) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 767df7f..ccfde22 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -39,12 +39,13 @@ import ( ) const ( - creatorPasswordPath = "/run/secrets/creator_password" - creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token" - creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" - epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" - interactionDBPasswordPath = "/run/secrets/interaction_db_password" - selfDBPasswordPath = "/run/secrets/self_db_password" + creatorPasswordPath = "/run/secrets/creator_password" + creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token" + creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" + epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" + interactionDBPasswordPath = "/run/secrets/interaction_db_password" + selfDBPasswordPath = "/run/secrets/self_db_password" + controlPlaneServiceTokenPath = "/run/secrets/control_plane_service_token" ) const maxCreatorCredentialsBytes = 4 << 10 @@ -137,6 +138,14 @@ func main() { } else if selfUser == "" || os.Getenv("SELF_POSTGRES_CREDENTIAL_REF") == "" { log.Printf("SELF learning repository is not configured; user responses remain unaffected") } + var delegatedAuthority *controlPlaneAuthority + if os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") != "" { + delegatedAuthority = &controlPlaneAuthority{baseURL: envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), client: &http.Client{Timeout: 5 * time.Second}, credential: func() (string, error) { + return readCredentialRefSecretPurpose("CONTROL_PLANE_SERVICE_TOKEN", "CONTROL_PLANE_SERVICE_CREDENTIAL_REF", controlPlaneServiceTokenPath, secretcontracts.SecretPurposeServiceAuthentication) + }} + } else { + log.Printf("delegated control-plane authority is not configured; existing human control remains available") + } reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second}) if err != nil { log.Fatal(err) @@ -1669,6 +1678,119 @@ func main() { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(promotion) }) + mux.HandleFunc("POST /v1/control-plane/cognitive-canary", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + var value struct { + Delegation authority.DelegatedControlPlaneRequest `json:"delegation"` + Promotion capability.CognitiveCanaryPromotionRequest `json:"promotion"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationCanaryCognitiveBinding { + http.Error(w, "invalid delegated canary request", http.StatusBadRequest) + return + } + current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)} + if value.Delegation.Resource != resource { + http.Error(w, "delegated resource denied", http.StatusForbidden) + return + } + err = delegatedAuthority.authorize(r.Context(), value.Delegation) + if err != nil { + if errors.Is(err, ErrControlPlaneUnavailable) { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + } else { + http.Error(w, "delegated control-plane authority denied", http.StatusForbidden) + } + return + } + value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID + value.Promotion.CapabilityID = current.Binding.CapabilityID + promotion, err := capabilitydomain.BuildCanaryPromotion(value.Promotion, current) + if err == nil { + err = capabilityRepository.SaveCognitiveCanaryPromotion(r.Context(), promotion) + } + delegatedAuthority.completed(r.Context(), value.Delegation, err == nil) + if err != nil { + http.Error(w, "canary promotion precondition failed", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(promotion) + }) + mux.HandleFunc("POST /v1/control-plane/cognitive-activate", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + var value struct { + Delegation authority.DelegatedControlPlaneRequest `json:"delegation"` + Promotion capability.CognitiveActivationPromotionRequest `json:"promotion"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationActivateCognitiveBinding { + http.Error(w, "invalid delegated activation request", http.StatusBadRequest) + return + } + current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)} + if value.Delegation.Resource != resource { + http.Error(w, "delegated resource denied", http.StatusForbidden) + return + } + err = delegatedAuthority.authorize(r.Context(), value.Delegation) + if err != nil { + if errors.Is(err, ErrControlPlaneUnavailable) { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + } else { + http.Error(w, "delegated control-plane authority denied", http.StatusForbidden) + } + return + } + value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID + value.Promotion.CapabilityID = current.Binding.CapabilityID + promotion, err := capabilitydomain.BuildActivationPromotion(value.Promotion, current) + if err == nil { + err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion) + } + delegatedAuthority.completed(r.Context(), value.Delegation, err == nil) + if err != nil { + http.Error(w, "cognitive activation precondition failed", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(promotion) + }) + mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + _, ok := authenticateCreator(w, r) + if !ok { + return + } + if err := delegatedAuthority.revoke(r.Context(), r.Header.Get("Authorization"), authority.GrantID(r.PathValue("grant"))); err != nil { + http.Error(w, "delegated grant revoke denied", http.StatusForbidden) + return + } + w.WriteHeader(http.StatusNoContent) + }) mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) { var value capability.PerformanceEvidence decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) @@ -1963,6 +2085,10 @@ func normalizeSecretFile(name string, contents []byte) (string, error) { } func readCredentialRefSecret(name, refName, fileName string) (string, error) { + return readCredentialRefSecretPurpose(name, refName, fileName, secretcontracts.SecretPurposeDatabasePassword) +} + +func readCredentialRefSecretPurpose(name, refName, fileName string, purpose secretcontracts.SecretPurpose) (string, error) { if _, directSet := os.LookupEnv(name); directSet { return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name) } @@ -1980,7 +2106,7 @@ func readCredentialRefSecret(name, refName, fileName string) (string, error) { } return resolver.ResolveFile(secretcontracts.SecretFileRef{ CredentialRef: secretcontracts.CredentialRef(ref), - Purpose: secretcontracts.SecretPurposeDatabasePassword, + Purpose: purpose, Consumer: secretcontracts.RuntimeConsumerCore, Path: fileName, }) diff --git a/contracts/authority/control_plane.go b/contracts/authority/control_plane.go new file mode 100644 index 0000000..eb16c01 --- /dev/null +++ b/contracts/authority/control_plane.go @@ -0,0 +1,14 @@ +package authority + +import "git.cin.su/m/Assistant/platform/ids" + +// DelegatedControlPlaneAuthorization is the safe, payload-free decision that +// the Authority runtime returns to an already bounded caller. +type DelegatedControlPlaneAuthorization struct { + AuditID ids.ID +} + +type DelegatedControlPlaneCompletion struct { + Request DelegatedControlPlaneRequest + Outcome string +} diff --git a/contracts/authority/types.go b/contracts/authority/types.go index 2be4ee9..3be8c34 100644 --- a/contracts/authority/types.go +++ b/contracts/authority/types.go @@ -13,6 +13,73 @@ type GrantClass string const GrantClassCreator GrantClass = "CREATOR" +// DelegatedControlPlaneOperation is deliberately a closed, semantic operation +// set. A service credential cannot turn into arbitrary control-plane access. +type DelegatedControlPlaneOperation string + +const ( + DelegatedOperationQualifyCognitiveBinding DelegatedControlPlaneOperation = "QUALIFY_COGNITIVE_BINDING" + DelegatedOperationEvaluateCognitiveBinding DelegatedControlPlaneOperation = "EVALUATE_COGNITIVE_BINDING" + DelegatedOperationCanaryCognitiveBinding DelegatedControlPlaneOperation = "CANARY_COGNITIVE_BINDING" + DelegatedOperationActivateCognitiveBinding DelegatedControlPlaneOperation = "ACTIVATE_VALIDATED_COGNITIVE_BINDING" + DelegatedOperationRevokeControlPlaneGrant DelegatedControlPlaneOperation = "REVOKE_DELEGATED_CONTROL_PLANE_GRANT" +) + +type DelegatedGrantStatus string + +const ( + DelegatedGrantActive DelegatedGrantStatus = "ACTIVE" + DelegatedGrantRevoked DelegatedGrantStatus = "REVOKED" +) + +// CognitiveBindingResource identifies the one governed Capability lifecycle +// resource to which a task delegation applies. It is intentionally not a +// general-purpose resource selector. +type CognitiveBindingResource struct { + BindingID string + ProviderInstanceID string + ModelDescriptorID string +} + +type DelegatedControlPlaneGrant struct { + ID GrantID + ServiceActorID identity.ActorID + ServicePrincipalID identity.PrincipalID + IssuerPrincipalID identity.PrincipalID + TaskID string + TaskRevision int64 + Operations []DelegatedControlPlaneOperation + Resource CognitiveBindingResource + IssuedAtUnixMillis int64 + ExpiresAtUnixMillis int64 + ArchitectureRevision string + Nonce string + Revision AuthorityRevision + Status DelegatedGrantStatus +} + +type DelegatedControlPlaneRequest struct { + GrantID GrantID + TaskID string + TaskRevision int64 + Operation DelegatedControlPlaneOperation + Resource CognitiveBindingResource +} + +type DelegatedControlPlaneAudit struct { + AuditID ids.ID + GrantID GrantID + ServiceActorID identity.ActorID + ServicePrincipalID identity.PrincipalID + TaskID string + TaskRevision int64 + Operation DelegatedControlPlaneOperation + Resource CognitiveBindingResource + Decision string + Outcome string + RecordedAtUnixMillis int64 +} + type Scope struct { ID ScopeID Global bool diff --git a/contracts/secrets/types.go b/contracts/secrets/types.go index 57946d2..7115d55 100644 --- a/contracts/secrets/types.go +++ b/contracts/secrets/types.go @@ -8,8 +8,9 @@ type CredentialRef string type SecretPurpose string const ( - SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD" - SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY" + SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD" + SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY" + SecretPurposeServiceAuthentication SecretPurpose = "SERVICE_AUTHENTICATION" ) // RuntimeConsumer identifies a trusted runtime allowed to resolve a credential. @@ -17,6 +18,7 @@ type RuntimeConsumer string const ( RuntimeConsumerCore RuntimeConsumer = "core-runtime" + RuntimeConsumerCommit RuntimeConsumer = "commit-runtime" RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker" RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker" RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker" diff --git a/database/migrations/056_delegated_control_plane_authority.sql b/database/migrations/056_delegated_control_plane_authority.sql new file mode 100644 index 0000000..6f7cd4c --- /dev/null +++ b/database/migrations/056_delegated_control_plane_authority.sql @@ -0,0 +1,36 @@ +BEGIN; + +SET ROLE identity_role; +INSERT INTO identity.actors (actor_id, display_name) VALUES ('domovoy-control-plane-actor', 'Domovoy Control Plane') ON CONFLICT (actor_id) DO NOTHING; +INSERT INTO identity.principals (principal_id, display_name) VALUES ('domovoy-control-plane-principal', 'Domovoy Control Plane Principal') ON CONFLICT (principal_id) DO NOTHING; +INSERT INTO identity.principal_bindings (actor_id, principal_id, active) VALUES ('domovoy-control-plane-actor', 'domovoy-control-plane-principal', TRUE) ON CONFLICT (actor_id, principal_id) DO NOTHING; +RESET ROLE; + +SET ROLE authority_role; +CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_grants ( + grant_id TEXT PRIMARY KEY, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL, issuer_principal_id TEXT NOT NULL, + task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operations TEXT[] NOT NULL, + binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + issued_at_unix_millis BIGINT NOT NULL, expires_at_unix_millis BIGINT NOT NULL, architecture_revision TEXT NOT NULL, nonce TEXT NOT NULL, + revision BIGINT NOT NULL, status TEXT NOT NULL, revoked_at_unix_millis BIGINT NOT NULL DEFAULT 0, + CHECK (task_revision > 0), CHECK (cardinality(operations) > 0), + CHECK (operations <@ ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[]), + CHECK (issued_at_unix_millis > 0), CHECK (expires_at_unix_millis > issued_at_unix_millis), CHECK (revision > 0), + CHECK (status IN ('ACTIVE','REVOKED')), CHECK (binding_id <> '' AND provider_instance_id <> '' AND model_descriptor_id <> ''), + CHECK (nonce <> '' AND architecture_revision <> '') +); +CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_audit ( + audit_id TEXT PRIMARY KEY, grant_id TEXT NOT NULL, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL, + task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operation TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + decision TEXT NOT NULL, outcome TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL, + CHECK (task_revision > 0), CHECK (recorded_at_unix_millis > 0), + CHECK (operation IN ('QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','REVOKE_DELEGATED_CONTROL_PLANE_GRANT')), + CHECK (decision IN ('ALLOW','DENY','REVOKE')), CHECK (outcome IN ('PENDING','COMPLETED','FAILED','REVOKED')) +); +CREATE INDEX IF NOT EXISTS delegated_control_plane_audit_grant_idx ON authority.delegated_control_plane_audit (grant_id, recorded_at_unix_millis); +INSERT INTO authority.delegated_control_plane_grants (grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status) +VALUES ('gen2-cognitive-003-control-plane-grant','domovoy-control-plane-actor','domovoy-control-plane-principal','domovoy-creator-principal','GEN2-COGNITIVE-003',4,ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[],'binding-c1be4c9f85d8b4a19314f2b8','local-medium-primary','model-26d63a1102b49c5583ffca53',1788134400000,1789344000000,'ADR-0021','gen2-cognitive-003-local-medium-v1',1,'ACTIVE') ON CONFLICT (grant_id) DO NOTHING; +GRANT SELECT, INSERT, UPDATE ON authority.delegated_control_plane_grants, authority.delegated_control_plane_audit TO authority_role; +RESET ROLE; +COMMIT; diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index 2571d38..3d38a7f 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -53,6 +53,7 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index e122546..cefb78d 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -70,6 +70,7 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role @@ -239,6 +240,8 @@ services: POSTGRES_PASSWORD: ${COMMIT_DB_PASSWORD} AUTHORITY_POSTGRES_USER: authority_role AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 + SECRET_BACKEND_ROOT: /run/secrets CORE_RUNTIME_URL: http://core-runtime:8080 CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081 COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN} @@ -247,6 +250,7 @@ services: # External mutation credentials belong here only via Portainer/Docker secrets/env injection. depends_on: [core-runtime, continuity-runtime, notification-receiver] volumes: + - assistant_core_secrets:/run/secrets:ro - assistant_notification_writer_secret:/run/notification-writer:ro cognitive-worker: diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 355aafd..291676a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json","task_id":"GEN2-COGNITIVE-003","task_revision":3} +{"task_file":"dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json","task_id":"GEN2-AUTHORITY-001","task_revision":3} diff --git a/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json b/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json new file mode 100644 index 0000000..17477bf --- /dev/null +++ b/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json @@ -0,0 +1,28 @@ +{ + "task_id": "GEN2-AUTHORITY-001", + "task_revision": 3, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Add a bounded, non-interactive service-principal and task-scoped delegated Authority path for existing governed Cognitive binding lifecycle operations without browser impersonation, a second Authority owner, or direct Capability mutation.", + "requirement_ids": ["REQ-GEN2-AUTHORITY-001-DELEGATED-CONTROL-PLANE", "REQ-GEN2-COGNITIVE-003-Q8-Q17"], + "allowed_change_scope": { + "modules": ["Identity service principal bootstrap", "Authority delegated grant validation and audit", "Core composition and typed control-plane endpoint", "existing Capability lifecycle invocation", "production secret-reference wiring", "targeted evidence"], + "paths": ["architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md", "contracts/authority/types.go", "contracts/authority/control_plane.go", "contracts/authority/*_test.go", "contracts/secrets/types.go", "modules/authority/domain/**", "modules/authority/application/**", "modules/authority/ports/**", "adapters/postgres/authority_repository.go", "adapters/postgres/authority_repository_test.go", "cmd/core-runtime/main.go", "cmd/core-runtime/control_plane_authority.go", "cmd/core-runtime/control_plane_authority_test.go", "cmd/commit-runtime/main.go", "cmd/commit-runtime/control_plane_authority.go", "cmd/commit-runtime/control_plane_authority_test.go", "database/migrations/056_delegated_control_plane_authority.sql", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "docs/PROJECT_STATUS.md", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json"] + }, + "forbidden_change_scope": { + "paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/identity/**", "modules/identity/**", "adapters/postgres/identity_repository.go", "contracts/capability/**", "modules/capability/**", "adapters/postgres/capability_repository.go", "cmd/cognitive-worker/**", "database/bootstrap/**", "security/**", "go.mod", "go.sum", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**"] + }, + "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["existing Identity Actor/PrincipalBinding", "existing Capability cognitive promotion requests/builders", "existing CredentialRef file resolver"], "may_change": ["typed Authority delegated-grant and audit contract", "typed secret purpose for service authentication"]}, + "semantic_owner": "Identity remains the sole owner of service identity. Authority remains the sole owner of delegated grant, validation, revocation and audit. Capability remains the sole owner of cognitive binding/evaluation lifecycle.", + "canonical_state": ["service authentication is distinct from delegated authority", "delegation is task/capability/resource/time scoped and default-deny", "service principal cannot issue, extend, revoke or broaden its own authority", "Capability activation continues to require independent evaluation and owner-local lifecycle validation", "audit contains no secret or user payload"], + "state_transitions": ["service credential authentication -> delegated grant validation -> existing Capability CANARY/ACTIVE validation -> owner-local lifecycle persistence -> Authority audit", "grant ACTIVE -> REVOKED/EXPIRED denies new privileged actions while retaining audit history"], + "side_effects_and_privileges": ["Core receives only an Authority CredentialRef and service authentication SecretRef through the protected file backend", "the positive new_privileges and new_database_owner_roles values are devctl compatibility gates for deploy/database surfaces; no new role, broad administrator privilege, or ownership change is granted", "no external effect bypasses Commit/Executor where those owners already govern it"], + "persistence_and_migrations": ["additive owner-local Authority tables only; seed exactly one bounded service identity and GEN2-COGNITIVE-003 grant through owner roles", "historical audit is retained on rollback"], + "failure_retry_restart": ["authentication or validation failure is fail-closed", "grant state is read durably on each request", "no in-memory permission cache", "revocation prevents new actions immediately", "existing Capability revision preconditions prevent replay or duplicate promotion"], + "assumptions": ["the protected assistant_core_secrets control plane can materialize a non-empty control-plane service credential without exposing its value", "the current production schema has migrations through 055", "the owner decision authorizes a bounded GEN2-COGNITIVE-003 grant only, not future task grants"], + "acceptance_scenarios": ["valid grant permits only its declared Cognitive lifecycle operation and binding revision", "different binding, missing grant, expired grant and revoked grant are denied", "service principal cannot create or extend grants", "restart uses durable grant state", "audit is recorded without payload/secret", "browser session is not required", "existing independent Capability evaluation remains a mandatory promotion precondition"], + "required_tests": ["targeted go test", "targeted go vet", "migration/compose validation", "preflight", "check-diff", "independent", "architecture/security MILESTONE", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/development/GEN2_TASK_INDEX.md b/development/GEN2_TASK_INDEX.md index a4ea4a0..6d3f956 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -48,4 +48,10 @@ - **M6 retirement:** `GEN2-029` — FULL before irreversible retirement. - **M6 final:** `GEN2-030` — FULL final acceptance. +## Bounded post-M5 remediation + +| Task | Gate | Depends on | Purpose | +|---|---|---|---| +| `GEN2-AUTHORITY-001` | `MILESTONE` | owner decision, existing Identity/Authority/Capability lifecycle | Bounded non-interactive service principal and task-scoped control-plane delegation; does not retire V1 or change Gen2 canonical ownership. | + Между реперными точками выполняется только FAST-профиль конкретного packet-а. diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index 2409200..97043cf 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -23,6 +23,8 @@ Each privileged runtime gets only its required secret/capability bindings. `comm The Core SELF learning repository uses the existing `assistant_core_secrets` volume and credential-reference mechanism with `self_role` and `credential://domovoy/self/db-password/v1`. The secret is consumed from the fixed `/run/secrets/self_db_password` path; its value is never placed in Compose environment, Git, logs or ordinary configuration. The role and schema already exist; this wiring does not create a role or broaden privileges. +Delegated non-interactive control-plane work is separately authenticated. The external `assistant_core_secrets` control plane must materialize `control_plane_service_token`, mode `0400`, for `core-runtime` and the existing Authority/Commit runtime only. Compose carries only `credential://domovoy/control-plane/service-token/v1`; trusted runtimes resolve the fixed file below `/run/secrets`. The service token is not a Creator session, cannot impersonate a human, and must be rotated or withdrawn in the secret backend. Removing the token disables new delegated operations; revoking the durable Authority grant also blocks them without deleting audit history. + Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together. The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile. diff --git a/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md new file mode 100644 index 0000000..b1dfc9f --- /dev/null +++ b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md @@ -0,0 +1,40 @@ +# GEN2-AUTHORITY-001 — protected service credential prerequisite + +Date: 2026-08-31 + +The existing `assistant_core_secrets` volume was inspected by filename only. +It contains the established Creator, Epistemics, Interaction, Local gateway, +SELF and Work artifacts, but no `control_plane_service_token` artifact. + +No secret value was read, copied, printed or inferred. The implementation is +fail-closed: Core and `commit-runtime` remain healthy while the delegated path +returns unavailable/denied until the external secret control plane materializes +the credential reference `credential://domovoy/control-plane/service-token/v1` +at `/run/secrets/control_plane_service_token` for those two trusted runtimes. + +This is a `BLOCKED_BY_MISSING_CAPABILITY` for live non-interactive acceptance, +not a reason to use a Creator session, password, browser cookie, SQL update, or +broader service authority. + +## Implemented and verified locally + +The bounded implementation is present on branch +`gen2/authority-001-delegated-control-plane`. It keeps Identity as the service +identity owner, Authority as the durable grant/revocation/audit owner, and +Capability as the cognitive lifecycle owner. Core is an Authority-runtime +client; the Authority endpoint is served by the existing Commit runtime that +already owns the Authority repository in the V2 runtime topology. + +On 2026-08-31 the following checks completed successfully without secret +values: + +- targeted `go test` for Authority contracts/domain, secret resolver, Core, + Commit runtime, and the Authority PostgreSQL adapter; +- targeted `go vet` for the same packages; +- production and candidate Compose static validation; +- task preflight, diff scope, independent governance, and architecture checks; +- `git diff --check`. + +The only missing external capability is protected materialization of +`control_plane_service_token` for Core and Commit runtime. No deployment or +privileged lifecycle request was attempted while it is absent. diff --git a/modules/authority/domain/delegated_control_plane.go b/modules/authority/domain/delegated_control_plane.go new file mode 100644 index 0000000..c5530d7 --- /dev/null +++ b/modules/authority/domain/delegated_control_plane.go @@ -0,0 +1,34 @@ +package domain + +import ( + "errors" + "slices" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" +) + +var ErrDelegatedAuthorityDenied = errors.New("delegated control-plane authority denied") + +// ValidateDelegatedControlPlaneGrant proves only the Authority-owned decision. +// It does not replace the receiving semantic owner's lifecycle preconditions. +func ValidateDelegatedControlPlaneGrant(nowUnixMillis int64, binding identity.PrincipalBinding, grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest) error { + if nowUnixMillis < 1 || binding.ActorID == "" || binding.PrincipalID == "" || !binding.Active || + grant.ID == "" || grant.Revision < 1 || grant.Status != authority.DelegatedGrantActive || + grant.ServiceActorID != binding.ActorID || grant.ServicePrincipalID != binding.PrincipalID || + grant.IssuerPrincipalID == "" || grant.ArchitectureRevision == "" || grant.Nonce == "" || + grant.IssuedAtUnixMillis < 1 || grant.ExpiresAtUnixMillis <= grant.IssuedAtUnixMillis || nowUnixMillis < grant.IssuedAtUnixMillis || nowUnixMillis >= grant.ExpiresAtUnixMillis || !RequestMatchesDelegatedControlPlaneGrant(grant, request) { + return ErrDelegatedAuthorityDenied + } + return nil +} + +func RequestMatchesDelegatedControlPlaneGrant(grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest) bool { + return grant.TaskID != "" && grant.TaskID == request.TaskID && grant.TaskRevision > 0 && grant.TaskRevision == request.TaskRevision && + request.GrantID == grant.ID && request.Operation != "" && slices.Contains(grant.Operations, request.Operation) && + grant.Resource.BindingID != "" && grant.Resource.ProviderInstanceID != "" && grant.Resource.ModelDescriptorID != "" && grant.Resource == request.Resource +} + +func CanIssuerRevokeDelegatedGrant(issuer identity.PrincipalID, grant authority.DelegatedControlPlaneGrant) bool { + return issuer != "" && issuer == grant.IssuerPrincipalID && grant.Status == authority.DelegatedGrantActive +} diff --git a/modules/authority/domain/delegated_control_plane_test.go b/modules/authority/domain/delegated_control_plane_test.go new file mode 100644 index 0000000..2685805 --- /dev/null +++ b/modules/authority/domain/delegated_control_plane_test.go @@ -0,0 +1,43 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" +) + +func delegatedGrant() authority.DelegatedControlPlaneGrant { + return authority.DelegatedControlPlaneGrant{ID: "grant-1", ServiceActorID: "service-actor", ServicePrincipalID: "service-principal", IssuerPrincipalID: "owner", TaskID: "GEN2-COGNITIVE-003", TaskRevision: 4, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationCanaryCognitiveBinding}, Resource: authority.CognitiveBindingResource{BindingID: "binding-1", ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1"}, IssuedAtUnixMillis: 10, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive} +} + +func TestDelegatedControlPlaneGrantIsExactAndTimeBound(t *testing.T) { + grant := delegatedGrant() + binding := identity.PrincipalBinding{ActorID: "service-actor", PrincipalID: "service-principal", Active: true} + request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationCanaryCognitiveBinding, Resource: grant.Resource} + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err != nil { + t.Fatalf("valid delegation denied: %v", err) + } + request.Resource.BindingID = "other" + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err == nil { + t.Fatal("different resource was allowed") + } + request.Resource = grant.Resource + if err := ValidateDelegatedControlPlaneGrant(100, binding, grant, request); err == nil { + t.Fatal("expired grant was allowed") + } + grant.Status = authority.DelegatedGrantRevoked + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err == nil { + t.Fatal("revoked grant was allowed") + } +} + +func TestDelegatedGrantCannotSelfRevokeOrBroaden(t *testing.T) { + grant := delegatedGrant() + if CanIssuerRevokeDelegatedGrant("service-principal", grant) { + t.Fatal("service principal may not revoke its own grant") + } + if !CanIssuerRevokeDelegatedGrant("owner", grant) { + t.Fatal("issuer could not revoke active grant") + } +} From f42a94ee7a34d01f5a3ac8bc05dd3c99ffc36e25 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 13:26:50 +0300 Subject: [PATCH 2/6] fix(authority): use isolated service credential mount --- cmd/commit-runtime/control_plane_authority.go | 9 +++--- .../control_plane_authority_test.go | 29 +++++++++++++++++++ .../control_plane_authority_test.go | 14 +++++++++ cmd/core-runtime/main.go | 18 ++++++++++-- deploy/docker-compose.candidate-slot.yml | 3 +- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/cmd/commit-runtime/control_plane_authority.go b/cmd/commit-runtime/control_plane_authority.go index 461db6f..dfd679f 100644 --- a/cmd/commit-runtime/control_plane_authority.go +++ b/cmd/commit-runtime/control_plane_authority.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "time" @@ -22,7 +23,7 @@ import ( const ( delegatedControlPlaneActor identity.ActorID = "domovoy-control-plane-actor" delegatedControlPlanePrincipal identity.PrincipalID = "domovoy-control-plane-principal" - delegatedControlPlaneTokenPath = "/run/secrets/control_plane_service_token" + delegatedControlPlaneTokenFile = "control_plane_service_token" ) type delegatedControlPlaneAuthoritySource interface { @@ -157,13 +158,13 @@ func delegatedControlPlaneCredential() (string, error) { if ref == "" { return "", errors.New("control-plane service credential ref is required") } - root := os.Getenv("SECRET_BACKEND_ROOT") + root := os.Getenv("CONTROL_PLANE_SERVICE_SECRET_ROOT") if root == "" { - root = "/run/secrets" + root = "/run/control-plane-secrets" } resolver, err := platformsecrets.NewFileResolver(root) if err != nil { return "", err } - return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCommit, Path: delegatedControlPlaneTokenPath}) + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCommit, Path: filepath.Join(root, delegatedControlPlaneTokenFile)}) } diff --git a/cmd/commit-runtime/control_plane_authority_test.go b/cmd/commit-runtime/control_plane_authority_test.go index 0c38840..445ebdd 100644 --- a/cmd/commit-runtime/control_plane_authority_test.go +++ b/cmd/commit-runtime/control_plane_authority_test.go @@ -5,6 +5,8 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -20,6 +22,33 @@ type delegatedAuthorityFake struct { revoked bool } +func TestDelegatedAuthorityCredentialRotationRejectsOldToken(t *testing.T) { + root := t.TempDir() + tokenPath := filepath.Join(root, "control_plane_service_token") + if err := os.WriteFile(tokenPath, []byte("old-token\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF", "credential://domovoy/control-plane/service-token/v1") + t.Setenv("CONTROL_PLANE_SERVICE_SECRET_ROOT", root) + h := delegatedControlPlaneHandler{credential: delegatedControlPlaneCredential} + oldRequest := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", nil) + oldRequest.Header.Set("Authorization", "Bearer old-token") + if !h.serviceAuthenticated(oldRequest) { + t.Fatal("active credential was rejected before rotation") + } + if err := os.WriteFile(tokenPath, []byte("new-token\n"), 0o600); err != nil { + t.Fatal(err) + } + if h.serviceAuthenticated(oldRequest) { + t.Fatal("old credential remained valid after rotation") + } + newRequest := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", nil) + newRequest.Header.Set("Authorization", "Bearer new-token") + if !h.serviceAuthenticated(newRequest) { + t.Fatal("new credential was rejected after rotation") + } +} + func (f *delegatedAuthorityFake) LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { if f.grant.ID == "" { return authority.DelegatedControlPlaneGrant{}, errors.New("missing") diff --git a/cmd/core-runtime/control_plane_authority_test.go b/cmd/core-runtime/control_plane_authority_test.go index 16d8031..2ab6297 100644 --- a/cmd/core-runtime/control_plane_authority_test.go +++ b/cmd/core-runtime/control_plane_authority_test.go @@ -4,6 +4,8 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "git.cin.su/m/Assistant/contracts/authority" @@ -23,3 +25,15 @@ func TestControlPlaneClientSendsOnlyServiceCredentialToAuthorityRuntime(t *testi t.Fatalf("authorize: %v", err) } } + +func TestControlPlaneCredentialUsesDedicatedSecretRoot(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, controlPlaneServiceTokenFile), []byte("test-machine-token\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF", "credential://domovoy/control-plane/service-token/v1") + t.Setenv("CONTROL_PLANE_SERVICE_SECRET_ROOT", root) + if got, err := readControlPlaneServiceCredential(); err != nil || got != "test-machine-token" { + t.Fatalf("control-plane credential resolution failed: %v", err) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index ccfde22..d7ded68 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -31,6 +31,7 @@ import ( "log" "net/http" "os" + "path/filepath" "regexp" "sort" "strconv" @@ -45,7 +46,7 @@ const ( epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" interactionDBPasswordPath = "/run/secrets/interaction_db_password" selfDBPasswordPath = "/run/secrets/self_db_password" - controlPlaneServiceTokenPath = "/run/secrets/control_plane_service_token" + controlPlaneServiceTokenFile = "control_plane_service_token" ) const maxCreatorCredentialsBytes = 4 << 10 @@ -141,7 +142,7 @@ func main() { var delegatedAuthority *controlPlaneAuthority if os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") != "" { delegatedAuthority = &controlPlaneAuthority{baseURL: envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), client: &http.Client{Timeout: 5 * time.Second}, credential: func() (string, error) { - return readCredentialRefSecretPurpose("CONTROL_PLANE_SERVICE_TOKEN", "CONTROL_PLANE_SERVICE_CREDENTIAL_REF", controlPlaneServiceTokenPath, secretcontracts.SecretPurposeServiceAuthentication) + return readControlPlaneServiceCredential() }} } else { log.Printf("delegated control-plane authority is not configured; existing human control remains available") @@ -2088,6 +2089,19 @@ func readCredentialRefSecret(name, refName, fileName string) (string, error) { return readCredentialRefSecretPurpose(name, refName, fileName, secretcontracts.SecretPurposeDatabasePassword) } +func readControlPlaneServiceCredential() (string, error) { + ref := os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") + if ref == "" { + return "", errors.New("CONTROL_PLANE_SERVICE_CREDENTIAL_REF is required") + } + root := envOr("CONTROL_PLANE_SERVICE_SECRET_ROOT", "/run/control-plane-secrets") + resolver, err := platformsecrets.NewFileResolver(root) + if err != nil { + return "", err + } + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCore, Path: filepath.Join(root, controlPlaneServiceTokenFile)}) +} + func readCredentialRefSecretPurpose(name, refName, fileName string, purpose secretcontracts.SecretPurpose) (string, error) { if _, directSet := os.LookupEnv(name); directSet { return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name) diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index d0b4790..f80cdae 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -23,7 +23,6 @@ services: SECRET_BACKEND_ROOT: /run/secrets volumes: - assistant_core_secrets:/run/secrets:ro - - assistant_control_plane_service_secrets:/run/control-plane-secrets:ro - assistant_research_sources:/var/lib/research-sources:ro networks: shared: @@ -55,6 +54,7 @@ services: CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 + CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role @@ -71,6 +71,7 @@ services: RESEARCH_RUNTIME_URL: http://research-runtime-candidate:8087 volumes: - assistant_core_secrets:/run/secrets:ro + - assistant_control_plane_service_secrets:/run/control-plane-secrets:ro networks: shared: aliases: [core-runtime-candidate] From a8be998cee037fa2dc4be95875b9d1c0364cc900 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 13:28:15 +0300 Subject: [PATCH 3/6] docs(authority): record candidate topology gap --- ...ty-001-secret-capability-gap-2026-08-31.md | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md index b1dfc9f..d42430d 100644 --- a/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md +++ b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md @@ -1,20 +1,12 @@ -# GEN2-AUTHORITY-001 — protected service credential prerequisite +# GEN2-AUTHORITY-001 — delegated control-plane deployment evidence Date: 2026-08-31 -The existing `assistant_core_secrets` volume was inspected by filename only. -It contains the established Creator, Epistemics, Interaction, Local gateway, -SELF and Work artifacts, but no `control_plane_service_token` artifact. - -No secret value was read, copied, printed or inferred. The implementation is -fail-closed: Core and `commit-runtime` remain healthy while the delegated path -returns unavailable/denied until the external secret control plane materializes -the credential reference `credential://domovoy/control-plane/service-token/v1` -at `/run/secrets/control_plane_service_token` for those two trusted runtimes. - -This is a `BLOCKED_BY_MISSING_CAPABILITY` for live non-interactive acceptance, -not a reason to use a Creator session, password, browser cookie, SQL update, or -broader service authority. +The former missing-credential prerequisite is resolved by +`GEN2-SECRETS-001`. A local one-shot provisioner created the dedicated +protected artifact and verified idempotent ensure/rotation without reading or +emitting its value. Core and Commit are wired to resolve it from the dedicated +read-only mount, not the shared secret volume. ## Implemented and verified locally @@ -35,6 +27,24 @@ values: - task preflight, diff scope, independent governance, and architecture checks; - `git diff --check`. -The only missing external capability is protected materialization of -`control_plane_service_token` for Core and Commit runtime. No deployment or -privileged lifecycle request was attempted while it is absent. +## Current deployment-control gap + +No delegated lifecycle operation has been attempted in production. + +On 2026-08-31 the stable Core and Commit runtimes both reported release +revision `2ee5da81145cdad2da1c92c24a91145ad4af61a5`. The existing candidate +slot script declares exactly four components: Creator Web, Core, Cognitive +Worker, and Research Runtime. It has no candidate Commit runtime. + +The Authority endpoint is intentionally served by Commit runtime. Therefore a +candidate Core cannot perform a real delegated Authority request against the +required matching candidate Commit runtime without either recreating stable +Commit (forbidden) or extending the candidate ReleaseSet/Supervisor contract +to contain Commit as an independently healthy and rollback-aware component. + +This is `BLOCKED_BY_SPEC_GAP`, not a credential or implementation failure. A +new explicit packet/revision must authorize the minimum deployment paths: +`deploy/docker-compose.candidate-slot.yml`, `deploy/run-production-canary.sh`, +and the Supervisor release-set contract/tests if its fixed four-component +schema must change. The change must define Commit candidate health, revision +consistency, traffic/promotion behavior, and whole-ReleaseSet rollback. From 2f085858dbe6b01510771e3d589fca48ce1a818d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 13:42:48 +0300 Subject: [PATCH 4/6] chore(governance): add GEN2-DEPLOY-004 release set task --- dev/ACTIVE_TASK.json | 2 +- ...4-candidate-commit-runtime-releaseset.json | 63 +++++++++++++++++++ development/GEN2_TASK_INDEX.md | 1 + 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 291676a..fedfd2f 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json","task_id":"GEN2-AUTHORITY-001","task_revision":3} +{"task_file":"dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json","task_id":"GEN2-DEPLOY-004","task_revision":1} diff --git a/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json new file mode 100644 index 0000000..62af884 --- /dev/null +++ b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json @@ -0,0 +1,63 @@ +{ + "task_id": "GEN2-DEPLOY-004", + "task_revision": 1, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Extend the isolated candidate slot and typed Supervisor ReleaseSet with Commit runtime, without recreating the stable ReleaseSet or changing semantic ownership, so the already implemented delegated Authority path can be accepted against an isolated five-component release.", + "requirement_ids": ["REQ-GEN2-DEPLOY-004-CANDIDATE-COMMIT", "REQ-GEN2-AUTHORITY-001-ISOLATED-COMMIT", "REQ-GEN2-DEPLOY-003-STABLE-ID-GUARD"], + "allowed_change_scope": { + "modules": ["deployment control", "runtime supervisor", "candidate Commit runtime deployment wiring", "inherited delegated Authority prerequisite only"], + "paths": [ + "deploy/docker-compose.candidate-slot.yml", + "deploy/run-production-canary.sh", + "runtime/supervisor/**", + "cmd/supervisor/**", + "tests/deployment/**", + "docs/evidence/**", + "docs/DEPLOYMENT.md", + "docs/PROJECT_STATUS.md", + "development/GEN2_TASK_INDEX.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json", + "architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md", + "contracts/authority/types.go", + "contracts/authority/control_plane.go", + "contracts/authority/*_test.go", + "contracts/secrets/types.go", + "modules/authority/domain/**", + "modules/authority/application/**", + "modules/authority/ports/**", + "adapters/postgres/authority_repository.go", + "adapters/postgres/authority_repository_test.go", + "cmd/core-runtime/main.go", + "cmd/core-runtime/control_plane_authority.go", + "cmd/core-runtime/control_plane_authority_test.go", + "cmd/commit-runtime/main.go", + "cmd/commit-runtime/control_plane_authority.go", + "cmd/commit-runtime/control_plane_authority_test.go", + "database/migrations/056_delegated_control_plane_authority.sql", + "deploy/docker-compose.prod.yml", + "docs/CREDENTIALS_AND_SECRETS.md", + "dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json" + ] + }, + "forbidden_change_scope": { + "paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/identity/**", "modules/identity/**", "adapters/postgres/identity_repository.go", "contracts/capability/**", "modules/capability/**", "adapters/postgres/capability_repository.go", "cmd/cognitive-worker/**", "database/bootstrap/**", "security/**", "go.mod", "go.sum", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**"] + }, + "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": { + "consume": ["existing typed Supervisor ReleaseSet and ComponentRelease", "existing Commit runtime health/readiness/revision endpoints", "existing delegated Authority service credential and task-scoped grant contracts", "existing shared PostgreSQL and external secret volumes"], + "may_change": ["typed Supervisor required component set and ReleaseSet evidence", "candidate Compose dependency wiring", "candidate deployment control script"] + }, + "semantic_owner": "Operations/Supervisor owns operational ReleaseSet evidence; Commit remains the sole canonical owner of durable Commit state; Authority and Capability ownership do not change.", + "canonical_state": ["candidate and stable Commit instances use the same transactional canonical Commit repository and do not form two semantic owners", "no direct stable container recreation", "no new canonical writer, schema, role, credential value, or provider routing owner", "pre-existing GEN2-AUTHORITY-001 paths are inherited only so devctl can validate the committed prerequisite diff against gen2-integration; this packet grants no new write authority for them"], + "state_transitions": ["stable five-component evidence retained -> isolated candidate five-component start -> all component health/readiness/revision observation -> Supervisor canary -> promote or whole ReleaseSet rollback", "candidate Commit failure -> candidate ReleaseSet unhealthy -> traffic stable without stable container recreation"], + "side_effects_and_privileges": ["production operations are limited to Docker/Compose candidate slot B and Supervisor control endpoints", "candidate Core and Creator point only to candidate Commit", "candidate Commit receives only required existing SecretRefs through read-only protected mounts", "stable IDs are captured before every candidate operation and any change aborts before canary"], + "persistence_and_migrations": ["no migration or durable semantic schema change", "shared PostgreSQL and existing protected volumes remain external resources and are never deleted", "Supervisor persists stable/candidate/LKG ReleaseSet evidence across restart"], + "failure_retry_restart": ["missing, unhealthy, or revision-mismatched Commit rejects the entire candidate ReleaseSet", "rollback changes Supervisor traffic only and retains stable containers", "Supervisor restart reloads Commit-inclusive LKG and candidate state", "candidate and stable Commit rely on existing repository idempotency/revision preconditions for bounded test resources"], + "assumptions": ["GEN2-AUTHORITY-001 implementation commits through a8be998 remain unmodified prerequisites on this task branch", "the protected control-plane secret artifact exists and can be mounted read-only without displaying it", "existing Commit repository concurrency/idempotency contracts support separate stable and candidate runtime processes against bounded test resources"], + "acceptance_scenarios": ["candidate ReleaseSet contains creator-web, core-runtime, cognitive-worker, research-runtime, and commit-runtime with one revision", "missing, unhealthy, or revision-mismatched candidate Commit prevents release READY", "candidate Core and candidate Creator resolve Commit only through commit-runtime-candidate while stable topology remains unchanged", "candidate start/update leaves all five captured stable container IDs unchanged", "forced candidate Commit failure causes whole ReleaseSet rollback to stable traffic without recreating stable", "Supervisor restart preserves Commit-inclusive LKG and candidate evidence", "candidate Commit readiness proves database and Authority endpoint readiness without emitting a secret"], + "required_tests": ["FAST: targeted go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted go vet ./runtime/supervisor ./cmd/supervisor", "FAST: compose config candidate slot", "FAST: targeted deployment shell regression for stable ID invariance", "FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST independent: go run ./cmd/devctl/independent", "FAST whitespace: git diff --check gen2-integration --", "PRODUCTION: isolated five-component ReleaseSet health, revision, candidate Commit failure rollback, Supervisor restart/LKG, and stable-ID drill"], + "terminal_gap_states": ["BLOCKED_BY_DEPLOYMENT_CONTROL", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/development/GEN2_TASK_INDEX.md b/development/GEN2_TASK_INDEX.md index 2f5f1ca..2f5625d 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -57,5 +57,6 @@ Bounded post-M5 remediation. Provides the technical protected-secret control-pla | Task | Gate | Depends on | Purpose | |---|---|---|---| | `GEN2-AUTHORITY-001` | `MILESTONE` | owner decision, existing Identity/Authority/Capability lifecycle | Bounded non-interactive service principal and task-scoped control-plane delegation; does not retire V1 or change Gen2 canonical ownership. | +| `GEN2-DEPLOY-004` | `MILESTONE` | GEN2-DEPLOY-003, GEN2-AUTHORITY-001 implementation | Extend the isolated candidate slot and typed Supervisor ReleaseSet with Commit runtime before delegated Authority acceptance; stable containers are never recreated by candidate operations. | Между реперными точками выполняется только FAST-профиль конкретного packet-а. From d1710907ec9968eb753ef5903c1329ee3370f28e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 14:13:40 +0300 Subject: [PATCH 5/6] docs(deploy): record candidate dependency closure gap --- dev/ACTIVE_TASK.json | 2 +- ...4-candidate-commit-runtime-releaseset.json | 26 ++++---- ...y-004-dependency-closure-gap-2026-08-31.md | 60 +++++++++++++++++++ 3 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 docs/evidence/gen2-deploy-004-dependency-closure-gap-2026-08-31.md diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index fedfd2f..f83385f 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json","task_id":"GEN2-DEPLOY-004","task_revision":1} +{"task_file":"dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json","task_id":"GEN2-DEPLOY-004","task_revision":2} diff --git a/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json index 62af884..6944dc5 100644 --- a/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json +++ b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json @@ -1,13 +1,13 @@ { "task_id": "GEN2-DEPLOY-004", - "task_revision": 1, + "task_revision": 2, "architecture_revision": "ARCH-GEN2-000", "base_repo_revision": "gen2-integration", "risk_tier": "E", - "intent": "Extend the isolated candidate slot and typed Supervisor ReleaseSet with Commit runtime, without recreating the stable ReleaseSet or changing semantic ownership, so the already implemented delegated Authority path can be accepted against an isolated five-component release.", - "requirement_ids": ["REQ-GEN2-DEPLOY-004-CANDIDATE-COMMIT", "REQ-GEN2-AUTHORITY-001-ISOLATED-COMMIT", "REQ-GEN2-DEPLOY-003-STABLE-ID-GUARD"], + "intent": "Close the isolated candidate slot over all versioned Assistant runtime dependencies of its candidate components, extend the typed Supervisor ReleaseSet accordingly, and keep stable containers untouched so the delegated Authority path can be accepted without candidate-to-stable runtime shortcuts.", + "requirement_ids": ["REQ-GEN2-DEPLOY-004-DEPENDENCY-CLOSURE", "REQ-GEN2-DEPLOY-004-CANDIDATE-COMMIT", "REQ-GEN2-AUTHORITY-001-ISOLATED-COMMIT", "REQ-GEN2-DEPLOY-003-STABLE-ID-GUARD"], "allowed_change_scope": { - "modules": ["deployment control", "runtime supervisor", "candidate Commit runtime deployment wiring", "inherited delegated Authority prerequisite only"], + "modules": ["deployment control", "runtime supervisor", "candidate Assistant runtime dependency closure", "inherited delegated Authority prerequisite only"], "paths": [ "deploy/docker-compose.candidate-slot.yml", "deploy/run-production-canary.sh", @@ -47,17 +47,17 @@ }, "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, "contracts": { - "consume": ["existing typed Supervisor ReleaseSet and ComponentRelease", "existing Commit runtime health/readiness/revision endpoints", "existing delegated Authority service credential and task-scoped grant contracts", "existing shared PostgreSQL and external secret volumes"], - "may_change": ["typed Supervisor required component set and ReleaseSet evidence", "candidate Compose dependency wiring", "candidate deployment control script"] + "consume": ["existing typed Supervisor ReleaseSet and ComponentRelease", "existing runtime health/readiness/revision endpoints", "existing delegated Authority service credential and task-scoped grant contracts", "existing shared PostgreSQL and external secret volumes"], + "may_change": ["typed Supervisor explicit required ComponentKind set and ReleaseSet evidence", "candidate Compose dependency wiring", "candidate deployment control script"] }, "semantic_owner": "Operations/Supervisor owns operational ReleaseSet evidence; Commit remains the sole canonical owner of durable Commit state; Authority and Capability ownership do not change.", - "canonical_state": ["candidate and stable Commit instances use the same transactional canonical Commit repository and do not form two semantic owners", "no direct stable container recreation", "no new canonical writer, schema, role, credential value, or provider routing owner", "pre-existing GEN2-AUTHORITY-001 paths are inherited only so devctl can validate the committed prerequisite diff against gen2-integration; this packet grants no new write authority for them"], - "state_transitions": ["stable five-component evidence retained -> isolated candidate five-component start -> all component health/readiness/revision observation -> Supervisor canary -> promote or whole ReleaseSet rollback", "candidate Commit failure -> candidate ReleaseSet unhealthy -> traffic stable without stable container recreation"], - "side_effects_and_privileges": ["production operations are limited to Docker/Compose candidate slot B and Supervisor control endpoints", "candidate Core and Creator point only to candidate Commit", "candidate Commit receives only required existing SecretRefs through read-only protected mounts", "stable IDs are captured before every candidate operation and any change aborts before canary"], + "canonical_state": ["stable and candidate instances of each owner use the same transactional canonical repository and do not form two semantic owners", "no direct stable container recreation", "no new canonical writer, schema, role, credential value, or provider routing owner", "pre-existing GEN2-AUTHORITY-001 paths are inherited only so devctl can validate the committed prerequisite diff against gen2-integration; this packet grants no new write authority for them"], + "state_transitions": ["stable dependency-closed ReleaseSet evidence retained -> isolated candidate dependency closure start -> all component health/readiness/revision and dependency-target observation -> Supervisor canary -> promote or whole ReleaseSet rollback", "missing, unhealthy, mixed-revision, or stable-targeted candidate component -> candidate ReleaseSet unhealthy -> traffic stable without stable container recreation"], + "side_effects_and_privileges": ["production operations are limited to Docker/Compose candidate slot B and Supervisor control endpoints", "candidate Creator, Core, Commit, Continuity, Agent Worker, Agent Execution Bridge, Work Graph and Task Verifier use only candidate Assistant runtime URLs", "PostgreSQL, protected secret volumes, model/gateway services and other explicitly classified non-Assistant infrastructure remain shared", "candidate components receive only required existing SecretRefs through read-only protected mounts", "stable IDs are captured before every candidate operation and any change aborts before canary"], "persistence_and_migrations": ["no migration or durable semantic schema change", "shared PostgreSQL and existing protected volumes remain external resources and are never deleted", "Supervisor persists stable/candidate/LKG ReleaseSet evidence across restart"], - "failure_retry_restart": ["missing, unhealthy, or revision-mismatched Commit rejects the entire candidate ReleaseSet", "rollback changes Supervisor traffic only and retains stable containers", "Supervisor restart reloads Commit-inclusive LKG and candidate state", "candidate and stable Commit rely on existing repository idempotency/revision preconditions for bounded test resources"], - "assumptions": ["GEN2-AUTHORITY-001 implementation commits through a8be998 remain unmodified prerequisites on this task branch", "the protected control-plane secret artifact exists and can be mounted read-only without displaying it", "existing Commit repository concurrency/idempotency contracts support separate stable and candidate runtime processes against bounded test resources"], - "acceptance_scenarios": ["candidate ReleaseSet contains creator-web, core-runtime, cognitive-worker, research-runtime, and commit-runtime with one revision", "missing, unhealthy, or revision-mismatched candidate Commit prevents release READY", "candidate Core and candidate Creator resolve Commit only through commit-runtime-candidate while stable topology remains unchanged", "candidate start/update leaves all five captured stable container IDs unchanged", "forced candidate Commit failure causes whole ReleaseSet rollback to stable traffic without recreating stable", "Supervisor restart preserves Commit-inclusive LKG and candidate evidence", "candidate Commit readiness proves database and Authority endpoint readiness without emitting a secret"], - "required_tests": ["FAST: targeted go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted go vet ./runtime/supervisor ./cmd/supervisor", "FAST: compose config candidate slot", "FAST: targeted deployment shell regression for stable ID invariance", "FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST independent: go run ./cmd/devctl/independent", "FAST whitespace: git diff --check gen2-integration --", "PRODUCTION: isolated five-component ReleaseSet health, revision, candidate Commit failure rollback, Supervisor restart/LKG, and stable-ID drill"], + "failure_retry_restart": ["missing, unhealthy, revision-mismatched, or stable-targeted candidate component rejects the whole candidate ReleaseSet", "rollback changes Supervisor traffic only and retains stable containers", "Supervisor restart reloads the complete dependency-closed LKG and candidate state", "candidate and stable owner runtimes rely on existing repository idempotency/revision preconditions for bounded test resources"], + "assumptions": ["GEN2-AUTHORITY-001 implementation commits through a8be998 remain unmodified prerequisites on this task branch", "the protected control-plane secret artifact exists and can be mounted read-only without displaying it", "existing owner repositories support concurrent stable/candidate instances over bounded test resources", "any runtime target absent from the static configuration graph is classified before it is admitted as shared infrastructure"], + "acceptance_scenarios": ["candidate ReleaseSet contains the transitive closure of creator-web, core-runtime, cognitive-worker, research-runtime, commit-runtime, continuity-runtime, agent-worker, agent-execution-bridge, work-graph-runtime, and task-verifier, each at one revision", "every candidate Assistant runtime URL targets a component in the same candidate ReleaseSet; stable Assistant runtime URLs abort before canary", "missing, unhealthy, or revision-mismatched required component prevents release READY", "candidate Core -> Commit -> Continuity and Creator -> Continuity/Agent Worker remain within candidate topology while stable topology is unchanged", "candidate start/update leaves every captured stable Assistant runtime container ID unchanged", "forced candidate Continuity or Commit failure causes whole ReleaseSet rollback to stable traffic without recreating stable", "Supervisor restart preserves the complete component set in LKG and candidate evidence", "Commit readiness proves database and Authority endpoint readiness without emitting a secret"], + "required_tests": ["FAST: targeted go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted go vet ./runtime/supervisor ./cmd/supervisor", "FAST: compose config candidate slot", "FAST: targeted deployment shell regression for dependency isolation and stable ID invariance", "FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST independent: go run ./cmd/devctl/independent", "FAST whitespace: git diff --check gen2-integration --", "PRODUCTION: isolated dependency-closed ReleaseSet health, revision, candidate Continuity/Commit rollback, Supervisor restart/LKG, and stable-ID drill"], "terminal_gap_states": ["BLOCKED_BY_DEPLOYMENT_CONTROL", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] } diff --git a/docs/evidence/gen2-deploy-004-dependency-closure-gap-2026-08-31.md b/docs/evidence/gen2-deploy-004-dependency-closure-gap-2026-08-31.md new file mode 100644 index 0000000..909c0bd --- /dev/null +++ b/docs/evidence/gen2-deploy-004-dependency-closure-gap-2026-08-31.md @@ -0,0 +1,60 @@ +# GEN2-DEPLOY-004 r2 — candidate dependency-closure finding + +## Decision and graph evidence + +The owner-approved r2 rule requires every versioned Assistant runtime reached +by normal candidate operation to be in the candidate ReleaseSet. Static +runtime configuration establishes this candidate closure: + +```text +creator-web -> core-runtime, continuity-runtime, commit-runtime, + cognitive-worker, agent-worker +core-runtime -> cognitive-worker, research-runtime, continuity-runtime, + commit-runtime +commit-runtime -> core-runtime, continuity-runtime +continuity-runtime -> core-runtime, commit-runtime +agent-worker -> task-verifier, agent-execution-bridge +agent-execution-bridge -> cognitive-worker, work-graph-runtime, task-verifier +work-graph-runtime -> core-runtime +``` + +Therefore the static closure is: Creator Web, Core Runtime, Cognitive Worker, +Research Runtime, Commit Runtime, Continuity Runtime, Agent Worker, Agent +Execution Bridge, Work Graph Runtime, and Task Verifier. + +PostgreSQL, protected secret volumes, the local model gateway/lifeboat, and +the configured web-search endpoint are classified as shared durable +infrastructure or shared external services. They do not contain an Assistant +semantic/runtime release revision. + +## Blocking dynamic dependency + +Commit's normal prospective-notification operation obtains the exact active +notification Capability binding from Core. The binding's immutable +`HTTPSOrigin`, delivery path, and revision select the receiver. This is not a +Compose environment URL and cannot be safely redirected by a container alias. + +Consequently, with the existing active binding, a candidate Commit executing a +normal notification operation would call the stable notification receiver. A +candidate notification-receiver container alone does not solve the problem: +the candidate would still resolve the stable binding. Reusing the stable name +would create ambiguous network routing, and changing the active binding would +mutate Capability-owned canonical state. + +No runtime, Capability state, migration, secret, or production container was +changed while establishing this finding. + +## Required owner decision + +Choose one governed boundary before DEPLOY-004 implementation can prove full +dependency isolation: + +1. authorize a bounded Capability-owned candidate binding/shadow-binding + lifecycle, including its authority, rollback and selection semantics; or +2. explicitly classify the notification receiver as a shared external service + for this ReleaseSet, despite it being an Assistant-managed runtime. + +The first option requires a new packet revision with the exact Capability +contract/owner paths. The second changes the release-boundary policy and must +be accepted explicitly. No direct binding update, stable alias reuse, or +candidate-to-stable shortcut is an acceptable substitute. From dec19e873d99af3f80d63f207ffc4001e9814bec Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 16:28:57 +0300 Subject: [PATCH 6/6] feat(capability): add release scoped shadow bindings --- adapters/notificationexecutor/executor.go | 53 ++++++-- .../notificationexecutor/executor_test.go | 30 +++++ adapters/postgres/authority_repository.go | 4 +- adapters/postgres/capability_repository.go | 88 ++++++++++++++ adapters/postgres/commit_repository.go | 23 ++++ .../candidate_release_context.go | 41 +++++++ .../candidate_release_context_test.go | 25 ++++ .../control_plane_authority_test.go | 20 +++ cmd/commit-runtime/creator_clients.go | 57 ++++++++- cmd/commit-runtime/execution.go | 10 +- cmd/commit-runtime/execution_test.go | 12 +- cmd/commit-runtime/main.go | 31 ++++- cmd/commit-runtime/notification_executor.go | 21 ++++ .../notification_executor_test.go | 58 ++++++++- .../control_plane_authority_test.go | 23 ++++ cmd/core-runtime/main.go | 114 ++++++++++++++++++ cmd/notification-receiver/main.go | 7 +- cmd/notification-receiver/server.go | 24 +++- cmd/notification-receiver/server_test.go | 28 +++++ contracts/authority/types.go | 39 ++++-- contracts/capability/types.go | 53 ++++++++ contracts/commit/execution_receipt.go | 8 ++ ...e_scoped_candidate_capability_bindings.sql | 61 ++++++++++ .../058_commit_execution_receipts.sql | 15 +++ dev/ACTIVE_TASK.json | 2 +- ...ease-scoped-candidate-shadow-bindings.json | 37 ++++++ ...4-candidate-commit-runtime-releaseset.json | 14 +-- development/GEN2_TASK_INDEX.md | 1 + ...tion-receiver-classification-2026-08-31.md | 22 ++++ .../domain/delegated_control_plane.go | 22 +++- .../domain/delegated_control_plane_test.go | 14 +++ .../capability/domain/candidate_binding.go | 85 +++++++++++++ .../domain/candidate_binding_test.go | 67 ++++++++++ modules/capability/ports/repository.go | 3 + modules/commit/domain/commit.go | 15 ++- modules/commit/ports/executor.go | 1 + 36 files changed, 1078 insertions(+), 50 deletions(-) create mode 100644 cmd/commit-runtime/candidate_release_context.go create mode 100644 cmd/commit-runtime/candidate_release_context_test.go create mode 100644 database/migrations/057_release_scoped_candidate_capability_bindings.sql create mode 100644 database/migrations/058_commit_execution_receipts.sql create mode 100644 dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json create mode 100644 docs/evidence/gen2-capability-001-notification-receiver-classification-2026-08-31.md create mode 100644 modules/capability/domain/candidate_binding.go create mode 100644 modules/capability/domain/candidate_binding_test.go diff --git a/adapters/notificationexecutor/executor.go b/adapters/notificationexecutor/executor.go index a2eaac6..6342a56 100644 --- a/adapters/notificationexecutor/executor.go +++ b/adapters/notificationexecutor/executor.go @@ -37,10 +37,12 @@ type DeliveryRequest struct { } type Receipt struct { - ReceiptID string `json:"receipt_id"` - ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"` - RequestDigest string `json:"request_digest"` - AcceptedAt int64 `json:"accepted_at_unix_millis"` + ReceiptID string `json:"receipt_id"` + ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"` + RequestDigest string `json:"request_digest"` + AcceptedAt int64 `json:"accepted_at_unix_millis"` + DeliveryMode string `json:"delivery_mode,omitempty"` + ExternalDeliveryAttempted bool `json:"external_delivery_attempted"` } func Digest(payload Payload) (string, error) { @@ -76,11 +78,27 @@ func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error) type Executor struct { binding capability.Binding + candidate *capability.CandidateBinding secret string client *http.Client executeAllowed bool } +// NewShadow accepts only a Capability-owned SHADOW binding. Its caller does +// not supply an endpoint; the target remains inside the resolved binding. +func NewShadow(binding capability.CandidateBinding, secret string, client *http.Client) (*Executor, error) { + if capabilitydomain.ValidateCandidateBinding(binding) != nil || binding.Context.Mode != capability.CandidateBindingModeShadow || binding.Status != capability.CandidateBindingActive { + return nil, ErrInvalidNotification + } + stable := capability.Binding{ID: binding.BaseActiveBindingID, Revision: binding.BaseActiveBindingRevision, OwnerPrincipalID: binding.Context.PrincipalID} + value, err := newExecutor(stable, secret, client, true) + if err != nil { + return nil, err + } + value.candidate = &binding + return value, nil +} + func New(binding capability.Binding, secret string, client *http.Client) (*Executor, error) { if err := capabilitydomain.UsableBinding(binding); err != nil { return nil, ErrInvalidNotification @@ -116,7 +134,7 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi if err != nil { return ports.ExecutionResult{Known: true} } - endpoint, err := e.endpoint(e.binding.DeliveryPath, string(action.Proposal.IntentID)) + endpoint, err := e.endpoint(e.deliveryPath(), string(action.Proposal.IntentID)) if err != nil { return ports.ExecutionResult{Known: true} } @@ -127,6 +145,10 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi request.Header.Set("Authorization", "Bearer "+e.secret) request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json") + if e.candidate != nil { + request.Header.Set("X-Candidate-Release", e.candidate.Context.ReleaseSetID) + request.Header.Set("X-Candidate-Task", e.candidate.Context.TaskID) + } response, err := e.client.Do(request) if err != nil { return ports.ExecutionResult{} @@ -139,10 +161,14 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi return ports.ExecutionResult{} } receipt, err := decodeReceipt(response.Body) - if err != nil || !matchingReceipt(receipt, action) { + if err != nil || !matchingReceipt(receipt, action) || (e.candidate != nil && (receipt.DeliveryMode != "SHADOW" || receipt.ExternalDeliveryAttempted)) { return ports.ExecutionResult{} } - return ports.ExecutionResult{Known: true, Succeeded: true} + result := ports.ExecutionResult{Known: true, Succeeded: true} + if e.candidate != nil { + result.Receipt = &commitcontract.ExecutionReceipt{ID: receipt.ReceiptID, ExecutorImplementationRef: "notification-shadow-receiver-v1", ExternalOperationRef: "SHADOW_CAPTURED", ExternalRequestID: string(receipt.ActionIntentID), RequestDigest: receipt.RequestDigest, DispatchAtUnixMillis: receipt.AcceptedAt, ExecutorResult: commitcontract.ExecutorAccepted, ReconciliationKey: receipt.ReceiptID, DeliveryMode: "SHADOW", ExternalDeliveryAttempted: false, ReleaseSetID: e.candidate.Context.ReleaseSetID, TaskID: e.candidate.Context.TaskID, TaskRevision: e.candidate.Context.TaskRevision, CapabilityBindingID: string(e.candidate.ID), CapabilityBindingRevision: e.candidate.Revision, TargetReference: e.candidate.Context.TargetRef} + } + return result } func (e *Executor) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult { @@ -186,7 +212,11 @@ func (e *Executor) requestFor(action commitcontract.AuthorizedAction) (Payload, } func (e *Executor) endpoint(path, intent string) (string, error) { - base, err := url.Parse(e.binding.HTTPSOrigin) + origin := e.binding.HTTPSOrigin + if e.candidate != nil { + origin = e.candidate.TargetOrigin + } + base, err := url.Parse(origin) if err != nil { return "", err } @@ -195,6 +225,13 @@ func (e *Executor) endpoint(path, intent string) (string, error) { return base.String(), nil } +func (e *Executor) deliveryPath() string { + if e.candidate != nil { + return e.candidate.DeliveryPath + } + return e.binding.DeliveryPath +} + func decodeReceipt(reader io.Reader) (Receipt, error) { var receipt Receipt decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1)) diff --git a/adapters/notificationexecutor/executor_test.go b/adapters/notificationexecutor/executor_test.go index efdee1f..6f2d089 100644 --- a/adapters/notificationexecutor/executor_test.go +++ b/adapters/notificationexecutor/executor_test.go @@ -9,6 +9,7 @@ import ( "git.cin.su/m/Assistant/contracts/capability" commitcontract "git.cin.su/m/Assistant/contracts/commit" "git.cin.su/m/Assistant/contracts/identity" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" ) func testAction(t *testing.T) commitcontract.AuthorizedAction { @@ -55,6 +56,35 @@ func TestExecutorPutAndReconcileMatchReceipt(t *testing.T) { } } +func TestShadowExecutorRequiresShadowReceipt(t *testing.T) { + action := testAction(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Candidate-Release") != "assistant-slot-b" || r.Header.Get("X-Candidate-Task") != "GEN2-CAPABILITY-001" { + t.Fatalf("missing candidate context") + } + _ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "shadow-receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1, DeliveryMode: "SHADOW", ExternalDeliveryAttempted: false}) + })) + defer server.Close() + binding := capability.CandidateBinding{ID: "candidate-binding-1", Revision: 1, BaseActiveBindingID: "binding-1", BaseActiveBindingRevision: 1, + Context: capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: "owner-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant"}, + TargetOrigin: server.URL, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive} + // The httptest address is intentionally not the production candidate target; + // its digest is recomputed locally only for this bounded test binding. + digest, err := capabilitydomain.CandidateTargetDigest(binding) + if err != nil { + t.Fatal(err) + } + binding.Context.TargetDigest = digest + executor, err := NewShadow(binding, "shadow-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + result := executor.Execute(action) + if !result.Known || !result.Succeeded || result.Receipt == nil || result.Receipt.DeliveryMode != "SHADOW" || result.Receipt.ExternalDeliveryAttempted { + t.Fatalf("shadow result=%+v", result) + } +} + func TestExecutorAmbiguousAndIntegrityOutcomes(t *testing.T) { action := testAction(t) for name, handler := range map[string]http.HandlerFunc{ diff --git a/adapters/postgres/authority_repository.go b/adapters/postgres/authority_repository.go index dda693c..8aaf7cb 100644 --- a/adapters/postgres/authority_repository.go +++ b/adapters/postgres/authority_repository.go @@ -31,7 +31,7 @@ func (r *AuthorityRepository) LoadGrant(ctx context.Context, id authority.GrantI func (r *AuthorityRepository) LoadDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { var grant authority.DelegatedControlPlaneGrant - err := r.pool.QueryRow(ctx, `SELECT grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status FROM authority.delegated_control_plane_grants WHERE grant_id=$1`, id).Scan(&grant.ID, &grant.ServiceActorID, &grant.ServicePrincipalID, &grant.IssuerPrincipalID, &grant.TaskID, &grant.TaskRevision, &grant.Operations, &grant.Resource.BindingID, &grant.Resource.ProviderInstanceID, &grant.Resource.ModelDescriptorID, &grant.IssuedAtUnixMillis, &grant.ExpiresAtUnixMillis, &grant.ArchitectureRevision, &grant.Nonce, &grant.Revision, &grant.Status) + err := r.pool.QueryRow(ctx, `SELECT grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,COALESCE(binding_id,''),COALESCE(provider_instance_id,''),COALESCE(model_descriptor_id,''),candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status FROM authority.delegated_control_plane_grants WHERE grant_id=$1`, id).Scan(&grant.ID, &grant.ServiceActorID, &grant.ServicePrincipalID, &grant.IssuerPrincipalID, &grant.TaskID, &grant.TaskRevision, &grant.Operations, &grant.Resource.BindingID, &grant.Resource.ProviderInstanceID, &grant.Resource.ModelDescriptorID, &grant.CandidateResource.ReleaseSetID, &grant.CandidateResource.Slot, &grant.CandidateResource.CapabilityID, &grant.CandidateResource.ResourceScope, &grant.CandidateResource.Mode, &grant.CandidateResource.TargetKind, &grant.CandidateResource.TargetRef, &grant.CandidateResource.TargetDigest, &grant.IssuedAtUnixMillis, &grant.ExpiresAtUnixMillis, &grant.ArchitectureRevision, &grant.Nonce, &grant.Revision, &grant.Status) if err != nil { return authority.DelegatedControlPlaneGrant{}, fmt.Errorf("load delegated control-plane grant: %w", err) } @@ -39,7 +39,7 @@ func (r *AuthorityRepository) LoadDelegatedControlPlaneGrant(ctx context.Context } func (r *AuthorityRepository) RecordDelegatedControlPlaneAudit(ctx context.Context, audit authority.DelegatedControlPlaneAudit) error { - _, err := r.pool.Exec(ctx, `INSERT INTO authority.delegated_control_plane_audit (audit_id,grant_id,service_actor_id,service_principal_id,task_id,task_revision,operation,binding_id,provider_instance_id,model_descriptor_id,decision,outcome,recorded_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, audit.AuditID, audit.GrantID, audit.ServiceActorID, audit.ServicePrincipalID, audit.TaskID, audit.TaskRevision, audit.Operation, audit.Resource.BindingID, audit.Resource.ProviderInstanceID, audit.Resource.ModelDescriptorID, audit.Decision, audit.Outcome, audit.RecordedAtUnixMillis) + _, err := r.pool.Exec(ctx, `INSERT INTO authority.delegated_control_plane_audit (audit_id,grant_id,service_actor_id,service_principal_id,task_id,task_revision,operation,binding_id,provider_instance_id,model_descriptor_id,candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,decision,outcome,recorded_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, audit.AuditID, audit.GrantID, audit.ServiceActorID, audit.ServicePrincipalID, audit.TaskID, audit.TaskRevision, audit.Operation, audit.Resource.BindingID, audit.Resource.ProviderInstanceID, audit.Resource.ModelDescriptorID, audit.CandidateResource.ReleaseSetID, audit.CandidateResource.Slot, audit.CandidateResource.CapabilityID, audit.CandidateResource.ResourceScope, audit.CandidateResource.Mode, audit.CandidateResource.TargetKind, audit.CandidateResource.TargetRef, audit.CandidateResource.TargetDigest, audit.Decision, audit.Outcome, audit.RecordedAtUnixMillis) if err != nil { return fmt.Errorf("record delegated control-plane audit: %w", err) } diff --git a/adapters/postgres/capability_repository.go b/adapters/postgres/capability_repository.go index a271daa..f1f670a 100644 --- a/adapters/postgres/capability_repository.go +++ b/adapters/postgres/capability_repository.go @@ -8,6 +8,7 @@ import ( "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/identity" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -442,6 +443,93 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability return binding, nil } +func (r *CapabilityRepository) SaveCandidateBinding(ctx context.Context, binding capability.CandidateBinding) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + result, err := tx.Exec(ctx, `INSERT INTO capability.release_candidate_bindings ( + binding_id,revision,base_active_binding_id,base_active_binding_revision,release_set_id,slot,task_id,task_revision,principal_id,capability_id,resource_scope,mode,target_kind,target_ref,target_digest,authority_grant_id, + target_origin,delivery_path,receipt_path,protocol,created_at_unix_millis,expires_at_unix_millis,status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) ON CONFLICT DO NOTHING`, + binding.ID, binding.Revision, binding.BaseActiveBindingID, binding.BaseActiveBindingRevision, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID, + binding.Context.TaskRevision, binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.Context.TargetKind, binding.Context.TargetRef, binding.Context.TargetDigest, binding.Context.AuthorityGrantID, + binding.TargetOrigin, binding.DeliveryPath, binding.ReceiptPath, binding.Protocol, binding.CreatedAtUnixMillis, binding.ExpiresAtUnixMillis, binding.Status) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrCapabilityBindingConflict + } + if binding.Status == capability.CandidateBindingActive { + result, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_heads ( + release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode,binding_id,revision) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode) DO UPDATE SET binding_id=EXCLUDED.binding_id,revision=EXCLUDED.revision + WHERE capability.release_candidate_binding_heads.revision < EXCLUDED.revision`, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID, + binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.ID, binding.Revision) + if err != nil || result.RowsAffected() != 1 { + if err != nil { + return err + } + return ErrCapabilityBindingConflict + } + } + _, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,'CREATED','CAPABILITY_OWNED_CANDIDATE_BINDING',$8)`, binding.ID, binding.Revision, binding.Context.ReleaseSetID, binding.Context.TaskID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.CreatedAtUnixMillis) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *CapabilityRepository) ResolveCandidateBinding(ctx context.Context, request capability.CandidateReleaseContext, nowUnixMillis int64) (capability.CandidateBindingResolution, error) { + var binding capability.CandidateBinding + err := r.pool.QueryRow(ctx, `SELECT b.binding_id,b.revision,b.base_active_binding_id,b.base_active_binding_revision,b.release_set_id,b.slot,b.task_id,b.task_revision,b.principal_id,b.capability_id,b.resource_scope,b.mode,b.target_kind,b.target_ref,b.target_digest,b.authority_grant_id,b.target_origin,b.delivery_path,b.receipt_path,b.protocol,b.created_at_unix_millis,b.expires_at_unix_millis,b.status + FROM capability.release_candidate_binding_heads h JOIN capability.release_candidate_bindings b ON b.binding_id=h.binding_id AND b.revision=h.revision + WHERE h.release_set_id=$1 AND h.slot=$2 AND h.task_id=$3 AND h.principal_id=$4 AND h.capability_id=$5 AND h.resource_scope=$6 AND h.mode=$7`, + request.ReleaseSetID, request.Slot, request.TaskID, request.PrincipalID, request.CapabilityID, request.ResourceScope, request.Mode). + Scan(&binding.ID, &binding.Revision, &binding.BaseActiveBindingID, &binding.BaseActiveBindingRevision, &binding.Context.ReleaseSetID, &binding.Context.Slot, &binding.Context.TaskID, &binding.Context.TaskRevision, &binding.Context.PrincipalID, &binding.Context.CapabilityID, &binding.Context.ResourceScope, &binding.Context.Mode, &binding.Context.TargetKind, &binding.Context.TargetRef, &binding.Context.TargetDigest, &binding.Context.AuthorityGrantID, &binding.TargetOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.CreatedAtUnixMillis, &binding.ExpiresAtUnixMillis, &binding.Status) + if err != nil { + return capability.CandidateBindingResolution{}, fmt.Errorf("candidate binding required: %w", err) + } + if capabilitydomain.CandidateBindingInvocable(binding, request, nowUnixMillis) != nil { + return capability.CandidateBindingResolution{}, ErrCapabilityBindingNotFound + } + _, _ = r.pool.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,'RESOLVED','EXACT_CONTEXT',$8)`, binding.ID, binding.Revision, request.ReleaseSetID, request.TaskID, request.CapabilityID, request.ResourceScope, request.Mode, nowUnixMillis) + return capability.CandidateBindingResolution{Binding: binding, Mode: binding.Context.Mode, Reason: "EXACT_CONTEXT"}, nil +} + +func (r *CapabilityRepository) RevokeCandidateBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64, nowUnixMillis int64) (bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + var binding capability.CandidateBinding + err = tx.QueryRow(ctx, `UPDATE capability.release_candidate_bindings SET status='REVOKED' WHERE binding_id=$1 AND revision=$2 AND status='ACTIVE' + RETURNING binding_id,revision,base_active_binding_id,base_active_binding_revision,release_set_id,slot,task_id,task_revision,principal_id,capability_id,resource_scope,mode,target_kind,target_ref,target_digest,authority_grant_id,target_origin,delivery_path,receipt_path,protocol,created_at_unix_millis,expires_at_unix_millis,status`, id, revision). + Scan(&binding.ID, &binding.Revision, &binding.BaseActiveBindingID, &binding.BaseActiveBindingRevision, &binding.Context.ReleaseSetID, &binding.Context.Slot, &binding.Context.TaskID, &binding.Context.TaskRevision, &binding.Context.PrincipalID, &binding.Context.CapabilityID, &binding.Context.ResourceScope, &binding.Context.Mode, &binding.Context.TargetKind, &binding.Context.TargetRef, &binding.Context.TargetDigest, &binding.Context.AuthorityGrantID, &binding.TargetOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.CreatedAtUnixMillis, &binding.ExpiresAtUnixMillis, &binding.Status) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, err + } + _, err = tx.Exec(ctx, `DELETE FROM capability.release_candidate_binding_heads WHERE release_set_id=$1 AND slot=$2 AND task_id=$3 AND principal_id=$4 AND capability_id=$5 AND resource_scope=$6 AND mode=$7 AND binding_id=$8 AND revision=$9`, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID, binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.ID, binding.Revision) + if err != nil { + return false, err + } + _, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,'REVOKED','EXPLICIT_REVOKE',$8)`, binding.ID, binding.Revision, binding.Context.ReleaseSetID, binding.Context.TaskID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, nowUnixMillis) + if err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error { _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) diff --git a/adapters/postgres/commit_repository.go b/adapters/postgres/commit_repository.go index 0f71bc9..cc32b69 100644 --- a/adapters/postgres/commit_repository.go +++ b/adapters/postgres/commit_repository.go @@ -111,6 +111,29 @@ func (r *CommitRepository) FinishAttempt(ctx context.Context, a commitcontract.A return nil } +func (r *CommitRepository) SaveExecutionReceipt(ctx context.Context, receipt commitcontract.ExecutionReceipt) error { + if !receipt.Valid() { + return ErrActionAttemptConflict + } + completed := int64(0) + if receipt.CompletedAtUnixMillis != nil { + completed = *receipt.CompletedAtUnixMillis + } + result, err := r.pool.Exec(ctx, `INSERT INTO commit.execution_receipts ( + receipt_id,action_attempt_id,executor_implementation_ref,external_operation_ref,external_request_id,request_digest,response_digest,dispatch_at_unix_millis,completed_at_unix_millis, + executor_result,reconciliation_key,raw_artifact_ref,delivery_mode,external_delivery_attempted,release_set_id,task_id,task_revision,capability_binding_id,capability_binding_revision,target_reference) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT (receipt_id) DO NOTHING`, + receipt.ID, receipt.ActionAttemptRef, receipt.ExecutorImplementationRef, receipt.ExternalOperationRef, receipt.ExternalRequestID, receipt.RequestDigest, receipt.ResponseDigest, receipt.DispatchAtUnixMillis, completed, + receipt.ExecutorResult, receipt.ReconciliationKey, receipt.RawArtifactRef, receipt.DeliveryMode, receipt.ExternalDeliveryAttempted, receipt.ReleaseSetID, receipt.TaskID, receipt.TaskRevision, receipt.CapabilityBindingID, receipt.CapabilityBindingRevision, receipt.TargetReference) + if err != nil { + return err + } + if result.RowsAffected() != 1 { + return ErrActionAttemptConflict + } + return nil +} + func (r *CommitRepository) LoadAuthorized(ctx context.Context, id commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) { return loadAuthorized(ctx, r.pool, id) } diff --git a/cmd/commit-runtime/candidate_release_context.go b/cmd/commit-runtime/candidate_release_context.go new file mode 100644 index 0000000..f752834 --- /dev/null +++ b/cmd/commit-runtime/candidate_release_context.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/identity" +) + +// candidateReleaseContextFromRuntime accepts only deployment-owned metadata. +// It never derives a candidate slot from an HTTP request or notification body. +func candidateReleaseContextFromRuntime() (*capability.CandidateReleaseContext, error) { + const prefix = "CANDIDATE_" + release := os.Getenv(prefix + "RELEASE_SET_ID") + if release == "" { + return nil, nil + } + context := &capability.CandidateReleaseContext{ + ReleaseSetID: release, + Slot: os.Getenv(prefix + "SLOT"), + TaskID: os.Getenv(prefix + "TASK_ID"), + PrincipalID: identity.PrincipalID(os.Getenv(prefix + "PRINCIPAL_ID")), + CapabilityID: capability.CapabilityID(os.Getenv(prefix + "CAPABILITY_ID")), + ResourceScope: os.Getenv(prefix + "RESOURCE_SCOPE"), + Mode: capability.CandidateBindingMode(os.Getenv(prefix + "BINDING_MODE")), + TargetKind: os.Getenv(prefix + "TARGET_KIND"), + TargetRef: os.Getenv(prefix + "TARGET_REF"), + TargetDigest: os.Getenv(prefix + "TARGET_DIGEST"), + AuthorityGrantID: os.Getenv(prefix + "AUTHORITY_GRANT_ID"), + } + var revision int64 + if _, err := fmt.Sscan(os.Getenv(prefix+"TASK_REVISION"), &revision); err != nil { + return nil, fmt.Errorf("candidate task revision: %w", err) + } + context.TaskRevision = revision + if context.Slot == "" || context.TaskID == "" || context.TaskRevision < 1 || context.PrincipalID == "" || context.CapabilityID == "" || context.ResourceScope == "" || context.Mode == "" || context.TargetKind == "" || context.TargetRef == "" || context.TargetDigest == "" || context.AuthorityGrantID == "" { + return nil, fmt.Errorf("incomplete candidate release metadata") + } + return context, nil +} diff --git a/cmd/commit-runtime/candidate_release_context_test.go b/cmd/commit-runtime/candidate_release_context_test.go new file mode 100644 index 0000000..41fddd3 --- /dev/null +++ b/cmd/commit-runtime/candidate_release_context_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestCandidateReleaseContextRequiresDeploymentMetadata(t *testing.T) { + t.Setenv("CANDIDATE_RELEASE_SET_ID", "assistant-slot-b") + if _, err := candidateReleaseContextFromRuntime(); err == nil { + t.Fatal("partial candidate metadata was accepted") + } + t.Setenv("CANDIDATE_SLOT", "b") + t.Setenv("CANDIDATE_TASK_ID", "GEN2-CAPABILITY-001") + t.Setenv("CANDIDATE_TASK_REVISION", "7") + t.Setenv("CANDIDATE_PRINCIPAL_ID", "domovoy-creator-principal") + t.Setenv("CANDIDATE_CAPABILITY_ID", "prospective-notification-delivery") + t.Setenv("CANDIDATE_RESOURCE_SCOPE", "synthetic-shadow-notification-v1") + t.Setenv("CANDIDATE_BINDING_MODE", "SHADOW") + t.Setenv("CANDIDATE_TARGET_KIND", "NOTIFICATION_SHADOW_RECEIVER") + t.Setenv("CANDIDATE_TARGET_REF", "notification-receiver-candidate") + t.Setenv("CANDIDATE_TARGET_DIGEST", "f9f9fa49a0727444c280c092d5bb340f3a8f33cf325c5396f5ae177a6666da35") + t.Setenv("CANDIDATE_AUTHORITY_GRANT_ID", "grant") + context, err := candidateReleaseContextFromRuntime() + if err != nil || context.ReleaseSetID != "assistant-slot-b" || context.TaskRevision != 7 { + t.Fatalf("context=%+v err=%v", context, err) + } +} diff --git a/cmd/commit-runtime/control_plane_authority_test.go b/cmd/commit-runtime/control_plane_authority_test.go index 445ebdd..acb150e 100644 --- a/cmd/commit-runtime/control_plane_authority_test.go +++ b/cmd/commit-runtime/control_plane_authority_test.go @@ -12,6 +12,7 @@ import ( "time" "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/platform/ids" ) @@ -22,6 +23,25 @@ type delegatedAuthorityFake struct { revoked bool } +func TestCandidateBindingResolutionUsesServiceCredential(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/internal/capability-bindings/candidate/resolve" || r.Header.Get("Authorization") != "Bearer service-token" { + t.Fatalf("unexpected candidate resolution request: %s %q", r.URL.Path, r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"reason":"EXACT_CONTEXT"}`)) + })) + defer server.Close() + client, err := newCreatorCoreClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + client.serviceCredential = func() (string, error) { return "service-token", nil } + if _, err := client.ResolveCandidateCapabilityBinding(context.Background(), capability.CandidateReleaseContext{}); err != nil { + t.Fatalf("resolve candidate binding: %v", err) + } +} + func TestDelegatedAuthorityCredentialRotationRejectsOldToken(t *testing.T) { root := t.TempDir() tokenPath := filepath.Join(root, "control_plane_service_token") diff --git a/cmd/commit-runtime/creator_clients.go b/cmd/commit-runtime/creator_clients.go index f961062..e3a3ce9 100644 --- a/cmd/commit-runtime/creator_clients.go +++ b/cmd/commit-runtime/creator_clients.go @@ -25,8 +25,9 @@ var ( ) type creatorCoreClient struct { - baseURL *url.URL - client *http.Client + baseURL *url.URL + client *http.Client + serviceCredential func() (string, error) } type creatorContinuityClient struct { @@ -121,6 +122,58 @@ func (c creatorCoreClient) CapabilityBinding(ctx context.Context, id capability. return binding, nil } +func (c creatorCoreClient) ResolveCandidateCapabilityBinding(ctx context.Context, context capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) { + var resolved capability.CandidateBindingResolution + status, err := c.postServiceAuthenticated(ctx, "/v1/internal/capability-bindings/candidate/resolve", context, &resolved) + if err != nil || status != http.StatusOK { + return capability.CandidateBindingResolution{}, errCreatorQueryUnavailable + } + return resolved, nil +} + +func (c creatorCoreClient) postServiceAuthenticated(ctx context.Context, path string, value any, destination any) (int, error) { + if c.serviceCredential == nil { + return 0, errCreatorQueryUnavailable + } + credential, err := c.serviceCredential() + if err != nil || credential == "" { + return 0, errCreatorQueryUnavailable + } + return c.postWithAuthorization(ctx, path, value, destination, "Bearer "+credential) +} + +func (c creatorCoreClient) post(ctx context.Context, path string, value any, destination any) (int, error) { + return c.postWithAuthorization(ctx, path, value, destination, "") +} + +func (c creatorCoreClient) postWithAuthorization(ctx context.Context, path string, value any, destination any, authorization string) (int, error) { + target := *c.baseURL + target.Path, target.RawQuery = path, "" + body, err := json.Marshal(value) + if err != nil { + return 0, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), strings.NewReader(string(body))) + if err != nil { + return 0, err + } + request.Header.Set("Content-Type", "application/json") + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response, err := c.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode == http.StatusOK && destination != nil { + if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(destination); err != nil { + return 0, err + } + } + return response.StatusCode, nil +} + func (c creatorCoreClient) get(ctx context.Context, path, authorization string, destination any) (int, error) { target := *c.baseURL target.Path = path diff --git a/cmd/commit-runtime/execution.go b/cmd/commit-runtime/execution.go index 3450e89..ad414d2 100644 --- a/cmd/commit-runtime/execution.go +++ b/cmd/commit-runtime/execution.go @@ -24,6 +24,7 @@ type executionRepository interface { LoadLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, error) BeginAttempt(context.Context, commitcontract.ActionAttempt) error FinishAttempt(context.Context, commitcontract.ActionAttempt) error + SaveExecutionReceipt(context.Context, commitcontract.ExecutionReceipt) error } type attemptRequest struct { @@ -94,10 +95,17 @@ func executeAuthorized(ctx context.Context, repository executionRepository, exec if err := repository.BeginAttempt(ctx, begun); err != nil { return commitcontract.ActionAttempt{}, err } - attempt, operationErr := commitdomain.Execute(action, attemptID, executor) + attempt, result, operationErr := commitdomain.ExecuteWithResult(action, attemptID, executor) if err := repository.FinishAttempt(ctx, attempt); err != nil { return commitcontract.ActionAttempt{}, err } + if result.Receipt != nil { + receipt := *result.Receipt + receipt.ActionAttemptRef = attempt.ID + if err := repository.SaveExecutionReceipt(ctx, receipt); err != nil { + return commitcontract.ActionAttempt{}, err + } + } return attempt, operationErr } diff --git a/cmd/commit-runtime/execution_test.go b/cmd/commit-runtime/execution_test.go index d336e7f..9ba6841 100644 --- a/cmd/commit-runtime/execution_test.go +++ b/cmd/commit-runtime/execution_test.go @@ -16,6 +16,12 @@ import ( type fakeExecutionRepository struct { action commitcontract.AuthorizedAction attempts []commitcontract.ActionAttempt + receipt commitcontract.ExecutionReceipt +} + +func (f *fakeExecutionRepository) SaveExecutionReceipt(_ context.Context, receipt commitcontract.ExecutionReceipt) error { + f.receipt = receipt + return nil } func (f *fakeExecutionRepository) LoadAuthorized(context.Context, commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) { @@ -47,7 +53,8 @@ type knownExecutor struct{ calls int } func (e *knownExecutor) Execute(commitcontract.AuthorizedAction) ports.ExecutionResult { e.calls++ - return ports.ExecutionResult{Known: true, Succeeded: true} + completed := int64(1) + return ports.ExecutionResult{Known: true, Succeeded: true, Receipt: &commitcontract.ExecutionReceipt{ID: "receipt-1", ExecutorImplementationRef: "test-executor", ExternalOperationRef: "SHADOW_CAPTURED", RequestDigest: "request", ExecutorResult: commitcontract.ExecutorReportedSuccess, ReconciliationKey: "receipt-1", DispatchAtUnixMillis: 1, CompletedAtUnixMillis: &completed}} } func (e *knownExecutor) Reconcile(commitcontract.AuthorizedAction) ports.ExecutionResult { return ports.ExecutionResult{Known: true, Succeeded: true} @@ -66,6 +73,9 @@ func TestExecutionIsDurablyBegunAndCannotBeBlindlyRepeated(t *testing.T) { if result.Code != http.StatusOK || executor.calls != 1 || repository.attempts[0].State != commitcontract.ActionSucceeded { t.Fatalf("status=%d calls=%d attempts=%#v", result.Code, executor.calls, repository.attempts) } + if repository.receipt.ActionAttemptRef != repository.attempts[0].ID { + t.Fatalf("receipt attempt reference=%q want %q", repository.receipt.ActionAttemptRef, repository.attempts[0].ID) + } repeated := httptest.NewRequest(http.MethodPost, "/v1/internal/authorized-actions/authorized-1/execute", strings.NewReader(`{"AttemptID":"attempt-2"}`)) repeated.Header.Set("Authorization", "Bearer secret") diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index 77a8d7f..480b30c 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -73,15 +73,18 @@ func main() { if err != nil { log.Fatal(err) } - notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE") + candidateContext, err := candidateReleaseContextFromRuntime() if err != nil { log.Fatal(err) } - notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF") - if notificationSecretRef == "" { - log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required") + notificationSecretRef, notificationSecret, err := notificationCredentialsFromRuntime(candidateContext != nil) + if err != nil { + log.Fatal(err) } - dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient} + if candidateContext != nil { + creatorCore.serviceCredential = delegatedControlPlaneCredential + } + dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient, candidateContext: candidateContext} mux := http.NewServeMux() mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) readinessClient := &http.Client{Timeout: 2 * time.Second} @@ -162,6 +165,24 @@ func main() { } } +// notificationCredentialsFromRuntime keeps candidate shadow execution +// physically separate from the production notification credential mount. +func notificationCredentialsFromRuntime(candidate bool) (string, string, error) { + fileName, refName := "NOTIFICATION_WRITE_TOKEN_FILE", "NOTIFICATION_WRITE_SECRET_REF" + if candidate { + fileName, refName = "CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", "CANDIDATE_NOTIFICATION_WRITE_SECRET_REF" + } + secret, err := readCommitSecretFile(fileName) + if err != nil { + return "", "", err + } + ref := os.Getenv(refName) + if ref == "" { + return "", "", fmt.Errorf("%s is required", refName) + } + return ref, secret, nil +} + func readCommitSecretFile(name string) (string, error) { path := os.Getenv(name) if path == "" { diff --git a/cmd/commit-runtime/notification_executor.go b/cmd/commit-runtime/notification_executor.go index d09fd9e..a294b53 100644 --- a/cmd/commit-runtime/notification_executor.go +++ b/cmd/commit-runtime/notification_executor.go @@ -14,6 +14,7 @@ import ( type notificationBindingSource interface { ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) + ResolveCandidateCapabilityBinding(context.Context, capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) } type operationDispatcher struct { @@ -23,6 +24,7 @@ type operationDispatcher struct { secretRef string secret string client *http.Client + candidateContext *capability.CandidateReleaseContext } func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult { @@ -30,6 +32,20 @@ func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) por return d.continuityExecutor.Execute(action) } subject := action.Proposal.NotificationSubject + if d.candidateContext != nil { + resolved, err := d.bindings.ResolveCandidateCapabilityBinding(context.Background(), *d.candidateContext) + if err != nil || resolved.Mode != capability.CandidateBindingModeShadow || resolved.Binding.Context != *d.candidateContext { + return ports.ExecutionResult{Known: true} + } + if subject.BindingID != resolved.Binding.BaseActiveBindingID || subject.BindingRevision != resolved.Binding.BaseActiveBindingRevision { + return ports.ExecutionResult{Known: true} + } + executor, err := notificationexecutor.NewShadow(resolved.Binding, d.secret, d.client) + if err != nil { + return ports.ExecutionResult{Known: true} + } + return executor.Execute(action) + } exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision) if err != nil || exact.SecretRef != d.secretRef { return ports.ExecutionResult{Known: true} @@ -49,6 +65,11 @@ func (d operationDispatcher) Reconcile(action commitcontract.AuthorizedAction) p if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification { return d.continuityReconciler.Reconcile(action) } + if d.candidateContext != nil { + // A candidate shadow delivery is never reconciled against the ACTIVE + // production target. Its typed receipt is the sole candidate evidence. + return ports.ExecutionResult{Known: true} + } subject := action.Proposal.NotificationSubject exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision) if err != nil || exact.SecretRef != d.secretRef { diff --git a/cmd/commit-runtime/notification_executor_test.go b/cmd/commit-runtime/notification_executor_test.go index 3bdfb4a..a26a2fe 100644 --- a/cmd/commit-runtime/notification_executor_test.go +++ b/cmd/commit-runtime/notification_executor_test.go @@ -3,21 +3,43 @@ package main import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "os" "testing" "git.cin.su/m/Assistant/adapters/notificationexecutor" "git.cin.su/m/Assistant/contracts/capability" commitcontract "git.cin.su/m/Assistant/contracts/commit" "git.cin.su/m/Assistant/contracts/identity" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" ) +func TestCandidateNotificationCredentialsDoNotFallbackToProductionCredential(t *testing.T) { + path := t.TempDir() + "/candidate-token" + if err := os.WriteFile(path, []byte("candidate-only-token"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", path) + t.Setenv("CANDIDATE_NOTIFICATION_WRITE_SECRET_REF", "credential://domovoy/candidate/notification-write/v1") + ref, secret, err := notificationCredentialsFromRuntime(true) + if err != nil || ref == "" || secret != "candidate-only-token" { + t.Fatalf("candidate credential resolution failed: ref=%q err=%v", ref, err) + } + t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", "") + if _, _, err := notificationCredentialsFromRuntime(true); err == nil { + t.Fatal("candidate path fell back to production notification credential") + } +} + type fakeNotificationBindings struct { - exact capability.Binding - active capability.Binding - exactErr error - activeErr error + exact capability.Binding + active capability.Binding + exactErr error + activeErr error + candidate capability.CandidateBindingResolution + candidateErr error } func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) { @@ -26,6 +48,9 @@ func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability. func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) { return f.active, f.activeErr } +func (f fakeNotificationBindings) ResolveCandidateCapabilityBinding(context.Context, capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) { + return f.candidate, f.candidateErr +} func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction { t.Helper() @@ -81,3 +106,28 @@ func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) { t.Fatalf("reconcile=%+v", result) } } + +func TestCandidateDispatcherUsesShadowBindingWithoutActiveFallback(t *testing.T) { + var action commitcontract.AuthorizedAction + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Candidate-Release") != "assistant-slot-b" || r.Header.Get("X-Candidate-Task") != "GEN2-CAPABILITY-001" { + t.Fatalf("candidate headers missing") + } + _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "shadow-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1, DeliveryMode: "SHADOW"}) + })) + defer server.Close() + stable := dispatcherBinding("https://stable.invalid") + action = dispatcherAction(t, stable) + context := capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: stable.OwnerPrincipalID, CapabilityID: stable.CapabilityID, ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant"} + candidate := capability.CandidateBinding{ID: "candidate-1", Revision: 1, BaseActiveBindingID: stable.ID, BaseActiveBindingRevision: stable.Revision, Context: context, TargetOrigin: server.URL, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive} + digest, err := capabilitydomain.CandidateTargetDigest(candidate) + if err != nil { + t.Fatal(err) + } + context.TargetDigest, candidate.Context.TargetDigest = digest, digest + dispatcher := operationDispatcher{continuityExecutor: &knownExecutor{}, continuityReconciler: &knownExecutor{}, bindings: fakeNotificationBindings{activeErr: errors.New("stable fallback must not run"), candidate: capability.CandidateBindingResolution{Binding: candidate, Mode: capability.CandidateBindingModeShadow}}, secret: "shadow-secret", client: server.Client(), candidateContext: &context} + result := dispatcher.Execute(action) + if !result.Known || !result.Succeeded || result.Receipt == nil || result.Receipt.DeliveryMode != "SHADOW" { + t.Fatalf("candidate result=%+v", result) + } +} diff --git a/cmd/core-runtime/control_plane_authority_test.go b/cmd/core-runtime/control_plane_authority_test.go index 2ab6297..71bc039 100644 --- a/cmd/core-runtime/control_plane_authority_test.go +++ b/cmd/core-runtime/control_plane_authority_test.go @@ -37,3 +37,26 @@ func TestControlPlaneCredentialUsesDedicatedSecretRoot(t *testing.T) { t.Fatalf("control-plane credential resolution failed: %v", err) } } + +func TestCandidateControlPlaneAuthenticationRejectsMissingOrWrongCredential(t *testing.T) { + credential := func() (string, error) { return "service-token", nil } + for _, authorization := range []string{"", "Bearer other-token", "Basic service-token"} { + request := httptest.NewRequest(http.MethodPost, "/v1/internal/capability-bindings/candidate/resolve", nil) + request.Header.Set("Authorization", authorization) + if candidateControlPlaneAuthenticated(request, credential) { + t.Fatalf("authorization %q was accepted", authorization) + } + } + request := httptest.NewRequest(http.MethodPost, "/v1/internal/capability-bindings/candidate/resolve", nil) + request.Header.Set("Authorization", "Bearer service-token") + if !candidateControlPlaneAuthenticated(request, credential) { + t.Fatal("expected service credential to be accepted") + } +} + +func TestCandidateReleaseContextRequiresCompleteRuntimeMetadata(t *testing.T) { + t.Setenv("CANDIDATE_RELEASE_SET_ID", "assistant-slot-b") + if _, err := candidateReleaseContextFromRuntime(); err == nil { + t.Fatal("partial candidate runtime metadata was accepted") + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index d7ded68..1815d3f 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -49,6 +50,60 @@ const ( controlPlaneServiceTokenFile = "control_plane_service_token" ) +func candidateAuthorityResource(context capability.CandidateReleaseContext, origin string) authority.CandidateCapabilityBindingResource { + digest := context.TargetDigest + if origin != "" { + calculated, err := capabilitydomain.CandidateTargetDigest(capability.CandidateBinding{Context: context, TargetOrigin: origin, Protocol: capability.ProtocolIdempotentReceiptV1}) + if err != nil { + digest = "" + } else { + digest = calculated + } + } + return authority.CandidateCapabilityBindingResource{ReleaseSetID: context.ReleaseSetID, Slot: context.Slot, CapabilityID: string(context.CapabilityID), ResourceScope: context.ResourceScope, Mode: string(context.Mode), TargetKind: context.TargetKind, TargetRef: context.TargetRef, TargetDigest: digest} +} + +// candidateReleaseContextFromRuntime accepts only deployment-owned metadata. +// Candidate HTTP callers cannot choose a release identity for this runtime. +func candidateReleaseContextFromRuntime() (*capability.CandidateReleaseContext, error) { + const prefix = "CANDIDATE_" + release := os.Getenv(prefix + "RELEASE_SET_ID") + if release == "" { + return nil, nil + } + context := &capability.CandidateReleaseContext{ + ReleaseSetID: release, + Slot: os.Getenv(prefix + "SLOT"), + TaskID: os.Getenv(prefix + "TASK_ID"), + PrincipalID: identity.PrincipalID(os.Getenv(prefix + "PRINCIPAL_ID")), + CapabilityID: capability.CapabilityID(os.Getenv(prefix + "CAPABILITY_ID")), + ResourceScope: os.Getenv(prefix + "RESOURCE_SCOPE"), + Mode: capability.CandidateBindingMode(os.Getenv(prefix + "BINDING_MODE")), + TargetKind: os.Getenv(prefix + "TARGET_KIND"), + TargetRef: os.Getenv(prefix + "TARGET_REF"), + TargetDigest: os.Getenv(prefix + "TARGET_DIGEST"), + AuthorityGrantID: os.Getenv(prefix + "AUTHORITY_GRANT_ID"), + } + revision, err := strconv.ParseInt(os.Getenv(prefix+"TASK_REVISION"), 10, 64) + if err != nil { + return nil, fmt.Errorf("candidate task revision: %w", err) + } + context.TaskRevision = revision + if context.Slot == "" || context.TaskID == "" || context.TaskRevision < 1 || context.PrincipalID == "" || context.CapabilityID == "" || context.ResourceScope == "" || context.Mode == "" || context.TargetKind == "" || context.TargetRef == "" || context.TargetDigest == "" || context.AuthorityGrantID == "" { + return nil, errors.New("incomplete candidate release metadata") + } + return context, nil +} + +func candidateControlPlaneAuthenticated(r *http.Request, credential func() (string, error)) bool { + if credential == nil { + return false + } + expected, err := credential() + presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + return err == nil && expected != "" && presented != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1 +} + const maxCreatorCredentialsBytes = 4 << 10 func main() { @@ -147,6 +202,10 @@ func main() { } else { log.Printf("delegated control-plane authority is not configured; existing human control remains available") } + candidateContext, err := candidateReleaseContextFromRuntime() + if err != nil { + log.Fatal(err) + } reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second}) if err != nil { log.Fatal(err) @@ -1499,6 +1558,61 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(binding) }) + mux.HandleFunc("POST /v1/internal/capability-bindings/candidate", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil || candidateContext == nil { + http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable) + return + } + if !candidateControlPlaneAuthenticated(r, delegatedAuthority.credential) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var binding capability.CandidateBinding + decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&binding); err != nil || binding.Context != *candidateContext || capabilitydomain.ValidateCandidateBinding(binding) != nil { + http.Error(w, "invalid candidate binding", http.StatusBadRequest) + return + } + resource := candidateAuthorityResource(binding.Context, binding.TargetOrigin) + request := authority.DelegatedControlPlaneRequest{GrantID: authority.GrantID(binding.Context.AuthorityGrantID), TaskID: binding.Context.TaskID, TaskRevision: binding.Context.TaskRevision, Operation: authority.DelegatedOperationCreateCandidateCapabilityBinding, CandidateResource: resource} + if err := delegatedAuthority.authorize(r.Context(), request); err != nil || capabilityRepository.SaveCandidateBinding(r.Context(), binding) != nil { + delegatedAuthority.completed(r.Context(), request, false) + http.Error(w, "candidate binding denied", http.StatusForbidden) + return + } + delegatedAuthority.completed(r.Context(), request, true) + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("POST /v1/internal/capability-bindings/candidate/resolve", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil || candidateContext == nil { + http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable) + return + } + if !candidateControlPlaneAuthenticated(r, delegatedAuthority.credential) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var context capability.CandidateReleaseContext + if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024+1)).Decode(&context); err != nil || context != *candidateContext { + http.Error(w, "invalid candidate context", http.StatusBadRequest) + return + } + resource := candidateAuthorityResource(context, "") + request := authority.DelegatedControlPlaneRequest{GrantID: authority.GrantID(context.AuthorityGrantID), TaskID: context.TaskID, TaskRevision: context.TaskRevision, Operation: authority.DelegatedOperationUseCandidateCapabilityBinding, CandidateResource: resource} + if err := delegatedAuthority.authorize(r.Context(), request); err != nil { + http.Error(w, "candidate binding denied", http.StatusForbidden) + return + } + resolved, err := capabilityRepository.ResolveCandidateBinding(r.Context(), context, time.Now().UnixMilli()) + delegatedAuthority.completed(r.Context(), request, err == nil) + if err != nil { + http.Error(w, "candidate binding required", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resolved) + }) mux.HandleFunc("GET /v1/cognitive-bindings/{id}/revisions/{revision}/snapshot", func(w http.ResponseWriter, r *http.Request) { revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64) if err != nil || revision < 1 { diff --git a/cmd/notification-receiver/main.go b/cmd/notification-receiver/main.go index ec7ec7c..3e4416f 100644 --- a/cmd/notification-receiver/main.go +++ b/cmd/notification-receiver/main.go @@ -19,7 +19,12 @@ func main() { if err != nil { log.Fatal(err) } - server, err := newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now) + var server *receiverServer + if os.Getenv("SHADOW_RECEIVER_MODE") == "1" { + server, err = newShadowReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, os.Getenv("CANDIDATE_RELEASE_SET_ID"), os.Getenv("CANDIDATE_TASK_ID"), time.Now) + } else { + server, err = newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now) + } if err != nil { log.Fatal(err) } diff --git a/cmd/notification-receiver/server.go b/cmd/notification-receiver/server.go index 421c30e..e8d3bb2 100644 --- a/cmd/notification-receiver/server.go +++ b/cmd/notification-receiver/server.go @@ -32,8 +32,11 @@ type receiverServer struct { writeToken string readToken string now func() time.Time + shadow *shadowReceiverContext } +type shadowReceiverContext struct{ releaseSetID, taskID string } + func newReceiverServer(directory, writeToken, readToken string, now func() time.Time) (*receiverServer, error) { if directory == "" || strings.TrimSpace(writeToken) == "" || strings.TrimSpace(readToken) == "" || writeToken == readToken || now == nil { @@ -45,6 +48,15 @@ func newReceiverServer(directory, writeToken, readToken string, now func() time. return &receiverServer{directory: directory, writeToken: writeToken, readToken: readToken, now: now}, nil } +func newShadowReceiverServer(directory, writeToken, readToken, releaseSetID, taskID string, now func() time.Time) (*receiverServer, error) { + server, err := newReceiverServer(directory, writeToken, readToken, now) + if err != nil || releaseSetID == "" || taskID == "" { + return nil, errors.New("invalid shadow receiver configuration") + } + server.shadow = &shadowReceiverContext{releaseSetID: releaseSetID, taskID: taskID} + return server, nil +} + func (s *receiverServer) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { @@ -62,6 +74,10 @@ func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) { http.Error(w, "receiver writer denied", http.StatusUnauthorized) return } + if s.shadow != nil && (r.Header.Get("X-Candidate-Release") != s.shadow.releaseSetID || r.Header.Get("X-Candidate-Task") != s.shadow.taskID) { + http.Error(w, "candidate shadow context denied", http.StatusForbidden) + return + } intent := r.PathValue("intent") if !safeIntent.MatchString(intent) { http.Error(w, "invalid action intent", http.StatusBadRequest) @@ -83,7 +99,13 @@ func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) { http.Error(w, "delivery digest mismatch", http.StatusConflict) return } - receipt := storedReceipt{Receipt: notificationexecutor.Receipt{ReceiptID: "receipt-" + intent, ActionIntentID: request.ActionIntentID, RequestDigest: digest, AcceptedAt: s.now().UnixMilli()}, Payload: request.Payload} + receipt := storedReceipt{Receipt: notificationexecutor.Receipt{ReceiptID: "receipt-" + intent, ActionIntentID: request.ActionIntentID, RequestDigest: digest, AcceptedAt: s.now().UnixMilli()}} + if s.shadow != nil { + receipt.DeliveryMode = "SHADOW" + receipt.ExternalDeliveryAttempted = false + } else { + receipt.Payload = request.Payload + } created, existing, err := s.create(receipt) if err != nil { http.Error(w, "receipt unavailable", http.StatusInternalServerError) diff --git a/cmd/notification-receiver/server_test.go b/cmd/notification-receiver/server_test.go index ba9eb8b..2fa7982 100644 --- a/cmd/notification-receiver/server_test.go +++ b/cmd/notification-receiver/server_test.go @@ -120,3 +120,31 @@ func TestConcurrentDuplicateCreatesOneReceipt(t *testing.T) { t.Fatalf("created=%d", created) } } + +func TestShadowReceiverRequiresContextAndDoesNotPersistPayload(t *testing.T) { + server, err := newShadowReceiverServer(t.TempDir(), "shadow-write", "shadow-read", "assistant-slot-b", "GEN2-CAPABILITY-001", func() time.Time { return time.UnixMilli(100) }) + if err != nil { + t.Fatal(err) + } + value := delivery(t, "shadow-intent", "must not persist") + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPut, "/delivery/v1/deliveries/shadow-intent", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer shadow-write") + req.Header.Set("X-Candidate-Release", "assistant-slot-b") + req.Header.Set("X-Candidate-Task", "GEN2-CAPABILITY-001") + response := httptest.NewRecorder() + server.routes().ServeHTTP(response, req) + if response.Code != http.StatusCreated || !bytes.Contains(response.Body.Bytes(), []byte(`"delivery_mode":"SHADOW"`)) || bytes.Contains(response.Body.Bytes(), []byte("must not persist")) { + t.Fatalf("shadow response status=%d body=%s", response.Code, response.Body.String()) + } + req = httptest.NewRequest(http.MethodPut, "/delivery/v1/deliveries/shadow-intent", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer shadow-write") + response = httptest.NewRecorder() + server.routes().ServeHTTP(response, req) + if response.Code != http.StatusForbidden { + t.Fatalf("unscoped shadow request status=%d", response.Code) + } +} diff --git a/contracts/authority/types.go b/contracts/authority/types.go index 3be8c34..524ddb3 100644 --- a/contracts/authority/types.go +++ b/contracts/authority/types.go @@ -18,11 +18,14 @@ const GrantClassCreator GrantClass = "CREATOR" type DelegatedControlPlaneOperation string const ( - DelegatedOperationQualifyCognitiveBinding DelegatedControlPlaneOperation = "QUALIFY_COGNITIVE_BINDING" - DelegatedOperationEvaluateCognitiveBinding DelegatedControlPlaneOperation = "EVALUATE_COGNITIVE_BINDING" - DelegatedOperationCanaryCognitiveBinding DelegatedControlPlaneOperation = "CANARY_COGNITIVE_BINDING" - DelegatedOperationActivateCognitiveBinding DelegatedControlPlaneOperation = "ACTIVATE_VALIDATED_COGNITIVE_BINDING" - DelegatedOperationRevokeControlPlaneGrant DelegatedControlPlaneOperation = "REVOKE_DELEGATED_CONTROL_PLANE_GRANT" + DelegatedOperationQualifyCognitiveBinding DelegatedControlPlaneOperation = "QUALIFY_COGNITIVE_BINDING" + DelegatedOperationEvaluateCognitiveBinding DelegatedControlPlaneOperation = "EVALUATE_COGNITIVE_BINDING" + DelegatedOperationCanaryCognitiveBinding DelegatedControlPlaneOperation = "CANARY_COGNITIVE_BINDING" + DelegatedOperationActivateCognitiveBinding DelegatedControlPlaneOperation = "ACTIVATE_VALIDATED_COGNITIVE_BINDING" + DelegatedOperationCreateCandidateCapabilityBinding DelegatedControlPlaneOperation = "CREATE_CANDIDATE_CAPABILITY_BINDING" + DelegatedOperationUseCandidateCapabilityBinding DelegatedControlPlaneOperation = "USE_CANDIDATE_CAPABILITY_BINDING" + DelegatedOperationRevokeCandidateCapabilityBinding DelegatedControlPlaneOperation = "REVOKE_CANDIDATE_CAPABILITY_BINDING" + DelegatedOperationRevokeControlPlaneGrant DelegatedControlPlaneOperation = "REVOKE_DELEGATED_CONTROL_PLANE_GRANT" ) type DelegatedGrantStatus string @@ -41,6 +44,19 @@ type CognitiveBindingResource struct { ModelDescriptorID string } +// CandidateCapabilityBindingResource is an exact, bounded control-plane +// resource. Authority does not resolve targets or own Capability state. +type CandidateCapabilityBindingResource struct { + ReleaseSetID string + Slot string + CapabilityID string + ResourceScope string + Mode string + TargetKind string + TargetRef string + TargetDigest string +} + type DelegatedControlPlaneGrant struct { ID GrantID ServiceActorID identity.ActorID @@ -50,6 +66,7 @@ type DelegatedControlPlaneGrant struct { TaskRevision int64 Operations []DelegatedControlPlaneOperation Resource CognitiveBindingResource + CandidateResource CandidateCapabilityBindingResource IssuedAtUnixMillis int64 ExpiresAtUnixMillis int64 ArchitectureRevision string @@ -59,11 +76,12 @@ type DelegatedControlPlaneGrant struct { } type DelegatedControlPlaneRequest struct { - GrantID GrantID - TaskID string - TaskRevision int64 - Operation DelegatedControlPlaneOperation - Resource CognitiveBindingResource + GrantID GrantID + TaskID string + TaskRevision int64 + Operation DelegatedControlPlaneOperation + Resource CognitiveBindingResource + CandidateResource CandidateCapabilityBindingResource } type DelegatedControlPlaneAudit struct { @@ -75,6 +93,7 @@ type DelegatedControlPlaneAudit struct { TaskRevision int64 Operation DelegatedControlPlaneOperation Resource CognitiveBindingResource + CandidateResource CandidateCapabilityBindingResource Decision string Outcome string RecordedAtUnixMillis int64 diff --git a/contracts/capability/types.go b/contracts/capability/types.go index 70ded9b..d6d1bf4 100644 --- a/contracts/capability/types.go +++ b/contracts/capability/types.go @@ -12,10 +12,21 @@ type CapabilityKind string type BindingState string type BindingProtocol string +// CandidateBindingMode controls how a release-scoped binding may be used. +// It is deliberately separate from a production BindingState. +type CandidateBindingMode string +type CandidateBindingStatus string + const ( BindingActive BindingState = "ACTIVE" BindingRetired BindingState = "RETIRED" + CandidateBindingModeCandidate CandidateBindingMode = "CANDIDATE" + CandidateBindingModeShadow CandidateBindingMode = "SHADOW" + + CandidateBindingActive CandidateBindingStatus = "ACTIVE" + CandidateBindingRevoked CandidateBindingStatus = "REVOKED" + ProtocolIdempotentReceiptV1 BindingProtocol = "IDEMPOTENT_RECEIPT_V1" CapabilityKindReadEmail CapabilityKind = "READ_EMAIL" @@ -54,6 +65,48 @@ type Binding struct { Health HealthState } +// CandidateReleaseContext is supplied by a candidate runtime, not inferred +// from a host name or process-local state. It scopes dynamic capability +// resolution to one release slot and task. +type CandidateReleaseContext struct { + ReleaseSetID string + Slot string + TaskID string + TaskRevision int64 + PrincipalID identity.PrincipalID + CapabilityID CapabilityID + ResourceScope string + Mode CandidateBindingMode + TargetKind string + TargetRef string + TargetDigest string + AuthorityGrantID string +} + +// CandidateBinding is Capability-owned release-scoped state. TargetOrigin is +// intentionally distinct from Binding.HTTPSOrigin: a SHADOW target can be an +// internal candidate receiver and must never be promoted into the ACTIVE head. +type CandidateBinding struct { + ID CapabilityBindingID + Revision int64 + BaseActiveBindingID CapabilityBindingID + BaseActiveBindingRevision int64 + Context CandidateReleaseContext + TargetOrigin string + DeliveryPath string + ReceiptPath string + Protocol BindingProtocol + CreatedAtUnixMillis int64 + ExpiresAtUnixMillis int64 + Status CandidateBindingStatus +} + +type CandidateBindingResolution struct { + Binding CandidateBinding + Mode CandidateBindingMode + Reason string +} + type HealthState string const ( diff --git a/contracts/commit/execution_receipt.go b/contracts/commit/execution_receipt.go index 40cdeeb..4584af9 100644 --- a/contracts/commit/execution_receipt.go +++ b/contracts/commit/execution_receipt.go @@ -25,6 +25,14 @@ type ExecutionReceipt struct { Ambiguity AmbiguityState ReconciliationKey string RawArtifactRef string + DeliveryMode string + ExternalDeliveryAttempted bool + ReleaseSetID string + TaskID string + TaskRevision int64 + CapabilityBindingID string + CapabilityBindingRevision int64 + TargetReference string } func (r ExecutionReceipt) Valid() bool { diff --git a/database/migrations/057_release_scoped_candidate_capability_bindings.sql b/database/migrations/057_release_scoped_candidate_capability_bindings.sql new file mode 100644 index 0000000..3434536 --- /dev/null +++ b/database/migrations/057_release_scoped_candidate_capability_bindings.sql @@ -0,0 +1,61 @@ +BEGIN; + +SET ROLE authority_role; +ALTER TABLE authority.delegated_control_plane_grants + ALTER COLUMN binding_id DROP NOT NULL, + ALTER COLUMN provider_instance_id DROP NOT NULL, + ALTER COLUMN model_descriptor_id DROP NOT NULL, + ADD COLUMN IF NOT EXISTS candidate_release_set_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_slot TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_capability_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_resource_scope TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_mode TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_kind TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_digest TEXT NOT NULL DEFAULT ''; +ALTER TABLE authority.delegated_control_plane_audit + ALTER COLUMN binding_id DROP NOT NULL, + ALTER COLUMN provider_instance_id DROP NOT NULL, + ALTER COLUMN model_descriptor_id DROP NOT NULL, + ADD COLUMN IF NOT EXISTS candidate_release_set_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_slot TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_capability_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_resource_scope TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_mode TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_kind TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS candidate_target_digest TEXT NOT NULL DEFAULT ''; +ALTER TABLE authority.delegated_control_plane_grants DROP CONSTRAINT IF EXISTS delegated_control_plane_grants_binding_id_check; +ALTER TABLE authority.delegated_control_plane_grants DROP CONSTRAINT IF EXISTS delegated_control_plane_grants_operations_check; +ALTER TABLE authority.delegated_control_plane_audit DROP CONSTRAINT IF EXISTS delegated_control_plane_audit_operation_check; +ALTER TABLE authority.delegated_control_plane_grants ADD CONSTRAINT delegated_control_plane_grants_operations_check CHECK (operations <@ ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING']::TEXT[]); +ALTER TABLE authority.delegated_control_plane_audit ADD CONSTRAINT delegated_control_plane_audit_operation_check CHECK (operation IN ('QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','REVOKE_DELEGATED_CONTROL_PLANE_GRANT','CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING')); +ALTER TABLE authority.delegated_control_plane_grants ADD CONSTRAINT delegated_control_plane_grants_exact_resource_check CHECK ( + (binding_id <> '' AND provider_instance_id <> '' AND model_descriptor_id <> '' AND candidate_release_set_id = '' AND candidate_slot = '' AND candidate_capability_id = '' AND candidate_resource_scope = '' AND candidate_mode = '' AND candidate_target_kind = '' AND candidate_target_ref = '') OR + (binding_id IS NULL AND provider_instance_id IS NULL AND model_descriptor_id IS NULL AND candidate_release_set_id <> '' AND candidate_slot <> '' AND candidate_capability_id <> '' AND candidate_resource_scope <> '' AND candidate_mode IN ('CANDIDATE','SHADOW') AND candidate_target_kind <> '' AND candidate_target_ref <> '' AND length(candidate_target_digest) = 64) +); +INSERT INTO authority.delegated_control_plane_grants (grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status) +VALUES ('gen2-capability-001-shadow-notification-grant','domovoy-control-plane-actor','domovoy-control-plane-principal','domovoy-creator-principal','GEN2-CAPABILITY-001',7,ARRAY['CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING']::TEXT[],NULL,NULL,NULL,'assistant-slot-b','b','prospective-notification-delivery','synthetic-shadow-notification-v1','SHADOW','NOTIFICATION_SHADOW_RECEIVER','notification-receiver-candidate','f9f9fa49a0727444c280c092d5bb340f3a8f33cf325c5396f5ae177a6666da35',(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000)::BIGINT,(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP + INTERVAL '14 days') * 1000)::BIGINT,'ADR-0021','gen2-capability-001-shadow-notification-r7',1,'ACTIVE') ON CONFLICT (grant_id) DO NOTHING; +RESET ROLE; + +SET ROLE capability_role; +CREATE TABLE IF NOT EXISTS capability.release_candidate_bindings ( + binding_id TEXT NOT NULL, revision BIGINT NOT NULL, base_active_binding_id TEXT NOT NULL, base_active_binding_revision BIGINT NOT NULL, + release_set_id TEXT NOT NULL, slot TEXT NOT NULL, task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, principal_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL, target_kind TEXT NOT NULL, target_ref TEXT NOT NULL, target_digest TEXT NOT NULL, authority_grant_id TEXT NOT NULL, + target_origin TEXT NOT NULL, delivery_path TEXT NOT NULL, receipt_path TEXT NOT NULL, protocol TEXT NOT NULL, + created_at_unix_millis BIGINT NOT NULL, expires_at_unix_millis BIGINT NOT NULL, status TEXT NOT NULL, + PRIMARY KEY (binding_id, revision), + CHECK (revision > 0 AND base_active_binding_revision > 0 AND task_revision > 0), CHECK (mode IN ('CANDIDATE','SHADOW')), CHECK (status IN ('ACTIVE','REVOKED')), + CHECK (expires_at_unix_millis > created_at_unix_millis), CHECK (target_origin <> '' AND delivery_path <> '' AND receipt_path <> '' AND target_kind <> '' AND target_ref <> '' AND length(target_digest) = 64 AND authority_grant_id <> '') +); +CREATE TABLE IF NOT EXISTS capability.release_candidate_binding_heads ( + release_set_id TEXT NOT NULL, slot TEXT NOT NULL, task_id TEXT NOT NULL, principal_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL, + binding_id TEXT NOT NULL, revision BIGINT NOT NULL, PRIMARY KEY (release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode) +); +CREATE TABLE IF NOT EXISTS capability.release_candidate_binding_audit ( + audit_id BIGSERIAL PRIMARY KEY, binding_id TEXT NOT NULL, revision BIGINT NOT NULL, release_set_id TEXT NOT NULL, task_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL, decision TEXT NOT NULL, reason TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL, + CHECK (decision IN ('CREATED','RESOLVED','DENIED','REVOKED','EXPIRED')) +); +CREATE INDEX IF NOT EXISTS release_candidate_binding_audit_lookup_idx ON capability.release_candidate_binding_audit (release_set_id, task_id, capability_id, recorded_at_unix_millis DESC); +RESET ROLE; +COMMIT; diff --git a/database/migrations/058_commit_execution_receipts.sql b/database/migrations/058_commit_execution_receipts.sql new file mode 100644 index 0000000..7a03256 --- /dev/null +++ b/database/migrations/058_commit_execution_receipts.sql @@ -0,0 +1,15 @@ +BEGIN; +SET ROLE commit_role; +CREATE TABLE IF NOT EXISTS commit.execution_receipts ( + receipt_id TEXT PRIMARY KEY, action_attempt_id TEXT NOT NULL UNIQUE, executor_implementation_ref TEXT NOT NULL, + external_operation_ref TEXT NOT NULL DEFAULT '', external_request_id TEXT NOT NULL DEFAULT '', request_digest TEXT NOT NULL, + response_digest TEXT NOT NULL DEFAULT '', dispatch_at_unix_millis BIGINT NOT NULL, completed_at_unix_millis BIGINT NOT NULL DEFAULT 0, + executor_result TEXT NOT NULL, reconciliation_key TEXT NOT NULL, raw_artifact_ref TEXT NOT NULL DEFAULT '', + delivery_mode TEXT NOT NULL DEFAULT '', external_delivery_attempted BOOLEAN NOT NULL DEFAULT FALSE, + release_set_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', task_revision BIGINT NOT NULL DEFAULT 0, + capability_binding_id TEXT NOT NULL DEFAULT '', capability_binding_revision BIGINT NOT NULL DEFAULT 0, target_reference TEXT NOT NULL DEFAULT '', + CHECK (dispatch_at_unix_millis > 0), CHECK (executor_result <> ''), CHECK (delivery_mode IN ('','SHADOW')), + CHECK (delivery_mode <> 'SHADOW' OR external_delivery_attempted = FALSE) +); +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index f83385f..0a47125 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json","task_id":"GEN2-DEPLOY-004","task_revision":2} +{"task_file":"dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json","task_id":"GEN2-CAPABILITY-001","task_revision":7} diff --git a/dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json b/dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json new file mode 100644 index 0000000..cbb7e38 --- /dev/null +++ b/dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json @@ -0,0 +1,37 @@ +{ + "task_id": "GEN2-CAPABILITY-001", + "task_revision": 7, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Add a Capability-owned, revisioned and time-bounded candidate/shadow binding lifecycle with typed CandidateReleaseContext and delegated task authority, so candidate side-effecting operations cannot fall back to stable ACTIVE bindings.", + "requirement_ids": ["REQ-GEN2-CAPABILITY-001-RELEASE-SCOPED-BINDING", "REQ-GEN2-DEPLOY-004-DYNAMIC-CLOSURE", "REQ-GEN2-AUTHORITY-001-TASK-SCOPED-DELEGATION"], + "allowed_change_scope": { + "modules": ["Capability candidate/shadow binding lifecycle and resolution", "Authority delegated grant validation/audit for candidate binding operations", "Core and Commit runtime typed composition", "candidate deployment/supervisor readiness integration", "notification shadow receiver acceptance"], + "paths": [ + "contracts/capability/**", "contracts/authority/types.go", "contracts/authority/control_plane.go", "contracts/authority/*_test.go", "contracts/commit/execution_receipt.go", "modules/commit/ports/executor.go", + "modules/capability/domain/**", "modules/capability/ports/**", "modules/authority/domain/**", "modules/authority/ports/**", "modules/commit/domain/commit.go", "modules/commit/domain/commit_test.go", + "adapters/postgres/capability_repository.go", "adapters/postgres/capability_repository_test.go", "adapters/postgres/authority_repository.go", "adapters/postgres/authority_repository_test.go", "adapters/postgres/commit_repository.go", "adapters/postgres/commit_repository_test.go", "adapters/notificationexecutor/**", + "cmd/core-runtime/main.go", "cmd/core-runtime/control_plane_authority.go", "cmd/core-runtime/control_plane_authority_test.go", + "cmd/commit-runtime/main.go", "cmd/commit-runtime/control_plane_authority.go", "cmd/commit-runtime/control_plane_authority_test.go", "cmd/commit-runtime/creator_clients.go", "cmd/commit-runtime/notification_executor.go", "cmd/commit-runtime/notification_executor_test.go", "cmd/commit-runtime/execution.go", "cmd/commit-runtime/execution_test.go", "cmd/commit-runtime/candidate_release_context.go", "cmd/commit-runtime/candidate_release_context_test.go", + "cmd/notification-receiver/**", "database/migrations/057_release_scoped_candidate_capability_bindings.sql", "database/migrations/058_commit_execution_receipts.sql", + "deploy/docker-compose.candidate-slot.yml", "deploy/run-production-canary.sh", "runtime/supervisor/**", "cmd/supervisor/**", "tests/deployment/**", + "docs/evidence/**", "docs/DEPLOYMENT.md", "docs/PROJECT_STATUS.md", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-CAPABILITY-001-release-scoped-candidate-shadow-bindings.json", "dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json", + "architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md", "contracts/secrets/types.go", "modules/authority/application/**", + "cmd/core-runtime/control_plane_authority.go", "cmd/commit-runtime/control_plane_authority.go", "database/migrations/056_delegated_control_plane_authority.sql", "deploy/docker-compose.prod.yml", "docs/CREDENTIALS_AND_SECRETS.md", "dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json" + ] + }, + "forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/identity/**", "modules/identity/**", "adapters/postgres/identity_repository.go", "database/bootstrap/**", "security/**", "go.mod", "go.sum", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["existing Capability Binding and repository owner", "existing Authority delegated service principal/grant/audit", "existing Commit executor and receipt/outcome contracts", "existing Supervisor ReleaseSet control plane"], "may_change": ["typed CandidateReleaseContext and candidate binding resolution contract", "typed delegated Authority operation/resource for create/use/revoke candidate binding", "Capability owner repository ports", "candidate ReleaseSet readiness evidence"]}, + "semantic_owner": "Capability remains the sole owner of capability binding, revision, resolution, lifecycle and binding promotion/retirement. Authority remains the sole owner of delegated grant validation/revocation/audit. Commit remains the sole owner of external action commit/execution state.", + "canonical_state": ["stable ACTIVE binding remains immutable during candidate acceptance", "candidate/shadow binding is a Capability-owned separate release-scoped revision, not a second owner", "candidate side-effecting resolution is fail-closed when the required candidate/shadow binding is absent", "no raw endpoint or secret crosses Core/Commit to bypass Capability", "shadow receipt is never a delivery WorldOutcome"], + "state_transitions": ["Authority grant -> Capability candidate/shadow binding create -> candidate typed resolve/use -> audit/receipt -> explicit revoke or expiry", "candidate binding never automatically becomes ACTIVE", "stable resolution without CandidateReleaseContext -> stable ACTIVE only"], + "side_effects_and_privileges": ["service authentication remains separate from Authority", "create/use/revoke operations require a task/release/capability/resource/time-scoped delegated grant", "candidate notification uses a synthetic shadow target and no production destination", "candidate binding cannot extend TTL, alter resource/release, grant itself rights, or promote itself"], + "persistence_and_migrations": ["additive Capability-owned release-scoped candidate binding and auditable resolution persistence only", "Authority audit stays owner-local", "no secret value is persisted", "rollback revokes/expires candidate binding and retains historical audit/evidence"], + "failure_retry_restart": ["missing/wrong-release/wrong-task/expired/revoked candidate binding denies without ACTIVE fallback", "restart reloads durable candidate binding and Authority state subject to TTL", "candidate binding creation is revisioned/idempotent", "shadow receiver failure produces a non-delivery receipt/outcome and does not create a real external action"], + "assumptions": ["migration 057 is globally free after existing migration 056", "the existing protected service credential is usable only as authentication and is never read or logged", "the notification receiver can be given a bounded shadow non-delivery mode without modifying production delivery state"], + "acceptance_scenarios": ["stable resolution returns only ACTIVE binding even when candidate bindings exist", "valid candidate release context plus grant resolves candidate/shadow binding", "wrong release/task, expired or revoked candidate binding denies", "missing required candidate binding denies with no stable fallback", "candidate binding cannot self-promote or alter its scope", "restart retains valid candidate binding/audit and revoked state", "synthetic candidate notification yields SHADOW_CAPTURED/NOT_DELIVERED_SHADOW with zero external delivery", "candidate dynamic dependency evidence identifies the notification receiver target without endpoint or secret logging"], + "required_tests": ["FAST: targeted go test ./modules/capability/... ./modules/authority/... ./adapters/postgres ./cmd/core-runtime ./cmd/commit-runtime ./cmd/notification-receiver ./runtime/supervisor ./cmd/supervisor", "FAST: targeted go vet for changed packages", "FAST: migration and compose config validation", "FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST independent: go run ./cmd/devctl/independent", "FAST whitespace: git diff --check gen2-integration --", "MILESTONE: Capability/Deploy/Authority candidate-shadow lifecycle and rollback evidence"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json index 6944dc5..b4433f1 100644 --- a/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json +++ b/dev/tasks/GEN2-DEPLOY-004-candidate-commit-runtime-releaseset.json @@ -1,11 +1,11 @@ { "task_id": "GEN2-DEPLOY-004", - "task_revision": 2, + "task_revision": 3, "architecture_revision": "ARCH-GEN2-000", "base_repo_revision": "gen2-integration", "risk_tier": "E", - "intent": "Close the isolated candidate slot over all versioned Assistant runtime dependencies of its candidate components, extend the typed Supervisor ReleaseSet accordingly, and keep stable containers untouched so the delegated Authority path can be accepted without candidate-to-stable runtime shortcuts.", - "requirement_ids": ["REQ-GEN2-DEPLOY-004-DEPENDENCY-CLOSURE", "REQ-GEN2-DEPLOY-004-CANDIDATE-COMMIT", "REQ-GEN2-AUTHORITY-001-ISOLATED-COMMIT", "REQ-GEN2-DEPLOY-003-STABLE-ID-GUARD"], + "intent": "Close the isolated candidate slot over static and Capability-resolved Assistant runtime dependencies, using GEN2-CAPABILITY-001 candidate/shadow binding resolution, and keep stable containers untouched so candidate side effects cannot fall back to production targets.", + "requirement_ids": ["REQ-GEN2-DEPLOY-004-DEPENDENCY-CLOSURE", "REQ-GEN2-DEPLOY-004-DYNAMIC-CAPABILITY-CLOSURE", "REQ-GEN2-CAPABILITY-001-RELEASE-SCOPED-BINDING", "REQ-GEN2-AUTHORITY-001-ISOLATED-COMMIT", "REQ-GEN2-DEPLOY-003-STABLE-ID-GUARD"], "allowed_change_scope": { "modules": ["deployment control", "runtime supervisor", "candidate Assistant runtime dependency closure", "inherited delegated Authority prerequisite only"], "paths": [ @@ -47,17 +47,17 @@ }, "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, "contracts": { - "consume": ["existing typed Supervisor ReleaseSet and ComponentRelease", "existing runtime health/readiness/revision endpoints", "existing delegated Authority service credential and task-scoped grant contracts", "existing shared PostgreSQL and external secret volumes"], - "may_change": ["typed Supervisor explicit required ComponentKind set and ReleaseSet evidence", "candidate Compose dependency wiring", "candidate deployment control script"] + "consume": ["existing typed Supervisor ReleaseSet and ComponentRelease", "existing runtime health/readiness/revision endpoints", "GEN2-CAPABILITY-001 CandidateReleaseContext and candidate/shadow binding resolution", "existing delegated Authority service credential and task-scoped grant contracts", "existing shared PostgreSQL and external secret volumes"], + "may_change": ["typed Supervisor explicit required ComponentKind set and static/dynamic dependency evidence", "candidate Compose dependency wiring", "candidate deployment control script"] }, "semantic_owner": "Operations/Supervisor owns operational ReleaseSet evidence; Commit remains the sole canonical owner of durable Commit state; Authority and Capability ownership do not change.", "canonical_state": ["stable and candidate instances of each owner use the same transactional canonical repository and do not form two semantic owners", "no direct stable container recreation", "no new canonical writer, schema, role, credential value, or provider routing owner", "pre-existing GEN2-AUTHORITY-001 paths are inherited only so devctl can validate the committed prerequisite diff against gen2-integration; this packet grants no new write authority for them"], - "state_transitions": ["stable dependency-closed ReleaseSet evidence retained -> isolated candidate dependency closure start -> all component health/readiness/revision and dependency-target observation -> Supervisor canary -> promote or whole ReleaseSet rollback", "missing, unhealthy, mixed-revision, or stable-targeted candidate component -> candidate ReleaseSet unhealthy -> traffic stable without stable container recreation"], + "state_transitions": ["stable dependency-closed ReleaseSet evidence retained -> isolated candidate static and Capability dependency closure start -> all component health/readiness/revision and dependency-target observation -> Supervisor canary -> promote or whole ReleaseSet rollback", "missing, unhealthy, mixed-revision, stable-targeted, or missing required candidate/shadow binding -> candidate ReleaseSet unhealthy -> traffic stable without stable container recreation"], "side_effects_and_privileges": ["production operations are limited to Docker/Compose candidate slot B and Supervisor control endpoints", "candidate Creator, Core, Commit, Continuity, Agent Worker, Agent Execution Bridge, Work Graph and Task Verifier use only candidate Assistant runtime URLs", "PostgreSQL, protected secret volumes, model/gateway services and other explicitly classified non-Assistant infrastructure remain shared", "candidate components receive only required existing SecretRefs through read-only protected mounts", "stable IDs are captured before every candidate operation and any change aborts before canary"], "persistence_and_migrations": ["no migration or durable semantic schema change", "shared PostgreSQL and existing protected volumes remain external resources and are never deleted", "Supervisor persists stable/candidate/LKG ReleaseSet evidence across restart"], "failure_retry_restart": ["missing, unhealthy, revision-mismatched, or stable-targeted candidate component rejects the whole candidate ReleaseSet", "rollback changes Supervisor traffic only and retains stable containers", "Supervisor restart reloads the complete dependency-closed LKG and candidate state", "candidate and stable owner runtimes rely on existing repository idempotency/revision preconditions for bounded test resources"], "assumptions": ["GEN2-AUTHORITY-001 implementation commits through a8be998 remain unmodified prerequisites on this task branch", "the protected control-plane secret artifact exists and can be mounted read-only without displaying it", "existing owner repositories support concurrent stable/candidate instances over bounded test resources", "any runtime target absent from the static configuration graph is classified before it is admitted as shared infrastructure"], - "acceptance_scenarios": ["candidate ReleaseSet contains the transitive closure of creator-web, core-runtime, cognitive-worker, research-runtime, commit-runtime, continuity-runtime, agent-worker, agent-execution-bridge, work-graph-runtime, and task-verifier, each at one revision", "every candidate Assistant runtime URL targets a component in the same candidate ReleaseSet; stable Assistant runtime URLs abort before canary", "missing, unhealthy, or revision-mismatched required component prevents release READY", "candidate Core -> Commit -> Continuity and Creator -> Continuity/Agent Worker remain within candidate topology while stable topology is unchanged", "candidate start/update leaves every captured stable Assistant runtime container ID unchanged", "forced candidate Continuity or Commit failure causes whole ReleaseSet rollback to stable traffic without recreating stable", "Supervisor restart preserves the complete component set in LKG and candidate evidence", "Commit readiness proves database and Authority endpoint readiness without emitting a secret"], + "acceptance_scenarios": ["candidate ReleaseSet contains the transitive static closure of creator-web, core-runtime, cognitive-worker, research-runtime, commit-runtime, continuity-runtime, agent-worker, agent-execution-bridge, work-graph-runtime, task-verifier, and every classified Capability target ReleaseComponent, each at one revision", "every candidate Assistant runtime URL targets a component in the same candidate ReleaseSet; stable Assistant runtime URLs abort before canary", "candidate side-effect resolution requires a valid same-ReleaseSet candidate/shadow binding and never falls back to ACTIVE production", "missing, unhealthy, revision-mismatched, unknown, or unbound required component prevents release READY", "candidate start/update leaves every captured stable Assistant runtime container ID unchanged", "forced candidate Continuity or Commit failure causes whole ReleaseSet rollback to stable traffic without recreating stable", "Supervisor restart preserves the complete component set, binding context and LKG evidence", "synthetic shadow notification produces no external delivery"], "required_tests": ["FAST: targeted go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted go vet ./runtime/supervisor ./cmd/supervisor", "FAST: compose config candidate slot", "FAST: targeted deployment shell regression for dependency isolation and stable ID invariance", "FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST independent: go run ./cmd/devctl/independent", "FAST whitespace: git diff --check gen2-integration --", "PRODUCTION: isolated dependency-closed ReleaseSet health, revision, candidate Continuity/Commit rollback, Supervisor restart/LKG, and stable-ID drill"], "terminal_gap_states": ["BLOCKED_BY_DEPLOYMENT_CONTROL", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] } diff --git a/development/GEN2_TASK_INDEX.md b/development/GEN2_TASK_INDEX.md index 2f5625d..9d7d7a9 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -58,5 +58,6 @@ Bounded post-M5 remediation. Provides the technical protected-secret control-pla |---|---|---|---| | `GEN2-AUTHORITY-001` | `MILESTONE` | owner decision, existing Identity/Authority/Capability lifecycle | Bounded non-interactive service principal and task-scoped control-plane delegation; does not retire V1 or change Gen2 canonical ownership. | | `GEN2-DEPLOY-004` | `MILESTONE` | GEN2-DEPLOY-003, GEN2-AUTHORITY-001 implementation | Extend the isolated candidate slot and typed Supervisor ReleaseSet with Commit runtime before delegated Authority acceptance; stable containers are never recreated by candidate operations. | +| `GEN2-CAPABILITY-001` | `MILESTONE` | owner architecture decision, GEN2-AUTHORITY-001 implementation | Capability-owned release-scoped candidate/shadow binding lifecycle for dynamic candidate dependency closure; stable ACTIVE bindings remain immutable. | Между реперными точками выполняется только FAST-профиль конкретного packet-а. diff --git a/docs/evidence/gen2-capability-001-notification-receiver-classification-2026-08-31.md b/docs/evidence/gen2-capability-001-notification-receiver-classification-2026-08-31.md new file mode 100644 index 0000000..e9fda27 --- /dev/null +++ b/docs/evidence/gen2-capability-001-notification-receiver-classification-2026-08-31.md @@ -0,0 +1,22 @@ +# GEN2-CAPABILITY-001 notification receiver classification + +## Evidence + +The notification target is implemented by this repository's +`cmd/notification-receiver` runtime. It exposes the bounded delivery and +receipt protocol used by `adapters/notificationexecutor`; it is therefore an +Assistant ReleaseComponent, not a shared external service. + +The current stable receiver persists idempotent receipts and is configured +with separate read/write secrets. Candidate acceptance must not reuse its +runtime instance, secret mount, or stable Capability ACTIVE binding. + +## Candidate decision + +The candidate ReleaseSet requires its own `notification-receiver-candidate` +instance in explicit shadow mode. That instance accepts only synthetic, +task-scoped delivery traffic and returns a shadow receipt. It has no +production delivery credential or production destination route. + +`SHADOW_CAPTURED` proves only receipt capture. It is not delivery, a sent +notification, or a verified World outcome. diff --git a/modules/authority/domain/delegated_control_plane.go b/modules/authority/domain/delegated_control_plane.go index c5530d7..ddc996d 100644 --- a/modules/authority/domain/delegated_control_plane.go +++ b/modules/authority/domain/delegated_control_plane.go @@ -24,9 +24,25 @@ func ValidateDelegatedControlPlaneGrant(nowUnixMillis int64, binding identity.Pr } func RequestMatchesDelegatedControlPlaneGrant(grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest) bool { - return grant.TaskID != "" && grant.TaskID == request.TaskID && grant.TaskRevision > 0 && grant.TaskRevision == request.TaskRevision && - request.GrantID == grant.ID && request.Operation != "" && slices.Contains(grant.Operations, request.Operation) && - grant.Resource.BindingID != "" && grant.Resource.ProviderInstanceID != "" && grant.Resource.ModelDescriptorID != "" && grant.Resource == request.Resource + if grant.TaskID == "" || grant.TaskID != request.TaskID || grant.TaskRevision < 1 || grant.TaskRevision != request.TaskRevision || + request.GrantID != grant.ID || request.Operation == "" || !slices.Contains(grant.Operations, request.Operation) { + return false + } + switch request.Operation { + case authority.DelegatedOperationCreateCandidateCapabilityBinding, authority.DelegatedOperationUseCandidateCapabilityBinding, authority.DelegatedOperationRevokeCandidateCapabilityBinding: + return validCandidateResource(grant.CandidateResource) && grant.CandidateResource == request.CandidateResource && request.Resource == (authority.CognitiveBindingResource{}) + default: + return validCognitiveResource(grant.Resource) && grant.Resource == request.Resource && request.CandidateResource == (authority.CandidateCapabilityBindingResource{}) + } +} + +func validCognitiveResource(resource authority.CognitiveBindingResource) bool { + return resource.BindingID != "" && resource.ProviderInstanceID != "" && resource.ModelDescriptorID != "" +} + +func validCandidateResource(resource authority.CandidateCapabilityBindingResource) bool { + return resource.ReleaseSetID != "" && resource.Slot != "" && resource.CapabilityID != "" && resource.ResourceScope != "" && + (resource.Mode == "CANDIDATE" || resource.Mode == "SHADOW") && resource.TargetKind != "" && resource.TargetRef != "" && len(resource.TargetDigest) == 64 } func CanIssuerRevokeDelegatedGrant(issuer identity.PrincipalID, grant authority.DelegatedControlPlaneGrant) bool { diff --git a/modules/authority/domain/delegated_control_plane_test.go b/modules/authority/domain/delegated_control_plane_test.go index 2685805..0944391 100644 --- a/modules/authority/domain/delegated_control_plane_test.go +++ b/modules/authority/domain/delegated_control_plane_test.go @@ -41,3 +41,17 @@ func TestDelegatedGrantCannotSelfRevokeOrBroaden(t *testing.T) { t.Fatal("issuer could not revoke active grant") } } + +func TestCandidateBindingGrantIsExactAndCannotBroaden(t *testing.T) { + resource := authority.CandidateCapabilityBindingResource{ReleaseSetID: "assistant-slot-b", Slot: "b", CapabilityID: "prospective-notification-delivery", ResourceScope: "synthetic-shadow-notification-v1", Mode: "SHADOW", TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", TargetDigest: "271c79ca1b1a590f1309461a87ba39bc017dfeb8d99268d1a7bd3a16d1d4b4d3"} + grant := authority.DelegatedControlPlaneGrant{ID: "candidate-grant", ServiceActorID: "service-actor", ServicePrincipalID: "service-principal", IssuerPrincipalID: "owner", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 2, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationUseCandidateCapabilityBinding}, CandidateResource: resource, IssuedAtUnixMillis: 10, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive} + binding := identity.PrincipalBinding{ActorID: "service-actor", PrincipalID: "service-principal", Active: true} + request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationUseCandidateCapabilityBinding, CandidateResource: resource} + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err != nil { + t.Fatalf("exact candidate grant denied: %v", err) + } + request.CandidateResource.ReleaseSetID = "assistant-slot-a" + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err == nil { + t.Fatal("different release set was allowed") + } +} diff --git a/modules/capability/domain/candidate_binding.go b/modules/capability/domain/candidate_binding.go new file mode 100644 index 0000000..cb5c8d9 --- /dev/null +++ b/modules/capability/domain/candidate_binding.go @@ -0,0 +1,85 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/url" + "strings" + + "git.cin.su/m/Assistant/contracts/capability" +) + +var ErrCandidateBindingRequired = errors.New("candidate capability binding required") + +type canonicalCandidateTargetDescriptor struct { + Version string `json:"version"` + BindingRevision int64 `json:"binding_revision"` + TargetKind string `json:"target_kind"` + TargetRef string `json:"target_ref"` + Protocol string `json:"protocol"` + Origin string `json:"origin"` + ReleaseSetID string `json:"release_set_id"` + Slot string `json:"slot"` + TaskID string `json:"task_id"` + TaskRevision int64 `json:"task_revision"` + CapabilityID string `json:"capability_id"` + ResourceScope string `json:"resource_scope"` + Mode string `json:"mode"` +} + +func CandidateTargetDigest(value capability.CandidateBinding) (string, error) { + origin, err := url.Parse(value.TargetOrigin) + if err != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") { + return "", ErrCandidateBindingRequired + } + origin.Scheme, origin.Host, origin.Path = strings.ToLower(origin.Scheme), strings.ToLower(origin.Host), "" + descriptor := canonicalCandidateTargetDescriptor{Version: "v1", BindingRevision: value.Revision, TargetKind: value.Context.TargetKind, TargetRef: value.Context.TargetRef, Protocol: string(value.Protocol), Origin: origin.String(), ReleaseSetID: value.Context.ReleaseSetID, Slot: value.Context.Slot, TaskID: value.Context.TaskID, TaskRevision: value.Context.TaskRevision, CapabilityID: string(value.Context.CapabilityID), ResourceScope: value.Context.ResourceScope, Mode: string(value.Context.Mode)} + encoded, err := json.Marshal(descriptor) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +// ValidateCandidateBinding validates Capability-owned candidate state. Stable +// Binding validation intentionally remains stricter and HTTPS-only. +func ValidateCandidateBinding(value capability.CandidateBinding) error { + context := value.Context + if value.ID == "" || value.Revision < 1 || value.BaseActiveBindingID == "" || value.BaseActiveBindingRevision < 1 || + context.ReleaseSetID == "" || context.Slot == "" || context.TaskID == "" || context.TaskRevision < 1 || context.PrincipalID == "" || + context.CapabilityID == "" || context.ResourceScope == "" || context.TargetKind == "" || context.TargetRef == "" || len(context.TargetDigest) != 64 || context.AuthorityGrantID == "" || + (value.Status != capability.CandidateBindingActive && value.Status != capability.CandidateBindingRevoked) || + (value.Context.Mode != capability.CandidateBindingModeCandidate && value.Context.Mode != capability.CandidateBindingModeShadow) || + value.Protocol != capability.ProtocolIdempotentReceiptV1 || value.CreatedAtUnixMillis < 1 || value.ExpiresAtUnixMillis <= value.CreatedAtUnixMillis || + !fixedPath(value.DeliveryPath) || !fixedPath(value.ReceiptPath) || value.DeliveryPath == value.ReceiptPath { + return ErrCandidateBindingRequired + } + origin, err := url.Parse(value.TargetOrigin) + if err != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") { + return ErrCandidateBindingRequired + } + if context.Mode == capability.CandidateBindingModeCandidate && origin.Scheme != "https" { + return ErrCandidateBindingRequired + } + if context.Mode == capability.CandidateBindingModeShadow && origin.Scheme != "http" && origin.Scheme != "https" { + return ErrCandidateBindingRequired + } + if strings.TrimSpace(value.TargetOrigin) != value.TargetOrigin { + return ErrCandidateBindingRequired + } + digest, err := CandidateTargetDigest(value) + if err != nil || digest != context.TargetDigest { + return ErrCandidateBindingRequired + } + return nil +} + +func CandidateBindingInvocable(value capability.CandidateBinding, context capability.CandidateReleaseContext, nowUnixMillis int64) error { + if ValidateCandidateBinding(value) != nil || value.Status != capability.CandidateBindingActive || nowUnixMillis < value.CreatedAtUnixMillis || nowUnixMillis >= value.ExpiresAtUnixMillis || value.Context != context { + return ErrCandidateBindingRequired + } + return nil +} diff --git a/modules/capability/domain/candidate_binding_test.go b/modules/capability/domain/candidate_binding_test.go new file mode 100644 index 0000000..eef8e62 --- /dev/null +++ b/modules/capability/domain/candidate_binding_test.go @@ -0,0 +1,67 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/identity" +) + +func validCandidateBinding() capability.CandidateBinding { + context := capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: identity.PrincipalID("owner-1"), CapabilityID: "prospective-notification-delivery", ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant-1"} + binding := capability.CandidateBinding{ID: "candidate-binding-1", Revision: 1, BaseActiveBindingID: "stable-binding-1", BaseActiveBindingRevision: 1, Context: context, TargetOrigin: "http://notification-receiver-candidate:8085", DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 10, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive} + digest, err := CandidateTargetDigest(binding) + if err != nil { + panic(err) + } + binding.Context.TargetDigest = digest + return binding +} + +func TestCandidateBindingIsExactAndFailsClosed(t *testing.T) { + binding := validCandidateBinding() + if err := CandidateBindingInvocable(binding, binding.Context, 20); err != nil { + t.Fatalf("valid candidate binding denied: %v", err) + } + for _, mutate := range []func(*capability.CandidateReleaseContext){ + func(v *capability.CandidateReleaseContext) { v.ReleaseSetID = "assistant-slot-other" }, + func(v *capability.CandidateReleaseContext) { v.TaskID = "GEN2-OTHER" }, + func(v *capability.CandidateReleaseContext) { v.AuthorityGrantID = "other-grant" }, + } { + request := binding.Context + mutate(&request) + if err := CandidateBindingInvocable(binding, request, 20); err == nil { + t.Fatal("mismatched candidate context was allowed") + } + } + if err := CandidateBindingInvocable(binding, binding.Context, 100); err == nil { + t.Fatal("expired candidate binding was allowed") + } + binding.Status = capability.CandidateBindingRevoked + if err := CandidateBindingInvocable(binding, binding.Context, 20); err == nil { + t.Fatal("revoked candidate binding was allowed") + } +} + +func TestCandidateTargetDigestBindsSemanticDescriptor(t *testing.T) { + binding := validCandidateBinding() + digest, err := CandidateTargetDigest(binding) + if err != nil || digest != binding.Context.TargetDigest { + t.Fatalf("digest=%q err=%v", digest, err) + } + if digest != "f9f9fa49a0727444c280c092d5bb340f3a8f33cf325c5396f5ae177a6666da35" { + t.Fatalf("canonical descriptor digest changed: %q", digest) + } + changed := binding + changed.Context.ResourceScope = "different-resource" + other, err := CandidateTargetDigest(changed) + if err != nil || other == digest { + t.Fatalf("scope did not affect digest: %q %q err=%v", digest, other, err) + } + changed = binding + changed.Revision++ + other, err = CandidateTargetDigest(changed) + if err != nil || other == digest { + t.Fatalf("binding revision did not affect digest: %q %q err=%v", digest, other, err) + } +} diff --git a/modules/capability/ports/repository.go b/modules/capability/ports/repository.go index 74e316d..f1e740a 100644 --- a/modules/capability/ports/repository.go +++ b/modules/capability/ports/repository.go @@ -14,6 +14,9 @@ type Repository interface { SaveBinding(context.Context, capability.Binding) error LoadBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) LoadActiveBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) + SaveCandidateBinding(context.Context, capability.CandidateBinding) error + ResolveCandidateBinding(context.Context, capability.CandidateReleaseContext, int64) (capability.CandidateBindingResolution, error) + RevokeCandidateBinding(context.Context, capability.CapabilityBindingID, int64, int64) (bool, error) SaveProviderInstance(context.Context, capability.ProviderInstance) error SaveModelDescriptor(context.Context, capability.ModelDescriptor) error SaveCognitiveBinding(context.Context, capability.CognitiveBinding) error diff --git a/modules/commit/domain/commit.go b/modules/commit/domain/commit.go index 1ed07fb..4631227 100644 --- a/modules/commit/domain/commit.go +++ b/modules/commit/domain/commit.go @@ -98,19 +98,26 @@ func validDigest(value string) bool { // Execute records UNKNOWN_OUTCOME rather than silently retrying an ambiguous // external mutation. func Execute(action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID, executor ports.Executor) (commitcontract.ActionAttempt, error) { + attempt, _, err := ExecuteWithResult(action, attemptID, executor) + return attempt, err +} + +// ExecuteWithResult preserves executor evidence for the Commit runtime while +// keeping the original domain API compatible with existing callers. +func ExecuteWithResult(action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID, executor ports.Executor) (commitcontract.ActionAttempt, ports.ExecutionResult, error) { if action.State != commitcontract.ActionAuthorized || attemptID == "" { - return commitcontract.ActionAttempt{}, ErrUnknownOutcome + return commitcontract.ActionAttempt{}, ports.ExecutionResult{}, ErrUnknownOutcome } attempt := commitcontract.ActionAttempt{ID: attemptID, AuthorizedID: action.ID, IntentID: action.Proposal.IntentID, State: commitcontract.ActionExecuting} result := executor.Execute(action) if !result.Known { attempt.State = commitcontract.ActionUnknownOutcome - return attempt, ErrUnknownOutcome + return attempt, result, ErrUnknownOutcome } if result.Succeeded { attempt.State = commitcontract.ActionSucceeded - return attempt, nil + return attempt, result, nil } attempt.State = commitcontract.ActionFailed - return attempt, nil + return attempt, result, nil } diff --git a/modules/commit/ports/executor.go b/modules/commit/ports/executor.go index 2af4ff4..802f12c 100644 --- a/modules/commit/ports/executor.go +++ b/modules/commit/ports/executor.go @@ -19,6 +19,7 @@ type Reconciler interface { type ExecutionResult struct { Succeeded bool Known bool + Receipt *commit.ExecutionReceipt } // AuthorityValidator is the remote-capable boundary to the Authority owner.