Skip to content

S529 lane B — what requirement does the review surface serve?

Task: id-417 D2/D3 (D3 blocked on D2). Question asked: what requirement does the review surface serve, is that requirement still live, and what should the surface therefore be. Posture: read-only on code; every claim below carries a file:line, a quoted passage, or a command and its output.

Headline. The built split does not mis-cut the two axes the record_lifecycle migration declares — it serves a third axis the facet never modelled. The quality-score has no persistence home, a hard ceiling of 65/100, and flags every expired document regardless of quality. And the requirement the owner attributes to the client feedback file is real and live, but it is not in that file — it is in the gap-analysis pair, where it carries an explicit pre-launch owner decision.


The migration declares two and I do not contradict it — but a third exists on the typed records and it is the one /review is built on.

AxisColumnsHomeOwner kinds
A — Review/Governancegovernance_review_status, governance_review_due, governance_reviewer_id, verified_at, verified_by, content_owner_idrecord_lifecycleboth {source_document, q_a_pair}
B — Freshness/expiry/cadencefreshness, lifecycle_type, expiry_date, next_review_date, review_cadence_daysrecord_lifecyclesource_document only
C — Publicationpublication_statussource_documents and q_a_pairsnot record_lifecycleboth

Axes A and B are the migration’s own, verbatim:

-- Review/Governance axis — spans {source_document, q_a_pair} (BI-20).supabase/migrations/20260628190000_id131_record_lifecycle_facet.sql:33

-- Freshness/expiry/review-cadence axis — source_document-ONLY (D7, BI-22). — same file, :40, enforced by record_lifecycle_freshness_axis_chk at :58

Axis C is measured, not asserted:

select table_name, column_name from information_schema.columns
where table_schema='public' and column_name='publication_status';
-- → q_a_pairs.publication_status, source_documents.publication_status
-- (record_lifecycle: no row)
SurfaceAxis servedOwner kinds reachedEvidence
/review UI + /api/review/queueC (publication + verification)source_document onlyqueries .from('source_documents'), app/api/review/queue/route.ts:274; tabs are drafts / pending / verified-review / verified-audit / all / unclassified / awaiting-publication, components/review/review-tabs.tsx:30-34,106-142
/api/review/cadenceBsource_document only.eq('owner_kind','source_document'), app/api/review/cadence/route.ts:101
/api/review/assignmentsBsource_document onlysame, app/api/review/assignments/route.ts:153
/api/review/actionAbothresolveReviewItemOwner(...), app/api/review/action/route.ts:81-85; writes .eq('owner_kind', ownerKind), :144-145
/api/governance/reviewAsource_document only.eq('owner_kind','source_document') at :78, :94, :166, :234, :260
/promotion-gate + /api/governance/promotion-candidates/{accept,edit,reject}knowledge-admission (R2)q_a_pair onlyapp/api/governance/promotion-candidates/[extractionId]/accept/route.ts:17-29

Crons — all three write the same axis-A column, and all three are source_document-only:

CronScheduleDerives fromWrites
freshness-transitionsdaily 03:15 (vercel.json)axis Bgovernance_review_status:'pending', route.ts:555-556
review-cadencedaily 03:45axis Bgovernance_review_status:'review_overdue', route.ts:210-211
quality-scoreSundays 05:00a score that is 46% axis B (§2)governance_review_status:'pending', route.ts:376-378

1.3 Where the built split contradicts the schema

Section titled “1.3 Where the built split contradicts the schema”

(i) The governance axis spans both kinds; every governance read surface is pinned to one. /api/governance/review filters owner_kind='source_document' in all five of its queries. But /api/review/action implements the full polymorphic write — and its own comment says why the read side never followed:

“Previously hardcoded to source_documents in every branch, so /library’s Bulk Verify (q_a_pairs-only …) 404’d for every pair. owner_kind is optional on the request body — that hook is OUT of this Subtask’s file-ownership boundary and still omits it”app/api/review/action/route.ts:76-80

Net effect: the platform can act on a Q&A governance review that it cannot list. The only caller that reaches the q_a_pair branch is /library’s bulk-verify hook, which is not a review surface.

(ii) The axis separation the DDL enforces is dissolved in the cron layer. D7 makes freshness structurally impossible on a q_a_pair (CHECK at :58). Yet two of the three writers of the governance column derive their trigger entirely from freshness. The migration separates the axes in the schema; the crons re-merge them at runtime.

(iii) /review serves axis C, which record_lifecycle does not model at all. So the /review vs /api/governance/review split is not a mis-cut of axes A and B — it is a surface for a third axis the facet never absorbed. This is a refinement of the dispatch’s anchor, not a contradiction of it: the anchor’s claim that the route split matches neither axis holds, and the measured reason is that /review’s actual concern is outside the facet entirely.

