docs(portal-behandel): ADR-0032 + demo note for the self-refreshing werkbak (refs #162)
Records why the werkbak polls rather than being pushed to (proposal #163): nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF for the same freshness plus connection state, a dependency and a stateful BFF. Names the ceiling (fixed 5s interval, per open page, keeps polling in a hidden tab) and the upgrade path (publish task events, then swap the interval for a stream). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-04
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
|
||||||
|
the self-service resume slice (#111), so #162 is the identifier that counts.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
|
||||||
|
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
|
||||||
|
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
|
||||||
|
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
|
||||||
|
that is already open, and until now a behandelaar had to reload the page to see it.
|
||||||
|
|
||||||
|
Three forces shape the mechanism:
|
||||||
|
|
||||||
|
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
|
||||||
|
task events, and there is no bus between the domain and the BFF.
|
||||||
|
- **The BFF is stateless** and sits behind each portal's nginx.
|
||||||
|
- **This is the repo's first live-updating view**, so the choice sets a precedent.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
|
||||||
|
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
|
||||||
|
state.**
|
||||||
|
|
||||||
|
The refresh is a *background* read: it leaves the rows and the loading/failure states
|
||||||
|
untouched until it has an answer, so a tick never flashes a spinner over rows a
|
||||||
|
behandelaar is reading and a single failed poll never swaps the list for the error alert.
|
||||||
|
A read that comes back also clears an earlier failure, so the view recovers on its own —
|
||||||
|
the same reload this slice set out to remove would otherwise be needed to escape a
|
||||||
|
transient error. Only a foreground read (on open, after a decision) speaks for whether the
|
||||||
|
werkbak is readable at all.
|
||||||
|
|
||||||
|
### Why not SSE or WebSockets
|
||||||
|
|
||||||
|
Neither buys freshness here, because **nothing notifies the BFF either**:
|
||||||
|
|
||||||
|
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
|
||||||
|
domain and forwards diffs — the same latency, plus connection lifecycle, nginx
|
||||||
|
buffering, and auth on a long-lived connection.
|
||||||
|
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
|
||||||
|
sticky-session-bound. A genuine push path would *also* need the domain to publish task
|
||||||
|
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
|
||||||
|
is none of those.
|
||||||
|
|
||||||
|
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
|
||||||
|
for review") in a handful of lines inside one component.
|
||||||
|
|
||||||
|
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
|
||||||
|
background tab. Each tick costs one Flowable task query plus a store read per open task.
|
||||||
|
- Upgrade path: publish task events from the domain, then swap the component's `interval`
|
||||||
|
for a stream. The endpoint contract and the component's rendering stay as they are;
|
||||||
|
gate on `document.visibilityState` first if request volume is the concern.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
|
||||||
|
service boundary moves.
|
||||||
|
- Self-healing: a transient read failure no longer strands the view until a manual reload.
|
||||||
|
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
|
||||||
|
page, which is itself the live-refresh assertion.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Staleness is bounded by one interval (≤5 s) rather than instant.
|
||||||
|
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
|
||||||
|
- The precedent is polling; a future view with genuinely high-frequency updates will have
|
||||||
|
to revisit this (see the upgrade path above).
|
||||||
|
|
||||||
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
|
||||||
|
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
|
||||||
@@ -5,6 +5,40 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
|
||||||
|
|
||||||
|
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
|
||||||
|
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
|
||||||
|
background refresh swaps the rows in without flashing the loading state, and a transient failure no
|
||||||
|
longer strands the view on its error message until someone reloads.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Two windows. Left: the behandel werkbak, already open and idle.
|
||||||
|
python3 infra/keycloak/check_realms.py otp # a code, valid right now
|
||||||
|
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
||||||
|
#
|
||||||
|
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
|
||||||
|
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
|
||||||
|
#
|
||||||
|
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
|
||||||
|
# never reloaded and never left the werkbak.
|
||||||
|
#
|
||||||
|
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
|
||||||
|
# absence of the reload IS the assertion.
|
||||||
|
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
|
||||||
|
#
|
||||||
|
# 5. Component level (background refresh, failure recovery, teardown):
|
||||||
|
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
|
||||||
|
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
|
||||||
|
|
||||||
|
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
|
||||||
|
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
||||||
|
|
||||||
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
||||||
|
|||||||
Reference in New Issue
Block a user