Bind trusted operation IDs before domain handlers

Why: A domain outbox could not correlate its committed event with the host receipt because the bus generated the operation ID after the handler returned.

Evidence: RED-to-GREEN bus and compiled-handler tests; local go test ./... -count=1 and isolated PostgreSQL operation/link checks passed as SELF.

Impact: Expose a host-generated operation ID to handlers and resolve a unique committed audit offset after dispatch; missing receipts remain retryable and ambiguous rows fail closed.

Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1
This commit is contained in:
weichen
2026-10-14 04:11:09 +00:00
parent 48fdddee25
commit a8fa2fe6c9
13 changed files with 180 additions and 8 deletions
+3 -1
View File
@@ -12,6 +12,7 @@
- Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects. - Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects.
- Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff. - Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff.
- Initial commit `c8dd169a1fdc1451fab3b475c14f9636a7f5bf6c` exposed only the actor-owned project slice. The next local increment fixes button actor propagation and adds a trusted domain-commit link publisher, verifier callback, exact linked metadata read, current `audit.read` and per-object metadata checks, and durable idempotency. Unlinked personal conversations remain private. - Initial commit `c8dd169a1fdc1451fab3b475c14f9636a7f5bf6c` exposed only the actor-owned project slice. The next local increment fixes button actor propagation and adds a trusted domain-commit link publisher, verifier callback, exact linked metadata read, current `audit.read` and per-object metadata checks, and durable idempotency. Unlinked personal conversations remain private.
- Trusted command dispatch now creates an operation ID before calling the compiled handler and passes that ID through `extension.Request`. The handler can commit the same ID with its domain event/outbox; the final Backend audit/receipt uses it. Client `params.operationId` is ignored. An outbox link request with `HostAuditOffset=0` resolves a unique committed host row by owner, fixed project, and operation ID; a missing row returns retryable `ErrProjectAuditPending`, while duplicate rows fail closed. The separate idempotency key still controls retry equivalence.
- Exact-source linked reads do not enumerate hidden links or reveal raw payload, private conversation ID, or content. Body disclosure and a broad project link list are not implemented; the public coverage reports the narrower capability. - Exact-source linked reads do not enumerate hidden links or reveal raw payload, private conversation ID, or content. Body disclosure and a broad project link list are not implemented; the public coverage reports the narrower capability.
## Verification and remaining work ## Verification and remaining work
@@ -21,5 +22,6 @@
- SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build. - SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build.
- SELF PostgreSQL receipt `/tmp/backend-w9-pg-cutoff-20261014-01.json`: isolated PostgreSQL 18.6 instance on port 55442 reproduced a low-ID transaction committing after a higher visible ID. `/tmp/backend-w9-pg-link-20261014-02.json` verifies the new link table, retry/conflict behavior, and the cutoff counterexample against isolated schemas. Both exited 0. The current PG cutoff means only `offset-upper-bound-only`; it is **not** a fixed historical visibility snapshot or safe incremental cursor. - SELF PostgreSQL receipt `/tmp/backend-w9-pg-cutoff-20261014-01.json`: isolated PostgreSQL 18.6 instance on port 55442 reproduced a low-ID transaction committing after a higher visible ID. `/tmp/backend-w9-pg-link-20261014-02.json` verifies the new link table, retry/conflict behavior, and the cutoff counterexample against isolated schemas. Both exited 0. The current PG cutoff means only `offset-upper-bound-only`; it is **not** a fixed historical visibility snapshot or safe incremental cursor.
- SELF PostgreSQL receipt `/tmp/backend-w9-pg-migrate-20261014-01.json`: isolated SQLite→PostgreSQL migration preserved one linked receipt and advanced its target sequence; the same migration ID replayed idempotently. The temporary PG instance was stopped and removed after verification. - SELF PostgreSQL receipt `/tmp/backend-w9-pg-migrate-20261014-01.json`: isolated SQLite→PostgreSQL migration preserved one linked receipt and advanced its target sequence; the same migration ID replayed idempotently. The temporary PG instance was stopped and removed after verification.
- SELF PostgreSQL receipt `/tmp/backend-w9-pg-operation-20261014-01.json`: a fresh isolated PG instance reran the link lookup, idempotency/conflict, and low-ID late-commit checks after trusted operation-offset resolution was added. The instance was stopped and removed.
- Integration constraint: this branch uses schema v31 (audit attribution) and v32 (links), while the independent W6 Backend branch also has a local v31. Neither branch has consumed the other's unmerged work. After the first branch lands, the later branch owner must sync the new main, renumber its unpublished migrations without rewriting an already released version, and rerun fresh/old SQLite and PG migration plus combined regression on the actual merged source. These isolated tests do not prove W6+W9 compatibility. - Integration constraint: this branch uses schema v31 (audit attribution) and v32 (links), while the independent W6 Backend branch also has a local v31. Neither branch has consumed the other's unmerged work. After the first branch lands, the later branch owner must sync the new main, renumber its unpublished migrations without rewriting an already released version, and rerun fresh/old SQLite and PG migration plus combined regression on the actual merged source. These isolated tests do not prove W6+W9 compatibility.
- Required next W9 work: add a committed-order waterline or materialize the authorized ID set at the first read, then prove with PG low-ID late commits that history and cross-page incremental reads neither omit nor duplicate operations. The Backend project-event source, actual RDM committed-event verifier/outbox consumer, run ID propagation from command dispatch, model/tool/background receipts, body/detail disclosure, broad authorized list pagination, history/export, and combined product verification remain unfinished. The exact-link slice is not Stage 7 audit acceptance. - Required next W9 work: add a committed-order waterline or materialize the authorized ID set at the first read, then prove with PG low-ID late commits that history and cross-page incremental reads neither omit nor duplicate operations. The Backend project-event source, actual RDM committed-event verifier/outbox consumer, run ID propagation from command dispatch, model/tool/background receipts, body/detail disclosure, broad authorized list pagination, history/export, and combined product verification remain unfinished. A domain commit followed by a lost Backend audit append leaves the link pending; recovery must retain and reconcile that unknown state without claiming a host receipt. The exact-link slice is not Stage 7 audit acceptance.
+1 -1
View File
@@ -67,7 +67,7 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor} host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}
return h(ctx, host, extension.Request{ return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance, ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, OperationID: req.OperationID,
HostProjectID: req.HostProjectID, HostProjectID: req.HostProjectID,
}) })
}) })
+14 -1
View File
@@ -128,9 +128,22 @@ func (h inprocHost) PublishProjectAuditLink(ctx context.Context, req extension.P
if holder != h.pluginID || verify == nil { if holder != h.pluginID || verify == nil {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
} }
if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset <= 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" { if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset < 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
} }
if req.HostAuditOffset == 0 {
resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditPending
}
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
req.HostAuditOffset = resolved
}
row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset) row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
+10 -1
View File
@@ -73,12 +73,13 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) {
t.Fatalf("unlinked private audit: %v", err) t.Fatalf("unlinked private audit: %v", err)
} }
alicePublisher := inprocHost{caps: caps, pluginID: "rdm", owner: "alice"} // trusted outbox-scoped host alicePublisher := inprocHost{caps: caps, pluginID: "rdm", owner: "alice"} // trusted outbox-scoped host
linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, HostAuditOffset: offset, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 { if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 {
t.Fatalf("private owner row visible without exact link: %+v %v", rows, err) t.Fatalf("private owner row visible without exact link: %+v %v", rows, err)
} }
wrongProject := linkReq wrongProject := linkReq
wrongProject.ProjectID = otherProject.ID wrongProject.ProjectID = otherProject.ID
wrongProject.HostAuditOffset = offset
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) { if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("wrong host project: %v", err) t.Fatalf("wrong host project: %v", err)
} }
@@ -90,10 +91,18 @@ func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) {
if _, err := (inprocHost{caps: caps, pluginID: "other", owner: "alice"}).PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) { if _, err := (inprocHost{caps: caps, pluginID: "other", owner: "alice"}).PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("other plugin: %v", err) t.Fatalf("other plugin: %v", err)
} }
pending := linkReq
pending.OperationID, pending.IdempotencyKey = "op-not-yet-recorded", "outbox-pending"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), pending); !errors.Is(err, extension.ErrProjectAuditPending) {
t.Fatalf("host receipt race must be retryable: %v", err)
}
first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq) first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if first.HostAuditOffset != offset {
t.Fatalf("trusted resolved offset: got %d want %d", first.HostAuditOffset, offset)
}
retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq) retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil || retry.LinkID != first.LinkID { if err != nil || retry.LinkID != first.LinkID {
t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err) t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err)
+61
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"encoding/json"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -10,6 +11,8 @@ import (
"agent-system-backend/internal/cmdbus" "agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store" "agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc" "agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
) )
func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) { func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) {
@@ -48,3 +51,61 @@ func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) {
t.Fatalf("button audit actor: %+v", rows) t.Fatalf("button audit actor: %+v", rows)
} }
} }
func TestCompiledHandlerReceivesHostOperationID(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
if err != nil {
t.Fatal(err)
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Now().UTC() })
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{compiledRegistry: compiledRegistry{bus: bus}, pluginID: "rdm", caps: caps}
var seen string
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.confirm","title":"Confirm"}`), func(_ context.Context, _ extension.Host, req extension.Request) (any, error) {
seen = req.OperationID
return "domain-committed", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", ConversationID: string(c.ID)})
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if seen == "" || resp.Receipt.OperationID != seen || len(rows) != 1 || rows[0].Envelope.OperationID != seen {
t.Fatalf("handler/domain correlation: seen=%q receipt=%+v rows=%+v", seen, resp.Receipt, rows)
}
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, project core.ID) (bool, error) { return actor == "bob" && project == p.ID, nil }); err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.OperationID == seen && proof.SourceID == "domain-commit-1" && proof.ResourceID == "doc-1", nil
}); err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, r extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && r.ResourceID == "doc-1"}, nil
}); err != nil {
t.Fatal(err)
}
publisher := inprocHost{caps: caps, owner: "alice", pluginID: "rdm"}
linked, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ProjectID: p.ID, OperationID: seen, SourceKind: "domain-commit", SourceID: "domain-commit-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"})
if err != nil || linked.HostAuditOffset != rows[0].Offset {
t.Fatalf("publish by trusted operation: %+v %v", linked, err)
}
bob := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"}
page, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "domain-commit-1", LinkedHostOffset: linked.HostAuditOffset})
if err != nil || len(page.Receipts) != 1 || page.Receipts[0].OperationID != seen {
t.Fatalf("linked read: %+v %v", page, err)
}
}
+6 -2
View File
@@ -89,6 +89,7 @@ type Request struct {
Actor string Actor string
Owner string Owner string
ConversationID string ConversationID string
OperationID string // overwritten by the trusted bus before handler execution
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染) AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行 Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文) HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
@@ -279,12 +280,15 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
if !ok { if !ok {
return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID) return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID)
} }
// The domain handler can persist this trusted ID with its transaction/outbox.
// Never accept an ID from Params or the incoming Request. A handler failure
// still writes only a rejected audit receipt with no committed operation ID.
req.OperationID = fmt.Sprintf("op_%s_%d", strings.ReplaceAll(req.ID, ".", "_"), b.now().UnixNano())
payload, err := h(ctx, req) payload, err := h(ctx, req)
if err != nil { if err != nil {
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
} }
op := fmt.Sprintf("op_%s_%d", strings.ReplaceAll(strings.TrimPrefix(req.ID, ""), ".", "_"), b.now().UnixNano()) return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload}
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, op, "", start), Payload: payload}
} }
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。 // Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
+28
View File
@@ -87,6 +87,34 @@ func TestDispatchFourEntrancesSameAudit(t *testing.T) {
} }
} }
func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
audit := &memAudit{}
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() })
var seen string
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) {
seen = req.OperationID
return "committed-domain-event", nil
}); err != nil {
t.Fatal(err)
}
resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", Params: map[string]any{"operationId": "forged-client-id"}})
if seen == "" || seen == "forged-client-id" || resp.Receipt.OperationID != seen || len(audit.logs) != 1 || audit.logs[0].OperationID != seen {
t.Fatalf("operation correlation missing: handler=%q receipt=%+v audit=%+v", seen, resp.Receipt, audit.logs)
}
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.fail", Title: "Fail"}, "", func(_ context.Context, req Request) (any, error) {
if req.OperationID == "" {
t.Fatal("handler must receive a tentative host operation ID")
}
return nil, errors.New("domain transaction rejected")
}); err != nil {
t.Fatal(err)
}
failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"})
if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" {
t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs)
}
}
func TestDispatchUnknownAndBadParams(t *testing.T) { func TestDispatchUnknownAndBadParams(t *testing.T) {
a := &memAudit{} a := &memAudit{}
b := newTestBus(a, &memCards{}) b := newTestBus(a, &memCards{})
+32
View File
@@ -23,6 +23,38 @@ type ProjectAuditLink struct {
IdempotencyKey string IdempotencyKey string
} }
// CommittedAuditOffset resolves exactly one persisted host receipt. A domain
// outbox can have the operation ID before the bus appends its audit row; no row
// yet is a retryable pending state, while duplicates are ambiguous and refused.
func (s *Store) CommittedAuditOffset(owner, projectID, operationID string) (int64, error) {
if owner == "" || projectID == "" || operationID == "" {
return 0, ErrNotFound
}
rows, err := s.db.Query(s.q(`SELECT id FROM command_audit WHERE owner=? AND project_id=? AND operation_id=? AND state='committed' ORDER BY id LIMIT 2`), owner, projectID, operationID)
if err != nil {
return 0, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, ErrNotFound
}
if len(ids) > 1 {
return 0, ErrConflict
}
return ids[0], nil
}
func (s *Store) CommandAuditForLink(owner string, offset int64) (ProjectCommandAuditRow, string, error) { func (s *Store) CommandAuditForLink(owner string, offset int64) (ProjectCommandAuditRow, string, error) {
var row ProjectCommandAuditRow var row ProjectCommandAuditRow
var projectID, raw string var projectID, raw string
@@ -32,6 +32,13 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
resolved, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1")
if err != nil || resolved != rows[0].Offset {
t.Fatalf("trusted operation lookup: %d %v", resolved, err)
}
if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-pending"); !errors.Is(err, ErrNotFound) {
t.Fatalf("unrecorded operation should wait: %v", err)
}
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(a.ID), Owner: "alice", SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(a.ID), Owner: "alice", SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
first, err := st.SaveProjectAuditLink(link) first, err := st.SaveProjectAuditLink(link)
if err != nil { if err != nil {
@@ -81,4 +88,10 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
if _, err := st.SaveProjectAuditLink(bobLink); err != nil { if _, err := st.SaveProjectAuditLink(bobLink); err != nil {
t.Fatalf("another owner may use the same outbox key: %v", err) t.Fatalf("another owner may use the same outbox key: %v", err)
} }
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1"); !errors.Is(err, ErrConflict) {
t.Fatalf("ambiguous host operation must not choose one: %v", err)
}
} }
+3 -1
View File
@@ -38,7 +38,9 @@ import (
// project-bound command-audit read slice and domain audit.read callback). // project-bound command-audit read slice and domain audit.read callback).
// 2.14.0-experimental: X2 optional committed project-audit link publisher, // 2.14.0-experimental: X2 optional committed project-audit link publisher,
// verifier, disclosure callback, and exact linked receipt read. // verifier, disclosure callback, and exact linked receipt read.
const SurfaceVersion = "2.14.0-experimental" // 2.15.0-experimental: trusted Request.OperationID before handler execution and
// retryable ProjectAuditLinkRequest.HostAuditOffset=0 resolution.
const SurfaceVersion = "2.15.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+3
View File
@@ -14,6 +14,9 @@ type Request struct {
Actor string Actor string
Owner string Owner string
ConversationID string ConversationID string
// OperationID is generated by the trusted host bus before this handler runs.
// Persist it with an actual domain commit/outbox; never treat it as a retry key.
OperationID string
// HostProjectID 本次分发所属基座项目(EXT-18 可选上下文;空=无项目 // HostProjectID 本次分发所属基座项目(EXT-18 可选上下文;空=无项目
// 上下文——API 入口无对话或对话未关联项目时为零值)。下游按显式映射 // 上下文——API 入口无对话或对话未关联项目时为零值)。下游按显式映射
// 解析(如 RDM rdm_host_project_map),不得据此替代自身授权判定。 // 解析(如 RDM rdm_host_project_map),不得据此替代自身授权判定。
+5 -1
View File
@@ -23,11 +23,15 @@ var (
ErrProjectAuditDenied = errors.New("extension: project audit denied") ErrProjectAuditDenied = errors.New("extension: project audit denied")
ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported") ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported")
ErrProjectAuditLinkConflict = errors.New("extension: project audit link conflict") ErrProjectAuditLinkConflict = errors.New("extension: project audit link conflict")
ErrProjectAuditPending = errors.New("extension: committed host audit receipt pending")
) )
// ProjectAuditLinkRequest binds one host command receipt to an already // ProjectAuditLinkRequest binds one host command receipt to an already
// committed domain fact. Owner is never caller supplied; the host handle and // committed domain fact. Owner is never caller supplied; the host handle and
// stored audit row establish it. This first slice supports domain-commit only. // stored audit row establish it. HostAuditOffset=0 asks the host to resolve a
// unique committed row by its trusted owner/project/OperationID, which lets an
// outbox wait for the bus audit commit after the domain handler returns. This
// first slice supports domain-commit only.
type ProjectAuditLinkRequest struct { type ProjectAuditLinkRequest struct {
ProjectID core.ID `json:"projectId"` ProjectID core.ID `json:"projectId"`
HostAuditOffset int64 `json:"hostAuditOffset"` HostAuditOffset int64 `json:"hostAuditOffset"`
+1
View File
@@ -10,6 +10,7 @@ Compiled
ErrNotLinked ErrNotLinked
ErrProjectAuditDenied ErrProjectAuditDenied
ErrProjectAuditLinkConflict ErrProjectAuditLinkConflict
ErrProjectAuditPending
ErrProjectAuditUnsupported ErrProjectAuditUnsupported
Handler Handler
Host Host