Skip to content

Spec: Notifications and vehicle ownership verification

Status: done Date: 2026-11-01

Problem Statement

A citizen only learns what has happened to their vehicles, fines and payments by opening the app and checking each screen. Nothing tells them when: - a fine has been issued against their vehicle - a dispute has been decided or a court has ruled - an admin has cleared or cancelled a fine - a payment has gone through, or a mobile-money payment has failed

The mobile Alerts tab and its unread badge exist, but they show hard-coded sample data. notification-service has a notifications table that nothing writes to. It has no event consumer and no push delivery. Its history endpoint trusts a userId query parameter, so any caller can read anyone's notifications.

Vehicle ownership has two problems: - A citizen can link any registered vehicle just by typing its number plate. Nothing checks that they own it. - Several citizens can link the same vehicle. A fine only records its vehicle, so the platform can't name one person who should be told about it.

Solution

Notifications. When something happens that the citizen needs to know about, TrafficGrid: 1. creates a notification record for them 2. sends it to their phone as a system-tray push notification (Android first) 3. lists it in the Alerts tab

The Alerts tab holds the full history, newest first. The citizen sees which alerts are unread, can mark one or all as read, and the tab and app icon show the unread count. Tapping a push or an alert opens the related fine, payment or vehicle.

Ownership. Each vehicle has at most one citizen owner. Linking a vehicle is now a claim that the Central Vehicle Registry (CVR) must verify: - The claimed vehicle shows on the citizen's Vehicles screen as "Verifying…" with no actions available. - After verification, which the mock CVR completes in about two minutes, the vehicle becomes fully usable and the citizen gets a "vehicle verified" alert. - If CVR rejects the claim, the vehicle disappears from the screen and the citizen gets a "verification failed" alert.

While a claim is pending or verified, nobody else can link that vehicle.

Notifications are only ever created from events the services already publish over Kafka. The services that own vehicles, fines and payments don't know notifications exist.

Test Seams

Following the agreed light testing scope:

  1. notification-service, end to end. One new Spring Boot test with an embedded Kafka broker and a Testcontainers Postgres database, in the same style as payments-service's payment-event listener integration test.
  2. It publishes real event messages onto the fines, payments and vehicles topics.
  3. It checks the outcome only through the citizen-facing notifications API.
  4. The vehicle-owner lookup and the Expo push sender are mocked beans.

This one seam covers every event-to-notification mapping, recipient choice, de-duplication, read state, device registration, push-failure handling and pruning of dead tokens. 2. vehicle-service, the existing HTTP API test (real Postgres, Kafka template mocked), extended with: - claiming a vehicle - the 409 conflict for a vehicle someone else has claimed - the mock CVR flow, with its delay set to zero and the poller triggered directly - the verify and reject events - the owner lookup - only verified links counting for ownership 3. payments-service, the existing event publisher and fines service unit tests, extended to check that every event carries an event id and that CREATE_FINE is only published after the transaction commits.

On mobile, the only new tests are plain unit tests for new pure helpers, such as mapping a notification to its category and deep link, following the existing src/lib tests. There are no new integration-tests module cases and no admin portal tests. The manual test cases in the Test Tracker are updated (see Documentation updates).

User Stories

Receiving notifications

  1. As a citizen, I want a push notification when a fine is issued against my vehicle, so that I know about it before the due date.
  2. As a citizen, I want the fine-issued notification to tell me the plate, offence, amount and due date, so that I can decide what to do without opening the app.
  3. As a citizen, I want to be told when a fine on my vehicle is referred to court, so that I know I can't simply pay it.
  4. As a citizen, I want to be told when my dispute is upheld and the fine cleared, so that I know I owe nothing.
  5. As a citizen, I want to be told when my dispute is rejected, along with my new due date, so that I pay in time.
  6. As a citizen, I want to be told the outcome of a court referral (penalty or dismissed), so that I know whether the fine is payable again.
  7. As a citizen, I want to be told when an admin clears or cancels a fine on my vehicle, so that I don't pay something that's no longer owed.
  8. As a citizen who pays fines, I want one "payment successful" notification that lists the fines it covered, so that I have confirmation without one alert per fine.
  9. As a citizen whose EcoCash payment failed, I want a notification with the provider's reason, so that I can fix the problem and try again.
  10. As a vehicle owner whose fine was paid by someone else, for example at a cash desk, I want a "fine paid" notification, so that I know it's settled.
  11. As a citizen who paid my own fine, I want only the payment notification and not a separate "fine paid" one, so that I'm not notified twice about the same thing.
  12. As a citizen, I want no notification for things I just did myself, such as opening or withdrawing a dispute or claiming a vehicle, so that alerts stay meaningful.
  13. As a citizen, I want a push notification even when the app is closed, so that I find out promptly.
  14. As a citizen with the app open, I want the push banner to still appear and the Alerts list to refresh, so that I don't miss it.
  15. As a citizen with several phones, I want notifications on each signed-in phone, so that I see them wherever I am.
  16. As a citizen who signed out on a phone, I want that phone to stop receiving my notifications, so that someone else using it doesn't see them.

