Quality & Governance — Technical Reference
Last verified: Session 220 (02 May 2026) — §5.3 publication-bulk-action endpoint + bulk-approve UX surface (PublicationBulkActionBar / PublicationBulkResultDialog) shipped + spec archived. Pending updates: None
Quality & Governance — Technical Reference
Section titled “Quality & Governance — Technical Reference”Overview
Section titled “Overview”Quality & Governance covers four overlapping but distinct subsystems on the content lifecycle:
- Publication lifecycle (
publication_status) — whether an item is ready for use:draft → in_review → published → archived. §5.2 Phases 1+2+2.5+1f shipped (S201–S202). - Quality score (
quality_score) — composite 0–100 health metric recalculated weekly. §5.5 Phase 1 schema, Phase 2 cron, Phase 3 quality fields, Phase 4 MCP filter widening, Phase 5 cadence-compliance modifier shipped (S200–S208). - Governance review (
governance_review_status) — change-management workflow that asks “has this recent edit been reviewed?” with approve/request_changes/revert actions. Bridged from quality drops and freshness transitions via per-domaingovernance_configpresets. - Content review (
verified_at) — verify/flag model for assessing individual content items at/review. Distinct from governance review (separate UI, separate API, separate action vocabulary).
In addition, Change Reports (/change-reports route, change_reports table)
summarise KB activity
over a period. Auto-generation for users >24h old is gated by an opt-out
preference and a 150-item cost guard.
API Routes
Section titled “API Routes”Governance configuration
Section titled “Governance configuration”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/governance | All | List per-domain governance config rows | app/api/governance/route.ts |
| POST | /api/governance | Admin | Upsert governance config via { domain, preset } (S177 P0-16) | app/api/governance/route.ts |
The POST handler maps preset (light_touch or strict) to concrete column
values via PRESET_VALUES in lib/governance/presets.ts. The seven
underlying columns (posture, timeout_days, quality_score_threshold,
auto_flag_on_quality_drop, auto_flag_on_freshness_transition,
auto_flag_cooldown_days, reviewer_id) are persisted directly so consumers
read columns, not preset.
Governance review (freshness/ownership)
Section titled “Governance review (freshness/ownership)”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/governance/review | All | List items pending governance review; ?count_only=true returns count | app/api/governance/review/route.ts |
| POST | /api/governance/review | Editor+ | Process governance review action (approve / request_changes / revert) | app/api/governance/review/route.ts |
The POST handler enforces governance_review_status ∈ ALLOWED_REVIEW_INPUT_STATUSES
(pending, review_overdue) before processing — see
lib/governance/review-input-statuses.ts. The approve branch also calls
computeNextReviewDate(...) from lib/governance/cadence-renewal.ts to
auto-renew next_review_date when the item has a configured cadence.
Content review (verify/flag)
Section titled “Content review (verify/flag)”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/review/queue | Editor+ | Fetch review queue items with sort/filter/pagination | app/api/review/queue/route.ts |
| POST | /api/review/action | Editor+ | Apply review action (verify/flag/skip/unverify/unflag) | app/api/review/action/route.ts |
| GET/POST/PATCH | /api/review/assignments | POST: Admin; GET/PATCH: Editor+ | Filter-based reviewer assignments | app/api/review/assignments/route.ts |
| GET | /api/review/cadence | Editor+ | Aggregate review-health stats (overdue / due-soon / domain breakdown) | app/api/review/cadence/route.ts |
| GET | /api/review/history | Editor+ | Verification history audit trail | app/api/review/history/route.ts |
| GET | /api/review/stats | Editor+ | Summary stats (totals, overdue, by_domain, by_content_type) | app/api/review/stats/route.ts |
GET /api/review/queue
Section titled “GET /api/review/queue”Query parameters validated by ReviewQueueParamsSchema:
| Param | Type | Required | Description |
|---|---|---|---|
status | enum | No | unverified (default), verified, flagged, draft, or all |
sort | enum | No | created_at (default desc), confidence_asc, quality_score_asc |
limit / offset | int | No | Pagination — limit clamped to 1..100 |
domain | string | No | Repeatable or comma-separated; filters primary_domain |
content_type | string | No | Repeatable or comma-separated |
source_file | string | No | Filter by source file |
source_document_id | string | No | Filter by source document |
assigned_to_me | string | No | Literal 'true' opts in; intersects with active review_assignments rows |
include_overdue | string | No | Literal 'true'; widens unverified filter to OR-include governance_review_status='review_overdue' (S205 WP-E T2) |
The route reads publication_status (NOT governance_review_status) for
draft filtering post-§5.2 Phase 2.5 (T8b).
Publication lifecycle
Section titled “Publication lifecycle”The publication-lifecycle transition is wired into the generic content PATCH route, not a dedicated endpoint:
| Method | Route | Auth | Field | Purpose | File |
|---|---|---|---|---|---|
| PATCH | /api/items/[id] | Editor+ | publication_status | Transition between draft/in_review/published/archived | app/api/items/[id]/route.ts |
| PATCH | /api/items/[id] | Editor+ | next_review_date | Set ISO date when next review is due | app/api/items/[id]/route.ts |
| PATCH | /api/items/[id] | Editor+ | review_cadence_days | Set recurring review cadence (1..1095, NULL clears) | app/api/items/[id]/route.ts |
The publication_status branch:
- Fetches current state (
publication_status,archived_at,archived_by,archive_reason, plus snapshot fields forcontent_history). - Validates the transition against
computeAllowedTransitions(fromStatus, role)per §3.2 + §3.4 — 403 if role has no transitions out of the current state, 409 if the requested target is not in the allowed set. - Applies
applyTransitionSideEffects(...)to stamparchived_at/archived_by/archive_reasonon archive paths and cleararchived_at(preservingarchived_by/archive_reason) on un-archive paths. - Persists with optimistic-concurrency guard
(
.eq('publication_status', fromStatus)) — concurrent state change returns 409 withConcurrent state change detected; please retry.. - Writes a
content_historyrow withchange_type='publication_state'andchange_reason='Transition from {from} to {to}'(+ optional(reason: {archive_reason})suffix). Theauto_version_content_historytrigger fillsversion.
Bulk publication action (§5.3, S220)
Section titled “Bulk publication action (§5.3, S220)”The bulk-approve UX layer on the publication-review queue (tab 6 of /review)
calls a dedicated server-side endpoint that mirrors the per-row PATCH semantics
above for each id, so multi-select approvals retain a distinct audit literal.
| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| POST | /api/review/publication-bulk-action | Editor+ | Bulk transition 'in_review' → 'published' (action=‘approve’) or 'in_review' → 'draft' (action=‘return_to_draft’); cap=50 | app/api/review/publication-bulk-action/route.ts |
Contract (PublicationBulkActionBodySchema at lib/validation/schemas.ts):
ids: 1..50 UUIDs (D-3 ratified S217 close-out — tightened from 100).action:'approve' | 'return_to_draft'.- Rate limit: 20 req/min per user (D-8 ratified S217).
Per-item iteration (sequential, NOT Promise.all) mirrors the per-row PATCH at
app/api/items/[id]/route.ts:199-365 exactly, with two changes:
- Pre-loop fromStatus guard (D-10): any row whose
fromStatus !== 'in_review'returns{ status: 'conflict' }regardless of role/action. Defence-in-depth on top of the.eq('publication_status', 'in_review')UPDATE filter — prevents silent'published' → 'draft'regressions on stale-queue selections. - Audit literal:
change_reason='bulk_approve'(action=‘approve’) or'bulk_return_to_draft'(action=‘return_to_draft’) — distinct from the per-row PATCH phrasing so audit queries can filter bulk-vs-singleton approvals (seedata-entry-points.mdAppendix D).
Response is ALWAYS HTTP 200 with
{ action, totalRequested, successCount, failureCount, results }. Five per-item
statuses: 'success', 'conflict', 'forbidden', 'not_found', 'error'.
Outer try/catch returns 500 on route-level crashes only.
Spec: .planning/.archive/.specs/publication-approval-gate-spec.md §4 + §6 + §7 (archived S220 W4).
Change Reports (digest)
Section titled “Change Reports (digest)”| Method | Route | Auth | Purpose | File |
|---|---|---|---|---|
| GET | /api/change-reports/latest | All | Fetch most recent stored digest | app/api/change-reports/latest/route.ts |
| GET | /api/change-reports/list | All | List historical digests (paginated) | app/api/change-reports/list/route.ts |
| GET | /api/change-reports/[id] | All | Fetch a single digest | app/api/change-reports/[id]/route.ts |
| POST | /api/change-reports/generate | Editor+ | Generate a new Change Report (rate limit 5/min/user) | app/api/change-reports/generate/route.ts |
POST /api/change-reports/generate enforces a cost guard at
lib/ai/change-reports.ts:241–254: if typedItems.length >= CHANGE_REPORT_AUTO_GEN_MAX_ITEMS
(150), it throws an AIServiceError with HTTP 413 + code='CHANGE_REPORT_TOO_MANY_ITEMS'
data: { item_count, max }. The route propagates the structured payload to the client unchanged.
Components
Section titled “Components”Review queue
Section titled “Review queue”| Component | File | Purpose |
|---|---|---|
ReviewQueuePanel | components/review/review-queue-panel.tsx | Main tabular queue UI with item selection + L-key toggle |
ReviewFilters | components/review/review-filters.tsx | Filter popover — status, domain, content_type, source file/doc, assigned-to-me, overdue reviews toggle (S205 WP-E T2) |
ReviewActionBar | components/review/review-action-bar.tsx | verify/flag/skip/unverify/unflag buttons |
ReviewCard | components/review/review-card.tsx | Individual item card |
ReviewProgressBar | components/review/review-progress-bar.tsx | Sticky session progress indicator |
ReviewSessionSummary | components/review/review-session-summary.tsx | Session counter + summary |
ReviewCadenceCard | components/review/review-cadence-card.tsx | Aggregate review-health stats card on /review |
ReviewHistorySection | components/review/review-history-section.tsx | Verification audit trail per item |
AssignmentManager | components/review/assignment-manager.tsx | Filter-based reviewer assignment UI |
PublicationReviewQueue | components/review/PublicationReviewQueue.tsx | Tab 6 of /review — items in publication_status='in_review'. Multi-select checkboxes + bulk action bar (§5.3 S220). Per-row <PublicationReviewActionBar> remains alongside selection state. |
PublicationReviewActionBar | components/review/publication-review-action-bar.tsx | Per-row Approve & publish / Return to draft / Open in editor. Independent of bulk-selection state. |
PublicationBulkActionBar | components/review/publication-bulk-action-bar.tsx | Sticky bulk action bar — counter, “Select all on page”, Clear, Approve selected, Return selected to draft. Mounts when ≥1 row selected; cap=50 enforced client-side with disabled-with-tooltip past the cap (S220 §5.3 D-3). |
PublicationBulkResultDialog | components/review/publication-bulk-result-dialog.tsx | Per-row outcome dialog opened on partial-failure response (failureCount > 0). Falls back to UUID + “(item no longer in queue)” when title lookup misses (S220 §5.3 §7.7). |
Quality, governance, cadence badges
Section titled “Quality, governance, cadence badges”| Component | File | Purpose |
|---|---|---|
VerificationBadge | components/shared/verification-badge.tsx | Binary trust badge — Unverified (amber ShieldAlert) / Verified (green ShieldCheck). WCAG: text label always present. |
QualityBadge | components/shared/quality-badge.tsx | Composite 0–100 score with simplified or component-breakdown tooltip |
QualityScoreBreakdown | components/shared/quality-score-breakdown.tsx | Five-component breakdown — used by metadata sidebar, content card |
QualityScore | components/shared/quality-score.tsx | Detailed form-metadata quality breakdown (separate domain — form drafting) |
GovernanceBadge | components/shared/governance-badge.tsx | governance_review_status icon+label badge — pending/approved/changes_requested/reverted/draft |
ReviewCadenceBadge | components/shared/review-cadence-badge.tsx | Four-band: overdue (red) / due ≤14d (amber) / due ≤30d (muted) / >30d (no badge). Pure helper calculateReviewBand(). |
ReviewCadenceEditor | components/content/review-cadence-editor.tsx | Admin/editor cadence editor — <input type="date"> + 5-preset Select (None / 90 / 182 / 365 / Custom 1..1095) |
GovernanceSection | components/settings/governance-section.tsx | Settings UI for per-domain preset configuration (Light-touch / Strict) |
Change Reports
Section titled “Change Reports”| Component | File | Purpose |
|---|---|---|
ChangeReportView | components/change-reports/change-report-view.tsx | Renders narrative summary + by-domain sections + Current KB Health card |
Current KB Health (OPS-19, S191) is the freshness state-of-the-world snapshot
(fresh/aging/stale/expired counts) — a current-state breakdown, not a
period-specific delta. Lives alongside the period-scoped delta counters in the
same view.
Library Modules
Section titled “Library Modules”| Module | File | Purpose |
|---|---|---|
calculateQualityScore / cadenceCompliancePenalty | lib/quality/quality-score.ts | Composite 0–100 score across 5 components (freshness 30%, confidence 20%, completeness 20%, summary 15%, citations 15%). §5.5 Phase 5 cadence modifier subtracts 0–40 from raw freshness when next_review_date is non-null. |
qaDetection / qaQualityActions | lib/quality/qa-detection.ts, quality-actions.ts | Quality-action repair suggestions surfaced via the MCP where_are_we_exposed tool (which absorbed the former get_quality_actions) |
PRESET_VALUES / inferPreset | lib/governance/presets.ts | Light-touch / Strict preset → concrete column values; inferPreset(posture) for legacy rows |
computeAllowedTransitions / applyTransitionSideEffects / VALID_PUBLICATION_STATUSES | lib/governance/publication-transitions.ts | Pure helper consumed by PATCH route + MCP update_publication_status tool. §3.2 transition matrix × §3.4 role-gate matrix. Drift-pinned by publication-transitions.test.ts. |
computeNextReviewDate | lib/governance/cadence-renewal.ts | Pure helper — auto-renewal arithmetic for the approve branch. GREATEST(currentNextReviewDate, today) + cadenceDays. Returns null when reviewCadenceDays is null. |
ALLOWED_REVIEW_INPUT_STATUSES | lib/governance/review-input-statuses.ts | Allow-list (pending, review_overdue) for governance review action handlers (route + MCP tool) |
changeReportFrequencyLabel | lib/change-reports/change-reports-helpers.ts | Maps frequency to user-facing label (e.g. “Weekly Change Report”) |
generateDigest / CHANGE_REPORT_AUTO_GEN_MAX_ITEMS | lib/ai/change-reports.ts | Anthropic-backed digest generator with 150-item cost guard |
recordPipelineRun | lib/pipeline/record-run.ts | Canonical write to pipeline_runs for all cron jobs (status: 'completed' | 'completed_with_errors' | 'failed') |
Database Tables
Section titled “Database Tables”| Table | Purpose | Key Columns | RLS |
|---|---|---|---|
governance_config | Per-domain governance rules (preset-driven) | domain, preset (light_touch/strict), posture, timeout_days, quality_score_threshold, auto_flag_on_quality_drop, auto_flag_on_freshness_transition, auto_flag_cooldown_days, reviewer_id | Admin: write |
review_assignments | Filter-based reviewer assignments (not per-item) | reviewer_id, assigned_by, assignment_type, filter_domains, filter_content_types, filter_freshness, filter_date_from, filter_date_to, due_date, status | Editor+: read/write |
verification_history | Append-only audit trail of verify/flag/unverify/unflag events | content_item_id, performed_by, action_type, note, performed_at | Insert-only |
ingestion_quality_log | Quality flag log (review_needed, classification, dedup, etc.) | content_item_id, flag_type, severity, details, resolved, resolved_at, resolved_by | Editor+: write |
content_history | Versioned snapshots of content_items | content_item_id, version, title, content, brief, detail, reference, change_summary, change_reason (mandatory per S153), change_type, created_by, created_at | Editor+: insert |
change_reports | Change Reports | frequency, period_start, period_end, item_count, domain_summaries, narrative_summary, generated_at, generated_by, tokens_used | Read: All |
user_notification_prefs | Per-user email + auto-gen preferences | user_id (PK), email_weekly_change_report, email_review_assignments, email_owned_content_flags, auto_generate_change_reports | User-owns-row |
pipeline_runs | Cron + ingest run audit log | pipeline_name, status, started_at, completed_at, items_processed, error_message, result (jsonb), git_sha | Editor+: read |
content_items (cols) | Carries lifecycle state on the canonical row | publication_status (NOT NULL DEFAULT 'published'), governance_review_status, next_review_date, review_cadence_days (CHECK 1..1095), quality_score, previous_quality_score, quality_score_updated_at, verified_at, verified_by, archived_at, archived_by, archive_reason, content_owner_id | Role-based |
Key CHECK constraints (S195+)
Section titled “Key CHECK constraints (S195+)”content_items.publication_status∈{'draft','in_review','published','archived'}(NOT NULL, DEFAULT'published'— migration20260427141626…).content_items.governance_review_status∈{'pending','approved','reverted','changes_requested','review_overdue'}(NULL allowed).'draft'was removed S202 §5.2 Phase 2.5 — migration20260427180854_tighten_governance_review_status_check_drop_draft.sql.content_items.review_cadence_days∈[1, 1095](NULL allowed). Migration20260427103256_add_review_cadence_columns.sql.content_history.change_typeenum extended with'publication_state'for publication transitions (commiteeb8ae25).
Key triggers and functions
Section titled “Key triggers and functions”enforce_archive_state_consistency— bidirectional PL/pgSQL trigger oncontent_itemsenforcingpublication_status='archived' ↔ archived_at IS NOT NULLacross 4 directions. Migration20260427141627_publication_status_indexes_and_trigger.sql.auto_version_content_history— BEFORE INSERT trigger setsNEW.version = COALESCE(MAX(version), 0) + 1. Routes omitversionfrom payloads and rely on this trigger.auto_v1_on_insert— DB trigger ensures everycontent_itemsinsert pairs with a v1content_historyrow;change_reasonis mapped fromingest_source.get_review_breakdown_stats()— aggregate stats RPC consumed by the review queue andReviewCadenceCard. Extended S204 T0 withoverduefield used by the Overdue-reviews filter pill.- The
findtool’s chunk branch (backed by thesearch_content_chunksRPC) — extended S207 withoverdue_review: boolean+review_due_within_days: integer (1..365)filters (migration20260428212936). whats_in_my_queue(facet: governance — formerlyget_governance_queue) — extended S207 withinclude_overdue: boolean+status_filter: enum('pending'|'review_overdue'|'all')filters; existing 4-arg callers unchanged.
Partial indexes
Section titled “Partial indexes”idx_content_items_next_review_date— partial onnext_review_date IS NOT NULL, excluding superseded + archived.idx_content_items_publication_status_published— partial onpublication_status='published'.idx_content_items_published_recent— partial onpublication_status='published'ordered bycreated_at DESC.idx_content_items_archived— partial onpublication_status='archived'.
MCP Tools
Section titled “MCP Tools”| Tool | Purpose | Read-only | Annotations |
|---|---|---|---|
delete_content_item | Archive (editor+) or hard-delete (admin only) a content item | No | DESTRUCTIVE_WRITE_ANNOTATIONS |
update_governance_status | Bulk update governance_review_status (change-management state, not lifecycle) | No | NON_IDEMPOTENT_WRITE_ANNOTATIONS |
update_publication_status | Transition publication_status (draft/in_review/published/archived); same role gate + transition matrix as PATCH route | No | SAFE_WRITE_ANNOTATIONS |
whats_in_my_queue (facet: governance) | One faceted queue (formerly get_governance_queue / get_review_queue / get_assignments_for_user / get_dashboard_summary); supports include_overdue + status_filter (S207) | Yes | READ_ONLY_ANNOTATIONS |
review_governance_item | Process governance review action (approve / request_changes / revert) — same allow-list as the route | No | SAFE_WRITE_ANNOTATIONS |
where_are_we_exposed | Five-layer exposure report — absorbed the former get_quality_briefing, get_quality_actions, get_quality_summary, audit_content, freshness, coverage-gap and certification reads | Yes | READ_ONLY_ANNOTATIONS |
find_duplicates (scope: all) | Cross-corpus duplicate detection (consolidated find_duplicate_candidates + find_all_duplicates) | Yes | READ_ONLY_ANNOTATIONS |
suggest_content_creation | Coverage-gap-driven content suggestions | Yes | READ_ONLY_ANNOTATIONS |
find (granularity: "chunk") | Heading-bounded chunk search; widened S207 with overdue_review + review_due_within_days (backed by the search_content_chunks RPC) | Yes | READ_ONLY_ANNOTATIONS |
update_publication_status and update_governance_status are intentionally
distinct: the former handles publication lifecycle (whether the item is ready
for use); the latter handles change-management workflow (whether a recent
edit has been reviewed). Both write content_history with the appropriate
change_reason.
Cron Jobs
Section titled “Cron Jobs”Schedules from vercel.json (UTC):
| Cron | Schedule | Route | Purpose |
|---|---|---|---|
freshness-transitions | Daily 03:15 | /api/cron/freshness-transitions | Detect freshness transitions, notify, auto-flag stale/expired items for governance review (Phase 2 bridge) |
review-cadence | Daily 03:45 | /api/cron/review-cadence | Flag next_review_date < CURRENT_DATE items as 'review_overdue' (§5.5 Phase 2 T1) |
classification-quality | Sundays 04:00 | /api/cron/classification-quality | Flag low-confidence anomalies |
quality-score | Sundays 05:00 | /api/cron/quality-score | Recalculate composite scores; notify on threshold drops; auto-flag for governance review (Phase 1 bridge) |
coverage-alerts | Mondays 05:00 | /api/cron/coverage-alerts | Coverage gap alerts |
content-gaps | Mondays 05:30 | /api/cron/content-gaps | Content gap suggestions |
intelligence-poll (SI) | Every 15 min | /api/cron/intelligence-poll | Sector intelligence feed polling |
intelligence-cleanup (SI) | Sundays 03:00 | /api/cron/intelligence-cleanup | 90-day SI article retention |
All cron handlers verify verifyCronAuth(request) (Vercel cron secret) and
record their run via recordPipelineRun() from @/lib/pipeline/record-run.
Cron timeout buffers and batch sizes
Section titled “Cron timeout buffers and batch sizes”quality-score:BATCH_SIZE=100,TIMEOUT_BUFFER_MS=40_000(maxDuration=50→ 10s buffer for writes),DEFAULT_THRESHOLD=40,BATCH_SUMMARY_THRESHOLD=20.freshness-transitions:BATCH_THRESHOLD=10for transition notifications,GOVERNANCE_BATCH_SUMMARY_THRESHOLD=20for governance auto-flag bridge. Also runscheckDateExpiryReminders()(entity + content-item expiry within 30 days) andcleanupExpiredNotifications()(delete expired+dismissed >30d).review-cadence:REVIEW_CADENCE_BATCH_SUMMARY_THRESHOLD=20,maxDuration=30. Notification idempotency usesgetExistingNotificationIds()keyed ontoday_utc_midnight.
Quality Score Formula
Section titled “Quality Score Formula”lib/quality/quality-score.ts — calculateQualityScore({...}) returns
{ score, components, label }.
| Component | Weight | Raw 0–100 derivation |
|---|---|---|
| Freshness | 30% | fresh→100, ageing/aging→60, stale→30, expired→0, null→100. Modulated by cadenceCompliancePenalty(next_review_date) when non-null. |
| Confidence | 20% | classification_confidence clamped to [0, 1] × 100; null → 0 |
| Completeness | 20% | Count of brief/detail/reference populated ÷ 3 × 100 |
| Summary | 15% | summary populated → 100, else 0 |
| Citations | 15% | min(citation_count × 20, 100) |
Final integer score = sum of weighted components, label band:
80+ Excellent | 60–79 Good | 40–59 Fair | 20–39 Needs Work | <20 Poor.
§5.5 Phase 5 cadence-compliance modifier
Section titled “§5.5 Phase 5 cadence-compliance modifier”cadenceCompliancePenalty(nextReviewDate, now) returns a non-negative
penalty subtracted from the raw freshness score (clamped at 0):
| Days until due | Penalty |
|---|---|
> 30 | 0 |
1..30 | 0–10 linear (graduated warning) |
≤ 0 and overdue ≤ 14 | 15 |
| Overdue 15..30 | 25 |
| Overdue > 30 | 40 |
Items with next_review_date IS NULL produce identical scores to the
pre-Phase 5 model — the null guard in freshnessRaw() preserves backwards
compatibility. Wired into the cron at app/api/cron/quality-score/route.ts
and into UI surfaces (content-card.tsx, metadata-sidebar.tsx,
QualityScoreBreakdown).
Publication Lifecycle Transition Matrix
Section titled “Publication Lifecycle Transition Matrix”§3.2 transition table × §3.4 role-gate matrix encoded in
lib/governance/publication-transitions.ts:
| Current state | Admin allowed → | Editor allowed → | Viewer |
|---|---|---|---|
draft | in_review, published | in_review | — |
in_review | published, draft | published, draft | — |
published | archived, draft | — | — |
archived | published, draft | — | — |
Disallowed transitions (always 409 Conflict):
draft → archived(drafts are deleted, not archived)in_review → archived(must return to draft or publish first)archived → in_review(must restore to draft, then resubmit)published → in_review(no “send back to review without revision”)
The role gate publishes 403 when the role has zero transitions out of the current state, 409 when the role can transition but not to the requested target.
Configuration
Section titled “Configuration”| Setting | Location | Default | Purpose |
|---|---|---|---|
CHANGE_REPORT_AUTO_GEN_MAX_ITEMS | lib/ai/change-reports.ts | 150 | Cost guard — rejects auto-gen on KBs with too many items in window |
BATCH_SIZE (quality-score) | app/api/cron/quality-score/route.ts | 100 | Items processed per DB fetch |
DEFAULT_THRESHOLD (quality) | app/api/cron/quality-score/route.ts | 40 | Per-domain threshold fallback when governance_config row missing |
BATCH_SUMMARY_THRESHOLD | app/api/cron/quality-score/route.ts | 20 | Switch to summary notifications above this count |
BATCH_THRESHOLD (freshness) | app/api/cron/freshness-transitions/route.ts | 10 | Switch to summary notifications for freshness transitions |
GOVERNANCE_BATCH_SUMMARY_THRESHOLD | app/api/cron/freshness-transitions/route.ts | 20 | Switch governance auto-flag notifications to summary |
REVIEW_CADENCE_BATCH_SUMMARY_THRESHOLD | app/api/cron/review-cadence/route.ts | 20 | Switch review-cadence notifications to summary |
| Light-touch preset | lib/governance/presets.ts | open / 40 / no auto-flag | Permissive default |
| Strict preset | lib/governance/presets.ts | review_on_change / 60 / 7d / 14d cooldown | Quality drops + freshness transitions auto-flag |
Testing
Section titled “Testing”| Test File | Tests | Covers |
|---|---|---|
__tests__/api/governance.test.ts | ~60 | /api/governance GET/POST — preset-driven config upsert |
__tests__/api/governance-integration.test.ts | ~12 | Full GET→POST→GET round-trip + RLS |
__tests__/api/items-patch-publication-status.test.ts | ~30 | publication_status PATCH route — transition matrix, side effects, history |
__tests__/api/items-patch-publication-status-roles.test.ts | ~18 | Role gate (403 vs 409 split per §3.4) |
__tests__/integration/items-patch-publication-status.integration.test.ts | — | Real DB integration — optimistic concurrency guard (PGRST116) |
__tests__/integration/publication-status-trigger.integration.test.ts | — | enforce_archive_state_consistency trigger — bidirectional invariant |
__tests__/integration/publication-status-migration.integration.test.ts | — | Migration fixture — seed → run → assert state |
__tests__/api/review/queue.test.ts | ~50 | /api/review/queue filter + sort + draft-vs-published path + include_overdue |
__tests__/api/review/cadence.test.ts | ~30 | /api/review/cadence aggregation |
__tests__/api/review/assignments.test.ts | ~28 | Assignment CRUD |
__tests__/api/review/history.test.ts | ~14 | History query |
__tests__/api/review/stats.test.ts | ~7 | Stats aggregation |
__tests__/api/cron/quality-score.test.ts | ~50 | Cron — score calc, threshold cross, governance bridge, batch summary |
__tests__/api/cron/freshness-transitions.test.ts | ~50 | Cron — freshness transitions, governance bridge, expiry reminders |
__tests__/api/cron/review-cadence.test.ts | ~25 | Cron — overdue flag, batch summary |
__tests__/lib/governance/publication-transitions.test.ts | ~25 | Pure helper — transition matrix + side effects + drift guard |
__tests__/lib/governance/cadence-renewal.test.ts | ~10 | Pure helper — computeNextReviewDate(...) |
__tests__/lib/governance/draft-writer-rewire-guard.test.ts | ~15 | Guard — every draft writer uses publication_status, not legacy column |
__tests__/lib/quality-score.test.ts | ~40 | Quality score calculation + cadence-compliance penalty schedule |
__tests__/mcp/update-publication-status.test.ts | ~20 | MCP tool parity with PATCH route |
__tests__/mcp/update-governance-status.test.ts | ~15 | MCP bulk update |
__tests__/mcp/governance-queue-tools.test.ts | ~20 | whats_in_my_queue (facet: governance) + review_governance_item (S207 widening) |
__tests__/components/governance-section.test.tsx | ~25 | Settings UI — preset selection |
__tests__/components/quality-score-breakdown.test.tsx | ~15 | Component breakdown rendering |
__tests__/components/content/review-cadence-editor.test.tsx | ~15 | Cadence editor — preset/custom/range validation |
__tests__/validation/content-history-change-reason.test.ts | ~25 | S153 guard — every content_history insert supplies change_reason |
__tests__/lib/governance-validation.test.ts | ~10 | Schema validation |
__tests__/lib/validation/schemas-publication-status.test.ts | ~15 | Zod superRefine for publication_status field |
Current Limitations
Section titled “Current Limitations”- Visibility is not gated. All
publication_statusvalues are visible to authenticated users — the publication-lifecycle column controls workflow, not access. RPC visibility flip is §5.2 Phase 3 (roadmap). - No publication-review queue tab. UI surfaces for
in_reviewitems are bundled with the §5.2 Phase 4 EP2 build (deferred). - No supersession + cron exclusion. §5.2 Phase 5 (roadmap).
- Email delivery not yet wired.
user_notification_prefs.email_*toggles exist; the downstream cron jobs read the table but no SMTP/SES integration yet (in-app notifications only). auto_flag_cooldown_daysflapping. The cooldown is keyed onverified_at; rapid recovery+regression cycles within the window are suppressed but the logic has not been validated against pathological inputs.- Cadence-compliance modifier is one-way. It can only subtract from freshness; it never adds. An item that’s overdue and stale gets the lower of the two but no compounding bonus for being on-time.
Architecture Decisions
Section titled “Architecture Decisions”| Decision | Rationale | Alternative Considered |
|---|---|---|
| Two separate review systems (verify/flag vs approve/request_changes) | Different question — content quality vs change-management. Unifying would force workflow into one shape that fits neither well. | Unified review queue with mode toggle (rejected — confused users, harder to reason about state). |
Preset-driven governance_config (S177 P0-16) | 7 columns × N domains was a configuration nightmare; admins picked obvious presets anyway. | Per-column UI (replaced); machine-learned presets (deferred — too little signal). |
Auto-version trigger for content_history.version | Removes a roundtrip per insert; race-free under SERIALIZABLE. | App-side MAX(version)+1 SELECT (replaced — race condition under concurrent writes). |
Optimistic-concurrency guard (.eq('publication_status', fromStatus)) | State-machine transitions are race-sensitive; a stale UPDATE returns PGRST116 → 409, letting the client re-fetch + retry. | Pessimistic SELECT FOR UPDATE (rejected — adds round-trip, doesn’t compose with PostgREST). |
| Cadence-compliance penalty subtracts only (never adds) | Score model is asymmetric — overdue is a quality signal; “very on-time” is the default state. Bonus would inflate scores arbitrarily. | Symmetric reward/penalty (rejected — distorts existing baselines and quality-score backfill). |
Mandatory change_reason on every content_history insert (S153 guard test) | Provenance is the entire point of the table; silent regressions silently destroy audit value. | Free-text-only without enforcement (replaced — drift detected within weeks). |
| Internal “digest” code, “Change Reports” UI label | Renaming hundreds of identifiers across TS+SQL was disproportionate to user-facing benefit; the label change is at the rendering boundary. | Full rename (deferred to a future workstream). |
'draft' removed from governance_review_status post-§5.2 | publication_status='draft' is now the canonical draft signal; storing it twice invited drift and confused readers. | Keep both columns (rejected — would have required dual-write logic). |