package postgres import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "knowledge-governance/internal/governance" ) // ErrReleaseAuditChainNotFound is returned for an unknown execution id. var ErrReleaseAuditChainNotFound = errors.New("release audit chain not found") const releaseAuditChainHeadSQL = ` SELECT e.id, e.operation, e.state, e.failure_level, COALESCE(e.release_batch_id,''), COALESCE(b.name,''), COALESCE(b.created_by,''), COALESCE(b.sealed_by,''), COALESCE(e.authorized_by,''), COALESCE(e.release_change_set_id::text,''), COALESCE(rcs.name,''), COALESCE(rcs.number,0), COALESCE(rcs.state,''), COALESCE(rcs.created_by,''), COALESCE(rcs.confirmed_by,''), rcs.confirmed_at, COALESCE(rcs.manifest_sha256,''), COALESCE(e.prior_execution_id,''), COALESCE((SELECT rb.id FROM release_executions AS rb WHERE rb.prior_execution_id = e.id AND rb.operation = 'rollback' ORDER BY rb.created_at DESC LIMIT 1),'') FROM release_executions AS e LEFT JOIN release_batches AS b ON b.id = e.release_batch_id LEFT JOIN release_change_sets AS rcs ON rcs.id = e.release_change_set_id WHERE e.id = $1 ` const releaseAuditSnapshotLinksSQL = ` SELECT snap.id, COALESCE(conn.name,''), snap.node_count, snap.file_count, snap.observed_at FROM release_change_set_snapshot_links AS link JOIN source_catalog_snapshots AS snap ON snap.id = link.snapshot_id LEFT JOIN source_connections AS conn ON conn.id = snap.source_connection_id WHERE link.change_set_id = $1 ORDER BY snap.id ` const releaseAuditCatalogLinksSQL = ` SELECT cset.id, cset.state, cset.add_count, cset.update_count, cset.move_count, cset.delete_count FROM release_change_set_catalog_links AS link JOIN source_catalog_change_sets AS cset ON cset.id = link.catalog_change_set_id WHERE link.change_set_id = $1 ORDER BY cset.id ` const releaseAuditItemsSQL = ` SELECT COALESCE(batch_item.publication_id, item.publication_id), COALESCE(version_title.title, ''), COALESCE(item.action, batch_item.action), COALESCE(item.state, 'pending'), COALESCE(batch_item.review_id, ''), COALESCE(batch_item.acceptance_run_id, ''), COALESCE(version.source_item_id, ''), COALESCE(batch_item.source_item_version_id, ''), COALESCE(item.production_revision, ''), COALESCE(item.error_code, '') FROM release_item_executions AS item JOIN test_publications AS publication ON publication.id = item.publication_id JOIN source_item_versions AS version ON version.id = publication.source_item_version_id JOIN source_items AS version_title ON version_title.id = version.source_item_id LEFT JOIN release_batch_items AS batch_item ON batch_item.release_batch_id = $2 AND batch_item.publication_id = item.publication_id WHERE item.release_execution_id = $1 ORDER BY item.id ` // GetReleaseAuditChain projects the complete R6 evidence chain for one // execution: execution -> batch -> release change set -> bound source // snapshots and catalog change sets -> per-item review/acceptance/source // identity, plus the compensating rollback row when one exists. Every link is // read through real foreign keys (migration 30), not denormalized JSON. Only // safe identifiers, labels, counts and timestamps are selected — never bundle // paths, credentials, base URLs, or signed object URLs. func (s *Store) GetReleaseAuditChain(ctx context.Context, executionID string) (governance.ReleaseAuditChain, error) { if !validShortID(executionID) { return governance.ReleaseAuditChain{}, ErrReleaseAuditChainNotFound } var chain governance.ReleaseAuditChain err := s.db.QueryRow(ctx, releaseAuditChainHeadSQL, executionID).Scan( &chain.ExecutionID, &chain.Operation, &chain.State, &chain.FailureLevel, &chain.BatchID, &chain.BatchName, &chain.CreatedBy, &chain.SealedBy, &chain.AuthorizedBy, &chain.ReleaseChangeSetID, &chain.ChangeSetName, &chain.ChangeSetNumber, &chain.ChangeSetState, &chain.ChangeSetCreatedBy, &chain.ChangeSetConfirmedBy, &chain.ChangeSetConfirmedAt, &chain.ManifestSHA256, &chain.PriorExecutionID, &chain.RollbackExecutionID, ) if errors.Is(err, pgx.ErrNoRows) { return governance.ReleaseAuditChain{}, ErrReleaseAuditChainNotFound } if err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("load release audit chain: %w", err) } chain.Snapshots = []governance.AuditSnapshot{} chain.CatalogChangeSets = []governance.AuditCatalogSet{} chain.Items = []governance.AuditChainItem{} if chain.ReleaseChangeSetID != "" { rows, err := s.db.Query(ctx, releaseAuditSnapshotLinksSQL, chain.ReleaseChangeSetID) if err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("list release snapshot links: %w", err) } for rows.Next() { var snap governance.AuditSnapshot if err := rows.Scan(&snap.ID, &snap.SourceName, &snap.NodeCount, &snap.FileCount, &snap.ObservedAt); err != nil { rows.Close() return governance.ReleaseAuditChain{}, fmt.Errorf("scan release snapshot link: %w", err) } chain.Snapshots = append(chain.Snapshots, snap) } if err := rows.Err(); err != nil { rows.Close() return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release snapshot links: %w", err) } rows.Close() rows, err = s.db.Query(ctx, releaseAuditCatalogLinksSQL, chain.ReleaseChangeSetID) if err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("list release catalog links: %w", err) } for rows.Next() { var set governance.AuditCatalogSet if err := rows.Scan(&set.ID, &set.State, &set.AddCount, &set.UpdateCount, &set.MoveCount, &set.DeleteCount); err != nil { rows.Close() return governance.ReleaseAuditChain{}, fmt.Errorf("scan release catalog link: %w", err) } chain.CatalogChangeSets = append(chain.CatalogChangeSets, set) } if err := rows.Err(); err != nil { rows.Close() return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release catalog links: %w", err) } rows.Close() } rows, err := s.db.Query(ctx, releaseAuditItemsSQL, executionID, chain.BatchID) if err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("list release audit items: %w", err) } defer rows.Close() for rows.Next() { var item governance.AuditChainItem if err := rows.Scan(&item.PublicationID, &item.Title, &item.Action, &item.ItemState, &item.ReviewID, &item.AcceptanceRunID, &item.SourceItemID, &item.SourceVersionID, &item.ProductionRevision, &item.ErrorCode); err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("scan release audit item: %w", err) } chain.Items = append(chain.Items, item) } if err := rows.Err(); err != nil { return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release audit items: %w", err) } return chain, nil }