Skip to content

feat(dedup): a form can ask whether a record already exists before it saves - #3770

Merged
rubenvdlinde merged 9 commits into
developmentfrom
feat/dedup-check-before-create
Sep 15, 2026
Merged

rubenvdlinde merged 9 commits into
developmentfrom
feat/dedup-check-before-create

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this builds

Ledger row 2.24, duplicate detection at intake. A form can now ask "is there
already one like this" before it saves, and a schema can say what a strong
match means at create.

duplicate-detection could only ever compare two objects that were already
stored, so a warning at intake had nothing to call: the rules and the scoring
existed, the create-time question did not.

  • DuplicateDetectionService::checkCandidate() scores an unsaved body
    against the stored objects of a register and schema. It writes nothing: the
    candidate never gets a uuid, never reaches a write path, and never touches
    saveObject. It is bounded exactly as the sweep is, the same capped read
    and the same blocking, so the two cannot disagree about which objects were
    eligible to pair.
  • One scorer, two inputs. scorePair() and checkCandidate() both run
    scoreAgainstRules(), which knows nothing about where its two payloads came
    from. A warning shown at intake and a duplicate found by a later sweep have
    to agree on what a duplicate is, and now they can only disagree by changing
    one function. DuplicateCandidateCheckTest::testCheckAndSweepAgreeOnTheScore
    scores the same two payloads through both entry points and compares.
  • POST /api/objects/{register}/{schema}/dedup-check, signed in, never
    public, returning each match with its score, the fields that matched and the
    rules that matched them.
  • onCreate and overrideGroups on x-openregister-dedup, validated at
    schema save. warn is the default and keeps today's behaviour exactly.
    block refuses a create that strongly matches, unless the caller is in a
    declared override group and asks with _dedupOverride.
  • The policy is enforced on the save path, not only in the endpoint, so a
    script, an import or an integration that never calls the check is stopped
    too. An exercised override lands on the new object's audit trail as
    dedup.overridden, naming what it was created over.

Three decisions worth reading

Blocking is evaluated in memory, not pushed down as an object filter. A
blocking token is normalised (trimmed, lowercased, accents folded) and an
object filter matches the stored value exactly. Pushing the candidate's raw
value down as a filter would have quietly dropped every duplicate whose casing
or spacing differed, which is most of them and exactly the ones the feature
exists to find. The cost is that the check reads the same capped set the sweep
reads; the gain is that the two provably agree.

The blocking guard is not gated on _rbac. _rbac: false means "skip the
permission checks" and ObjectsController sets it for every administrator, so
gating on it would have turned the declaration off for the caller most likely
to be doing a bulk create. This is a policy about the data, not about the
caller's rights. The opt-in that bounds the blast radius is the declaration:
nothing happens at all unless a schema asked for onCreate: "block".

There is no implicit admin bypass on overrideGroups. A schema that
declares block and names no override group has said that nobody overrides,
administrator included. That is a legitimate thing to declare, and an implicit
bypass would make it silently untrue. ADR-023: the permission is declared.

The policy is resolved lazily from the app container, the way
SaveObject::resolveRetentionService() already is, because it reuses
DuplicateDetectionService, which reads through ObjectService, which owns
SaveObject. Constructor injection would close that cycle.

The override is a parameter, not a body key. Found while building, and
worth naming because it is the silent kind. ObjectsController strips every
_-prefixed key from a create body before the save path sees it, which is the
convention for control parameters that must not be persisted onto the object.
So the first version of this never arrived: a caller entitled to save through a
blocking match would have been refused with a 409 and no way to tell a refusal
from a flag that vanished on the way. It is now read from the raw request and
threaded through ObjectService::saveObject() and SaveObject::saveObject(),
exactly as _failIfExists is, and a reflection test pins both hops. The body
key still works for service-layer callers, which have no request to read from.

The check's threshold control is _threshold, not threshold. Same class
of problem, caught the same way. A schema is free to declare a property
called threshold; a control sharing that name would have silently dropped the
candidate's own value out of the comparison. The underscore prefix is already
the API's reserved namespace, so nothing that starts with one can be a
property, and a test asserts that a property literally named threshold
reaches the scorer as data.

