Skip to content
Updated Aug 22, 2026

Operational depth: preferences, templates & the burn ledger

Schema deep-dive · living document · #20 in the reading sequence

Tablesnotifications.notification_preferences[1] · documents.templates[2] · eligibility.accumulator_applications[3]
Owner servicesnotifications (:4006) · document-service (:4013) · eligibility (:4002) - three databases, three owners, no shared write path
Locatornone minted here - natural keys: party_locator (prefs), (market_code, template_type) (templates), (claim_locator, claim_line_id) (ledger)
Last updated2026-08-21
Companioncompletes: Consent, documents & notifications (#12) · Eligibility projection (#6) · Claim (#7) · Notifications posture · Eligibility & Accumulators (narrative)

1. Scope and usage

This page deliberately collects three single-table extensions that each complete an already-covered domain. They share no code, no database and no owner; what unites them is that each is the operational depth behind a headline table another page already walked:

  • notification_preferences - the per-party routing prefs behind the delivery log. The notifications page covered notification_log (what the platform told the party); this is the small sibling that says, per party, which channel and which event types the member wants. The schema carries this per-member routing ahead of the stage that routes on it; today the live paths deliver through Novu, which holds the canonical subscriber preference (§3).
  • templates - the doc-gen templates behind the rendered PDFs. That same page covered documents.documents (the rendered bytes); this is the (market, type) row whose content_html produced them. Eight rows define the entire set of documents the platform can render.
  • accumulator_applications - the claim-line to allowance burn ledger behind the counter. The eligibility page covered coverage_accumulators (the running total) and the claim page featured this ledger in its worked example; here it gets its own page as the forward record of every accumulator burn - the cross-service double-entry that makes consumption exactly-once.

Each table is small (6, 8 and 51 live rows) and each is derived or configuration rather than a source of truth: destroy the preferences and the delivery log survives; destroy the templates and the rendered PDFs survive; destroy the ledger and the accumulator counter survives (but loses the ability to prove or reverse a single burn). They are grouped here because a page each would be three paragraphs, and because reading them together shows the same pattern three times: a headline table, and the quiet operational table that configures or audits it.

2. Boundaries and relationships

This is not…That concern lives inJoin
the delivery record - a preference row is a routing intent, not proof anything was sentnotifications.notification_log (page #12)party_locator
the contact record - the dispatcher fetches email/phone live from policy-admin per event; the email/phone columns here are carried but empty on every live rowParty (policy-admin)party_locator
the delivering preference model - the two live-delivery paths (SendDirect[9], SendSlackDM[37]) trigger Novu workflows, and Novu holds the subscriber preference that governs a message it delivers. Two preference models, one per side of the hop (§3, §9)Novu (notifications.dev.hiolly.com)subscriber id = party_locator
the rendered document - a template is the HTML source; the PDF bytes are a separate rowdocuments.documents (page #12)market_code + template_type
the template engine - templates are rows; rendering is html/template in-process[20] then wkhtmltopdfdocument-service-
the running counter - the ledger is one row per burn; the sum lives elsewhereeligibility.coverage_accumulators (page #6)(party_id, term_id, accumulator_type, coverage_term_key)
the claim - the ledger records that a claim line burned allowance, not the claim itselfclaims.claim_lines (page #7)claim_locator, claim_line_id (text, no FK)
a reversal ledger - it is the append-only forward record; the un-apply that reverses a burn is the extension point in §9, and the (claim_locator, claim_line_id) key is what makes it exact§9(claim_locator, claim_line_id)

Zero foreign keys across all three. party_locator, market_code, claim_locator and claim_line_id are all soft references - the cross-service no-FK rule (data architecture). The ledger's claim_line_id is text, not uuid, precisely so it can hold a fallback key when a line has no id (§3).

3. Structure

DDL[1][2][3] · Go models[5][6][7]

notification_preferences

One row per party (UNIQUE party_locator).

FieldTypeReqNotes
iduuidPK
party_iduuidpolicy-admin's party uuid
party_locatortextUNIQUE - the routing subject and the lookup key
emailtextCarried, writable via the API, empty on every live row (see below)
phonetextSame - contact truth lives on the Party
preferred_channeltextDefault 'EMAIL'; EMAIL | SMS | IN_APP (application enum)
opted_out_eventsjsonbDefault '[]'; array of event-type strings
created_at / updated_attimestamptzBookkeeping

templates

One row per (market_code, template_type).

FieldTypeReqNotes
iduuidPK, gen_random_uuid()
market_codetext'GB' on every live row; UNIQUE with template_type
template_typetextUNIQUE with market_code; the eight live values are the whole render set
content_htmltextThe full Go html/template source, rendered per document
created_at / updated_attimestamptzupdated_at moves on every upsert

accumulator_applications

One row per burn; UNIQUE (claim_locator, claim_line_id).

FieldTypeReqNotes
iduuidPK, minted per apply
claim_locatortextThe consuming CLM-; separately indexed
claim_line_idtextTEXT - the line uuid, or the coverage_term_key when the line has no id (below)
party_iduuidWhose allowance moved
term_iduuidWhich policy year (the accumulator scope)
accumulator_typetextThe resolved type: SESSION_LIMIT | BENEFIT_LIMIT | USAGE
coverage_term_keytextThe burned module key (gp_video, diagnostics, …)
amountnumeric(14,4)In the accumulator's own unit - sessions or pounds
applied_attimestamptzDefault now()

Field-by-field: what and why

notification_preferences.preferred_channel / opted_out_events - read on the event-driven path. The event-driven Dispatch pipeline (the Kafka consumer) does consult this table (it is wired with the prefs repo at boot[16]): after resolving the party's contact it loads preferences, lets preferred_channel override the default EMAIL, and skips the event entirely if its type is in opted_out_events[8][11]. The notifications page records the producer-side gap on that path: producers omit the envelope partyLocator the consumer reads, so an event resolves against "", 404s and logs SKIPPED - a defect owned by that page, and the reason this preference read has not yet chosen a channel for a delivered message. The path that does deliver, SendDirect (POST /send → Novu), states in its own comment that it "bypasses the event-log / preferences / party-lookup pipeline"[9] and delivers through Novu (SMTP only as a fallback) instead[10]. The architectural fact a reader needs is that there are two preference models, one on each side of the Novu hop. notification_preferences is the Olly-side record - per party, with a channel and an opt-out list the Go dispatcher understands, and the shape member self-service writes through the API (§7). Novu holds a subscriber preference model of its own, keyed on the subscriber id the client sends; the client's own comment records that this is the sending email today and party_locator later[36]. The Olly model carries the member's stated routing; the Novu model governs what Novu delivers. §9 sets out the two ways to converge them and which file each change lands in.

email / phone - carried and empty. Both columns are writable through the member API[13] but empty on all six live rows, because contact truth lives on the Party and the dispatcher fetches it live per event. They are carried against a future member override; the Party record stays canonical for contact today.

templates.template_type - the eight rows are the render set. A document can only be generated if a (market_code, template_type) row exists: Generate fetches the template by that pair and fails at fetch if it is missing[20]. The eight live rows, all market_code = 'GB':

template_typeseeded bycontent_html bytesfirst created
POLICY_SCHEDULEout of band (no migration - §9)5 1962026-07-10
INVOICEmigration 0006[22]4 8362026-07-12
EMPLOYER_CONTRACTmigration 0008[23]11 8092026-07-12
IPIDmigration 0009 (#1675)[24]3 7392026-08-19
CERTIFICATE_OF_INSURANCEmigration 0009 (#1675)[24]2 1192026-08-19
POLICY_WORDINGmigration 0009 (#1675)[24]2 7332026-08-19
DIRECT_DEBIT_GUARANTEEmigration 0009 (#1675)[24]2 1432026-08-19
MEMBERSHIP_CARDmigration 0009 (#1675)[24]1 4582026-08-19

The corpus grew from three to eight on 2026-08-19: the first three back the event-driven and onboarding flows the notifications page described (which, dated 2026-08-18, saw only those three), and the five added by #1675 are the direct-to-consumer health-document pack. Only three of the eight (POLICY_SCHEDULE, INVOICE) are reachable from the Kafka event map[21]; EMPLOYER_CONTRACT and the five D2C types are generated only through the internal POST /internal/documents/generate composer path.

content_html is upsert-immutable. The repository's only write is an ON CONFLICT (market_code, template_type) DO UPDATE SET content_html, updated_at[17] - there is no delete path in the service - so a re-run migration replaces the body in place and moves updated_at (five 0009 rows show created_at = updated_at; the three older rows likewise, none re-seeded since).

accumulator_applications.claim_line_id - TEXT and the idempotency discriminator. It is text, not uuid, because a line without an id falls back to its coverage_term_key: lineID := app.ClaimLineID; if lineID == "" { lineID = app.CoverageTermKey }[28]. The point is that replaying the claim stays safe even for a line the caller never numbered - the failure that matters. Two GP-video lines on one claim are two rows; the same line replayed is one.

amount is in the accumulator's own unit, and eligibility picks the unit. The claims caller sends both quantity and amount per line[30]; consumedUnits returns the quantity a SESSION_LIMIT/USAGE accumulator meters (events) and the money amount for everything else, and resolveAccumulatorType lets the stored accumulator type win over any caller hint[29]. So a gp_video row stores amount = 1.0000 meaning one session, and a diagnostics row stores amount = 120.0000 meaning £120 - the number is unit-free in the column and only means something alongside accumulator_type.

The ledger INSERT is the lock. The whole reason this table exists is in migration 0009's header comment[4]: applying a line is a non-idempotent read-modify-write (consumed_amount = consumed_amount + n), and the previous guard (last_claim_locator != $claim) only remembered the most recent claim, so apply(A), apply(B), apply(A) counted A twice. Apply now runs one transaction: INSERT … ON CONFLICT (claim_locator, claim_line_id) DO NOTHING; if the insert took no row it is a replay and returns before touching the counter; if the subsequent UPDATE moves no accumulator, a sentinel error rolls the ledger row back so the slot is never banked without the burn[26]. The row in this table is the proof a specific line already consumed.

4. Invariants

InvariantEnforced by
One preference row per partyDB UNIQUE party_locator[1]
preferred_channel defaults to EMAILDB DEFAULT[1]
preferred_channelApplication only - handler validation map[13]; no DB CHECK
A member reads/writes only their own prefsApplication: JWT party_locator claim overrides the path (#1544)[13]; /preferences/list refuses any member caller[15]
One template per (market, type)DB UNIQUE[2]
content_html is presentDB NOT NULL[2]
A template is replaced, never deletedConvention + code shape - only Upsert exists in the repository[17]; nothing in the DB forbids a delete
A claim line consumes at most onceDB UNIQUE (claim_locator, claim_line_id) + the INSERT-claims-the-slot transaction[3][26]
Ledger row ⇔ accumulator movedApplication: same transaction, sentinel rollback when no accumulator exists[26]
amount is in the right unitApplication: consumedUnits meters by the stored accumulator_type[29]; no DB constraint on the number
accumulator_type vocabularyNothing - no CHECK; the writer resolves it from the stored accumulator row[29]
Ledger is append-onlyConvention - no UPDATE/DELETE path exists (an errNoAccumulator rollback is the only removal, and only of a row that never committed)
Row changes captured to CDCDebezium publications dbz_notifications, dbz_documents, dbz_eligibility (live \d)

5. Lifecycle

None of the three has a status machine. A preference row is upserted in place (the API preserves id/party_id and overwrites the mutable fields[13]); a template is upserted and otherwise immutable[17]; a ledger row is written once and never changes. The lifecycle that matters is the burn - the cross-service transaction that writes the ledger row, and the reason this page frames the table as double-entry:

The claims side builds the request when a claim reaches APPROVED, keying each line by line.ID[33] and sending it over HTTP[32]; the eligibility handler resolves the type, applies each line, and on a real (non-replay) burn fires a fire-and-forget eligibility.accumulators.applied event so utilisation and notifications can react[31]. Idempotency is what lets claims call this unconditionally on every path to APPROVED (the claim page is the other half of this story).

6. Populated example

One live row-set per table (values from the dev-2 databases, 2026-08-21; no free-text PII lives in any of these tables, so nothing is redacted; locators and uuids are real).

notification_preferences: the SMS member

The six live rows are all seeded parties (PTY-2026-000004 through -000009), every one at defaults - empty contact, no opt-outs, five EMAIL and one SMS. The member-facing PUT route exists (§7) and is ready; no member has set one yet, so the routing capability is in place ahead of the members who will use it. The single non-default row:

json
{
  "party_locator": "PTY-2026-000006",
  "preferred_channel": "SMS",
  "opted_out_events": [],
  "email": "",
  "phone": ""
}
FieldRead byWhat actually happens
preferred_channel: SMSthe event-driven Dispatch[8]selects the SMS arm on that path; the paths that deliver today trigger Novu, where the equivalent choice sits on the Novu subscriber (§3, §9)
phone: ""the SMS arm's guardeven on the dispatch path this row would SKIP "no phone number on file" - contact truth is on the Party
opted_out_events: []isOptedOut[11]nothing opted out

templates: the render set

The eight content_html rows (all GB) are the full inventory in §3's table. The one the platform generates most is POLICY_SCHEDULE - the member Benefit Schedule PDF walked on the notifications page and plan documents. Generate reads it by (market_code, template_type) and renders content_html with the document's data[20]; a missing pair fails at fetch, which is why the five D2C types could not produce a document before #1675 seeded their rows.

accumulator_applications: the burn that ties three pages together

The most recent gp_video burn for member PTY-2026-000459 - the exact row the claim page and the eligibility page both reference from their own side of the service boundary. Pulled live:

json
{
  "id": "a1829c9e-b8e9-4e3c-80ae-9816573c86d9",
  "claim_locator": "CLM-2026-002725",
  "claim_line_id": "3098ce13-a064-47a8-95ef-a0cc721a0dd9",
  "party_id": "53b82c7e-15cb-5daf-a04d-bee5f4c664cd",
  "term_id": "e5e93520-4367-4aea-8c97-fc3aa8db62c8",
  "accumulator_type": "SESSION_LIMIT",
  "coverage_term_key": "gp_video",
  "amount": "1.0000",
  "applied_at": "2026-07-30T14:24:49Z"
}
FieldRead byWhat actually happens
(claim_locator, claim_line_id)the UNIQUE constraintthis claim's line 3098ce13 has burned exactly once; a redelivery or resubmit inserts nothing
accumulator_type: SESSION_LIMIT + amount: 1.0000consumedUnits[29]one session, not £1 - the same one session that moved coverage_accumulators.gp_video to 9-of-5 on the eligibility page
claim_line_id = the claim line's uuididempotencyit is claims.claim_lines.id verbatim (claim page) - the double-entry's join across the service boundary
party_id / term_idthe consuming UPDATE's WHEREscope the burn to this member's 2026 policy-year accumulator

Live population for context: 51 ledger rows - 48 SESSION_LIMIT (48.0000 sessions in total) and 3 BENEFIT_LIMIT (£325.0000). No USAGE-type application exists: unmetered modules get an accumulator row but nothing has ever consumed one (§9).

7. Who references these tables

WhereColumn / mechanismMeaning there
notifications Dispatch (Kafka consumer)GetByPartyLocator on party_locator[12]the one in-request read of preferences; the notifications page covers why this pipeline's events resolve against an empty party locator
member app / member-portal-apiGET/PUT /preferences/{party} (JWT-scoped)[13]"my notification settings"
web-adminGET /preferences/list (members refused)[15]ops view of every party's prefs
document-service GenerateGetByMarketAndType(market, type)[20]fetch the HTML to render a PDF - the only live reader of templates
document-service consumereventToTemplateType[21]maps policy.issued/invoice.paid to POLICY_SCHEDULE/INVOICE template rows
claims (adjudication)PATCH …/accumulators/{term}/apply[32]writes a ledger row per approved line (the sole writer)
eligibility internalsListApplications(claimLocator)[27]the exact-rows-to-undo read the reversal path builds on; it names itself "the basis for reversing" a claim's consumption (§9)
BigQuery olly_cdcDebezium dbz_notifications / dbz_documents / dbz_eligibilityanalytics/BI lineage - the only reader of the ledger today

8. Design determinations

  1. Idempotency by ledger, not by last-writer memory. The (claim_locator, claim_line_id) UNIQUE plus the INSERT-claims-the-slot transaction replaced the last_claim_locator guard that double-counted on A, B, A. Migration 0009's header comment is the decision record[4]; the table shipped in the accumulator-apply-chain fix (2026-07-12).
  2. Eligibility owns the metering unit, so the ledger stores a unit-free number. Callers send both quantity and amount; the stored accumulator type decides which the ledger records[29]. Claims keeps no term-key → type map to drift.
  3. A line with no id still deduplicates - against its coverage-term key.claim_line_id is TEXT so the fallback fits; replaying the claim stays safe[28].
  4. Documents are configured by data, one template row per (market, type). Adding a market or a document type is a templates row, not a deploy; the D2C health pack (#1675, 2026-08-19) added five types this way[24].
  5. Preferences are modelled on both sides of the Novu hop, on purpose.SendDirect routes around this table by Flow-0 scope (#1164)[9], and the Novu client is deliberately narrow - one endpoint, no subscriber CRUD, because subscriber management belongs to Novu[35]. That leaves the Olly table as the member-stated record and Novu as the delivery-time authority; §9 gives the two convergence routes and their cost.
  6. Member self-service is claim-scoped. The JWT party_locator overrides the path on the preference read/write, and /preferences/list refuses member callers (#1544)[13][15].

9. Caveats and extensibility

Group and individual cover share all three unchanged. Preferences and the ledger key on party_locator / party_id, which is a member whether the policy is scheme-based or direct-to-consumer; the group-ness lives in authorisation upstream. Templates are the one place group vs individual shows in the data, and it shows as content, not structure: EMPLOYER_CONTRACT is the employer-facing template, the five #1675 types are the D2C member pack, and POLICY_SCHEDULE/INVOICE serve both - all eight are just (market_code, template_type) rows in one table. A new market is a new market_code value on the same rows; no schema change anywhere.

Extension points - when, what, where:

When we need …What to addWhere
members to set their own notification routing (choose a channel, mute an event type, and have a delivered message honour it)the delivering paths to read this table before they trigger: apply preferred_channel to the workflow/channel choice and filter on opted_out_events, the way the event-driven Dispatch already does[8][11]services/notifications/internal/dispatch/dispatcher.go - SendDirect[9] and SendSlackDM[37]. The repository read already exists (GetByPartyLocator[12]) and the dispatcher is constructed with the prefs repo[16]; no schema change
the two preference models to converge (so a member's stated routing is the routing Novu applies)pick a direction. Push: mirror each row onto the Novu subscriber's channel preferences on write, which means adding subscriber CRUD to a client whose package doc records it as trigger-only by design[35]. Pull: keep Novu authoritative and make this table the member-facing projection of it. Either way, key both sides alike by moving the Novu subscriber id from the sending email to party_locator[36]services/notifications/internal/novu/client.go (subscriber id and any new endpoint) + services/notifications/internal/dispatch/ (which side is read at send time). The Novu-side workflows and integrations stay in the Novu dashboard
a new market or a new document typeone templates row - (market_code, template_type, content_html) - seeded by a document-service migration; plus one line in eventToTemplateType if it should also be event-driven[21]services/document-service/migrations/. No migration to the schema itself: the table is keyed (market_code, template_type) with the body in content_html, so a type is a row and a market is a value. The D2C pack added five types exactly this way[24], and Generate finds them by that pair with no code change[20]
claims to be reversed or adjusted after they burned allowance (a rejection after approval, a corrected line, a resubmission for less)an un-apply that mirrors Apply: read the claim's ledger rows, decrement coverage_accumulators.consumed_amount by each amount, and remove or negate the ledger row - in one transaction, so the slot and the counter move togetherservices/eligibility/internal/repository/gorm_accumulators.go (mirror Apply[26]), a route beside the apply handler in internal/handler/internal.go[28], called from services/claims/internal/client/eligibility.go[32]. The idempotency key does the hard part: (claim_locator, claim_line_id) identifies exactly the rows to undo, and ListApplications returns them[27]
in-process readers of the burn ledger (an ops "what did this claim consume", a reconciliation job, a member-facing statement)a handler over ListApplications, which is already written and returns a claim's rows[27]services/eligibility/internal/handler/internal.go. Today the 51 rows reach their readers through Debezium → BigQuery (dbz_eligibility), which is why no in-process caller exists yet
a new metering type in the ledger (DEDUCTIBLE, OUT_OF_POCKET_MAX)nothing structural - consumedUnits defaults every unknown type to money metering[29], and accumulator_type has no CHECK, so such rows meter correctly the day something seeds themservices/eligibility/internal/handler/internal.go if the new type should meter as events rather than money
a member contact override (notify this address, not the Party's)a writer that populates the email / phone columns already carried on the row, and a dispatcher that prefers them over the live policy-admin lookupservices/notifications/internal/handler/preferences.go[13] already accepts both fields; the columns exist, so this is dispatcher precedence, not schema

Live-row facts worth carrying:

  • All six preference rows are at seed defaults - contiguous PTY-2026-000004..-000009, empty contact, no opt-outs. The read/write route is live, so the first member-set row needs no change to anything.
  • No USAGE application has landed. USAGE is a valid ledger type and consumedUnits meters it as sessions; unmetered modules hold limit-0 USAGE accumulators that nothing has consumed, so every live row is SESSION_LIMIT or BENEFIT_LIMIT.
  • amount is unit-free by design (determination 2): 1.0000 (a session) and 120.0000 (£120) sit in one column, and the meaning comes from accumulator_type. Summing amount across types gives a number that means nothing - read the two columns together.

Known defects - the fix, and where:

These are wiring mismatches rather than stages the product has not reached, and each is a concrete change.

  • Seed POLICY_SCHEDULE from a migration. The oldest template row (2026-07-10) was inserted out of band; migration 0007 only mentions it in a comment about the ''-vs-NULL invoice bug[34], so a clean rebuild comes up without the template the platform generates most, and Generate fails at fetch for every Benefit Schedule. Fix: add a document-service/migrations/ upsert for ('GB','POLICY_SCHEDULE', …) in the same form as the INVOICE and D2C seeds[22][24].
  • Correct or drop the in-repo template seeder. seed_document_templates.go writes lowercase-hyphen types (policy-schedule, eob, pre-auth-letter, renewal-notice)[25] that match neither the uppercase eventToTemplateType output nor any live row, so running it adds four templates nothing fetches. Fix: in infra/local/seeder/seed_document_templates.go, use the uppercase types the consumer and the migrations agree on, or delete the file now that the migrations seed the corpus.
  • Reach TemplateService or remove it. main.go constructs it and then discards the value: _ = tmplSvc // available for future admin endpoints[19], so GetTemplate/UpsertTemplate[18] cannot be called and the live read path is DocumentService going to the repository directly. Fix: register the admin template routes it was built for in services/document-service/cmd/server/main.go, or drop the construction so the service has one template read path.
  • Back the v2 preference routes with the table. GET/PUT /preferences decode and echo a boolean body without touching notification_preferences[14], so a caller on that shape gets a 200 and stores nothing - a second preference contract that disagrees with the persisted one. Fix: in services/notifications/internal/handler/preferences.go, route them through the same repository Upsert the party-scoped handlers use[12], or remove them so /preferences/{party} is the only contract.

References

Code links are pinned to commit b61c5802 on main (2026-08-21); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. notifications/migrations/0002_create_notification_preferences.sql - preferences DDL; UNIQUE party_locator L5, preferred_channel DEFAULT 'EMAIL' L8
  2. document-service/migrations/0002_create_templates.sql - templates DDL; content_html NOT NULL L7, UNIQUE (market, type) L10
  3. eligibility/migrations/0009_create_accumulator_applications.sql - ledger DDL; UNIQUE (claim_locator, claim_line_id) L24
  4. 0009_create_accumulator_applications.sql (header) - the A-B-A double-count story: why the ledger exists
  5. packages/go/domain/notifications.go#L9 - NotificationPreferences model
  6. document-service/internal/repository/repository.go#L16 - Template model (documents.templates)
  7. eligibility/internal/repository/repository.go#L46 - AccumulatorApplication + ApplyInput models (the ledger model lives in the repo package, not domain)
  8. notifications/internal/dispatch/dispatcher.go#L321 - Dispatch: loads preferences, preferred_channel override, opt-out check
  9. dispatcher.go#L173 - SendDirect comment: bypasses preferences/party-lookup (Flow-0 #1164)
  10. dispatcher.go#L187 - SendDirect: Novu-first, SMTP fallback
  11. dispatcher.go#L383 - isOptedOut over opted_out_events
  12. notifications/internal/repository/gorm_preferences.go#L20 - GetByPartyLocator + Upsert (ON CONFLICT party_locator)
  13. notifications/internal/handler/preferences.go#L14 - get/update prefs; #1544 member-claim override; channel validation map
  14. preferences.go#L147 - the v2 stub routes (decode/echo, never touch the table)
  15. preferences.go#L151 - listPreferences refuses member callers
  16. notifications/cmd/server/main.go#L137 - prefs repo wired into the dispatcher
  17. document-service/internal/repository/gorm_template.go#L19 - Upsert (replace content_html) / GetByMarketAndType / List
  18. document-service/internal/service/template.go#L20 - TemplateService.GetTemplate / UpsertTemplate (unreachable today)
  19. document-service/cmd/server/main.go#L72 - _ = tmplSvc // available for future admin endpoints
  20. document-service/internal/service/document.go#L105 - Generate: fetch template by (market, type), render content_html
  21. document-service/internal/kafka/consumer.go#L30 - eventToTemplateType: which events reach which template rows
  22. document-service/migrations/0006_invoice_documents.sql#L37 - INVOICE template seed
  23. document-service/migrations/0008_employer_contract.sql#L21 - EMPLOYER_CONTRACT template seed
  24. document-service/migrations/0009_d2c_health_documents.sql - the five D2C template seeds (IPID, CERTIFICATE_OF_INSURANCE, POLICY_WORDING, DIRECT_DEBIT_GUARANTEE, MEMBERSHIP_CARD) (#1675)
  25. infra/local/seeder/seed_document_templates.go#L28 - the stale lowercase-hyphen seeder (matches no live row)
  26. eligibility/internal/repository/gorm_accumulators.go#L54 - Apply: INSERT-claims-the-slot transaction + errNoAccumulator rollback
  27. gorm_accumulators.go#L100 - ListApplications, "the basis for reversing it" (defined, uncalled)
  28. eligibility/internal/handler/internal.go#L173 - apply handler: resolve type, line-id fallback, consumedUnits, Apply
  29. internal.go#L104 - consumedUnits (sessions vs pounds) + resolveAccumulatorType (stored row wins)
  30. internal.go#L67 - AccumulatorApply wire shape: both quantity and amount sent
  31. internal.go#L224 - fire-and-forget eligibility.accumulators.applied on a real burn
  32. claims/internal/client/eligibility.go#L182 - the claims-side apply PATCH call
  33. claims/internal/service/claim.go#L1041 - buildAccumulatorRequest (claimLineId = line.ID) + applyAccumulators
  34. document-service/migrations/0007_invoice_locator_null_not_empty.sql#L3 - the ''-vs-NULL comment that mentions POLICY_SCHEDULE (but does not seed it)
  35. notifications/internal/novu/client.go#L1 - package doc: trigger-only by design, "no subscriber CRUD, no workflow management - those live in the Novu dashboard"
  36. notifications/internal/novu/client.go#L37 - Subscriber: the id Novu keys on is the email today, party_locator later
  37. dispatcher.go#L256 - SendSlackDM: Novu trigger with Slack overrides, no SMTP fallback and no preference read

Live-schema facts (Debezium publications, the 6 preference rows and their defaults, the 8-row template inventory with content_html sizes and created dates, the 51 ledger rows by type, and the worked CLM-2026-002725 row) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly against databases notifications, documents and eligibility · \d notifications.notification_preferences, \d documents.templates, \d eligibility.accumulator_applications, and the row/count selects above, 2026-08-21.

Olly Health Insurance Platform