Document Service
Container olly-document-service, in-cluster port 8080 (host-published 127.0.0.1:4013), Postgres schema documents. Gateway prefix /document-service (prefix stripped by APISIX).
The Document Service renders member- and employer-facing documents (invoices, policy schedules, EOBs, pre-auth letters, renewal notices) from HTML templates, stores them in Postgres, and serves them as PDFs. Rendering is event-driven: a Kafka consumer maps inbound event types to a template type, fills the template, and persists the result. Reads are tenancy-checked, and downloads flow through short-lived HMAC-signed URLs so a plain browser click works without auth headers.
Field reference: column-level detail lives in the catalog. This page is the narrative.
What it owns
Table (documents schema) | Description |
|---|---|
documents.templates | One HTML template per (market_code, template_type). Upserted in place; not versioned. |
documents.documents | Generated document instances. Rendered PDF bytes plus party_locator, policy_locator, scheme_locator, invoice_locator (all indexed). |
documents.outbox | Transactional outbox for reliable Kafka publishing of document.ready. |
Rendering
| Stage | Implementation |
|---|---|
| Template lookup | documents.templates row by (market_code, template_type). Market defaults to GB when the event payload omits marketCode. |
| HTML composition | Go html/template executed against the event payload. Invoices are enriched from billing (BILLING_URL) and the party name from policy-admin; policy schedules build a benefit table from the policy's coverageTerms. |
| PDF generation | Headless Chromium (chromium-browser --headless --print-to-pdf, override via PDF_RENDERER_BIN). Real PDFs are the default; PDF_FALLBACK_HTML=true is an explicit opt-out that stores the HTML instead. |
API
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /documents | JWT + tenancy | List document metadata. Filters: partyLocator, policyLocator, schemeLocator, invoiceLocator, type, limit (at least one locator required, else 400). |
GET | /documents/{locator} | JWT + tenancy | Stream the stored PDF inline. Foreign locator → 404. |
GET | /documents/{locator}/download-url | JWT + tenancy | Mint a signed download link: 200 {url, expiresAt} (RFC3339). |
GET | /documents/{locator}/content?exp=&sig= | Public, signature-verified | Serve the PDF as Content-Disposition: attachment. |
POST | /internal/documents/generate | Cluster-only | Synchronous generate for internal callers; blocked at the gateway edge. |
GET | /healthz, /readyz | none | Liveness / readiness. |
Signed downloads
download-url returns {PublicBaseURL}/documents/{locator}/content?exp=…&sig=…. The signature is HMAC-SHA256 over "download\n" + locator + "\n" + exp with the DOC_DOWNLOAD_SIGNING_SECRET env secret, base64url-encoded, valid for 10 minutes. The content route verifies the signature (constant-time) before any DB lookup: a missing/invalid token or bad signature gets 401, an expired link gets 401 "download link expired", an unset signing secret gets 503 (fail-closed), and only then an unknown locator gets 404. Responses carry Cache-Control: private, no-store.
Tenancy
Reads are scoped to the caller's JWT claims party_locator and org_locator:
- A document is visible when its
party_locatormatches the caller's party or organisation, or when the caller's organisation is the employer of the document's scheme. Scheme ownership is resolved live via group-scheme-service (GET /api/v1/schemes, forwarding the caller's bearer, 60s cache). - Reads by locator (
/documents/{locator},/download-url) return 404 for a foreign document, indistinguishable from absence. - The list endpoint returns 403 for a foreign
partyLocator/schemeLocatorfilter (the caller asserted a key, so there is no existence oracle to protect). - If the scheme resolver is unavailable the check fails closed with 503; the document is never served on resolver failure.
- In-cluster callers presenting the
X-Internal-Servicesecret are not narrowed.
The list response is metadata only, never document bytes: {locator, type, generatedAt, downloadUrl, partyLocator, policyLocator, schemeLocator, invoiceLocator} per row.
Events
Document-service publishes through a transactional outbox (documents.outbox) and consumes generation triggers from the other services' topics. Outbox rows carry the payload, an optional state snapshot, and the enqueue-time trace context; the worker (internal/outbox/worker.go) hands each row to internal/outbox/producer.go, which builds the canonical platform envelope (see the Kafka Event Catalog), stamps correlationId from the stored trace, lifts the client lineage (sessionId / activityId / activityName) from W3C baggage, attaches the state snapshot, and publishes to document-events (KAFKA_OUTBOUND_TOPIC; note the hyphen, not document.events). Messages are keyed policy locator first, then party / invoice / document locator.
Consumes
Default topics enrollment-events, claims.events, billing.events (override via KAFKA_TOPICS). Consumer group document-service. Event types not in the map are ignored; both eventType and event_type envelope spellings are decoded, with a fallback to payload.eventType.
Inbound eventType | Template type rendered |
|---|---|
policy.issued, policy.endorsed | POLICY_SCHEDULE |
invoice.finalised, invoice.paid | INVOICE |
claim.approved | EOB |
prior-auth.decided | PRE_AUTH_LETTER |
policy.renewal_due | RENEWAL_NOTICE |
INVOICE generation is idempotent on (invoice_locator, template_type): a partial unique index plus a pre-insert check means an event replay cannot mint a second invoice document.
Produces
All four types share the camelCase locator payload (documentLocator, partyLocator, policyLocator, schemeLocator, invoiceLocator, templateType, marketCode); locators may be empty strings depending on template type.
eventType | Emitted when | State subjects |
|---|---|---|
document.ready | document rendered and persisted, same transaction as the outbox row | document |
document.reissued | a policy.endorsed re-render superseded an existing document; mirrors document.ready so consumers can tell a superseding schedule from a duplicate first issue | document |
document.generation_failed | a Generate call failed; stage names the broken transition (fetch_template, render_template, render_pdf, persist) and reason carries the error | none (no document row exists on a failure) |
document.downloaded | a document's PDF bytes were actually served (JWT-gated or signed-URL route); minting a download URL alone does not fire it | document |
The state.document snapshot freezes the row minus the PDF bytes (stateOfDocument in internal/service/document.go). There is no DLQ and no retry/backoff on the consumer: a generate error emits document.generation_failed, logs, and returns. Contracts (payload schema, required state subjects, lineage, producers/consumers as code refs, golden examples) live in the event registry, one directory per type under packages/go/domain/eventregistry/registry/.
Locator and document shape
locator is a DOC-YYYY-NNNNNN sequence string (an in-memory counter seeded from MAX(locator) at startup). Rows carry party_locator, policy_locator, scheme_locator and invoice_locator for filtering and tenancy; invoice_locator is stored as NULL (never empty string) so the partial unique index holds.
Templates
documents.templates has UNIQUE (market_code, template_type); templates are upserted in place (no versioning, no rollback, no admin routes).
Dependencies
| Dependency | Purpose | Failure mode |
|---|---|---|
Postgres (documents schema) | Persistence | Hard fail |
| Kafka | Event input + outbox publish | Generation pauses when broker is down; outbox publishes on recovery |
group-scheme-service (GROUP_SCHEME_URL) | Scheme-ownership resolution for tenancy | Fail closed: reads needing scheme resolution return 503 |
Billing (BILLING_URL) | Invoice enrichment at render time | Render error, event skipped |
| Policy Admin | Party name enrichment for invoices | Degraded content |
Invariants
- A document row and its
document.readyoutbox row are written in one transaction. - At most one
INVOICEdocument per invoice: idempotent on(invoice_locator, template_type). - Every read of document bytes is either JWT + tenancy-checked or carries a valid unexpired HMAC signature.
- A foreign document read by locator returns 404, identical to absence.
- Signed URLs expire after 10 minutes and are bound to a single locator.
- At most one template per
(market_code, template_type); an edit overwrites it.
Caveats
- No failure path. Render/generate errors are logged and dropped; no failure event, retry, or DLQ.
- JWT enforcement depends on
KEYCLOAK_JWKS_URI. When unset (test mode) the business routes mount without JWT; compose sets it in every deployed environment. - Non-invoice generation is not deduped. Replaying
policy.issuedorclaim.approvedproduces a new document with a new locator; onlyINVOICEhas an idempotency key. - Templates are not versioned. An upsert overwrites the previous template with no history.
Planned, not implemented
- Admin template management. Routes to read/publish/rollback versioned templates (the parked
TemplateService). - Object storage. Move document bytes to GCS/S3 with signed URLs to bound Postgres growth.
- Failure events. A
document.render.failedevent with DLQ, retry/backoff, and a Notifications alert workflow. - Idempotency for non-invoice types.
source_event_iddedup andsha256content hashing.
