Skip to content

feat(digital-post): one seam for digital post, and a flag that refuses rather than simulates - #2062

Merged
rubenvdlinde merged 13 commits into
developmentfrom
feat/berichtenbox-digital-post-adapter
Sep 18, 2026
Merged

rubenvdlinde merged 13 commits into
developmentfrom
feat/berichtenbox-digital-post-adapter

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What this closes

From ~/memcap-work/feature-competitor-map.md:

  • integriq/berichtenbox-digital-post-adapter - OpenCatalogi/ZGW-ref (partial), Zaaksysteem.nl (partial), no competitor scores yes. 12.9 Digital post or berichtenbox

What is in here

  • REQ-DPA-001. DigitalPostProviderInterface with getProviderId, getConfigSchema, send, status and pollInbound, plus activationRefusals so a binding says what it needs before it is switched on. Bindings for log, berichtenbox and postex. 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.
  • REQ-DPA-002. DigitalPostSendRequestedEvent with a result slot that carries the tracked message id or a structured refusal, never both. The digitalPostMessage schema 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. DigitalPostDeliveredEvent goes out on every status change, including failed.
  • REQ-DPA-003. 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. An instance with no intake inbox is told the item stayed here. Health answers last send, last error and queue depth.
  • REQ-DPA-004. dispatch() now takes a certificateRef instead 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.
  • REQ-DPA-005. The DI factory branches on logius.berichtenbox.feature_flag, which it did not before. Unset, the mock is served and every result says simulated. 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.
  • REQ-DPA-006. The Berichtenbox binding is built on the client that ships. No second Berichtenbox client exists. The catalog descriptor names the Logius product, both credentials, and what the flag does.

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::issueSigningMaterial in OpenRegister, without which PkiOverheidCredentialResolver fails closed for every reference. BerichtenboxClientUnavailable is what a flagged instance resolves to until BerichtenboxClientHttp can 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 -l on 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.

  • 21 tests, 68 assertions on the digital post suites; the catalog suite still green at 24 tests.
  • Mutation check: making a failed dispatch fall back to a simulated delivery reddened both assertions that say a refusal is not a simulation, then restored.
  • tests/e2e/digital-post-source.spec.ts covers 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:strict and the e2e run.

🤖 Generated with Claude Code

… 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.
…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.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/integriq @ 2b2c4b2

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
rubenvdlinde merged commit 626d8da into development Sep 18, 2026
37 of 48 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant