Task — engineering-spec@1

"KB dual-language `translation_of` link — vi/en pairing with locale-aware reader display and translation parity audit"

draftTASK-KB-009
module kb · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-KB-001 · blocks none

§1 — Description (BCP-14 normative)

The KB service MUST ship translation linking at services/kb/src/translation/ with bi-directional links + parity check + locale-aware display, 3 memory audit kinds.

  1. MUST validate kb_locale against closed enum per DEC-1961.
  1. MUST define table extension at migration 0009: ``sql ALTER TABLE kb_documents ADD COLUMN locale TEXT CHECK (locale IS NULL OR locale IN ('vi','en')); ALTER TABLE kb_documents ADD COLUMN translation_of UUID REFERENCES kb_documents(doc_id); CREATE INDEX docs_translation_idx ON kb_documents(tenant_id, translation_of) WHERE translation_of IS NOT NULL; GRANT UPDATE (locale, translation_of) ON kb_documents TO cyberos_app; ``
  1. MUST enforce bidirectional link per DEC-1960 — if A.translation_of=B, then B.translation_of MUST equal A. Validated at write.
  1. MUST check parity at parity_checker.rs::check(doc, translation) per DEC-1962:
  1. MUST route reader display per DEC-1963 at locale_router.rs::route(doc, user_locale):
  1. MUST expose endpoints: ``text PUT /v1/kb/docs/{id}/translation body: {translation_of_doc_id} GET /v1/kb/docs/{id}/translation-parity (CDO check) ``
  1. MUST emit 3 memory audit kinds per DEC-1964. PII per TASK-MEMORY-111: diff-summary SHA256.
  1. MUST thread trace_id from link/parity/display → audit.
  1. MUST NOT allow asymmetric link per DEC-1960.
  1. MUST un-link by setting translation_of = NULL (still requires bidirectional update).

§2 — Why this design

Why bi-directional (DEC-1960)? Users navigating from either side need access to the other.

Why locale enum cardinality 2 (DEC-1961)? Current scope (VN agency); extensible if Indonesian/Thai expansion needed.

Why parity check (DEC-1962)? Source updates outpace manual translation; without alerts, translations stale silently.

Why auto-switch reader (DEC-1963)? Search may surface either locale; user sees their preferred without manual click.


§3 — API contract

Sample link creation:

PUT /v1/kb/docs/{vi_doc}/translation
{ "translation_of_doc_id": "uuid-en-doc" }

Bi-directional auto-update: en doc's translation_of also set to vi doc.

Parity check response:

{
  "vi_doc_id": "uuid",
  "en_doc_id": "uuid",
  "vi_last_updated": "2026-05-15T10:00:00Z",
  "en_last_updated": "2026-04-20T10:00:00Z",
  "drift_days": 25,
  "diff_summary": "VN version added Section 3 about new VAT regulation; EN missing this section.",
  "ai_suggested_translation": "..."
}

§4 — Acceptance criteria

  1. locale enum cardinality 2. 2. Bi-directional link enforced. 3. CHECK constraint on locale values. 4. Index on translation_of for lookups. 5. Parity check via TASK-AI-003. 6. Source update triggers parity audit. 7. Locale router auto-switches. 8. No-translation banner shown when missing. 9. 3 memory audit kinds emitted. 10. PII scrubbed (diff-summary SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. CDO-only link/parity write. 14. Un-link respects bidirectional invariant. 15. Independent search indexes (TASK-KB-004+005 per locale). 16. Append-only via REVOKE except 2 cols. 17. Both sides remain queryable. 18. Self-reference rejected. 19. Cross-tenant link rejected (FK + RLS). 20. Multiple-translation chain prevented (one-to-one).

§5 — Verification

#[tokio::test]
async fn bidirectional_link_enforced() {
    let ctx = TestContext::with_vi_and_en_docs().await;
    ctx.link_translation(ctx.vi_doc, ctx.en_doc).await;
    let en = ctx.fetch_doc(ctx.en_doc).await;
    assert_eq!(en.translation_of, Some(ctx.vi_doc));
}

#[tokio::test]
async fn locale_router_auto_switches() {
    let ctx = TestContext::with_translation_pair().await;
    let r = ctx.fetch_doc_as_user(ctx.vi_doc, "en").await;
    assert_eq!(r.served_doc_id, ctx.en_doc);
}

#[tokio::test]
async fn parity_alert_on_source_update() {
    let ctx = TestContext::with_translation_pair().await;
    ctx.update_doc(ctx.vi_doc, "new content").await;
    tokio::time::sleep(Duration::from_secs(1)).await;
    let audits = ctx.fetch_memory_audits("kb.translation_parity_alert").await;
    assert!(!audits.is_empty());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-KB-001. Cross-module: TASK-AI-003 (diff summary), TASK-KB-007 (Q&A respects locale), TASK-AUTH-101 (CDO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Self-reference linkvalidate400use different doc
Cross-tenant linkRLS + FK404inherent
Asymmetric link writevalidatorreject; rollbackinherent
User locale unknowndefault to eninherenttenant config
Translation missing (one-side)banner showninherentcreate translation
AI parity check timeoutretry; degradesev-2; manual reviewinherent
Locale = NULL on docwarn at linkreject 400set locale
Triangulation (A→B, B→C)one-to-one constraintrejectuse direct
Locale change mid-pairupdate bothmanual CDOinherent
Independent index driftper-locale TASK-KB-004/005inherentinherent

§11 — Implementation notes


End of TASK-KB-009 spec.