(iv) An axis-A action writes an axis-B column. Approving a governance review advances next_review_date:

governance_review_status: 'approved', … ...(nextReviewDate && { next_review_date: nextReviewDate })

app/api/governance/review/route.ts:198-204


The owner’s question was literal, so here is the literal answer.

Where it is computed. lib/quality/quality-score.ts:178 calculateQualityScore. It has exactly one call site in the entire tree — the cron:

$ grep -rn "calculateQualityScore\|calculateAndRoundQualityScore" app/ lib/ | grep -v lib/quality/quality-score.ts
app/api/cron/quality-score/route.ts:17 (import)
app/api/cron/quality-score/route.ts:243 (call)

bunx gitnexus impact calculateQualityScore --direction upstreamimpactedCount: 3, risk: "LOW", direct: 2, processes_affected: 1 (the cron GET).

What it scores, declared vs live. Declared: five weighted components — freshness 30, confidence 20, completeness 20, summary 15, citations 15 (quality-score.ts:52-58). Live: the cron passes only freshness, classification_confidence, summary, and hardcodes citation_count: 0 (route.ts:243-251). It never passes brief/detail/reference — those columns were dropped at M3 (BI-11), per the cron’s own header at :41-47.

Measured ceiling (executed probe against the real module, cron-identical input shape):

BEST POSSIBLE (fresh + conf 1.0 + summary) = 65 [fresh 30, conf 20, complete 0, summ 15, cite 0]
same inputs with brief/detail/reference/citations = 100
unreachable weight = completeness 20 + citations 15 = 35 of 100
freshness share of the reachable range = 30/65 = 46.2%

What it is compared against. governance_config.quality_score_threshold per domain, falling back to DEFAULT_THRESHOLD = 40 (route.ts:36). Live config, measured:

select domain, quality_score_threshold, auto_flag_on_quality_drop from public.governance_config;
-- → e2e-seeded-domain | 40 | false (one row)

The code default is fail-open: domainConfig?.auto_flag_on_quality_drop ?? true (route.ts:268) — any domain with no config row auto-flags on.

Whether it persists. It does not. Measured:

select table_schema, table_name, column_name from information_schema.columns
where column_name in ('quality_score','previous_quality_score','quality_score_updated_at')
and table_schema in ('public','api');
-- → [] (zero rows)

The cron agrees (route.ts:137-141, totalUpdated hardcoded 0), and the read surface has already given up on it: /api/review/queue returns quality_score: null literally (route.ts:832) and its quality_score_asc sort is “a documented no-op” (:348-349, :530).

What it actually discriminates on. Executed enumeration of the full live input space (4 freshness states × 5 confidence values × summary present/absent) against threshold 40:

combinations below threshold: 28/40
of the 'expired' combinations: 10/10 below threshold
max score attainable per freshness state (conf=1.0, summary present):
fresh = 65
aging = 53
stale = 44
expired = 35 <-- BELOW THRESHOLD EVEN AT PERFECT QUALITY

Direct answer. It scores freshness (46% of the reachable range), classification confidence, and whether a summary string is non-empty — against a threshold of 40 that exactly one synthetic domain row configures. The result is discarded; its only effects are a notification and an auto-flag onto the governance column. An expired document is flagged as a quality failure no matter how good it is. It is a freshness restatement wearing a quality label, and it writes that restatement onto the other axis.

Is there a requirement behind it? There was, and it was a fix-or-remove ask that was never actioned. The client raised completeness scoring specifically:

“Completeness scoring sitting at 0/20 on most items suggests the metric is not populated — fix or replace”kb-hub-gap-analysis-liam.md:390 “Fix completeness scoring or remove it” — same file, :405

Neither branch was taken. The metric has since degraded from unpopulated to structurally unreachable. The requirement behind the quality-score as built is not live.

The governing spec does not exist. docs/specs/p0-document-control-lifecycle-spec.md is cited by 10 files — including the cadence penalty schedule (quality-score.ts:15), the review-cadence cron (:31), lib/governance/review-input-statuses.ts:14 and lib/governance/cadence-renewal.ts:9. Measured:

$ find <canonical> <docs-site> <client-archive> -name "p0-document-control*"
(no output)
$ git log --oneline --all -- "**/p0-document-control-lifecycle-spec.md"
(no output)

It is absent from every checkout and was never in git history.


ReqSourceLive?
R-DOC — document control: version, changelog, owner, next-review-datekb-hub-gap-analysis-liam.md:216-221 §7.6LIVE, partly built
R-QA-REVIEW — human sign-off on promoted Q&A diffscorpus-reframe-review.html:341 (R2) + DR-026LIVE, built in a separate place
R-GOV — governance reviewDR-034 (explicit RETAIN)LIVE
R-SCOPE — hard scope tagskh-client-feedback.md:31-33LIVE, routed to id-71 (S528 E2)
R-QSCORE — quality score as builtkb-hub-gap-analysis-liam.md:390,405NOT LIVE (see §2)

