Files
Agent-System-Backend/internal/store/project_audit_links.go
T
agent-exec-transport 97f60c1df6
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
2026-10-22 03:41:15 +00:00

187 lines
7.7 KiB
Go

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
}
// 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) {
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.
// The published binding is immutable: a row that was deleted, or edited after
// publication (state, operation, owner, or the stored envelope), no longer
// resolves as this link. A read must never project something the domain
// verifier never vouched for, so drift is refused instead of re-projected.
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
AND a.state='committed' AND a.operation_id=l.operation_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
}
// The link was published only against a committed row owned by link.Owner whose
// envelope carried this operation. Re-check the envelope itself: a payload that
// no longer matches the verified binding is drift, not a new fact.
if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" {
return link, row, ErrConflict
}
return link, row, nil
}