Alerts tab

  1. As a citizen, I want the Alerts tab to show my real notifications, newest first, so that I have a history of what happened.
  2. As a citizen, I want older alerts to load as I scroll, so that a long history stays fast.
  3. As a citizen, I want unread alerts to stand out, so that I can see what's new.
  4. As a citizen, I want the Alerts tab badge to show my unread count, so that I know when to look.
  5. As a citizen, I want the app icon badge to show my unread count, so that I notice without opening the app.
  6. As a citizen, I want opening an alert to mark it read, so that the count stays accurate.
  7. As a citizen, I want "Mark all read" to be saved on the server, so that alerts stay read on my other phone and after reinstalling.
  8. As a citizen, I want to filter alerts by Fines, Payments and Vehicles, so that I can find a particular one.
  9. As a citizen, I don't want to see a Renewals filter that never has anything in it, so that the screen isn't misleading.
  10. As a citizen, I want each alert to show when it happened in my local time, such as "5 min ago", so that I can see how recent it is.
  11. As a citizen, I want tapping an alert or a push to open the related fine, payment or vehicle, so that I can act on it immediately.
  12. As a citizen, I want the unread count to refresh when I return to the app, so that it's right even if a push was missed.
  13. As a citizen with no notifications, I want a clear empty state, so that I know nothing is wrong.

Push permission

  1. As a citizen, I want to be asked for notification permission after I first sign in, not on first launch, so that I understand why it's being asked.
  2. As a citizen who declined permission, I want the Alerts tab to keep working, so that I don't lose my history.
  3. As a citizen who declined permission, I want a Settings option that takes me to the phone's settings to turn notifications on, so that I can change my mind.

Vehicle ownership verification

  1. As a citizen, I want to add my vehicle by number plate and have it checked against the Central Vehicle Registry, so that only real owners can manage vehicles.
  2. As a citizen who just added a vehicle, I want it to appear straight away as "Verifying…", so that I know my request was received and I don't add it again.
  3. As a citizen with a vehicle being verified, I want its actions (fines, payments, documents) unavailable, so that I can't act on a vehicle that isn't confirmed as mine.
  4. As a citizen, I want a "vehicle verified" notification when CVR confirms ownership, so that I know the vehicle is ready to use.
  5. As a citizen, I want a "verification failed" notification when CVR rejects my claim, so that I know why the vehicle didn't appear.
  6. As a citizen whose claim was rejected, I want the vehicle removed from my Vehicles screen, so that the screen only shows vehicles I own.
  7. As a citizen trying to add a vehicle that is already claimed or owned by another account, I want a clear message telling me to contact support, without revealing who owns it, so that I know what to do next.
  8. As a vehicle owner, I want nobody else to be able to link my vehicle while my ownership is pending or verified, so that strangers can't see or pay my fines.
  9. As a citizen whose claim was rejected, I want the vehicle to become claimable again, so that the real owner (possibly me, after fixing my records) can claim it.
  10. As a citizen, I want fines on a vehicle that's still being verified to stay hidden until it's verified, so that only confirmed owners see a vehicle's fines.
  11. As a citizen who linked vehicles before verification existed, I want my existing vehicles to keep working, so that the change doesn't disrupt me.

