Skip to content

Round 2 parity: the digital post record reaches the register it is written to - #2083

Merged
rubenvdlinde merged 24 commits into
developmentfrom
parity/round2
Sep 19, 2026
Merged

rubenvdlinde merged 24 commits into
developmentfrom
parity/round2

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What this lands

24 commits of the round 2 parity programme, closing with #2082, which attaches the
digitalPostMessage schema to the register it is written to.

Before #2082 the schema shipped in components.schemas and in neither
register.integriq.schemas[] nor the guard's SCHEMA_SLUGS. DigitalPostService::SCHEMA
addresses it by that slug and its write sits inside a catch (Throwable) that only logs a
warning, so every record of what was posted as digital post was dropped on send and nothing
errored. The letter went; the evidence that it went did not. The defect arrived on
development with 626d8da and is repaired here.

What I verified locally

Range: origin/development (626d8dab) .. parity/round2 (9d2b6ea8), 24 commits.
git rev-list --count parity/round2..origin/development is 0, so development is a strict
ancestor and the merge base is the development tip. A file this range never touches is
byte identical to development, so anything reported in it is inherited by construction.
Every number below is measured against a git archive origin/development control tree with
the same vendor, not assumed.

stage branch development verdict
check:no-legacy-types PASS
check:routes PASS, 261 routes
lint (php -l) PASS
phpcs 482 errors, 694 warnings 482 errors, 651 warnings 0 new errors, +43 new warnings
phpmd 123 123 0 new, identical sets
psalm 2 errors 2 errors 0 new
phpstan 3 errors 4 errors 1 fixed, 0 new
test:all OK, 3679 tests, 12329 assertions 0 failures, 2 skipped

Frontend, every leg the caller in code-quality.yml declares: eslint, stylelint, vitest,
check:specs, check:l10n-js and the webpack build all exit 0. Three legs are red and all
three are inherited: test:l10n 4 missing on both, format 18 files on both, and
check:schema-l10n 1372 uncovered against 1374 on development, so the branch is two
better there.

Hydra gates, --base origin/development --scope-to-diff: 55 of 55 applicable gates ran,
53 pass. Both failures are named below.

The attachment is the binding, not a padded constant

Detaching the schema again, by reverting only lib/Settings/integriq_register.json to
9d2b6ea^, reddens RegisterDescriptorTest::testRegisterDeclaresAllSchemaSlugs at line 228,
on the assertSame itself rather than on setUp, with exactly one failure of 56.
testNoSchemaIsDeclaredOutsideTheSlugList stays green under that mutation, which is the
control: the two guards bind different things, so the fix is the register attachment and not
the constant padded to match.

Inherited, and reported rather than adopted

  • phpcs: 482 errors, every one of them present on development at the same file and count.
  • phpmd: 123 findings, the same set as development; the only difference is two line
    numbers shifting by one where this range added a line to FileMigrationSource.
  • psalm: 2 UndefinedClass errors on OCA\OpenRegister\Event\RegistrySubscriptionRequestedEvent.
    The class does exist in OpenRegister and a stub ships here; it is simply absent from the
    UndefinedClass suppression list in psalm.xml that carries every other cross app
    OpenRegister class. Present on development too, so inherited.
  • gate-106 cross-app-schema-slug: the slug it objects to is message, which is on
    development. The gate fired only because fix(digital-post): attach the message schema to the register it is written to #2082 touched the descriptor and brought the
    whole file back into diff scope. The slug this branch adds, digitalPostMessage, is not
    flagged.
  • gate-101 demo-data-coverage: 11 schemas without demo data, 10 of them already on
    development.

Carried in, and genuinely new

  • +43 phpcs warnings, all of one kind: a missing @spec PHPDoc tag on a class or public
    method in the 22 files this range adds. No new errors.
  • gate-101 counts one more schema than development does, digitalPostMessage, and it
    counts it because fix(digital-post): attach the message schema to the register it is written to #2082 attached it. While the schema was unattached the gate could not
    see it. Left for the demo data sweep rather than regenerated here: the generator rewrites
    the whole mock register, and on filinq a --keep run discarded 4451 lines of curated data.

Not run in this lane, and saying so rather than reporting a green I did not earn: Playwright,
Newman, axe, the coverage guard (neither pcov nor xdebug is loaded in php8.3 on this box) and
the PHP by Nextcloud matrix, all of which need a live instance this lane may not touch.
composer install needed --ignore-platform-req for ext-bcmath, ext-soap and ext-intl,
which are absent here; vendor/ matched composer.lock exactly at 141 of 141 packages.

🤖 Generated with Claude Code