R-DOC — and a correction to the attribution

Section titled “R-DOC — and a correction to the attribution”

The dispatch (and the S528 board) attribute the document-lifecycle framing to kh-client-content-archive/docs/client-briefs/kh-client-feedback.md. I read all 38 lines of that file. The framing is not in it. Its three items are: (1) list_user_workspaces returns an invalid response shape; (2) MCP search underperforming — five answered questions returned as gaps; (3) scope tags and enforced citation. No document-lifecycle content appears.

The framing is real and the owner’s memory of it is sound — the source is a different document. It is kb-hub-gap-analysis-liam.md §7.6:

“The refined mind map specified version, change history, ownership, review cadence, especially for Company/Corporate and Bid Detail content. The build has quality scoring and freshness tracking but document control (version number, changelog, owner, next-review-date) is not visible.”:216-221

and it carries an explicit recorded owner decision:

Liam decision: Required. Cross-reference with Roadmap item ‘5. Document Control & Lifecycle’ — intention is to prioritise that whole section to be completed pre-launch too.”kb-hub-gap-analysis-action-tracker.md:186-190

This matters for the ruling: R-DOC is the only requirement in this report with a recorded pre-launch owner decision attached, and it is stronger evidence than the file the board cited.

Delivery status against the tracker’s own five-phase plan (action-tracker.md:210-226): Phase 1 (columns) and Phase 2 (cron) shipped and now live on record_lifecycle; Phase 5 (cadence in the scorer) shipped as cadenceCompliancePenalty. Unbuilt: Phase 3’s /review “overdue reviews” filter, and — critically — version and changelog, the two fields §7.6 names first, have no column on record_lifecycle at all (migration :25-49).

R-QA-REVIEW — already a review queue, just not called one

Section titled “R-QA-REVIEW — already a review queue, just not called one”

R2, verbatim:

“The authoritative gate is knowledge admission: promotion + dedup review + confidence gate for records; ontology linter + publish gate for concepts. Authority is earned at sign-off, not inherited from a folder.corpus-reframe-review.html:341

Built as /promotion-gate over the awaiting_review bucket. The accept route states its own job:

“ACCEPT applies the extraction’s OWN carried fields … onto the published pair — the ‘apply the diff’ action DR-026 blocks from auto-firing; a human now confirms it per-item.”accept/route.ts:24-29

That is a review queue by every property except its name and its position in the IA — it is a separate Governance-zone nav entry from Review (components/shell/nav-config.ts:133 vs :146).


Binding constraint on all three options. id-71 {71.9} whats_in_my_queue is done and shipped — a faceted queue (content_quality | governance | all) over lib/attention.ts, whose test strategy explicitly asserts “/review + /api/governance/review routes unchanged” (tasks/id-71.md:192, restated :103). Its TECH is equally explicit that the cron writers and formatGovernanceQueue “are unchanged producers; only the MCP read surface consolidates.” The facet enum is the composition seam. Any option that changes those routes lands as an amendment to {71.9}‘s enum plus its fixture — never as a silent break.

Section titled “Option 1 — RECOMMENDED: one queue, faceted by the axes as they actually are”

/review becomes the single human queue, with facets matching the three measured axes:

  • publication (axis C — both kinds) — absorbs today’s drafts / pending / awaiting-publication tabs.
  • governance (axis A — both kinds) — absorbs /api/governance/review and the promotion-candidate awaiting_review bucket, which is a governance sign-off on a proposed diff.
  • freshness (axis B — source_document only) — absorbs cadence + assignments.

Each facet is owner-kind-polymorphic exactly where its axis is. /api/review/action already implements the polymorphic axis-A write — this makes the read side match the write side that already exists (§1.3(i)).

Composes with {71.9}: the MCP facet enum widens content_quality|governance|allpublication|governance|freshness|all — one enum plus one fixture edit. The lib/attention.ts producers stay the producers, precisely as {71.9}‘s TECH specifies.

Makes possible: reviewing promoted Q&As in the same place as everything else (the owner’s steer); the Q&A half of the governance axis becomes listable; freshness stops masquerading as quality.

Costs: three tab-groups collapse into one faceted surface — real front-end work in review-content.tsx (733 lines) and review-tabs.tsx. /promotion-gate loses its own nav entry, which is a change to the Governance zone’s entry set and therefore needs an owner nav ruling under DR-041 — even though DR-041 makes no claim about concern boundaries.

Option 2 — fix the axis violations only, keep the surfaces

Section titled “Option 2 — fix the axis violations only, keep the surfaces”