The contract for consumers

POST /api/objects/{register}/{schema}/dedup-check
{ "requester": "bsn:123", "subject": "Kapvergunning Eikenlaan" }

200 {
  "matches": [
    { "uuid": "...", "score": 0.97,
      "matchedOn": ["requester", "subject"],
      "matchedRules": [{ "field": "requester", "method": "exact", "similarity": 1.0 }] }
  ],
  "total": 1,
  "threshold": 0.85
}

An optional _threshold overrides the schema's cut-off for one call.

A refused create answers 409 with the same match shape under matches. A
caller in an override group repeats the create with _dedupOverride=true; it
is a request, evaluated against the schema's declared overrideGroups, and
asking is never the same as being allowed. It never has to be declared as a
schema property and never lands in the stored object.

What was verified

Every command below ran in this lane's own clone, with a per-lane HOME and
TMPDIR so the phpstan and pdepend caches are not shared with the other lanes
on this host.

composer lint                        exit 0   every PHP file parses
composer check:migration-version     exit 0   no migrations added, nothing to check
composer phpcs                       exit 0   74/74 files clean
composer phpstan                     exit 0   [OK] No errors, whole tree
composer psalm                       exit 0   No errors found!  (7h21m on a saturated host)
composer phpmd                       1 NEW finding, addressed below; 56 inherited
COMPOSER_PROCESS_TIMEOUT=0 composer test:all
                                     21591 tests, 53888 assertions
                                     0 failures, 0 errors, 24 skipped
                                     exit 1 comes from "No code coverage driver
                                     available" alone, which is a property of
                                     this host and not of the suite
npm run lint                         0 errors, 932 warnings (all inherited, no JS touched)

diff-check (hydra, --scope-to-diff, gates-timeout 1800)
  gates    PASS    0 NEW   0 inherited
  php -l   PASS    0 NEW
  phpcs    see the note below
  phpstan  PASS    0 NEW   0 inherited
  phpunit  PASS    0 NEW

The one NEW phpmd finding, and what was done with it. DuplicateDetectionService
reads at complexity 61 against a threshold of 50. It carries a class-level
@SuppressWarnings(PHPMD.ExcessiveClassComplexity) with the reason written out
in full: the class now has two entry points over one rule engine, and splitting
them is the obvious way under the threshold and the wrong one, because the
whole point is that the intake warning and the later sweep cannot disagree
about what a duplicate is. MergeService records the same reasoning for the
same rule. Suppressed with a reason and named here, not passed over in silence.

On diff-check's 82 phpcs "NEW" findings. Every one is
CustomSniffs.Functions.NamedParameters inside tests/, and the project's own
phpcs.xml declares <file>lib</file>, so composer phpcs, which is what CI
runs, never scans tests/ at all. diff-check passes changed files to phpcs
explicitly, which overrides that element, so it is measuring a tree the gate
does not. composer phpcs passes clean, and the new tests follow the calling
style of every other test in this repo.

A phpstan result cache lied, and the control is why it is not in this body
as a finding.
After merging development in, phpstan reported 1000+ errors
across 84 files, almost all of them "Call to an undefined method
Register::getId()" in files this change never touches. getId() comes from
Nextcloud's Entity base class and exists; no lock file moved in the merge.
Clearing the cache and re-running gave [OK] No errors. The number was the
cache, not the code, and counting it would have sent somebody after 84 files
that were fine.

development was merged in twice while this was open, once for a conflict
in tests/newman/run-all.sh and SaveObject.php's import block and once for
the import block again, both resolved as a union. phpcs, phpstan,
test:all and the affected unit tests were re-run against the merged tree and
are the numbers quoted above. psalm is from the tree immediately before the
last merge plus a re-run over the one file this change touched after it.

Three runs were thrown away rather than quoted. The first check:strict
had files edited under it mid-run, the second was abandoned for the same
reason, and the third was invalidated by merging development in to resolve a
conflict. The numbers above are from runs against the tree that is being
merged.

