Task — engineering-spec@1

"KB server-side renderer — markdown → sanitised HTML (ammonia) + sanitised plaintext for memory ingest"

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

§1 — Description (BCP-14 normative)

The KB service MUST ship server-side renderer at services/kb/src/renderer/ producing sanitised HTML + plaintext + cached, 3 memory audit kinds.

  1. MUST validate render_target against closed enum per DEC-1892.
  1. MUST render at markdown_to_html.rs::render(doc, target):
  1. MUST extract plaintext at plaintext_extract.rs::extract(html) per DEC-1891 — strips tags, decodes entities, normalises whitespace.
  1. MUST cache per DEC-1893 at table: ``sql CREATE TABLE kb_render_cache ( cache_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, doc_id UUID NOT NULL, version_id UUID NOT NULL, target TEXT NOT NULL CHECK (target IN ('html_full','html_excerpt','plaintext','json_ast')), rendered_content TEXT NOT NULL, rendered_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, doc_id, version_id, target) ); CREATE INDEX render_cache_doc_idx ON kb_render_cache(tenant_id, doc_id, version_id); ALTER TABLE kb_render_cache ENABLE ROW LEVEL SECURITY; CREATE POLICY render_cache_rls ON kb_render_cache USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); GRANT DELETE ON kb_render_cache TO cyberos_app; -- invalidation ``
  1. MUST invalidate cache on new version per DEC-1893 — DELETE rows where doc_id matches but version_id differs from current.
  1. MUST expose endpoints: ``text GET /v1/kb/docs/{id}/render?target=html_full (cache-checked) POST /v1/kb/docs/{id}/render (force re-render, CDO) ``
  1. MUST emit 3 memory audit kinds per DEC-1894. PII per TASK-MEMORY-111: rendered_content text SHA-256 hashed.
  1. MUST thread trace_id from render request → renderer → cache → audit.
  1. MUST NOT bypass ammonia per DEC-1890.
  1. MUST NOT serve uncached invalidated content (must re-render).

§2 — Why this design

Why ammonia (DEC-1890)? Pure-Rust XSS-safe sanitiser; whitelist approach; widely audited.

Why server-side (DEC-1890)? Client trust = XSS risk; always sanitise before serving.

Why plaintext for memory (DEC-1891)? Vector quality depends on clean text; HTML tags poison embeddings.

Why cache (DEC-1893)? Markdown render is non-trivial; cache 100x speedup on repeat reads.


§3 — API contract

GET    /v1/kb/docs/{id}/render?target=html_full
POST   /v1/kb/docs/{id}/render

Sample response:

{
  "doc_id": "uuid",
  "version_id": "uuid",
  "target": "html_full",
  "rendered_content": "<h1>Onboarding Guide</h1>...",
  "rendered_at": "2026-05-17T10:00:00Z"
}

§4 — Acceptance criteria

  1. render_target enum cardinality 4. 2. Markdown → HTML works. 3. HTML sanitised (no script/event handlers). 4. Plaintext extraction removes tags. 5. XSS payload blocked. 6. Excerpt 200 words capped. 7. JSON AST returned correctly. 8. Cache hit on repeat. 9. Cache invalidated on new version. 10. 3 memory audit kinds emitted. 11. PII scrubbed (rendered_content SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Force re-render CDO-only. 15. UNIQUE(doc, version, target) constraint. 16. Append-only via REVOKE UPDATE (only DELETE on invalidation). 17. Ammonia whitelist documented. 18. Render performance < 50ms for 10k-char doc. 19. Large doc (>1MB) supported with timeout. 20. AT-rules (style) sanitised.

§5 — Verification

#[tokio::test]
async fn xss_payload_blocked() {
    let md = r#"# Hello\n<script>alert(1)</script>\n<img src=x onerror=alert(1)>"#;
    let html = render_html(md);
    assert!(!html.contains("<script>"));
    assert!(!html.contains("onerror"));
}

#[tokio::test]
async fn plaintext_strips_tags() {
    let md = r#"# Heading\n**bold** _italic_"#;
    let plain = render_plaintext(md);
    assert!(!plain.contains("<"));
    assert!(plain.contains("Heading"));
    assert!(plain.contains("bold"));
}

#[tokio::test]
async fn cache_invalidated_on_new_version() {
    let ctx = TestContext::with_doc().await;
    ctx.render(ctx.doc_id, "html_full").await;
    ctx.create_new_version(ctx.doc_id).await;
    let cache = ctx.fetch_cache_for_doc(ctx.doc_id).await;
    let old_version_rows = cache.iter().filter(|r| r.version_id == ctx.original_version).count();
    assert_eq!(old_version_rows, 0);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-KB-001. Downstream: TASK-KB-005 (semantic ingest uses plaintext). Cross-module: TASK-AI-019 (memory Layer 2 ingest), TASK-AUTH-101 (CDO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Ammonia rejects all (config too strict)empty output sev-2inherenttune config
Markdown parse failcatchsev-2; raw fallbackdata fix
Large doc timeout5s limit504 + sev-2split doc
Cache table corruptionDELETE+re-renderinherentinherent
XSS bypass attempttests catchinherentbug fix
Plaintext encoding issueUTF-8 enforcementinherentinherent
Cross-tenant cache leakRLS0 rowsinherent
Version mismatch (race)UNIQUElast-write-winsinherent
AT-rule injectionammonia blocksinherentinherent
Inline SVG with scriptammonia blocksinherentinherent

§11 — Implementation notes


End of TASK-KB-002 spec.