feat(dedup): a form can ask whether a record already exists before it saves - #3770
Merged
Merged
Conversation
… 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.
Contributor
Quality Report — ConductionNL/openregister @
|
| 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.
Contributor
Quality Report — ConductionNL/openregister @
|
| 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
Contributor
Quality Report — ConductionNL/openregister @
|
| 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.
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 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-detectioncould only ever compare two objects that were alreadystored, 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 bodyagainst 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 readand the same blocking, so the two cannot disagree about which objects were
eligible to pair.
scorePair()andcheckCandidate()both runscoreAgainstRules(), which knows nothing about where its two payloads camefrom. 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::testCheckAndSweepAgreeOnTheScorescores the same two payloads through both entry points and compares.
POST /api/objects/{register}/{schema}/dedup-check, signed in, neverpublic, returning each match with its score, the fields that matched and the
rules that matched them.
onCreateandoverrideGroupsonx-openregister-dedup, validated atschema save.
warnis the default and keeps today's behaviour exactly.blockrefuses a create that strongly matches, unless the caller is in adeclared override group and asks with
_dedupOverride.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: falsemeans "skip thepermission checks" and
ObjectsControllersets it for every administrator, sogating 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 thatdeclares
blockand 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 reusesDuplicateDetectionService, which reads throughObjectService, which ownsSaveObject. 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.
ObjectsControllerstrips every_-prefixed key from a create body before the save path sees it, which is theconvention 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()andSaveObject::saveObject(),exactly as
_failIfExistsis, and a reflection test pins both hops. The bodykey still works for service-layer callers, which have no request to read from.
The check's threshold control is
_threshold, notthreshold. Same classof problem, caught the same way. A schema is free to declare a property
called
threshold; a control sharing that name would have silently dropped thecandidate'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
thresholdreaches the scorer as data.
The contract for consumers
An optional
_thresholdoverrides the schema's cut-off for one call.A refused create answers
409with the same match shape undermatches. Acaller in an override group repeats the create with
_dedupOverride=true; itis a request, evaluated against the schema's declared
overrideGroups, andasking 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
HOMEandTMPDIRso the phpstan and pdepend caches are not shared with the other laneson this host.
The one NEW phpmd finding, and what was done with it.
DuplicateDetectionServicereads at complexity 61 against a threshold of 50. It carries a class-level
@SuppressWarnings(PHPMD.ExcessiveClassComplexity)with the reason written outin 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.
MergeServicerecords the same reasoning for thesame 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.NamedParametersinsidetests/, and the project's ownphpcs.xmldeclares<file>lib</file>, socomposer phpcs, which is what CIruns, never scans
tests/at all. diff-check passes changed files to phpcsexplicitly, which overrides that element, so it is measuring a tree the gate
does not.
composer phpcspasses clean, and the new tests follow the callingstyle 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
developmentin, phpstan reported 1000+ errorsacross 84 files, almost all of them "Call to an undefined method
Register::getId()" in files this change never touches.getId()comes fromNextcloud's
Entitybase class and exists; no lock file moved in the merge.Clearing the cache and re-running gave
[OK] No errors. The number was thecache, not the code, and counting it would have sent somebody after 84 files
that were fine.
developmentwas merged in twice while this was open, once for a conflictin
tests/newman/run-all.shandSaveObject.php's import block and once forthe import block again, both resolved as a union.
phpcs,phpstan,test:alland the affected unit tests were re-run against the merged tree andare the numbers quoted above.
psalmis from the tree immediately before thelast 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:stricthad files edited under it mid-run, the second was abandoned for the same
reason, and the third was invalidated by merging
developmentin to resolve aconflict. 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 anoverride 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-intakeslug.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 eslintwarnings across
src/. None are fixed here per the inherited-debt rule, andnone are in the new code: phpstan and psalm both report zero over the whole
tree.
🤖 Generated with Claude Code