What was left out

The 409 refusal is not asserted in Newman: that path needs a caller outside
the override groups and the collection runs as one user. It is covered by
DedupCreatePolicyTest, which exercises the refusal, the refusal of an
override request from outside the group, and the refusal of a group member who
did not ask.

No frontend. dossiq owns the intake form and the warning, under its own
duplicate-warning-at-intake slug.

Inherited findings

267 phpcs findings on lines this change did not touch (223 in appinfo/routes.php,
44 in two test files), 56 phpmd findings elsewhere in lib/, and 932 eslint
warnings across src/. None are fixed here per the inherited-debt rule, and
none are in the new code: phpstan and psalm both report zero over the whole
tree.

🤖 Generated with Claude Code

… saves

The duplicate scorer only ever compared two stored objects, so a warning at
intake had nothing to call. It now scores an unsaved body against the stored
set through the same rules, the same normalisation and the same cut-off, and
a schema can declare what a strong match does at create: warn, or block with
a named group allowed to override.

- checkCandidate() on DuplicateDetectionService, bounded by the same cap and
  the same blocking the sweep uses, blocking evaluated in memory because a
  blocking token is normalised and an object filter is not.
- POST /api/objects/{register}/{schema}/dedup-check, read-only, registered
  above objects#postPatch so the literal segment wins.
- onCreate and overrideGroups validated on the annotation.
- DedupCreatePolicy enforces block on the save path, so a client that skips
  the endpoint is stopped too, and an exercised override is on the audit
  trail as dedup.overridden.
Includes the parity test the design asks for: the same two payloads scored
through both entry points, so a change to either scorer that does not change
the other fails here.
$_rbac false means skip the permission checks, and ObjectsController sets it
for every admin, so gating the guard on it turned the declaration off for the
caller most likely to be creating in bulk. The policy is about the data, not
the caller's rights.
…rty of that name survives

A schema is free to declare a property called `threshold`. Stripping that key
from the candidate would have dropped the caller's own value out of the
comparison and answered confidently about a body the endpoint never fully
read. Underscore-prefixed keys are already the API's reserved namespace.
…ered out of the body

ObjectsController strips every `_`-prefixed key from a create body before the
save path sees it, which is the convention for control parameters that must
not be persisted onto the object. So `_dedupOverride` never arrived: a caller
entitled to save through a blocking match would have been refused with a 409
and no way to tell a refusal from a flag that vanished.

It is now read from the raw request and threaded through
ObjectService::saveObject() and SaveObject::saveObject(), exactly as
`_failIfExists` is. The body key still works for service-layer callers, which
have no request to read from.

A reflection test pins both hops, because the failure is silent.
…ck-before-create

# Conflicts:
#	lib/Service/Object/SaveObject.php
#	tests/newman/run-all.sh
…call

phpstan's baseline pins the count of that nullsafe pattern in this file, and a
fifth one is a hard error. $registerId is already what resolveSchemaAndRegister
returned, so the call was redundant as well as counted.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ f0c3fb6

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
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

Quality workflow — 2026-09-15 09:53 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ 3f22cfb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
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

Quality workflow — 2026-09-15 10:01 UTC

Download the full PDF report from the workflow artifacts.

…rule engine

phpmd reads the class at complexity 61 against a threshold of 50, and the
eleven points are exactly the guarantee this change exists to make: a sweep
over stored pairs and a check of an unsaved candidate share one config
resolution, one blocking token, one path resolver and one scorer. Two classes
would be two copies of that agreement with no way to notice when they drifted.
…ck-before-create

# Conflicts:
#	lib/Service/Object/SaveObject.php
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ bbe5b58

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
test-l10n
test-l10n-parity
format
check-schema-l10n
check-l10n-js
composer ✅ 174/174
npm ✅ 653/653
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

Quality workflow — 2026-09-15 18:35 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit d27a9a1 into development Sep 15, 2026
39 of 48 checks passed
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