Signal Emitter Map
- 1. Why this document exists
- 2. Status: the catalogue is deliberately incomplete
- 3. Method and limits
- 4. Signal catalogue: declared versus configured
- 5.
REGISTRATION_COMPLETED - 6.
PAYMENT_COMPLETED - 7.
RESULT_SET_CHANGED - 8. The recurring shape
- 9. Which gaps are unbuilt, and which look wrong
- 10. Keeping this map true
- 11. Status of this map
1. Why this document exists
A reactor is only as trustworthy as the emitter behind it. If one write path raises a signal and a sibling path does not, the reactor appears to work — it fires when tested and stays silent in production, and the silence looks identical to "nothing happened".
That failure has already occurred once. RESULT_SET_CHANGED was delivered as part of the emitter story and is emitted only from a deprecated import path; the canonical bulk import and the entire REST surface for results are silent. It was found by tracing before an exercise, not by a test.
This page exists so that the question "which paths raise this signal, and which paths should but do not?" has a written answer that can be checked, rather than being re-derived by whoever next needs to know.
Reactor Catalogue documents what consumes signals. This page documents what produces them.
2. Status: the catalogue is deliberately incomplete
Signal coverage is work in progress, and this page is a map of where it has reached — not a defect register.
The Signal & Sweep work is being delivered in phases against the design journal, its ADO stories and these pages. The framework was released early, and knowingly, so that the machinery could be exercised against a real environment rather than only against tests. Nobody expects the signal catalogue to be complete.
Two different things are therefore being tracked here, and they carry different expectations:
| Expectation | Status | |
|---|---|---|
The foundation |
Expected to work now. Emit semantics, transaction and idempotency guarantees, debounce and squash, leader election, dispatch, retry, sweep scheduling, the runtime control surface. |
Working, and largely proven in an environment: leases elected and failed over across a rolling upgrade, and a signal emitted, routed and completed end to end. |
The catalogue |
Expected to grow. Which signal types exist, and which write paths raise them. |
Three of eight planned types built. Coverage within those three is partial. |
So an entry below marked silent is usually not yet built rather than broken. The value of writing them down is that the distinction stops being implicit: a path nobody has wired yet and a path that was supposed to be wired and is not look identical from the outside, and only one of them is a problem.
Where a gap does look like a genuine defect rather than unbuilt scope, it is called out as such in Which gaps are unbuilt, and which look wrong.
3. Method and limits
Static call-graph tracing against admin-service develop at ecfa626f. For each signal type: locate every SignalPublisher.emit call site, identify the enclosing method, and trace callers outward to a REST endpoint or scheduled entry point. Then, for the same domain state, enumerate sibling write paths and check each for an emit.
Two limits worth stating:
-
Static only. Nothing here was proven by running the paths, except where a row in the coverage table says otherwise.
-
Reflective and framework-mediated calls are not followed. A path invoked purely through Spring proxying or the form framework’s dispatch is identified by reading the framework, not by the call graph.
Where a path is marked silent, that means no emit was found on it. It does not by itself mean the path should emit — that is a product decision, and the gap register separates the two.
4. Signal catalogue: declared versus configured
SignalTypes declares three constants. application.yml configures debounce windows for eight names.
| Signal type | Declared | Emitted | Note |
|---|---|---|---|
|
Yes |
Yes |
One emit site |
|
Yes |
Yes |
Two emit sites |
|
Yes |
Yes |
One emit site, on a deprecated path only |
|
No |
No |
Configured only |
|
No |
No |
Configured only |
|
No |
No |
Configured only |
|
No |
No |
Configured only |
|
No |
No |
Configured only |
Five of the eight configured types do not exist in code. They are not declared in SignalTypes and appear nowhere outside the configuration file. Reading application.yml alone gives the impression of an eight-signal framework; three are real.
These five are planned, not missing. The configuration was written from the intended catalogue, ahead of the emitters, and two of them already have an obvious owning service waiting: PersonMergeService writes participants and would raise PERSON_MERGED; CategoryChangeServiceEx changes categories and would raise PARTICIPANT_CATEGORY_CHANGED.
Nothing breaks at runtime — a debounce entry for a type never emitted is never consulted. The only real cost is legibility: application.yml reads as an eight-signal framework, and a reader has no way to tell which three are live. Worth a comment in the configuration marking the unbuilt entries, so the file states its own status.
5. REGISTRATION_COMPLETED
Emitted from one site: EventFormController.onDone(), immediately after the draft order is saved.
Aggregate key order:{id}; debounce 0s, so non-squashable — one registration, one signal. The organisation is taken from the order.
onDone() is protected and invoked by the form framework at the end of a process flow, so the only entry point is the registration wizard.
5.1. Silent paths reaching participant state
EventParticipant rows are written by at least seven other classes: FormService, EventParticipantService, EventParticipantServiceEx, CategoryChangeServiceEx, PaymentService, PersonMergeService and ProcessStepService. None emits.
Most obviously, a participant CSV import creates registrations and raises nothing — no importer references SignalPublisher anywhere in the codebase.
Whether a bulk import should raise a registration signal is a genuine product question, not an oversight to be fixed reflexively: with a reactor attached, importing several hundred participants would send several hundred emails. The point is that the answer must be deliberate and written down, because today it is decided by omission.
6. PAYMENT_COMPLETED
Emitted from two sites.
| Emit site | Enclosing method | Entry point |
|---|---|---|
|
|
|
|
|
|
Both are correctly guarded. OrderServiceEx emits only on a genuine PENDING → PAID transition, captured before the patch is applied, so a gateway delivering its callback twice does not signal twice. PaymentService returns early when the order is already PAID, giving the same idempotency.
Verified in event-stage on 2026-09-01: PATCH /api/orders/participant/36114 with {"status":"PAID"} produced signal 1500 — PAYMENT_COMPLETED, Order/36114, organisation 9 — with processAfter equal to createdAt and COMPLETED 272 ms later.
6.1. Silent paths reaching PAID
This is the largest gap surface found.
| Path | Reached from | Emits |
|---|---|---|
|
|
No |
|
|
No |
|
No current caller found |
No |
|
|
No |
|
|
No |
Two observations.
The PUT sibling of a working PATCH is silent. PATCH /orders/participant/{id} emits; PUT /orders/participant/{id} does not. They differ only in method, and nothing in either signature signals the difference.
The generated CRUD surface bypasses the emitter entirely. OrderServiceEx extends OrderService but overrides only the ParticipantOrder variants. The plain DTO save, update and partialUpdate are inherited and go straight to the repository, so PUT /api/orders/{id} can move an order to PAID silently.
The membership domain emits nothing at all. Membership orders are created through saveEntity and transitioned through updateStatus, neither of which emits — and MEMBERSHIP_ACTIVATED, the type that would cover it, is configured but never declared. A membership purchase is invisible to the framework end to end.
7. RESULT_SET_CHANGED
Emitted from one site: RaceResultServiceEx.publishResultSetChangedEvent, reached only from RaceResultServiceEx.save(RaceResult) — the entity overload — and deleteAll(Set<RaceResult>).
Aggregate key series:{id} where a series exists, otherwise null, which deliberately makes the signal non-squashable rather than collapsing unrelated result sets under a series:null key. Debounce 60s — the only non-zero window in the framework, and therefore the only signal that exercises squashing.
7.1. Silent paths reaching result state
| Path | Emits |
|---|---|
|
Yes — the only emitting path |
|
No |
|
No |
|
No |
|
No |
Two independent causes. The bulk import routes through the async import framework to RaceResultRowProcessor, which writes rows directly and references neither the service nor the publisher. The REST surface calls inherited DTO overloads — RaceResultServiceEx overrides only the entity save and deleteAll — so the publishing overload is never reached from the web layer.
The result is that the signal fires only from the path the design has deprecated. Tracked as a user story under the Signal & Sweep feature.
The same method publishes ResultSetChangedEvent, so any listener on that event has identical reach.
8. The recurring shape
Three of the gaps above share one cause, and it is worth naming because it will recur.
An *Ex service extends a generated service and adds an entity-level method that emits. The generated DTO-level methods are inherited unchanged and do not emit. The REST resource calls the DTO methods. The emitting overload is never reached from the web layer.
This is invisible at the call site: orderService.save(dto) and raceResultService.save(entity) read identically and resolve to different methods with different behaviour. It cannot be caught by reading the resource, only by checking which overload resolves.
9. Which gaps are unbuilt, and which look wrong
Separating scope from defect, so the list above can be acted on rather than merely absorbed.
| Gap | Reading | Why |
|---|---|---|
Five configured signal types not declared |
Unbuilt |
Planned catalogue, emitters not yet written. |
Membership domain emits nothing |
Unbuilt |
|
Participant import raises no registration signal |
Undecided |
Needs a product answer before it is a gap at all: with a reactor attached, importing several hundred participants would email several hundred people. Decide, then record the decision. |
Most participant write paths silent |
Unbuilt |
Only the registration wizard is in scope so far. |
|
Probable defect |
The story was delivered as complete, and the signal cannot be raised from any supported path. Tracked separately. |
|
Probable defect |
Same resource, same domain effect, differing only in HTTP method, with nothing in either signature explaining the difference. Most likely an oversight rather than a decision. |
Generated order CRUD can set |
Worth a decision |
Either emit, or restrict the surface so status cannot be changed through it. Leaving both open is the weakest option. |
10. Keeping this map true
Suggestions, not yet implemented:
-
An architecture test asserting that every write path to a signalled aggregate passes through an emitting method, or is explicitly listed as exempt. This is the only mechanism that would fail when a new endpoint is added.
-
Emit at the lowest common point. Where every path funnels through one repository save, emitting there is harder to bypass than emitting in each service method.
-
Delete or declare the phantom types. Configuration naming five signals that do not exist will mislead every future reader.
-
A review checklist item: when adding an endpoint that mutates a signalled aggregate, state which signal it raises or why it raises none.
11. Status of this map
Covers the three declared signal types as at develop ecfa626f. It is a point-in-time trace of work in progress and will drift as both endpoints and emitters are added; treat a discrepancy between this page and the code as a defect in the page until proven otherwise. As each further signal type is built, extend the map in the same shape — emit sites, entry points, then the silent siblings.