Skip to content

fix(review): #4580 review findings, round 1 - #4655

Open
WilcoLouwerse wants to merge 33 commits into
developmentfrom
review/pr-4580-fixes
Open

WilcoLouwerse wants to merge 33 commits into
developmentfrom
review/pr-4580-fixes

Conversation

@WilcoLouwerse

Copy link
Copy Markdown
Contributor

Fixes for the review of #4580 (#4580 (review)). Merge into development; the next sync PR carries them to beta. Nothing here is merged by the reviewer.

Findings → commits

Finding Commit What changed
eng-f2 🔴 A required file property can never be satisfied by an upload token 59794316c6 An uploaded file counts as supplying its property, so a required file property no longer fails validation.
eng-f6 🔴 Review policy fails open for visibility when it cannot be read 402a14a9a9 Both lookups (schema read, file-to-object) fail closed to a decider-owned, hide-others policy.
data-f1 🔴 Bulk save overwrites another user's draft 728c921b61 2d0dbae3fb c5354845b3 d942f683a9 Bulk save, bulk delete and single delete refuse another user's draft (without announcing it exists); admins pass like the read path; the guard runs before the appendOnly check.
plat-f1 🔴 Frozen-file guard ignores writes made through a share f67de4564a The frozen-file guard resolves a node to its owner's home, so writes through user, group, federated and public-link shares are refused too.
plat-f2 🔴 Any admin-UI file-settings save clears the instance review default f1f9c1d83a A file-settings save keeps the stored review default when the key is absent; an explicit null still clears it.
plat-f3 🔴 Deferred-chunk limit is 32000, but NC 32 and 33 accept 4000 02b92226c4 fb4be1d927 59f9acaccb The chunk limit is read from JobList::MAX_ARGUMENT_JSON_LENGTH when the server defines it (34+), else 4000; an entry that alone is too large is kept without oldData and the log names the rules that are then skipped. A refused webhook job argument no longer aborts the other webhooks.
eng-f1 🟡 Upload endpoint ignores the form's audience 227bc52ed7 upload() applies the form's audience like submit(); a file property without maxSize is capped at 20 MiB.
eng-f3 🟡 Idempotency key is neither subject-bound nor race-safe 94854b289f 224da09c48 6703ff76a5 The stored answer is bound to the submitter and a payload hash (409 otherwise), and the key is locked while the submit runs (409 for a parallel retry; needs Nextcloud file locking). Spec scenario and docs updated.
eng-f5 🟡 Draft cleanup deletes without re-checking the draft f990f3dd52 The cleanup re-reads the object and deletes only a draft that is still expired.
eng-f7 🟡 Dependent form reported unpublished when nothing was unpublished 33f3e3fb57 A form with no unpublish closure is reported as unpublish-unsupported, not unpublished.
eng-f8 🟡 Task wake still overwrites another listener's signal c967b2d6bd The task wake merges its payload into the run signal instead of overwriting another listener's.
eng-f9 🟡 Decision ownership check is skipped in two cases 1dfdcd386f The decision ownership check also runs for a dangling uuid and inside the transaction (compare-and-swap link).
data-f3 🟡 RBAC draft predicate ignores unhealed _status 363e6075c7 The draft predicate is skipped on a table the healer reports without _status.
data-f4 🟡 Forced re-import deletes local authorization the file lacks 03e338eb67 A forced re-import keeps the instance's authorization when the file has none.
data-f5 🟡 Aggregation fast path counts drafts the list hides d4aa263863 The aggregation fast path applies the same draft predicate as the list.
plat-f5 🟡 A later listener's allow() overrides an earlier refusal a33e49b49f A refusal by an earlier listener can no longer be overridden by a later allow().
surf-f1 🟡 Object GET /logs bypasses the audit-history redaction 4e900c4582 902c28b14c Non-admin readers of GET /logs get the redacted history #4614 introduced (page shape without next/prev); admins unchanged.
surf-f4 🟡 Copies still send users to the removed personal settings form 5faeef9582 9420851e65 Links and texts point at the user settings instead of the removed personal settings form (en and nl l10n; other languages via Transifex).
aud-p1 🟡 The "may not let itself in" check ignores remote principals e19745bc96 A grant_manage holder can no longer grant to remote / remote_group principals.
aud-p4 🟡 Guard failure imports shipped authorization unguarded d0d2725b32 When the shipped-configuration guard fails, the instance's live authorization is kept instead of the shipped block.
plat-f4 🟡 Aborted delete or rename reads as success to internal callers (partial) 71f4796dc4 DeleteFileHandler::deleteFile() returns false when the node survives a refused delete. Rename and move: see below.
surf-f6 🟡 Dependent-form re-check runs on one schema write path only (partial) 1f4eafbd73 A container that cannot build the dependents check now logs a warning instead of skipping silently. The other write paths: see below.
eng-f10 🟡 Failure to arm a statutory expiry timer is only logged (partial) 09d5d985fd 50bee16d8f A failed statutory timer is logged at error level naming case, item and legal effect. Failing the transition is not possible from here: see below.