Leave /review, /promotion-gate and /api/governance/review where they are. Retire the quality-score cron; stop deriving axis A from axis B; add the q_a_pair read leg to /api/governance/review.

Makes possible: the schema contradictions close with no UX work; ships in a fraction of the time; zero DR-041 exposure.

Costs: does not deliver the owner’s “review promoted Q&As in the review surface” steer. Leaves three surfaces a reviewer must learn, and leaves /review serving an axis the facet does not model — so the next task still opens with archaeology, which is the precise cost id-417 exists to stop (“every task starts with archaeology”).

Take R-DOC literally: /review becomes a document-control surface — version, changelog, owner, next-review-date, review status — with Q&A review left as a separate promotion surface.

Makes possible: the one requirement carrying an explicit pre-launch owner decision, delivered whole.

Costs: version and changelog have no schema home (migration :25-49), so this option needs a migration before it needs a UI. It also hard-splits document review from Q&A review — the opposite of the owner’s steer, and it re-cuts against axis A deliberately spanning both kinds. Recommend only if version + changelog prove to be the binding client commitment (see §6).


5. What must be retired, and what that unblocks

Section titled “5. What must be retired, and what that unblocks”

The quality-score cron and lib/quality/quality-score.ts. No persistence home, 35/100 structurally unreachable, expired always flagged, and its governance bridge duplicates freshness-transitions’ bridge onto the same column. Blast radius measured as LOW (gitnexus, §2) — one call site.

Unblocks: {71.9}‘s content_quality facet stops pointing at a producer with no live requirement; governance_config.quality_score_threshold / auto_flag_on_quality_drop / the quality_flag notification type lose their only writer; id-71 {71.29}‘s “dead ‘quality’ ExposureLayerKey member” cleanup gains a coherent story; and /api/review/queue’s hardcoded quality_score: null plus its no-op sort can be deleted rather than perpetually explained.

Caveat, stated plainly: retiring it removes the only level-triggered governance backstop. freshness-transitions flags only on a transition (newTransitions filter, route.ts:494-496), so a document that went expired before the flag existed is never re-flagged by it. If a level-triggered sweep is wanted it belongs on axis B as a freshness sweep — not on a quality score.

lib/mcp/formatters/briefing.ts:23,35-36,145,165-167 — still formats quality_score and previous_quality_score deltas for values that have no column anywhere. Retires with the cron.

The 10 p0-document-control-lifecycle-spec.md citations. Either the spec is restored from wherever it actually lives, or the citations are repointed at the real authority. Until then cadenceCompliancePenalty’s schedule and ALLOWED_REVIEW_INPUT_STATUSES are unfalsifiable — no reviewer can check them against anything.

The /api/review/ vs /api/governance/review path split — under Option 1 only.


Questions carried verbatim.

  1. “Is the cadence-compliance penalty schedule (0 / −10 / −15 / −25 / −40, quality-score.ts:101-110) the ratified one?” — Its only cited source, p0-document-control-lifecycle-spec.md §9.3, exists in no checkout and has no git history. No named requirement plus no current source.
  2. “Should record_lifecycle gain a publication axis, or should publication stay on the typed records?” — The migration models two axes and publication is neither; nothing I read rules on whether that was deliberate. It decides whether Option 1’s publication facet reads one table or two.
  3. “Does governance_config’s per-domain policy survive if the quality-score cron retires?” — Three of its columns exist only for that cron. Whether per-domain governance policy is a live requirement independent of it is settled nowhere I read.
  4. “Are version + changelog (§7.6’s first two named fields) still a client commitment?” — The recorded decision is “Required … prioritise pre-launch” (action-tracker.md:186-190), dated to the S188 era. No later source I read either reaffirms or retires it, and it has no schema home. This one gates Option 3.

  • Not read in full: app/review/review-content.tsx (733 lines) and app/api/review/queue/route.ts (837). I measured their query shapes, filters and tab definitions by targeted grep, not their rendering logic — current-queue UX detail is under-specified here, which matters for costing Option 1’s front-end work.
  • MCP side not measured. I did not read lib/mcp/tools/review.ts FACET_BY_TYPE, whats_in_my_queue’s live shape, or formatGovernanceQueue. Option 1’s claim that the facet enum is a one-edit seam derives from {71.9}‘s task record and TECH text, not from reading the tool file. Verify before committing to that estimate.
  • Not read: app/api/review/history, publication-bulk-action, stats, and the classification-quality cron.
  • No test run. No behavioural verification that retiring quality-score leaves the suite green; __tests__/api/cron/quality-score.test.ts exists and I did not read it.
  • Evidence hygiene. No consumer-counting, population, or emptiness claim is used as evidence anywhere above. The single population figure quoted (governance_config has one synthetic row) establishes only which threshold value the code path takes — it is not an argument that the surface is unused.