Files
Agent-System-Backend/internal/app/project_work_observation.go
T
weichen 7cee124b8f Capture actual project work sources before reuse
Why: Project retrieval hits, tool results, and saved visible analysis must be individually traceable and fully analyzed before later project actions consume them.

Evidence: RED tests first showed missing run-source capture, tool-result capture, and run-wide preflight; go test -p 1 ./... and go build ./... passed; isolated PostgreSQL source, migration, audit, and context tests passed.

Impact: Persist actual run-bound sources with current-status checks and exact manifests behind the disabled X3 assembly gate; no live product path, migration, deployment, or release is enabled.

Refs: TT-N03, TT-R01; approved X3 stage-five plan r1
2026-10-14 16:25:22 +00:00

333 lines
14 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sync"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
type projectWorkObserverSlot struct {
mu sync.RWMutex
provider *extension.ProjectWorkObserver
}
func (s *projectWorkObserverSlot) get() *extension.ProjectWorkObserver {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.provider == nil {
return nil
}
copy := *s.provider
return &copy
}
func (r compiledRegistry) RegisterProjectWorkObserver(extension.ProjectWorkObserver) error {
return errors.New("extension: declarative plugins cannot register project work observers")
}
func (r pluginRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error {
if r.pluginID != "rdm" || r.caps.workObserver == nil {
return errors.New("extension: project work observer requires trusted RDM registration")
}
if p.Observe == nil || p.Preflight == nil || p.Respond == nil {
return errors.New("extension: project work observer requires all callbacks")
}
s := r.caps.workObserver
s.mu.Lock()
defer s.mu.Unlock()
if s.provider != nil {
return errors.New("extension: project work observer already registered")
}
copy := p
s.provider = &copy
return nil
}
func (r recordRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error {
reg, ok := r.Registry.(extension.ProjectWorkObservationRegistry)
if !ok {
return errors.New("extension: project work observation registration unavailable")
}
return reg.RegisterProjectWorkObserver(p)
}
// deliverProjectWorkObservations replays only committed source parts, rechecking
// their owner and current project work authority before disclosing text to RDM.
// An Observe ACK is an intake receipt, not a preflight or publication decision.
// Failures leave the row pending for a later idempotent retry.
func deliverProjectWorkObservations(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), limit int, verify ...func(context.Context, store.ProjectWorkObservationRow) error,
) (int, error) {
provider := slot.get()
if provider == nil || work == nil {
return 0, errors.New("extension: trusted project work observer unavailable")
}
rows, err := st.PendingProjectWorkObservations(limit)
if err != nil {
return 0, err
}
return deliverProjectWorkRows(ctx, st, provider, work, rows, verify...)
}
func deliverProjectWorkRows(ctx context.Context, st *store.Store, provider *extension.ProjectWorkObserver,
work func(context.Context, string, core.ID) (bool, error), rows []store.ProjectWorkObservationRow,
verify ...func(context.Context, store.ProjectWorkObservationRow) error,
) (int, error) {
completed := 0
for _, row := range rows {
if row.State != "pending" {
continue
}
if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" {
if len(verify) != 1 || verify[0] == nil {
return completed, fmt.Errorf("project work source %s: current resource status verifier unavailable", row.EventID)
}
if err := verify[0](ctx, row); err != nil {
return completed, err
}
}
if err := ctx.Err(); err != nil {
return completed, err
}
conv, err := st.GetConversation(row.Owner, row.ConversationID)
if err != nil || string(conv.ProjectID) != row.ProjectID {
return completed, fmt.Errorf("project work source %s: current conversation binding unavailable", row.EventID)
}
allowed, err := work(ctx, row.Owner, core.ID(row.ProjectID))
if err != nil || !allowed {
return completed, fmt.Errorf("project work source %s: current project authorization denied", row.EventID)
}
observation := extension.WorkObservation{
EventID: row.EventID, Phase: "source-committed", Actor: row.Owner, Owner: row.Owner,
ProjectID: core.ID(row.ProjectID), ConversationID: core.ID(row.ConversationID), RunID: core.ID(row.RunID),
SourceKind: row.SourceKind, SourceID: row.SourceID, SourceVersion: row.SourceVersion,
SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex, SpanStart: row.SpanStart, SpanEnd: row.SpanEnd,
Text: row.Text, Scope: row.Scope, Delivery: row.Delivery, DeliveryReason: row.Reason, Metadata: row.Metadata, ObservedAt: row.ObservedAt,
}
result, err := provider.Observe(ctx, observation)
if err != nil {
return completed, err
}
switch result.State {
case extension.WorkObservationPending:
continue
case extension.WorkObservationAccepted, extension.WorkObservationPartial, extension.WorkObservationDenied:
if err := st.FinishProjectWorkObservation(row.EventID, result.ReceiptID, string(result.State)); err != nil {
return completed, err
}
completed++
default:
return completed, fmt.Errorf("project work source %s: invalid observer result %q", row.EventID, result.State)
}
}
return completed, nil
}
func verifyProjectKnowledgeObservation(_ context.Context, caps hostcaps, row store.ProjectWorkObservationRow) error {
if row.SourceKind != "retrieval-hit" || caps.st == nil {
return errors.New("project work source: unsupported resource verification")
}
doc, err := caps.st.ProjectWorkKnowledgeSource(row.SourceID)
if err != nil {
return errors.New("project work source: current knowledge source unavailable")
}
hash := sha256.Sum256([]byte(doc.Chunk))
actualHash := fmt.Sprintf("%x", hash)
if actualHash != row.SourceVersion || actualHash != row.SourceSHA256 || doc.Chunk != row.Text ||
(row.Metadata["ref"] != "" && row.Metadata["ref"] != doc.Ref) ||
(row.Metadata["source"] != "" && row.Metadata["source"] != doc.Source) ||
(doc.ProjectID != "" && string(doc.ProjectID) != row.ProjectID) {
return errors.New("project work source: knowledge version or scope changed")
}
if caps.authz != nil && caps.authz.hasV2() {
allowed, err := caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: row.Owner, Action: "model-context", ResourceType: "knowledge", ResourceID: doc.ID,
Owner: string(doc.Owner), ProjectID: doc.ProjectID, Source: doc.Source,
})
if err != nil || !allowed {
return errors.New("project work source: current knowledge authorization denied")
}
} else if string(doc.Owner) != row.Owner {
return errors.New("project work source: shared knowledge authorization unavailable")
}
return nil
}
// A transient tool result has no external source authority to recheck. Its
// committed private outbox bytes and stored run are the only reliable source;
// RDM must keep it a candidate until a separately verifiable evidence source
// or human confirmation supports a formal project claim.
func verifyProjectTransientObservation(_ context.Context, st *store.Store, row store.ProjectWorkObservationRow) error {
if row.SourceKind != "tool-result" || row.Metadata["sourceStatus"] != "transient" ||
row.Metadata["toolName"] == "" || row.RunID == "" {
return errors.New("project work source: transient result lacks trusted provenance")
}
run, err := st.GetRunByID(core.ID(row.RunID))
if err != nil || string(run.Owner) != row.Owner || string(run.ProjectID) != row.ProjectID || string(run.ConversationID) != row.ConversationID {
return errors.New("project work source: transient run binding unavailable")
}
return nil
}
// preflightProjectMessage delivers this committed message's pending parts
// inline, then asks RDM to prove that every exact source part was analyzed for
// the pending action. A durable intake ACK alone never permits the action.
func preflightProjectMessage(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), messageID, action string,
) (extension.ProjectWorkDecision, error) {
pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "project source awaiting analysis"}
provider := slot.get()
if provider == nil || work == nil {
return pending, errors.New("extension: trusted project work observer unavailable")
}
rows, err := st.ProjectMessageObservations(messageID)
if err != nil {
return pending, err
}
if len(rows) == 0 {
return pending, errors.New("extension: committed project message has no source manifest")
}
if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows); err != nil {
return pending, err
}
rows, err = st.ProjectMessageObservations(messageID)
if err != nil {
return pending, err
}
manifest := make([]extension.ProjectWorkSource, 0, len(rows))
for _, row := range rows {
if row.State != "accepted" || row.Delivery != "complete" {
return pending, nil
}
manifest = append(manifest, extension.ProjectWorkSource{
EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID,
SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex,
})
}
first := rows[0]
for _, row := range rows[1:] {
if row.Owner != first.Owner || row.ProjectID != first.ProjectID || row.ConversationID != first.ConversationID {
return pending, errors.New("extension: mixed project source manifest")
}
}
conv, err := st.GetConversation(first.Owner, first.ConversationID)
if err != nil || string(conv.ProjectID) != first.ProjectID {
return pending, errors.New("extension: project binding changed before preflight")
}
allowed, err := work(ctx, first.Owner, conv.ProjectID)
if err != nil || !allowed {
return pending, errors.New("extension: current project work denied before preflight")
}
raw, err := json.Marshal(manifest)
if err != nil {
return pending, err
}
watermark := fmt.Sprintf("%x", sha256.Sum256(raw))
decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{
Actor: first.Owner, Owner: first.Owner, ProjectID: conv.ProjectID,
ConversationID: conv.ID, RunID: core.ID(first.RunID), Action: action,
Manifest: manifest, SourceWatermark: watermark,
})
if err != nil {
return pending, err
}
switch decision.State {
case extension.ProjectWorkProceed:
if decision.DecisionID == "" || decision.SourceWatermark != watermark {
return pending, errors.New("extension: project preflight decision did not bind current sources")
}
case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied:
// A non-proceed decision is a real result, never an implied approval.
default:
return pending, errors.New("extension: invalid project preflight decision")
}
return decision, nil
}
// preflightProjectRun binds all committed user, retrieval, tool, and visible
// analysis parts of one run before a later model or business action consumes
// them. Missing/partial parts keep the action pending. The domain provider
// decides whether the complete source set contains a blocking risk.
func preflightProjectRun(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot,
work func(context.Context, string, core.ID) (bool, error), verify func(context.Context, store.ProjectWorkObservationRow) error,
runID, action string,
) (extension.ProjectWorkDecision, error) {
pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "run sources awaiting analysis"}
provider := slot.get()
if provider == nil || work == nil || verify == nil {
return pending, errors.New("extension: trusted project run observer unavailable")
}
run, err := st.GetRunByID(core.ID(runID))
if err != nil || run.ProjectID == "" {
return pending, errors.New("extension: trusted project run unavailable")
}
rows, err := st.ProjectRunObservations(runID)
if err != nil {
return pending, err
}
if len(rows) == 0 {
return pending, errors.New("extension: project run has no trusted source manifest")
}
if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows, verify); err != nil {
return pending, err
}
rows, err = st.ProjectRunObservations(runID)
if err != nil {
return pending, err
}
manifest := make([]extension.ProjectWorkSource, 0, len(rows))
for _, row := range rows {
if row.State != "accepted" || row.Delivery != "complete" {
return pending, nil
}
if row.RunID != runID || row.Owner != string(run.Owner) || row.ProjectID != string(run.ProjectID) || row.ConversationID != string(run.ConversationID) {
return pending, errors.New("extension: mixed run source manifest")
}
manifest = append(manifest, extension.ProjectWorkSource{
EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID,
SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex,
})
}
conv, err := st.GetConversation(string(run.Owner), string(run.ConversationID))
if err != nil || conv.ProjectID != run.ProjectID {
return pending, errors.New("extension: run project binding changed before preflight")
}
allowed, err := work(ctx, string(run.Owner), run.ProjectID)
if err != nil || !allowed {
return pending, errors.New("extension: current run project work denied")
}
raw, err := json.Marshal(manifest)
if err != nil {
return pending, err
}
watermark := fmt.Sprintf("%x", sha256.Sum256(raw))
decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{
Actor: string(run.Owner), Owner: string(run.Owner), ProjectID: run.ProjectID,
ConversationID: run.ConversationID, RunID: run.ID, Action: action,
Manifest: manifest, SourceWatermark: watermark,
})
if err != nil {
return pending, err
}
switch decision.State {
case extension.ProjectWorkProceed:
if decision.DecisionID == "" || decision.SourceWatermark != watermark {
return pending, errors.New("extension: run preflight decision did not bind current sources")
}
case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied:
default:
return pending, errors.New("extension: invalid project run preflight decision")
}
return decision, nil
}