Operations and engineering

  1. As a developer, I want the mock CVR to behave like a real asynchronous registry (request now, result later), so that swapping in the real CVR changes only the client.
  2. As a tester, I want the mock CVR's delay to be configurable and its outcome predictable (plates ending in 0 are rejected), so that I can test both paths quickly.
  3. As an operator, I want a notification recorded even when push delivery fails, so that the citizen still sees it in Alerts.
  4. As an operator, I want a duplicate Kafka delivery not to create a duplicate notification, so that redelivery after a restart is harmless.
  5. As an operator, I want dead push tokens removed automatically, so that we stop sending to uninstalled apps.
  6. As an operator, I want an alert skipped and logged when a fine's vehicle has no verified owner, so that unaddressable events are visible but don't fail.
  7. As a security reviewer, I want the notifications API to serve only the signed-in citizen's own data, taken from the gateway's identity headers, so that nobody can read someone else's alerts by changing a parameter.
  8. As a developer, I want the notifications API under /api/v1/notifications like every other service, so that clients use one URL convention.
  9. As an auditor, I want CVR verification and rejection published as vehicle events, so that ownership changes have an audit trail.
  10. As a developer, I want a fine's creation event published only after it is saved, so that nobody is notified about a fine that was rolled back.

Implementation Decisions

Terminology

  • Notification: a stored record of one event for one citizen. Push is a delivery attempt for it. The Alerts tab is the list of the citizen's notifications.
  • Claim / link: a citizen_vehicles row. Its verification status is PENDING_VERIFICATION, VERIFIED or REJECTED.
  • Owner: the citizen holding the one VERIFIED link for a vehicle. A vehicle has zero or one owner.
  • CVR (Central Vehicle Registry): the system that verifies actual vehicle ownership. VTS is not involved in ownership.
  • The existing vehicle-level is_verified flag (registration verified against registries) is unrelated to link verification and stays as it is.

Ownership and CVR verification (vehicle-service)

  • Schema (citizen_vehicles): new columns verification_status (NOT NULL, with a CHECK on the three values), cvr_request_id and verified_at.
  • A partial unique index on vehicle_id covers rows whose status is PENDING_VERIFICATION or VERIFIED. This enforces at most one active claim per vehicle.
  • The existing (citizen, vehicle) uniqueness is relaxed so that a citizen whose claim was rejected can claim the vehicle again.
  • Rejected rows are kept for audit.
  • Migration: every existing link becomes VERIFIED, with verified_at set to linked_at. There is no deduplication step, because no vehicle is currently linked to more than one citizen. This is dev data.
  • Claiming: the existing citizen "link vehicle" endpoint:
  • creates the link as PENDING_VERIFICATION
  • submits a verification request to CVR through a CvrVerificationClient, storing the returned request id
  • returns the link with its status

If the vehicle has a pending or verified link for another citizen, it returns 409 with a generic message and nothing about the other citizen. If the caller already has an active claim on the vehicle, it returns the existing 409 duplicate error. The plate must still exist in the vehicle register, as today. - Mock CVR: exposed by vehicle-service under /api/v1/mock/cvr/verifications and reached through the gateway like the payments mock callback. - POST takes the plate and the citizen id and returns {requestId, status: PENDING}. - GET /{requestId} returns PENDING until a configurable delay has passed (default 2 minutes), then VERIFIED, or REJECTED when the plate's last character is 0. - Requests are held in a small table so they survive restarts. - Poller: a @Scheduled job runs every ~30 s (configurable). It asks CVR about every PENDING_VERIFICATION link, then moves the link to VERIFIED (setting verified_at) or REJECTED. A CVR error leaves the link pending for the next run. - Events: settling a link publishes VERIFY_CITIZEN_VEHICLE or REJECT_CITIZEN_VEHICLE after commit. afterState carries the citizen id, vehicle id and plate (and the rejection reason). All vehicle-service events move from the deprecated audit.events topic to trafficgrid.vehicles, and audit-service subscribes to trafficgrid.vehicles. - Owner lookup: a new GET /api/v1/vehicles/{id}/owner returns the owning citizen's id and the link id, or 404 when the vehicle has no verified owner. It is for service-to-service calls. - Only VERIFIED counts: the owner lookup, ownership checks behind the citizen's vehicle detail and actions, and the vehicle list payments-service uses for the citizen's fines, disputes and payments all consider only verified links. The citizen's own vehicle list returns pending links too, with their status, so the app can show "Verifying…". It never returns rejected links. - Unlinking a vehicle (pending or verified) deletes the link and frees the vehicle, as today.

Event changes (all publishers)

  • Every published audit-event message gets an eventId (a UUID generated at publish time) on the shared envelope, so consumers can de-duplicate.
  • payments-service publishes CREATE_FINE after commit, like its other events.

