Skip to content
Updated Jul 12, 2026

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.templatesOne HTML template per (market_code, template_type). Upserted in place; not versioned.
documents.documentsGenerated document instances. Rendered PDF bytes plus party_locator, policy_locator, scheme_locator, invoice_locator (all indexed).
documents.outboxTransactional outbox for reliable Kafka publishing of document.ready.

Rendering

StageImplementation
Template lookupdocuments.templates row by (market_code, template_type). Market defaults to GB when the event payload omits marketCode.
HTML compositionGo 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 generationHeadless 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

MethodPathAuthDescription
GET/documentsJWT + tenancyList document metadata. Filters: partyLocator, policyLocator, schemeLocator, invoiceLocator, type, limit (at least one locator required, else 400).
GET/documents/{locator}JWT + tenancyStream the stored PDF inline. Foreign locator → 404.
GET/documents/{locator}/download-urlJWT + tenancyMint a signed download link: 200 {url, expiresAt} (RFC3339).
GET/documents/{locator}/content?exp=&sig=Public, signature-verifiedServe the PDF as Content-Disposition: attachment.
POST/internal/documents/generateCluster-onlySynchronous generate for internal callers; blocked at the gateway edge.
GET/healthz, /readyznoneLiveness / 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_locator matches 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/schemeLocator filter (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-Service secret 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 eventTypeTemplate type rendered
policy.issued, policy.endorsedPOLICY_SCHEDULE
invoice.finalised, invoice.paidINVOICE
claim.approvedEOB
prior-auth.decidedPRE_AUTH_LETTER
policy.renewal_dueRENEWAL_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.

eventTypeEmitted whenState subjects
document.readydocument rendered and persisted, same transaction as the outbox rowdocument
document.reissueda policy.endorsed re-render superseded an existing document; mirrors document.ready so consumers can tell a superseding schedule from a duplicate first issuedocument
document.generation_faileda Generate call failed; stage names the broken transition (fetch_template, render_template, render_pdf, persist) and reason carries the errornone (no document row exists on a failure)
document.downloadeda document's PDF bytes were actually served (JWT-gated or signed-URL route); minting a download URL alone does not fire itdocument

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

DependencyPurposeFailure mode
Postgres (documents schema)PersistenceHard fail
KafkaEvent input + outbox publishGeneration pauses when broker is down; outbox publishes on recovery
group-scheme-service (GROUP_SCHEME_URL)Scheme-ownership resolution for tenancyFail closed: reads needing scheme resolution return 503
Billing (BILLING_URL)Invoice enrichment at render timeRender error, event skipped
Policy AdminParty name enrichment for invoicesDegraded content

Invariants

  • A document row and its document.ready outbox row are written in one transaction.
  • At most one INVOICE document 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.issued or claim.approved produces a new document with a new locator; only INVOICE has 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.failed event with DLQ, retry/backoff, and a Notifications alert workflow.
  • Idempotency for non-invoice types. source_event_id dedup and sha256 content hashing.

Olly Health Insurance Platform