Operational depth: preferences, templates & the burn ledger
Schema deep-dive · living document · #20 in the reading sequence
| Tables | notifications.notification_preferences[1] · documents.templates[2] · eligibility.accumulator_applications[3] |
| Owner services | notifications (:4006) · document-service (:4013) · eligibility (:4002) - three databases, three owners, no shared write path |
| Locator | none minted here - natural keys: party_locator (prefs), (market_code, template_type) (templates), (claim_locator, claim_line_id) (ledger) |
| Last updated | 2026-08-21 |
| Companion | completes: 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 coverednotification_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 covereddocuments.documents(the rendered bytes); this is the(market, type)row whosecontent_htmlproduced 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 coveredcoverage_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 in | Join |
|---|---|---|
| the delivery record - a preference row is a routing intent, not proof anything was sent | notifications.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 row | Party (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 row | documents.documents (page #12) | market_code + template_type |
the template engine - templates are rows; rendering is html/template in-process[20] then wkhtmltopdf | document-service | - |
| the running counter - the ledger is one row per burn; the sum lives elsewhere | eligibility.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 itself | claims.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).
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
party_id | uuid | ✓ | policy-admin's party uuid |
party_locator | text | ✓ | UNIQUE - the routing subject and the lookup key |
email | text | Carried, writable via the API, empty on every live row (see below) | |
phone | text | Same - contact truth lives on the Party | |
preferred_channel | text | ✓ | Default 'EMAIL'; EMAIL | SMS | IN_APP (application enum) |
opted_out_events | jsonb | Default '[]'; array of event-type strings | |
created_at / updated_at | timestamptz | ✓ | Bookkeeping |
templates
One row per (market_code, template_type).
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK, gen_random_uuid() |
market_code | text | ✓ | 'GB' on every live row; UNIQUE with template_type |
template_type | text | ✓ | UNIQUE with market_code; the eight live values are the whole render set |
content_html | text | ✓ | The full Go html/template source, rendered per document |
created_at / updated_at | timestamptz | ✓ | updated_at moves on every upsert |
accumulator_applications
One row per burn; UNIQUE (claim_locator, claim_line_id).
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK, minted per apply |
claim_locator | text | ✓ | The consuming CLM-; separately indexed |
claim_line_id | text | ✓ | TEXT - the line uuid, or the coverage_term_key when the line has no id (below) |
party_id | uuid | ✓ | Whose allowance moved |
term_id | uuid | ✓ | Which policy year (the accumulator scope) |
accumulator_type | text | ✓ | The resolved type: SESSION_LIMIT | BENEFIT_LIMIT | USAGE |
coverage_term_key | text | ✓ | The burned module key (gp_video, diagnostics, …) |
amount | numeric(14,4) | ✓ | In the accumulator's own unit - sessions or pounds |
applied_at | timestamptz | ✓ | Default 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_type | seeded by | content_html bytes | first created |
|---|---|---|---|
POLICY_SCHEDULE | out of band (no migration - §9) | 5 196 | 2026-07-10 |
INVOICE | migration 0006[22] | 4 836 | 2026-07-12 |
EMPLOYER_CONTRACT | migration 0008[23] | 11 809 | 2026-07-12 |
IPID | migration 0009 (#1675)[24] | 3 739 | 2026-08-19 |
CERTIFICATE_OF_INSURANCE | migration 0009 (#1675)[24] | 2 119 | 2026-08-19 |
POLICY_WORDING | migration 0009 (#1675)[24] | 2 733 | 2026-08-19 |
DIRECT_DEBIT_GUARANTEE | migration 0009 (#1675)[24] | 2 143 | 2026-08-19 |
MEMBERSHIP_CARD | migration 0009 (#1675)[24] | 1 458 | 2026-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
| Invariant | Enforced by |
|---|---|
| One preference row per party | DB UNIQUE party_locator[1] |
preferred_channel defaults to EMAIL | DB DEFAULT[1] |
preferred_channel ∈ | Application only - handler validation map[13]; no DB CHECK |
| A member reads/writes only their own prefs | Application: 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 present | DB NOT NULL[2] |
| A template is replaced, never deleted | Convention + code shape - only Upsert exists in the repository[17]; nothing in the DB forbids a delete |
| A claim line consumes at most once | DB UNIQUE (claim_locator, claim_line_id) + the INSERT-claims-the-slot transaction[3][26] |
| Ledger row ⇔ accumulator moved | Application: same transaction, sentinel rollback when no accumulator exists[26] |
amount is in the right unit | Application: consumedUnits meters by the stored accumulator_type[29]; no DB constraint on the number |
accumulator_type vocabulary | Nothing - no CHECK; the writer resolves it from the stored accumulator row[29] |
| Ledger is append-only | Convention - no UPDATE/DELETE path exists (an errNoAccumulator rollback is the only removal, and only of a row that never committed) |
| Row changes captured to CDC | Debezium 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:
{
"party_locator": "PTY-2026-000006",
"preferred_channel": "SMS",
"opted_out_events": [],
"email": "",
"phone": ""
}| Field | Read by | What actually happens |
|---|---|---|
preferred_channel: SMS | the 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 guard | even 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:
{
"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"
}| Field | Read by | What actually happens |
|---|---|---|
(claim_locator, claim_line_id) | the UNIQUE constraint | this claim's line 3098ce13 has burned exactly once; a redelivery or resubmit inserts nothing |
accumulator_type: SESSION_LIMIT + amount: 1.0000 | consumedUnits[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 uuid | idempotency | it is claims.claim_lines.id verbatim (claim page) - the double-entry's join across the service boundary |
party_id / term_id | the consuming UPDATE's WHERE | scope 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
| Where | Column / mechanism | Meaning 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-api | GET/PUT /preferences/{party} (JWT-scoped)[13] | "my notification settings" |
| web-admin | GET /preferences/list (members refused)[15] | ops view of every party's prefs |
document-service Generate | GetByMarketAndType(market, type)[20] | fetch the HTML to render a PDF - the only live reader of templates |
| document-service consumer | eventToTemplateType[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 internals | ListApplications(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_cdc | Debezium dbz_notifications / dbz_documents / dbz_eligibility | analytics/BI lineage - the only reader of the ledger today |
8. Design determinations
- Idempotency by ledger, not by last-writer memory. The
(claim_locator, claim_line_id)UNIQUE plus the INSERT-claims-the-slot transaction replaced thelast_claim_locatorguard 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). - 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.
- A line with no id still deduplicates - against its coverage-term key.
claim_line_idis TEXT so the fallback fits; replaying the claim stays safe[28]. - Documents are configured by data, one template row per (market, type). Adding a market or a document type is a
templatesrow, not a deploy; the D2C health pack (#1675, 2026-08-19) added five types this way[24]. - Preferences are modelled on both sides of the Novu hop, on purpose.
SendDirectroutes 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. - Member self-service is claim-scoped. The JWT
party_locatoroverrides the path on the preference read/write, and/preferences/listrefuses 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 add | Where |
|---|---|---|
| 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 type | one 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 together | services/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 them | services/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 lookup | services/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
USAGEapplication has landed.USAGEis a valid ledger type andconsumedUnitsmeters it as sessions; unmetered modules hold limit-0USAGEaccumulators that nothing has consumed, so every live row isSESSION_LIMITorBENEFIT_LIMIT. amountis unit-free by design (determination 2):1.0000(a session) and120.0000(£120) sit in one column, and the meaning comes fromaccumulator_type. Summingamountacross 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_SCHEDULEfrom 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, andGeneratefails at fetch for every Benefit Schedule. Fix: add adocument-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.gowrites lowercase-hyphen types (policy-schedule,eob,pre-auth-letter,renewal-notice)[25] that match neither the uppercaseeventToTemplateTypeoutput nor any live row, so running it adds four templates nothing fetches. Fix: ininfra/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
TemplateServiceor remove it.main.goconstructs it and then discards the value:_ = tmplSvc // available for future admin endpoints[19], soGetTemplate/UpsertTemplate[18] cannot be called and the live read path isDocumentServicegoing to the repository directly. Fix: register the admin template routes it was built for inservices/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 /preferencesdecode and echo a boolean body without touchingnotification_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: inservices/notifications/internal/handler/preferences.go, route them through the same repositoryUpsertthe 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.
notifications/migrations/0002_create_notification_preferences.sql- preferences DDL; UNIQUEparty_locatorL5,preferred_channelDEFAULT 'EMAIL' L8document-service/migrations/0002_create_templates.sql- templates DDL;content_htmlNOT NULL L7, UNIQUE (market, type) L10eligibility/migrations/0009_create_accumulator_applications.sql- ledger DDL; UNIQUE(claim_locator, claim_line_id)L240009_create_accumulator_applications.sql(header) - the A-B-A double-count story: why the ledger existspackages/go/domain/notifications.go#L9- NotificationPreferences modeldocument-service/internal/repository/repository.go#L16- Template model (documents.templates)eligibility/internal/repository/repository.go#L46- AccumulatorApplication + ApplyInput models (the ledger model lives in the repo package, notdomain)notifications/internal/dispatch/dispatcher.go#L321- Dispatch: loads preferences,preferred_channeloverride, opt-out checkdispatcher.go#L173- SendDirect comment: bypasses preferences/party-lookup (Flow-0 #1164)dispatcher.go#L187- SendDirect: Novu-first, SMTP fallbackdispatcher.go#L383- isOptedOut overopted_out_eventsnotifications/internal/repository/gorm_preferences.go#L20- GetByPartyLocator + Upsert (ON CONFLICT party_locator)notifications/internal/handler/preferences.go#L14- get/update prefs; #1544 member-claim override; channel validation mappreferences.go#L147- the v2 stub routes (decode/echo, never touch the table)preferences.go#L151- listPreferences refuses member callersnotifications/cmd/server/main.go#L137- prefs repo wired into the dispatcherdocument-service/internal/repository/gorm_template.go#L19- Upsert (replace content_html) / GetByMarketAndType / Listdocument-service/internal/service/template.go#L20- TemplateService.GetTemplate / UpsertTemplate (unreachable today)document-service/cmd/server/main.go#L72-_ = tmplSvc // available for future admin endpointsdocument-service/internal/service/document.go#L105- Generate: fetch template by (market, type), render content_htmldocument-service/internal/kafka/consumer.go#L30- eventToTemplateType: which events reach which template rowsdocument-service/migrations/0006_invoice_documents.sql#L37- INVOICE template seeddocument-service/migrations/0008_employer_contract.sql#L21- EMPLOYER_CONTRACT template seeddocument-service/migrations/0009_d2c_health_documents.sql- the five D2C template seeds (IPID, CERTIFICATE_OF_INSURANCE, POLICY_WORDING, DIRECT_DEBIT_GUARANTEE, MEMBERSHIP_CARD) (#1675)infra/local/seeder/seed_document_templates.go#L28- the stale lowercase-hyphen seeder (matches no live row)eligibility/internal/repository/gorm_accumulators.go#L54- Apply: INSERT-claims-the-slot transaction + errNoAccumulator rollbackgorm_accumulators.go#L100- ListApplications, "the basis for reversing it" (defined, uncalled)eligibility/internal/handler/internal.go#L173- apply handler: resolve type, line-id fallback, consumedUnits, Applyinternal.go#L104- consumedUnits (sessions vs pounds) + resolveAccumulatorType (stored row wins)internal.go#L67- AccumulatorApply wire shape: both quantity and amount sentinternal.go#L224- fire-and-forget eligibility.accumulators.applied on a real burnclaims/internal/client/eligibility.go#L182- the claims-side apply PATCH callclaims/internal/service/claim.go#L1041- buildAccumulatorRequest (claimLineId = line.ID) + applyAccumulatorsdocument-service/migrations/0007_invoice_locator_null_not_empty.sql#L3- the ''-vs-NULL comment that mentions POLICY_SCHEDULE (but does not seed it)notifications/internal/novu/client.go#L1- package doc: trigger-only by design, "no subscriber CRUD, no workflow management - those live in the Novu dashboard"notifications/internal/novu/client.go#L37- Subscriber: the id Novu keys on is the email today,party_locatorlaterdispatcher.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.
