Task — engineering-spec@1
"KB server-side renderer — markdown → sanitised HTML (ammonia) + sanitised plaintext for memory ingest"
draftTASK-KB-002
§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.
- MUST validate
render_targetagainst closed enum per DEC-1892.
- MUST render at
markdown_to_html.rs::render(doc, target):
- Parse via
pulldown-cmark. - Sanitise via ammonia per DEC-1890 with strict whitelist (no script, no inline event handlers, no javascript: URLs).
- Output per target (full HTML, excerpt 200 words, plaintext, JSON AST).
- MUST extract plaintext at
plaintext_extract.rs::extract(html)per DEC-1891 — strips tags, decodes entities, normalises whitespace.
- 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``
- MUST invalidate cache on new version per DEC-1893 — DELETE rows where doc_id matches but version_id differs from current.
- 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)``
- MUST emit 3 memory audit kinds per DEC-1894. PII per TASK-MEMORY-111: rendered_content text SHA-256 hashed.
- MUST thread trace_id from render request → renderer → cache → audit.
- MUST NOT bypass ammonia per DEC-1890.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Ammonia rejects all (config too strict) | empty output sev-2 | inherent | tune config |
| Markdown parse fail | catch | sev-2; raw fallback | data fix |
| Large doc timeout | 5s limit | 504 + sev-2 | split doc |
| Cache table corruption | DELETE+re-render | inherent | inherent |
| XSS bypass attempt | tests catch | inherent | bug fix |
| Plaintext encoding issue | UTF-8 enforcement | inherent | inherent |
| Cross-tenant cache leak | RLS | 0 rows | inherent |
| Version mismatch (race) | UNIQUE | last-write-wins | inherent |
| AT-rule injection | ammonia blocks | inherent | inherent |
| Inline SVG with script | ammonia blocks | inherent | inherent |
§11 — Implementation notes
- §11.1 Ammonia config: allow basic HTML tags + safe attributes (href, src, alt, title); no event handlers; HTTP/HTTPS only.
- §11.2 Plaintext: ammonia first → strip remaining tags → normalise whitespace.
- §11.3 Cache TTL: indefinite; invalidated only on new doc version.
- §11.4 memory audit body: doc_id, version_id, target; rendered_content SHA256.
- §11.5 Force re-render: bypasses cache lookup; useful for ammonia config updates.
End of TASK-KB-002 spec.