Files
knowledge-governance/internal/platform/postgres/test_publications.go
姚章浩 acf053faf3 feat(release): 建批时冻结 + 验收通过自动批准 —— 把"每份文档 8 步"砍掉两步并去掉两次卡死
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。

P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
  (同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
  "谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
  前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
  "同一计划复用同一版本"永远不命中。

P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
  口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
  只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
  更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。

顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。

测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
2026-10-10 15:05:20 +08:00

826 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
var (
ErrInvalidAcceptanceResult = errors.New("invalid acceptance result")
ErrInvalidErrorCode = errors.New("invalid test publication error code")
ErrPublicationNotUploadable = errors.New("test publication is not uploadable")
ErrPublicationNotRetryable = errors.New("test publication is not retryable")
ErrPublicationNotFound = errors.New("test publication not found")
ErrPublicationStateConflict = errors.New("test publication state conflict")
)
type PublicationForUpload struct {
ID string
SourceItemVersionID string
TestKBID string
KnowledgeID string
IngestState governance.IngestState
ProcessingState governance.ProcessingState
TargetScope governance.Visibility
ContentObjectID string
SHA256 string
SizeBytes int64
MIME string
ValidatedFormat string
LocalSpoolRef string
Title string
}
type PublicationForPoll struct {
ID string
TestKBID string
KnowledgeID string
ProcessingState governance.ProcessingState
PollSequence int
LastRemoteStatus string
LastRemoteUpdatedAt *time.Time
ProcessingStartedAt *time.Time
LastObservedAt *time.Time
LastProgressAt *time.Time
}
type PublicationForValidation struct {
ID string
TestKBID string
KnowledgeID string
// TargetScope 决定这条知识发到哪个生产库(对内/对外)。自动批准策略依赖它:
// 默认只自动批准"对内","对外"仍要人工。
TargetScope string
}
type LegacyVerificationCandidate struct {
ID string
TestKBID string
KnowledgeID string
}
const loadPublicationForUploadSQL = `
SELECT p.id, p.source_item_version_id, p.test_kb_id, p.knowledge_id,
p.ingest_state, p.processing_state, p.target_scope,
co.id, co.sha256, co.size_bytes, co.mime, co.validated_format,
co.local_spool_ref, si.title
FROM test_publications AS p
JOIN source_item_versions AS siv ON siv.id = p.source_item_version_id
JOIN content_objects AS co ON co.id = siv.content_object_id
JOIN source_items AS si ON si.id = siv.source_item_id
WHERE p.id = $1
AND p.ingest_state IN ('pending', 'uploading')
AND p.processing_state = 'pending'
AND p.knowledge_id = ''
AND siv.content_state = 'verified'
AND co.content_state = 'verified'
AND co.local_spool_ref <> ''
AND co.size_bytes > 0
`
const loadPublicationForPollSQL = `
SELECT id, test_kb_id, knowledge_id, processing_state, poll_sequence,
last_remote_status, last_remote_updated_at, processing_started_at,
last_observed_at, updated_at
FROM test_publications
WHERE id = $1
`
const loadPublicationForValidationSQL = `
SELECT id, test_kb_id, knowledge_id, target_scope
FROM test_publications
WHERE id = $1
AND ingest_state = 'uploaded'
AND processing_state = 'completed'
AND knowledge_id <> ''
`
const listLegacyVerificationCandidatesSQL = `
SELECT p.id, p.test_kb_id, p.knowledge_id
FROM test_publications p
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
JOIN source_items si ON si.id = siv.source_item_id
JOIN source_connections sc ON sc.id = si.source_connection_id
WHERE p.knowledge_id <> ''
AND sc.source_type = 'pp'
ORDER BY p.id
`
const countTestPublicationsSQL = `
SELECT COUNT(*)
FROM test_publications
WHERE ($1 = '' OR processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR target_scope = $2)
AND ($3::boolean IS NOT TRUE OR EXISTS (
SELECT 1 FROM source_item_versions siv
JOIN source_items si ON si.id = siv.source_item_id
WHERE siv.id = test_publications.source_item_version_id
AND test_publications.superseded_at IS NULL
AND test_publications.knowledge_id <> ''
AND test_publications.processing_state = 'completed'
AND test_publications.retrieval_state NOT IN ('disabling','disabled')
AND si.policy_state = 'blocked'))
`
const listTestPublicationsSQL = `
SELECT p.id, si.title, p.ingest_state, p.processing_state, p.retrieval_state,
p.target_scope, COALESCE(p.change_set_id, ''),
CASE WHEN EXISTS (
SELECT 1
FROM test_publications other
JOIN source_item_versions other_version ON other_version.id=other.source_item_version_id
WHERE other.id<>p.id AND other_version.source_item_id=siv.source_item_id
) THEN 'updated' ELSE 'added' END AS change_type,
-- Both flags compare against a LEFT-JOINed content object, so the raw
-- expression is NULL (not false) for a row whose artifact was never
-- fetched; a NULL here makes the whole list fail to scan into a Go bool
-- and 500s the endpoint. Fold NULL to false.
COALESCE((p.ingest_state='pending' AND p.processing_state IN ('pending','planned')
AND p.retrieval_state='unpublished' AND p.knowledge_id=''
AND co.content_state='verified' AND co.local_spool_ref<>'' AND co.size_bytes>0
AND NOT EXISTS (SELECT 1 FROM processing_confirmations confirmation WHERE confirmation.publication_id=p.id)
AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type='test.upload' AND job.payload->>'publication_id'=p.id)), false) AS can_start,
p.retry_count, p.last_error,
COALESCE(((p.processing_state IN ('failed', 'stalled')
OR (p.ingest_state = 'failed' AND p.processing_state = 'pending'))
AND co.content_state = 'verified'
AND co.local_spool_ref <> ''), false) AS can_retry,
p.updated_at
FROM test_publications p
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
JOIN source_items si ON si.id = siv.source_item_id
LEFT JOIN content_objects co ON co.id = siv.content_object_id
WHERE ($1 = '' OR p.processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR p.target_scope = $2) AND p.superseded_at IS NULL
-- removal=required:被禁止导入但仍在测试库存活(口径与监控「需下架」车道一致)
AND ($5::boolean IS NOT TRUE OR (p.knowledge_id <> '' AND p.processing_state = 'completed'
AND p.retrieval_state NOT IN ('disabling','disabled') AND si.policy_state = 'blocked'))
ORDER BY p.updated_at DESC, p.id
LIMIT $3 OFFSET $4
`
const listAcceptanceResultsSQL = `
SELECT check_type, state, policy_version, evidence_summary, observed_at
FROM acceptance_results
WHERE publication_id = $1
ORDER BY observed_at DESC
`
const loadRetryStateSQL = `
SELECT p.processing_state, p.ingest_state,
EXISTS (
SELECT 1 FROM source_item_versions siv
JOIN content_objects co ON co.id = siv.content_object_id
WHERE siv.id = p.source_item_version_id
AND co.content_state = 'verified'
AND co.local_spool_ref <> ''
)
FROM test_publications p
WHERE p.id = $1
`
const retryPublicationSQL = `
WITH updated AS (
UPDATE test_publications
SET ingest_state = 'pending',
processing_state = 'pending',
knowledge_id = '',
poll_sequence = 0,
last_remote_status = '',
last_remote_updated_at = NULL,
last_observed_at = NULL,
processing_started_at = NULL,
completed_at = NULL,
stalled_at = NULL,
last_error = '',
retry_count = retry_count + 1,
updated_at = $2
WHERE id = $1
AND (
processing_state IN ('failed', 'stalled')
OR (ingest_state = 'failed' AND processing_state = 'pending')
)
RETURNING id, retry_count
)
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $3, 'test.upload', $4::jsonb, 'pending', 0, $2, 5,
'test-retry:' || updated.id || ':' || updated.retry_count, $2, $2
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
`
const markUploadingSQL = `
UPDATE test_publications
SET ingest_state = 'uploading',
last_error = '',
updated_at = $2
WHERE id = $1
AND ingest_state IN ('pending', 'uploading')
AND processing_state = 'pending'
AND knowledge_id = ''
`
const recordUploadAndSchedulePollSQL = `
WITH updated AS (
UPDATE test_publications
SET knowledge_id = $2,
ingest_state = 'uploaded',
processing_state = 'pending',
poll_sequence = 1,
last_remote_status = 'pending',
processing_started_at = COALESCE(processing_started_at, $3),
last_observed_at = $3,
last_error = '',
updated_at = $3
WHERE id = $1
AND processing_state = 'pending'
AND (
(ingest_state = 'uploading' AND knowledge_id = '' AND poll_sequence = 0)
OR (ingest_state = 'uploaded' AND knowledge_id = $2 AND poll_sequence = 1)
)
RETURNING poll_sequence, retry_count
), enqueued AS (
-- Orchestration identity is (publication, round, sequence): retry_count is
-- bumped by every manual retry, so poll/validate keys can never collide
-- with the consumed first round's completed rows (jobs.idempotency_key is
-- globally unique and ON CONFLICT DO NOTHING would silently swallow the
-- next round's schedules).
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT 'job-' || substring(md5($1 || '#test.poll#' || updated.retry_count || '#1') from 1 for 32),
'test.poll', $4::jsonb, 'pending', $5, $6, $7,
'test-poll:' || $1 || ':' || updated.retry_count || ':1', $3, $3
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordRemoteStatusAndScheduleNextPollSQL = `
WITH updated AS (
UPDATE test_publications
SET processing_state = $3::varchar,
last_remote_status = $3::varchar,
last_remote_updated_at = $4,
last_observed_at = $5,
updated_at = CASE
WHEN last_remote_status IS DISTINCT FROM $3::varchar
OR last_remote_updated_at IS DISTINCT FROM $4
THEN $5
ELSE updated_at
END,
processing_started_at = COALESCE(processing_started_at, $5),
poll_sequence = $2 + 1
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
RETURNING poll_sequence, retry_count
), enqueued AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT 'job-' || substring(md5($1 || '#test.poll#' || updated.retry_count || '#' || updated.poll_sequence) from 1 for 32),
'test.poll', $6::jsonb, 'pending', $7, $8, $9,
'test-poll:' || $1 || ':' || updated.retry_count || ':' || updated.poll_sequence, $5, $5
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordCompletedAndScheduleValidationSQL = `
WITH updated AS (
UPDATE test_publications
SET processing_state = 'completed',
last_remote_status = 'completed',
last_remote_updated_at = $3,
last_observed_at = $4,
completed_at = $4,
parser_generation = $7,
index_generation = $8,
last_error = '',
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
RETURNING poll_sequence, retry_count
), enqueued AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT 'job-' || substring(md5($1 || '#test.validate#' || updated.retry_count || '#' || updated.poll_sequence) from 1 for 32),
'test.validate', $5::jsonb, 'pending', $6, $9, $10,
'test-validate:' || $1 || ':' || updated.retry_count || ':' || updated.poll_sequence, $4, $4
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordProcessingFailureSQL = `
UPDATE test_publications
SET processing_state = 'failed',
last_remote_status = 'failed',
last_observed_at = $4,
last_error = $3,
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
`
const recordUploadFailureSQL = `
UPDATE test_publications
SET ingest_state = 'failed',
processing_state = 'failed',
last_error = $2,
updated_at = $3
WHERE id = $1
AND ingest_state IN ('pending', 'uploading')
AND processing_state = 'pending'
AND knowledge_id = ''
`
const markStalledSQL = `
UPDATE test_publications
SET processing_state = 'stalled',
last_observed_at = $4,
last_error = 'processing_stalled',
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
AND updated_at <= $3
`
const appendAcceptanceResultSQL = `
INSERT INTO acceptance_results (
id, publication_id, run_id, check_type, state,
policy_version, evidence_summary, observed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
ON CONFLICT (id) DO NOTHING
`
func (s *Store) LoadPublicationForUpload(ctx context.Context, publicationID string) (PublicationForUpload, error) {
var candidate PublicationForUpload
var ingestState string
var processingState string
err := s.db.QueryRow(ctx, loadPublicationForUploadSQL, publicationID).Scan(
&candidate.ID,
&candidate.SourceItemVersionID,
&candidate.TestKBID,
&candidate.KnowledgeID,
&ingestState,
&processingState,
&candidate.TargetScope,
&candidate.ContentObjectID,
&candidate.SHA256,
&candidate.SizeBytes,
&candidate.MIME,
&candidate.ValidatedFormat,
&candidate.LocalSpoolRef,
&candidate.Title,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForUpload{}, ErrPublicationNotUploadable
}
if err != nil {
return PublicationForUpload{}, fmt.Errorf("load test publication for upload: %w", err)
}
candidate.IngestState = governance.IngestState(ingestState)
candidate.ProcessingState = governance.ProcessingState(processingState)
return candidate, nil
}
func (s *Store) LoadPublicationForPoll(ctx context.Context, publicationID string) (PublicationForPoll, error) {
var publication PublicationForPoll
var processingState string
err := s.db.QueryRow(ctx, loadPublicationForPollSQL, publicationID).Scan(
&publication.ID,
&publication.TestKBID,
&publication.KnowledgeID,
&processingState,
&publication.PollSequence,
&publication.LastRemoteStatus,
&publication.LastRemoteUpdatedAt,
&publication.ProcessingStartedAt,
&publication.LastObservedAt,
&publication.LastProgressAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForPoll{}, ErrPublicationNotFound
}
if err != nil {
return PublicationForPoll{}, fmt.Errorf("load test publication for poll: %w", err)
}
publication.ProcessingState = governance.ProcessingState(processingState)
return publication, nil
}
func (s *Store) LoadPublicationForValidation(ctx context.Context, publicationID string) (PublicationForValidation, error) {
var publication PublicationForValidation
err := s.db.QueryRow(ctx, loadPublicationForValidationSQL, publicationID).Scan(
&publication.ID,
&publication.TestKBID,
&publication.KnowledgeID,
&publication.TargetScope,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForValidation{}, ErrPublicationStateConflict
}
if err != nil {
return PublicationForValidation{}, fmt.Errorf("load test publication for validation: %w", err)
}
return publication, nil
}
func (s *Store) ListLegacyVerificationCandidates(ctx context.Context) ([]LegacyVerificationCandidate, error) {
rows, err := s.db.Query(ctx, listLegacyVerificationCandidatesSQL)
if err != nil {
return nil, fmt.Errorf("list legacy verification candidates: %w", err)
}
defer rows.Close()
var candidates []LegacyVerificationCandidate
for rows.Next() {
var candidate LegacyVerificationCandidate
if err := rows.Scan(&candidate.ID, &candidate.TestKBID, &candidate.KnowledgeID); err != nil {
return nil, fmt.Errorf("scan legacy verification candidate: %w", err)
}
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy verification candidates: %w", err)
}
return candidates, nil
}
func (s *Store) ListTestPublications(ctx context.Context, filter governance.TestPublicationFilter) (governance.TestPublicationPage, error) {
var page governance.TestPublicationPage
if filter.TargetScope != "" && filter.TargetScope != string(governance.VisibilityInternal) && filter.TargetScope != string(governance.VisibilityExternal) {
return governance.TestPublicationPage{}, errors.New("invalid test publication target scope")
}
if err := s.db.QueryRow(ctx, countTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.RemovalRequired).Scan(&page.Total); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("count test publications: %w", err)
}
rows, err := s.db.Query(ctx, listTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.Limit, filter.Offset, filter.RemovalRequired)
if err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("list test publications: %w", err)
}
defer rows.Close()
page.Items = make([]governance.TestPublication, 0)
for rows.Next() {
var item governance.TestPublication
if err := rows.Scan(
&item.ID,
&item.Title,
&item.IngestState,
&item.ProcessingState,
&item.RetrievalState,
&item.TargetScope,
&item.ChangeSetID,
&item.ChangeType,
&item.CanStart,
&item.RetryCount,
&item.LastError,
&item.CanRetry,
&item.UpdatedAt,
); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("scan test publication: %w", err)
}
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("iterate test publications: %w", err)
}
return page, nil
}
func (s *Store) ListAcceptanceResults(ctx context.Context, publicationID string) ([]governance.AcceptanceRecord, error) {
rows, err := s.db.Query(ctx, listAcceptanceResultsSQL, publicationID)
if err != nil {
return nil, fmt.Errorf("list acceptance results: %w", err)
}
defer rows.Close()
results := make([]governance.AcceptanceRecord, 0)
for rows.Next() {
var record governance.AcceptanceRecord
if err := rows.Scan(&record.CheckType, &record.State, &record.PolicyVersion, &record.EvidenceSummary, &record.ObservedAt); err != nil {
return nil, fmt.Errorf("scan acceptance result: %w", err)
}
results = append(results, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate acceptance results: %w", err)
}
return results, nil
}
func (s *Store) RetryPublicationWithOperation(ctx context.Context, publicationID string, now time.Time, operationID string) (bool, error) {
fingerprint, err := operationFingerprint(struct {
PublicationID string `json:"publication_id"`
}{publicationID})
if err != nil {
return false, err
}
raw, replayed, err := s.executeOperation(ctx, "test.publication.retry", operationID, fingerprint, now, func(tx pgx.Tx) (json.RawMessage, error) {
if err := NewStore(tx).RetryPublication(ctx, publicationID, now); err != nil {
return nil, err
}
return json.Marshal(map[string]string{"id": publicationID, "status": "retry_queued"})
})
if err != nil {
return false, err
}
if !json.Valid(raw) {
return false, errors.New("invalid stored retry result")
}
return replayed, nil
}
func (s *Store) RetryPublication(ctx context.Context, publicationID string, now time.Time) error {
var processingState, ingestState string
var spoolAvailable bool
err := s.db.QueryRow(ctx, loadRetryStateSQL, publicationID).Scan(&processingState, &ingestState, &spoolAvailable)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPublicationNotFound
}
if err != nil {
return fmt.Errorf("load test publication retry state: %w", err)
}
legacyUploadFailed := ingestState == string(governance.IngestFailed) && processingState == string(governance.ProcessingPending)
if processingState != string(governance.ProcessingFailed) && processingState != string(governance.ProcessingStalled) && !legacyUploadFailed {
return ErrPublicationStateConflict
}
if !spoolAvailable {
return ErrPublicationNotRetryable
}
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
}{PublicationID: publicationID})
if err != nil {
return errors.New("encode retry upload job")
}
jobID := orchestrationJobID("test.upload", publicationID, fmt.Sprint(now.UnixNano()))
tag, err := s.db.Exec(ctx, retryPublicationSQL, publicationID, now, jobID, payload)
if err != nil {
return fmt.Errorf("retry test publication: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) MarkUploading(ctx context.Context, publicationID string, now time.Time) error {
tag, err := s.db.Exec(ctx, markUploadingSQL, publicationID, now)
if err != nil {
return fmt.Errorf("mark test publication uploading: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) RecordUploadAndSchedulePoll(ctx context.Context, publicationID, knowledgeID string, now, nextAt time.Time) (int, error) {
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
Sequence int `json:"sequence"`
}{PublicationID: publicationID, Sequence: 1})
if err != nil {
return 0, errors.New("encode first test poll job")
}
var sequence int
err = s.db.QueryRow(
ctx,
recordUploadAndSchedulePollSQL,
publicationID,
knowledgeID,
now,
payload,
0,
nextAt,
5,
).Scan(&sequence)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPublicationStateConflict
}
if err != nil {
return 0, fmt.Errorf("record test upload and schedule poll: %w", err)
}
return sequence, nil
}
func (s *Store) RecordRemoteStatusAndScheduleNextPoll(
ctx context.Context,
publicationID string,
sequence int,
status governance.ProcessingState,
remoteUpdatedAt *time.Time,
observedAt time.Time,
nextAt time.Time,
) (int, error) {
if sequence < 1 || (status != governance.ProcessingPending && status != governance.ProcessingProcessing && status != governance.ProcessingFinalizing) {
return 0, ErrPublicationStateConflict
}
nextSequence := sequence + 1
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
Sequence int `json:"sequence"`
}{PublicationID: publicationID, Sequence: nextSequence})
if err != nil {
return 0, errors.New("encode next test poll job")
}
var storedSequence int
err = s.db.QueryRow(
ctx,
recordRemoteStatusAndScheduleNextPollSQL,
publicationID,
sequence,
string(status),
remoteUpdatedAt,
observedAt,
payload,
0,
nextAt,
5,
).Scan(&storedSequence)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPublicationStateConflict
}
if err != nil {
return 0, fmt.Errorf("record remote status and schedule next test poll: %w", err)
}
return storedSequence, nil
}
func (s *Store) RecordCompletedAndScheduleValidation(
ctx context.Context,
publicationID string,
sequence int,
remoteUpdatedAt *time.Time,
observedAt time.Time,
) error {
if sequence < 1 {
return ErrPublicationStateConflict
}
// The release gate requires a parse revision for every completed
// publication. WeKnora reports a single terminal update timestamp for the
// parse+index pipeline, so both generations are stamped from it (falling
// back to the observation time) together with the pinned WeKnora contract
// identity. This keeps manual/seeded publications releasable without
// pretending the document was parsed by a different pipeline.
generationAt := observedAt.UTC()
if remoteUpdatedAt != nil && !remoteUpdatedAt.IsZero() {
generationAt = remoteUpdatedAt.UTC()
}
generation := "weknora-v0.7.1-m74@" + generationAt.Format(time.RFC3339)
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
}{PublicationID: publicationID})
if err != nil {
return errors.New("encode test validation job")
}
var storedSequence int
err = s.db.QueryRow(
ctx,
recordCompletedAndScheduleValidationSQL,
publicationID,
sequence,
remoteUpdatedAt,
observedAt,
payload,
0,
generation,
generation,
observedAt,
5,
).Scan(&storedSequence)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPublicationStateConflict
}
if err != nil {
return fmt.Errorf("record test completion and schedule validation: %w", err)
}
return nil
}
func (s *Store) RecordProcessingFailure(ctx context.Context, publicationID string, sequence int, code string, observedAt time.Time) error {
if sequence < 1 || !validErrorCode(code) {
return ErrInvalidErrorCode
}
tag, err := s.db.Exec(ctx, recordProcessingFailureSQL, publicationID, sequence, code, observedAt)
if err != nil {
return fmt.Errorf("record test processing failure: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) RecordUploadFailure(ctx context.Context, publicationID, code string, observedAt time.Time) error {
if !validErrorCode(code) {
return ErrInvalidErrorCode
}
tag, err := s.db.Exec(ctx, recordUploadFailureSQL, publicationID, code, observedAt)
if err != nil {
return fmt.Errorf("record test upload failure: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) MarkStalled(ctx context.Context, publicationID string, sequence int, cutoff, observedAt time.Time) error {
if sequence < 1 || cutoff.After(observedAt) {
return ErrPublicationStateConflict
}
tag, err := s.db.Exec(ctx, markStalledSQL, publicationID, sequence, cutoff, observedAt)
if err != nil {
return fmt.Errorf("mark test publication stalled: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) AppendAcceptanceResult(ctx context.Context, result governance.AcceptanceResult) error {
if !validShortID(result.ID) || !validShortID(result.PublicationID) || !validShortID(result.RunID) ||
!validErrorCode(result.CheckType) || !validErrorCode(result.PolicyVersion) || !result.State.Valid() ||
result.ObservedAt.IsZero() || len(result.EvidenceSummary) == 0 || len(result.EvidenceSummary) > 4096 || !json.Valid(result.EvidenceSummary) {
return ErrInvalidAcceptanceResult
}
var evidence map[string]any
if err := json.Unmarshal(result.EvidenceSummary, &evidence); err != nil || evidence == nil {
return ErrInvalidAcceptanceResult
}
_, err := s.db.Exec(
ctx,
appendAcceptanceResultSQL,
result.ID,
result.PublicationID,
result.RunID,
result.CheckType,
string(result.State),
result.PolicyVersion,
[]byte(result.EvidenceSummary),
result.ObservedAt,
)
if err != nil {
return fmt.Errorf("append acceptance result: %w", err)
}
return nil
}
func validShortID(value string) bool {
return len(value) > 0 && len(value) <= 36 && !strings.ContainsAny(value, " /\\ \r\n")
}
func validErrorCode(code string) bool {
if len(code) == 0 || len(code) > 64 {
return false
}
for _, character := range code {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || strings.ContainsRune("_.:-", character) {
continue
}
return false
}
return true
}
func orchestrationJobID(parts ...string) string {
digest := sha256.New()
for _, part := range parts {
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(part))
}
return "job-" + hex.EncodeToString(digest.Sum(nil))[:32]
}