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 }