"KB dual-language `translation_of` link — vi/en pairing with locale-aware reader display and translation parity audit"
§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.
- MUST validate
kb_localeagainst closed enum per DEC-1961.
- 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;``
- MUST enforce bidirectional link per DEC-1960 — if A.translation_of=B, then B.translation_of MUST equal A. Validated at write.
- MUST check parity at
parity_checker.rs::check(doc, translation)per DEC-1962:
- Triggered on source doc version update
- TASK-AI-003 generates diff-summary of changes
- Alert emitted (memory audit + CDO notification)
- CDO reviews + propagates to translation
- MUST route reader display per DEC-1963 at
locale_router.rs::route(doc, user_locale):
- If doc.locale matches user_locale, return doc
- Else if doc has translation_of in user_locale, redirect to translation
- Else show as-is with banner "Translation not available"
- MUST expose endpoints: ``
text PUT /v1/kb/docs/{id}/translation body: {translation_of_doc_id} GET /v1/kb/docs/{id}/translation-parity (CDO check)``
- MUST emit 3 memory audit kinds per DEC-1964. PII per TASK-MEMORY-111: diff-summary SHA256.
- MUST thread trace_id from link/parity/display → audit.
- MUST NOT allow asymmetric link per DEC-1960.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Self-reference link | validate | 400 | use different doc |
| Cross-tenant link | RLS + FK | 404 | inherent |
| Asymmetric link write | validator | reject; rollback | inherent |
| User locale unknown | default to en | inherent | tenant config |
| Translation missing (one-side) | banner shown | inherent | create translation |
| AI parity check timeout | retry; degrade | sev-2; manual review | inherent |
| Locale = NULL on doc | warn at link | reject 400 | set locale |
| Triangulation (A→B, B→C) | one-to-one constraint | reject | use direct |
| Locale change mid-pair | update both | manual CDO | inherent |
| Independent index drift | per-locale TASK-KB-004/005 | inherent | inherent |
§11 — Implementation notes
- §11.1 Bi-dir enforcement: trigger on UPDATE sets the partner's translation_of too.
- §11.2 Parity check cron: nightly compares last_updated of both sides; alerts on > 7-day drift.
- §11.3 Locale router accepts Accept-Language header or user.preferred_locale.
- §11.4 memory audit body: doc_id pair, drift_days; diff_summary SHA256.
- §11.5 Future locales: add to enum + migration; reader router handles automatically.
End of TASK-KB-009 spec.