Notification model (notification-service)

  • The notifications table is extended (V2 migration) rather than replaced:
  • read_at (nullable)
  • payload (JSONB: deep-link target and display data such as plate, amount and due date)
  • event_id, with a unique constraint on (event_id, user_id)
  • Types: VEHICLE_VERIFIED, VEHICLE_VERIFICATION_FAILED, FINE_ISSUED, FINE_PAID, FINE_DISPUTE_DECIDED, FINE_COURT_OUTCOME, FINE_STATUS_CHANGED, PAYMENT_SUCCESS, PAYMENT_FAILED. The existing DOCUMENT_EXPIRY_REMINDER and FINE_DUE_REMINDER remain, unused.
  • Reference types: add VEHICLE and PAYMENT.
  • The CHECK constraints are updated to match the new types.
  • One record per notification, with channel PUSH. status records the push attempt: SENT, or FAILED when no device accepted it (including when the citizen has no registered devices). The record exists, and the alert is listed, whatever the push outcome.
  • Device tokens: a new device_tokens table holds user id, Expo push token (unique), platform and last-seen time. A citizen can have several. Registering a token that belongs to another user moves it to the caller.
  • notification_preferences and its endpoints are left as they are. No preference is consulted when sending.

Event-to-notification mapping

Topic Action Condition Type Recipient
trafficgrid.vehicles VERIFY_CITIZEN_VEHICLE none VEHICLE_VERIFIED the claiming citizen
trafficgrid.vehicles REJECT_CITIZEN_VEHICLE none VEHICLE_VERIFICATION_FAILED the claiming citizen
trafficgrid.fines CREATE_FINE status PENDING or REFERRED_TO_COURT FINE_ISSUED (court wording when referred) vehicle owner
trafficgrid.fines SETTLE_FINE owner ≠ payer FINE_PAID vehicle owner
trafficgrid.fines DECIDE_FINE_DISPUTE upheld or rejected FINE_DISPUTE_DECIDED (new due date when rejected) vehicle owner
trafficgrid.fines COURT_OUTCOME_RECORDED penalty or dismissed FINE_COURT_OUTCOME vehicle owner
trafficgrid.fines UPDATE_FINE_STATUS to CLEARED or CANCELLED FINE_STATUS_CHANGED vehicle owner
trafficgrid.payments SUCCEED_PAYMENT paidBy present PAYMENT_SUCCESS (lists the covered fines) payer
trafficgrid.payments FAIL_PAYMENT paidBy present PAYMENT_FAILED (includes the provider message) payer
  • Every other action is ignored, including OPEN_FINE_DISPUTE, WITHDRAW_FINE_DISPUTE, LINK_CITIZEN_VEHICLE and CREATE_VEHICLE.
  • Vehicle owner is looked up through vehicle-service's owner endpoint (Feign through Eureka) when the event is processed. If there is no owner, nothing is created and the skip is logged at info level.
  • The payer for SETTLE_FINE comes from the settling payment's paidBy. If afterState doesn't carry it, payments-service adds it to the settle event's state.
  • Title and body text are built in notification-service from the event state, in plain English, with money formatted from integer cents and currency.

Consumption and delivery (notification-service)

  • Consumers: Kafka listeners on trafficgrid.fines, trafficgrid.payments and trafficgrid.vehicles, in consumer group notifications-service. The consumer configuration follows payments-service's: error-handling deserializer, fixed back-off and a dead-letter topic.
  • Wiring: docker-compose gives notification-service KAFKA_BOOTSTRAP_SERVERS and a Kafka dependency, and config-server gets its Kafka and Feign settings.
  • Flow per event:
  • resolve the recipient
  • insert the notification (a duplicate event_id/user_id pair means it was already handled, so stop)
  • send the push to all of the recipient's tokens through Expo's push HTTP API
  • record SENT or FAILED

A push error never fails the message. Only an error before the insert, such as the owner lookup being unavailable, is retried and then sent to the dead-letter topic. - Expo: the client sends the title, the body, a data object (notification id, type, deep-link target) and badge set to the recipient's unread count after the insert. Tickets that come back with DeviceNotRegistered delete that token. Delayed delivery receipts are not polled. - Expo access token: if one is configured, it comes from configuration and the environment.

