This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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-а.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user