feat(digital-post): one seam for digital post, and a flag that refuses rather than simulates - #2062
Merged
rubenvdlinde merged 13 commits intoSep 18, 2026
Merged
Conversation
… never carries one REQ-DPA-004: a PEM has no business travelling through a method argument, a source configuration value or an app-config string. dispatch() now takes a certificateRef, and a live binding resolves the material inside integriq through PkiOverheidCredentialResolver, exactly as WusProfileService does. The mock ignores the reference as it ignored the material before.
…tex bindings DigitalPostProviderInterface with getProviderId, getConfigSchema, send, status and pollInbound, resolved by providerId from the source configuration. A provider id nothing answers to fails naming itself and the ids that do exist, because a misspelled provider falling back to the log binding would report letters delivered that were never sent. The Berichtenbox binding is built on the client that already ships rather than a second one, and refuses activation without a PKIoverheid certificate reference and a sender OIN, naming which of the two is missing. Postex goes over the shared gateway transport, so it carries no client of its own. The log binding reaches delivered and says simulated on every result.
DigitalPostSendRequestedEvent carries the letter and a result slot that answers with the tracked message id or a structured refusal, never both. DigitalPostDeliveredEvent is dispatched on every status change, including failed, so a consumer listening for the happy path hears about the unhappy one too. The digitalPostMessage schema tracks recipient, subject, body, attachments, requestedBy and the lifecycle queued, sent, delivered, read, failed. The message exists before the provider is called, so a provider that throws leaves a letter somebody can see and retry rather than a gap in a log, and a failed send keeps its attachments. Every result carries simulated, so a delivered status from a binding that sends nothing can never be read as a letter that arrived.
…aches the intake inbox The DI factory now branches on logius.berichtenbox.feature_flag, which it did not before. With the flag unset the mock is served and every result says simulated. With the flag set the mock is not reachable at all: the instance resolves to a binding that refuses and names what is missing, because an operator who turns the flag on is asking for real letters and a simulated delivery there would be indistinguishable from a real one. The status job polls only letters still on their way, so a delivered, read or failed one is never asked about again, and a change dispatches DigitalPostDeliveredEvent rather than making the requesting app poll integriq. The inbound job offers each received item to the same document intake seam the mail intake uses, on channel digitalPost and carrying the sender as metadata, so an instance with no intake inbox is told the item stayed here.
…at it needs Unit tests for the seam, the three bindings, the tracked message and the inbound job, plus the e2e spec for the scenarios a running instance can answer. The catalog descriptor now names the Logius product the binding addresses, the two credentials it cannot work without, and what the feature flag does to a send. Mutation check: making a failed dispatch fall back to a simulated delivery reddened the two assertions that say a refusal is not a simulation, then restored.
rubenvdlinde
requested review from
Rem-Dam,
SudoThijn,
WilcoLouwerse,
bbrands02,
remko48 and
rjzondervan
as code owners
September 18, 2026 07:09
…ithout a lookup (#2066) A guard against the recursion already existed when this change was picked up, added by outbound-webhooks-activation. It works, and it is not enough. It recognises the event machinery's own rows by resolving the schema ids of `event` and `event_message` from one existing row per schema, then caching the answer in a process static. On an instance whose event table is empty that resolves to nothing. A fresh install is such an instance, and so is the moment after this change's own remediation purge. The empty answer is then cached for the life of the PHP worker, the guard is inert, and nothing at all bounds what follows. Two things that survive that: - A generated-by marker stamped on every CloudEvent this app writes and read back on the way in. It travels with the row, so it needs no lookup and it still works after the register is renamed or the schema is copied into a second register, which is the #2150 collision shape. - A per-request ceiling. If both other guards go quiet, forwarding stops after EventLoopGuard::MAX_CHAIN events rather than never. That is the answer to what a loop does before it is stopped: at most 25 events and a named refusal, instead of the measured 255 CloudEvents per object create and 45,398 self-generated rows. `event_subscription` joins the machinery schemas: writing a subscription is bookkeeping and must not become an event. The decision is a pure class with no IO, so every branch is testable directly. A refusal is always named, because a guard that went inert and a guard that stopped a loop both forward zero events, and a log has to tell those apart. Sections 3 to 6 of the change stay open on purpose: the queued job, the subscription cache, the purge command and the instance verification are each their own change, and none of them is what stops the loop.
… it already dispatches (#2067) The dispatch arm for `action.kind = 'flow'` was already here, calling FlowRunnerService and asserted by a unit test. So was the one for `'mapping'`. Neither could be reached. `event_subscription.action.kind` listed four values, and `flow` was not one of them, so a subscription naming it could not be saved. `action.flowId`, which the arm reads, did not exist as a property at all. A dispatch arm nothing can reach and a dispatch arm nobody uses look identical from the outside, which is why this sat. This opens the enum to `flow` and `mapping`, adds `flowId` and `mappingId`, and adds the test that would have caught it: the kinds the register accepts and the kinds the switch handles are compared, in both directions. A kind with an arm but no enum entry is unreachable; a kind with an enum entry but no arm saves cleanly and then fails once per delivery as a configuration error. Both lists are read from their own files rather than restated here, so an arm added without registering it fails this test rather than passing against a list the test also forgot. The schema edit is additive: existing subscriptions keep validating, and `flowId`/`mappingId` are plain strings with no format, because adding a format to a property is the breaking change, not adding the property. Task 3, the picker, stays open. The modal offers three kinds and has never offered `notificaties` either, and a flow picker needs a flow list the modal cannot currently fetch, so that is its own change.
…now, and the contract half of the applier (#2068) All three claimed changes are already built. This measures that, records the evidence so the ledger stops being wrong, and closes the two coverage gaps the measurement found. The engine already fails closed on an unknown disappearance policy: deleteInvalidObjects reads it through the throwing reader and, on a value it cannot read, skips the whole deletion step, sets guardInfo.reason, logs it and dispatches the guarded event. Nothing asserted that. The value object's refusal was covered, but a value object that throws into a caller which swallows the throw is the same as no refusal at all, and a misspelled markEnded would have deleted the records it was written to preserve while reporting an ordinary deletion count. DisappearanceApplier::applyToContract had no test. Every assertion in this change was about the object, and the contract is the half the engine reads on the next run, so it is what stops a flagged record being re-flagged or a returning record being missed. The policy difference is pinned too: keepAndFlag must not write an end date, because that would claim the record ended, which is a statement about the subject rather than about the source. Skipped with evidence rather than rebuilt: x-openregister-property-source is not in openregister's property vocabulary, measured against a clone rather than asked; and RegistrySubscriptionRequestedEvent appears once, in a docblock, and is never defined or dispatched, so there is no wire shape to bind to.
…cannot widen (#2069) An expression language that can read the environment is a data exfiltration path: an expression is configuration somebody edits in a form, and env:DATABASE_PASSWORD in a template that renders into an email is a credential leaving the building with a 200 beside it. What the allowlist admits: exact keys, one at a time. No wildcard, no prefix pattern, no empty entry, no regular expression, no case-insensitive match. Every one of those is a rule rather than a list, and a rule also admits whatever is added to the environment next year. DB_* looks careful until somebody names a variable DB_ROOT_PASSWORD. An unreadable stored list allows nothing, and a wildcard smuggled into storage is dropped on read too. Who may change it: an instance administrator, enforced twice — the attribute before the controller runs and requireAdmin() in every body, because a guard that lives only in an attribute disappears when somebody adds a route by hand. The list holds keys and provenance, never values, and not even whether a key is set: that is a map of what is worth asking for. A refusal throws rather than answering an empty string, names the key and never the value, and an unregistered prefix does not fall through to another source. env: refuses a write naming the prefix, because the interface this mirrors has a store() half and a caller will try. Checked before building: lib/Expression did not exist. migration-source- adapters is already merged as #2056 and is skipped; objecten-api-facade is genuinely unbuilt and untouched.
…fils it (#2071) Closes the last task of registry-subscription-connector. I measured the wrong repo to conclude this was blocked. On 2026-09-18 I reported it still blocked because the event name appears once in this repo, in a docblock. That is the wrong place to look for a cross-app dependency: OpenRegister owns and emits it, and it has shipped. lib/Event/RegistrySubscriptionRequestedEvent.php exists there and RegistrySubscriptionNotifier dispatches it. So the wire shape is read rather than guessed: five fields and a getPayload() returning exactly those keys. Two of them, registry and identityValue, are spellings SubscriptionRequestHandler already accepted, so the binding really was one line, as the tasks file predicted. The listener reads getPayload() rather than assembling one from the getters, because assembling it here would be a second definition of the wire shape and the two would drift the first time OpenRegister added a field. It swallows its own failures. The event is dispatched inside OpenRegister's own work, so a registry integriq cannot reach must fail as a subscription that did not happen, not as a save that failed for a reason the person saving cannot act on. The stub was diffed against the real class rather than written from the docblock: same five constructor arguments in the same order, same five getters, same five payload keys. A stub that drifts from the real class can only pass.
…#2072) The seam a phone system reaches integriq through: a provider interface, one shared normaliser so every vendor's payload becomes one shape, the typed CallEvent the KCC panel is fed from, and a guard against a PBX's retries popping the same caller three times. The rule worth keeping: a number this cannot place is EMPTY, never guessed. PhoneNumberValidator already normalises to E.164 and the SMS path uses it, so this reuses it, with one deliberate difference. That validator prefixes bare digits with a plus and accepts the result, so 612345678 becomes +612345678, a valid number in another country. For an outbound SMS that is fine, it fails to deliver. For a caller lookup it matches somebody else's partij, and an agent picks up the phone looking at the wrong person's case history with nothing on screen saying the match was a guess. Asserted in both directions so neither side can be tidied into the other without a test saying what it costs. The event also tells a WITHHELD number apart from an UNKNOWN one. Both give caller = null, but only one is worth asking the caller for their number. The deduplicator is a cache, not a ledger, and says so: two nodes in the same millisecond can both pass. That is survivable only because nothing here writes, and the class and the tasks both say where the idempotency that protects the record actually belongs.
…enbox-digital-post-adapter # Conflicts: # lib/Settings/integriq_register.json
…registry Finishes task 5's open half: the provider picker on the source page, its Dutch and English strings, and the prose docs. The picker reads GET /apps/integriq/api/digital-post/providers, which returns the registry's own describeAll(), rather than a list written beside it. A hardcoded picker goes stale in silence: a binding added to the registry is invisible until somebody remembers the form, and a binding removed leaves an option that saves a provider id nothing answers to. Neither failure says a word on screen, and the first evidence either way is a letter nobody posted. A stored provider this instance does not carry renders as itself rather than being cleared, so a source configured on another instance reads as set to something not installed here, not as nothing is set. The endpoint refuses an anonymous caller: a binding's config schema names the credentials it wants, which is not a public inventory of what this instance talks to. Screenshots still open. They need a running instance with a binding configured, which this lane does not have and must not borrow.
Contributor
Quality Report — ConductionNL/integriq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| format | ❌ | ||||
| check-schema-l10n | ❌ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 141/141 | |||
| npm | ✅ | ✅ 545/545 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test. |
||||
| Hydra gates | ❌ | ||||
| Hydra gates (axe) | ✅ |
Quality workflow — 2026-09-18 17:05 UTC
Download the full PDF report from the workflow artifacts.
rubenvdlinde
added a commit
that referenced
this pull request
Sep 18, 2026
…itten to (#2082) `digitalPostMessage` shipped in `components.schemas` and in neither `register.integriq.schemas[]` nor the test's `SCHEMA_SLUGS`. Declaring a schema is not attaching it: the schema existed on the instance and every read and write through `/api/objects/integriq/digitalPostMessage` answered "Schema not found". `DigitalPostService::SCHEMA` is that slug, and the write around it is wrapped in a `catch (Throwable)` that logs a warning, so the record of what was posted as digital post was dropped on every send and nothing errored. The letter still went; the evidence that it went did not. This is the exact shape `sync_item_dead_letter` shipped in, which is why the constant carries a comment about that one. It now carries a second. The guard written to catch it did catch it: `testNoSchemaIsDeclaredOutsideTheSlugList` has been failing, so the unit suite is red. Detaching the schema again reddens `testRegisterDeclaresAllSchemaSlugs`, so the attachment is the fix and not the constant being padded to match. 🔴 THE DEFECT ORIGINATES ON `development`, NOT ON THIS BRANCH. It arrived with 626d8da (#2062, the digital post seam), and both files are byte-identical on `development` today, so `development` is shipping a red unit suite and a silent write. It is repaired here so it is fixed when `parity/round2` lands.
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this closes
From
~/memcap-work/feature-competitor-map.md:What is in here
DigitalPostProviderInterfacewithgetProviderId,getConfigSchema,send,statusandpollInbound, plusactivationRefusalsso a binding says what it needs before it is switched on. Bindings forlog,berichtenboxandpostex. A provider id nothing answers to fails naming itself and the ids that do exist: a misspelled provider that fell back to the log binding would report letters delivered that were never sent.DigitalPostSendRequestedEventwith a result slot that carries the tracked message id or a structured refusal, never both. ThedigitalPostMessageschema tracks the lifecycle, and the message is stored before the provider is called, so a provider that throws leaves a letter somebody can retry rather than a gap in a log. A failed send keeps its attachments.DigitalPostDeliveredEventgoes out on every status change, includingfailed.digitalPostand carrying the sender as metadata. An instance with no intake inbox is told the item stayed here. Health answers last send, last error and queue depth.dispatch()now takes acertificateRefinstead of a PEM and a key, on the abstract, the mock and the source adapter. A PEM cannot travel through a call argument any more, and the test asserts the envelope contains neither a certificate nor a key. An unavailable broker fails the send closed with the broker's reason.logius.berichtenbox.feature_flag, which it did not before. Unset, the mock is served and every result sayssimulated. Set, the mock is not served at all: the instance resolves to a binding that refuses and names what is missing. An operator who turns that flag on is asking for real letters, and a simulated delivery there is indistinguishable from a real one.Still blocked, deliberately
Task 8, the live network leg, is untouched. All three of its blockers stand: Logius BBK OAuth client credentials, a PKIoverheid Services-server certificate, and
CredentialBrokerService::issueSigningMaterialin OpenRegister, without whichPkiOverheidCredentialResolverfails closed for every reference.BerichtenboxClientUnavailableis what a flagged instance resolves to untilBerichtenboxClientHttpcan exist, and it says all of that in its refusal.Also open, one line each in
tasks.md: the source page's provider picker and its strings, and the cross-repo follow-ups.Verified
Build-first phase, so the agreed subset:
php -lon every changed PHP file, the touched unit suites,openspec validate berichtenbox-digital-post-adapter --strict, eslint and tsc on the new spec, and the JSON validator on the register.tests/e2e/digital-post-source.spec.tscovers the activation refusal, the no-simulated-delivery invariant and the catalog descriptor. Written for the nightly, not run here.Deferred to the later quality sweep and recorded in
quality-debt.md: phpmd, psalm, phpstan, the whole-tree gates,check:strictand the e2e run.🤖 Generated with Claude Code