…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.
…ller refuses (#2070)

Tasks 1 and 2 of zgw-connectors-for-dossiq.

Six sets, one per ZGW component, each declaring its auth, the apiVersion it
speaks, and its synchronizations and mappings BY SLUG per ADR-015. A shipped
numeric id points at whatever holds that id on the installing instance, so it
resolves, it installs, and it wires the set to somebody else's configuration.

The refusal that matters is two sets on one schema. Both synchronizations run,
both write the bound schema, and each overwrites what the other just wrote.
Nothing errors. The schema holds whichever set ran last, changing every few
minutes, and both source pages report a healthy synchronization because from
each one's own side it is healthy: it read its store and it wrote its objects.
An operator can watch one page forever without seeing the other undoing it. So
the second install is refused and NAMES the set that holds the schema.

No set names a fleet app, and that is enforced rather than intended. App ids in
this fleet move, and the lookups they feed are duck-typed: a name nothing
answers to does not error, it returns false, and the integration becomes a
silent no-op that every screen reports as configured. Both the current and the
retired spellings are refused, because a set written before a rename carries the
old one and checking only the new one lets every pre-rename set through.

A set installed against no register or schema is refused: that synchronization
runs, reads the store and writes nowhere while reporting a good run.

Every shipped set is run through the template guard by a test, so the rule is
checked against the things it was written for rather than only against fixtures.
… so (#2073)

Tasks 1 to 5 of one-off-and-suppressed-recipients.

The resolved list alone is not a record. From "these three addresses received
it", a recipient deliberately kept off a beschikking, a recipient who was never
on the list, and a bug that dropped one all look identical. So all three parts
are kept: standing, added and suppressed, with the suppressed recipient still in
the record and marked.

A suppressed recipient carries no delivery state. Not failed, not pending, and
above all not "not reported": that reads as a transport that never answered and
sends somebody chasing a provider about a message nobody sent.

Three refusals, each stopping the send rather than correcting it, because every
available correction is a lie. A suppression with no reason is refused, since
defaulting it to "no reason given" makes the record complete and useless. A
suppression of a recipient the CALLER marked required is refused and the refusal
repeats the caller's own words: "this recipient is required" gives a handler
nothing to argue with or escalate, "the applicant must receive the beschikking
under Awb 3:41" tells them what to do next. A suppression of somebody not on the
list is refused, because it records a decision that changed nothing.

Integriq never decides what is required. Only what the caller marked, with the
caller's own stated reason. A rule invented here would be integriq overriding a
municipality's own legal advice about its own letters.

The resolver is pure, which is the strongest available proof that an addition
touches nothing outside its message: there is no standing list to update, no
party record to amend, no subject to touch.

The preview calls the send's resolution rather than reimplementing it. A second
implementation drifts, and the first time anybody notices is when a handler has
approved a list the message did not go to, which is worse than no preview.
…ise, by name (#2074)

Tasks for REQ-SOW-001 and REQ-SOW-003 of signed-outbound-webhooks.

Signed is the default. The opposite default is what most outbound webhooks ship
with: unsigned unless configured, so every receiver that never got round to
verification keeps working and nobody finds out which ones those are until
somebody asks. A receiver cannot tell a request from integriq from a request
that merely says it is, and neither can integriq's own delivery log.

Unsigned without a reason is refused. Turning signing off on a Friday afternoon
to make a stubborn receiver work is legitimate; doing it and leaving nothing to
say why is how it is still off two years later, when nobody remembers the
receiver was supposed to be fixed. The reason, the user and the time are stored.

An existing subscription never gains a secret on upgrade. Retrofitting one would
start sending a signature to a receiver that was never given the secret: the
strict ones begin rejecting deliveries, the lax ones ignore the header, and
either way an operator now believes a channel is verified that is not, which is
worse than knowing it is unsigned.

And a save that does not mention the secret does not lose it, so an ordinary
edit of a sink or a filter never silently unsigns a channel.

The list marks the unsigned one with its reason. A list showing only "push"
beside both leaves an operator to open every subscription in turn to find them,
which is the same as not telling them.

Every delivery attempt records its signing, retries and operator replays
included. A log that records it only on the first try cannot answer the question
a receiver asks after a bad night: was the request I got at 03:14 signed? A
replay is the attempt most likely to differ, because it happens after somebody
has been changing the configuration.

The verification recipe is available whether or not the secret is revealed. The
person integrating the receiving end is usually not the one who created the
subscription and will never see the reveal.

Reuses the existing WebhookSignatureService for generation, signing, rotation
grace and the whsec_ prefix rather than growing a second scheme.
…uard (#2075)

Gap register row 12.3, statutory. This builds the three pieces that decide
correctness and no HTTP: no route of either API is served yet, and the PR
body says so route by route rather than implying the whole.

Each of the three fails invisibly if it is got wrong. Two registers can each
hold a schema called melding, so a name-inferred mapping writes into
whichever was first and nothing says which — the mapping is declared, the
uuid is the published identity that survives a reseed, and a second
declaration for one uuid is refused rather than overwriting.

The token check is fail-closed in its ORDERING: the token resolves before an
objecttype is looked up and before any register is touched, because a lookup
that happens first is a lookup an unauthenticated caller caused. The key is
resolved by reference and compared with hash_equals, and a declaration
carrying a literal key is refused — that is how a key reaches an export.

The leak guard is an allowlist, not a blocklist. A blocklist is correct until
OpenRegister adds a field, and then it ships that field to every counterparty
in the landscape in a release nobody connects to the leak.

Named rather than hidden: the 404/403 split lets a valid token enumerate
which objecttypes exist. That is the standard's shape, and a test asserts it.
Both standards' routes are served now, and the PR body's tables say route by
route what a consumer reaches. Two things are still not true of this facade
and are named rather than implied.

The geometry search has been run against NO spatial engine. It is a haversine
over the candidate set in PHP, deliberately, so it needs no PostGIS — and
that is also its limit: on a large register it reads more rows than a spatial
index would. The tests exercise the arithmetic against fixed coordinates with
no database at all.

A write is REFUSED with 503 while the objecten kanaal is unwired. The
requirement pairs writing and announcing in one sentence because they are one
contract: an object that changed without an announcement is an object the
landscape does not know changed. A loud temporary refusal beats a quiet
permanent divergence, and it means this cannot ship as an unannounced write
by accident.

A list with no type is refused rather than answered with everything, because
the token carries a permission per objecttype. A malformed data_attrs clause
matches nothing rather than being ignored. An unevaluable search refuses
rather than widening. count is the total, not the page size. A PATCH merges.
And every one of the ten routes runs the token check first, asserted
structurally so a route added later fails.
…not be sure of (#2076)

Finishes tasks 1 and 2: the two bindings, the source resolver, the intake,
the endpoint, and the lookup that turns a ringing number into a person.

The lookup is where the wrong-person risk lives, so nearly all of it is
refusals. The comparison is exact over full E.164 strings: not a suffix
match, not the last nine digits, not a LIKE. +31612345678 and
+49612345678 differ only by their prefix and are different people, and an
agent reads the panel and says a name out loud before anybody can check
it. More than one party on a number is no match either, because a shared
landline is common and picking the first is picking at random. When no
party matches there are no open cases either: a case list beside a null
caller is somebody's cases on screen under unknown caller.

The search seam is its own interface, kept off
KlantinteractiesProviderInterface, because adding a method there would
force the log sandbox and the REST client to answer a question neither
was written for. Until a binding implements it every caller reads as
unknown, which is true and visible.

Two refusals on the way in. A binding that throws during verification has
not said yes, so the request is refused. And a source naming a binding
this instance does not have is refused rather than falling back to the
sandbox, unlike the KISS resolver it otherwise mirrors: a typo would
look like a working integration quietly delivering to a log file.

A test harness bug found on the way: an arrow function captures by value,
so the cache double never saw its own writes and every retry looked like
a first sight. The test would have passed for a deduplicator that does
nothing.
… ones I had missed (#2079)

Three wrappers checked whether OpenRegister's context class existed, elevated if
it did, and otherwise called the operation plainly. That fallback is not a
graceful degradation: it runs the identical write as whoever is signed in and
returns the same value the elevated call would have, so nothing distinguishes it
from having worked. The wrong principal is recorded, or a permission check fails
somewhere unrelated for a reason nobody traces back to a missing class.

SystemWrite is now the one way to say it. The guard stays, because integriq
genuinely cannot hard-depend on OpenRegister; the fallback goes, because
answering "no" by doing it anyway was what was wrong.

The repo-wide test found more than the three. SynchronizationContractService and
SynchronizationService called the context UNGUARDED in four places, a fourth
shape that does not degrade but would fatal if OpenRegister were absent, and
that a scan for class_exists never sees. Seven sites across five files now go
through SystemWrite, which prefers assertSystem so the elevation is verified
rather than merely entered.

Three other files name the context only in comments. The test looks for actual
use rather than the word, so that documentation stays.

The refusal is split into refuseWhenUnavailable(bool, string) because inside
run() the branch is unreachable wherever OpenRegister is installed, which is
everywhere the suite runs: mutating the throw away reddened nothing, leaving the
most important rule in the class pinned by no test. Taking availability as an
argument makes the decision assertable rather than the environment.

OPENREGISTER ITSELF MUST NOT GAIN THIS GUARD. It uses the context unguarded in
fourteen files because it owns the class: there class_exists is always true and
a guard would be dead code. A test records that so nobody aligns them.

The integriq create-cascade question is now answerable: a sweep has one idiom to
trust. The next lane should re-run the per-call measurement and treat a
SystemWrite::run site as system-written and everything else as acting-user.
…d filinq (#2080)

* feat(document generation): the vendor seam, and a vendor nobody could reach says so

One provider interface with a log binding, SmartDocuments and Xential.
Filinq asks for a render with a typed command, integriq tracks it as a
job, resolves the credential by reference through the broker, and
announces the outcome.

Three rules the job holds. It records a hash of the merge data and never
the data, so integriq never ends up with a second copy of the case. A
vendor nobody could reach is unreachable, not failed: the vendor refused
nothing, the render is unfinished, and nothing is announced while nobody
knows whether a document exists. And a render is filed as complete only
once its document has been fetched whole: reported done with no document,
or fetched empty, ends failed with the reason.

* feat(document generation): the operator half, the sweep, and the docs

Two administrator endpoints: read a vendor's own template list for a
source, and activate a source only when it can actually render. A vendor
source with no credentialRef is refused there, not at the first render,
where the refusal would arrive as a failed beschikking.

A five-minute sweep polls every queued and unreachable render, which is
what makes unreachable a state rather than a dead end.

No bespoke Vue source form: these sources are configured through the
surfaces every other integriq source uses, and the tasks file says so.
Kept both route blocks: the CTI events endpoint and the objecten API from the
integration branch, and the digital post provider listing from development.
The kcc-cti-adapter tasks file and the app version come from the integration
branch, which is further along on both.
… clear the new gate and phpcs findings

The merge of development into parity/round2 kept our side of appinfo/info.xml
whole, and our side did not have the two `<job>` lines development added with
the digital post adapter. The classes came across with the merge, their tests
came across, and nothing registered them, so DigitalPostStatusJob and
DigitalPostInboundJob were two TimedJobs that cron would never call: a letter
handed to a provider would have stayed "on its way" for good, and nothing that
arrived would have reached the intake inbox. Both are back where development
had them.

Gate 7 read DELETE /api/v2/objects/{uuid} as an unscoped lookup. The guard is
real - the token verdict runs first and the write handler resolves register and
schema from the objecttype the token was approved for - but the method also
took the caller's uuid straight to the delete while its siblings resolve it
first. It now does what they do: the principal is resolved once instead of
twice, and a uuid belonging to another objecttype meets the read path's 404
rather than a delete that was never authorised for it.

The rest is the code standard on lines this branch wrote: named parameters on
the twenty-two internal calls that were missing them, and twelve ternaries
written out as the conditionals they are. No behaviour moves; the Objecten and
document generation suites, 84 tests, pass unchanged.

Inherited findings are left alone and reported rather than fixed.
…t that can fail, and the spec tags this branch owed

Ten routes were #[PublicPage] with no volume ceiling, which ADR-082 exists to
stop: anonymous callers, no session, nothing bounding how often they may ask.
Reads take 600 a minute and writes 120, which is a supplier's integration rather
than a browser.

The Objecten controller had no test of its own at all. Its handlers did, and a
handler test cannot see the one thing only the controller decides: whether the
token verdict is reached before anything opens a register. Ten routes are now
asserted to answer 401 without a token, per route rather than once, because a
route that forgot the call would pass a single-route test.

That test also found the delete was not theoretical. Mutating the uuid
resolution away reddens the assertion, not the setup, and the old code answered
204 for an object belonging to an objecttype the token was never approved for.
Gate 7 was right.

Fifty-five methods carry the @SPEC their class already carried. Two in
SystemWrite carry a reason-bearing exclude instead: there is no change to cite.
The tag on SystemWriteTest named
openspec/changes/three-degrading-system-writes/, which was never written, in
this repo or any other, and a citation that resolves to nothing reads as
coverage while stopping anyone from looking. It is gone, and the docblock says
why.

Left alone and reported instead: eleven schemas short of demo data, ten of them
identical to development and one ours, because the regenerator rewrites the
whole mock register and dropped 7,521 lines of curated data on the first try.
Four manifest strings have no Dutch key; this branch touched no manifest.
…son beside every suppression

Twenty-nine findings sat on lines this branch added. Four are now gone for real:
the haversine term is $haversine rather than $h, the SmartDocuments binding is
$smartDocuments, and the two named arguments in the tests moved with it, which
is how the rename got caught at all.

The rest are suppressed, and every suppression carries the sentence that makes
it checkable. RenderOutcome's queued(), rendered(), failed() and unreachable()
are that value object's named constructors, so static access to them is how it
is built; the alternative is a `new` with four positional arguments and no name
for the state being expressed. ZgwSetCatalogue is a catalogue of constants with
nothing to instantiate. $argument on a TimedJob is Nextcloud's signature. The
boolean flag on refuse() and verdictFor() is there because a token carries read
or read-and-write PER objecttype, so one lookup answers both questions and two
methods would be two chances to call the wrong one. Ten of the Objecten
controller's eleven public methods are the VNG standard's own routes, and
splitting the class to get under the threshold would put the single token check
in two places.

A suppression nobody can argue with is how five analysers came to read zero
while a thousand findings sat behind them, so none of these is bare.
…ollaborator nobody asked

PHPStan named two things on lines this branch wrote, and both were real.

SystemWrite::run() preferred OpenRegister's `assertSystem()` and fell back to
`run()` "only for an OpenRegister old enough not to have it". No OpenRegister
has it: `assertSystem` appears nowhere in that repository, and the stub the
whole suite runs against does not declare it either, so `method_exists()`
answered false in production and in the tests alike. The docblock read as a
verification the elevation never got. Deleted rather than stubbed, because
adding the method to the stub would make the suite assert an API the peer app
does not publish, and the remaining path is the one that was always taken.

CtiEventIntake took a CallEventNormaliser and never read it. Normalising is the
provider's job — `$provider->normalize()` in handle() — and both bindings take
their own normaliser and use it. The unread one is gone, along with the argument
in the test that constructed it.

Also wrapped two @SPEC exclude lines that ran past the 150-character limit.
…ct we already ship (#2081)

Parity ledger row 1.9, the Teams third of it. Outlook is already answered by
the graph mailbox transport and .msg import, and an Office document by the
Files action once it is in Nextcloud. Teams is answered by nothing, and what
it needs is one adapter and one signature scheme: Teams signs an outgoing
webhook with Authorization: HMAC <base64> and no timestamp, which none of
openconnector, stripe or github reads.

Names the thing that makes this worth building: nothing in dossiq listens to
IntakeMessageRoutedEvent today, so every channel we receive is held.

No code in this pass.
The CI format leg is not part of check:strict, so it went unrun all night. It
names nineteen files; eighteen are development's and stay that way.
…tops shouting

`check:schema-l10n` is a CI leg that check:strict does not run, so nobody had
looked at it all night. It counts 284 schema strings with no catalogue key
against a baseline of 1111. Measured on an origin/development worktree, that
number is 263 there, so twenty-one of them were this branch's, all of them the
document generation job's own titles and descriptions. They now have an English
identity key and a Dutch translation, and the uncovered count matches
development's exactly again.

The schema title was "Document Generation Job". Title Case is banned outright by
voice.md section 8 and it is the first thing a Dutch reader sees on the register
page, so it is "Document generation job" now, sentence case like every other
title in the file.

While in the same two files, four manifest strings that gate 102 names had no
Dutch key at all and rendered English inside a translated form: Adres, Zaak,
Ingeschakeld, Reden. They are development's strings, not this branch's, and they
are fixed here only because the exception for inherited debt is exactly this
shape: already in the file, under a minute.

`npm run l10n:build` regenerated en.js and nl.js; `check:l10n-js` agrees.
PHPStan's only `method.notFound` in the app: `IRootFolder::get()` answers a
Node, and `getContent()` is declared on File. For a real file it worked, because
the concrete class is a File. For a folder it did not, and the catch-all two
lines below turned the resulting error into "could not be read" — the sentence
written for a corrupt delivery. An operator who typed a directory path was sent
to look at the file.

The node is checked before it is read, and the refusal names what is wrong. The
new clause that rethrows InvalidArgumentException is load-bearing rather than
tidy: without it the Throwable catch swallows the specific message and hands
back the generic one, which is the thing being fixed.

Mutating the guard to `if (false)` reddens the new test with the old sentence,
so it pins the behaviour rather than the shape.

This file is identical on development. It is fixed here because it is a defect
with a one-line cause, not because the sweep is adopting inherited debt: the
remaining programme-era findings in it, twelve phpcs errors, are untouched and
reported.
…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.
@rubenvdlinde
rubenvdlinde merged commit f0b77f0 into development Sep 19, 2026
34 of 43 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/integriq @ 66f3e14

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-19 05:54 UTC

Download the full PDF report from the workflow artifacts.

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