Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5cd964e51 | ||
|
|
959c89d47b | ||
|
|
760f671077 | ||
|
|
759328972a | ||
|
|
01e1bdea1e | ||
|
|
7a3280b007 | ||
|
|
c6139699a9 | ||
|
|
6e0040fe89 | ||
|
|
45fb5f8709 | ||
|
|
b4f86b8c46 | ||
|
|
6446cd692a | ||
|
|
91d03b730d | ||
|
|
0e56fe7662 | ||
|
|
2d2615b734 |
@@ -0,0 +1,212 @@
|
||||
package notificationexecutor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
"git.cin.su/m/Assistant/modules/commit/ports"
|
||||
)
|
||||
|
||||
const maxResponseBytes = 16 << 10
|
||||
|
||||
var ErrInvalidNotification = errors.New("invalid prospective notification")
|
||||
|
||||
type Payload struct {
|
||||
ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"`
|
||||
TemplateCode string `json:"template_code"`
|
||||
ProspectiveItemID string `json:"prospective_item_id"`
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DeliveryRequest struct {
|
||||
Payload
|
||||
RequestDigest string `json:"request_digest"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func Digest(payload Payload) (string, error) {
|
||||
if payload.ActionIntentID == "" || payload.TemplateCode != commitcontract.ProspectiveDeliveredTemplateV1 ||
|
||||
payload.ProspectiveItemID == "" || payload.ExpectedRevision < 1 || strings.TrimSpace(payload.Title) == "" ||
|
||||
strings.TrimSpace(payload.Message) == "" || len(payload.Title) > 160 || len(payload.Message) > 2048 {
|
||||
return "", ErrInvalidNotification
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error) {
|
||||
subject := action.Proposal.NotificationSubject
|
||||
if action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification ||
|
||||
action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification ||
|
||||
subject.BindingID == "" || subject.BindingRevision < 1 {
|
||||
return Payload{}, ErrInvalidNotification
|
||||
}
|
||||
payload := Payload{ActionIntentID: action.Proposal.IntentID, TemplateCode: subject.TemplateCode,
|
||||
ProspectiveItemID: string(subject.ProspectiveItemID), ExpectedRevision: int64(subject.ExpectedRevision),
|
||||
Title: subject.RenderedTitle, Message: subject.RenderedMessage}
|
||||
digest, err := Digest(payload)
|
||||
if err != nil || digest != subject.RequestDigest {
|
||||
return Payload{}, ErrInvalidNotification
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
type Executor struct {
|
||||
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 {
|
||||
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: ©Client, 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}
|
||||
}
|
||||
body, err := json.Marshal(DeliveryRequest{Payload: payload, RequestDigest: action.Proposal.NotificationSubject.RequestDigest})
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
endpoint, err := e.endpoint(e.binding.DeliveryPath, string(action.Proposal.IntentID))
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+e.secret)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := e.client.Do(request)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated {
|
||||
if response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
receipt, err := decodeReceipt(response.Body)
|
||||
if err != nil || !matchingReceipt(receipt, action) {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
}
|
||||
|
||||
func (e *Executor) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
if _, err := e.requestFor(action); err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
endpoint, err := e.endpoint(e.binding.ReceiptPath, string(action.Proposal.IntentID))
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+e.secret)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := e.client.Do(request)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
receipt, err := decodeReceipt(response.Body)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
if !matchingReceipt(receipt, action) {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
}
|
||||
|
||||
func (e *Executor) requestFor(action commitcontract.AuthorizedAction) (Payload, error) {
|
||||
if action.Proposal.NotificationSubject.BindingID != e.binding.ID || action.Proposal.NotificationSubject.BindingRevision != e.binding.Revision ||
|
||||
action.PrincipalID != e.binding.OwnerPrincipalID {
|
||||
return Payload{}, ErrInvalidNotification
|
||||
}
|
||||
return PayloadFromAction(action)
|
||||
}
|
||||
|
||||
func (e *Executor) endpoint(path, intent string) (string, error) {
|
||||
base, err := url.Parse(e.binding.HTTPSOrigin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base.Path = path + "/" + url.PathEscape(intent)
|
||||
base.RawPath, base.RawQuery, base.Fragment = "", "", ""
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func decodeReceipt(reader io.Reader) (Receipt, error) {
|
||||
var receipt Receipt
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1))
|
||||
if err := decoder.Decode(&receipt); err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
if receipt.ReceiptID == "" || receipt.ActionIntentID == "" || len(receipt.RequestDigest) != 64 || receipt.AcceptedAt <= 0 {
|
||||
return Receipt{}, fmt.Errorf("invalid receipt")
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func matchingReceipt(receipt Receipt, action commitcontract.AuthorizedAction) bool {
|
||||
return receipt.ActionIntentID == action.Proposal.IntentID && receipt.RequestDigest == action.Proposal.NotificationSubject.RequestDigest
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package notificationexecutor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
func testAction(t *testing.T) commitcontract.AuthorizedAction {
|
||||
t.Helper()
|
||||
payload := Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 2, Title: "Домовой", Message: "Пора вернуться к задаче"}
|
||||
digest, err := Digest(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return commitcontract.AuthorizedAction{PrincipalID: identity.PrincipalID("owner-1"), Proposal: commitcontract.ActionProposal{
|
||||
IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification,
|
||||
NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 2, TemplateCode: payload.TemplateCode, BindingID: "binding-1", BindingRevision: 1, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest},
|
||||
}}
|
||||
}
|
||||
|
||||
func testBinding(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 TestExecutorPutAndReconcileMatchReceipt(t *testing.T) {
|
||||
action := testAction(t)
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Error("missing credential")
|
||||
}
|
||||
if r.Method == http.MethodPut && r.URL.Path != "/delivery/v1/deliveries/intent-1" {
|
||||
t.Errorf("bad put path %s", r.URL.Path)
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path != "/delivery/v1/receipts/intent-1" {
|
||||
t.Errorf("bad get path %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "receipt-intent-1", ActionIntentID: "intent-1", RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
|
||||
}))
|
||||
defer server.Close()
|
||||
executor, err := New(testBinding(server.URL), "secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := executor.Execute(action); !result.Known || !result.Succeeded {
|
||||
t.Fatalf("execute: %+v", result)
|
||||
}
|
||||
if result := executor.Reconcile(action); !result.Known || !result.Succeeded {
|
||||
t.Fatalf("reconcile: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAmbiguousAndIntegrityOutcomes(t *testing.T) {
|
||||
action := testAction(t)
|
||||
for name, handler := range map[string]http.HandlerFunc{
|
||||
"server error": func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) },
|
||||
"bad receipt": func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "r", ActionIntentID: "other", RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
server := httptest.NewTLSServer(handler)
|
||||
defer server.Close()
|
||||
executor, err := New(testBinding(server.URL), "secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := executor.Execute(action); result.Known {
|
||||
t.Fatalf("must be unknown: %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
action.Proposal.NotificationSubject.RequestDigest = "bad"
|
||||
server := httptest.NewTLSServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
executor, err := New(testBinding(server.URL), "secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := executor.Execute(action); !result.Known || result.Succeeded {
|
||||
t.Fatalf("invalid evidence must fail closed: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorDoesNotFollowRedirect(t *testing.T) {
|
||||
action := testAction(t)
|
||||
targetCalls := 0
|
||||
target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { targetCalls++ }))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
executor, err := New(testBinding(redirect.URL), "secret", redirect.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := executor.Execute(action); result.Known {
|
||||
t.Fatalf("redirect must be ambiguous: %+v", result)
|
||||
}
|
||||
if targetCalls != 0 {
|
||||
t.Fatal("redirect target was called")
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrCapabilityHealthNotFound = errors.New("capability health not found")
|
||||
var ErrCapabilityBindingNotFound = errors.New("capability binding not found")
|
||||
var ErrCapabilityBindingConflict = errors.New("capability binding conflict")
|
||||
|
||||
type CapabilityRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
@@ -56,3 +59,72 @@ func (r *CapabilityRepository) RebuildHealthProjection(ctx context.Context) (int
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capability.Binding) 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.notification_bindings (
|
||||
binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path,
|
||||
protocol,secret_ref,verified_at_unix_millis,health)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`,
|
||||
binding.ID, binding.CapabilityID, binding.OwnerPrincipalID, binding.Revision, binding.State,
|
||||
binding.HTTPSOrigin, binding.DeliveryPath, binding.ReceiptPath, binding.Protocol, binding.SecretRef,
|
||||
binding.VerifiedAtUnixMillis, binding.Health)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
existing, loadErr := r.LoadBinding(ctx, binding.ID, binding.Revision)
|
||||
if loadErr != nil || existing != binding {
|
||||
return ErrCapabilityBindingConflict
|
||||
}
|
||||
}
|
||||
if binding.State == capability.BindingActive {
|
||||
result, err = tx.Exec(ctx, `INSERT INTO capability.notification_binding_heads (capability_id,owner_principal_id,binding_id,revision)
|
||||
VALUES ($1,$2,$3,$4) ON CONFLICT (capability_id,owner_principal_id) DO UPDATE SET binding_id=EXCLUDED.binding_id,revision=EXCLUDED.revision
|
||||
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision`, binding.CapabilityID, binding.OwnerPrincipalID, binding.ID, binding.Revision)
|
||||
if err != nil || result.RowsAffected() != 1 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrCapabilityBindingConflict
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `DELETE FROM capability.notification_binding_heads WHERE capability_id=$1 AND owner_principal_id=$2 AND binding_id=$3 AND revision < $4`, binding.CapabilityID, binding.OwnerPrincipalID, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) {
|
||||
var binding capability.Binding
|
||||
err := r.pool.QueryRow(ctx, `SELECT binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path,
|
||||
protocol,secret_ref,verified_at_unix_millis,health FROM capability.notification_bindings WHERE binding_id=$1 AND revision=$2`, id, revision).
|
||||
Scan(&binding.ID, &binding.CapabilityID, &binding.OwnerPrincipalID, &binding.Revision, &binding.State,
|
||||
&binding.HTTPSOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.SecretRef,
|
||||
&binding.VerifiedAtUnixMillis, &binding.Health)
|
||||
if err != nil {
|
||||
return capability.Binding{}, fmt.Errorf("load capability binding: %w", err)
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) (capability.Binding, error) {
|
||||
var binding capability.Binding
|
||||
err := r.pool.QueryRow(ctx, `SELECT b.binding_id,b.capability_id,b.owner_principal_id,b.revision,b.state,b.https_origin,b.delivery_path,b.receipt_path,
|
||||
b.protocol,b.secret_ref,b.verified_at_unix_millis,b.health FROM capability.notification_binding_heads h
|
||||
JOIN capability.notification_bindings b ON b.binding_id=h.binding_id AND b.revision=h.revision
|
||||
WHERE h.capability_id=$1 AND h.owner_principal_id=$2 AND b.state=$3`, capabilityID, owner, capability.BindingActive).
|
||||
Scan(&binding.ID, &binding.CapabilityID, &binding.OwnerPrincipalID, &binding.Revision, &binding.State,
|
||||
&binding.HTTPSOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.SecretRef,
|
||||
&binding.VerifiedAtUnixMillis, &binding.Health)
|
||||
if err != nil {
|
||||
return capability.Binding{}, fmt.Errorf("load active capability binding: %w", err)
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"reflect"
|
||||
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/continuity"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -29,6 +31,12 @@ func OpenCommit(ctx context.Context, c ContinuityConfig) (*CommitRepository, err
|
||||
}
|
||||
func (r *CommitRepository) Close() { r.pool.Close() }
|
||||
func (r *CommitRepository) SaveAuthorized(ctx context.Context, a commitcontract.AuthorizedAction) error {
|
||||
prospectiveItemID := a.Proposal.ContinuitySubject.ProspectiveItemID
|
||||
expectedRevision := a.Proposal.ContinuitySubject.ExpectedRevision
|
||||
if a.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
|
||||
prospectiveItemID = a.Proposal.NotificationSubject.ProspectiveItemID
|
||||
expectedRevision = a.Proposal.NotificationSubject.ExpectedRevision
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -41,15 +49,19 @@ func (r *CommitRepository) SaveAuthorized(ctx context.Context, a commitcontract.
|
||||
authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
|
||||
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
|
||||
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
|
||||
proposal_state,action_state,authorized_at_unix_millis,envelope_revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,1)
|
||||
proposal_state,action_state,authorized_at_unix_millis,envelope_revision,notification_template_code,
|
||||
capability_binding_id,capability_binding_revision,notification_title,notification_message,request_digest)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,1,$22,$23,$24,$25,$26,$27)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
a.ID, a.Proposal.IntentID, a.Proposal.ActorID, a.PrincipalID, a.GrantID, a.GrantRevision, a.ScopeID,
|
||||
a.Proposal.Target.TaskID, a.Proposal.Target.GoalID, a.Proposal.Target.ScopeID, a.Proposal.Target.Revision,
|
||||
a.Proposal.CapabilityID, a.Proposal.Operation, a.Proposal.ContinuitySubject.ProspectiveItemID,
|
||||
a.Proposal.ContinuitySubject.ExpectedRevision, a.Proposal.ContinuitySubject.FromState,
|
||||
a.Proposal.CapabilityID, a.Proposal.Operation, prospectiveItemID,
|
||||
expectedRevision, a.Proposal.ContinuitySubject.FromState,
|
||||
a.Proposal.ContinuitySubject.ToState, a.Proposal.ContinuitySubject.EvidenceRef,
|
||||
a.Proposal.State, a.State, a.AuthorizedAtUnixMillis)
|
||||
a.Proposal.State, a.State, a.AuthorizedAtUnixMillis, a.Proposal.NotificationSubject.TemplateCode,
|
||||
a.Proposal.NotificationSubject.BindingID, a.Proposal.NotificationSubject.BindingRevision,
|
||||
a.Proposal.NotificationSubject.RenderedTitle, a.Proposal.NotificationSubject.RenderedMessage,
|
||||
a.Proposal.NotificationSubject.RequestDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -112,24 +124,60 @@ func (r *CommitRepository) LoadLatestAttempt(ctx context.Context, id commitcontr
|
||||
return attempt, err
|
||||
}
|
||||
|
||||
func (r *CommitRepository) FindLatestAttempt(ctx context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
|
||||
attempt, err := r.LoadLatestAttempt(ctx, id)
|
||||
if errors.Is(err, ErrActionAttemptNotFound) {
|
||||
return commitcontract.ActionAttempt{}, false, nil
|
||||
}
|
||||
return attempt, err == nil, err
|
||||
}
|
||||
|
||||
func (r *CommitRepository) ListAuthorizedByOwner(ctx context.Context, actorID identity.ActorID, principalID identity.PrincipalID, limit int) ([]commitcontract.AuthorizedAction, error) {
|
||||
if actorID == "" || principalID == "" || limit < 1 || limit > 50 {
|
||||
return nil, fmt.Errorf("invalid authorized action owner query")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
|
||||
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
|
||||
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
|
||||
proposal_state,action_state,authorized_at_unix_millis,notification_template_code,capability_binding_id,
|
||||
capability_binding_revision,notification_title,notification_message,request_digest
|
||||
FROM commit.authorized_action_envelopes
|
||||
WHERE actor_id=$1 AND principal_id=$2
|
||||
ORDER BY authorized_at_unix_millis DESC, authorized_action_id DESC LIMIT $3`, actorID, principalID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list authorized actions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
actions := make([]commitcontract.AuthorizedAction, 0)
|
||||
for rows.Next() {
|
||||
var action commitcontract.AuthorizedAction
|
||||
if err := scanAuthorized(rows, &action); err != nil {
|
||||
return nil, fmt.Errorf("scan authorized action: %w", err)
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list authorized actions: %w", err)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
type commitRowQuerier interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
type commitRowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) {
|
||||
var action commitcontract.AuthorizedAction
|
||||
err := querier.QueryRow(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
|
||||
err := scanAuthorized(querier.QueryRow(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
|
||||
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
|
||||
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
|
||||
proposal_state,action_state,authorized_at_unix_millis
|
||||
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id).Scan(
|
||||
&action.ID, &action.Proposal.IntentID, &action.Proposal.ActorID, &action.PrincipalID,
|
||||
&action.GrantID, &action.GrantRevision, &action.ScopeID, &action.Proposal.Target.TaskID,
|
||||
&action.Proposal.Target.GoalID, &action.Proposal.Target.ScopeID, &action.Proposal.Target.Revision,
|
||||
&action.Proposal.CapabilityID, &action.Proposal.Operation, &action.Proposal.ContinuitySubject.ProspectiveItemID,
|
||||
&action.Proposal.ContinuitySubject.ExpectedRevision, &action.Proposal.ContinuitySubject.FromState,
|
||||
&action.Proposal.ContinuitySubject.ToState, &action.Proposal.ContinuitySubject.EvidenceRef,
|
||||
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis)
|
||||
proposal_state,action_state,authorized_at_unix_millis,notification_template_code,capability_binding_id,
|
||||
capability_binding_revision,notification_title,notification_message,request_digest
|
||||
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id), &action)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return commitcontract.AuthorizedAction{}, ErrAuthorizedActionNotFound
|
||||
}
|
||||
@@ -138,3 +186,23 @@ func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcont
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func scanAuthorized(row commitRowScanner, action *commitcontract.AuthorizedAction) error {
|
||||
err := row.Scan(
|
||||
&action.ID, &action.Proposal.IntentID, &action.Proposal.ActorID, &action.PrincipalID,
|
||||
&action.GrantID, &action.GrantRevision, &action.ScopeID, &action.Proposal.Target.TaskID,
|
||||
&action.Proposal.Target.GoalID, &action.Proposal.Target.ScopeID, &action.Proposal.Target.Revision,
|
||||
&action.Proposal.CapabilityID, &action.Proposal.Operation, &action.Proposal.ContinuitySubject.ProspectiveItemID,
|
||||
&action.Proposal.ContinuitySubject.ExpectedRevision, &action.Proposal.ContinuitySubject.FromState,
|
||||
&action.Proposal.ContinuitySubject.ToState, &action.Proposal.ContinuitySubject.EvidenceRef,
|
||||
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis,
|
||||
&action.Proposal.NotificationSubject.TemplateCode, &action.Proposal.NotificationSubject.BindingID,
|
||||
&action.Proposal.NotificationSubject.BindingRevision, &action.Proposal.NotificationSubject.RenderedTitle,
|
||||
&action.Proposal.NotificationSubject.RenderedMessage, &action.Proposal.NotificationSubject.RequestDigest)
|
||||
if err == nil && action.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
|
||||
action.Proposal.NotificationSubject.ProspectiveItemID = action.Proposal.ContinuitySubject.ProspectiveItemID
|
||||
action.Proposal.NotificationSubject.ExpectedRevision = action.Proposal.ContinuitySubject.ExpectedRevision
|
||||
action.Proposal.ContinuitySubject = continuity.SatisfyDeliveredSubject{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-0005: Prospective Notification Delivery
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-08-16
|
||||
|
||||
Decision authority: Creator continuation after the explicit `ARCH-000005` acceptance request
|
||||
|
||||
## Context
|
||||
|
||||
Domovoy has a durable Commit/Executor safety path, but no accepted operation that crosses the Assistant canonical-state boundary. `ARCH-000005` compared alternatives and proposed a typed notification about one Creator-owned `ProspectiveItem`. The Creator accepted that recommendation and selected local Windows notification display as the first channel.
|
||||
|
||||
## Decision
|
||||
|
||||
1. The first external operation is `deliver-prospective-notification-v1` under capability `prospective-notification-delivery`.
|
||||
2. The subject is one Creator-owned `ProspectiveItem` at an expected revision in `DELIVERED`; delivery does not change Continuity state and does not prove reading, satisfaction, or commitment fulfilment.
|
||||
3. The Creator client supplies only the item, expected revision, live Work target, and explicit confirmation. The server derives authority, operation, binding, destination, template, body, and action identities.
|
||||
4. Capability owns a versioned, owner-local `CapabilityBinding`. Commit records its ID and revision; secrets remain external references and bytes are available only to the relevant adapters.
|
||||
5. Commit/Executor is the only Assistant component permitted to issue the mutating delivery request. It uses the fixed HTTPS destination and receipt paths from the active binding, disables redirects, and accepts only bounded typed responses.
|
||||
6. Delivery uses `PUT <delivery-path>/<ActionIntentID>`. Same intent and digest returns the original receipt; a different digest is an integrity conflict. Read-only reconciliation uses `GET <receipt-path>/<ActionIntentID>`.
|
||||
7. Ambiguous send outcomes become `UNKNOWN_OUTCOME`; they are never blindly retried. Reconciliation compares the durable request digest with the external receipt.
|
||||
8. The initial destination is a dedicated receipt receiver deployed on `docker-prod`, exposed through the existing `https://assistent.cin.su` origin. It has no access to Assistant canonical databases and persists immutable external receipts in its own durable volume.
|
||||
9. The destination uses separate credentials for Commit write/reconciliation and Windows read access. Credentials are generated and stored outside Git, database rows, images, logs, browser storage, and cognitive context.
|
||||
10. A current-user Windows agent reads accepted receipts, shows local operating-system notifications, and keeps only local projection state needed to avoid redisplay. Receiver acceptance is the Commit outcome; operating-system display and human reading are explicitly outside that outcome.
|
||||
11. Binding retirement blocks new sends while preserving bounded reconciliation for historical actions. Secret rotation creates a new secret version and binding revision without rewriting history.
|
||||
12. `docker-test` is prohibited for implementation, verification, and deployment of this capability.
|
||||
|
||||
## Rationale
|
||||
|
||||
The operation adds useful external behavior without introducing arbitrary webhooks, provider payloads, SSRF-controlled destinations, or provider-specific semantics. A compatible receiver makes duplicate prevention and read-only reconciliation testable. Separating the receipt outcome from Windows display keeps Commit evidence honest while permitting eventual local delivery when the workstation is available.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Capability binding lifecycle and persistence must be implemented before authorization can succeed.
|
||||
- Commit requires a typed notification envelope and durable digest evidence in addition to the existing Continuity transition envelope.
|
||||
- The receiver and Windows reader are adapters, not owners of Continuity or Commit canonical state.
|
||||
- A receiver outage or ambiguous response cannot trigger an automatic duplicate send.
|
||||
- The receiver requires durable backup/retention and operational health checks; local Windows projection loss may cause redisplay but cannot cause a second external receipt.
|
||||
- No new cross-module dependency or canonical owner is introduced; existing registry permissions are sufficient.
|
||||
|
||||
## Validity conditions
|
||||
|
||||
- Owner, Work justification, Authority grant, expected Prospective revision, and exact binding revision are validated before Commit execution.
|
||||
- Receiver receipt identity and digest are stable and queryable by `ActionIntentID`.
|
||||
- Only the isolated Executor can read the write credential.
|
||||
- Windows read access cannot create or alter receipts.
|
||||
- Tests cover hostile input, duplicate intents, digest conflicts, lost acknowledgement, restart recovery, retirement, and rotation.
|
||||
|
||||
## Revisit triggers
|
||||
|
||||
- A provider cannot implement authoritative idempotency and read-only receipt lookup.
|
||||
- Notification content requires Artifact payload ownership rather than the bounded V1 template.
|
||||
- A requirement appears to prove operating-system display or human reading.
|
||||
- Multi-recipient routing, arbitrary templates, or another provider/channel is requested.
|
||||
|
||||
## Migration and rollback
|
||||
|
||||
Implementation is additive. Rollback retires the binding, revokes both external credentials, stops the Windows agent and receiver, and preserves Commit attempts plus receiver receipts for audit and reconciliation. Existing internal Continuity actions remain unchanged.
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +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"
|
||||
@@ -24,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 {
|
||||
@@ -37,6 +43,8 @@ type creatorAuthoritySource interface {
|
||||
type creatorCommitRepository interface {
|
||||
executionRepository
|
||||
SaveAuthorized(context.Context, commitcontract.AuthorizedAction) error
|
||||
ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error)
|
||||
FindLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error)
|
||||
}
|
||||
|
||||
type creatorCommandHandler struct {
|
||||
@@ -57,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
|
||||
@@ -64,9 +79,169 @@ type creatorActionResponse struct {
|
||||
State commitcontract.ActionState
|
||||
}
|
||||
|
||||
type creatorActionHistoryItem struct {
|
||||
AuthorizedActionID commitcontract.AuthorizedActionID
|
||||
ActionIntentID commitcontract.ActionIntentID
|
||||
ActionAttemptID commitcontract.ActionAttemptID
|
||||
Operation commitcontract.ExternalOperation
|
||||
ProspectiveItemID continuity.ProspectiveItemID
|
||||
State commitcontract.ActionState
|
||||
AuthorizedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type creatorActionHistoryResponse struct {
|
||||
Items []creatorActionHistoryItem
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *http.Request) {
|
||||
session, sessionOK := h.creatorSession(w, r, r.Header.Get("Authorization"))
|
||||
if !sessionOK {
|
||||
return
|
||||
}
|
||||
limit := 25
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 50 {
|
||||
http.Error(w, "invalid action history limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
actions, err := h.repository.ListAuthorizedByOwner(r.Context(), session.ActorID, session.PrincipalID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
items := make([]creatorActionHistoryItem, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
attempt, found, findErr := h.repository.FindLatestAttempt(r.Context(), action.ID)
|
||||
if findErr != nil {
|
||||
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: prospectiveItemID,
|
||||
State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis,
|
||||
}
|
||||
if found {
|
||||
item.ActionAttemptID = attempt.ID
|
||||
item.State = attempt.State
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = 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) {
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +59,43 @@ func (f fakeCreatorAuthority) LoadGrant(context.Context, authority.GrantID) (aut
|
||||
return f.grant, f.scope, nil
|
||||
}
|
||||
|
||||
func (f *fakeExecutionRepository) ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error) {
|
||||
if f.action.ID == "" {
|
||||
return []commitcontract.AuthorizedAction{}, nil
|
||||
}
|
||||
return []commitcontract.AuthorizedAction{f.action}, nil
|
||||
}
|
||||
|
||||
func (f *fakeExecutionRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
|
||||
for index := len(f.attempts) - 1; index >= 0; index-- {
|
||||
if f.attempts[index].AuthorizedID == id {
|
||||
return f.attempts[index], true, nil
|
||||
}
|
||||
}
|
||||
return commitcontract.ActionAttempt{}, false, nil
|
||||
}
|
||||
|
||||
type fakeCreatorHistoryRepository struct {
|
||||
fakeExecutionRepository
|
||||
actions []commitcontract.AuthorizedAction
|
||||
latest map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt
|
||||
actorID identity.ActorID
|
||||
principalID identity.PrincipalID
|
||||
limit int
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (f *fakeCreatorHistoryRepository) ListAuthorizedByOwner(_ context.Context, actorID identity.ActorID, principalID identity.PrincipalID, limit int) ([]commitcontract.AuthorizedAction, error) {
|
||||
f.actorID, f.principalID, f.limit = actorID, principalID, limit
|
||||
f.listCalls++
|
||||
return f.actions, nil
|
||||
}
|
||||
|
||||
func (f *fakeCreatorHistoryRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) {
|
||||
attempt, ok := f.latest[id]
|
||||
return attempt, ok, nil
|
||||
}
|
||||
|
||||
func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) {
|
||||
handler, repository, executor := validCreatorCommandHandler()
|
||||
mux := http.NewServeMux()
|
||||
@@ -83,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
|
||||
@@ -127,10 +205,73 @@ func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorActionHistoryUsesSessionOwnerAndShowsMissingAttempt(t *testing.T) {
|
||||
session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000}
|
||||
repository := &fakeCreatorHistoryRepository{
|
||||
actions: []commitcontract.AuthorizedAction{
|
||||
{ID: "authorized-new", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 20, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-new", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-new"}}},
|
||||
{ID: "authorized-pending", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 10, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-pending", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-pending"}}},
|
||||
},
|
||||
latest: map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt{
|
||||
"authorized-new": {ID: "attempt-new", AuthorizedID: "authorized-new", IntentID: "intent-new", State: commitcontract.ActionSucceeded},
|
||||
},
|
||||
}
|
||||
handler := creatorCommandHandler{core: fakeCreatorCore{session: session}, repository: repository}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorCommandRoutes(mux, handler)
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/creator/authorized-actions?limit=2", nil)
|
||||
request.Header.Set("Authorization", "Bearer creator-session")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var result creatorActionHistoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.actorID != session.ActorID || repository.principalID != session.PrincipalID || repository.limit != 2 || repository.listCalls != 1 {
|
||||
t.Fatalf("owner query actor=%q principal=%q limit=%d calls=%d", repository.actorID, repository.principalID, repository.limit, repository.listCalls)
|
||||
}
|
||||
if len(result.Items) != 2 || result.Items[0].State != commitcontract.ActionSucceeded || result.Items[0].ActionAttemptID != "attempt-new" ||
|
||||
result.Items[1].State != commitcontract.ActionAuthorized || result.Items[1].ActionAttemptID != "" {
|
||||
t.Fatalf("history=%#v", result.Items)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "EvidenceRef") || strings.Contains(response.Body.String(), "GrantID") {
|
||||
t.Fatalf("history leaked restricted envelope fields: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
sessionErr error
|
||||
path string
|
||||
status int
|
||||
}{
|
||||
{name: "session", sessionErr: errCreatorSessionDenied, path: "/v1/creator/authorized-actions", status: http.StatusUnauthorized},
|
||||
{name: "zero", path: "/v1/creator/authorized-actions?limit=0", status: http.StatusBadRequest},
|
||||
{name: "too large", path: "/v1/creator/authorized-actions?limit=51", status: http.StatusBadRequest},
|
||||
{name: "invalid", path: "/v1/creator/authorized-actions?limit=many", status: http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := &fakeCreatorHistoryRepository{}
|
||||
handler := creatorCommandHandler{core: fakeCreatorCore{session: identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, sessionErr: test.sessionErr}, repository: repository}
|
||||
mux := http.NewServeMux()
|
||||
registerCreatorCommandRoutes(mux, handler)
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
if response.Code != test.status || repository.listCalls != 0 {
|
||||
t.Fatalf("status=%d calls=%d", response.Code, repository.listCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -183,6 +184,43 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/capability-bindings/{id}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) {
|
||||
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
|
||||
if err != nil || revision < 1 {
|
||||
http.Error(w, "invalid binding revision", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
binding, err := capabilityRepository.LoadBinding(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
|
||||
if err != nil {
|
||||
http.Error(w, "capability binding not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err = capabilitydomain.ValidateBinding(binding); err != nil {
|
||||
http.Error(w, "capability binding invalid", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(binding)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/capability-bindings/active", func(w http.ResponseWriter, r *http.Request) {
|
||||
capabilityID := capability.CapabilityID(r.URL.Query().Get("capability_id"))
|
||||
ownerID := identity.PrincipalID(r.URL.Query().Get("owner_principal_id"))
|
||||
if capabilityID == "" || ownerID == "" {
|
||||
http.Error(w, "invalid binding query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
binding, err := capabilityRepository.LoadActiveBinding(r.Context(), capabilityID, ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, "active capability binding not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err = capabilitydomain.UsableBinding(binding); err != nil {
|
||||
http.Error(w, "active capability binding unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(binding)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/capabilities/health-projection/rebuild", func(w http.ResponseWriter, r *http.Request) {
|
||||
rebuilt, err := capabilityRepository.RebuildHealthProjection(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -155,8 +155,12 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
}
|
||||
case method == http.MethodGet && path == "/api/prospective-items":
|
||||
return targetContinuity, "/v1/creator/prospective-items", true
|
||||
case method == http.MethodGet && path == "/api/actions":
|
||||
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) {
|
||||
|
||||
@@ -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,58 @@ 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 {
|
||||
t.Fatalf("unexpected Commit history target %s?%s %s", r.URL.Path, r.URL.RawQuery, r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatal("authorization header was not forwarded")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"Items":[{"AuthorizedActionID":"authorized-1"}]}`)
|
||||
}))
|
||||
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.MethodGet, "/api/actions?limit=25", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "authorized-1") {
|
||||
t.Fatalf("response = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
|
||||
if err != nil {
|
||||
@@ -168,6 +221,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
|
||||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
|
||||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
|
||||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
|
||||
strings.Contains(response.Body.String(), "bootstrap-token") {
|
||||
t.Fatalf("console response = %d", response.Code)
|
||||
|
||||
@@ -112,6 +112,33 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.register-row em.bad { color: var(--bad); }
|
||||
.register-empty { margin: 0; padding: 22px 18px; color: var(--faint); font-size: 11px; }
|
||||
|
||||
.activity-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(13, 20, 17, .9); }
|
||||
.activity-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.activity-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.activity-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.activity-tools { display: flex; align-items: center; gap: 12px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .1em; }
|
||||
.activity-tools .icon-button { color: var(--muted); font-size: 18px; }
|
||||
.activity-list { max-height: 300px; overflow: auto; scrollbar-color: var(--faint) transparent; }
|
||||
.activity-list[aria-busy="true"] { opacity: .72; }
|
||||
.activity-row { min-height: 68px; padding: 11px 18px; display: grid; grid-template-columns: 112px minmax(150px, .8fr) minmax(210px, 1.2fr) 145px auto; gap: 16px; align-items: center; border-bottom: 1px solid var(--line-soft); }
|
||||
.activity-row:last-child { border-bottom: 0; }
|
||||
.activity-row:hover { background: var(--panel-high); }
|
||||
.activity-state { width: fit-content; padding: 5px 8px; border: 1px solid currentColor; border-radius: 100px; color: var(--faint); font: 600 8px/1 var(--mono); letter-spacing: .06em; }
|
||||
.activity-state[data-state="SUCCEEDED"] { color: var(--good); }
|
||||
.activity-state[data-state="FAILED"] { color: var(--bad); }
|
||||
.activity-state[data-state="UNKNOWN_OUTCOME"], .activity-state[data-state="EXECUTING"] { color: var(--warn); }
|
||||
.activity-state[data-state="AUTHORIZED"] { color: var(--signal); }
|
||||
.activity-operation, .activity-identity { min-width: 0; }
|
||||
.activity-operation b, .activity-operation span, .activity-identity code, .activity-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-operation b { color: var(--ink); font: 600 10px/1.4 var(--mono); }
|
||||
.activity-operation span, .activity-identity small { margin-top: 4px; color: var(--faint); font: 9px/1.3 var(--mono); }
|
||||
.activity-identity code { color: var(--muted); font: 10px/1.4 var(--mono); }
|
||||
.activity-row time { color: var(--muted); font: 9px/1.4 var(--mono); white-space: nowrap; }
|
||||
.activity-reconcile { min-height: 34px; padding: 0 11px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); background: transparent; cursor: pointer; font: 600 9px/1 var(--mono); }
|
||||
.activity-reconcile:hover:not(:disabled) { color: var(--base); background: var(--warn); }
|
||||
.activity-reconcile:disabled { opacity: .55; cursor: wait; }
|
||||
.activity-error { color: var(--bad); }
|
||||
|
||||
.data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; }
|
||||
.task-card { animation-delay: .08s; }
|
||||
@@ -171,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; }
|
||||
@@ -215,6 +247,14 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
|
||||
.signal-strip { grid-template-columns: 1fr; }
|
||||
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.register-panel > header { align-items: start; }
|
||||
.activity-panel > header { align-items: flex-start; }
|
||||
.activity-tools { align-items: flex-end; flex-direction: column-reverse; }
|
||||
.activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.activity-state { grid-column: 2; grid-row: 1; }
|
||||
.activity-operation { grid-column: 1; grid-row: 1; }
|
||||
.activity-identity { grid-column: 1 / -1; grid-row: 2; }
|
||||
.activity-row time { grid-column: 1; grid-row: 3; }
|
||||
.activity-reconcile { grid-column: 2; grid-row: 2 / span 2; }
|
||||
.register-row { padding: 13px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 7px 12px; }
|
||||
.register-row time { grid-column: 1; grid-row: 2; }
|
||||
.register-row span { display: none; }
|
||||
@@ -234,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; }
|
||||
|
||||
@@ -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 = "—") {
|
||||
@@ -80,6 +81,7 @@ async function showConsole() {
|
||||
restoreContext();
|
||||
const context = saveContext();
|
||||
if (context["session-id"] && context["workspace-id"]) loadDashboard();
|
||||
else loadActionHistory();
|
||||
}
|
||||
|
||||
function endSession(message = "Сессия завершена.") {
|
||||
@@ -92,6 +94,7 @@ function endSession(message = "Сессия завершена.") {
|
||||
currentTask = null;
|
||||
currentProspective = null;
|
||||
pendingCommand = null;
|
||||
pendingNotification = null;
|
||||
lastAuthorizedActionID = "";
|
||||
notify(message);
|
||||
}
|
||||
@@ -136,6 +139,7 @@ contextForm.addEventListener("submit", (event) => {
|
||||
});
|
||||
|
||||
byId("refresh-all").addEventListener("click", loadDashboard);
|
||||
byId("refresh-actions").addEventListener("click", loadActionHistory);
|
||||
byId("logout").addEventListener("click", () => endSession());
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -162,6 +166,7 @@ async function loadDashboard() {
|
||||
updateActionReadiness();
|
||||
}
|
||||
work.push(loadProspectiveRegister(context["prospective-id"]));
|
||||
work.push(loadActionHistory());
|
||||
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
||||
else setSignal("capability", "не выбрана", "idle");
|
||||
|
||||
@@ -169,6 +174,96 @@ async function loadDashboard() {
|
||||
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
|
||||
}
|
||||
|
||||
async function loadActionHistory() {
|
||||
const list = byId("action-history");
|
||||
list.setAttribute("aria-busy", "true");
|
||||
list.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "register-empty";
|
||||
loading.textContent = "Читаю канонический журнал Commit…";
|
||||
list.append(loading);
|
||||
try {
|
||||
const result = await request("/api/actions?limit=25");
|
||||
const items = Array.isArray(result.Items) ? result.Items : [];
|
||||
setText("action-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`);
|
||||
list.replaceChildren();
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "register-empty";
|
||||
empty.textContent = "У текущего Создателя пока нет авторизованных действий.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
items.forEach((item) => list.append(createActionHistoryRow(item)));
|
||||
} catch (error) {
|
||||
list.replaceChildren();
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty activity-error";
|
||||
failed.textContent = error.message;
|
||||
list.append(failed);
|
||||
setText("action-count", "ОШИБКА");
|
||||
} finally {
|
||||
list.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function createActionHistoryRow(item) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "activity-row";
|
||||
const state = document.createElement("strong");
|
||||
state.className = "activity-state";
|
||||
state.dataset.state = item.State || "UNKNOWN";
|
||||
state.textContent = item.State || "UNKNOWN";
|
||||
const operation = document.createElement("div");
|
||||
operation.className = "activity-operation";
|
||||
const operationName = document.createElement("b");
|
||||
operationName.textContent = item.Operation || "неизвестная операция";
|
||||
const subject = document.createElement("span");
|
||||
subject.textContent = item.ProspectiveItemID || "без subject";
|
||||
operation.append(operationName, subject);
|
||||
const identity = document.createElement("div");
|
||||
identity.className = "activity-identity";
|
||||
const actionID = document.createElement("code");
|
||||
actionID.textContent = item.AuthorizedActionID || "—";
|
||||
actionID.title = item.AuthorizedActionID || "";
|
||||
const attemptID = document.createElement("small");
|
||||
attemptID.textContent = item.ActionAttemptID ? `attempt ${item.ActionAttemptID}` : "попытка ещё не создана";
|
||||
attemptID.title = item.ActionAttemptID || "";
|
||||
identity.append(actionID, attemptID);
|
||||
const time = document.createElement("time");
|
||||
time.dateTime = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toISOString() : "";
|
||||
time.textContent = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||||
row.append(state, operation, identity, time);
|
||||
if (item.State === "UNKNOWN_OUTCOME" && item.AuthorizedActionID) {
|
||||
const reconcile = document.createElement("button");
|
||||
reconcile.type = "button";
|
||||
reconcile.className = "activity-reconcile";
|
||||
reconcile.textContent = "Сверить";
|
||||
reconcile.setAttribute("aria-label", `Сверить исход действия ${item.AuthorizedActionID}`);
|
||||
reconcile.addEventListener("click", () => reconcileHistoryAction(item.AuthorizedActionID, reconcile));
|
||||
row.append(reconcile);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function reconcileHistoryAction(actionID, button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "Сверяю…";
|
||||
try {
|
||||
const result = await request(`/api/actions/${encodeURIComponent(actionID)}/reconcile`, {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: "{}",
|
||||
});
|
||||
renderActionResult(result);
|
||||
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
|
||||
await loadActionHistory();
|
||||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||||
} catch (error) {
|
||||
notify(error.message);
|
||||
button.disabled = false;
|
||||
button.textContent = "Сверить";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFocus(query) {
|
||||
setSignal("focus", "загрузка", "loading");
|
||||
try {
|
||||
@@ -333,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-задачей.";
|
||||
@@ -348,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);
|
||||
@@ -380,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;
|
||||
@@ -398,6 +554,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => {
|
||||
pendingCommand = null;
|
||||
byId("satisfaction-dialog").close();
|
||||
renderActionResult(result);
|
||||
await loadActionHistory();
|
||||
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
|
||||
await loadProspectiveRegister(command.ProspectiveItemID);
|
||||
} catch (error) {
|
||||
@@ -431,6 +588,7 @@ byId("reconcile-action").addEventListener("click", async () => {
|
||||
body: "{}",
|
||||
});
|
||||
renderActionResult(result);
|
||||
await loadActionHistory();
|
||||
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
|
||||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||||
} catch (error) {
|
||||
|
||||
@@ -102,6 +102,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title">
|
||||
<header>
|
||||
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
|
||||
<div class="activity-tools">
|
||||
<span id="action-count">0 ЗАПИСЕЙ</span>
|
||||
<button id="refresh-actions" class="icon-button" type="button" aria-label="Обновить журнал действий" title="Обновить журнал действий">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="action-history" class="activity-list" aria-live="polite" aria-busy="false">
|
||||
<p class="register-empty">Журнал ещё не загружен.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-grid">
|
||||
<article class="data-card focus-card">
|
||||
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
|
||||
@@ -151,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>
|
||||
@@ -196,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>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOr("LISTEN_ADDR", ":8085")
|
||||
writeToken, err := readSecretFile("RECEIVER_WRITE_TOKEN_FILE")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
readToken, err := readSecretFile("RECEIVER_READ_TOKEN_FILE")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server, err := newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("notification-receiver listening on %s", addr)
|
||||
if err = http.ListenAndServe(addr, server.routes()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readSecretFile(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
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
||||
)
|
||||
|
||||
const maxDeliveryBytes = 8 << 10
|
||||
|
||||
var safeIntent = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
|
||||
type storedReceipt struct {
|
||||
notificationexecutor.Receipt
|
||||
Payload notificationexecutor.Payload `json:"payload"`
|
||||
}
|
||||
|
||||
type receiverServer struct {
|
||||
directory string
|
||||
writeToken string
|
||||
readToken string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newReceiverServer(directory, writeToken, readToken string, now func() time.Time) (*receiverServer, error) {
|
||||
if directory == "" || strings.TrimSpace(writeToken) == "" || strings.TrimSpace(readToken) == "" ||
|
||||
writeToken == readToken || now == nil {
|
||||
return nil, errors.New("invalid receiver configuration")
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create receipt directory: %w", err)
|
||||
}
|
||||
return &receiverServer{directory: directory, writeToken: writeToken, readToken: readToken, now: now}, nil
|
||||
}
|
||||
|
||||
func (s *receiverServer) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
||||
})
|
||||
mux.HandleFunc("PUT /delivery/v1/deliveries/{intent}", s.put)
|
||||
mux.HandleFunc("GET /delivery/v1/receipts/{intent}", s.get)
|
||||
mux.HandleFunc("GET /delivery/v1/receipts", s.list)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) {
|
||||
if !tokenMatches(s.writeToken, r) {
|
||||
http.Error(w, "receiver writer denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
intent := r.PathValue("intent")
|
||||
if !safeIntent.MatchString(intent) {
|
||||
http.Error(w, "invalid action intent", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var request notificationexecutor.DeliveryRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDeliveryBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || string(request.ActionIntentID) != intent {
|
||||
http.Error(w, "invalid delivery", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ensureEOF(decoder); err != nil {
|
||||
http.Error(w, "invalid delivery", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
digest, err := notificationexecutor.Digest(request.Payload)
|
||||
if err != nil || digest != request.RequestDigest {
|
||||
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}
|
||||
created, existing, err := s.create(receipt)
|
||||
if err != nil {
|
||||
http.Error(w, "receipt unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
if existing.RequestDigest != digest {
|
||||
http.Error(w, "action intent digest conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
receipt = existing
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if created {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(receipt)
|
||||
}
|
||||
|
||||
func (s *receiverServer) get(w http.ResponseWriter, r *http.Request) {
|
||||
if !tokenMatches(s.writeToken, r) && !tokenMatches(s.readToken, r) {
|
||||
http.Error(w, "receiver reader denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
intent := r.PathValue("intent")
|
||||
if !safeIntent.MatchString(intent) {
|
||||
http.Error(w, "invalid action intent", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
receipt, err := s.load(intent)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "receipt not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "receipt unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeStoredReceipt(w, receipt)
|
||||
}
|
||||
|
||||
func (s *receiverServer) list(w http.ResponseWriter, r *http.Request) {
|
||||
if !tokenMatches(s.readToken, r) {
|
||||
http.Error(w, "receiver reader denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
http.Error(w, "invalid limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
entries, err := os.ReadDir(s.directory)
|
||||
if err != nil {
|
||||
http.Error(w, "receipts unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
receipts := make([]storedReceipt, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
receipt, loadErr := s.load(strings.TrimSuffix(entry.Name(), ".json"))
|
||||
if loadErr != nil {
|
||||
continue
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
sort.Slice(receipts, func(i, j int) bool {
|
||||
if receipts[i].AcceptedAt == receipts[j].AcceptedAt {
|
||||
return receipts[i].ReceiptID > receipts[j].ReceiptID
|
||||
}
|
||||
return receipts[i].AcceptedAt > receipts[j].AcceptedAt
|
||||
})
|
||||
if len(receipts) > limit {
|
||||
receipts = receipts[:limit]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Items []storedReceipt `json:"items"`
|
||||
}{Items: receipts})
|
||||
}
|
||||
|
||||
func (s *receiverServer) create(receipt storedReceipt) (bool, storedReceipt, error) {
|
||||
encoded, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return false, storedReceipt{}, err
|
||||
}
|
||||
temporary, err := os.CreateTemp(s.directory, ".receipt-*.tmp")
|
||||
if err != nil {
|
||||
return false, storedReceipt{}, err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
if err = temporary.Chmod(0600); err == nil {
|
||||
_, err = temporary.Write(encoded)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
closeErr := temporary.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return false, storedReceipt{}, err
|
||||
}
|
||||
target := s.path(string(receipt.ActionIntentID))
|
||||
if err = os.Link(temporaryName, target); err == nil {
|
||||
return true, storedReceipt{}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return false, storedReceipt{}, err
|
||||
}
|
||||
existing, err := s.load(string(receipt.ActionIntentID))
|
||||
return false, existing, err
|
||||
}
|
||||
|
||||
func (s *receiverServer) load(intent string) (storedReceipt, error) {
|
||||
var receipt storedReceipt
|
||||
file, err := os.Open(s.path(intent))
|
||||
if err != nil {
|
||||
return receipt, err
|
||||
}
|
||||
defer file.Close()
|
||||
decoder := json.NewDecoder(io.LimitReader(file, maxDeliveryBytes+1))
|
||||
if err = decoder.Decode(&receipt); err != nil {
|
||||
return storedReceipt{}, err
|
||||
}
|
||||
if string(receipt.ActionIntentID) != intent {
|
||||
return storedReceipt{}, errors.New("receipt identity mismatch")
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func (s *receiverServer) path(intent string) string {
|
||||
return filepath.Join(s.directory, intent+".json")
|
||||
}
|
||||
func tokenMatches(expected string, r *http.Request) bool {
|
||||
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
func ensureEOF(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 writeStoredReceipt(w http.ResponseWriter, receipt storedReceipt) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(receipt)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
)
|
||||
|
||||
func delivery(t *testing.T, intent, message string) notificationexecutor.DeliveryRequest {
|
||||
t.Helper()
|
||||
payload := notificationexecutor.Payload{ActionIntentID: commitcontract.ActionIntentID(intent), TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: message}
|
||||
digest, err := notificationexecutor.Digest(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return notificationexecutor.DeliveryRequest{Payload: payload, RequestDigest: digest}
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler http.Handler, method, path, token string, value any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
if value != nil {
|
||||
if err := json.NewEncoder(&body).Encode(value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest(method, path, &body)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestReceiverIdempotencyConflictAndRestart(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
clock := func() time.Time { return time.UnixMilli(100) }
|
||||
server, err := newReceiverServer(directory, "write-secret", "read-secret", clock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := delivery(t, "intent-1", "message one")
|
||||
w := request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", first)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", first)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("repeat status %d", w.Code)
|
||||
}
|
||||
conflict := delivery(t, "intent-1", "different")
|
||||
w = request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", conflict)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("conflict status %d", w.Code)
|
||||
}
|
||||
restarted, err := newReceiverServer(directory, "write-secret", "read-secret", clock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = request(t, restarted.routes(), http.MethodGet, "/delivery/v1/receipts/intent-1", "write-secret", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("restart get status %d", w.Code)
|
||||
}
|
||||
w = request(t, restarted.routes(), http.MethodGet, "/delivery/v1/receipts?limit=10", "read-secret", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverSeparatesReadAndWriteAuthority(t *testing.T) {
|
||||
server, err := newReceiverServer(t.TempDir(), "write-secret", "read-secret", time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := server.routes()
|
||||
value := delivery(t, "intent-1", "message")
|
||||
if w := request(t, handler, http.MethodPut, "/delivery/v1/deliveries/intent-1", "read-secret", value); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("reader wrote: %d", w.Code)
|
||||
}
|
||||
if w := request(t, handler, http.MethodGet, "/delivery/v1/receipts", "write-secret", nil); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("writer listed: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDuplicateCreatesOneReceipt(t *testing.T) {
|
||||
server, err := newReceiverServer(t.TempDir(), "write-secret", "read-secret", time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := delivery(t, "intent-concurrent", "message")
|
||||
handler := server.routes()
|
||||
codes := make(chan int, 8)
|
||||
var group sync.WaitGroup
|
||||
for range 8 {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
codes <- request(t, handler, http.MethodPut, "/delivery/v1/deliveries/intent-concurrent", "write-secret", value).Code
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(codes)
|
||||
created := 0
|
||||
for code := range codes {
|
||||
if code == http.StatusCreated {
|
||||
created++
|
||||
} else if code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", code)
|
||||
}
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("created=%d", created)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
package capability
|
||||
|
||||
import "git.cin.su/m/Assistant/platform/ids"
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type CapabilityID ids.ID
|
||||
type CapabilityBindingID ids.ID
|
||||
|
||||
type BindingState string
|
||||
type BindingProtocol string
|
||||
|
||||
const (
|
||||
BindingActive BindingState = "ACTIVE"
|
||||
BindingRetired BindingState = "RETIRED"
|
||||
|
||||
ProtocolIdempotentReceiptV1 BindingProtocol = "IDEMPOTENT_RECEIPT_V1"
|
||||
)
|
||||
|
||||
// Binding is Capability-owned correctness state. SecretRef is an opaque
|
||||
// lookup name; it must never contain credential bytes.
|
||||
type Binding struct {
|
||||
ID CapabilityBindingID
|
||||
CapabilityID CapabilityID
|
||||
OwnerPrincipalID identity.PrincipalID
|
||||
Revision int64
|
||||
State BindingState
|
||||
HTTPSOrigin string
|
||||
DeliveryPath string
|
||||
ReceiptPath string
|
||||
Protocol BindingProtocol
|
||||
SecretRef string
|
||||
VerifiedAtUnixMillis int64
|
||||
Health HealthState
|
||||
}
|
||||
|
||||
type HealthState string
|
||||
|
||||
const (
|
||||
|
||||
+29
-11
@@ -28,20 +28,38 @@ const (
|
||||
type ExternalOperation string
|
||||
|
||||
const (
|
||||
CapabilityTestExecutor capability.CapabilityID = "test-executor"
|
||||
CapabilityContinuityTransition capability.CapabilityID = "continuity-transition"
|
||||
OperationTestIdempotentEffect ExternalOperation = "test-idempotent-effect"
|
||||
OperationSatisfyDelivered ExternalOperation = "satisfy-delivered"
|
||||
CapabilityTestExecutor capability.CapabilityID = "test-executor"
|
||||
CapabilityContinuityTransition capability.CapabilityID = "continuity-transition"
|
||||
CapabilityProspectiveNotification capability.CapabilityID = "prospective-notification-delivery"
|
||||
OperationTestIdempotentEffect ExternalOperation = "test-idempotent-effect"
|
||||
OperationSatisfyDelivered ExternalOperation = "satisfy-delivered"
|
||||
OperationDeliverProspectiveNotification ExternalOperation = "deliver-prospective-notification-v1"
|
||||
)
|
||||
|
||||
const ProspectiveDeliveredTemplateV1 = "prospective-delivered-v1"
|
||||
|
||||
// ProspectiveNotificationSubject is immutable Commit evidence. Destination
|
||||
// data is resolved from the exact Capability binding revision, never from UI.
|
||||
type ProspectiveNotificationSubject struct {
|
||||
ProspectiveItemID continuity.ProspectiveItemID
|
||||
ExpectedRevision continuity.ProspectiveRevision
|
||||
TemplateCode string
|
||||
BindingID capability.CapabilityBindingID
|
||||
BindingRevision int64
|
||||
RenderedTitle string
|
||||
RenderedMessage string
|
||||
RequestDigest string
|
||||
}
|
||||
|
||||
type ActionProposal struct {
|
||||
IntentID ActionIntentID
|
||||
ActorID identity.ActorID
|
||||
CapabilityID capability.CapabilityID
|
||||
Target work.TaskTarget
|
||||
Operation ExternalOperation
|
||||
ContinuitySubject continuity.SatisfyDeliveredSubject
|
||||
State ActionState
|
||||
IntentID ActionIntentID
|
||||
ActorID identity.ActorID
|
||||
CapabilityID capability.CapabilityID
|
||||
Target work.TaskTarget
|
||||
Operation ExternalOperation
|
||||
ContinuitySubject continuity.SatisfyDeliveredSubject
|
||||
NotificationSubject ProspectiveNotificationSubject
|
||||
State ActionState
|
||||
}
|
||||
|
||||
type AuthorizedAction struct {
|
||||
|
||||
@@ -110,9 +110,20 @@ CREATE TABLE IF NOT EXISTS commit.authorized_action_envelopes (
|
||||
action_state TEXT NOT NULL,
|
||||
authorized_at_unix_millis BIGINT NOT NULL,
|
||||
envelope_revision BIGINT NOT NULL,
|
||||
notification_template_code TEXT NOT NULL DEFAULT '',
|
||||
capability_binding_id TEXT NOT NULL DEFAULT '',
|
||||
capability_binding_revision BIGINT NOT NULL DEFAULT 0,
|
||||
notification_title TEXT NOT NULL DEFAULT '',
|
||||
notification_message TEXT NOT NULL DEFAULT '',
|
||||
request_digest TEXT NOT NULL DEFAULT '',
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (grant_revision > 0), CHECK (task_revision > 0),
|
||||
CHECK (authorized_at_unix_millis > 0), CHECK (envelope_revision = 1)
|
||||
CHECK (authorized_at_unix_millis > 0), CHECK (envelope_revision = 1),
|
||||
CHECK (operation <> 'deliver-prospective-notification-v1' OR (
|
||||
notification_template_code = 'prospective-delivered-v1' AND notification_title <> '' AND notification_message <> '' AND capability_binding_id <> '' AND
|
||||
capability_binding_revision > 0 AND request_digest ~ '^[0-9a-f]{64}$' AND
|
||||
expected_prospective_revision > 0 AND prospective_item_id <> ''
|
||||
))
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
@@ -157,6 +168,23 @@ CREATE TABLE IF NOT EXISTS capability.health_projection (
|
||||
semantic_version INTEGER NOT NULL,
|
||||
observed_at_unix_millis BIGINT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.notification_bindings (
|
||||
binding_id TEXT NOT NULL, capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL, state TEXT NOT NULL, https_origin TEXT NOT NULL,
|
||||
delivery_path TEXT NOT NULL, receipt_path TEXT NOT NULL, protocol TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL, verified_at_unix_millis BIGINT NOT NULL, health TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (binding_id, revision),
|
||||
CHECK (revision > 0), CHECK (verified_at_unix_millis > 0),
|
||||
CHECK (state IN ('ACTIVE','RETIRED')), CHECK (health IN ('AVAILABLE','DEGRADED','UNAVAILABLE')),
|
||||
CHECK (protocol = 'IDEMPOTENT_RECEIPT_V1'), CHECK (https_origin LIKE 'https://%'),
|
||||
CHECK (delivery_path LIKE '/%'), CHECK (receipt_path LIKE '/%')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.notification_binding_heads (
|
||||
capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL, revision BIGINT NOT NULL,
|
||||
PRIMARY KEY (capability_id, owner_principal_id), CHECK (revision > 0)
|
||||
);
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE continuity_role;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
BEGIN;
|
||||
SET ROLE capability_role;
|
||||
CREATE TABLE IF NOT EXISTS capability.notification_bindings (
|
||||
binding_id TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
https_origin TEXT NOT NULL,
|
||||
delivery_path TEXT NOT NULL,
|
||||
receipt_path TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
secret_ref TEXT NOT NULL,
|
||||
verified_at_unix_millis BIGINT NOT NULL,
|
||||
health TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (binding_id, revision),
|
||||
CHECK (revision > 0),
|
||||
CHECK (verified_at_unix_millis > 0),
|
||||
CHECK (state IN ('ACTIVE','RETIRED')),
|
||||
CHECK (health IN ('AVAILABLE','DEGRADED','UNAVAILABLE')),
|
||||
CHECK (protocol = 'IDEMPOTENT_RECEIPT_V1'),
|
||||
CHECK (https_origin LIKE 'https://%'),
|
||||
CHECK (delivery_path LIKE '/%'),
|
||||
CHECK (receipt_path LIKE '/%')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.notification_binding_heads (
|
||||
capability_id TEXT NOT NULL,
|
||||
owner_principal_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
PRIMARY KEY (capability_id, owner_principal_id),
|
||||
CHECK (revision > 0)
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
SET ROLE commit_role;
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_template_code TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS capability_binding_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS capability_binding_revision BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_title TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_message TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS request_digest TEXT NOT NULL DEFAULT '';
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='authorized_action_notification_evidence_v1' AND conrelid='commit.authorized_action_envelopes'::regclass) THEN
|
||||
ALTER TABLE commit.authorized_action_envelopes ADD CONSTRAINT authorized_action_notification_evidence_v1 CHECK (
|
||||
operation <> 'deliver-prospective-notification-v1' OR (
|
||||
notification_template_code = 'prospective-delivered-v1' AND notification_title <> '' AND notification_message <> '' AND
|
||||
capability_binding_id <> '' AND capability_binding_revision > 0 AND
|
||||
request_digest ~ '^[0-9a-f]{64}$' AND expected_prospective_revision > 0 AND prospective_item_id <> ''
|
||||
)
|
||||
) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE commit.authorized_action_envelopes VALIDATE CONSTRAINT authorized_action_notification_evidence_v1;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
assistent.cin.su {
|
||||
encode zstd gzip
|
||||
|
||||
handle /delivery/* {
|
||||
reverse_proxy host.docker.internal:18081
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy host.docker.internal:18080
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/images/postgres.Dockerfile
|
||||
args: { SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-assistant}
|
||||
@@ -30,8 +31,27 @@ services:
|
||||
command: ["-js", "-m", "8222"]
|
||||
volumes: ["assistant_nats:/data"]
|
||||
|
||||
notification-receiver:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: notification-receiver, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8085"
|
||||
RECEIPT_DIRECTORY: /var/lib/notification-receiver
|
||||
RECEIVER_WRITE_TOKEN_FILE: /run/notification-writer/token
|
||||
RECEIVER_READ_TOKEN_FILE: /run/notification-reader/token
|
||||
ports: ["${NOTIFICATION_RECEIVER_PORT:-18081}:8085"]
|
||||
volumes:
|
||||
- assistant_notification_receipts:/var/lib/notification-receiver
|
||||
- assistant_notification_writer_secret:/run/notification-writer:ro
|
||||
- assistant_notification_reader_secret:/run/notification-reader:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
core-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime } }
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
@@ -55,7 +75,7 @@ services:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
|
||||
continuity-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime } }
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8081"
|
||||
@@ -68,7 +88,7 @@ services:
|
||||
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
|
||||
|
||||
commit-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime } }
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8082"
|
||||
@@ -82,16 +102,20 @@ services:
|
||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
|
||||
NOTIFICATION_WRITE_TOKEN_FILE: /run/notification-writer/token
|
||||
NOTIFICATION_WRITE_SECRET_REF: domovoy-notification-write-v1
|
||||
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
|
||||
depends_on: [core-runtime, continuity-runtime]
|
||||
depends_on: [core-runtime, continuity-runtime, notification-receiver]
|
||||
volumes:
|
||||
- assistant_notification_writer_secret:/run/notification-writer:ro
|
||||
|
||||
cognitive-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker } }
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment: { LISTEN_ADDR: ":8083" }
|
||||
|
||||
creator-web:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web } }
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8084"
|
||||
@@ -111,3 +135,8 @@ volumes:
|
||||
assistant_nats:
|
||||
assistant_core_secrets:
|
||||
external: true
|
||||
assistant_notification_receipts:
|
||||
assistant_notification_writer_secret:
|
||||
external: true
|
||||
assistant_notification_reader_secret:
|
||||
external: true
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
FROM postgres:18.6-alpine
|
||||
ARG SOURCE_REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
|
||||
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
||||
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
|
||||
@@ -7,6 +7,9 @@ ARG TARGET
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/runtime ./cmd/${TARGET}
|
||||
|
||||
FROM alpine:3.23
|
||||
ARG SOURCE_REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
||||
USER assistant
|
||||
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^[0-9a-f]{40}$')]
|
||||
[string]$Revision,
|
||||
[string]$DockerContext = 'docker-prod',
|
||||
[string]$LocalManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json",
|
||||
[string]$NasManifestPath = '\\nas\MST\codex\_monitoring\domovoy\deployment-manifest.json',
|
||||
[string[]]$ExpectedContainers = @(
|
||||
'assistant-postgres-1',
|
||||
'assistant-notification-receiver-1',
|
||||
'assistant-core-runtime-1',
|
||||
'assistant-continuity-runtime-1',
|
||||
'assistant-commit-runtime-1',
|
||||
'assistant-cognitive-worker-1',
|
||||
'assistant-creator-web-1'
|
||||
)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Write-AtomicJson([string]$Path, [string]$Json) {
|
||||
$directory = Split-Path -Parent $Path
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
$partial = Join-Path $directory ('.' + [IO.Path]::GetFileName($Path) + ".${PID}.partial")
|
||||
try {
|
||||
[IO.File]::WriteAllText($partial, $Json + "`n", [Text.UTF8Encoding]::new($false))
|
||||
Move-Item -LiteralPath $partial -Destination $Path -Force
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force }
|
||||
}
|
||||
}
|
||||
|
||||
$artifacts = foreach ($container in $ExpectedContainers) {
|
||||
$raw = ((& docker.exe --context $DockerContext inspect $container --format '{{json .}}') -join '').Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "Cannot inspect $container" }
|
||||
$inspect = $raw | ConvertFrom-Json
|
||||
$actual = [string]$inspect.Config.Labels.'org.opencontainers.image.revision'
|
||||
if ($actual -ne $Revision) { throw "$container revision mismatch: expected=$Revision actual=$actual" }
|
||||
if ($inspect.State.Status -ne 'running') { throw "$container is not running" }
|
||||
[ordered]@{ container = $container; image_id = [string]$inspect.Image; revision = $actual }
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
version = 1
|
||||
project = 'domovoy'
|
||||
revision = $Revision
|
||||
promoted_at_utc = [DateTime]::UtcNow.ToString('o')
|
||||
artifacts = @($artifacts)
|
||||
}
|
||||
$json = $manifest | ConvertTo-Json -Depth 5
|
||||
Write-AtomicJson $LocalManifestPath $json
|
||||
Write-AtomicJson $NasManifestPath $json
|
||||
Write-Output "Production artifact confirmed: revision=$Revision containers=$($artifacts.Count)"
|
||||
@@ -0,0 +1,83 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Endpoint = 'https://assistent.cin.su/delivery/v1/receipts?limit=100',
|
||||
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
|
||||
[string]$StatePath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-state.json",
|
||||
[ValidateRange(1, 5)]
|
||||
[int]$MaximumDisplays = 3,
|
||||
[switch]$NoDisplay
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Write-AtomicJson {
|
||||
param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path)
|
||||
$directory = Split-Path -Parent $Path
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
$temporary = Join-Path $directory ('.{0}.{1}.tmp' -f (Split-Path -Leaf $Path), [guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
[IO.File]::WriteAllText($temporary, (($Value | ConvertTo-Json -Depth 8) + "`n"), [Text.UTF8Encoding]::new($false))
|
||||
Move-Item -LiteralPath $temporary -Destination $Path -Force
|
||||
}
|
||||
finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } }
|
||||
}
|
||||
|
||||
$uri = [Uri]$Endpoint
|
||||
if ($uri.Scheme -ne 'https' -or $uri.Host -ne 'assistent.cin.su' -or $uri.AbsolutePath -ne '/delivery/v1/receipts') {
|
||||
throw 'Notification receiver endpoint is outside the fixed HTTPS binding'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw "Notification receiver key not found: $KeyRecordPath" }
|
||||
$keyRecord = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
|
||||
if ($keyRecord.version -ne 1 -or $keyRecord.project -ne 'domovoy' -or $keyRecord.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
|
||||
$secure = ConvertTo-SecureString ([string]$keyRecord.protected_value)
|
||||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) }
|
||||
if ([string]::IsNullOrWhiteSpace($token)) { throw 'Notification receiver key is empty' }
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $uri.AbsoluteUri -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 15
|
||||
}
|
||||
finally { $token = $null; $secure.Dispose() }
|
||||
|
||||
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||
if (Test-Path -LiteralPath $StatePath -PathType Leaf) {
|
||||
$previous = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
if ($previous.version -eq 1) { foreach ($id in @($previous.displayed_receipt_ids)) { [void]$seen.Add([string]$id) } }
|
||||
}
|
||||
|
||||
$candidates = @($response.items | Where-Object {
|
||||
$_.receipt_id -match '^receipt-[A-Za-z0-9_-]{1,128}$' -and
|
||||
$_.action_intent_id -match '^[A-Za-z0-9_-]{1,128}$' -and
|
||||
$_.request_digest -match '^[0-9a-f]{64}$' -and
|
||||
$_.accepted_at_unix_millis -gt 0 -and
|
||||
$_.payload.template_code -eq 'prospective-delivered-v1' -and
|
||||
-not [string]::IsNullOrWhiteSpace([string]$_.payload.title) -and
|
||||
-not [string]::IsNullOrWhiteSpace([string]$_.payload.message) -and
|
||||
-not $seen.Contains([string]$_.receipt_id)
|
||||
} | Sort-Object accepted_at_unix_millis | Select-Object -First $MaximumDisplays)
|
||||
|
||||
if (-not $NoDisplay -and $candidates.Count -gt 0) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$notification = [System.Windows.Forms.NotifyIcon]::new()
|
||||
try {
|
||||
$notification.Icon = [System.Drawing.SystemIcons]::Information
|
||||
$notification.Visible = $true
|
||||
$notification.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info
|
||||
foreach ($receipt in $candidates) {
|
||||
$notification.BalloonTipTitle = ([string]$receipt.payload.title).Substring(0, [Math]::Min(63, ([string]$receipt.payload.title).Length))
|
||||
$notification.BalloonTipText = ([string]$receipt.payload.message).Substring(0, [Math]::Min(255, ([string]$receipt.payload.message).Length))
|
||||
$notification.ShowBalloonTip(6000)
|
||||
Start-Sleep -Seconds 7
|
||||
[void]$seen.Add([string]$receipt.receipt_id)
|
||||
}
|
||||
}
|
||||
finally { $notification.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $NoDisplay) {
|
||||
$bounded = @($seen | Select-Object -Last 1000)
|
||||
Write-AtomicJson -Path $StatePath -Value ([ordered]@{ version = 1; displayed_receipt_ids = $bounded; last_poll_at_utc = [DateTime]::UtcNow.ToString('o') })
|
||||
}
|
||||
Write-Output "notification_receiver=poll_ok new=$($candidates.Count) displayed=$(if ($NoDisplay) { 0 } else { $candidates.Count })"
|
||||
@@ -0,0 +1,40 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ScriptPath = '\\nas\MST\codex\assistent\deploy\windows\Invoke-DomovoyNotificationReceiver.ps1',
|
||||
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
|
||||
[string]$NasKeyBackupPath = '\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json',
|
||||
[string]$TaskName = 'Domovoy Notification Receiver'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf) -and (Test-Path -LiteralPath $NasKeyBackupPath -PathType Leaf)) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeyRecordPath) | Out-Null
|
||||
Copy-Item -LiteralPath $NasKeyBackupPath -Destination $KeyRecordPath
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw 'DPAPI-protected notification read key is missing' }
|
||||
$record = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
|
||||
if ($record.version -ne 1 -or $record.project -ne 'domovoy' -or $record.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
|
||||
$probe = ConvertTo-SecureString ([string]$record.protected_value)
|
||||
$probe.Dispose()
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
& icacls.exe $KeyRecordPath /inheritance:r /grant:r "${identity}:F" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'DPAPI key ACL hardening failed' }
|
||||
$nasDirectory = Split-Path -Parent $NasKeyBackupPath
|
||||
New-Item -ItemType Directory -Force -Path $nasDirectory | Out-Null
|
||||
$partial = "$NasKeyBackupPath.$PID.partial"
|
||||
Copy-Item -LiteralPath $KeyRecordPath -Destination $partial
|
||||
Move-Item -LiteralPath $partial -Destination $NasKeyBackupPath -Force
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { throw 'Notification receiver script not found' }
|
||||
$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source
|
||||
$arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`""
|
||||
$action = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments
|
||||
$repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 2) -RepetitionDuration (New-TimeSpan -Days 3650)
|
||||
$logon = New-ScheduledTaskTrigger -AtLogOn -User $identity
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger @($repeat, $logon) -Principal $principal -Settings $settings -Description 'Read typed Domovoy receipts and show local Windows notifications.' -Force | Out-Null
|
||||
Write-Output "Scheduled task registered: $TaskName"
|
||||
@@ -7,6 +7,7 @@ param(
|
||||
[string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres',
|
||||
[string]$NasStatusPath = '\\nas\MST\codex\_monitoring\domovoy\status.json',
|
||||
[string]$LocalStatusPath = "$env:LOCALAPPDATA\Domovoy\monitoring\status.json",
|
||||
[string]$DeploymentManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json",
|
||||
[double]$MaxProductionBackupAgeHours = 30,
|
||||
[double]$MaxNasBackupAgeHours = 36,
|
||||
[int]$MinimumCertificateDays = 21,
|
||||
@@ -14,6 +15,7 @@ param(
|
||||
'assistant-core-runtime-1',
|
||||
'assistant-creator-web-1',
|
||||
'assistant-commit-runtime-1',
|
||||
'assistant-notification-receiver-1',
|
||||
'assistant-continuity-runtime-1',
|
||||
'assistant-cognitive-worker-1',
|
||||
'assistant-postgres-1',
|
||||
@@ -59,6 +61,21 @@ foreach ($container in $ExpectedContainers) {
|
||||
catch { Add-Check "container:$container" $false $_.Exception.Message }
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = Get-Content -LiteralPath $DeploymentManifestPath -Raw | ConvertFrom-Json
|
||||
if ($manifest.version -ne 1 -or $manifest.revision -notmatch '^[0-9a-f]{40}$') { throw 'invalid deployment manifest' }
|
||||
if (@($manifest.artifacts).Count -ne 7) { throw 'deployment manifest artifact count mismatch' }
|
||||
foreach ($artifact in $manifest.artifacts) {
|
||||
$raw = ((& docker.exe --context $DockerContext inspect $artifact.container --format '{{json .}}') -join '').Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "cannot inspect $($artifact.container)" }
|
||||
$inspect = $raw | ConvertFrom-Json
|
||||
$label = [string]$inspect.Config.Labels.'org.opencontainers.image.revision'
|
||||
if ($label -ne $manifest.revision -or [string]$inspect.Image -ne [string]$artifact.image_id) { throw "artifact drift: $($artifact.container)" }
|
||||
}
|
||||
Add-Check 'deployment-artifact' $true "revision=$($manifest.revision) containers=$(@($manifest.artifacts).Count)"
|
||||
}
|
||||
catch { Add-Check 'deployment-artifact' $false $_.Exception.Message }
|
||||
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $Endpoint -TimeoutSec 15
|
||||
$ok = $response.status -eq 'ok'
|
||||
@@ -130,6 +147,14 @@ try {
|
||||
}
|
||||
catch { Add-Check 'offhost-backup-task' $false $_.Exception.Message }
|
||||
|
||||
try {
|
||||
$notificationTask = Get-ScheduledTask -TaskName 'Domovoy Notification Receiver'
|
||||
$notificationInfo = Get-ScheduledTaskInfo -TaskName $notificationTask.TaskName
|
||||
$ok = $notificationTask.State -ne 'Disabled' -and $notificationInfo.LastTaskResult -eq 0
|
||||
Add-Check 'notification-receiver-task' $ok "state=$($notificationTask.State) last_result=$($notificationInfo.LastTaskResult) next=$($notificationInfo.NextRunTime.ToString('o'))"
|
||||
}
|
||||
catch { Add-Check 'notification-receiver-task' $false $_.Exception.Message }
|
||||
|
||||
$overall = if (@($checks | Where-Object { -not $_.ok }).Count -eq 0) { 'healthy' } else { 'degraded' }
|
||||
$record = [ordered]@{
|
||||
version = 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000039-production-health-notifications.json",
|
||||
"task_id": "DEV-000039",
|
||||
"task_file": "dev/tasks/DEV-000045-production-artifact-provenance.json",
|
||||
"task_id": "DEV-000045",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"task_id": "ARCH-000005",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0004",
|
||||
"base_repo_revision": "91d03b730d1bdbbe8e67b831fb21046768c4df5e",
|
||||
"risk_tier": "E",
|
||||
"intent": "Produce a proposal-only decision for Domovoy's first real external operation, including typed semantics, authority, capability binding, idempotency, reconciliation, secret lifecycle, and safe implementation slices without installing credentials or changing runtime behavior.",
|
||||
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "CONSTITUTION-1.3.5", "CONSTITUTION-1.3.14", "CONSTITUTION-1.3.31"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"docs/FIRST_EXTERNAL_OPERATION_PROPOSAL.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/ARCH-000005-first-external-operation-proposal.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"security/**",
|
||||
"database/**",
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"adapters/**",
|
||||
"cmd/**",
|
||||
"deploy/**",
|
||||
"tests/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_third_party_dependencies": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["Architecture Constitution", "Core Semantic Model V1", "ADR-0003", "existing CapabilityBinding ownership", "existing Commit/Executor boundary"],
|
||||
"may_change": []
|
||||
},
|
||||
"semantic_owner": "proposal-only; no architecture decision is accepted by this task",
|
||||
"canonical_state": ["none"],
|
||||
"state_transitions": ["none"],
|
||||
"side_effects_and_privileges": ["select ARCH-000005 in the protected active-task pointer so promotion CI can validate this proposal", "no endpoint call, secret creation, deployment, or runtime mutation"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["proposal must specify deterministic idempotency and read-only reconciliation", "implementation remains blocked until explicit acceptance and a concrete compatible destination"],
|
||||
"assumptions": [
|
||||
"The first external operation should extend the existing Creator and Continuity flow rather than introduce an unrelated automation domain",
|
||||
"A destination chosen later can provide an idempotent create-by-ActionIntentID and read-only lookup protocol",
|
||||
"No arbitrary URL or arbitrary provider payload should cross the Creator or model boundary",
|
||||
"docker-test remains prohibited"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"proposal compares notification, arbitrary webhook, file export, Git mutation, and direct provider alternatives",
|
||||
"proposal recommends one typed operation without treating the provider mechanism as domain meaning",
|
||||
"proposal keeps destination binding canonical under the existing Capability owner and secrets external",
|
||||
"proposal requires explicit Creator confirmation, live Work justification, current authority, and current capability binding revision",
|
||||
"proposal defines idempotent execution, UNKNOWN_OUTCOME, read-only reconciliation, and binding retirement behavior",
|
||||
"proposal prevents SSRF, redirects, arbitrary URL/body, ambient credentials, and blind retry",
|
||||
"proposal identifies the minimum external destination facts still requiring explicit acceptance",
|
||||
"the active task pointer selects this proposal and no implementation, protected architecture, credential, or deployment state is changed"
|
||||
],
|
||||
"required_tests": [
|
||||
"architecture preflight",
|
||||
"exact diff scope",
|
||||
"constitutional and ADR-0003 semantic review",
|
||||
"go test",
|
||||
"go vet",
|
||||
"CI"
|
||||
],
|
||||
"terminal_gap_states": [
|
||||
"BLOCKED_BY_SPEC_GAP",
|
||||
"BLOCKED_BY_ARCHITECTURE_GAP",
|
||||
"BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"BLOCKED_BY_CONFLICT"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"task_id": "ARCH-000006",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0004",
|
||||
"base_repo_revision": "b4f86b8c46870fe6a1c38f9cfdf35f0a818f9065",
|
||||
"risk_tier": "E",
|
||||
"intent": "Accept the typed prospective notification operation, compatible receipt protocol, docker-prod receiver destination, and local Windows display channel proposed by ARCH-000005.",
|
||||
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "CONSTITUTION-1.3.5", "CONSTITUTION-1.3.14", "CONSTITUTION-1.3.31"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"architecture/adrs/ADR-0005-prospective-notification-delivery.md",
|
||||
"docs/FIRST_EXTERNAL_OPERATION_PROPOSAL.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/ARCH-000006-accept-prospective-notification-delivery.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/ARCHITECTURE_CONSTITUTION.md",
|
||||
"architecture/CORE_SEMANTIC_MODEL_V1.md",
|
||||
"architecture/architecture-registry.yaml",
|
||||
"architecture/architecture-registry.json",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"security/**",
|
||||
"database/**",
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"adapters/**",
|
||||
"cmd/**",
|
||||
"deploy/**",
|
||||
"tests/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 2,
|
||||
"new_third_party_dependencies": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["contracts.authority@v1", "contracts.capability@v1", "contracts.work@v1", "contracts.continuity@v1", "contracts.commit@v1"],
|
||||
"may_change": ["typed Commit notification envelope", "CapabilityBinding lifecycle", "prospective notification executor port"]
|
||||
},
|
||||
"semantic_owner": "Capability owns binding; Commit owns action evidence; Continuity owns ProspectiveItem; the receiver is an external adapter system",
|
||||
"canonical_state": ["no canonical state added by this acceptance task"],
|
||||
"state_transitions": ["no runtime transition in this acceptance task", "future delivery never changes ProspectiveItem state"],
|
||||
"side_effects_and_privileges": [
|
||||
"accept one future Commit write/reconciliation credential scoped to the fixed receiver protocol",
|
||||
"accept one future read-only Windows receiver credential",
|
||||
"no credential generation, endpoint call, deployment, or runtime mutation in this task"
|
||||
],
|
||||
"persistence_and_migrations": ["none in this acceptance task"],
|
||||
"failure_retry_restart": [
|
||||
"ambiguous send outcome is UNKNOWN_OUTCOME and is never blindly retried",
|
||||
"reconciliation is read-only and compares immutable digest evidence",
|
||||
"Windows projection failure cannot alter receiver or canonical Commit outcomes"
|
||||
],
|
||||
"assumptions": [
|
||||
"The Creator's continuation after the explicit acceptance request accepts the ARCH-000005 recommendation and local Windows channel",
|
||||
"The compatible receiver is an external adapter boundary even when deployed on docker-prod beside Assistant containers",
|
||||
"The existing architecture registry ownership and dependency graph is sufficient",
|
||||
"docker-test remains prohibited"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"ADR accepts only the typed notification operation and no arbitrary webhook surface",
|
||||
"CapabilityBinding remains Capability-owned and secrets remain outside canonical state",
|
||||
"Commit success means receiver receipt acceptance, not Windows display or human reading",
|
||||
"receiver write and Windows read credentials are separate and least-privileged",
|
||||
"timeout remains UNKNOWN_OUTCOME and reconciliation remains read-only",
|
||||
"no registry change, credential creation, implementation, or deployment occurs in this task"
|
||||
],
|
||||
"required_tests": ["architecture preflight", "exact diff scope", "constitutional and ADR semantic review", "go test", "go vet", "CI", "independent Tier E review before promotion"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"task_id": "DEV-000040",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0004",
|
||||
"base_repo_revision": "2d2615b734e0df053bced78e51f548b37ea0e60c",
|
||||
"risk_tier": "E",
|
||||
"intent": "Expose a bounded, owner-scoped, read-only Creator action history from Commit canonical state and render it in the Domovoy console without new mutation authority, persistence, credentials, or docker-test.",
|
||||
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "CONSTITUTION-1.3.23", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["commit"],
|
||||
"paths": [
|
||||
"adapters/postgres/commit_repository.go",
|
||||
"cmd/commit-runtime/creator_command.go",
|
||||
"cmd/commit-runtime/creator_command_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",
|
||||
"tests/creator_commit/verify-remote.ps1",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000040-creator-action-history.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"contracts/**",
|
||||
"modules/identity/**",
|
||||
"modules/authority/**",
|
||||
"modules/work/**",
|
||||
"modules/continuity/**",
|
||||
"database/**",
|
||||
"security/**",
|
||||
"deploy/**"
|
||||
]
|
||||
},
|
||||
"allowed_architecture_delta": {
|
||||
"new_cross_module_dependencies": 0,
|
||||
"new_canonical_owners": 0,
|
||||
"new_privileges": 0,
|
||||
"new_third_party_dependencies": 0
|
||||
},
|
||||
"contracts": {
|
||||
"consume": ["Creator session", "contracts.commit@v1 canonical action envelope and attempt", "existing Creator gateway"],
|
||||
"may_change": []
|
||||
},
|
||||
"semantic_owner": "commit for canonical action data; Creator UI is a read-only projection",
|
||||
"canonical_state": ["existing Commit authorized action envelopes and action attempts; no new canonical state"],
|
||||
"state_transitions": ["none; public query is read-only"],
|
||||
"side_effects_and_privileges": ["authenticated HTTP reads from the existing commit schema", "browser renders a replaceable projection"],
|
||||
"persistence_and_migrations": ["none; bounded owner-local SELECT only"],
|
||||
"failure_retry_restart": ["invalid or expired session is denied before repository access", "invalid limits are rejected", "an authorized action with no attempt remains visible as AUTHORIZED", "query failure returns explicit service unavailability and does not mutate state", "UI refresh is explicit and safe to repeat"],
|
||||
"assumptions": [
|
||||
"The first page of at most 50 newest actions is sufficient for the V1 operational console",
|
||||
"AuthorizedAtUnixMillis plus AuthorizedActionID provides deterministic descending order",
|
||||
"EvidenceRef and grant details are intentionally omitted from the list response",
|
||||
"docker-test remains prohibited"
|
||||
],
|
||||
"acceptance_scenarios": [
|
||||
"missing or invalid Creator session is denied before Commit history query",
|
||||
"Actor and Principal filters are taken only from the validated server session",
|
||||
"history is bounded to 1 through 50 and ordered deterministically newest first",
|
||||
"only Commit-owned tables are queried",
|
||||
"an action without an attempt is returned as AUTHORIZED with no attempt identity",
|
||||
"the gateway allowlists only the exact GET action-history route",
|
||||
"the console renders loading, empty, error, succeeded, failed, unknown, executing, and authorized states without exposing evidence or credentials",
|
||||
"desktop and mobile layouts remain usable and keyboard accessible",
|
||||
"production smoke observes the newly committed Creator action through the authenticated history endpoint",
|
||||
"exact diff validation observes only declared paths"
|
||||
],
|
||||
"required_tests": [
|
||||
"unit",
|
||||
"owner filter and bounded query",
|
||||
"gateway allowlist",
|
||||
"runtime HTTP",
|
||||
"responsive browser",
|
||||
"browser console",
|
||||
"remote Docker smoke",
|
||||
"PowerShell parser",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"task_id": "DEV-000041",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "45fb5f8709ef9f327c5e41e7f9adb0d7a7d06866",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement the non-user-facing foundation for ADR-0005: Capability-owned versioned receiver binding, typed Commit notification evidence, a durable idempotent receipt receiver, and a strict HTTPS executor/reconciler adapter without enabling a Creator mutation route or installing credentials.",
|
||||
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "ADR-0005"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["capability", "commit"],
|
||||
"paths": [
|
||||
"contracts/capability/types.go",
|
||||
"contracts/commit/types.go",
|
||||
"modules/capability/domain/binding.go",
|
||||
"modules/capability/domain/binding_test.go",
|
||||
"modules/capability/ports/repository.go",
|
||||
"adapters/postgres/capability_repository.go",
|
||||
"adapters/postgres/capability_repository_test.go",
|
||||
"adapters/postgres/commit_repository.go",
|
||||
"adapters/notificationexecutor/executor.go",
|
||||
"adapters/notificationexecutor/executor_test.go",
|
||||
"cmd/core-runtime/main.go",
|
||||
"cmd/core-runtime/main_test.go",
|
||||
"cmd/notification-receiver/main.go",
|
||||
"cmd/notification-receiver/server.go",
|
||||
"cmd/notification-receiver/server_test.go",
|
||||
"database/migrations/010_capability_notification_binding.sql",
|
||||
"database/migrations/011_commit_notification_envelope.sql",
|
||||
"database/bootstrap/001_roles_and_schemas.sql",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000041-notification-delivery-foundation.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"architecture/**",
|
||||
"ci/**",
|
||||
".gitea/**",
|
||||
"modules/identity/**",
|
||||
"modules/authority/**",
|
||||
"modules/work/**",
|
||||
"modules/continuity/**",
|
||||
"cmd/creator-web/**",
|
||||
"deploy/**",
|
||||
"security/**"
|
||||
]
|
||||
},
|
||||
"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.capability@v1", "contracts.commit@v1", "ADR-0005 fixed receipt protocol"],
|
||||
"may_change": ["CapabilityBinding V1", "Commit prospective notification evidence V1"]
|
||||
},
|
||||
"semantic_owner": "Capability owns binding state; Commit owns immutable action evidence; receiver storage is external adapter receipt state",
|
||||
"canonical_state": ["capability.notification_bindings", "future Commit envelope columns only; no action rows created by this task"],
|
||||
"state_transitions": ["CapabilityBinding ACTIVE to RETIRED with monotonic revision", "receiver receipt absent to immutable present"],
|
||||
"side_effects_and_privileges": ["receiver PUT writes only its isolated receipt directory", "receiver GET/list are read-only", "HTTPS adapter accepts only a validated fixed binding", "no credentials installed and no route exposed to Creator"],
|
||||
"persistence_and_migrations": ["add Capability-owned binding table", "add backward-compatible notification evidence columns to Commit envelope", "receiver uses atomic create and immutable receipt files"],
|
||||
"failure_retry_restart": ["same intent and digest returns the original receipt", "same intent with different digest is conflict", "timeout or malformed success is unknown", "GET reconciliation never writes", "restart rebuilds receiver truth from durable receipt files"],
|
||||
"assumptions": ["The public TLS reverse proxy is configured only in a later deployment task", "secret references are identifiers and never secret bytes", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["binding validation rejects HTTP, non-fixed paths, invalid revisions, unhealthy or retired state", "owner-local binding load cannot return a foreign principal binding", "receiver authorization separates writer and reader", "receiver survives restart and keeps one receipt per action intent", "executor disables redirects, bounds response size, verifies receipt identity and digest, and classifies ambiguous failures as unknown", "historical Commit rows remain readable after additive migration", "no Creator command can invoke the new executor in this task"],
|
||||
"required_tests": ["unit", "receiver restart and conflict", "TLS executor and reconciliation", "PostgreSQL repository contract where available", "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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"task_id": "DEV-000043",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "c6139699a9efed624b49b15c801f5c04e884a0aa",
|
||||
"risk_tier": "E",
|
||||
"intent": "Deploy the accepted notification operation on docker-prod using available port 18081, fixed HTTPS routing, isolated writer/reader secret volumes, a durable receiver volume, and a current-user Windows display agent with DPAPI-protected read credential; verify an exact-artifact end-to-end receipt without docker-test.",
|
||||
"requirement_ids": ["ADR-0005", "INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"deploy/docker-compose.prod.yml",
|
||||
"deploy/caddy/assistent.caddy",
|
||||
"deploy/windows/Invoke-DomovoyNotificationReceiver.ps1",
|
||||
"deploy/windows/Register-DomovoyNotificationTask.ps1",
|
||||
"deploy/windows/Test-DomovoyProductionHealth.ps1",
|
||||
"tests/creator_commit/verify-remote.ps1",
|
||||
"docs/CREDENTIALS_AND_SECRETS.md",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000043-production-notification-delivery.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 2, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["ADR-0005", "DEV-000041 receiver protocol", "DEV-000042 Creator command", "existing production compose and monitoring"], "may_change": []},
|
||||
"semantic_owner": "no new canonical owner; receiver is external receipt state and Windows state is a rebuildable projection",
|
||||
"canonical_state": ["CapabilityBinding inserted through Capability-owned schema", "Commit action/attempt evidence", "receiver immutable receipt volume"],
|
||||
"state_transitions": ["install ACTIVE binding revision 1", "external receipt absent to present", "Windows local projection unseen to displayed"],
|
||||
"side_effects_and_privileges": ["generate isolated write and read tokens outside Git", "deploy notification-receiver on docker-prod port 18081", "route only /delivery/* through assistent.cin.su", "mount write secret into Commit and receiver only", "mount read secret into receiver only", "register current-user Windows polling task"],
|
||||
"persistence_and_migrations": ["apply migrations 010 and 011 on production", "create external secret volumes and durable receiver volume", "store Windows read token only as CurrentUser DPAPI ciphertext and back up ciphertext to the private NAS key directory"],
|
||||
"failure_retry_restart": ["compose restart preserves receipts", "Windows polls read-only and deduplicates by receipt ID", "poll failure leaves receipts unseen for later retry", "receiver outage yields UNKNOWN_OUTCOME and read-only reconciliation", "no blind mutation retry"],
|
||||
"assumptions": ["docker.cin.su SSH alias docker is the production host", "port 18081 is available", "Caddy host routing can reach host.docker.internal:18081", "assistent.cin.su TLS remains valid", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["all expected containers including notification-receiver are healthy", "Caddy exposes receiver only under HTTPS /delivery/*", "unauthorized/write-with-read-token/arbitrary path requests are denied", "same intent and digest returns one receipt and mismatched digest conflicts", "production migration and binding match exact ADR fields", "Creator command yields SUCCEEDED or recoverable UNKNOWN_OUTCOME with durable evidence", "Windows agent reads without write privilege and displays/deduplicates the receipt", "restart preserves receipt and scheduled task succeeds", "no plaintext token appears in Git, task arguments, logs, database, browser storage or NAS"],
|
||||
"required_tests": ["PowerShell parser", "unit", "architecture preflight", "exact diff scope", "go test", "go vet", "CI", "production compose health", "HTTPS receiver adversarial smoke", "Creator authenticated smoke", "receiver restart persistence", "Windows scheduled-task result", "browser console", "docker-test absence"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"task_id": "DEV-000044",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "759328972a7ae583f27eec5b97a7a8dea9df2ce7",
|
||||
"risk_tier": "E",
|
||||
"intent": "Re-audit the exact merged Assistant artifact after the first production notification operation, update repository status evidence, and identify remaining work without inventing unapproved product or architecture scope; docker-test remains prohibited.",
|
||||
"requirement_ids": ["ADR-0005", "INV-AUTH-001", "INV-OWN-001", "INV-CONT-001", "INV-TOPO-001", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"docs/FOUNDATION_EXIT_AUDIT.md",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"tests/creator_commit/verify-remote.ps1",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000044-post-notification-production-audit.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["ADR-0005", "DEV-000041", "DEV-000042", "DEV-000043", "existing production verification scripts"], "may_change": []},
|
||||
"semantic_owner": "none-verification",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["read-only production verification", "authenticated smoke may create only its documented disposable test evidence through the existing Commit path"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["audit records failures as gaps and does not weaken checks", "no production mutation outside existing verification harness semantics"],
|
||||
"assumptions": ["origin/main is the deployed exact artifact", "docker.cin.su SSH alias docker remains the production host", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["local deterministic tests and architecture checks pass", "production stack and Windows tasks are healthy", "notification HTTPS authorization and durable receipt path pass", "Creator remote smoke passes through a Windows/Docker CLI-safe command invocation", "production artifact provenance is either proven or recorded as an explicit gap", "repository documents completed post-foundation work and remaining product decisions"],
|
||||
"required_tests": ["task schema", "architecture preflight", "exact diff scope", "go test", "go vet", "production health", "authenticated Creator notification smoke", "docker-test absence"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000045",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "959c89d47bf231dbcfb7a05e7b63b0e8579e3f0c",
|
||||
"risk_tier": "E",
|
||||
"intent": "Stamp project-built production images with the immutable Git revision, record promoted image digests in an external deployment manifest, and make production health reject provenance drift without using docker-test.",
|
||||
"requirement_ids": ["INV-TOPO-001", "DEV-000044-FINDING-ARTIFACT-PROVENANCE", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["deploy/docker-compose.prod.yml", "deploy/images/runtime.Dockerfile", "deploy/images/postgres.Dockerfile", "deploy/windows/Confirm-DomovoyProductionArtifact.ps1", "deploy/windows/Test-DomovoyProductionHealth.ps1", "docs/DEPLOYMENT.md", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000045-production-artifact-provenance.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**", "tests/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["existing production Compose topology", "Docker OCI image metadata"], "may_change": []},
|
||||
"semantic_owner": "none-operational-verification",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["rebuild and replace project-owned production containers with exact merged source", "write non-secret deployment manifest to local profile and private NAS monitoring directory"],
|
||||
"persistence_and_migrations": ["non-secret external deployment manifest only"],
|
||||
"failure_retry_restart": ["manifest is published atomically only after all expected labels and digests verify", "failed verification leaves previous manifest intact", "health becomes degraded on revision or digest drift"],
|
||||
"assumptions": ["docker context docker-prod is the production endpoint", "Git HEAD is merged origin/main before promotion", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["every project-built image has the merged Git revision OCI label", "manifest records revision and exact image digests", "health verifies running labels and digests against the manifest", "production remains healthy after exact-artifact promotion"],
|
||||
"required_tests": ["PowerShell parser", "task schema", "architecture preflight", "exact diff scope", "go test", "go vet", "Compose config", "production artifact confirmation", "production health", "CI", "docker-test absence"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -24,3 +24,5 @@ Each privileged runtime gets only its required secret/capability bindings. `comm
|
||||
Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together.
|
||||
|
||||
The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile.
|
||||
|
||||
Prospective notification delivery uses two unrelated random tokens. The write token is stored only in external Docker volume `assistant_notification_writer_secret`, mounted read-only into `commit-runtime` and `notification-receiver`. The read token is stored in external Docker volume `assistant_notification_reader_secret`, mounted read-only only into `notification-receiver`; its workstation copy is CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\notification-receiver-read-key.dpapi.json`. Only that ciphertext is backed up to `\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json`. Plaintext tokens must not enter compose environment, Git, PostgreSQL, task arguments, logs, browser storage, or NAS.
|
||||
|
||||
@@ -14,6 +14,21 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/
|
||||
|
||||
The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed.
|
||||
|
||||
Project-built production images receive `org.opencontainers.image.revision` and `org.opencontainers.image.source` OCI labels. Promotion must set `ASSISTANT_SOURCE_REVISION` to the full merged `origin/main` SHA before `docker compose build/up`; the default `unknown` is intentionally rejected by artifact confirmation. After all project containers are running, execute:
|
||||
|
||||
```powershell
|
||||
$revision = git rev-parse origin/main
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\windows\Confirm-DomovoyProductionArtifact.ps1 -Revision $revision -DockerContext docker-prod
|
||||
```
|
||||
|
||||
The confirmation script accepts the promotion only when every project-built container has that revision label, then atomically records its exact Docker image ID in `%LOCALAPPDATA%\Domovoy\monitoring\deployment-manifest.json` and the private NAS monitoring directory. Production health compares every running label and image ID to this manifest and becomes degraded on drift. The manifest is non-secret operational evidence; it is not canonical Assistant state.
|
||||
|
||||
The first external operation adds `assistant-notification-receiver-1` on production host port `18081`. Caddy applies `deploy/caddy/assistent.caddy`, routing only `https://assistent.cin.su/delivery/*` to that port and all other paths to Creator Web on `18080`. The receiver has an isolated durable volume and no Assistant database access. Commit success means an immutable receiver receipt exists; Windows display and human reading are not Commit outcomes.
|
||||
|
||||
Migrations `010_capability_notification_binding.sql` and `011_commit_notification_envelope.sql` must be applied before the new runtimes start. The initial Capability binding is owner-local for `domovoy-creator-principal`, revision 1, capability `prospective-notification-delivery`, origin `https://assistent.cin.su`, delivery path `/delivery/v1/deliveries`, receipt path `/delivery/v1/receipts`, protocol `IDEMPOTENT_RECEIPT_V1`, and secret reference `domovoy-notification-write-v1`.
|
||||
|
||||
`deploy/windows/Invoke-DomovoyNotificationReceiver.ps1` polls the receipt list with the read-only credential, displays up to three unseen local notifications, and records only rebuildable receipt IDs under `%LOCALAPPDATA%\Domovoy`. `Register-DomovoyNotificationTask.ps1` registers the current-user task every two minutes and at logon. It requires an interactive session for display and never uses `docker-test`.
|
||||
|
||||
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
|
||||
|
||||
The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# First external operation proposal
|
||||
|
||||
Status: **ACCEPTED by ADR-0005**
|
||||
|
||||
Task: `ARCH-000005`, revision 1
|
||||
|
||||
Base: `91d03b730d1bdbbe8e67b831fb21046768c4df5e`
|
||||
|
||||
## Objective
|
||||
|
||||
Choose the smallest useful real-world operation that exercises Domovoy's existing safety path without creating a generic mutation escape hatch:
|
||||
|
||||
`Creator session → owner validation → live Work justification → Capability binding revision → Authority validation → durable Commit → isolated Executor → external receipt → durable attempt/outcome → read-only reconciliation`
|
||||
|
||||
The proposal task changed no runtime, contract, schema, credential, endpoint, or deployment state. Its recommendation and the local Windows display channel were subsequently accepted by `ADR-0005` under task `ARCH-000006`.
|
||||
|
||||
## Current gap
|
||||
|
||||
Foundation and the Creator console prove internal Continuity execution, durable attempts, `UNKNOWN_OUTCOME`, and reconciliation. No production Executor currently mutates a system outside Assistant-owned canonical state. Before adding one, V1 needs an explicit decision for:
|
||||
|
||||
- the semantic operation and subject;
|
||||
- who may request it and with which live justification;
|
||||
- how a destination is bound, revised, disabled, and recovered;
|
||||
- which exact external protocol makes duplicate prevention and reconciliation provable;
|
||||
- where credentials live and how they rotate;
|
||||
- which ambiguous failures become `UNKNOWN_OUTCOME`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Typed Prospective notification — recommended
|
||||
|
||||
Deliver one server-constructed notification about one Creator-owned `ProspectiveItem` to one preconfigured Creator destination. This extends existing Continuity product behavior while keeping delivery distinct from satisfaction or commitment fulfilment.
|
||||
|
||||
The semantic operation is `deliver-prospective-notification-v1`; HTTP is only its first adapter mechanism.
|
||||
|
||||
### Arbitrary webhook — rejected
|
||||
|
||||
Accepting a URL, headers, method, or free-form JSON from a browser/model would be a universal mutation capability, an SSRF surface, and a substitute for typed domain contracts. It cannot be the first operation.
|
||||
|
||||
### Direct email, Telegram, or chat-provider send — deferred
|
||||
|
||||
These are useful delivery mechanisms but their duplicate and reconciliation semantics differ. Selecting one before a recipient and provider contract exist would embed provider behavior into the domain decision. A provider may be used later only behind the accepted typed operation and Executor port.
|
||||
|
||||
### File or NAS export — deferred
|
||||
|
||||
An export is mechanically idempotent, but meaningful payload export requires Artifact metadata/payload lifecycle that V1 has not implemented. Creating unowned files first would bypass Artifact ownership questions.
|
||||
|
||||
### Git mutation — rejected for V1
|
||||
|
||||
Repository mutation belongs to the separately deferred Development Control Plane and would give the first product Executor disproportionate authority.
|
||||
|
||||
## Proposed semantic operation
|
||||
|
||||
Capability: `prospective-notification-delivery`
|
||||
|
||||
Operation: `deliver-prospective-notification-v1`
|
||||
|
||||
Subject and immutable commit evidence:
|
||||
|
||||
- `ProspectiveItemID` and expected Prospective revision;
|
||||
- owner Principal, Scope and Goal observed from Continuity;
|
||||
- live Work `TaskTarget` justification and revision;
|
||||
- server-selected notification template code `prospective-delivered-v1`;
|
||||
- current `CapabilityBindingID` and binding revision;
|
||||
- `ActionIntentID`, AuthorizedAction ID and authorization time.
|
||||
|
||||
The browser supplies only the selected Prospective item, expected revision, Work target and an explicit confirmation. It never supplies Actor, Principal, grant, capability, operation, destination URL, authorization header, template, message body, Action IDs, or provider fields.
|
||||
|
||||
The owner must validate that the Prospective item belongs to the authenticated Principal and is currently `DELIVERED`. Notification success does **not** change the Prospective item to `SATISFIED`, fulfil a Commitment, or prove that a human read the message.
|
||||
|
||||
## Destination binding and secret lifecycle
|
||||
|
||||
The existing Capability owner already owns `CapabilityBinding`. An implementation should materialize a versioned binding rather than use ambient configuration as correctness state. Minimum binding meaning:
|
||||
|
||||
- stable Binding ID and revision;
|
||||
- owner Principal and capability ID;
|
||||
- fixed HTTPS origin and fixed delivery/receipt path template;
|
||||
- active/retired lifecycle;
|
||||
- declared idempotency and reconciliation protocol version;
|
||||
- external secret reference, never literal secret material;
|
||||
- verification time and health state.
|
||||
|
||||
Commit records the binding ID and revision used for authorization. A retired, missing, unhealthy, owner-mismatched, or revised binding fails closed before a new external attempt. Historical actions retain their original binding evidence. Rotation creates a new secret version/reference; it does not rewrite old envelopes.
|
||||
|
||||
Secret bytes are mounted only into the isolated Commit Runtime Executor. They must not enter PostgreSQL, Creator responses, browser storage, image layers, task packets, Git, logs, or cognitive context.
|
||||
|
||||
## Required external protocol
|
||||
|
||||
The first adapter may use HTTPS only if the selected destination implements a narrow receipt protocol:
|
||||
|
||||
1. `PUT <fixed-delivery-path>/<ActionIntentID>` creates the delivery exactly once.
|
||||
2. The body is the server-rendered typed V1 notification; its canonical digest is recorded before execution.
|
||||
3. Repeating the same intent and digest returns the original receipt without redelivery.
|
||||
4. Reusing an intent with a different digest returns an integrity conflict without mutation.
|
||||
5. `GET <fixed-receipt-path>/<ActionIntentID>` is read-only and returns whether the delivery exists plus the stored digest/receipt identity.
|
||||
6. Success is accepted only when the receipt identity and digest match the committed request.
|
||||
|
||||
The endpoint origin and paths come only from the validated binding. Redirects are disabled. DNS resolution, certificate validation, response size, content type and timeouts are bounded. No request field can select another host, scheme, path, header or credential.
|
||||
|
||||
## Failure and reconciliation semantics
|
||||
|
||||
- Binding/authority/owner/revision failure: reject before Commit or external attempt as appropriate.
|
||||
- Definite protocol rejection before mutation: `FAILED` with typed reason.
|
||||
- Timeout, connection loss after send, malformed success, unexpected 5xx, or receipt mismatch: `UNKNOWN_OUTCOME`.
|
||||
- `UNKNOWN_OUTCOME` blocks another delivery attempt for the same action.
|
||||
- Explicit reconciliation issues only the receipt GET and creates a new attempt identity.
|
||||
- Matching receipt/digest reconciles to `SUCCEEDED`; explicit absence remains unknown unless the provider contract proves absence is authoritative after its consistency window.
|
||||
- Executor restart loads the immutable envelope and latest attempt; no process-local queue, mutex or timer is correctness state.
|
||||
- Binding retirement blocks new authorization but preserves reconciliation access for historical actions until an explicit recovery/retention decision exists.
|
||||
|
||||
## Security constraints
|
||||
|
||||
- No arbitrary URL, HTTP method, headers, body, template or provider operation.
|
||||
- No redirects and no fallback host.
|
||||
- No ambient machine credential or credential shared with another capability.
|
||||
- No direct call from UI, Core, Continuity, cognitive worker or model adapter.
|
||||
- No logging of body, bearer secret or full provider response.
|
||||
- No automatic retry of ambiguous mutation.
|
||||
- No implication that delivery equals reading, satisfaction or fulfilment.
|
||||
- No `docker-test` deployment or verification.
|
||||
|
||||
## Acceptance evidence required from an implementation
|
||||
|
||||
- adversarial browser/model fields cannot alter the operation or destination;
|
||||
- foreign owner, wrong Prospective state, stale item/Work/binding revision, revoked grant and unhealthy binding are denied;
|
||||
- duplicate delivery with the same intent produces one external receipt;
|
||||
- lost acknowledgement records `UNKNOWN_OUTCOME` and read-only reconciliation finds the original receipt;
|
||||
- mismatched digest is an integrity failure and never redelivers;
|
||||
- restart recovery uses durable Commit state and provider receipt only;
|
||||
- secret rotation and binding retirement fail closed without rewriting history;
|
||||
- Commit Runtime is the only component with the delivery credential;
|
||||
- production smoke uses the selected real destination and does not use `docker-test`.
|
||||
|
||||
## Implementation slices after acceptance
|
||||
|
||||
1. Accept the semantic/protocol decision in an ADR-level task; confirm that the existing registry dependencies remain sufficient.
|
||||
2. Implement Capability-owned binding lifecycle and owner-local persistence, with only an external secret reference.
|
||||
3. Extend the typed Commit envelope for the notification subject, binding revision and request digest.
|
||||
4. Implement a deterministic fake receiver and adversarial/idempotency/recovery tests.
|
||||
5. Implement the isolated HTTPS Executor/reconciler with strict destination policy and secret-file injection.
|
||||
6. Add the explicit Creator confirmation flow and action-history rendering.
|
||||
7. Configure one concrete compatible destination, generate its credential outside Git, deploy on `docker-prod`, and run exact-artifact production smoke.
|
||||
|
||||
## Resolution
|
||||
|
||||
`ADR-0005` accepts this typed operation and protocol. The first compatible receiver will run on `docker-prod` behind the existing `https://assistent.cin.su` origin, and a separately authorized local Windows agent will show accepted receipts to the Creator. Receiver acceptance is the external Commit outcome; operating-system display and human reading are not.
|
||||
@@ -21,8 +21,10 @@ Status is based on executed repository tests and Docker-stack verification on 20
|
||||
|
||||
Foundation V1 is eligible for freeze. Creator session authentication was also completed as DEV-000019 and does not carry Authority.
|
||||
|
||||
Post-foundation product work is intentionally separate:
|
||||
The three original post-foundation gates are complete:
|
||||
|
||||
1. Choose and specify the first real external operation before adding a production Executor credential.
|
||||
2. Build Creator API/UI only through validated Identity, Authority and Commit boundaries.
|
||||
3. Add exact diff-to-task scope comparison to promotion CI when the first Git baseline exists.
|
||||
1. `ADR-0005` specifies the first real external operation, prospective notification delivery.
|
||||
2. DEV-000024 through DEV-000032 and DEV-000040 through DEV-000043 provide the authenticated Creator API/UI and external operation only through the validated Identity, Authority, Continuity, Capability and Commit boundaries.
|
||||
3. `ADR-0004` and ARCH-000004 implement exact diff-to-task scope comparison.
|
||||
|
||||
DEV-000044 re-ran local architecture/unit checks and the production Creator-to-receiver smoke on 2026-08-16. The deployed stack and Windows operational tasks were healthy. Current product status and explicitly uncommitted future scope are recorded in `docs/PROJECT_STATUS.md`.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-16. Active audit: DEV-000044 revision 3.
|
||||
|
||||
## Delivered
|
||||
|
||||
- Foundation V1 is frozen and all exit criteria pass.
|
||||
- The single Creator account authenticates at `https://assistent.cin.su`; authentication does not imply Authority.
|
||||
- Creator can inspect the bounded operational view, register and satisfy Continuity prospective items, inspect action history, and explicitly request the accepted notification operation.
|
||||
- `deliver-prospective-notification-v1` executes only through Capability binding, current Authority and Commit/Executor validation. Its success evidence is an immutable receiver receipt.
|
||||
- Production runs on `docker.cin.su`; the project does not use the shared test Docker host.
|
||||
- PostgreSQL backup/restore, encrypted off-host backup, production health monitoring and the current-user Windows notification projection are installed and healthy.
|
||||
|
||||
## Evidence from DEV-000044
|
||||
|
||||
- `go test ./...`, `go vet ./...`, architecture preflight/checks and PowerShell parsing pass.
|
||||
- `Test-DomovoyProductionHealth.ps1` reports `healthy` for the production stack, HTTPS/DNS/TLS, backups and registered Windows tasks.
|
||||
- `tests/creator_commit/verify-remote.ps1` passes the authenticated Creator → Commit → notification receiver receipt flow on `docker-prod`.
|
||||
- The smoke harness was corrected to preserve HTTP Authorization headers under Windows PowerShell 5.1 native-command argument handling.
|
||||
|
||||
## Remaining work requiring a new task decision
|
||||
|
||||
1. **Cognitive provider:** the cognitive worker intentionally remains the deterministic fake. Selecting a real model/provider, data policy and allowed proposal contract requires a separate capability/architecture decision; it must not gain canonical mutation authority.
|
||||
2. **Product breadth:** notification delivery is the only accepted real external operation. Any additional operation needs its own proposal, authority model, idempotency/reconciliation semantics and Capability binding.
|
||||
3. **Artifact and Epistemics product behavior:** their ownership boundaries exist, but no product task has yet specified durable user-facing workflows for them.
|
||||
|
||||
DEV-000045 closes the DEV-000044 artifact-provenance finding for new promotions by stamping the merged Git revision into OCI labels, recording exact promoted image IDs outside the repository, and making production monitoring reject label or digest drift. Images that predate DEV-000045 remain historically unverifiable from the image alone.
|
||||
|
||||
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
|
||||
@@ -0,0 +1,54 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
var ErrInvalidBinding = errors.New("invalid capability binding")
|
||||
|
||||
func ValidateBinding(binding capability.Binding) error {
|
||||
if binding.ID == "" || binding.CapabilityID == "" || binding.OwnerPrincipalID == "" ||
|
||||
binding.Revision < 1 || binding.Protocol != capability.ProtocolIdempotentReceiptV1 ||
|
||||
binding.SecretRef == "" || binding.VerifiedAtUnixMillis <= 0 {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
if binding.State != capability.BindingActive && binding.State != capability.BindingRetired {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
if binding.Health != capability.HealthAvailable && binding.Health != capability.HealthDegraded && binding.Health != capability.HealthUnavailable {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
origin, err := url.Parse(binding.HTTPSOrigin)
|
||||
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
if !fixedPath(binding.DeliveryPath) || !fixedPath(binding.ReceiptPath) || binding.DeliveryPath == binding.ReceiptPath {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UsableBinding(binding capability.Binding) error {
|
||||
if err := ValidateBinding(binding); err != nil || binding.State != capability.BindingActive || binding.Health != capability.HealthAvailable {
|
||||
return ErrInvalidBinding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RetireBinding(binding capability.Binding) (capability.Binding, error) {
|
||||
if err := ValidateBinding(binding); err != nil || binding.State != capability.BindingActive {
|
||||
return capability.Binding{}, ErrInvalidBinding
|
||||
}
|
||||
binding.State = capability.BindingRetired
|
||||
binding.Revision++
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func fixedPath(value string) bool {
|
||||
return strings.HasPrefix(value, "/") && value != "/" && !strings.HasSuffix(value, "/") &&
|
||||
!strings.Contains(value, "//") && !strings.ContainsAny(value, "?#\\") && !strings.Contains(value, "..")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
func validBinding() capability.Binding {
|
||||
return capability.Binding{ID: "binding-1", CapabilityID: "prospective-notification-delivery", OwnerPrincipalID: identity.PrincipalID("owner-1"), Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/receipts", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}
|
||||
}
|
||||
|
||||
func TestValidateBinding(t *testing.T) {
|
||||
b := validBinding()
|
||||
b.ReceiptPath = "/delivery/v1/lookup"
|
||||
if err := ValidateBinding(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*capability.Binding){
|
||||
func(v *capability.Binding) { v.HTTPSOrigin = "http://assistent.cin.su" },
|
||||
func(v *capability.Binding) { v.DeliveryPath = "//evil" },
|
||||
func(v *capability.Binding) { v.SecretRef = "" },
|
||||
func(v *capability.Binding) { v.Revision = 0 },
|
||||
func(v *capability.Binding) { v.Health = capability.HealthDegraded },
|
||||
} {
|
||||
candidate := b
|
||||
mutate(&candidate)
|
||||
if err := UsableBinding(candidate); err == nil {
|
||||
t.Fatalf("expected rejection: %+v", candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireBindingIncrementsRevision(t *testing.T) {
|
||||
b := validBinding()
|
||||
b.ReceiptPath = "/delivery/v1/lookup"
|
||||
retired, err := RetireBinding(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retired.State != capability.BindingRetired || retired.Revision != 2 {
|
||||
t.Fatalf("unexpected retired binding: %+v", retired)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
SaveHealth(context.Context, capability.Health) error
|
||||
LoadHealth(context.Context, capability.CapabilityID) (capability.Health, error)
|
||||
RebuildHealthProjection(context.Context) (int64, error)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ $ErrorActionPreference = "Stop"
|
||||
$coreContainer = "assistant-core-runtime-1"
|
||||
$commitContainer = "assistant-commit-runtime-1"
|
||||
$continuityContainer = "assistant-continuity-runtime-1"
|
||||
$receiverContainer = "assistant-notification-receiver-1"
|
||||
|
||||
function Post-Json([string]$container, [string]$url, [object]$body) {
|
||||
$json = $body | ConvertTo-Json -Depth 10 -Compress
|
||||
@@ -28,7 +29,7 @@ function Creator-Post([string]$url, [object]$body, [string]$token) {
|
||||
try {
|
||||
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
|
||||
docker --context $DockerContext cp $local "${commitContainer}:$remote" | Out-Null
|
||||
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $CREATOR_TEST_SESSION" --post-file "$1" "$2"' sh $remote $url
|
||||
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -qO- --header=Content-Type:application/json --header=$auth --post-file=$1 $2' sh $remote $url
|
||||
if ($LASTEXITCODE -ne 0) { throw "Creator POST $url failed" }
|
||||
return ($result -join "`n")
|
||||
} finally {
|
||||
@@ -37,6 +38,12 @@ function Creator-Post([string]$url, [object]$body, [string]$token) {
|
||||
}
|
||||
}
|
||||
|
||||
function Creator-Get([string]$url, [string]$token) {
|
||||
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -qO- --header=$auth $1' sh $url
|
||||
if ($LASTEXITCODE -ne 0) { throw "Creator GET $url failed" }
|
||||
return ($result -join "`n")
|
||||
}
|
||||
|
||||
function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]$status) {
|
||||
$json = $body | ConvertTo-Json -Depth 10 -Compress
|
||||
$local = New-TemporaryFile
|
||||
@@ -46,7 +53,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
|
||||
docker --context $DockerContext cp $local "${commitContainer}:$remote" | Out-Null
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$result = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $CREATOR_TEST_SESSION" --post-file "$1" "$2"' sh $remote $url 2>&1) -join "`n"
|
||||
$result = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -S -O /dev/null --header=Content-Type:application/json --header=$auth --post-file=$1 $2' sh $remote $url 2>&1) -join "`n"
|
||||
$ErrorActionPreference = $previous
|
||||
if ($result -notmatch "HTTP/1\.[01] $status") { throw "Creator POST $url did not return $status" }
|
||||
} finally {
|
||||
@@ -55,11 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
|
||||
}
|
||||
}
|
||||
|
||||
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer
|
||||
$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1
|
||||
if (-not $bootstrapEntry) { throw "Creator bootstrap metadata unavailable" }
|
||||
$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length)
|
||||
$sessionResponse = docker --context $DockerContext exec --env "CREATOR_TEST_BOOTSTRAP=$bootstrapToken" $coreContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_BOOTSTRAP" --post-data="" "$1/v1/creator/sessions"' sh http://localhost:8080
|
||||
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
|
||||
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
|
||||
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token
|
||||
|
||||
@@ -82,9 +85,42 @@ $action = Creator-Post http://localhost:8082/v1/creator/continuity/satisfy-deliv
|
||||
if ($action.State -ne "SUCCEEDED" -or -not $action.AuthorizedActionID -or -not $action.ActionIntentID -or -not $action.ActionAttemptID) {
|
||||
throw "Creator command response is incomplete"
|
||||
}
|
||||
$historyRaw = Creator-Get 'http://localhost:8082/v1/creator/authorized-actions?limit=50' $creatorSession
|
||||
$history = $historyRaw | ConvertFrom-Json
|
||||
$historyItem = @($history.Items | Where-Object { $_.AuthorizedActionID -eq $action.AuthorizedActionID })
|
||||
if ($historyItem.Count -ne 1 -or $historyItem[0].ActionAttemptID -ne $action.ActionAttemptID -or $historyItem[0].State -ne 'SUCCEEDED') {
|
||||
throw "Creator action history does not contain the committed outcome"
|
||||
}
|
||||
if ($historyRaw -match 'EvidenceRef|GrantID') { throw "Creator action history exposed restricted envelope fields" }
|
||||
$updated = docker --context $DockerContext exec $continuityContainer wget -qO- "http://localhost:8081/v1/prospective-items/$prospective" | ConvertFrom-Json
|
||||
if ($updated.State -ne "SATISFIED" -or $updated.Revision -ne 2) { throw "Creator command did not satisfy item exactly once" }
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/satisfy-delivered $command $creatorSession 409
|
||||
Expect-CreatorStatus "http://localhost:8082/v1/creator/authorized-actions/$($action.AuthorizedActionID)/reconcile" @{} $creatorSession 409
|
||||
|
||||
$notificationProspective = "prospective-notification-$suffix"
|
||||
Post-Json $continuityContainer http://localhost:8081/v1/prospective-items @{
|
||||
ID=$notificationProspective;CommitmentID="commitment-notification-$suffix";OwnerPrincipalID="domovoy-creator-principal"
|
||||
ScopeID=$scope;GoalID=$goal;TriggerAtUnixMillis=1800000000000;State="DELIVERED";Revision=1
|
||||
} | Out-Null
|
||||
$notificationCommand = @{
|
||||
Target=@{TaskID=$task;GoalID=$goal;ScopeID=$scope;Revision=1}
|
||||
ProspectiveItemID=$notificationProspective;ExpectedRevision=1;Confirm=$true
|
||||
}
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand "invalid" 401
|
||||
$hostileNotification = $notificationCommand.Clone()
|
||||
$hostileNotification.URL = 'https://example.invalid'
|
||||
$hostileNotification.Body = 'arbitrary'
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $hostileNotification $creatorSession 400
|
||||
$notificationAction = Creator-Post http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand $creatorSession | ConvertFrom-Json
|
||||
if ($notificationAction.State -ne 'SUCCEEDED' -or -not $notificationAction.ActionIntentID) { throw 'Creator notification did not produce a successful receipt' }
|
||||
$notificationItem = docker --context $DockerContext exec $continuityContainer wget -qO- "http://localhost:8081/v1/prospective-items/$notificationProspective" | ConvertFrom-Json
|
||||
if ($notificationItem.State -ne 'DELIVERED' -or $notificationItem.Revision -ne 1) { throw 'Notification changed Continuity canonical state' }
|
||||
$receiptRaw = docker --context $DockerContext exec $receiverContainer sh -c 'token=$(cat /run/notification-reader/token); auth=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$auth $1' sh "http://localhost:8085/delivery/v1/receipts/$($notificationAction.ActionIntentID)"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Notification receipt lookup failed' }
|
||||
$receipt = ($receiptRaw -join "`n") | ConvertFrom-Json
|
||||
if ($receipt.action_intent_id -ne $notificationAction.ActionIntentID -or $receipt.request_digest -notmatch '^[0-9a-f]{64}$') { throw 'Notification receipt evidence mismatch' }
|
||||
$notificationHistory = Creator-Get 'http://localhost:8082/v1/creator/authorized-actions?limit=50' $creatorSession | ConvertFrom-Json
|
||||
$notificationHistoryItem = @($notificationHistory.Items | Where-Object { $_.AuthorizedActionID -eq $notificationAction.AuthorizedActionID })
|
||||
if ($notificationHistoryItem.Count -ne 1 -or $notificationHistoryItem[0].Operation -ne 'deliver-prospective-notification-v1' -or $notificationHistoryItem[0].State -ne 'SUCCEEDED') { throw 'Notification action history is incomplete' }
|
||||
|
||||
Write-Output "Creator Commit command verification: PASS"
|
||||
|
||||
Reference in New Issue
Block a user