feat: enable Creator notification command #10

Merged
m merged 1 commits from codex/creator-notification-command-v1 into main 2026-08-16 16:19:55 +00:00
16 changed files with 622 additions and 18 deletions
+23 -5
View File
@@ -75,21 +75,39 @@ func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error)
}
type Executor struct {
binding capability.Binding
secret string
client *http.Client
binding capability.Binding
secret string
client *http.Client
executeAllowed bool
}
func New(binding capability.Binding, secret string, client *http.Client) (*Executor, error) {
if err := capabilitydomain.UsableBinding(binding); err != nil || strings.TrimSpace(secret) == "" || client == nil {
if err := capabilitydomain.UsableBinding(binding); err != nil {
return nil, ErrInvalidNotification
}
return newExecutor(binding, secret, client, true)
}
func NewReconciler(binding capability.Binding, secret string, client *http.Client) (*Executor, error) {
if err := capabilitydomain.ValidateBinding(binding); err != nil {
return nil, ErrInvalidNotification
}
return newExecutor(binding, secret, client, false)
}
func newExecutor(binding capability.Binding, secret string, client *http.Client, executeAllowed bool) (*Executor, error) {
if strings.TrimSpace(secret) == "" || client == nil {
return nil, ErrInvalidNotification
}
copyClient := *client
copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
return &Executor{binding: binding, secret: secret, client: &copyClient}, nil
return &Executor{binding: binding, secret: secret, client: &copyClient, executeAllowed: executeAllowed}, nil
}
func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if !e.executeAllowed {
return ports.ExecutionResult{Known: true}
}
payload, err := e.requestFor(action)
if err != nil {
return ports.ExecutionResult{Known: true}
+30
View File
@@ -8,8 +8,10 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/work"
@@ -95,10 +97,38 @@ func (c creatorCoreClient) Binding(ctx context.Context, actor identity.ActorID,
return binding, nil
}
func (c creatorCoreClient) ActiveCapabilityBinding(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) (capability.Binding, error) {
var binding capability.Binding
target := *c.baseURL
target.Path = "/v1/capability-bindings/active"
query := target.Query()
query.Set("capability_id", string(capabilityID))
query.Set("owner_principal_id", string(owner))
target.RawQuery = query.Encode()
status, err := c.getURL(ctx, target, "", &binding)
if err != nil || status != http.StatusOK {
return capability.Binding{}, errCreatorQueryUnavailable
}
return binding, nil
}
func (c creatorCoreClient) CapabilityBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) {
var binding capability.Binding
status, err := c.get(ctx, "/v1/capability-bindings/"+url.PathEscape(string(id))+"/revisions/"+strconv.FormatInt(revision, 10), "", &binding)
if err != nil || status != http.StatusOK {
return capability.Binding{}, errCreatorQueryUnavailable
}
return binding, nil
}
func (c creatorCoreClient) get(ctx context.Context, path, authorization string, destination any) (int, error) {
target := *c.baseURL
target.Path = path
target.RawQuery = ""
return c.getURL(ctx, target, authorization, destination)
}
func (c creatorCoreClient) getURL(ctx context.Context, target url.URL, authorization string, destination any) (int, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return 0, errCreatorQueryUnavailable
+116 -1
View File
@@ -4,12 +4,16 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/notificationexecutor"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
@@ -25,6 +29,7 @@ type creatorSessionSource interface {
Session(context.Context, string) (identity.CreatorSession, error)
Task(context.Context, work.TaskID) (work.TaskTarget, error)
Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error)
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
}
type creatorContinuitySource interface {
@@ -60,6 +65,13 @@ type creatorSatisfyRequest struct {
EvidenceRef string
}
type creatorNotifyRequest struct {
Target work.TaskTarget
ProspectiveItemID continuity.ProspectiveItemID
ExpectedRevision continuity.ProspectiveRevision
Confirm bool
}
type creatorActionResponse struct {
AuthorizedActionID commitcontract.AuthorizedActionID
ActionIntentID commitcontract.ActionIntentID
@@ -83,6 +95,7 @@ type creatorActionHistoryResponse struct {
func registerCreatorCommandRoutes(mux *http.ServeMux, handler creatorCommandHandler) {
mux.HandleFunc("POST /v1/creator/continuity/satisfy-delivered", handler.satisfyDelivered)
mux.HandleFunc("POST /v1/creator/continuity/notify-delivered", handler.notifyDelivered)
mux.HandleFunc("POST /v1/creator/authorized-actions/{id}/reconcile", handler.reconcile)
mux.HandleFunc("GET /v1/creator/authorized-actions", handler.listAuthorizedActions)
}
@@ -113,9 +126,13 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
return
}
prospectiveItemID := action.Proposal.ContinuitySubject.ProspectiveItemID
if action.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
prospectiveItemID = action.Proposal.NotificationSubject.ProspectiveItemID
}
item := creatorActionHistoryItem{
AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID,
Operation: action.Proposal.Operation, ProspectiveItemID: action.Proposal.ContinuitySubject.ProspectiveItemID,
Operation: action.Proposal.Operation, ProspectiveItemID: prospectiveItemID,
State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis,
}
if found {
@@ -129,6 +146,104 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h
_ = json.NewEncoder(w).Encode(creatorActionHistoryResponse{Items: items})
}
func (h creatorCommandHandler) notifyDelivered(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
if !ok {
return
}
var request creatorNotifyRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || !request.Confirm || request.Target.TaskID == "" || request.Target.GoalID == "" || request.Target.ScopeID == "" || request.Target.Revision == 0 || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
http.Error(w, "invalid Creator notification request", http.StatusBadRequest)
return
}
item, err := h.continuity.Item(r.Context(), authorization, request.ProspectiveItemID)
if errors.Is(err, errCreatorOwnerDenied) {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if errors.Is(err, errCreatorSubjectMissing) {
http.Error(w, "prospective item not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Continuity owner validation unavailable", http.StatusServiceUnavailable)
return
}
if item.OwnerPrincipalID != session.PrincipalID {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.ScopeID != request.Target.ScopeID || item.GoalID != request.Target.GoalID {
http.Error(w, "prospective item is stale or outside justification", http.StatusConflict)
return
}
target, err := h.core.Task(r.Context(), request.Target.TaskID)
if err != nil || target != request.Target {
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
return
}
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
if err != nil {
http.Error(w, "Creator authority unavailable", http.StatusConflict)
return
}
identityBinding, err := h.core.Binding(r.Context(), session.ActorID, session.PrincipalID)
if err != nil {
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
return
}
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, session.PrincipalID)
if err != nil {
http.Error(w, "notification capability unavailable", http.StatusConflict)
return
}
intentID, authorizedID, attemptID, err := h.actionIDs()
if err != nil {
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
return
}
title := "Домовой: напоминание"
message := fmt.Sprintf("Проспективная запись %s ожидает вашего внимания.", item.ID)
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
return
}
proposal := commitcontract.ActionProposal{IntentID: intentID, ActorID: session.ActorID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: request.Target, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest}}
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
if err != nil {
http.Error(w, "Creator authorization denied", http.StatusConflict)
return
}
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
http.Error(w, "commit authorized action", http.StatusInternalServerError)
return
}
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
http.Error(w, "execute committed notification", http.StatusInternalServerError)
return
}
status := http.StatusCreated
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
status = http.StatusAccepted
}
writeCreatorActionResponse(w, status, action, attempt)
}
func ensureCreatorEOF(decoder *json.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
return errors.New("trailing JSON")
}
func (h creatorCommandHandler) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
+46 -5
View File
@@ -11,6 +11,7 @@ import (
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
@@ -19,10 +20,12 @@ import (
)
type fakeCreatorCore struct {
session identity.CreatorSession
target work.TaskTarget
binding identity.PrincipalBinding
sessionErr error
session identity.CreatorSession
target work.TaskTarget
binding identity.PrincipalBinding
capabilityBinding capability.Binding
capabilityErr error
sessionErr error
}
func (f fakeCreatorCore) Session(context.Context, string) (identity.CreatorSession, error) {
@@ -34,6 +37,9 @@ func (f fakeCreatorCore) Task(context.Context, work.TaskID) (work.TaskTarget, er
func (f fakeCreatorCore) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) {
return f.binding, nil
}
func (f fakeCreatorCore) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) {
return f.capabilityBinding, f.capabilityErr
}
type fakeCreatorContinuity struct {
item continuity.ProspectiveItem
@@ -120,6 +126,41 @@ func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) {
}
}
func TestCreatorNotificationDerivesTypedEnvelopeAndLeavesContinuityState(t *testing.T) {
handler, repository, executor := validCreatorCommandHandler()
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(`{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true}`))
request.Header.Set("Authorization", "Bearer creator-session")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusCreated || executor.calls != 1 {
t.Fatalf("status=%d calls=%d body=%s", response.Code, executor.calls, response.Body.String())
}
action := repository.action
subject := action.Proposal.NotificationSubject
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification || action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification || subject.BindingID != "notification-binding" || subject.BindingRevision != 1 || len(subject.RequestDigest) != 64 || subject.RenderedTitle == "" || subject.RenderedMessage == "" || action.Proposal.ContinuitySubject.ProspectiveItemID != "" {
t.Fatalf("invalid notification action: %#v", action)
}
}
func TestCreatorNotificationRejectsMissingConfirmationAndHostileFields(t *testing.T) {
for name, body := range map[string]string{"no confirmation": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4}`, "hostile destination": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true,"URL":"https://evil.invalid","Body":"arbitrary"}`} {
t.Run(name, func(t *testing.T) {
handler, repository, executor := validCreatorCommandHandler()
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(body))
request.Header.Set("Authorization", "Bearer creator-session")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || executor.calls != 0 || repository.action.ID != "" {
t.Fatalf("status=%d calls=%d", response.Code, executor.calls)
}
})
}
}
func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *testing.T) {
tests := []struct {
name string
@@ -230,7 +271,7 @@ func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing
func validCreatorCommandHandler() (creatorCommandHandler, *fakeExecutionRepository, *knownExecutor) {
session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000}
target := work.TaskTarget{TaskID: "task-1", GoalID: "goal-1", ScopeID: "scope-1", Revision: 3}
core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}}
core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}, capabilityBinding: capability.Binding{ID: "notification-binding", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: session.PrincipalID, Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}}
item := continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: session.PrincipalID, ScopeID: target.ScopeID, GoalID: target.GoalID, State: continuity.ProspectiveDelivered, Revision: 4}
grant := authority.AuthorityGrant{ID: creatorGrantID, ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: target.ScopeID, Class: authority.GrantClassCreator, Revision: 2, Active: true}
repository := &fakeExecutionRepository{}
+31 -5
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/continuityexecutor"
@@ -59,13 +60,10 @@ func main() {
if executorToken == "" {
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
}
executor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
continuityExecutor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
registerExecutionRoutes(mux, repository, executor, executor, executorToken)
creatorHTTPClient := &http.Client{Timeout: 5 * time.Second}
creatorCore, err := newCreatorCoreClient(coreURL, creatorHTTPClient)
if err != nil {
@@ -75,9 +73,21 @@ func main() {
if err != nil {
log.Fatal(err)
}
notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE")
if err != nil {
log.Fatal(err)
}
notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF")
if notificationSecretRef == "" {
log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required")
}
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken)
registerCreatorCommandRoutes(mux, creatorCommandHandler{
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
executor: executor, reconciler: executor, newID: ids.New, now: time.Now,
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
})
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
var request authorizationRequest
@@ -138,6 +148,22 @@ func main() {
}
}
func readCommitSecretFile(name string) (string, error) {
path := os.Getenv(name)
if path == "" {
return "", fmt.Errorf("%s is required", name)
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
secret := strings.TrimSpace(string(value))
if secret == "" {
return "", fmt.Errorf("%s is empty", name)
}
return secret, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
@@ -0,0 +1,62 @@
package main
import (
"context"
"net/http"
"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"
"git.cin.su/m/Assistant/modules/commit/ports"
)
type notificationBindingSource interface {
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error)
}
type operationDispatcher struct {
continuityExecutor ports.Executor
continuityReconciler ports.Reconciler
bindings notificationBindingSource
secretRef string
secret string
client *http.Client
}
func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
return d.continuityExecutor.Execute(action)
}
subject := action.Proposal.NotificationSubject
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
if err != nil || exact.SecretRef != d.secretRef {
return ports.ExecutionResult{Known: true}
}
active, err := d.bindings.ActiveCapabilityBinding(context.Background(), action.Proposal.CapabilityID, action.PrincipalID)
if err != nil || active != exact {
return ports.ExecutionResult{Known: true}
}
executor, err := notificationexecutor.New(exact, d.secret, d.client)
if err != nil {
return ports.ExecutionResult{Known: true}
}
return executor.Execute(action)
}
func (d operationDispatcher) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
return d.continuityReconciler.Reconcile(action)
}
subject := action.Proposal.NotificationSubject
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
if err != nil || exact.SecretRef != d.secretRef {
return ports.ExecutionResult{}
}
reconciler, err := notificationexecutor.NewReconciler(exact, d.secret, d.client)
if err != nil {
return ports.ExecutionResult{}
}
return reconciler.Reconcile(action)
}
@@ -0,0 +1,83 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
)
type fakeNotificationBindings struct {
exact capability.Binding
active capability.Binding
exactErr error
activeErr error
}
func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) {
return f.exact, f.exactErr
}
func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) {
return f.active, f.activeErr
}
func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction {
t.Helper()
payload := notificationexecutor.Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: "Пора вернуться"}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
t.Fatal(err)
}
return commitcontract.AuthorizedAction{PrincipalID: binding.OwnerPrincipalID, Proposal: commitcontract.ActionProposal{IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 1, TemplateCode: payload.TemplateCode, BindingID: binding.ID, BindingRevision: binding.Revision, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest}}}
}
func dispatcherBinding(origin string) capability.Binding {
return capability.Binding{ID: "binding-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: "owner-1", Revision: 1, State: capability.BindingActive, HTTPSOrigin: origin, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}
}
func TestOperationDispatcherRequiresExactCurrentBinding(t *testing.T) {
var action commitcontract.AuthorizedAction
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
}))
defer server.Close()
binding := dispatcherBinding(server.URL)
action = dispatcherAction(t, binding)
fallback := &knownExecutor{}
dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding, active: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()}
if result := dispatcher.Execute(action); !result.Known || !result.Succeeded {
t.Fatalf("execute=%+v", result)
}
stale := binding
stale.Revision = 2
dispatcher.bindings = fakeNotificationBindings{exact: binding, active: stale}
if result := dispatcher.Execute(action); !result.Known || result.Succeeded {
t.Fatalf("stale binding=%+v", result)
}
}
func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) {
var action commitcontract.AuthorizedAction
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatal("reconciliation mutated receiver")
}
_ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
}))
defer server.Close()
binding := dispatcherBinding(server.URL)
action = dispatcherAction(t, binding)
binding.State = capability.BindingRetired
binding.Revision = 1
fallback := &knownExecutor{}
dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()}
if result := dispatcher.Reconcile(action); !result.Known || !result.Succeeded {
t.Fatalf("reconcile=%+v", result)
}
}
+2
View File
@@ -159,6 +159,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
return targetCommit, "/v1/creator/authorized-actions", true
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
return targetCommit, "/v1/creator/continuity/satisfy-delivered", true
case method == http.MethodPost && path == "/api/actions/notify-delivered":
return targetCommit, "/v1/creator/continuity/notify-delivered", true
case method == http.MethodPost && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"):
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/actions/"), "/reconcile")
if validID(id) {
+29
View File
@@ -74,6 +74,7 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
{http.MethodGet, "/api/tasks/../../healthz"},
{http.MethodPost, "/api/actions/arbitrary"},
{http.MethodGet, "/api/actions/satisfy-delivered"},
{http.MethodGet, "/api/actions/notify-delivered"},
} {
request := httptest.NewRequest(test.method, test.path, nil)
response := httptest.NewRecorder()
@@ -158,6 +159,34 @@ func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) {
}
}
func TestGatewayRoutesTypedNotificationCommandToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/continuity/notify-delivered" || r.Method != http.MethodPost {
t.Fatalf("unexpected Commit target %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
text := string(body)
if !strings.Contains(text, "prospective-1") || !strings.Contains(text, "Confirm") || strings.Contains(text, "URL") || strings.Contains(text, "CapabilityID") {
t.Fatalf("unexpected notification body %s", text)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"State":"SUCCEEDED"}`)
}))
defer commitRuntime.Close()
handler, err := newHandler("http://core-runtime.invalid", "http://continuity-runtime.invalid", commitRuntime.URL, commitRuntime.Client())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/actions/notify-delivered", strings.NewReader(`{"ProspectiveItemID":"prospective-1","Confirm":true}`))
request.Header.Set("Authorization", "Bearer bounded-session")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestGatewayRoutesCreatorActionHistoryToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/authorized-actions" || r.URL.RawQuery != "limit=25" || r.Method != http.MethodGet {
@@ -198,6 +198,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.action-result strong[data-state="FAILED"] { color: var(--bad); }
.action-result strong[data-state="UNKNOWN_OUTCOME"] { color: var(--warn); }
.action-result .secondary-button { min-width: 154px; margin: 0; padding: 0 14px; }
.notification-command { grid-column: 1 / -1; padding: 14px 0 0; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-top: 1px solid var(--line-soft); }
.notification-command div { min-width: 0; }
.notification-command strong { display: block; margin-bottom: 5px; color: var(--ink); font: 600 10px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.notification-command span { display: block; color: var(--muted); font-size: 11px; line-height: 1.45; }
.notification-command .secondary-button { flex: 0 0 auto; min-width: 190px; margin: 0; }
.confirmation-dialog { width: min(560px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #111915; box-shadow: var(--shadow); }
.confirmation-dialog::backdrop { background: rgba(3, 6, 5, .78); backdrop-filter: blur(5px); }
.confirmation-dialog form { padding: 30px; }
@@ -269,6 +274,8 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.commit-zone-copy { grid-column: auto; grid-row: auto; margin-bottom: 6px; }
.action-result { grid-template-columns: 1fr; align-items: stretch; }
.action-result .secondary-button { width: 100%; }
.notification-command { align-items: stretch; flex-direction: column; }
.notification-command .secondary-button { width: 100%; }
.confirmation-dialog form { padding: 24px 20px; }
.confirmation-grid { grid-template-columns: 1fr; }
.confirmation-grid .wide { grid-column: auto; }
+63
View File
@@ -11,6 +11,7 @@ let toastTimer;
let currentTask = null;
let currentProspective = null;
let pendingCommand = null;
let pendingNotification = null;
let lastAuthorizedActionID = "";
function setText(id, value, fallback = "—") {
@@ -93,6 +94,7 @@ function endSession(message = "Сессия завершена.") {
currentTask = null;
currentProspective = null;
pendingCommand = null;
pendingNotification = null;
lastAuthorizedActionID = "";
notify(message);
}
@@ -426,6 +428,7 @@ async function loadCapability(capabilityID) {
function updateActionReadiness() {
const button = byId("prepare-satisfaction");
const notificationButton = byId("prepare-notification");
if (!button) return;
const evidence = byId("satisfaction-evidence").value.trim();
let message = "Доступно только для доставленного ожидания с проверенной Work-задачей.";
@@ -441,6 +444,19 @@ function updateActionReadiness() {
}
button.disabled = !ready;
setText("action-readiness", message);
let notificationReady = false;
let notificationMessage = "Доступно для доставленного ожидания с проверенной Work-задачей.";
if (!currentProspective) notificationMessage = "Сначала выберите и загрузите ожидание.";
else if (currentProspective.State !== "DELIVERED") notificationMessage = `Состояние ${currentProspective.State || "UNKNOWN"}: уведомление доступно только для DELIVERED.`;
else if (!currentTask) notificationMessage = "Загрузите проверенную Work-задачу как обоснование.";
else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) notificationMessage = "Цель или область Work-задачи не совпадает с ожиданием.";
else {
notificationReady = true;
notificationMessage = `Готово: receiver получит фиксированный шаблон для ${currentProspective.ID} · rev ${currentProspective.Revision}.`;
}
notificationButton.disabled = !notificationReady;
setText("notification-readiness", notificationMessage);
}
byId("satisfaction-evidence").addEventListener("input", updateActionReadiness);
@@ -473,6 +489,53 @@ byId("cancel-satisfaction").addEventListener("click", () => {
byId("satisfaction-dialog").close();
});
byId("prepare-notification").addEventListener("click", () => {
updateActionReadiness();
if (byId("prepare-notification").disabled || !currentTask || !currentProspective) return;
pendingNotification = {
Target: {TaskID: currentTask.TaskID, GoalID: currentTask.GoalID, ScopeID: currentTask.ScopeID, Revision: currentTask.Revision},
ProspectiveItemID: currentProspective.ID,
ExpectedRevision: currentProspective.Revision,
Confirm: true,
};
setText("notify-confirm-prospective", pendingNotification.ProspectiveItemID);
setText("notify-confirm-prospective-revision", pendingNotification.ExpectedRevision);
setText("notify-confirm-task", pendingNotification.Target.TaskID);
setText("notify-confirm-task-revision", pendingNotification.Target.Revision);
setText("notification-confirmation-error", "", "");
byId("notification-dialog").showModal();
});
byId("cancel-notification").addEventListener("click", () => {
pendingNotification = null;
byId("notification-dialog").close();
});
byId("confirm-notification").addEventListener("click", async () => {
if (!pendingNotification) return;
const command = pendingNotification;
const button = byId("confirm-notification");
const prepare = byId("prepare-notification");
button.disabled = true;
prepare.disabled = true;
button.textContent = "Отправляю…";
setText("notification-confirmation-error", "", "");
try {
const result = await request("/api/actions/notify-delivered", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(command)});
pendingNotification = null;
byId("notification-dialog").close();
renderActionResult(result);
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
} catch (error) {
setText("notification-confirmation-error", error.message);
} finally {
button.disabled = false;
button.textContent = "Подтверждаю отправку";
updateActionReadiness();
}
});
byId("confirm-satisfaction").addEventListener("click", async () => {
if (!pendingCommand) return;
const command = pendingCommand;
+24
View File
@@ -164,6 +164,10 @@
<button id="prepare-satisfaction" class="commit-button" type="button" disabled>
<span>Отметить исполненным</span><span aria-hidden="true">→</span>
</button>
<div class="notification-command">
<div><strong>Windows-напоминание</strong><span id="notification-readiness">Доступно для доставленного ожидания с проверенной Work-задачей.</span></div>
<button id="prepare-notification" class="secondary-button" type="button" disabled>Отправить уведомление</button>
</div>
<div id="action-result" class="action-result" hidden>
<div><span>ИСХОД</span><strong id="action-state">—</strong></div>
<div><span>ACTION</span><code id="action-id">—</code></div>
@@ -209,6 +213,26 @@
</form>
</dialog>
<dialog id="notification-dialog" class="confirmation-dialog" aria-labelledby="notification-confirmation-title">
<form method="dialog">
<p class="eyebrow">commit / внешнее действие</p>
<h2 id="notification-confirmation-title">Отправить напоминание?</h2>
<p class="confirmation-copy">Домовой создаст неизменяемое авторизованное действие и передаст типизированное уведомление в закреплённый receiver. Состояние Continuity не изменится; доставка не означает прочтение.</p>
<dl class="confirmation-grid">
<div><dt>Ожидание</dt><dd id="notify-confirm-prospective">—</dd></div>
<div><dt>Ревизия</dt><dd id="notify-confirm-prospective-revision">—</dd></div>
<div><dt>Work-задача</dt><dd id="notify-confirm-task">—</dd></div>
<div><dt>Ревизия Work</dt><dd id="notify-confirm-task-revision">—</dd></div>
<div class="wide"><dt>Канал</dt><dd>Локальное уведомление Windows · закреплённый receiver</dd></div>
</dl>
<p id="notification-confirmation-error" class="form-error" role="alert"></p>
<div class="confirmation-actions">
<button id="cancel-notification" class="text-button" type="button">Отмена</button>
<button id="confirm-notification" class="commit-button" type="button">Подтверждаю отправку</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/assets/console.js" defer></script>
</body>
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000041-notification-delivery-foundation.json",
"task_id": "DEV-000041",
"task_file": "dev/tasks/DEV-000042-creator-notification-command.json",
"task_id": "DEV-000042",
"task_revision": 1
}
@@ -0,0 +1,46 @@
{
"task_id": "DEV-000042",
"task_revision": 1,
"architecture_revision": "ADR-0005",
"base_repo_revision": "6e0040fe89c938549295798c77006877902a338d",
"risk_tier": "E",
"intent": "Enable the ADR-0005 typed prospective notification command through the authenticated Creator flow, exact CapabilityBinding validation, dynamic Commit Executor dispatch, and an explicit confirmation control in the Creator console without installing production credentials or deploying.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-CONT-001", "ADR-0005"],
"allowed_change_scope": {
"modules": ["commit"],
"paths": [
"modules/commit/domain/commit.go",
"modules/commit/domain/notification_test.go",
"adapters/notificationexecutor/executor.go",
"adapters/notificationexecutor/executor_test.go",
"cmd/commit-runtime/creator_clients.go",
"cmd/commit-runtime/creator_command.go",
"cmd/commit-runtime/creator_command_test.go",
"cmd/commit-runtime/main.go",
"cmd/commit-runtime/notification_executor.go",
"cmd/commit-runtime/notification_executor_test.go",
"cmd/creator-web/server.go",
"cmd/creator-web/server_test.go",
"cmd/creator-web/static/index.html",
"cmd/creator-web/static/assets/console.js",
"cmd/creator-web/static/assets/console.css",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000042-creator-notification-command.json"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "ci/**", ".gitea/**", "database/**", "deploy/**", "security/**", "modules/identity/**", "modules/authority/**", "modules/work/**", "modules/continuity/**"]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["contracts.identity@v1", "contracts.authority@v1", "contracts.work@v1", "contracts.continuity@v1", "contracts.capability@v1", "contracts.commit@v1", "ADR-0005"], "may_change": []},
"semantic_owner": "Commit owns action evidence and attempts; Capability owns binding; Continuity owns the unchanged ProspectiveItem; Creator UI is a replaceable projection",
"canonical_state": ["existing Commit authorized action envelopes and attempts only"],
"state_transitions": ["notification action AUTHORIZED to EXECUTING to SUCCEEDED, FAILED, or UNKNOWN_OUTCOME", "ProspectiveItem remains DELIVERED"],
"side_effects_and_privileges": ["new Creator route may invoke only deliver-prospective-notification-v1 after explicit confirmation", "Executor resolves only the exact committed binding and configured external secret reference", "no credential installation or production call in this task"],
"persistence_and_migrations": ["uses DEV-000041 additive schema; no migration in this task"],
"failure_retry_restart": ["stale item, Work target, grant, binding, owner or digest fail closed", "ambiguous PUT records UNKNOWN_OUTCOME", "reconciliation uses GET only", "retired historical binding may reconcile but cannot execute a new send"],
"assumptions": ["one configured write secret reference is sufficient for the initial receiver", "browser confirmation is explicit per notification request", "docker-test remains prohibited"],
"acceptance_scenarios": ["browser cannot submit actor, principal, capability, operation, destination, template, body, IDs, headers or credentials", "confirmation=false is rejected", "foreign owner, non-DELIVERED or stale item, stale Work, invalid grant, and missing/unhealthy/revised binding are rejected before external execution", "server renders the fixed V1 message and digest", "dynamic executor dispatch leaves existing Continuity satisfaction unchanged", "action history identifies notification actions", "UI exposes clear pending/success/unknown/error states and remains usable on desktop/mobile"],
"required_tests": ["domain authorization", "hostile request fields", "dynamic executor dispatch", "Creator HTTP", "gateway allowlist", "responsive browser", "browser console", "architecture preflight", "exact diff scope", "go test", "go vet", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
+12
View File
@@ -1,6 +1,7 @@
package domain
import (
"encoding/hex"
"errors"
"strings"
@@ -71,11 +72,22 @@ func allowedOperation(proposal commitcontract.ActionProposal, context Authorizat
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
subject.FromState == continuity.ProspectiveDelivered && subject.ToState == continuity.ProspectiveSatisfied &&
strings.TrimSpace(subject.EvidenceRef) != ""
case context.Grant.Class == authority.GrantClassCreator && proposal.CapabilityID == commitcontract.CapabilityProspectiveNotification && proposal.Operation == commitcontract.OperationDeliverProspectiveNotification:
subject := proposal.NotificationSubject
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
subject.TemplateCode == commitcontract.ProspectiveDeliveredTemplateV1 && subject.BindingID != "" && subject.BindingRevision > 0 &&
strings.TrimSpace(subject.RenderedTitle) != "" && len(subject.RenderedTitle) <= 160 &&
strings.TrimSpace(subject.RenderedMessage) != "" && len(subject.RenderedMessage) <= 2048 && validDigest(subject.RequestDigest)
default:
return false
}
}
func validDigest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == 32 && value == strings.ToLower(value)
}
// 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) {
@@ -0,0 +1,46 @@
package domain
import (
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/authority"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/work"
)
type allowValidator struct{}
func (allowValidator) Validate(authority.AuthorizationContext) error { return nil }
func notificationProposal() commitcontract.ActionProposal {
return commitcontract.ActionProposal{IntentID: "intent", ActorID: "actor", CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: work.TaskTarget{TaskID: "task", GoalID: "goal", ScopeID: "scope", Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item", ExpectedRevision: 1, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: "binding", BindingRevision: 1, RenderedTitle: "Домовой", RenderedMessage: "Пора вернуться", RequestDigest: strings.Repeat("a", 64)}}
}
func notificationContext() AuthorizationContext {
var context AuthorizationContext
context.Binding.ActorID, context.Binding.PrincipalID, context.Binding.Active = "actor", "owner", true
context.Scope = authority.Scope{ID: "scope"}
context.Grant = authority.AuthorityGrant{ID: "grant", ActorID: "actor", PrincipalID: "owner", ScopeID: "scope", Class: authority.GrantClassCreator, Revision: 1, Active: true}
return context
}
func TestAuthorizeProspectiveNotification(t *testing.T) {
action, err := Authorize(notificationProposal(), notificationContext(), allowValidator{}, "authorized", 1)
if err != nil {
t.Fatal(err)
}
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
t.Fatal("operation changed")
}
}
func TestAuthorizeProspectiveNotificationRejectsInvalidEvidence(t *testing.T) {
for name, mutate := range map[string]func(*commitcontract.ActionProposal){"binding": func(p *commitcontract.ActionProposal) { p.NotificationSubject.BindingID = "" }, "digest": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RequestDigest = "AA" }, "message": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RenderedMessage = "" }, "operation": func(p *commitcontract.ActionProposal) { p.Operation = "arbitrary-webhook" }} {
t.Run(name, func(t *testing.T) {
proposal := notificationProposal()
mutate(&proposal)
if _, err := Authorize(proposal, notificationContext(), allowValidator{}, "authorized", 1); err == nil {
t.Fatal("expected denial")
}
})
}
}