Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f86b8c46 | ||
|
|
6446cd692a | ||
|
|
91d03b730d | ||
|
|
0e56fe7662 | ||
|
|
2d2615b734 |
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -112,24 +113,58 @@ 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
|
||||
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)
|
||||
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id), &action)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return commitcontract.AuthorizedAction{}, ErrAuthorizedActionNotFound
|
||||
}
|
||||
@@ -138,3 +173,14 @@ func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcont
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func scanAuthorized(row commitRowScanner, action *commitcontract.AuthorizedAction) error {
|
||||
return 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)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,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 {
|
||||
@@ -64,9 +67,66 @@ 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/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
|
||||
}
|
||||
item := creatorActionHistoryItem{
|
||||
AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID,
|
||||
Operation: action.Proposal.Operation, ProspectiveItemID: action.Proposal.ContinuitySubject.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) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -53,6 +53,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()
|
||||
@@ -127,6 +164,69 @@ 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}
|
||||
|
||||
@@ -155,6 +155,8 @@ 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 && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"):
|
||||
|
||||
@@ -158,6 +158,30 @@ func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 +192,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; }
|
||||
@@ -215,6 +242,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; }
|
||||
|
||||
@@ -80,6 +80,7 @@ async function showConsole() {
|
||||
restoreContext();
|
||||
const context = saveContext();
|
||||
if (context["session-id"] && context["workspace-id"]) loadDashboard();
|
||||
else loadActionHistory();
|
||||
}
|
||||
|
||||
function endSession(message = "Сессия завершена.") {
|
||||
@@ -136,6 +137,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 +164,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 +172,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 {
|
||||
@@ -398,6 +491,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 +525,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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000039-production-health-notifications.json",
|
||||
"task_id": "DEV-000039",
|
||||
"task_file": "dev/tasks/ARCH-000005-first-external-operation-proposal.json",
|
||||
"task_id": "ARCH-000005",
|
||||
"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,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,151 @@
|
||||
# First external operation proposal
|
||||
|
||||
Status: **PROPOSED — NOT ACCEPTED**
|
||||
|
||||
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`
|
||||
|
||||
This proposal changes no runtime, contract, schema, architecture authority, credential, endpoint, or deployment state. Its Tier E task packet selects itself in `dev/ACTIVE_TASK.json` only so fail-closed promotion CI validates the proposal's exact diff.
|
||||
|
||||
## 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.
|
||||
|
||||
## Decision still required
|
||||
|
||||
Implementation is `BLOCKED_BY_SPEC_GAP` until both are explicitly accepted:
|
||||
|
||||
1. the typed operation and safety protocol in this proposal;
|
||||
2. one concrete destination base URL that implements the receipt protocol, including where the resulting notification is ultimately shown to the Creator.
|
||||
|
||||
No credential should be generated before that destination exists.
|
||||
@@ -37,6 +37,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 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$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
|
||||
@@ -55,11 +61,12 @@ 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 [ -n "$CREATOR_BOOTSTRAP_TOKEN_FILE" ]; then token=$(cat "$CREATOR_BOOTSTRAP_TOKEN_FILE"); fi
|
||||
if [ -z "$token" ]; then exit 7; fi
|
||||
wget -qO- --header="Authorization: Bearer $token" --post-data="" "$1/v1/creator/sessions"
|
||||
' sh http://localhost:8080
|
||||
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
|
||||
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token
|
||||
|
||||
@@ -82,6 +89,13 @@ $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
|
||||
|
||||
Reference in New Issue
Block a user