187 lines
7.7 KiB
Go
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
|
|
}
|