Left to the author (threads stay open)

  • eng-f13 🟡 Email recipient reference may point at any object in the organisation — not done: checking the related object's schema needs a rule for reference properties without $ref, and failing closed there would disable untyped references. Recommendation: refuse on a $ref mismatch when one is declared, plus a per-rule allowlist for untyped references.
  • aud-p3 🟡 Delegated grant changes leave no audit-trail row — not done: audit rows for grant, revoke, owning-group change and grant_manage list change. Recommendation: one AuditTrailMapper helper called from ObjectSharingService, ObjectOwnershipService::setOwnerGroup and OrganisationController::update, modelled on ownership_handover.
  • surf-f6 🟡 Dependent-form re-check runs on one schema write path only — the check still runs only on SchemasController. The importer, SchemaImportController, SchemaTool, SchemasToolProvider and SchemaService need a decision on what refuse means for a write without a response. Recommendation: a schema-updated listener with a controller-suppression flag, unpublish-only on non-request paths.
  • plat-f4 🟡 Aborted delete or rename reads as success to internal callers — the rename and move handlers still report success for a refused operation, and ExampleSetRemoveLiveTest has no case for it.
  • eng-f10 🟡 Failure to arm a statutory expiry timer is only logged — CasePlanStateMachine::flushEvents() swallows listener exceptions after the transition commits, so a statutory item can stay active without its expiry timer. A visible marker on the item needs a schema field.
  • plat-f3 follow-up: a webhook payload over the job-argument limit (4000 characters on Nextcloud 32/33) is now logged and not delivered; delivering it needs a payload reference or trimming.
  • data-f1 note: like the existing single-save guard, the new draft guards exempt admins but not a userless caller inside SystemOperationContext (the read path also exempts that one, MagicRbacHandler::isTrustedSystemCaller). No current caller hits it (system jobs pass _rbac:false); add the same exemption if a system-context flow should touch other users' drafts.

Tracked elsewhere

Checks run on this branch

  • bash hydra/scripts/run-hydra-gates.sh --scope-to-diff --base b9e5d8df40 → exit 0, COVERAGE 65 of 65 applicable gates ran (32 not applicable)
  • vendor/bin/phpunit on every touched test file, then tests/Unit/{Service,Controller,Listener,Db,Event} → no failures other than 61 could not find driver / not loadable errors (no database in this environment; the same errors occur without these changes); tests/Unit/Service/Object + the draft-delete and bulk tests after the last commit → OK (2747 tests)
  • vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 on every changed lib/ file → clean
  • Pre-push adversarial review, three rounds → round 1 and 2 findings fixed in the pre-push commits above
  • not run (no database or live Nextcloud): a Newman PUT as a sharee against a frozen object (plat-f1), a count over a table with a draft and an active row (data-f5; SQL and bindings are asserted instead), the guarded SQL update in EntityRelationMapper::linkDecision (eng-f9), a real parallel idempotent submit (eng-f3)

🤖 Generated with Claude Code

WilcoLouwerse and others added 30 commits October 15, 2026 17:39
…isfied by an upload token

validateAll() now excuses a property that carries an upload in the plan's files ('uploaded'), because mapPayload() moves the file out of the validated object. A required file property with a valid token is accepted; without a token it still answers 422 required.
Thread: #4580 (comment)
Sweep: saveDraft/promoteDraft/submit all go through validateAll, so one place. DraftActivation does not see form uploads.
Check: phpunit tests/Unit/Service/Form/FormSubmitServiceTest.php -> OK 26 tests (new test errors without the fix); phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hen it cannot be read