Notifications API (notification-service)

  • Identity: the user is always the authenticated user taken from the gateway's identity headers (core-common AuthenticatedUser). The userId query parameter is removed.
  • Gateway route: changes to /api/v1/notifications/**, the same convention as the other services. The old /notification-service/** prefix route is removed.
  • Endpoints (all for the CITIZEN role, wrapped in the shared response envelope):
  • GET /api/v1/notifications/me: paged, newest first, with optional category filter (FINES, PAYMENTS, VEHICLES, derived from type). Each item has id, type, category, title, body, payload, createdAt, readAt.
  • GET /api/v1/notifications/me/unread-count: {count}.
  • PATCH /api/v1/notifications/{id}/read: marks one notification read. Calling it again is harmless. Another user's id returns 404.
  • POST /api/v1/notifications/me/read-all: marks all of the caller's notifications read and returns the new count (0).
  • POST /api/v1/notifications/me/devices with {expoPushToken, platform}: registers or refreshes a device.
  • DELETE /api/v1/notifications/me/devices/{expoPushToken}: unregisters a device.
  • Errors use the core-common exception types through a subclass of core-web's GlobalExceptionHandler.

Mobile app (traffic-grid-mobile)

  • Push setup: add expo-notifications and expo-device, the notifications config plugin, an EAS project id and Android FCM v1 credentials. The team has to provide the Firebase project and the Expo account. Code stays platform-neutral; iOS needs an APNs key later.
  • Permission: requested once, after the first successful sign-in. If granted, the app gets the Expo push token and registers it after sign-in and on every app start while signed in. Sign-out unregisters the token before clearing the session. Settings gets an "Enable notifications" row that opens the OS app settings when permission is denied.
  • Alerts data: the mock notifications source is replaced by the real API, using react-query.
  • The model changes from timeAgo to createdAt, formatted on the device, and from a boolean read to readAt.
  • Categories become fines, payments and vehicles.
  • The Renewals filter is hidden.
  • Pagination is infinite scroll.
  • "Mark all read" and opening an alert call the API.
  • The tab badge uses the unread-count query.
  • Receiving pushes: with the app in the foreground, the banner is still shown, and receiving a push invalidates the notifications and unread-count queries. Tapping a push (foreground, background or cold start) or an alert marks it read and navigates to the deep link: the fine detail, the payment or receipt, or the vehicle detail (or the Vehicles screen for a failed verification). Returning the app to the foreground refetches the unread count.
  • App icon badge: set from the push's badge and from the unread-count query, and cleared on "Mark all read".
  • Vehicles screen:
  • Pending links render greyed out with a "Verifying…" badge and no actions.
  • After adding a vehicle, the list shows the pending entry.
  • A 409 conflict shows: "This vehicle is already registered to another account. If you believe you own it, contact support."
  • Rejected links never appear.

Documentation updates (project-docs)

These are part of the work, and each ticket updates the docs for the behaviour it delivers:

  • API Contracts:
  • §6.2 Vehicles: the owner lookup.
  • §6.4 Citizen Vehicles: verification status on links, the 409 conflict, pending links in the list, only verified links counting elsewhere.
  • A new section on the mock CVR.
  • §6.9 Notifications: rewritten for the /api/v1/notifications paths, the me endpoints, devices, categories and the removed userId parameter. Preferences are kept but marked unused for sending.
  • Data Model:
  • §1.1 Stakeholders: add CVR as the ownership registry, and stop describing VTS as verifying ownership.
  • §2.3: clarify is_verified as distinct from link verification.
  • §2.4 citizen_vehicles: one owner per vehicle, the verification columns and lifecycle, remove "family car".
  • §2.11 notifications: new columns, types and reference types.
  • A new device_tokens section and the mock CVR request table.
  • §3 Cross-service relationships: the owner lookup.
  • §5 Pending items: real CVR integration, iOS push.
  • Kafka Design:
  • §3 Events By Topic and "As implemented": the vehicle actions on trafficgrid.vehicles, the new verify and reject actions, notification-service as the live consumer of fines, payments and vehicles, dispute notifications no longer deferred.
  • §6 Event Envelope: eventId.
  • §5: the (event_id, user_id) de-duplication.
  • Failure Handling:
  • Vehicle verification depends on CVR. A CVR outage leaves claims pending, not failed.
  • A push outage leaves notifications recorded with status FAILED and visible in Alerts.
  • A missing owner skips the notification.
  • Implementation Tracker:
  • a snapshot entry for this feature
  • notification-service status in the services inventory
  • notifications, notification_preferences and device_tokens in the tables list
  • the Notifications section
  • the gateway path note (notification-service is no longer the exception)
  • CVR in external integrations (mock)
  • Test Tracker data (test-cases.json):
  • Update the API-NOTIF cases to the new paths and behaviour, and add API cases for the mapping, de-duplication, read state, devices and the claim conflict.
  • Update MOB-ALRT cases 001–004 and MOB-NAV-004, and add MOB cases for push permission, tapping a push, the "Verifying…" vehicle state and the verification alerts.
  • Mark MOB-ALRT-005 to 007 (channel and category preferences) as out of scope.
  • mkdocs navigation: add this spec under Specs.

Testing Decisions

  • A good test drives the system through the same interfaces a real client or event uses (HTTP endpoints, Kafka topics) and checks the outcome a citizen would see. It doesn't check repository calls, private methods or message-building internals. Text is checked only where it carries data (plate, amount, new due date, provider reason).
  • notification-service: one end-to-end test class is the main seam.
  • Each mapping row produces the expected type for the expected recipient, and ignored actions produce nothing.
  • A fine with no verified owner produces nothing.
  • SETTLE_FINE notifies the owner only when the owner isn't the payer.
  • Publishing the same event twice yields one notification.
  • The list is newest first and paged, and the category filter works.
  • The unread count, mark-one-read and mark-all-read behave correctly, and another user's notification is refused with 404.
  • Device register, refresh and unregister work.
  • A push sender error leaves the notification listed with status FAILED.
  • A DeviceNotRegistered result deletes the token.
  • Prior art: payments-service's payment-event listener integration test (embedded Kafka plus Testcontainers Postgres).
  • vehicle-service: extend the existing vehicle API test (HTTP plus Testcontainers Postgres, mocked Kafka template).
  • A claim creates a pending link.
  • A second citizen gets 409 while the link is pending or verified, and may claim after a rejection.
  • With the CVR delay set to zero, running the poller verifies a normal plate and rejects one ending in 0, publishing the matching event.
  • The owner endpoint returns only a verified owner.
  • The citizen vehicle list shows pending but not rejected links.
  • Verified-only lookups exclude pending links.
  • payments-service: extend the existing event publisher and fines service unit tests to cover eventId on every message and CREATE_FINE being published only after commit.
  • Mobile: unit tests only for new pure helpers in src/lib, such as type → category, type/payload → deep-link route, and created-at → relative time, if not already covered by the existing formatter tests.
  • Existing tests that the change breaks are fixed. In particular, vehicle and payments tests that assume a link is immediately usable need their links created as verified.

Out of Scope

  • Email and SMS delivery, and wiring auth-service's password-reset link through notification-service.
  • Per-type or per-channel notification preferences, and any use of notification_preferences when sending.
  • iOS push delivery (no APNs key yet). The code is written to support it.
  • Polling Expo push receipts, and retrying failed pushes.
  • Deleting or archiving notifications, and retention or cleanup jobs.
  • Notifications for admins or officers.
  • Notifications for citizen-initiated actions (claiming a vehicle, opening or withdrawing a dispute), document expiry and fine-due reminders.
  • A real CVR integration, and real EcoCash or bank-transfer confirmation.
  • Ownership transfer, ownership disputes and a support flow for 409 conflicts.
  • Admin portal changes. Verification is fully automatic through CVR.
  • New tests in the integration-tests module and in the admin portal.

Further Notes

  • Firebase is a new external dependency. System-tray push on Android is delivered by Google's Firebase Cloud Messaging (FCM); there is no other channel for standard Android phones while the app is closed. Expo's push service hands every Android message to FCM, so the platform needs a Firebase project, the app's google-services.json and an FCM v1 service-account key uploaded to Expo. Neither the app nor the backend uses a Firebase SDK: notification-service only calls Expo, and the app only uses expo-notifications. iOS works the same way through APNs.

  • Consuming the existing domain events, rather than having producers send "notify user X" commands, keeps notification logic in one service. The cost is that the consumer parses the audit envelope's afterState JSON, and the de-duplication relies on the new eventId.

  • Resolving the owner at processing time means a fine processed after its vehicle changed hands notifies the new owner. That is acceptable for now.
  • Because a pending claim reserves the vehicle, a false claim blocks the real owner for at most the CVR delay, after which CVR rejects it. With the real CVR, rejected claimants could be rate-limited if this becomes abusive.
  • payments-service's fines-payable client currently uses the vehicle id as a stand-in for the owner id. The new owner lookup lets it use the real owner, but changing that isn't part of this work.