Add verified project audit links and button actors

Why: Owner-only command audit cannot prove shared business commits, and button dispatch omitted its authenticated actor.

Evidence: Local go test ./... passed; isolated PostgreSQL cutoff, link, and SQLite-to-PostgreSQL migration receipts are recorded as SELF in the task card.

Impact: Add an optional domain-verified metadata link, exact authorized cross-member read, idempotent storage, actor propagation, and honest PostgreSQL cutoff coverage. Broad audit pagination and other sources remain pending.

Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1
This commit is contained in:
weichen
2026-10-14 04:05:10 +00:00
parent c8dd169a1f
commit 48fdddee25
18 changed files with 812 additions and 29 deletions
+8 -3
View File
@@ -1,20 +1,25 @@
# W9 Backend project audit read slice
# W9 Backend project audit candidate
- Scope: TT-A01/A02/A04/U04, approved stage 5 W9. Stage 6 local implementation; human review, formal acceptance, PR, CI, tag, and deployment are pending.
- Branch/worktree: `feature/rdm-two-trees-project-audit` at `/home/weichen/projects/Agent-System-Backend-wt-feature-rdm-two-trees-project-audit`.
- R0: Backend authoritative `origin/main` `5d5fc9891c0eb8994545ccb8b740535d9b99fb0e`; main was clean and equal to the fetched remote before this worktree was created. Core gitlink in this worktree is `5773e74086791731059730bd3726dc8c9cd1381f`.
- Startup receipt: `/tmp/backend-w9-precheck-20261014-01.json` (SELF, exit 2, `SYNC_REQUIRED` for the separate Dev checkout `feature/rdm-increment-acceptance-note`, six commits behind Dev `origin/main`). This Backend task uses the independent-development path in Backend `AGENTS.md`; it does not write that Dev checkout or its task card. The receipt is preserved as a failure, not relabeled READY.
- Approved affected design: `rdm-two-trees-stage6-audit-link-design-adjustment-r1.md`, SHA256 `385d9d7570217ddcce9fe60a60c99dc58c9a4875ad790584a724d09a210a6e52`; user approval in the task session covers this local Backend increment, not tag publication or Stage 7 acceptance.
## Implemented local slice
- Optional `ProjectAuditHost` and domain `audit.read` registration. A different plugin, absent registration, or unbound actor cannot read.
- 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.
- Coverage explicitly marks cross-member published operations, legacy project attribution, run relations, model/background receipts, and resource bodies unsupported. This is not the full W9 or complete project audit.
- 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.
- 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
- The session saw RED for the missing store and host interfaces, unscoped cutoff, cross-member private disclosure, and conversation-project reassignment before the respective fixes. These RED outputs were not captured by `make evidence`, so this card does not use them as formal evidence.
- SELF receipt `/tmp/backend-w9-go-test-20261014-01.json`: `go test ./...`, exit 0, run on the pre-commit worktree based on R0.
- 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.
- Required next X2 contract: persist trusted operation/run/resource attribution and publication or visibility at source, with a per-object metadata/body disclosure callback and cursor binding to actor, authorization epoch, project, filter, and cutoff. Then test two members reading published project operations while private conversations, revoked objects, and bodies without current rights remain hidden. Current `command_audit` has owner, conversation, raw envelope, and new project/name snapshots, but no proof that an operation was published or that its resource may be disclosed. Add real model/tool/background receipts, delayed receipt reconciliation, history/export checks, and PostgreSQL/combined consumer verification before claiming W9 complete.
- 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.
- 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.
+9
View File
@@ -85,6 +85,15 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) {
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
}
var linkedResource string
if err := db.QueryRow(`SELECT resource_id FROM project_audit_links WHERE host_audit_offset=7 AND source_id='commit-1'`).Scan(&linkedResource); err != nil || linkedResource != "doc-1" {
t.Fatalf("审计链接迁移丢失: resource=%q err=%v", linkedResource, err)
}
var newLinkID int64
if err := db.QueryRow(`INSERT INTO project_audit_links(host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
VALUES (7,'p1','alice','domain-commit','commit-2','op-1','document','doc-2','','metadata','outbox-2','2026-10-11T03:00:01Z') RETURNING id`).Scan(&newLinkID); err != nil || newLinkID <= 3 {
t.Fatalf("审计链接序列未推进: id=%d err=%v", newLinkID, err)
}
// 报告不含秘密值。
var report string
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
+2 -2
View File
@@ -284,7 +284,7 @@ var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
"activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
@@ -784,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error {
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit", "project_audit_links"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
+4 -2
View File
@@ -70,8 +70,10 @@ func seedEdgeRows(t *testing.T, st *store.Store) {
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
// 命令审计(SERIAL 主键:显式 id 插入)。
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner, project_id, actor_name) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'committed', 'op-1', 'c1', '{"actor":"alice","commandId":"cmd-1","operationId":"op-1","state":"committed","conversationId":"c1"}', 'alice', 'p1', '爱丽丝')`)
mustExec(t, st, `INSERT INTO project_audit_links (id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) VALUES
(3,7,'p1','alice','domain-commit','commit-1','op-1','document','doc-1','','metadata','outbox-1','2026-10-11T01:00:01Z')`)
// 设置('' 默认租户 scope)。
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
+1 -11
View File
@@ -557,17 +557,7 @@ func Run(opt Options) error {
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
return dispatchSurfaceAction(ctx, bus, d)
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
+187 -6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"sync"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -12,9 +13,11 @@ import (
// projectAuditSlot binds the audit.read policy to its registering domain plugin.
// A different plugin cannot borrow that policy through its own Host handle.
type projectAuditSlot struct {
mu sync.RWMutex
holder string
check extension.ProjectAuditAuthorizer
mu sync.RWMutex
holder string
check extension.ProjectAuditAuthorizer
verifier extension.ProjectAuditLinkVerifier
disclose extension.ProjectAuditDisclosureAuthorizer
}
func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error {
@@ -23,13 +26,39 @@ func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAudi
}
s.mu.Lock()
defer s.mu.Unlock()
if s.check != nil {
if s.check != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit authorizer already registered")
}
s.holder, s.check = pluginID, check
return nil
}
func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) error {
if pluginID == "" || verify == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.verifier != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit verifier already registered")
}
s.holder, s.verifier = pluginID, verify
return nil
}
func (s *projectAuditSlot) registerDisclosure(pluginID string, disclose extension.ProjectAuditDisclosureAuthorizer) error {
if pluginID == "" || disclose == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.disclose != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit disclosure authorizer already registered")
}
s.holder, s.disclose = pluginID, disclose
return nil
}
func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit authorizers")
}
@@ -49,7 +78,111 @@ func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAu
return reg.RegisterProjectAuditAuthorizer(check)
}
func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) error {
return errors.New("extension: declarative plugins cannot register project audit link verifiers")
}
func (r compiledRegistry) RegisterProjectAuditDisclosureAuthorizer(extension.ProjectAuditDisclosureAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit disclosure authorizers")
}
func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.registerVerifier(r.pluginID, verify)
}
func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.registerDisclosure(r.pluginID, disclose)
}
func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) error {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditLinkVerifier(verify)
}
func (r recordRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditDisclosureAuthorizer(disclose)
}
var _ extension.ProjectAuditHost = inprocHost{}
var _ extension.ProjectAuditPublisher = inprocHost{}
func (h inprocHost) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) {
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if h.caps.projectAudit == nil || h.caps.st == nil {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
}
slot := h.caps.projectAudit
slot.mu.RLock()
holder, verify := slot.holder, slot.verifier
slot.mu.RUnlock()
if holder != h.pluginID || verify == nil {
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 == "" {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if projectID != string(req.ProjectID) || row.Envelope.Actor != h.owner || row.Envelope.OperationID != req.OperationID || string(row.Envelope.State) != "committed" || row.Envelope.RunID != req.RunID {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if req.RunID != "" {
bound, err := h.caps.st.RunBoundToAudit(h.owner, projectID, row.Envelope.ConversationID, req.RunID)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !bound {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
}
previous, lookupErr := h.caps.st.ProjectAuditLinkByKey(h.owner, req.IdempotencyKey)
if lookupErr == nil {
if previous.HostAuditOffset != req.HostAuditOffset || previous.ProjectID != string(req.ProjectID) || previous.SourceKind != req.SourceKind || previous.SourceID != req.SourceID || previous.OperationID != req.OperationID || previous.ResourceKind != req.ResourceKind || previous.ResourceID != req.ResourceID || previous.RunID != req.RunID || previous.DisclosureClass != req.DisclosureClass {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
return extension.ProjectAuditLinkReceipt{LinkID: previous.ID, HostAuditOffset: previous.HostAuditOffset}, nil
}
if !errors.Is(lookupErr, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, lookupErr
}
proof := extension.ProjectAuditLinkProof{ProjectAuditLinkRequest: req, Owner: h.owner}
ok, err := verify(ctx, proof)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !ok {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
link := store.ProjectAuditLink{HostAuditOffset: req.HostAuditOffset, ProjectID: projectID, Owner: h.owner,
SourceKind: req.SourceKind, SourceID: req.SourceID, OperationID: req.OperationID, ResourceKind: req.ResourceKind,
ResourceID: req.ResourceID, RunID: req.RunID, DisclosureClass: req.DisclosureClass, IdempotencyKey: req.IdempotencyKey}
saved, err := h.caps.st.SaveProjectAuditLink(link)
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
return extension.ProjectAuditLinkReceipt{LinkID: saved.ID, HostAuditOffset: saved.HostAuditOffset}, nil
}
func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) {
if err := ctx.Err(); err != nil {
@@ -69,21 +202,31 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
ok, err := check(h.actor, core.ID(q.ProjectID))
if err != nil || !ok {
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !ok {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
return h.queryLinkedProjectAudit(ctx, q, slot)
}
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
if err != nil {
return extension.ProjectAuditPage{}, err
}
cutoffConsistency := "committed-prefix"
if h.caps.st.Dialect() == "postgres" {
cutoffConsistency = "offset-upper-bound-only"
}
page := extension.ProjectAuditPage{
Receipts: make([]extension.HostAuditReceipt, 0, len(rows)),
NextOffset: next, CutoffOffset: cutoff,
Coverage: extension.ProjectAuditCoverage{
CommandAudit: "actor-owned-project-bound-only", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: cutoffConsistency, CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
},
}
@@ -101,3 +244,41 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
}
return page, nil
}
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
slot.mu.RLock()
disclose := slot.disclose
slot.mu.RUnlock()
if disclose == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
link, row, err := h.caps.st.ProjectAuditLinkedReceipt(string(q.ProjectID), q.LinkedSourceKind, q.LinkedSourceID, q.LinkedHostOffset)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditPage{}, err
}
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
allowed, err := disclose(ctx, h.actor, resource)
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !allowed.Metadata {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
}
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice at action"); err != nil {
t.Fatal(err)
}
if err := st.EnsureUser("bob", "Bob"); err != nil {
t.Fatal(err)
}
p, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
otherProject, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "private", "")
if err != nil {
t.Fatal(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, ParamsDigest: "secret-digest"}); err != nil {
t.Fatal(err)
}
private, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
offset := private[0].Offset
slot := &projectAuditSlot{}
caps := hostcaps{st: st, projectAudit: slot}
reg := pluginRegistry{pluginID: "rdm", caps: caps}
auditAllowed := true
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
return auditAllowed && actor == "bob" && projectID == 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.SourceKind == "domain-commit" && proof.SourceID == "commit-1" && proof.OperationID == "op-1" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1", nil
}); err != nil {
t.Fatal(err)
}
metadataAllowed := true
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
return extension.ProjectAuditDisclosure{Metadata: metadataAllowed && actor == "bob" && resource.ResourceID == "doc-1"}, nil
}); err != nil {
t.Fatal(err)
}
bob := inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}
q := extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "commit-1", LinkedHostOffset: offset}
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("unlinked private audit: %v", err)
}
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"}
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)
}
wrongProject := linkReq
wrongProject.ProjectID = otherProject.ID
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("wrong host project: %v", err)
}
uncommitted := linkReq
uncommitted.SourceID, uncommitted.IdempotencyKey = "uncommitted", "outbox-uncommitted"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), uncommitted); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("uncommitted source: %v", err)
}
if _, err := (inprocHost{caps: caps, pluginID: "other", owner: "alice"}).PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("other plugin: %v", err)
}
first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil {
t.Fatal(err)
}
retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
if err != nil || retry.LinkID != first.LinkID {
t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err)
}
page, err := bob.QueryProjectAudit(context.Background(), q)
if err != nil {
t.Fatal(err)
}
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "rdm.confirm" || page.Receipts[0].ActorName != "Alice at action" || page.Receipts[0].ConversationID != "" || page.Coverage.CrossMember != "verified-exact-link-only" {
t.Fatalf("linked page: %+v", page)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "secret-digest") || strings.Contains(string(raw), string(c.ID)) {
t.Fatalf("private receipt fields leaked: %s", raw)
}
metadataAllowed = false
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked object metadata: %v", err)
}
metadataAllowed, auditAllowed = true, false
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked audit.read: %v", err)
}
linkReq.ResourceID = "doc-2"
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditLinkConflict) {
t.Fatalf("same key different link: %v", err)
}
metadataAllowed, auditAllowed = true, true
if err := st.Close(); err != nil {
t.Fatal(err)
}
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("storage outage during publish must remain retryable: %v", err)
}
if _, err := bob.QueryProjectAudit(context.Background(), q); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("storage outage during linked read must be explicit: %v", err)
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T)
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" {
t.Fatalf("A page: %+v", page)
}
if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" {
if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" || page.Coverage.CutoffConsistency != "committed-prefix" {
t.Fatalf("coverage: %+v", page.Coverage)
}
if page.Receipts[0].ActorName != "BOB" {
+50
View File
@@ -0,0 +1,50 @@
package app
import (
"context"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
)
func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice at action"); err != nil {
t.Fatal(err)
}
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() })
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.submit", Title: "Submit"}, "", func(context.Context, cmdbus.Request) (any, error) { return "ok", nil }); err != nil {
t.Fatal(err)
}
result, err := dispatchSurfaceAction(context.Background(), bus, surfacesvc.ActionDispatch{CommandID: "demo.submit", Owner: "alice", ConversationID: string(c.ID)})
if err != nil {
t.Fatal(err)
}
if result.State != "committed" {
t.Fatalf("state: %s", result.State)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Envelope.Actor != "alice" || rows[0].ActorName != "Alice at action" {
t.Fatalf("button audit actor: %+v", rows)
}
}
+25
View File
@@ -0,0 +1,25 @@
package app
import (
"context"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/surfacesvc"
)
// dispatchSurfaceAction receives Owner from SurfaceService's authenticated
// caller. The button is acting for that user, so both Actor and Owner must be
// populated before the command bus writes its audit envelope.
func dispatchSurfaceAction(ctx context.Context, bus *cmdbus.Bus, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Actor: d.Owner, Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
}
+9
View File
@@ -857,6 +857,15 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
"postgres": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`},
}},
migration{version: 32, name: "project_audit_committed_links",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_audit_links (id INTEGER PRIMARY KEY AUTOINCREMENT,host_audit_offset INTEGER NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
"postgres": {`CREATE TABLE project_audit_links (id BIGSERIAL PRIMARY KEY,host_audit_offset BIGINT NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`},
},
down: map[string][]string{
"sqlite": {`DROP TABLE IF EXISTS project_audit_links`},
"postgres": {`DROP TABLE IF EXISTS project_audit_links`},
}},
)
}
+1
View File
@@ -36,6 +36,7 @@ var migrationBreaksOldReaders = map[int]bool{
29: false, // redeem_attempts:新表,旧读者不受影响
30: false, // project_event_publication_proofs:新表,旧读者不受影响
31: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响
32: false, // project_audit_committed_links:新表,旧读者不受影响
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+143
View File
@@ -0,0 +1,143 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
)
// ProjectAuditLink is an immutable association written only after a trusted
// domain verifier has checked the committed source event.
type ProjectAuditLink struct {
ID int64
HostAuditOffset int64
ProjectID string
Owner string
SourceKind string
SourceID string
OperationID string
ResourceKind string
ResourceID string
RunID string
DisclosureClass string
IdempotencyKey string
}
func (s *Store) CommandAuditForLink(owner string, offset int64) (ProjectCommandAuditRow, string, error) {
var row ProjectCommandAuditRow
var projectID, raw string
if owner == "" || offset <= 0 {
return row, "", ErrNotFound
}
err := s.db.QueryRow(s.q(`SELECT id,actor_name,project_id,payload FROM command_audit WHERE id=? AND owner=? AND project_id<>''`), offset, owner).
Scan(&row.Offset, &row.ActorName, &projectID, &raw)
if errors.Is(err, sql.ErrNoRows) {
return row, "", ErrNotFound
}
if err != nil {
return row, "", err
}
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
return row, "", err
}
return row, projectID, nil
}
func (s *Store) RunBoundToAudit(owner, projectID, conversationID, runID string) (bool, error) {
if owner == "" || projectID == "" || conversationID == "" || runID == "" {
return false, nil
}
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND project_id=? AND conversation_id=?`), runID, owner, projectID, conversationID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
const projectAuditLinkColumns = `id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key`
const projectAuditLinkAliasedColumns = `l.id,l.host_audit_offset,l.project_id,l.owner,l.source_kind,l.source_id,l.operation_id,l.resource_kind,l.resource_id,l.run_id,l.disclosure_class,l.idempotency_key`
func scanProjectAuditLink(scan func(...any) error) (ProjectAuditLink, error) {
var link ProjectAuditLink
err := scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner, &link.SourceKind, &link.SourceID,
&link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID, &link.DisclosureClass, &link.IdempotencyKey)
if errors.Is(err, sql.ErrNoRows) {
return link, ErrNotFound
}
return link, err
}
func (s *Store) ProjectAuditLinkByKey(owner, key string) (ProjectAuditLink, error) {
if owner == "" || key == "" {
return ProjectAuditLink{}, ErrNotFound
}
return scanProjectAuditLink(func(dst ...any) error {
return s.db.QueryRow(s.q(`SELECT `+projectAuditLinkColumns+` FROM project_audit_links WHERE owner=? AND idempotency_key=?`), owner, key).Scan(dst...)
})
}
func sameProjectAuditLink(a, b ProjectAuditLink) bool {
return a.HostAuditOffset == b.HostAuditOffset && a.ProjectID == b.ProjectID && a.Owner == b.Owner &&
a.SourceKind == b.SourceKind && a.SourceID == b.SourceID && a.OperationID == b.OperationID &&
a.ResourceKind == b.ResourceKind && a.ResourceID == b.ResourceID && a.RunID == b.RunID &&
a.DisclosureClass == b.DisclosureClass && a.IdempotencyKey == b.IdempotencyKey
}
// SaveProjectAuditLink is idempotent for an identical key and binding. Both the
// key and the host/source triple are unique; racing or conflicting retries
// resolve by reading the committed winner, never by overwriting it.
func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, error) {
var id int64
err := s.db.QueryRow(s.q(`INSERT INTO project_audit_links(host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING RETURNING id`), link.HostAuditOffset, link.ProjectID, link.Owner,
link.SourceKind, link.SourceID, link.OperationID, link.ResourceKind, link.ResourceID, link.RunID, link.DisclosureClass,
link.IdempotencyKey, nowISO()).Scan(&id)
if err == nil {
link.ID = id
return link, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ProjectAuditLink{}, err
}
previous, err := scanProjectAuditLink(func(dst ...any) error {
return s.db.QueryRow(s.q(`SELECT `+projectAuditLinkColumns+` FROM project_audit_links
WHERE (owner=? AND idempotency_key=?) OR (host_audit_offset=? AND source_kind=? AND source_id=?) LIMIT 1`),
link.Owner, link.IdempotencyKey, link.HostAuditOffset, link.SourceKind, link.SourceID).Scan(dst...)
})
if err != nil {
return ProjectAuditLink{}, err
}
if !sameProjectAuditLink(previous, link) {
return ProjectAuditLink{}, ErrConflict
}
return previous, nil
}
// ProjectAuditLinkedReceipt reads one exact association and the underlying
// host row. It does not enumerate private links or decide field disclosure.
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
var row ProjectCommandAuditRow
var link ProjectAuditLink
var raw string
if projectID == "" || sourceKind == "" || sourceID == "" || hostOffset <= 0 {
return link, row, ErrNotFound
}
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
&link.DisclosureClass, &link.IdempotencyKey, &row.ActorName, &raw)
if errors.Is(err, sql.ErrNoRows) {
return link, row, ErrNotFound
}
if err != nil {
return link, row, err
}
row.Offset = link.HostAuditOffset
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
return link, row, err
}
return link, row, nil
}
@@ -0,0 +1,84 @@
package store
import (
"errors"
"testing"
"time"
agentcommand "agent-command"
)
func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
st := openTestStore(t) // SQLite locally; isolated PostgreSQL schema when configured.
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
a, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
b, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(a.ID), "work", "")
if err != nil {
t.Fatal(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)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10)
if err != nil {
t.Fatal(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"}
first, err := st.SaveProjectAuditLink(link)
if err != nil {
t.Fatal(err)
}
retry, err := st.SaveProjectAuditLink(link)
if err != nil || retry.ID != first.ID {
t.Fatalf("retry: first=%+v retry=%+v err=%v", first, retry, err)
}
if found, err := st.ProjectAuditLinkByKey("alice", "outbox-1"); err != nil || found.ID != first.ID {
t.Fatalf("key lookup: %+v %v", found, err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(b.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("wrong project: %v", err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "other", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("wrong source: %v", err)
}
got, audit, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "commit-1", rows[0].Offset)
if err != nil || got.ID != first.ID || audit.Envelope.CommandID != "rdm.confirm" {
t.Fatalf("linked read: %+v %+v %v", got, audit, err)
}
link.ResourceID = "doc-2"
if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) {
t.Fatalf("same key different binding: %v", err)
}
link.ResourceID = "doc-1"
link.IdempotencyKey = "outbox-2"
if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) {
t.Fatalf("same source different key: %v", err)
}
if err := st.EnsureUser("bob", "Bob"); err != nil {
t.Fatal(err)
}
bobConversation, err := st.CreateConversationInProject("bob", string(a.ID), "shared work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "bob", CommandID: "rdm.confirm", OperationID: "op-bob", ConversationID: string(bobConversation.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
bobRows, _, _, err := st.ListProjectCommandAudits("bob", string(a.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
bobLink := ProjectAuditLink{HostAuditOffset: bobRows[0].Offset, ProjectID: string(a.ID), Owner: "bob", SourceKind: "domain-commit", SourceID: "commit-bob", OperationID: "op-bob", ResourceKind: "document", ResourceID: "doc-bob", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
if _, err := st.SaveProjectAuditLink(bobLink); err != nil {
t.Fatalf("another owner may use the same outbox key: %v", err)
}
}
+60
View File
@@ -2,6 +2,7 @@ package store
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
@@ -172,3 +173,62 @@ func TestProjectCommandAuditDoesNotMoveWithConversation(t *testing.T) {
t.Fatalf("project attribution changed: A=%+v B=%+v", oldProject, newProject)
}
}
// PostgreSQL sequence IDs are allocated before commit. This test records the
// current upper-bound semantics: a lower ID can appear after a page cutoff was
// captured, so callers must not describe the cutoff as a visibility snapshot.
func TestProjectAuditPostgresCutoffLateCommit(t *testing.T) {
if os.Getenv("AGENT_PG_TEST_DSN") == "" {
t.Skip("AGENT_PG_TEST_DSN unset: PG concurrency check NOT_RUN")
}
st := openTestStore(t)
if st.Dialect() != "postgres" {
t.Fatal("expected postgres")
}
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
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)
}
env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "late-low-id", ConversationID: string(c.ID)}
raw, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
tx, err := st.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
var lowID int64
if err := tx.QueryRow(st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES (?,?,'','','','',?,?,?,?,?) RETURNING id`), time.Now().UTC().Format(time.RFC3339Nano), "late-low-id", string(c.ID), "alice", string(raw), "Alice", string(p.ID)).Scan(&lowID); err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "committed-high-id", ConversationID: string(c.ID)}); err != nil {
t.Fatal(err)
}
first, cutoff, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(first) != 1 || first[0].Envelope.CommandID != "committed-high-id" || lowID >= cutoff {
t.Fatalf("first page: rows=%+v low=%d cutoff=%d", first, lowID, cutoff)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
later, fixed, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, cutoff, 10)
if err != nil {
t.Fatal(err)
}
if fixed != cutoff || len(later) != 2 || later[1].Envelope.CommandID != "late-low-id" {
t.Fatalf("late commit not exposed: rows=%+v cutoff=%d", later, fixed)
}
}
+3 -1
View File
@@ -36,7 +36,9 @@ import (
// ResourceAuthzQuery.Source for per-document publication authorization.
// 2.13.0-experimental: optional ProjectAuditHost and ProjectAuditRegistry (X2
// project-bound command-audit read slice and domain audit.read callback).
const SurfaceVersion = "2.13.0-experimental"
// 2.14.0-experimental: X2 optional committed project-audit link publisher,
// verifier, disclosure callback, and exact linked receipt read.
const SurfaceVersion = "2.14.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+75 -3
View File
@@ -20,18 +20,89 @@ type ProjectAuditRegistry interface {
}
var (
ErrProjectAuditDenied = errors.New("extension: project audit denied")
ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported")
ErrProjectAuditDenied = errors.New("extension: project audit denied")
ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported")
ErrProjectAuditLinkConflict = errors.New("extension: project audit link conflict")
)
// ProjectAuditLinkRequest binds one host command receipt to an already
// committed domain fact. Owner is never caller supplied; the host handle and
// stored audit row establish it. This first slice supports domain-commit only.
type ProjectAuditLinkRequest struct {
ProjectID core.ID `json:"projectId"`
HostAuditOffset int64 `json:"hostAuditOffset"`
SourceKind string `json:"sourceKind"`
SourceID string `json:"sourceId"`
OperationID string `json:"operationId"`
ResourceKind string `json:"resourceKind"`
ResourceID string `json:"resourceId"`
RunID string `json:"runId,omitempty"`
DisclosureClass string `json:"disclosureClass"`
IdempotencyKey string `json:"idempotencyKey"`
}
// ProjectAuditLinkProof contains the caller request plus identity and source
// fields re-read from trusted host storage. The domain verifier must check its
// committed source event and operation/resource attribution, not a model claim.
type ProjectAuditLinkProof struct {
ProjectAuditLinkRequest
Owner string
}
type ProjectAuditLinkVerifier func(context.Context, ProjectAuditLinkProof) (bool, error)
// ProjectAuditLinkedResource is the stored, immutable link presented to the
// current domain disclosure policy. A caller ID alone never authorizes it.
type ProjectAuditLinkedResource struct {
ProjectID core.ID
Owner string
SourceKind string
SourceID string
OperationID string
ResourceKind string
ResourceID string
RunID string
DisclosureClass string
}
type ProjectAuditDisclosure struct {
Metadata bool
Body bool
}
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
// ProjectAuditLinkRegistry is optional. Only the registering compiled domain
// plugin may publish or read its links.
type ProjectAuditLinkRegistry interface {
RegisterProjectAuditLinkVerifier(ProjectAuditLinkVerifier) error
RegisterProjectAuditDisclosureAuthorizer(ProjectAuditDisclosureAuthorizer) error
}
type ProjectAuditLinkReceipt struct {
LinkID int64 `json:"linkId"`
HostAuditOffset int64 `json:"hostAuditOffset"`
}
type ProjectAuditPublisher interface {
PublishProjectAuditLink(context.Context, ProjectAuditLinkRequest) (ProjectAuditLinkReceipt, error)
}
// ProjectAuditQuery reads one project-bound command-audit slice. Offset is a
// host source offset, not a cross-domain event sequence. A nonzero BeforeOffset
// must be paired with the CutoffOffset returned on the first page.
// must be paired with the CutoffOffset returned on the first page. On PostgreSQL
// that cutoff bounds offsets but is not a fixed visibility snapshot: a lower ID
// from a still-open transaction may commit later. See Coverage.CutoffConsistency.
type ProjectAuditQuery struct {
ProjectID core.ID `json:"projectId"`
BeforeOffset int64 `json:"beforeOffset,omitempty"`
CutoffOffset int64 `json:"cutoffOffset,omitempty"`
Limit int `json:"limit,omitempty"`
// Linked* select one exact committed link. This avoids enumerating hidden
// private links or leaking their counts before cursor policy is complete.
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
LinkedSourceID string `json:"linkedSourceId,omitempty"`
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
}
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
@@ -52,6 +123,7 @@ type HostAuditReceipt struct {
// host sources and old unbound rows cannot be inferred from owner-wide audit.
type ProjectAuditCoverage struct {
CommandAudit string `json:"commandAudit"`
CutoffConsistency string `json:"cutoffConsistency"`
CrossMember string `json:"crossMember"`
LegacyProjectBinding string `json:"legacyProjectBinding"`
RunRelations string `json:"runRelations"`
+13
View File
@@ -9,6 +9,7 @@ AdminZoneSpec
Compiled
ErrNotLinked
ErrProjectAuditDenied
ErrProjectAuditLinkConflict
ErrProjectAuditUnsupported
Handler
Host
@@ -37,9 +38,21 @@ NewScopedHost
Options
ProjectAuditAuthorizer
ProjectAuditCoverage
ProjectAuditDisclosure
ProjectAuditDisclosureAuthorizer
ProjectAuditHost
ProjectAuditHost.QueryProjectAudit
ProjectAuditLinkProof
ProjectAuditLinkReceipt
ProjectAuditLinkRegistry
ProjectAuditLinkRegistry.RegisterProjectAuditDisclosureAuthorizer
ProjectAuditLinkRegistry.RegisterProjectAuditLinkVerifier
ProjectAuditLinkRequest
ProjectAuditLinkVerifier
ProjectAuditLinkedResource
ProjectAuditPage
ProjectAuditPublisher
ProjectAuditPublisher.PublishProjectAuditLink
ProjectAuditQuery
ProjectAuditRegistry
ProjectAuditRegistry.RegisterProjectAuditAuthorizer