An unreadable schema now yields a decider-owned policy that also hides others, and a throwing file-to-object lookup in subjectOf() yields the same closed policy instead of the open instance default (also covers the surface slice's duplicate of this finding).
Thread: #4580 (comment)
Sweep: both failure branches of ReviewPolicyResolver now share closedPolicy(); no other resolver builds a fail-closed review policy.
Check: phpunit tests/Unit/Service/Decision -> OK 17 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
applyBulkSafeguards() now asks DraftStatusPolicy::keptFrom() for an existing row when RBAC is enforced and rejects the row with the same 'Permission denied' reason a refused update gives, so a draft stays unannounced. The owner's bulk save still updates it. Tests in Wave12BulkSafeguardsTest (the test fails without the fix).
Thread: #4580 (comment)
Sweep: keptFrom had one caller (ObjectService::refuseSomebodyElsesDraft); the bulk safeguard is the only other path that resolves an existing row past RBAC.
Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rough a share

A node outside the openregister home (a sharee's view: /alice/files/<share>/x.pdf) is now resolved by file id: only a node owned by the app account is looked up, in the app's home via IRootFolder::getById, and the existing UUID-ancestor + folder proof runs on that twin. A node that does not exist yet stands for its nearest existing ancestor. A home that cannot be searched fails closed with a log line. New constructor argument: IRootFolder (autowired).
Thread: #4580 (comment)
Sweep: the listener is the only path-prefix owner test (grep for '/files/' prefix checks in lib/ finds no other frozen-file gate).
Check: phpunit tests/Unit/Listener/FrozenNodeWriteListenerTest.php OK 21 tests; phpcs on the lib file clean. The Newman PUT-as-sharee case from Done-when was not run (no live Nextcloud).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he instance review default

updateFileSettingsOnly() writes anonymisationReview only when the request carries the key; without it the stored declaration is kept, and an explicit null still clears it. The test that asserted null after an empty save now asserts the kept value, plus the explicit-null clear. The Vue form is left alone: the server keeps the stored value, so the form need not send it.
Thread: #4580 (comment)
Sweep: ReviewPolicy::SETTINGS_KEY is written nowhere else (ReviewPolicyResolver only reads it).
Check: phpunit tests/Unit/Service/Settings/FileSettingsHandlerTest.php OK 20 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and 33 accept 4000

The chunk budget now follows the running Nextcloud: ListenerDeferralService::getArgumentLimit() reads OC\BackgroundJob\JobList::MAX_ARGUMENT_JSON_LENGTH when the server defines it (34 and later: 32000) and uses 4000 otherwise, which is what NC 32 and 33 enforce. Picking by constant rather than a fixed 4000 keeps 35 at full chunks; the 4000 fallback is the safe side everywhere. The limit can be injected through the constructor (optional, autowired as null). An entry that alone exceeds the limit is enqueued without its oldData and the reduction logged at warning level (the 'updated' notification is still sent, only calculatedChange is skipped); an entry still too large is dropped and named as before. MAX_ARGUMENT_LENGTH is replaced by ARGUMENT_LENGTH_NC32/NC34 (no other user). DeferredChunksFitTheJobLimitTest runs at 4000 and at 32000 and covers the reduction; the 'too large for any job' test now uses an entry whose size is not oldData.
Thread: #4580 (comment)
Sweep: the openspec change deferred-listener-batches-stay-under-the-job-limit (proposal + spec requirement) restated 32,000 and is updated to the runtime limit and the reduction.
Check: phpunit tests/Unit/Service/Deferral + ObjectCleanupJobTest OK 34 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FormsController::upload() applies submit()'s audience rule (shared audienceRefuses()/signInRequired() helpers): an anonymous visitor of a form that is not public gets 401 before any rule lookup or write. FormUploadStore also gets a 20 MiB default cap for a file property that declares no maxSize (surface slice's duplicate of this finding); a declared limit, larger or smaller, is kept.
Thread: #4580 (comment)
Sweep: FormUploadStore::issue has one caller (FormsController::upload).
Check: phpunit FormsControllerTest + FormUploadStoreTest OK 27 tests; phpcs on the lib files clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nor race-safe

The stored answer is now bound to the submitter (user id, scope value or anonymous marker) and a canonical hash of the payload: another subject, or the same key with another payload, gets a 409 that carries nothing of the first answer. A key is reserved with an exclusive ILockingProvider lock for the length of its submit (acquire, find, write, remember, release in a finally), so a parallel retry answers 409 and creates no second object; the lock is released after a refusal too. FormIdempotencyStore gets ILockingProvider and IL10N (autowired), find()/remember() take the binding, and an entry without a binding (older than this change, at most 24 h) matches nobody. Tests use a small in-memory MemoryLockingProvider. docs/features/form-destination.md gets the 409 row and the binding sentence (same sentence as the finding's).
Thread: #4580 (comment)
Sweep: FormIdempotencyStore has one caller (FormSubmitService::submitAll) plus the purge job, which is unchanged; the idempotency header is read only in FormsController::submit.
Check: phpunit tests/Unit/Service/Form + FormUploadPurgeJobTest + FormsControllerTest OK 106 tests; phpcs on the two lib files clean. The two-concurrent-requests Done-when is covered through a simulated parallel request inside the save, not a real parallel run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the draft

remove() now reads the object again (ObjectService::find, RBAC off, no render, no audit read) and deletes only while it is still a draft last changed before the cutoff the list was made with; a draft sent or touched since is counted as refused and logged at info level, an object that cannot be read again is left alone. The cutoff is passed through walk() and removeWithin(). DraftExpiryTest gets a find() fake and a test with a draft sent after the list.
Thread: #4580 (comment)
Sweep: DraftExpiry::remove has one caller; DraftExpiryJob and DraftsExpiredCommand only call sweep()/preview().
DraftExpiryJobTest gets the same find() fake (it builds DraftExpiry itself).
Check: phpunit tests/Unit/BackgroundJob + DraftsExpiredCommandTest + DraftExpiryTest OK 338 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nothing was unpublished

unpublish() answers 'unpublish-unavailable' (and logs a warning) for a FormDependent registered without a closure, instead of falling through to notify the author and answer 'unpublished'. The outcome is already listed per form in the schema save response ('affectedForms'), so the save surfaces it. FormDependentsCheckTest covers the null closure.
Thread: #4580 (comment)
Sweep: FormDependent::$unpublish is read only in FormDependentsCheck::unpublish.
Check: phpunit tests/Unit/Service/Form/FormDependentsCheckTest.php OK 5 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ma write path only (logging part)

formDependentsCheck() now logs at warning level when the container cannot build the check (it used to return null in silence, which skipped an owning app's refuse setting without a trace), and the lookup happens only for an update that carries properties or required. Test: testAFailingDependentFormCheckIsLogged.
Not done here: running the check on the other schema write paths (configuration importer, SchemaImportController, SchemaTool, SchemasToolProvider, SchemaService), because what 'refuse' means for a write that has no response to refuse is the author's call. See the hand-back note.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Controller/SchemasControllerTest.php OK 83 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion the file lacks

Under an administrator's decision only the authorization roots the incoming definition carries are decided; a block only the instance holds keeps its normal state (local is preserved and reported) instead of settling as UPSTREAM and being dropped, which left the schema default-open. New GuardedDescriptorMergeTest case under a ShippedAuthorizationDecision; the replace-when-shipped-has-it cases stay green (the test fails with the old roots($live, $incoming)).
Thread: #4580 (comment)
Sweep: AuthorizationBlocks::roots() has this one caller.
Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK 63 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tatus

MagicRbacHandler now takes the MagicMetadataColumnHealer (built first in MagicMapper::initializeHandlers) and an optional bare table name; reachableRowSqlFor() leaves the (_status IS NULL OR _status <> 'draft') term out when the table is known to lack _status, so a non-admin on a degraded table no longer fails with an unknown-column error. The table name is threaded through applyRbacFilters, buildRbacConditionsSql, buildRbacRowPredicateSql, buildRbacPredicateForAlias and buildRbacSqlTerms (all optional; a caller that names no table keeps the predicate, which is the narrower and safe answer) and passed by every caller that knows it: MagicSearchHandler (list, UNION conditions, single-object reads), the three MagicMapper single-object and scan sites, RelatedRowQueryApplier (the related table) and AggregationRunner (via MagicMapper::rbacRowPredicateSql(register:)). New test: testADegradedTableGetsNoDraftPredicate (missing: no _status, owner admit kept; healthy table and unnamed table: predicate kept).
Thread: #4580 (comment)
Check: phpunit tests/Unit/Db tests/Unit/Service/Aggregation tests/Unit/Service/Query: 2407 tests, 49 errors, all 'PDOException: could not find driver' or 'OC\DB\Schema\Table is not loadable' (no database driver / Nextcloud server classes in this environment), none from the touched code; MagicRbacDraftVisibilityTest OK; phpcs on the touched lib files: no errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…list hides

The native aggregation path adds DraftListLens's clause for the mode a query-less list uses (EXCLUDE, or NONE for a schema with draftsListed: false) next to the archive predicate: others' drafts are out of the count, the signed-in caller's own stay in through a bound owner placeholder (kept in step with the organisation bindings that follow), and the clause is left out of a table known to lack _status. This also holds for an administrator, whose RBAC predicate is null. New tests assert the generated WHERE and bindings for nobody, a user, draftsListed false and a degraded table.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/Aggregation: 276 tests, 21 errors ('PDOException: could not find driver', identical at HEAD) and no other failure; phpcs on the lib file clean. The 'count over a table with one draft and one active row returns 1' Done-when needs a database and was not run (SQL-level assertions stand in for it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…earlier refusal

DecisionChangeRequestedEvent keeps a refused flag: once refuse() was called, allow() does nothing, so the registration order of two apps' listeners cannot turn a veto into an allow. testAnAllowClearsTheReason is replaced by a sticky-refusal test covering both orders; the supervisor-may-be-refused and listener-may-allow cases are untouched and green.
Thread: #4580 (comment)
Sweep: DecisionVisibility's event is deny-by-default and allow-only (order independent); no other vetoable event in lib/Event has an allow() that clears a refusal.
Check: phpunit tests/Unit/Service/Decision + tests/Unit/Event OK 245 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… remote principals

GrantManageGuard::letsItselfIn() takes the remote principal being named and refuses it outright for a holder admitted by the special right alone (a federated cloud id or remote group cannot be proved not to be the caller); ObjectSharingService passes remote and remote_group grantees. The selfGrants data provider of GrantManageSpecialRightTest gains a remote and a remote_group case. The owner and the administrator are unaffected.
Thread: #4580 (comment)
Sweep: letsItselfIn has two callers; ObjectOwnershipService names only a group (remote is not applicable there).
Check: phpunit tests/Unit/Service/Rbac/GrantManageSpecialRightTest.php OK 17 tests; phpcs on the two lib files clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on unguarded

When the guard throws, the import still finishes but now writes the incoming definition with the instance's own authorization blocks put back (the schema's, and each property the instance already holds; a block the instance lacks is not added, a property the instance does not hold brings its shipped block), and logs it. Authorization blocks the merge took from the app because only the app changed them are now recorded on the audit trail (configuration.conflict.decided with decision applied-shipped, once per block root, skipping roots already recorded for an explicit decision); recordDecision() gets a decision argument for it. New tests: a throwing baseline read keeps the instance authorization, and an app-only authorization change is audited.
Thread: verdict #4580 (review) (this finding has no inline thread)
Check: phpunit tests/Unit/Service/ShippedBaseline + RegisterDescriptorServiceTest OK; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ersonal settings form

The relink notification now links to the OpenRegister app page with #user-settings (as the OAuth callback does) and says 'user settings'; the admin hint in PushNotificationsConfiguration.vue, its en/nl l10n keys (nl: gebruikersinstellingen), the credential-oauth2-connect requirement heading and text, the notificatie-engine requirement sentence, and the stale header comments in BrowserNotificationsSection.vue and OAuth2Endpoints.php are reworded to the user settings dialog. Other languages' l10n entries keep the old key until Transifex syncs. New NotifierTest case asserts the link.
Thread: #4580 (comment)
Sweep: git grep -i 'personal settings' over lib, src and openspec/specs leaves only comments that say OpenRegister is NOT in Nextcloud's personal settings (CredentialOauth2Controller, App.vue, UserSettings.vue).
Check: phpunit tests/Unit/Notification/NotifierTest.php OK 22 tests; l10n/en.json and nl.json parse. The anchored code is spec text and strings only, no eslint run was needed beyond the one-line template change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory redaction

ObjectsController::logs() answers a non-admin through ObjectHistoryForReaders::page() (the same read check, doelbinding guard and redaction as the audit-trails route: 404 for an object the caller may not read, no session, request or ipAddress, no property the caller may not see); an administrator keeps the stored history as before. The page keeps the results/total/page/pages/limit/offset shape; the legacy next/prev links are not produced on the reader path. The requirement in audit-trail-immutable/spec.md now names both URLs. The 17 existing testLogs* cases of ObjectsControllerTest, which exercise the stored-history path, now run as an administrator. New ObjectsControllerLogsRedactionTest covers delegation, the 404 and the administrator path; the redaction itself is covered by AuditTrailObjectHistoryForReadersTest.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Controller/ObjectsControllerTest.php + ObjectsControllerLogsRedactionTest.php OK 279 tests; tests/Unit/Controller overall: only 'could not find driver' errors (no database driver here); phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er's signal

FlowRunService::signal() merges the new payload over the signal still waiting on the suspended run (array_merge: keys of both survive, a key both set takes the later value) instead of assigning it outright, so the FlowTaskBridge outcome bag and another listener's payload no longer depend on listener order. A consumed signal is still removed by persistResult(), so nothing from an earlier question reaches the merge. FlowRunServiceTest gets a two-signal case.
Thread: #4580 (comment)
Sweep: signal() is the only writer of the signal context key (RegistryStepDispatcher only reads and unsets it).
Check: phpunit tests/Unit/Service/Flow OK 1525 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o cases

decide() now treats a relation that names a decision whose record cannot be found as owned by nobody but a supervisor (a synthetic decision with no decider goes through DecisionOwnership::refusal, so a supervisor, an administrator or the owning app's listener still gets through and a reviewer is refused), and links the new decision with a compare-and-swap: EntityRelationMapper::linkDecision() gains expectedUuid and guarded arguments, and a guarded link writes only while the stored link is still the one read, otherwise it throws DecisionConflictException, which decide() rolls back and answers as decision-owned. Two reviewers taking a first decision at once can no longer both link theirs. New cases in DecisionOwnershipTest: dangling link refused for a reviewer and allowed for a supervisor, and a lost link race refused.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Controller/DecisionOwnershipTest + EntityRelationsControllerTest + tests/Unit/Service/Decision + EntityRelationMapperUpdateDecisionMetadataTest OK 57 tests; phpcs on the three lib files clean. The guarded SQL update itself needs a database and was not run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is only logged

CasePlanItemTimers::arm() now logs at error level and rethrows when the timer of a statutory (wettelijk) deadline is refused, so onEntered() raises it instead of letting the item sit active past a legal term with nothing to end it; advisory and service-norm timers stay a warning and a 0. New test: a statutory arm failure is raised, a service-norm one is not. Note for the author: CaseItemTimerListener::handle() calls onEntered() from an event listener, and Nextcloud's event dispatcher logs and swallows a listener's exception, so whether the transition itself fails depends on the caller of the transition; a visible marker on the item would need a schema field and is not added here.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/Case + tests/Unit/Listener OK 608 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s to internal callers (delete part)

DeleteFileHandler::deleteFile() reads the node's id before delete() and asks the root folder for it afterwards; a node that is still there (Nextcloud ignores a listener's abortOperation() through the Node API, as the frozen-object guard uses) is logged and reported false instead of true, so FileService::deleteFile() callers such as the example-set remover and the merge no longer believe a file is gone that is not. New test: delete() that leaves the node behind answers false. The rename/move handlers are not changed here (left to the author, see the hand-back note), and the ExampleSetRemoveLiveTest case from Done-when was not added.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/File OK 489 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The spec-coverage gate flagged the new release() method; it carries the same @SPEC as reserve() and the rest of the store.
Thread: #4580 (comment)
Check: bash scripts/run-hydra-gates.sh --scope-to-diff --base b9e5d8d -> re-run after this commit (see hand-back).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er path

CasePlanStateMachine::flushEvents() catches Throwable around every dispatchTyped after the transition is committed, so the rethrow changed only a log line. It is reverted: arm() returns false again, and the error-level log now says the item is active WITHOUT its statutory expiry timer, naming the plan item, the case object and the legal effect. No schema field is added; a visible marker on the item is for the author.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/Case OK 108 tests; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other user's draft

PermissionHandler::filterUuidsForPermissions() now asks DraftStatusPolicy::keptFrom() before the delete permission, so another user's draft is left out of a bulk delete like an object that does not exist. ObjectService::deleteObject() asks it too (RBAC on) after the object is found and before the handler runs, answering DoesNotExistException, as the save path does; the read lookup already hid it through the RBAC predicate, this is the explicit rule. Tests: bulk filter (fails at HEAD) and single delete.
Thread: #4580 (comment)
Check: phpunit PermissionHandlerBulkDeleteLookupTest, ObjectServiceBulkDeleteTest, ObjectServiceDraftDeleteTest and tests/Unit/Service/Object OK; phpcs on both lib files clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly check

The draft guard (Step 2b) now runs above the appendOnly gate in applyBulkSafeguards(), so a non-owner of a draft on an appendOnly schema gets the same 'Permission denied' as everywhere else instead of an appendOnly message that confirms the uuid names an existing row. New test.
Thread: #4580 (comment)
Check: phpunit Wave12BulkSafeguardsTest + BulkSafeguardSchemaResolutionTest OK; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dropping oldData for an entry that alone exceeds the job limit keeps the entry but skips the conditioned updated rules and the calculatedChange rules for that object (they fail closed without the previous data). The warning now says so, the docblock and comments no longer claim the notification is still sent, and the proposal, spec requirement and scenario of deferred-listener-batches-stay-under-the-job-limit are corrected; the test was renamed accordingly and asserts the log text.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/Deferral OK; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…argument

WebhookService::dispatchEvent() enqueues WebhookDeliveryJob with the whole event payload as the job argument, the same 4000 (NC 32 and 33) or 32000 (34 and later) limit. A refusal from IJobList::add() was uncaught: it aborted the webhooks after it in the loop and surfaced as an exception in the event listener, where it is only a log line. Each add is now guarded: the refusal is logged at error level with the webhook id, event, payload length and message, and the loop goes on. That delivery is still lost (no redesign of webhook delivery; trimming or a payload reference is the author's call). Test: a refused first webhook does not stop the second.
Thread: #4580 (comment)
Check: phpunit WebhookServiceTest, WebhookAsyncDispatchTest and WebhookEventListenerTest OK; phpcs on the lib file clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w key behaviour

The idempotency scenario in form-destination-validator now says same subject and same payload; two new scenarios state the 409 for another subject or payload and for a parallel retry. docs/features/form-destination.md states that the parallel-retry protection relies on Nextcloud file locking (filelocking.enabled); without it only the sequential repeat is answered from the stored answer.
Thread: #4580 (comment)
Check: documentation only; no test run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WilcoLouwerse and others added 3 commits October 15, 2026 18:49
…ys user settings

docs/federated-configuration.md still sent readers to the Configuration store pane in personal settings; it says user settings now. Other languages' l10n entries stay for Transifex.
Thread: #4580 (comment)
Check: documentation only; git grep -i 'personal settings' -- docs finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the inline ternary phpcs refuses in ObjectsController::logsForReader() with an if; no behaviour change.
Thread: #4580 (comment)
Check: phpunit ObjectsControllerTest + ObjectsControllerLogsRedactionTest OK; phpcs on all touched lib files now clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ike the read path

The three draft guards (bulk delete filter in PermissionHandler, ObjectService::deleteObject, SaveObjects bulk-save safeguard) no longer refuse an administrator: an administrator reads every draft (MagicRbacHandler bypass) and the single save already lets one through, so they use the same group check (the 'admin' group id) the handlers already use. A non-owner non-admin is still refused. Tests: admin passes the bulk delete filter, deleteObject and the bulk save; a non-owner non-admin is still kept; and the owner deletes their own draft through ObjectService::deleteObject.
Thread: #4580 (comment)
Check: phpunit tests/Unit/Service/Object + ObjectServiceDraftDeleteTest + ObjectServiceBulkDeleteTest + BulkSafeguardSchemaResolutionTest OK 2747 tests; phpcs on the three lib files clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ f8342a5

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 ✅
composer ✅ ✅ 173/173
npm ✅ ✅ 648/648
app:check-code ⏭️
info.xml ✅
REUSE ✅
lockfile sync ✅
PHPUnit ❌ 26239/26246 · 25 skipped
Newman ✅ 714/714
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 locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development.
Hydra gates ❌

Quality workflow — 2026-10-15 17:44 UTC

Download the full PDF report from the workflow artifacts.

This branch has not been deployed

No deployments
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