Skip to content

Release: merge development into beta - #3477

Open
github-actions[bot] wants to merge 137 commits into
betafrom
development
Open

Release: merge development into beta#3477
github-actions[bot] wants to merge 137 commits into
betafrom
development

Conversation

@github-actions

@github-actions github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

Conduction Release Bot and others added 30 commits September 6, 2026 11:40
The docstring states that CnPageRenderer "forwards [pages[].title] to the
page component as a raw prop without translating it", and excludes the
field on that basis. That is no longer true.

Measured 2026-09-06 on integriq, which wires
`<CnAppRoot :translate="translateForApp">` exactly as src/App.vue does
here, against a Nextcloud 34 instance with the user set to nl and the
app's l10n/ deployed: the page heading rendered "StUF-berichten", not
"StUF messages". Report card labels and descriptions translate too;
CnReportsPage builds resolvedCards with this.tr(card.label).

On integriq the stale exclusion had a cost: four live keys were reported
UNUSED and clean:l10n offered to delete them. Deleting a translated page
title removes the localised string and leaves the English source
rendering correctly, so nothing fails and nobody notices.

THIS CHANGE IS PREVENTIVE HERE, NOT A FIX. Measured on this repository:
check:l10n reports 769 unused before and 769 after, and clean-l10n
proposes an identical set. Every page title openregister declares already
has a literal t() call in src/, so none were at risk. The guard matters
the moment that stops being true, which a reports page would do at once
(this repo declares none today).

Adding a field to this set can only make the cleaner more conservative,
never less, which is why the no-op is the expected outcome rather than a
reason to doubt the change.

The docstring now records how to verify a field: against the DOM, not the
source. The template renders {{ card.label }} and only resolvedCards
shows the translation, so reading the component alone reproduces the
wrong conclusion, and the measurement needs the app's l10n/ deployed,
which the built bundle does not carry.

Verified: eslint, prettier and test:l10n all exit 0.
…0260906094202

chore(sync): carry beta back into development
…fest

docs(l10n): correct what collectDynamicKeys says about page titles
…0260906124808

chore(sync): carry beta back into development
Carries the flow-editor work the trigger config removal and the guarded Run
button belong to: the settings modal no longer offers a trigger, because what
starts a flow is a step on the canvas, and Run explains itself instead of
letting the engine refuse several seconds later in words about nodes.

The pin was already ahead of what was installed — package.json said ^2.37.0
while node_modules held 2.36.1, so npm install had not been re-run since the
pin last moved. This bump and a rebuild close both gaps.
#3490)

First half of flow-semantic-versions: the comparison a semantic version is
derived from, with its spec.

THE RULE IS ABOUT REMOVAL. A consumer of a flow can depend on three things —
that a step exists, that a path connects, and that a step still reads the key
it read — and every one is broken by taking something away, none by adding.
So a removed node, a removed edge, or a removed config key on a SURVIVING node
is MAJOR, and everything else is MINOR.

Changing a VALUE can break a consumer too and is not detectable as such from
the graph. Guessing would produce majors nobody believes, and a version people
ignore carries no information at all. That gap is what the author's explicit
override is for, and why the override only goes UP: the diff is evidence, and
evidence can be added to but not argued down.

🔴 A KEY THAT WENT WITH ITS NODE COUNTS ONCE. It is one change — the node went
— and counting it twice inflates the list the author reads before publishing,
which is the part that makes the verdict credible.

TWO DIALECTS, ONE EDGE. The editor writes `from: 'a'` and the dossiq
projections write `from: ['a']`. Both normalise, because reading them as
different would report a removal and an addition on a graph nobody touched.
That is not hypothetical: both shapes are live in one flow on the demo
instance.

WHY THE ORDINAL SURVIVES, in the design: `version` is an integer in two tables
with a UNIQUE (flow_uuid, version), `FlowRun.flowVersion` is what a RUN PINS
for its whole life including a suspension of weeks, and 28 call sites read it.
Replacing all that to buy a label would put the run pin at the mercy of a
derivation — a bug would then not mislabel a version, it would repoint a run.
Semver is an additional fact; the ordinal stays identity and ordering.

Verified: 10 tests, 5 of which break under mutation of the removal detection
and the surviving-node guard. 1145 flow unit tests green; PHPCS, PHPStan and
Psalm clean.
…is (#3492)

* feat(flow): store a semantic version, and work out what the next one is

Second half of flow-semantic-versions: the columns, the migration, and the
arithmetic. The comparison itself landed in #3490.

🔴 THE ORDINAL IS UNTOUCHED. `version` stays an integer, stays unique with
`flow_uuid`, and stays what a RUN PINS for its whole life — including runs
suspended for weeks on a human task. Semver is an additional, author-facing
fact in a nullable column. Replacing the ordinal would migrate two unique
indexes, every run row's pin and 28 call sites to buy a label, and would put
the run pin at the mercy of a derivation: a bug would then not mislabel a
version, it would repoint a run.

A draft has NULL rather than 0.0.0. It has not been compared with anything
yet, and a null that means "not derived" is honest where a number is a claim.

`semver_source` records `derived` or `backfill` beside the value, because the
back-fill cannot know whether a historical publish was breaking — the graphs
it would compare are the ones it is being run to describe. A version that says
where it came from can be distrusted correctly; one that silently claims to be
derived cannot.

THE ARITHMETIC IS ITS OWN CLASS, apart from the diff, because they fail
differently: the diff reads two graphs and says what was taken away, this
reads a verdict and says what to call the next version. Together, a change to
the numbering could break the comparison, and the comparison is the half with
evidence behind it.

FIRST PUBLISH IS ALWAYS 1.0.0, whatever the diff said. Calling it 2.0.0
because it "removed" everything from nothing would be arithmetic rather than
meaning. PATCH IS ALWAYS 0: nothing in a graph distinguishes a fix from a
feature, and three digits look more precise than two.

AN AUTHOR MAY RAISE AND NEVER LOWER. They know which values a consumer reads
and the diff does not, so they can add a major it did not find. They cannot
publish a removal as minor: the diff is evidence, and the refusal names what
went.

Verified: 10 tests, 4 of which break under mutation of the major branch and
the refusal. PHPCS, PHPStan, Psalm and PHPMD clean. `<version>` bumped in the
same commit so the migration actually runs — checked with
scripts/check-migration-version-bump.php, which is gate-110's local half.

⚠️ phpcs caught me inserting both properties BETWEEN their neighbour's
docblock and the property it described, orphaning two docblocks. Moved below;
the diff is now pure addition with no removals.

* feat(flow): publishing derives the semantic version, proven on a live instance

Wires the diff and the arithmetic into `FlowVersionService::publish()`, and
proves the result over the HTTP API rather than through mocks.

DERIVED AGAINST THE PUBLISHED GRAPH, not the previous ordinal. They are
usually the same and are not always: a deprecated version, or a draft opened
and abandoned, leaves a gap — and what a consumer is running is the published
one.

🔴 AN UNREADABLE PREVIOUS GRAPH IS NOT A FAILURE TO PUBLISH. The definition row
may have been pruned, and refusing would let a LABELLING feature block a
release. It falls back to a minor and says so in the log, rather than a
confident major nobody can check.

The controller passes `bump` through and answers a refusal with 409 and
`kind: version-bump-refused` — an UnexpectedValueException rather than a
lifecycle refusal, because the flow's state is fine and the request is not.
Both entities now serialise `semver`, and FlowVersion also `semverSource`, so
a back-filled version can be distrusted correctly.

PROVEN ON :8080, six Playwright specs over the live API: first publish is
1.0.0 with the ORDINAL STILL 1 — the point of the whole design, since the
ordinal is what a run pins; adding a step is 1.1.0; removing a step is 2.0.0;
removing a config key from a surviving step is 2.0.0; an author raises a
value-only change to 2.0.0; and publishing a removal as minor is refused with
the removed step NAMED in the message.

Two of my own fixtures were wrong and the suite said so. `/draft` answers 201
because opening a draft CREATES a version row, not 200 — that cost five red
tests about versions they were not testing. And the added step in the minor
case dangled, so the dead-end guard refused the publish before any version was
derived: the guard working, my fixture not.

4358 unit tests green across the flow and controller suites; PHPCS clean on
all of lib/; PHPStan and Psalm clean on every changed file.

* feat(flow): number the versions that predate semantic versions

The last piece of flow-semantic-versions: existing published versions get a
number, and the number says where it came from.

🔴 IT DOES NOT INVENT HISTORY. The repair cannot know whether the third publish
of a flow was breaking — the graphs it would compare are precisely the ones it
is being run to describe, and older definition rows may have been pruned. So it
numbers a flow's published versions in ordinal order (1.0.0, 1.1.0, 1.2.0) and
stamps `semverSource = backfill` beside each. That marker is the point: a
version that says where it came from can be distrusted correctly, and the UI
says so on hover. One that silently claims to be derived cannot.

A DERIVED VERSION IS NEVER OVERWRITTEN. It is a stronger fact than anything
this step can produce, so only rows with no semver are touched. Drafts are
skipped entirely — a draft has not been compared with anything.

🔴 IT MUST NOT FAIL AN UPGRADE. A flow whose history is incomplete is already
in that state; a repair that turns a reporting gap into a failed `occ upgrade`
makes it everybody's problem. Each flow is stamped in its own try, failures are
counted, and the step reports them.

⚠️ I WROTE `findAll()` AND THE STEP SILENTLY DID NOTHING. FlowMapper has
`findAllFlows()`. The call threw, the step reported itself skipped, the upgrade
went green, and only counting the rows afterwards found it. It now uses the
paged `everyFlow()` the sibling repair uses — paged because the mapper's
default limit is 100, and a repair that stamps the first hundred flows and
reports success is worse than one that fails.

Verified ON :8080 by running `occ upgrade` and counting: 28 rows back-filled,
25 derived, and the 8 without a version are exactly the drafts. One flow's
three versions read 1.0.0, 1.1.0, 1.2.0 in ordinal order. 1155 flow unit tests
green; PHPCS, PHPStan and Psalm clean; `<version>` bumped so the step actually
runs, checked with scripts/check-migration-version-bump.php.

* fix(flow): the three gates this branch tripped, and one that predates it

PRETTIER on the new e2e spec. Formatted; the SPDX header survived the write,
checked rather than assumed.

GATE-16 spec-coverage wanted a @SPEC on every changed method. Added to the six
I wrote — they do implement the spec, so the tag is a statement rather than a
formality.

PHPMD CouplingBetweenObjects, which is the interesting one. FlowVersionService
was ALREADY at 13 against a limit of "under 13", and `development`'s Code
Quality job was already failing because of it. My two new collaborators took it
to 16.

So the comparison and the numbering moved INTO FlowSemanticVersion, which now
answers the whole question through one `forPublish()` — a caller asking what
the next version is should not also have to know that answering it means
reading two graphs. That is a real reduction, 16 to 15, and it makes the
service's helper do one thing: decide WHICH graph is being replaced and
survive not finding it.

15 is still over, so the class carries a documented suppression. A version
transition is where a flow's separate concerns have to meet — the version rows,
the head that mirrors them, the definition store, the lifecycle guard, the
trigger index that must move in the SAME transaction, the numbering, the
connection, the log. Splitting the class would move the coupling into a caller
rather than remove it, and would spread the transaction boundary — the thing
that makes a publish safe — across two objects. The suppression says that,
rather than raising the limit for every class in the app.

Verified: phpmd clean across all of lib/Service/Flow, phpcs and PHPStan clean
on every changed file, prettier clean, 1155 flow unit tests green.

* feat(flow): tell the author what a publish will be called, before they publish

The derivation was already right and entirely invisible: an author found out a
change was major by publishing it, and the first surprising major is the one
that teaches them to ignore the number.

`GET /api/flows/{id}/version-preview` answers what the next version would be
and, when it is major, names what the publish takes away. It never refuses:
asking a question is not asserting an answer, so the refusal stays at the
publish, where the author has actually claimed something.

The preview and the publish read the SAME two graphs, through the same
`publishedGraphOf()` and the same `FlowSemanticVersion`. A preview computed a
second way is a second opinion, and the first time the two disagree the author
learns to believe neither.

Two gaps this also closes:

- `Flow` mirrored `semver` but not `semverSource`, so the UI's "this number was
  back-filled" branch could never fire — it was reading a field the backend had
  no column for. The column, the entity field and the publish now carry it.
- The back-fill stamped version ROWS only. Every historic flow would have kept
  showing its ordinal in the pill for ever: the rows right and the screen
  wrong. It now mirrors the live version onto the flow, marked `backfill`, and
  still never overwrites a derived value.

The repair had no test at all, which the coverage guard caught and was right
about. It has one now, including the history-with-a-gap case: ordinals 1, 2 and
5 number as 1.0.0, 1.1.0, 1.2.0, because numbering by the ordinal would invent a
1.4.0 whose 1.2.0 and 1.3.0 nothing ever published.

Every new test was seen RED by mutating the implementation, not by being
written after it: numbering by ordinal, dropping the flow mirror, and reading a
null published graph each kill their own assertion.

Local: phpcs, psalm, phpstan and phpmd clean on every changed lib file (phpmd
per directory, never over all of lib); 1,314 unit tests green; the six live-API
Playwright specs green against :8080.
…nd it (#3496)

* feat(flow): every step says what kind of thing it is, and where to find it

Two independent facts about a step type, neither of which the engine could
answer. The KIND is semantic and is what an interchange needs: a BPMN export has
to emit `userTask`, `gateway`, `sendTask`. The CATEGORY is a grouping and is
what a palette of 65 entries needs so an author can find anything at all.

They are deliberately not derived from one another. `await-signal` is a
`receiveTask` and by kind belongs with the integration steps, but it is the
machine half of a pair whose other half is "Ask a person", and an author
choosing between the two looks in ONE place. So it is `receiveTask` and `human`.

THE VOCABULARY IS BPMN'S, NOT OURS. The kinds are exactly BPMN's element types
and the list is closed, because its whole value is being the vocabulary an
interchange already speaks; a local synonym would have to be mapped back on
export and the mapping is the part that rots. A value outside either list is
dropped and logged rather than served: `userTsak` would otherwise reach a BPMN
export as an element type nothing recognises, and grow a palette group of one.

BOTH ARE OPTIONAL, AND THAT IS LOAD-BEARING. On the live instance 38 of the 65
step types come from other repositories on their own release cycles. PHP has no
interface defaults, so requiring the methods would fatal those apps on the next
release, and guessing on their behalf would write a wrong element type that
nobody revisits because it looks answered. They are defaulted in
`FlowNodeTaxonomyResolver` instead, exactly as `IFlowNodeConfigKeys` already
does it, so an undeclared node still registers, still appears, and says
`serviceTask` / `other` where its owner can see it.

All 27 nodes this app ships declare both. Verified against the running
catalogue, not the source: 65 entries, every one carrying both fields, zero of
ours in `other`, and 38 contributed nodes present under the defaults.

Two quality findings this change caused, both fixed rather than suppressed:

- `FlowNodeRegistry` went to coupling 13. Rather than add a suppression, the
  "what does this node say it is" question moved out whole — kind, category and
  role — leaving the registry with FEWER dependencies than it started with. It
  now decides which nodes exist; the resolver reads their declarations.
- `DecisionTableNode` tipped to complexity 51. Its `assign()` turned out to be
  byte-identical to `SetFieldsNode`'s, and the latter's docblock said so out
  loud. One copy now, in `FlowFieldPath`, carrying the richer of the two
  explanations: two copies of a rule about how authored structure is built is
  the shape that drifts, and the drift would be invisible.

Every new test was seen RED by mutating the implementation: serving a declared
value without checking the vocabulary, and defaulting the category to something
other than `other`, each kill their own assertion.

Local: phpcs, psalm, phpstan and phpmd clean across lib/Service/Flow and
lib/Service/Flow/Nodes (phpmd per directory, never over all of lib); 1,290 flow
and repair unit tests green; 3,205 controller tests green; 5 live-API Playwright
specs green against :8080.

* test(flow): pin what every shipped node declares, and tag the resolver's constructor

Two CI findings on the taxonomy change, both real.

gate-16 named one changed public method with no `@spec`: the resolver's
constructor. Tagged.

The coverage guard was the interesting one. 54 new methods across 27 nodes,
each a one-line `return IFlowNodeTaxonomy::…`, and nothing executed any of
them: the registry tests drive doubles, so the real nodes' declarations were
shipped untested. The guard was right that the change added statements without
covering them, and the honest fix is not to cover the lines but to assert the
FACT they carry.

So the table of what each node is — the review this change asked for — is now
mechanical. A new node with no entry fails until somebody writes down what kind
of thing it is; a changed assignment fails until somebody changes it here too,
deliberately. That is the failure worth catching: a `switch` that stops being a
`gateway` changes what a BPMN export says about the process, and nothing else in
the suite would notice. Proven by making exactly that change: it fails, naming
the node and the field.

⚠️ The nodes are built without their constructors. `getKind()` and
`getCategory()` return a constant and read no state, so an instance with no
dependencies answers exactly as a real one does; constructing 27 classes for
real would mean mocking l10n, url generators and mappers to ask each of them one
question that cannot depend on any of it.

163 assertions over the 54 methods. phpcs, phpmd and 1,174 flow unit tests green.

* fix(flow): attribute the taxonomy test's coverage, and tag a method the gate found

Two CI findings, both mine to fix.

`@coversNothing` was wrong on the declared-taxonomy test. The coverage guard
refused the change again with the SAME numbers as before, which was the tell: a
test that attributes no coverage leaves the 54 new methods reading as untested,
which is indistinguishable from not having written it. It now names the 27 node
classes it covers.

gate-16 named `FilterNode::validateConfig` as a changed method with no `@spec`.
It is pre-existing debt — the method predates this change and the class only
became "changed" because its declaration line gained the interface — but it is
debt this change surfaced, so it is tagged rather than left.

🔑 MY LOCAL GATE RUN WAS THE INSTRUMENT THAT LIED. `check_spec_coverage.py`
takes its base through the `HYDRA_GATE_BASE_REF` environment variable and a
positional path; I invoked it with `--base`, which it ignores. It inspected
nothing, printed `# count=0`, and exited 0 — a clean pass that had checked
nothing at all. Invoked the way the runner does it, it reproduced CI's finding
exactly.

* test(flow): attribute the resolver's coverage to the test that drives it

The registry test exercises `FlowNodeTaxonomyResolver` on every assertion and
claimed only the registry, so the resolver's own statements counted as
untested — the same gap as the node declarations, one file over.
flow-controls has failed every development push since b532c9f (08:07Z
2026-09-07) and still fails on 853db46. 1 failed, 74 passed, both attempts.

THE FAILURE MESSAGE POINTED AT THE WRONG THING. It read "Needs
@conduction/nextcloud-vue >= 2.24.0" while d13b0e5 had already put the app on
2.39.0, so anyone reading it goes looking for a release that shipped long ago.

`flow-publish` exists in 2.39.0 and it is a MENU ITEM. CnFlowSidebar pushes
Publish into `flowActions` and renders it as an NcActionButton inside an
NcActions — the `#secondary-actions` slot here, since this app mounts
`<CnFlowSidebar />` with no `embedded` prop and NcAppSidebar wraps that slot in
its own menu. CnFlowLifecycleControls states it outright:

    WHAT IS DELIBERATELY NOT HERE — THE VERBS. Publish, Create draft version
    and Deprecate live in the header's action menu.

So the item cannot be visible until the menu is opened, and asserting on it
directly can only ever time out.

Two things it is NOT, both checked before writing this:
- not `isDraft`, which is `(lifecycleStatus || 'draft') === 'draft'`, so a
  newly created flow defaults to draft and the action IS pushed;
- not `disabled`, which would render a disabled item that is still visible.
  The item is absent, not disabled.

⚠️ THE HELPER TRIES SEVERAL TRIGGERS ON PURPOSE, and that is a statement about
what I could verify rather than a hedge. The two hosts label their trigger
differently, and this workstation cannot run openregister's e2e — its instance
carries thirty-odd apps and the shared global setup times out logging in at
30s, twice, before any test starts. So rather than guess one selector, it tries
each and stops at the first that reveals the item; if none does it throws
naming every trigger it tried AND every button actually on the page. The
previous assertion pointed at a library version and cost an afternoon.

eslint and prettier exit 0 and playwright lists the test. The proof is the
development push run.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#3498)

* feat(flow): a performer reference carries its type, and resolves late

"jdoe" on an assignee field is ambiguous on any real instance: a Nextcloud
deployment may hold a user AND a group of that name, and the guard this
prepares to replace accepted both — uid equality first, then group membership.
So "may this person answer" had two answers and took whichever came first.

A `PrincipalReference` says which one it means, and an app that owns a concept
the engine has never heard of — a position on a body, a function, a case role —
says so in the same shape, through the same event idiom flow nodes already use.

🔴 RESOLVED LATE AND RE-RESOLVED EVERY TIME, NEVER FROZEN AT CREATION. A
municipal approval outlives the roster it was raised against: a task assigned to
the bezwaarcommissie in March must be answerable by whoever sits on it in June,
and one assigned to the afdelingshoofd must follow the post rather than the
person who held it. Freezing is simpler and faster and gets the domain backwards
in the direction that hurts — it keeps authorising someone who has left and
stops authorising the person now responsible. The cost is one membership lookup
on a verb a human is performing by hand.

Three readings that preserve behaviour rather than quietly widening it:

- A BARE STRING STILL MEANS `user`. Reading it as "try every type" would be
  friendlier and would widen who may answer on every flow already stored.
- A LEGACY FIELD'S NAME TYPES ITS ENTRIES. `candidateGroups` holds bare group
  names; reading them as users would move who may answer on every stored flow.
- A SINGLE `{type, id}` MAP IS ONE REFERENCE. A map is itself an array, so a
  list reader that does not check for sequential keys reads its two VALUES as
  two references, silently assigning the task to a user called "group".

An unknown type resolves to nobody and says so in the log rather than throwing:
this runs while authorising an answer, and a type whose app was disabled since
the flow was authored must refuse the answer, not return a 500 where "you may
not answer this" belongs. A resolver that throws is caught for the same reason.

17 unit tests, each seen RED by mutating the implementation. The three mutants
were the design's own central mistakes: caching the resolution (2 fail), reading
a bare string as a group (3 fail), and reading a single map as a list (1 fail).

This is section 1 of the change. The guard, the node and the picker follow.

* feat(flow): the answer guard resolves a typed reference, and keeps the old reading for bare strings

`mayAnswer()` compared the acting uid to a stored STRING: uid equality first,
then group membership. So `"bezwaar"` authorised the user AND the group's
members, and which of the two the author meant was never recorded.

The guard now reads the recorded value's SHAPE, because the shape is the
information:

- A BARE STRING keeps its old, wider meaning, unchanged. Every stored flow on
  every instance was authored against exactly those semantics, and narrowing
  them would silently stop authorising the groups the fleet's flows name by
  bare string. Which of the two an old string meant is a question for a person;
  the repair will report the ambiguous ones and rewrite only what resolves one
  way.
- A TYPED reference means exactly what it says, resolved fresh on every answer
  through the resolver registry. Whoever holds the post today may answer;
  whoever held it in March may not, however the task records who was asked.

`recordedFor()` still answers with one string for callers that want one, but
the guard no longer goes through it: flattening a reference to a string before
the guard sees it is precisely what loses the distinction.

The task service's pool guard takes the SAME path for typed candidates, after
its three legacy branches so nothing they already admit can be narrowed. Two
guards over one question is how they come to disagree, and a disagreement there
means a task somebody can complete and cannot see, or the reverse.

Fail-closed throughout, in the same direction as the existing missing-group-
manager branch: no registry, an unknown type, or a resolver that throws all
REFUSE. Asserted rather than inferred from a passing suite.

The hot path is untouched: the inbox never calls this guard — it predicates in
the datastore — and nothing here resolves row by row.

8 new tests, each seen RED by mutating the guard: routing a bare string through
the registry fails 4 (the regression that would break every stored flow), and
admitting instead of refusing without a registry fails 1. 1,491 flow and task
tests green; phpcs, phpmd, psalm and phpstan clean.

The two `PrincipalReference::` call sites carry a documented StaticAccess
suppression: they are named constructors on a value object, which is the
canonical idiom and indistinguishable to that rule from a static service call.

* feat(flow): the node reads typed performers, and is called "Ask a person or group"

The step has always been able to ask a group and never said so: the guard
resolved a bare name as a uid OR a group, so a good part of the fleet's
approvals are addressed to a committee under a label that named one person. The
name now says what it does.

`UserTaskConfig` reads every performer field as typed references, including one
new `candidates` field that expresses what three fields did and may mix types.

🔑 THE THREE LEGACY FIELDS ARE READ BY THEIR NAMES, and that is what makes this
safe. `candidateUsers`, `candidateGroups` and `candidateRole` differ only in the
kind of thing you type into them — a type system implemented as field names,
which existed because the field was a text box. Each is read as candidates of
ITS OWN type, so no stored flow changes meaning and none needs migrating.
`candidateRole` reads as a GROUP because the pre-typed guard resolved it through
`isInGroup()`; any other reading would move who may answer on every stored flow
that uses it.

🔴 AN UNKNOWN TYPE IS REFUSED AT SAVE. AN EMPTY RESOLUTION IS NOT. These are two
kinds of wrongness discovered by two different people. An unknown type is a
defect in the document: the author is at the keyboard, the field is on screen,
and the fix is to pick another type — so the refusal names both the type and the
id. An empty resolution is a fact about the instance and it changes; a committee
with no members today has members next week, and refusing the SAVE would make
the flow unauthorable for a reason that has nothing to do with the flow.

`namesAPerformer()` now reads references rather than strings, because a
`{type, id}` map casts to the string "Array" — non-empty, so the old test called
a typed assignee "named" for entirely the wrong reason.

Without a registry nothing is refused: an instance that cannot say which types
exist must not decide that none of them do.

8 tests, seen RED by mutating the implementation: reading `candidateGroups` as
users fails 1 (the stored-flow regression), and refusing an empty resolution at
save fails 2. 1,499 flow and task tests green; phpcs, phpmd, psalm, phpstan and
gate-16 clean.

* feat(flow): a step asking nobody fails loudly instead of raising a task for nobody

🔴 THE MEASURED DEFECT. A step naming a group with no members — or one that does
not exist — created a task addressed to nobody. The run suspended, a heartbeat
re-read it every few minutes, and NOTHING said anything: the task existed, the
run looked healthy, and the approval simply never happened. Silence was the
defect.

The step now fails, naming what it asked and that nobody holds it. That is
strictly better even when the author chooses to continue past it, because a
failure is subject to the flow's own `onError` policy — which is a decision the
author gets to make, and silence is not.

🔑 REFUSED AT TASK CREATION, NOT AT SAVE. An empty resolution is a fact about
the instance and it changes: a committee with no members today has members next
week. Refusing the save would make the flow unauthorable for a reason that has
nothing to do with the flow. This is the moment somebody actually needs to be
found.

An unassigned step is untouched — that is deliberately open — and an instance
with no resolver refuses nothing.

Five Playwright specs over the live API, all green against :8080, including the
positive control: a group that DOES have members still raises its task. The
refusal is asserted on the run's own `status: failed` and the named reason, not
on "the response mentions an error somewhere" — a looser check passes for the
suspended run this test exists to refuse.

⚠️ `POST /flows/{id}/run` answers 201, not 200: running a flow CREATES a run.
Asserting 200 cost two red tests about behaviour they were not testing.

* test(flow): cover the built-in resolvers, their registration, and the typed pool guard

The coverage guard refused the change and was right: 170 statements added and
three whole classes with no test at all — both built-in resolvers, the listener
that contributes them, and the task guard's typed branch.

What the new tests actually assert, beyond executing the lines:

- A DELETED ACCOUNT RESOLVES TO NOBODY, not to its own uid. Returning the uid
  would authorise an identity nobody can log in as, and would hide the
  commonest way a performer goes away behind a resolution that looks
  successful.
- A missing group and an empty group are the same fact, deliberately: from the
  caller's side both mean there is nobody to ask.
- The built-ins register through the SAME event every consuming app uses, so
  the contribution path is exercised by its owner and cannot rot unnoticed.
- The task guard's typed branch admits whoever holds the reference, and a bare
  candidate keeps working unchanged. Removing the branch fails one; that is the
  test biting.

⚠️ A task with no `performerType` is denied before the pool is ever consulted,
so a fixture without one tests the determinability guard and says nothing about
candidates. Cost two red assertions until the denial message was read rather
than guessed at.

1,510 flow and task tests green; phpcs clean.
Brings the flow editor halves that read the engine features already on
development: the publish preflight dialog (which fetches
`/flows/{id}/version-preview`), the step picker grouped by the per-node
category, the health dot, the run sidebar and the version pill.

Verified on the dev instance after `occ upgrade`: the catalogue serves 65
entries with a kind and category on every one, all 27 nodes this app ships
declaring; `openregister.user-task` reads "Ask a person or group"; and the
preflight answers `{"verdict":"minor","next":"1.1.0","current":"1.0.0"}`.
hydra-gates v1.15.1 -> v1.16.0
nc-vue      - -> 2.37.0

Lock-only: both packages are already declared with caret ranges that
permit these versions, so nothing about what this app ACCEPTS changes
- only what it currently resolves to. Opened by the weekly fleet
shared-dependency bump, because a lock nobody re-resolves is a pin
nobody chose.

Merging is gated by this repository's own suite, deliberately: taking
hydra-gates v1.8.1 added patchObject() to a published interface, which
is a load-time fatal for any concrete double that implements it without
the method. CI is the only thing that can tell a safe bump from that.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…t the rest (#3505)

A stored `"bezwaar"` means a user OR a group, because the guard it was written
against tried both. This repair resolves each string once and rewrites it as
`{type, id}` where exactly one kind of thing answers to that name.

🔴 THREE OUTCOMES, AND ONLY ONE OF THEM MAY BE ACTED ON.

  resolves under exactly one type → rewrite; the string meant that
  resolves under more than one    → LEAVE IT, and report, naming both
  resolves under no type          → LEAVE IT, and report, naming where

The ambiguous case is not hypothetical, and the dev instance proved it on the
first run: `"admin"` is BOTH a user and a group there, on five steps across four
flows. Today both may answer; choosing one would silently narrow who can, and
that is a decision for a person, not for a step running unattended during an
upgrade.

🔴 IT MUST NOT FAIL AN UPGRADE. A flow naming a performer nobody can be is
already broken, and a repair that turns pre-existing breakage into a failed
`occ upgrade` makes it everybody's problem instead of its owner's. Every
unmigrated string is reported BY NAME with the flow, the step, the field and
which of the two reasons applies — "3 could not be migrated" tells nobody which
flow to open.

🔑 A FIELD WHOSE NAME SAYS THE TYPE NEEDS NO GUESS. `candidateGroups` holds
group names and always did, so its entries are typed without resolving anything:
the field name is the evidence, not the roster.

⚠️ A TEMPLATE IS NOT A NAME, and the first draft got this wrong. The dev
instance carries `{{ case.assignee }}`, which is resolved per item when the step
runs — nothing to look up and nothing broken about it. The first run reported it
as "already broken", a false alarm that would have sent somebody looking for a
flow that works correctly. Templates are now left alone silently.

Verified against the live instance: unambiguous group names typed
(`behandelaars`, `bezwaarcommissie`, `Beleidsadviseur`), `admin` left bare and
reported, the template left alone, upgrade successful.

11 unit tests, each seen RED by mutating the implementation: taking the first
type when several answer fails 1, and dropping an unresolvable entry fails 1 —
the latter only after the test was strengthened, because "nothing was stored"
cannot tell "left alone" from "deleted".
…ker (#3508)

openregister's E2E job has been red all day, on two specs, both waiting
15s for `.cn-flow-sidebar__palette` and reporting "the step palette did
not render".

It rendered. It moved. nextcloud-vue 2.40.0 says so in the component's
own header:

    ⚠️ THE PALETTE IS NOT HERE ANY MORE. A live instance serves
    SIXTY-FIVE step types, and a one-per-row list that long in a 300px
    column is a scroll rather than a chooser. It is
    `CnFlowStepPickerModal`, opened from the toolbar, where the same
    entries render as a grid. With it went the Steps tab, and with that
    the tab strip.

Only the CSS rules stayed behind in the sidebar, which is exactly why
this read as a rendering failure: the class still existed, so nothing
about the message pointed at a retirement. The page snapshot in the
trace is unambiguous — the sidebar carries a heading and a Runs region
and nothing else, and the toolbar carries "Add a step".

Both specs now open the picker from the toolbar and assert inside it:

  · the ITEM count, not the container, is what proves the catalogue
    loaded. An empty picker renders the same dialog as a full one, so
    without this a missing lock step reads as a lock bug rather than as
    a load failure.
  · picking a step closes the dialog (`add()` emits `close`), so
    flow-lock-nodes reopens it per node, and both specs assert the
    dialog is hidden before touching the canvas. A dialog left open is a
    focus trap over every interaction after it.

flow-controls opens the picker where the step is ADDED rather than in
the "the controls exist" block: that block asserts the button, which is
the control that exists there now.

`tests/e2e/flow-engine.spec.ts` carried the same stale locator. It sits
outside `tests/e2e/ci`, which is the only path CI runs, so it would have
waited out its timeout for whoever ran it next rather than failing a
job. Fixed with the others.

prettier and eslint clean.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.9 to 2.2.13.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…3511)

#3508 moved these specs onto the step picker and got the hard part right:
the picker opens, the catalogue loads, the item is clicked, the dialog
closes. Then flow-controls failed at

    the step did not reach the canvas
    locator('.cn-flow-detail__node').filter({ hasText: 'End' })

The step HAD reached the canvas. It was the wrong step.

I replaced an exact match with a loose one. The old locator was
`palette.getByText('End', { exact: true })`; the new one was
`.filter({ hasText: 'End' })` over the whole picker item — and each card
carries the step's NAME, its role word, its DESCRIPTION and its
catalogue id, with `hasText` matching case-insensitively as a substring
across all of it. Sixty-five steps go through that filter, and 'End'
appears in more than one of them.

flow-lock-nodes had the same defect and it is sharper there: the file
exists to tell "Lock an object" and "Unlock an object" apart, and the
first is a SUBSTRING of the second, so the loose filter matched both and
`.first()` decided which one the test was really about.

Both now filter on the name element with an anchored regex, so the card's
description and id cannot satisfy them.

prettier and eslint clean.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#3507)

* feat(flow): a run keeps the set of objects it is working with, by role

"The run should keep an array of objects that it is interacting with" — and it
now does, as a DECLARED set rather than a derived one.

🔴 THE AUDIT CANNOT SERVE THIS PURPOSE, and that is the whole design decision.
It already answers "what did this run write", but a case flow READS a case it
never writes, WRITES a task and a document it does not consider subjects, and
may write one object at three steps for three different reasons. The derived
list is too much and too little at once — and decisively it carries no ROLE.
`attachTo: case` needs a name, and a list of things that happened has none to
give. So the two coexist: derived answers the auditor, declared answers the
author.

A ROLE, NOT AN INDEX. `attachTo: 2` would work, would be unreadable, and would
break the moment a step is inserted. Roles are per-run and unconstrained on
purpose — `case`, `besluit`, `aanvraag` are all fine — and a typo is caught by
the addressing rule failing loudly rather than by a registry somebody has to
maintain. The refusal NAMES the roles the run holds, which is what makes that
trade work.

🔑 IDEMPOTENCY IS NOT OPTIONAL. A user-task node is re-entered on a heartbeat,
by design, with its task still open, so any node that records a subject is
re-entered too. Forty wake-ups leave one subject and log nothing: only a
DIFFERENT object under a held role is a replacement.

REPLACEMENT IS RECORDED, NOT REFUSED. Superseding a draft decision with a final
one genuinely means `decision` to be the new object, and refusing would force
authors into `decision2`. But it is also exactly how a later step silently
attaches to the wrong record, so the log entry names both objects and the role
— which makes "why is this task on the old decision" answerable in under a
minute.

ON THE RUN ROW, NOT IN A RESUME SLOT: slots are per node, and a three-week
approval must wake up still knowing what its case is.

The repair seeds ONE thing: the `trigger` entry, from `subject_uuid`, which is
a fact the run already carried. 🔴 NOTHING is back-filled from the audit —
those declarations would then be ADDRESSABLE by `attachTo`, and a task attached
to an object no author ever declared is worse than one that fails loudly the
first time somebody uses it.

9 unit tests, each seen RED by mutating the implementation: making a re-fire
append fails the heartbeat test, and returning nothing for an unheld role
instead of failing fails two.

⚠️ `findAllRuns()`, not `findAll()` — writing the latter once already produced a
repair that threw, reported itself skipped, and let an upgrade go green having
done nothing.

* test(flow): cover the trigger-subject repair and the run's serialisation

The repair had no test at all, which is the worst place in this codebase to
have none: a repair step runs at occ upgrade and nowhere else, and it warns
without failing, so one that throws reports itself skipped and lets the
upgrade go green having done nothing.

The tests pin the four things it must get right: it seeds only from the
column the run already carried, it leaves a run that already declared
something alone, it reads every page rather than the first, and an
unresolvable mapper or one bad row skips instead of failing the upgrade.

The paging test found a trap in its own double rather than in the step. The
real findAllRuns takes (flowId, status, limit, offset), so a callback
reading the first two arguments as limit and offset sees null for both,
pins the page index at zero and loops forever. That is a hang, not a
failure, and only the double can get it wrong: the step itself calls with
named arguments.

FlowRun's three date branches in jsonSerialize were uncovered. All three are
separately nullable, so one being formatted is no evidence about the other
two, and they are asserted separately.
…ompt (#3503)

* feat(flow): an agent is a performer of the same step, asked with a prompt

You can now address the ask-a-person step to an agent. It is the SAME step: the
agent completes its task through the ordinary verbs, past the same guard, into
the same audit, against the same outcome vocabulary a person's answer takes.
The only difference is what it is handed — a person gets fields to fill in, an
agent gets a prompt.

🔴 AN AGENT RESOLVES TO EXACTLY ONE IDENTITY AND NEVER THROUGH A GROUP. That is
the whole safety property. The answer guard compares the acting uid against
what a reference resolves to, so if an agent reference ever expanded through
group membership, the agent's step would become answerable by every human in
that group — and, the other way round, a human's step by the agent. The
resolver has no notion of groups at all, which is asserted rather than assumed.

An agent with no real identity resolves to nobody, and the step then fails
loudly rather than raising a task addressed to something that cannot log in.
That reuses the refusal built for an empty group, deliberately: they are the
same fact.

🔑 THE NODE DISPATCHES; IT NEVER INVOKES A RUNTIME. The engine has no business
knowing how an agent runs, and naming the app that does would put a consuming
app's name in OpenRegister. The task is created FIRST and the event second, so
if nothing is listening the task simply sits there — reassignable to a person
like any other, which is the escape hatch that makes an agent performer safe to
use at all. Asserted: an unanswered agent task reassigned to a person is
answerable by that person and no longer by the agent.

⚠️ THIS IS `AgentRunRequestedEvent`'s FIRST DISPATCHER. It was declared and
fired by nothing; an event nobody sends is a contract nobody can rely on.

`UserTaskNode` tipped to complexity 53 doing this, so the two performer
questions moved out whole into `UserTaskPerformers` rather than taking a
suppression: the node orchestrates a task's lifecycle, and this decides who the
task is for. Both of its questions are asked at creation time for the same
reason — that is the moment somebody actually has to be found.

7 tests, each seen RED by mutating the implementation: dispatching for every
performer rather than only agents fails 1 (the widening that would make every
stored flow start calling an agent), and sending an empty prompt fails 2.
1,528 flow and task tests green; phpcs, phpmd, psalm, phpstan and gate-16 clean.

* test(flow): attribute the extracted performer class's coverage

`UserTaskPerformers` came out of `UserTaskNode` to keep the node under the
complexity ceiling, and the tests that drive it on every assertion still claimed
only the node — so 53 extracted statements read as untested. The coverage guard
refused the change and was right about what it saw.

No behaviour changes; the two suites that exercise the class now say so.

* fix(flow): a typed assignee reached the task row as the string "Array"

Casting a {type, id} map to a string yields the literal "Array". That string
was written into the task row AND into the run's resume slot, where the
answer guard reads it as a legacy bare string, matches it against no uid and
no group, and leaves the task answerable by nobody. That is the exact
silence typed principals exist to remove, put back by a cast.

PrincipalReference::storedString() now owns the conversion, because it is
knowledge about references rather than about a step. A bare string passes
through trimmed, since every flow ever authored names people that way. A
single reference becomes type:id. Several references are not one stored
value, so the answer is empty and the task pools, which is what the
candidate fields are for.

type:id is what an inbox can predicate on in the datastore, which is the
rule the design sets: the resolver decides authorisation, not listing, and
an inbox resolving a reference per row would resolve it a hundred times on
one page. TaskInboxCriteria::assigneeNames() expands the caller into the few
strings that mean them: the bare uid, user:<uid>, and group:<id> for each
group they are in. agent: is deliberately absent, so an agent's task stays
out of a person's inbox.

The resume slot keeps the reference's shape, because the shape is the
meaning: the guard reads a bare string as the old wider union and a typed
reference as exactly what it says, resolved fresh on every answer.

UserTaskPerformers had no direct test. Its two methods were only ever
reached through a node fixture that passes a null registry, so the half that
matters never executed. It is tested directly now, and the four remaining
uncovered lines in UserTaskNode are covered too.
…r says which types exist (#3506)

* feat(flow): an agent is a performer of the same step, asked with a prompt

You can now address the ask-a-person step to an agent. It is the SAME step: the
agent completes its task through the ordinary verbs, past the same guard, into
the same audit, against the same outcome vocabulary a person's answer takes.
The only difference is what it is handed — a person gets fields to fill in, an
agent gets a prompt.

🔴 AN AGENT RESOLVES TO EXACTLY ONE IDENTITY AND NEVER THROUGH A GROUP. That is
the whole safety property. The answer guard compares the acting uid against
what a reference resolves to, so if an agent reference ever expanded through
group membership, the agent's step would become answerable by every human in
that group — and, the other way round, a human's step by the agent. The
resolver has no notion of groups at all, which is asserted rather than assumed.

An agent with no real identity resolves to nobody, and the step then fails
loudly rather than raising a task addressed to something that cannot log in.
That reuses the refusal built for an empty group, deliberately: they are the
same fact.

🔑 THE NODE DISPATCHES; IT NEVER INVOKES A RUNTIME. The engine has no business
knowing how an agent runs, and naming the app that does would put a consuming
app's name in OpenRegister. The task is created FIRST and the event second, so
if nothing is listening the task simply sits there — reassignable to a person
like any other, which is the escape hatch that makes an agent performer safe to
use at all. Asserted: an unanswered agent task reassigned to a person is
answerable by that person and no longer by the agent.

⚠️ THIS IS `AgentRunRequestedEvent`'s FIRST DISPATCHER. It was declared and
fired by nothing; an event nobody sends is a contract nobody can rely on.

`UserTaskNode` tipped to complexity 53 doing this, so the two performer
questions moved out whole into `UserTaskPerformers` rather than taking a
suppression: the node orchestrates a task's lifecycle, and this decides who the
task is for. Both of its questions are asked at creation time for the same
reason — that is the moment somebody actually has to be found.

7 tests, each seen RED by mutating the implementation: dispatching for every
performer rather than only agents fails 1 (the widening that would make every
stored flow start calling an agent), and sending an empty prompt fails 2.
1,528 flow and task tests green; phpcs, phpmd, psalm, phpstan and gate-16 clean.

* feat(flow): the performer fields are principal pickers, and the server says which types exist

Three things the typed reference made possible and nothing had yet used.

THE FIELDS SAY WHAT THEY ACCEPT. Every performer field declares `principal`
rather than `text`. The field always accepted a person OR a group and could
only ever say one of them, so authors typed a committee's name into a box
labelled for a user and nothing recorded which they meant. `Candidates` and
`Prompt` are declared too, so the editor can offer the one field that replaces
three and the instruction an agent is given in place of a form.

`performerType` is gone from the FORM. With every reference carrying its own
type it was a second, weaker copy of the same fact, and two sources of one truth
is how they drift. The COLUMN stays, because other things read it, and it is now
derived from the reference — an explicitly authored value still wins, so no
stored flow changes meaning.

THE SERVER SAYS WHICH TYPES EXIST. `GET /api/flow/principal-types` answers with
what this instance can resolve. An editor that offered only what it can SEARCH
would silently refuse a position, a function or a case role — types contributed
by apps whose search it does not know — and the author would have no way to say
what they meant. Verified live: it answers `agent`, `group`, `user` on the dev
instance.

THE RECORDED RESOLUTION IS EVIDENCE, AND NO GUARD READS IT. `flow-tasks`
requires the task to record who was asked, and the danger is that such a field
quietly becomes an authorisation list: it is a snapshot of a roster that has
since moved. Asserted: somebody recorded in March who has since left the
committee still appears in the record and may no longer answer.

Two quality findings, both fixed by moving code rather than suppressing it:

- `FlowController` would have reached ten constructor parameters. The endpoint
  has its own small controller instead — it belongs beside the node catalogue by
  subject, and it depends on none of the other nine.
- `UserTaskConfig` reached the complexity ceiling and coupling 13. The type
  refusal and the performer-kind derivation moved into `UserTaskPerformers`,
  which now owns EVERY question about who a step asks: whether their type is
  known, whether anybody holds them, what kind they are, and how an agent is
  asked. The reader's job is to read fields.

4,737 unit tests green across flow, task and controller; phpcs, phpmd and
gate-16 clean.

* test(flow): attribute the extracted performer class's coverage

`UserTaskPerformers` came out of `UserTaskNode` to keep the node under the
complexity ceiling, and the tests that drive it on every assertion still claimed
only the node — so 53 extracted statements read as untested. The coverage guard
refused the change and was right about what it saw.

No behaviour changes; the two suites that exercise the class now say so.

* fix(flow): a typed assignee reached the task row as the string "Array"

Casting a {type, id} map to a string yields the literal "Array". That string
was written into the task row AND into the run's resume slot, where the
answer guard reads it as a legacy bare string, matches it against no uid and
no group, and leaves the task answerable by nobody. That is the exact
silence typed principals exist to remove, put back by a cast.

PrincipalReference::storedString() now owns the conversion, because it is
knowledge about references rather than about a step. A bare string passes
through trimmed, since every flow ever authored names people that way. A
single reference becomes type:id. Several references are not one stored
value, so the answer is empty and the task pools, which is what the
candidate fields are for.

type:id is what an inbox can predicate on in the datastore, which is the
rule the design sets: the resolver decides authorisation, not listing, and
an inbox resolving a reference per row would resolve it a hundred times on
one page. TaskInboxCriteria::assigneeNames() expands the caller into the few
strings that mean them: the bare uid, user:<uid>, and group:<id> for each
group they are in. agent: is deliberately absent, so an agent's task stays
out of a person's inbox.

The resume slot keeps the reference's shape, because the shape is the
meaning: the guard reads a bare string as the old wider union and a typed
reference as exactly what it says, resolved fresh on every answer.

UserTaskPerformers had no direct test. Its two methods were only ever
reached through a node fixture that passes a null registry, so the half that
matters never executed. It is tested directly now, and the four remaining
uncovered lines in UserTaskNode are covered too.

* test(flow): cover the step's own readings and the unknown-type refusal

The config reader's list, template and null handling had no direct test, and
these are the readings a flow author trips over rather than the ones a
developer does: a list written as "approved, rejected" in a text field, a
trailing comma producing an empty candidate nobody can be, a title carrying
a template, a field left blank that must stay null rather than become an
empty string in a date column.

UserTaskPerformers::refuseUnknownTypes and kindFor are covered too. The
refusal names both the type and the id, because the author is at the
keyboard with the field on screen, and an instance with no registry refuses
nothing: one that cannot say which types exist must not decide that none
of them do.

* test(flow): adapt the performers test to this branch's constructor

UserTaskConfig does not take the resolver registry here: the unknown-type
refusal moved onto UserTaskPerformers, which is where the registry now
lives.
With the picker and the exact-match fixes in, this spec now builds the
flow correctly — the trace shows the End step on the canvas, an edge from
the start node, and Run enabled. It then times out at 45s, and the error
it reports is the CLEANUP that ran afterwards:

    page.evaluate: Target page, context or browser has been closed
      at deleteFlow (...:221)

which names neither the control it was waiting for nor the menu it was
waiting in. The last locator in the trace is `[data-testid="flow-publish"]`.

THE PAGE HAS THREE BUTTONS NAMED "Actions": the canvas has one and
NcAppSidebar renders one in its header. `openFlowActionsMenu` took
`.first()` of each candidate group, so it opened a menu that does not
carry the flow actions, found no Publish there, and moved on having
tried ONE of the three. Its own descriptive error never fired, because
the test budget ran out first.

Two changes:

  · try EVERY button each selector matches, not just the first, and
    press Escape between attempts so a menu that did open cannot cover
    the next candidate;
  · bound each click at 5s. An unbounded click on a control that never
    becomes actionable spends the budget the remaining candidates need,
    which is how a helper written to name its failure managed to produce
    a timeout somewhere else instead.

The sidebar header menu is tried first now, because that is where these
actions actually live.

prettier and eslint clean.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…3519)

With the actions-menu fix in (#3517), the menu opens and Publish is
pressed. The flow then stays a Draft and the spec reports

    Publish was pressed but the flow never became published — the store
    still shows the draft it started as, so the POST was rejected or
    swallowed

There was no POST to reject. The trace's entire request log for this
test is the flow's own `POST /api/flows` (201) and the cleanup DELETE.
Nothing was ever sent to publish.

The menu item's handler is `run: () => { this.publishOpen = true }`. It
mounts `CnFlowPublishDialog` — the confirmation carrying the next
version, what the bump removes, and any refusal — and PUBLISHING is what
that dialog's own primary button does. The spec pressed the opener and
waited for the outcome of a button it never reached.

Now it asserts the dialog opens, then confirms through
`[data-testid="flow-publish-confirm"]`.

A REFUSAL IS READ OUT RATHER THAN WAITED ON. `flow-publish-refusal`
renders in the same dialog when the version bump is declined, which is a
legitimate answer; without this the confirm would sit out its budget and
report the lifecycle pill instead of the reason printed on screen.

prettier and eslint clean, SPDX header intact.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#3519 made the failure precise — "pressing Publish did not open the
publish confirmation" — and the page snapshot shows why it could not:

    - button "Actions" [expanded]
    - menu "Actions":
        - menuitem "Edit flow"
        - menuitem "Enable"
        - menuitem "Publish"          ← right there, unclicked

The menu opened. The item was present. Nothing happened.

`data-testid` is a fallthrough attribute on `NcActionButton`, and that
component's root is the `<li>`; the handler is bound to the `<button>`
inside it. `clickThemed` dispatches the event straight at the element it
is handed, so aiming it at the `<li>` fires an event nothing listens for.
Playwright's own `.click()` would have walked down to a clickable
descendant and hidden this, which is the trade this file already
documents for using a dispatched event.

Now driven through `getByRole('menuitem', { name: 'Publish' })` — the
accessibility tree names it, so this is also how a screen-reader user
reaches it — with the testid's inner button as a fallback if the role is
ever renamed.

Same shape as the `flow-settings-name` fix in integriq #1889: a testid on
a wrapper component is not a testid on the control.

prettier and eslint clean, SPDX header intact.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h the global server (#3523)

A unit-test run took 19 GB of RAM and 6 GB of swap on 2026-09-08. The
bootstrap loaded the workspace's uninstalled lib/base.php and kept running
with a half-built OC::$server; four classes then asked that container for
app services, and without the app's factories it autowired the
MagicMapper -> SettingsService -> ValidationOperationsHandler ->
ValidateObject cycle until memory ran out. Twelve unit test files hit a
1 GB cap within seconds in that state.

- tests/bootstrap.php only boots a root whose config.php says installed;
  an explicit root that cannot boot stops the run instead of pretending.
- phpunit.xml caps memory at 2G (the suite peaks at 454 MB).
- SaveObject, ObjectService, ObjectsController and EntityRecognitionHandler
  resolve services through their injected app container.
- SaveObjectRetentionResolutionTest pins the seam; two ObjectsController
  tests register services on the container mock instead of OC::$server.
- Two stale UnusedParam psalm baseline entries removed.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
… a task to it (#3513)

* feat(flow): a run keeps the set of objects it is working with, by role

"The run should keep an array of objects that it is interacting with" — and it
now does, as a DECLARED set rather than a derived one.

🔴 THE AUDIT CANNOT SERVE THIS PURPOSE, and that is the whole design decision.
It already answers "what did this run write", but a case flow READS a case it
never writes, WRITES a task and a document it does not consider subjects, and
may write one object at three steps for three different reasons. The derived
list is too much and too little at once — and decisively it carries no ROLE.
`attachTo: case` needs a name, and a list of things that happened has none to
give. So the two coexist: derived answers the auditor, declared answers the
author.

A ROLE, NOT AN INDEX. `attachTo: 2` would work, would be unreadable, and would
break the moment a step is inserted. Roles are per-run and unconstrained on
purpose — `case`, `besluit`, `aanvraag` are all fine — and a typo is caught by
the addressing rule failing loudly rather than by a registry somebody has to
maintain. The refusal NAMES the roles the run holds, which is what makes that
trade work.

🔑 IDEMPOTENCY IS NOT OPTIONAL. A user-task node is re-entered on a heartbeat,
by design, with its task still open, so any node that records a subject is
re-entered too. Forty wake-ups leave one subject and log nothing: only a
DIFFERENT object under a held role is a replacement.

REPLACEMENT IS RECORDED, NOT REFUSED. Superseding a draft decision with a final
one genuinely means `decision` to be the new object, and refusing would force
authors into `decision2`. But it is also exactly how a later step silently
attaches to the wrong record, so the log entry names both objects and the role
— which makes "why is this task on the old decision" answerable in under a
minute.

ON THE RUN ROW, NOT IN A RESUME SLOT: slots are per node, and a three-week
approval must wake up still knowing what its case is.

The repair seeds ONE thing: the `trigger` entry, from `subject_uuid`, which is
a fact the run already carried. 🔴 NOTHING is back-filled from the audit —
those declarations would then be ADDRESSABLE by `attachTo`, and a task attached
to an object no author ever declared is worse than one that fails loudly the
first time somebody uses it.

9 unit tests, each seen RED by mutating the implementation: making a re-fire
append fails the heartbeat test, and returning nothing for an unheld role
instead of failing fails two.

⚠️ `findAllRuns()`, not `findAll()` — writing the latter once already produced a
repair that threw, reported itself skipped, and let an upgrade go green having
done nothing.

* feat(flow): record a run's declared subjects and attach a task to one

A node knows its run only as a uuid in the context array, and the subject
set lives on the run row, because it must survive a three-week suspension
that a per-node resume slot does not. This is the one place that turns the
uuid into the row, records, and stores.

Two deliberately opposite failure modes:

Recording never fails the step that did the work. The object has already
been written or locked by the time this runs, so failing here would report
a step as failed after its effect landed. An unreadable run is logged and
the step carries on.

Attaching does fail, before the task exists. A task attached to nothing
looks fine in every list and is wrong in the one place it matters, so an
unheld role throws and no task is created. The refusal names the roles the
run does hold, which is what makes an unregistered role vocabulary
workable.

Attachment goes through the task's existing objectUuid, registerId and
schemaId. Three things already read those fields, so the attachment works
everywhere immediately; a new column would have needed all three taught
about it, and the two that were not taught would have been found by a user.

Refs openspec/changes/flow-run-subjects-and-answers

* feat(flow): let object-write and lock-object declare what the run is working on

Both nodes accept an optional subjectRole. Naming one records the object
the step acted on under that role, so a later step can attach its task to
it by name. Naming none records nothing: the subject set is declared, never
derived. A set that filled itself would be the audit-derived object list
again, which already exists and answers a different question, and every
entry in it would then be addressable by attachTo, inventing declarations
no author made.

One object, or none. A role is a singular name, so a step that wrote forty
objects has no single one to mean by it. Recording the fortieth would be
arbitrary and recording all forty in turn would fire thirty-nine
replacement warnings for a set that ends up holding one entry anyway. So a
multi-object step records nothing and says so once, and the author finds
out at the point of use, where the later attachTo fails naming the roles
the run does hold. The count decision lives in the recorder because it is
the same decision for both nodes.

The write node skips a delete: the object is gone, and a role pointing at
it would attach a task to a record that no longer exists. The lock node
records only after every target is held, so a run never claims a subject it
failed to lock, and records no register or schema because it locks by uuid
and never resolves them.

The RBAC rationale moved from inside execute() to the class docblock, where
a reader looks for it, which also brings the method back under its length
budget.

Refs openspec/changes/flow-run-subjects-and-answers tasks 3.1, 3.2

* test(flow): the run read serves its declared subjects, and the two lists disagree

GET /api/flow-runs/{uuid} already carries the declared set, because the
entity serialises it. What had no test is the thing the whole change rests
on: that the declared set and the audit-derived object list answer different
questions and must keep disagreeing.

The run in the test READS a case it never writes and WRITES a task it does
not consider a subject, so the case appears only in the declared set and the
task only in the derived list. The audit answers the auditor; only a
declaration answers the author, and only a declaration carries a role.

A run that declared nothing answers an empty set rather than a missing key,
and a run nobody may see is a 404 here too: the declared set names the
records a flow is working on, so it is as sensitive as the run itself.

* docs(flow): mark the delivered subject-recording tasks

Sections 1, 2, 3 and 5 are done. Section 4 (attachTo on the user-task node)
waits on the agent-performer and principal-picker branches, which rewrite the
same node.

* fix(flow): keep the lock node under its complexity budget

The node guarded the empty role itself and the recorder guards it too, which
is one rule in two places. That second copy took LockObjectNode to a
complexity of exactly 50, which is the configured threshold, so CI's phpmd
refused it while my per-file local runs passed: CI runs the whole lib tree
with a baseline file and a second unused-parameter ruleset, and I had run
neither.

The guard now lives only in the recorder, where the decision belongs and
where it is tested. The node's own test asserts what is left to assert: that
a step naming no role passes no role on, rather than substituting one.

* feat(flow): a task can hang on an object the run declared earlier

The user-task step accepts an optional attachTo naming a subject role of its
run. The created task is anchored to that entry through the task row's
existing objectUuid, registerId and schemaId, so the subject-anchored inbox
read, the case sidebar and the portal visibility rule all keep working with
no change. A new column would have needed all three taught about it, and the
two that were not taught would have been found by a user.

The anchor overrides the item's own, which is the point of the field.
Without attachTo the task hangs on whatever record the step was raised from;
with it, the task hangs on the object the author named, which may be one no
item in this stream carries.

Naming a role the run never recorded fails the step and creates nothing. The
refusal lands before the task exists, because a task attached to nothing
looks fine in every list and is exactly the task an author believed was
attached to the case. It names both the role asked for and the roles the run
does hold, which is what makes an unregistered role vocabulary workable.

Refs openspec/changes/flow-run-subjects-and-answers tasks 4.1, 4.2

* test(flow): cover the run's declared subjects in the Newman suite

The whole scene over the live HTTP API: a case is created and recorded under
the role case, the case is locked, a person is asked with attachTo: case,
they answer with a form value, and the answer routes.

Newman rather than Playwright, because these are HTTP contract assertions and
playwright.config.ts excludes api-direct from every project. A spec there
runs only when a developer invokes an ad-hoc config by hand, so CI executes
none of it, and a green spec with zero CI coverage looks identical from the
outside. That is the same reason the delegation and register-descriptors
collections were moved here.

Not a unit test, because every part of this already has one and they all
passed while the parts did not meet. The recorder writes onto the run row,
which only exists once a run is persisted; attachTo reads that row back in a
later step of the same run; and the anchor has to survive the task bridge,
the task row and the inbox read. A unit test can assert each hand-off and
cannot assert that the chain holds.

Three scenarios: the scene itself, a write naming no role declaring nothing
while the audit still shows the write, and attachTo naming an unheld role
failing the step and leaving no task. The last is the assertion the whole
capability exists for.

Registered in run-all.sh, which CI runs through api-test-coverage.yml on
every PR to development. A collection that is not registered there runs
nowhere.

* ci(newman): run the flow-subjects collection

Registering a collection in run-all.sh is not enough to make it run: the
workflow passes an explicit COLLECTIONS subset, so anything outside that list
executes nowhere however carefully it is registered.

flow-subjects belongs in the default set. It seeds its own register and
schema and removes them again, so it hardcodes none of the dev-container
state that keeps platform-annotations and files out, and it drives every run
through the synchronous test endpoint rather than the background worker,
which is what keeps flow-engine out.

* docs(flow): record what shipped and where the proof lives

Sections 4, 5 and 6.1/6.2 are done. The proof is a Newman collection rather
than a Playwright spec, and the entry says why: playwright.config.ts excludes
api-direct from every project, so a spec there would be green and cover
nothing.

4.3 asserts the attached task through the subject-anchored inbox read. The
case sidebar is a dossiq surface reading the same three task fields, so it is
covered by the choice of fields rather than by a second assertion here.

* fix(flow): serialise a run's subjects as an object, never an empty array

Found by the new Newman collection on its first CI run, which is what it is
for. json_encode turns an empty PHP array into [], so a run that declared
nothing served a JSON ARRAY where every populated run serves a MAP. A typed
client cannot read both, and nothing in PHP notices: assertSame([], ...)
passes on the array the encoder then flattens.

The cast makes the wire shape one thing, and the tests now assert the
encoding rather than the PHP value.

The collection also learned to explain itself. Every assertion in the scene
folder reads something the run was supposed to produce, so when the run fails
they all fail for a reason none of them names. The suspend assertion now
carries the run body in its message.

* fix(test): the scene's edges are sequence, not actions

The engine refused the whole run: an edge carrying type and config is the
pre-inversion shape, and it says so plainly — an edge is sequence and a node
is the action.

The route edge came from tests/e2e/api-direct/flow-engine.spec.ts, which
still carries that shape because nothing runs it. That is the same reason
this file is a Newman collection rather than a Playwright spec, and it is a
neat demonstration of the cost: a spec CI never executes goes stale without
anyone finding out, and then gets copied.

The branch was never asserted here anyway, so the graph is now a straight
line and the prose no longer implies otherwise.

* fix(tasks): a form answer writes as the run that raised the task

A flow that locked its case and then asked a person with a form on that case
could never be answered. The completion came back 500 with LockedException:
"Object is locked by flow run …, running as admin" — naming the very run
that was trying to write.

The object-lock guard already intends to allow this. It reads the acting run
from an ambient context precisely so a flow that locks its case at one step
is not turned away by its own lock at the next. But that context is set while
the ENGINE walks the graph, and a form answer arrives on a PERSON's HTTP
request, where nothing had set it — so the run's own write read as a
stranger's and was refused.

TaskFormCompletion now pushes the task's runUuid around the subject write and
pops it in a finally, so a refused write does not leave the rest of the
request pretending to be a run.

Found by the flow-subjects Newman collection on its second CI run, driving
the scene from the brief: create a case, lock it, ask a person with a form on
it. Every earlier layer passed while this one did not, which is exactly what
a collection that walks the whole scene is for.

* test(flow): a register holding objects declines deletion, and that is correct

The teardown asserted the fixture register and schema were gone and got 409.
That is not a failed teardown: the runs created case objects in the register,
and a register holding objects is not deletable, which is the behaviour
wanted everywhere else.

Accepting 409 is safe here because every fixture is namespaced with the run
stamp, so nothing a later run does can collide with what is left, and CI
destroys the container when the job ends. A 5xx still fails, which is the
distinction worth keeping: a register declining to disappear under its own
contents is not the same as the register read breaking.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Every gate measured by exit code rather than by its summary line, which is
the distinction that matters here: phpunit has printed OK and exited 1, and
phpmd over the whole lib tree is OOM-killed and reports that as a pass, so it
is swept per directory with the baseline instead.

lint clean, phpcs 0, phpmd E=0, psalm 0, phpstan 0, phpunit 0 over 19,434
tests, and both l10n gates 0 across all 36 locales.

Also notes that CLAUDE.md's "Known state" claim that test:l10n is red at
HEAD is stale. It passes, and the 17 keys it names are present — a stale
note about a red gate is how a real red one later gets waved through.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…ks (#3528)

On an instance with hundreds of schemas the provider took one lock per schema in a single transaction and failed with SQLSTATE[53200] out of shared memory, swallowing the error into an empty result. Search now runs in bounded chunks, keeps ordering and links, and logs a failed chunk as an error while returning the rest.

Work in progress checkpoint: checks and live verification follow in the next commit.
…#3530)

* feat(openspec): contacts-leaf-cases-panel

* feat(openspec): content-search-index

* feat(openspec): files-leaf-save-to-object

* feat(openspec): favourites-and-recent

* feat(openspec): rbac-department-role-matrix

* feat(openspec): audit-log-page

* feat(openspec): query-related-schema-rows

* feat(openspec): notes-leaf-rich-text-lock-export

* feat(openspec): registry-subscriptions

* feat(openspec): activity-leaf

* feat(openspec): generated-identifier

* feat(openspec): lifecycle-over-reference-field

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… global server (#3532)

* refactor(jobs): background jobs resolve collaborators through an injected container

The eight jobs that reached for \OC::$server at run time now take the app
container in their constructor. Their tests register mocks on a per-test
container mock (new RegistersContainerServices trait) instead of on the
process-wide fake server, which never reset and leaked mocks between tests.
BlobMigrationJobTest no longer needs its capture-and-restore dance or its
skip; its twelve cases run.

Also: psalm and phpmd get explicit 2G memory limits in composer.json, and
the NoLegacyServerAccessors sniff test points at the vendored hydra-gates
sniff and runs again (it was skipped for a PHPCS 3.9 bug and aimed at a
file this repo no longer carries).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: search trail, magic mapper and organisation controller use their injected collaborators

SearchTrailController takes the SearchTrailMapper in its constructor and the
mapper gains the clearAllLogs() the admin endpoint always called (the method
never existed, so clearAll threw an \Error for an undefined method).
MagicMapper's cross-table lookups use the register and schema mappers it
already has; OrganisationController reads the session it already has. The
tests stop registering mocks on the global server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: services take the collaborator they used to fetch from the global server

ApiTokenSettingsController takes IClientService, EndpointService the
AgentMapper, ActivityFilterService and the seven MarkerLookupTrait providers a
LoggerInterface, TenantLifecycleService the IUserManager, DeleteObject the
OrganisationMapper and ConfigurationSettingsHandler the IAppManager.
ImportHandler takes an optional IAppManager (twelve test files construct it
positionally); without one the seed-data app dependency check is skipped with
a debug log instead of reaching for the server.

The provider factories in Application.php pass the logger. Tests that
registered mocks on the global server now inject them, and the seams that
were unreachable before (token probes, provisioning the org admin, the active
organisation on delete, version info) get one test each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: link services, the relations controller and the grant resolver resolve through an injected container

RelationsController, AnalyticsLinkService, DeckLinkService,
BookmarkLinkService, DeckCardService and FileVersioningHandler take a
ContainerInterface and resolve the other apps' classes (Deck, Bookmarks,
Files_Versions, Analytics) and the pluggable leaf link services through it,
wrapped exactly as before (class_exists guard, try/catch, null on absence).
DeckCardService also takes the IUserManager it used to fetch by string.

CaseTokenService and ObjectGrantResolver already accepted an optional
container with a global fallback; the container is required now and the
fallbacks (an anonymous PSR-11 shim and a Server::get of the container) are
gone. The tests inject a container mock; the relations controller gets two
tests for the leaf path that used to be unreachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(schema): say why the entity keeps its guarded global lookup

Schema is an entity, not DI-built, and resolveIntegrationRegistryIds() runs
from setConfiguration(), which every mapper calls while hydrating a row.
Threading the registry ids through those thirty call sites is a change of
its own, so the lookup stays behind its isset() guard, with a targeted
phpcs:ignore and a docblock that says so. It is the one remaining site in
lib/ outside AppInfo, AppHost and Migration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor: mark the deliberate container lookups, and wire the last services

Closes the openregister half of the 2026-09-08 memory incident. Every remaining
\OC::$server->get() / \OCP\Server::get() in lib now carries a phpcs:ignore
naming why it stays, so the new fleet sniff can land as an error:

- two PSR-11 adapter shims that ARE the container being handed to a
  collaborator, so there is nothing to inject into them
- three probes for optional sibling apps (keepiq credential services, AppAPI
  PublicFunctions), each guarded by class_exists and try/catch
- one provider fallback, tried only after the injected container
- Db\Schema, an entity rather than a DI-built class, which already documented
  its guarded lookup

Also wires the container and logger arguments the earlier commits' constructors
now require, and drops two dead branches static analysis had flagged: AgentMapper
findByUuid returns a non-nullable Agent and throws, so its null check could never
run, and IResponse::getHeader returns an array, so its null coalesce could never
fire. Neither changes behaviour.

Verified locally: 19,454 unit tests exit 0, peak 454 MB; phpcs exit 0 across all
47 touched lib files; the fleet sniff reports 0 unannotated lookups in lib.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
rubenvdlinde and others added 30 commits September 12, 2026 12:54
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Implements OpenRegister's registry-subscriptions capability, the design Ruben chose on 2026-09-11 for Tier B item B22 over integriq's brp-kvk-store-and-subscriptions (rejected the same morning, retired in ConductionNL/integriq#1970, merged as c1d34690).

- x-openregister-registry schema annotation, validated (blocking) at schema save.
- openregister_registry_subs state table, keyed by object uuid.
- @self.registry render mirror.
- POST/DELETE .../registry-subscription to request/end a subscription, guarded by the caller's update permission.
- POST /api/registry/{registry}/updates, the connector's inbound update: applies only owned properties, refuses the rest, writes an explicit registry-actor audit row.

Query lenses (_registry[state]/[updatedBefore]) are deliberately deferred; see design.md's implementation addendum.

CI: run 34688988262, a pull_request-triggered run that actually attached to this PR, conclusion success, 46 checks, all pass or correctly skipping. development advanced by one commit since (an unrelated fix, only appinfo/info.xml's version string conflicted); resolved and verified locally by exit code (php -l, phpcs, phpstan, gate-16 spec-coverage, migration-version-bump check) rather than waiting through another full CI cycle against a fast-moving base.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…#3661)

* feat(archival): a schema can declare the three archival facts MDTO asks for

GAP A3. aggregatieniveau, beperkingGebruik and dekkingInTijd appeared in zero
PHP files. The export learned to emit them in #3618, but nothing wrote them,
so it could only ever omit them, and an omission reads exactly like a record
that genuinely has none.

x-openregister-archival now takes three keys beside retention:

  aggregationLevel: Dossier
  useRestriction:   { type: "Geen beperking", description: "..." }
  temporalCoverage: { type: "Looptijd", startProperty: startdatum,
                      endProperty: einddatum }

temporalCoverage names date PROPERTIES rather than dates, the way
sourceDateProperty does. MDTO defines dekkingInTijd as the period the
record's CONTENT pertains to, which differs per row, so a literal date on a
schema would be the same wrong answer for every one of them.

Unknown keys are refused at the top level and inside each block, the way
unknown retention keys already were, and a term outside MDTO's own
begrippenlijst is refused too. Both lists are formally OPEN, so that check is
stricter than the standard, and deliberately: the exported element cites the
list it took the term from, so a term that is not on the named list would
make the document claim a provenance the term does not have. MdtoTerms is the
one home for those lists, so the check that refuses a term and the document
that cites it cannot disagree.

RetentionEvaluator resolves the facts for the row, ArchivalDecisionResolver
emits them into _retention in the abstract English vocabulary with the
object's own retention block overriding the schema, and MdtoValueReader gains
the annotation as its third source so a schema-declared fact reaches the
document. Each is present only when established, absent otherwise, the same
rule UnestablishedValues applies to the rest of the block.

* test(archival): the declared facts, from schema to document

Four cases each, as the change asks for: declared on the schema, overridden on
the object, absent (emitting nothing, no placeholder), and invalid (refused).

The validator refuses an unknown key at the top level and inside each block, a
term outside MDTO's begrippenlijst, and a literal date where the annotation
takes a property name.

The evaluator resolves temporalCoverage against the ROW, truncates a timestamp
to the date union MDTO allows, and drops an end date the row cannot supply
while keeping the start.

The resolver emits each fact into _retention, lets the object's own block win
over the schema's, reads the TMLO spelling, and omits a fact nobody declared.

The generator turns a schema-declared fact into elements, and the XSD suite
gains a case whose facts come only from the annotation, so the document that
carries them is still validated against the vendored schema.

* docs(archival): A3 is resolved, and D1 records what the export now emits

The proposal still said these three facts have no writer, which was true when
it was written and is the reason the export could only omit them. A3 now
records the annotation that declares them, the per-object override, the
property-naming rule for temporal coverage, and the two checks that refuse a
mistake instead of exporting it.

It also records what openbaarheid turned out to be: MDTO has no such element,
because publicity is carried by beperkingGebruik, whose "Geen beperking" term
is how a record says it is open. So that part of the finding is a vocabulary
question for WOO rather than a missing writer.

D1 separates what was absent at audit time from what is absent now. Still
missing: betrokkene and the relation elements that carry aggregation
structure, which openregister has no source for until a schema can say which
relation expresses containment.

* docs(archival): the spec for the three declared facts

Names the annotation keys, the resolution order, the property-naming rule for
temporal coverage, and the two refusals: unknown keys, and terms outside the
begrippenlijst the exported element cites. Records why that second check is
stricter than MDTO, whose lists are open, and what it would take to support a
local term instead.

* refactor(archival): split the facts validator out, and fix the two style findings

phpmd put ArchivalAnnotationValidator over the complexity threshold and
resolve() over the method-length one, both because of this change.

The three fact validators are now ArchivalFactsValidator. That is a real seam,
not a shuffle: the original validates the disposal DECISION, a duration and
the rules that override it, while these validate FACTS about the record
against MDTO's begrippenlijsten. Moving methods within a class would have
raised complexity rather than lowered it, which is what happened the last time
I tried it.

phpcs also wanted a named argument in the shortened resolver call, and an
inline if replaced by a statement.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
… the schema (WOO-563) (#3638)

* fix(import): keep the register-schema link when a version check skips the schema

An import that re-ran with an unchanged schema version took the skip path
before the register-schema link was written, so the schema stayed
unattached to the register it was imported into and every later
register-scoped lookup missed it (WOO-556 finding B24).

The link is now established on the skip path too, so a no-op re-import
converges on the same state as a first import instead of leaving the
register half-wired.

UNFINISHED — committed to preserve the work across a WSL crash. Not yet
verified: the new
tests/Unit/Service/Configuration/ImportHandlerRegisterSchemaLinkOnVersionSkipTest.php
has not been run, no quality gates have been run, and there is no PR.

Refs: WOO-563

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(import): make the register/schema mapper doubles match the real signatures

The doubles declared find(string $id) while RegisterMapper::find takes
($id, $_rbac, $_multitenancy) and SchemaMapper::find takes ($id,
$_extend, $_rbac, $_multitenancy). Production calls both with NAMED
arguments, and PHPUnit forwards an invocation's named arguments to the
callback as named arguments, so the closure would die on "Unknown named
parameter $_rbac" — an Error, which the DoesNotExistException catch
below does not see, so the per-register Throwable guard swallowed the
whole register import. The suite would then read as "the fix does not
work" when the fixed code was never reached.

Verified against the branch: 4 passed with the fix, and the two
behavioural tests fail without it (restored HEAD~1's ImportHandler,
re-ran, got exactly the twin-fork and missing-link failures back).

Refs: WOO-563

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(import): one implementation of the schema-link union rule

Review follow-up on #3638. The full register update and the version-gated
skip path each carried their own array_unique(array_merge(...)), with
different flags. Harmless for the ids actually stored, but the rule
"union, never replace" was stated at length beside each copy — the exact
shape that rots, and #2935 was this rule holding in one place and not the
other. Both now call unionSchemaIds(), which states it once.

Also tightens the skip path's no-op guard: it compares against the DEDUPED
current list instead of the raw one, so a register whose stored list
already held a duplicate no longer gets a silently de-duplicated row
written from a path whose premise is that it writes nothing.

Configuration suite still 409 passing; phpcs, phpmd and php -l clean.

Refs: WOO-563

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(import): return the mutated register, not update()'s Entity-typed value

CI on #3638 failed one test in 19844: ImportServiceRegisterAutoCreateTest
never configures registerMapper->update(), so PHPUnit returns a bare
Entity stub for it, and linkImportedSchemas()'s Register return type
rejected that with a TypeError. The path never wrote before WOO-563, so it
never met the stub. QBMapper::update() returns the same instance it was
given, so persisting and then returning the entity we mutated is identical
at runtime and only differs in the declared type.

Verified in the container with the worktree bootstrap: the four WOO-563
tests plus ImportServiceRegisterAutoCreateTest, 7 passed, 34 assertions.

Refs: WOO-563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…WOO-567) (#3639)

* fix(objects): keep the instant of a date-time with a non-UTC offset

A format: date-time value written with a non-UTC offset came back with the
offset replaced by +00:00 and the clock time untouched, so the instant
silently moved. PATCH {"term":"2026-10-20T00:00:00+02:00"} echoed +02:00,
and the following GET returned 2026-10-20T00:00:00+00:00 — two hours
later than what was sent. For a WMEBV objection term (art. 2:10) that is a
legally relevant shift, not a display quirk (WOO-556 finding B23).

Normalises through the instant instead of the clock face, and carries the
same rule into the magic-mapper search and statistics handlers so filters
and sorting agree with what is stored. The spec gains the corresponding
requirement.

UNFINISHED — committed to preserve the work across a WSL crash. Not yet
verified: the 138 new lines of DateTimeNormalizerTest have not been run,
no quality gates have been run, the PATCH/GET cycle has not been replayed
on the dev instance, the negative-offset and DST-boundary cases are
unconfirmed, and there is no PR. Existing stored values may already be
wrong (clock-time-as-UTC); whether a migration is needed is still open.

Refs: WOO-567

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(objects): do not convert a calendar date through a timezone

Self-review catch on #3639. The WOO-567 fix routed BOTH `date` and
`date-time` properties through formatForDatabase(), which converts to UTC
before storing. That is right for a date-time, which names an instant, and
wrong for a `date`, which names a calendar DAY and has no instant.
Converting moved it in both directions — measured on this branch:

  2026-10-20T00:00:00+02:00  ->  stored 2026-10-19   (a day early)
  2026-10-20T23:30:00-05:00  ->  stored 2026-10-21   (a day late)

So a due date submitted from a client that sends an offset silently landed
on the wrong day — the same class of bug WOO-567 is about, introduced one
format over while fixing it.

The write path now splits on format: a date-time is converted, a date
keeps the wall-clock reading it arrived with, which is what this code did
before WOO-567 touched it. Five new tests pin both halves, including the
contrast case where the same input is preserved as a date and converted as
a date-time.

Refs: WOO-567

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(objects): import DateTimeImmutable and DateTimeZone instead of qualifying them inline

phpmd's MissingImport rule failed CI on #3639 at the two write sites that
did `new \DateTimeZone(...)` inline. Imported both classes next to the
existing `use DateTime;` and dropped the leading backslashes at all four
usages. No behaviour change; php -l, phpcs and phpmd clean on the file.

Refs: WOO-567

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dy calls (#3662)

countBySchemas() returns 0 and findBySchemas() returns [], unconditionally.
Neither has a caller anywhere in lib/, src/, tests/ or appinfo/: measured zero
references outside their own definitions.

They are the shape this repository has been removing all week: code that looks
like a working query and silently answers 'nothing'. A future caller would get
'no objects in these schemas' from a method that never looked, and would have
no reason to doubt it. Whoever needs to count or find across schemas should
write it against the magic tables deliberately, the way the retention sweep
does, rather than inherit a lie.

getSizeDistributionChartData() is left in place on purpose: it answers with an
empty chart, but it HAS a caller (DashboardService::getObjectsBySizeChartData),
so deleting it would break the dashboard. Implementing it for real is a
separate change.

Verified: phpcs exit 0, phpunit tests/Unit/Db 1609 tests exit 0.
…0260912131909

chore(sync): carry beta back into development
…app-owned state machine (#3666)

* feat(lifecycle): add the public provider contract for app-supplied actions

An app whose transitions live in data rather than in the schema has no way
to answer available-actions today. This is the contract it implements, a
sibling of the guard and action interfaces: read-only, called on a GET, and
throwing rather than returning an empty list when it cannot answer.

* feat(lifecycle): resolve provider tags through a fail-closed registry

Models LifecycleGuardRegistry: OpenRegister container first, IServerContainer
as fallback for an FQCN another app owns, a per-request instance cache, and no
static container lookup. An unresolved tag throws rather than answering with an
empty list, because an empty list is a legitimate answer here and a silent
failure would be indistinguishable from one.

LifecycleProviderException extends RuntimeException so existing catch sites
keep behaving, while the controller can still tell a provider failure from a
missing object.

* feat(lifecycle): answer available-actions from a provider when one is declared

The provider branch sits between the static map and the graph block, so a
schema that declares transitions keeps them whatever else it says, and
TransitionEngineGraphTest's precedence case stays green. Whatever the provider
returns is republished through publishedInputs(), the same normalisation the
write path uses, so the inputs contract cannot drift per mode.

A provider that throws is wrapped rather than swallowed: an empty list is a
legitimate answer, so a failure that looked like one would render as a dead
timeline the client believes is correct.

The two new PHPMD suppressions carry their reasons in the file, following the
house idiom rather than growing the baseline.

* feat(lifecycle): validate provider-mode annotations at schema-save time

Provider mode is shape-checked like graph mode: field required, enum relaxed
because the app owns the state vocabulary, initial accepting either form. The
provider tag itself must be a non-empty string, since a mistyped one resolves
to nothing on a GET, in front of a user.

Two modes on one field are refused rather than settled by precedence. The
engine does pick one, but the one it drops would read as declared and never
run, which is the failure the graph-condition refusal already guards against.

* feat(lifecycle): refuse to store a broken provider declaration

Most lifecycle findings are advisory and stored as written, because refusing
the whole import over a partial annotation breaks register imports. A provider
declaration is the wrong shape for that policy: it fails on a GET rather than
at save time, so an author sees a save succeed and a user sees a dead
timeline.

* feat(lifecycle): answer 502 when a provider cannot be read

The widget reads 404 as no lifecycle and anything else as could not read, but
an empty action list is a successful answer meaning no moves from here. So a
provider failure that collapsed into that list would render as a dead timeline
nobody knows is broken, which is the bug this change exists to fix. Caught
before the RuntimeException branch it extends, so the existing 403 and 404
paths are untouched.

* test(lifecycle): pin the provider mode, its precedence and both fail-closed routes

Covers what is published, that static transitions still win, that inputs run
through the same normalisation the write path uses, that an unresolvable tag
and a throwing provider both refuse rather than answering with an empty list,
that a blocked entry keeps its reason, and that a provider with genuinely no
moves still answers an empty list as a success.

Plus the validator's provider cases, the SchemaMapper save refusal, and the
controller's 502 beside the untouched 403 and 404.

* docs(lifecycle): specify provider mode, its precedence and its fail-closed rule

The new methods carry @SPEC tags pointing at this capability, so a reader
following one should find the mode described. Documents the three published
requirements: what provider mode is and where it sits in the precedence order,
that a provider which cannot answer must fail closed rather than return an
empty list, and what the validator accepts and refuses.
…ncode (#3664)

The Twig SecurityPolicy in MappingService allowlisted json_decode and not
its counterpart. A stored mapping that encodes a value to JSON failed at
render with a SecurityError naming a filter, which reads as a broken
mapping rather than a policy gap.

json_encode is a Twig core filter and a pure value transform. The policy
already denies every method and property, so this widens nothing an
attacker can reach.

Two tests: json_encode renders, and a filter nobody allowlisted
(striptags) is still refused. Without the second, the first would also
pass on a sandbox that had been switched off.

Closes #3663
…es not refuse the schema (#3674)

openregister#3661 added a top-level allow-list to `x-openregister-archival`
and refused any key outside it. The annotation is optional and an extra key
declares nothing, but refusing it refuses the SCHEMA, and `ImportHandler`
drops a refused schema and every object that needed it. Two apps went red
within the hour on payloads they had carried for weeks:

  filinq   9 of 22 schemas, on `category` / `action` / `responsibleParty`
  pipelinq the `ticket` supertype, on a `_note`

Neither failure named this annotation. Each surfaced as an HTTP 412 out of
the app's own demo-data seeding, four layers away, because the rejected
schema left `ticket_schema` / `correspondence` unresolved in app config.
Every app clones openregister@development at CI run time, so the check
reached the whole fleet the minute it merged.

An unknown key at the TOP level is now reported as a warning and ignored,
which is the rule R07 already applies one level up: an unknown
`x-openregister-*` key is dropped and logged, never fatal. A key inside the
annotation should not be stricter than the key that contains it.

Everything that validates a fact the schema actually DECLARED stays fatal:
a malformed ISO-8601 retention, an unknown key inside `retention` /
`useRestriction` / `temporalCoverage`, and a term outside MDTO's
begrippenlijst. Those sit beside a real declaration, where a typo changes a
meaning rather than adding an inert key.

Verified: the two new tests reproduce both apps' payloads verbatim and fail
without the fix (mutation: dropped the `severity` line, 2 failures, exit 1;
restored, 20 passed). Archival suite 122 passed. phpcs on the changed files
reports 0 findings on lines this change touched.

Inherited, not fixed here: phpcs reports 71 pre-existing `@spec`-tag
warnings on untouched lines of SchemaMapper.php.

Refs: openregister#3661
* feat(e2e): refuse the shared instance unless the run names it

The dev box runs one Nextcloud on localhost:8080 that bind-mounts everyone's
checkouts. A suite that seeds and deletes objects there is editing a
colleague's environment, and both playwright configs could land on it by
accident: the flow config carried its own 'http://localhost:8080' fallback.

tests/e2e/shared-instance.ts holds the guard. resolveBaseUrl() now ends with
assertInstancePermitted(), and playwright.flow.config.ts goes through
resolveBaseUrl() instead of its own fallback, so there is one entrance.
Aiming at the shared instance still works, it just has to be named:
OPENREGISTER_E2E_ALLOW_SHARED_INSTANCE=http://localhost:8080. CI is exempt,
where localhost:8080 is the runner's own throwaway instance.

The unit test sits in tests/unit/ because jest is this repo's unit runner and
its testPathIgnorePatterns skips tests/e2e/, so a copy next to the guard would
never have run.

* fix(e2e): stop the guard's unit test failing on CI, and satisfy prettier

Two things the first commit got wrong.

The guard exempts CI on purpose, and the unit runner runs on a runner, so the
three refusal cases passed on a laptop and failed everywhere it mattered.
Measured on this branch before the fix: CI=true npx jest gave 3 failed, 7
passed. The test now clears CI and GITHUB_ACTIONS in a beforeEach and restores
them after. jest has no vi.stubEnv, so that is written by hand.

Both files also failed prettier --check, which is its own CI leg and is not
implied by eslint passing. Re-rendered from the template and reformatted.

Verified: 10 of 10 pass with CI=true and without. The whole jest suite goes
from 37 suites / 324 tests to 38 suites / 334 tests, so the file is collected
rather than dark, and playwright still collects 259 tests in 53 files, the same
as development.
…y delete (#3683)

* fix(calendar): refuse a VEVENT with no start, and make delete actually delete

Two faults in the object-anchored calendar endpoints, found while writing e2e
coverage for the Appointments section of dossiq's case Work tab.

DTSTART is now required (#3680). createEvent() emitted the property only when
the caller supplied one, and both callers, CalendarEventsController and
TaskEventsController, validated the summary alone. A VEVENT without DTSTART is
not merely incomplete: its firstoccurence is NULL so it matches no time-range
query and no calendar view can show it, and Sabre's ITip plugin then answers
500 to both DELETE and PUT on it. No client can remove such an event or add the
missing property afterwards; only a SQL delete clears one. The guard lives in
CalendarEventService::createEvent(), the single place a VEVENT is serialised,
so both controllers are covered by one check, and both already turn a thrown
Exception into a 400.

VTODOs are unaffected. The task projector writes DUE and no DTSTART, which is
correct for a task, and it does not go through this builder.

DELETE now deletes (#3681). CalendarEventsController::destroy(), the endpoint
behind the leaf's "Delete meeting" row action, called unlinkEvent(), which ends
in updateCalendarObject: it strips the X-OPENREGISTER-* properties and the link
row and never touches the meeting. It then answered {"success": true}, so
cancelling a hearing left the hearing on everyone's calendar while the case
confirmed the deletion, and the two row actions were the same operation.
TaskEventsController::destroy() carried the identical fault. Both now call a
new CalendarEventService::deleteEvent().

The controller test asserted unlinkEvent, so it had locked the defect in place.
It now asserts deleteEvent and that unlinkEvent is never called. Four new
service tests cover the start guard and the delete path, and each new test was
confirmed to fail against the old code: reverting destroy() reddens only the
controller test, and removing the start guard reddens only
testCreateEventRefusesAPayloadWithNoStart.

Fixes #3680
Fixes #3681

* test(calendar): the task-side destroy test asserted the defect too

CI caught this: I ran the three calendar test files and treated that as the
suite, so TaskEventsControllerTest went unrun. Its destroy test expected
unlinkEvent, and was green precisely because the endpoint did the wrong thing.
Its name said so outright: testDestroyStripsTheVeventAndRemovesTheLink.

It now asserts deleteEvent is called and unlinkEvent is not, matching the twin
test on CalendarEventsControllerTest, and is renamed to the promise rather than
the behaviour.

Full suite after the change: 20108 tests, 49282 assertions, 0 failures.
* feat(lifecycle): provider mode can take the move it offers

* test(lifecycle): pin the provider write path and its three error codes

* test(lifecycle): pin the no-mode annotation, not the static one, on the provider guard

* docs(openspec): specify the provider write half and its three error codes

* style(lifecycle): name the exception message parameter

* refactor(lifecycle): split the delegating-mode dispatch out of applyTransition
appDisplayName was unset, so every CnAppRoot surface that reads it
(NcContent's appName, CnRequestFeatureDialog, and — once
nextcloud-vue#1145 ships — the setup wizard's title) fell back to the
bare appId "openregister" instead of a proper name.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…enregister owns (#3688)

* spec(openspec): working-calendar-admin, the administered working calendar (rows 8.12, Q8.20)

* spec(openspec): end-date-roll-on-the-calendar, the Atw roll on a timer (row 8.11)

* spec(openspec): calendar-change-recomputes-timers (row Q8.17)

* spec(openspec): calendar-time-zone (row Q8.19)

* spec(openspec): term-engine-diagnostic (row Q8.18)

* spec(openspec): object-watchers, follow an object you do not own (row 13.18)

* spec(openspec): timeline-entry-visibility, internal or public per entry (row 6.15)

* spec(openspec): object-presence, who has an object open (row Q2.31)

* spec(openspec): note-edit-history (row Q6.17)

* spec(openspec): relation-types-with-inverses (row 2.26)

* spec(openspec): identity-survives-a-move (row Q2.30)

* spec(openspec): saved-view-count-alert (row 9.13)

* spec(openspec): view-group-share, the openregister half of row 9.4

* spec(openspec): send-at-on-the-messaging-leaf (row 6.9)

* spec(openspec): reply-threading-by-headers (row Q6.18)

* spec(openspec): feature-toggle-surface (row 11.15)

* spec(openspec): settings-change-audit (row Q10.13)

* spec(openspec): scoped-api-tokens (row Q13.20)

* spec(openspec): field-rules-by-state (row 11.25)

* spec(openspec): scoped-api-tokens cites the existing OAuth2 scope rule

* spec(openspec): sensitive-field-reveal-audit, the openregister half of row 5.6

* spec(openspec): property-code-list-from-concept-scheme, the openregister half of row 11.10

* spec(openspec): dedup-check-before-create, the openregister half of row 2.24

* spec(openspec): macro-flows-with-next-item (row 3.20)

* spec(openspec): migrate-run-between-versions (row 3.16)

* spec(openspec): external-register-view-leaf (row 5.13)

* spec(openspec): competitor-parity-2026-09 umbrella, coverage confirmation, index and build order
…, for a period (#3690)

* feat(people): a person on an object is a user or a contact, in a role, for a period

The contact link table takes a Nextcloud user beside a CardDAV contact, a
validity window and a note, and one person may hold several roles on one
object. A schema declares its roles under configuration.linkRoles, and a
role outside that vocabulary is refused. PersonLinkService is the write
surface for both kinds, UserLinkWriter the account side, and every write
is announced so an app can project it.

OpenSpec change people-on-objects.

* fix(people): the role vocabulary reader carries its spec tag
…ill lists (Q2.33, Q13.23) (#3692)

* spec(openspec): an object archives, hides, stays read-only and restores, gap register row Q2.33

* spec(openspec): a grant on a parent reaches its descendants, gap register row Q13.23

* docs(openspec): index the two openregister gaps the regenerated register still lists
…stops 500ing (#3693)

* fix(contacts): the link mapper answers find(), so unlinking a person stops 500ing

ContactService::unlinkContact(), updateRole() and the controller's legacy id
path all call ContactLinkMapper::find(); QBMapper has none and the class did
not declare one, so every unlink of a contact answered 500 with "Call to
undefined method". Measured on a running instance while verifying dossiq's
people surface.

The service's own tests could not see it: the mapper double declared the
method with addMethods(['find']), which adds a method the real class lacks.
The double now doubles only what exists, and a mapper test pins every method
its callers use.

* fix(deck): the deck link mapper answers find() too, and the baseline stops hiding both

psalm's baseline carried UndefinedMethod:find for ContactService twice and for
DeckCardService once: the same missing mapper method, recorded rather than
fixed. DeckCardService::unlinkCard() has been answering 500 the same way.
Both mappers now declare find(), and the entries are gone, which
findUnusedBaselineEntry would have failed the build over anyway.

* test(deck): the mapper double stops adding a method the class now has

PHPUnit refuses addMethods() for a method the class declares, which is the
whole point: the double can no longer claim a find() the real mapper lacks.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…3696)

* fix(mocks): stop doubles inventing methods the real classes cannot answer

A double built with addMethods() CREATES the named method on the mock, so
the real class is never consulted: a suite full of them passes while the
production path answers 500. Six such doubles were hiding live bugs.

- ViewUpdatedEvent had no accessors at all, and ConfigurationUpdatedEvent
  had only getNewConfiguration()/getOldConfiguration(), while the webhook
  listener calls getView()/getConfiguration() like every other event. Both
  dispatches were a fatal error; both tests doubled the event to invent the
  accessor. The events now carry the accessor their siblings do and the
  tests build real events.
- WebhookLog has a `webhook` property, not `webhookId`, so the retry job
  and the retry endpoint both threw out of Entity::__call. The job's test
  had built a TestWebhookLog subclass with an invented webhookId property
  to keep itself green. Callers now use getWebhook() and the crutch is gone.
- SchemaCacheHandler serialized `tags` and `register` off Schema, which
  declares neither, so every cache write threw; two tests even asserted the
  reconstruct path ALWAYS fails, pinning the bug as the behaviour. Both
  fields are gone and the reconstruct tests assert a rebuild.
- TextExtraction ObjectHandler fell back to getName() on Schema and
  Register, neither of which has a `name` property, for any entity without
  a title.
- Source has no `configuration` property, column or serialized field, only
  a leftover @method docblock, so all three Twig token functions and the
  REST fetcher's identifier lookup threw. The token functions now read
  authConfig, the flat credential map every other reader in the app uses.

tests/Unit/MockedCallSurfaceTest.php makes it mechanical: every
addMethods() name in the suite must be backed by a real property on a
class with __call, and a class without __call may not be given methods at
all. It fails with file, line, class and method, and carries a second test
so an empty scan cannot read as a pass.

* chore(mocks): inline the dead identifier lookup and satisfy the comment style gate
…here a right cannot be taken away (Q13.25) (#3698)

Gap register v3 (ConductionNL/market-intelligence #128) ends on two gaps
with no change. This is the openregister one.

Row 13.25 asks where a role's permissions come from: is the grantable set
published, and can a right be removed as well as given. Batch 12 of round
4 proposed it and left dossiq unrated, saying the permission resolver
needed reading end to end rather than a grep. That reading was done for
register v3 against dossiq development at 9c478d810, and the row rates no.

What exists here does not close it. rbac-scopes publishes six permission
types and supports named roles whose actions array names CRUD verbs, but a
custom verb exists only as a vote at evaluation time, so nothing can
enumerate what may be granted. GET /api/scopes answers what a user may do
and never where the grant came from. And every rule adds: the only way to
remove a right is to stop granting it somewhere else.

rbac-inherits-to-children put a deny out of scope in one sentence: no
competitor in the register has one. Huly ships nine Forbid permissions
beside its fifty-two, driven in batch 12. This change depends on that one
and completes the rule from the other side.

Four things: a published catalogue of grantable verbs, a declaration for
custom verbs beside the vote that evaluates them, a deny that wins over any
broader grant including an inherited one, and provenance on every answer,
including the deny that removed one. Administration cannot be denied away.

dossiq's half is named: the mandate matrix names its grantable set instead
of untyped decisionTypes and caseTypes strings, and the role editor gains
the permission half a role has never had. The catalogue unblocks that
halfway through, which is why it is built first.

No implementation. openspec validate permission-provenance-and-deny
--strict passes.
…dark suites wave 4) (#3700)

* test(controllers): resolve the six controllers from the container

ControllersIntegrationTest built every controller by hand, positionally,
so RegistersController gaining five dependencies errored all 101 tests
before their first assertion. They now come from the app container with
only IRequest and IUserSession overridden, and the override is restored
in tearDown so no later file inherits the doubles.

Measured in a real Nextcloud: 101 tests / 101 errors becomes 98 tests /
98 pass. Three tests were removed rather than skipped: they drove
SettingsController methods that ea99a50 deleted with the SOLR index.

Also fixes the PHP warning that surfaced once the tests ran: clearing the
object cache read a 'entries' key getStats() never had, so it reported 0
cleared and raised two warnings per call.

* test(objects): resolve ObjectsController from the container too

Same rot, same fix: 59 tests errored on argument 5 of a hand-built
constructor. The controller now comes from the container with IRequest,
IUserSession and IGroupManager overridden, and the group-manager double
answers isAdmin() from the same flag as getUserGroupIds(), because the
container hands it to SaveObject as well, whose isAdmin() returns bool.

Measured: 59 tests / 59 errors becomes 55 tests / 55 pass. Four tests
were removed rather than skipped: they drive publish()/depublish(), which
the deprecate-published-metadata spec removed in 68091c3.

* test(suites): a canary that fails when a container double is left behind

Two Service files override IRequest, IUserSession and IGroupManager on
the app container. An override is process-global, so a missing restore
would hand this file's doubles to every later file, and the failures
would surface as somebody else's problem. The canary sorts after both
and asks the container whether it is still answering with real services.

* style(tests): pass the container helpers and the canary assertion by name

The named-parameters sniff flagged the fourteen calls this branch added.
* fix(edepot): a schema-declared archival fact now reaches the SIP

Proven on a live instance before changing anything. A schema declaring
aggregationLevel, useRestriction and temporalCoverage, an object with no
per-object override, and the real transfer path building a SIP: the object's
GET carried all three facts, and the mdto.xml inside the package carried none
of them. beperkingGebruik fell back to "Nader te bepalen", the term that means
nobody recorded one, while the schema had recorded one.

The cause is that the annotation block is derived, not stored. RenderObject
computes it while rendering a response and nothing persists it, so a path that
loads an object straight from the mapper, which is what the transfer does, saw
no annotation at all. Everything #3661 shipped was a silent no-op on the one
path it exists for.

ObjectArchivalAnnotation derives it from the schema, reusing the same
RetentionEvaluator the render path uses, and MdtoSourceReader passes it down
as the third layer of its source chain. A stored block is honoured when the
caller already has one, so a rendered object costs no schema lookup, and
resolving several facts for one object costs one lookup.

The two alternatives were worse. Persisting the evaluated block adds a copy
that goes stale the moment a schema's annotation changes, which is the drift
this area has spent the week removing. Calling the render path from the
transfer path couples the two for a value that is a schema fact rather than a
presentation concern, and RenderObject reaches every object response.

Same instance, after the fix: aggregatieniveau Dossier, dekkingInTijd
2021-01-01 to 2021-12-31 resolved from the row's own properties,
beperkingGebruik "Geen beperking" with its description, and the document
still validates against MDTO-XML1.0.1.

* test(edepot): guard the SIP against losing a schema-declared fact

The guard is at the packaging level, not the reader's: SipPackageBuilder
through MdtoXmlGenerator, MdtoSourceReader and ObjectArchivalAnnotation, with
only the schema source and the temp manager stubbed. It opens the built
package and reads the MDTO document out of it.

That level is the point. Every unit test of the reader passed while the SIP
carried none of the facts a schema declared, because the reader was handed a
rendered object and the transfer path is handed a mapper-loaded one. The
mutation that reproduces the shipped defect, never deriving the annotation
from the schema, reddens this test and nothing else.

It also asserts the packaged document still validates, and that a recorded
restriction does not come out as "Nader te bepalen", the term that means
nobody recorded one.

Drops a dead branch in ObjectArchivalAnnotation: getSchema() returns ?string,
so the comparison against 0 could never fire.

* style(archival): give the memo uuid property its @var tag

* style(tests): pass arguments by name on the lines the SIP fix added

The named-parameters sniff flagged thirty calls this branch added or
changed, plus a file docblock after declare() and an undocumented property.
…Service suites (wave 5) (#3704)

* test(mappers): drop the retired published argument from the Schema and Register mapper tests

SchemaMapperIntegrationTest and RegisterMapperIntegrationTest still passed the
?bool $published parameter that 12927d3 (2026-03-13, "Remove
published/depublished metadata system in favor of RBAC \$now rules") deleted from
find(), findAll(), findBySlug(), findMultiple() and getSchemasByRegisterId().
Every call landed a null in the bool $_rbac slot, which is 35 TypeErrors.

The calls now name the flags they mean. RegisterMapper::findAll() also lost its
_extend parameter, so its tests drop that argument too.

Three tests changed meaning rather than shape:

- testFindBySlugWithPublishedParam and testFindWithPublishedBypass tested the
  removed parameter itself. Deleted.
- testCreateSchemasWithSameTitleThrowsUniqueConstraint expected a unique-index
  violation. Version1Date20260726000000 (2026-07-26) dropped
  schemas_org_app_slug_unique deliberately, because schemas are many-to-many
  with registers and "unique within a register set" cannot be a single-table
  index; the invariant moved to ImportHandler. Rewritten to pin what is true
  now: two schemas may share a slug and both stay retrievable.
- testCreateFromArrayGeneratesFacetForDateFormatProperty expected terms for a
  date-time property. Schema::determineFacetType() has answered date_histogram
  for a date format since 1367ae3 (2025-09-02), and the date-named-property
  test three lines above asserts exactly that. The test disagreed with its own
  neighbour.

SchemaMapper: 77 of 108 passing, 28 errors, 3 failures. Now 106 tests green.
RegisterMapper: 18 of 25 passing, 7 errors. Now 25 tests green.

* fix(audit): spell the retention interval per platform, as the search-trail twin already does

AuditTrailMapper::setExpiryDate() built DATE_ADD(created, INTERVAL n SECOND),
which is MySQL and MariaDB only. SearchTrailMapper::setExpiryDate() hit exactly
this when the hourly LogCleanUpTask started calling it and was taught to spell
the interval per platform; the audit-trail copy was left behind, so it would
throw on every PostgreSQL install the moment anything called it.

The integration test had been skipping on PostgreSQL with "DATE_ADD is not
supported", which reads like a platform limit and was really a defect hiding
behind a skip. The skip is gone and the test runs on both backends. Reverting
the platform branch reddens it with the PostgreSQL syntax error.

AuditTrailMapperIntegrationTest also called MagicMapper::insertEntity(), which
went with the blob objects table; insert() is the successor. 30 of 35 passing
with 4 errors and 1 skip before, 35 green after.

* test(rbac): assert the fail-closed rule on the emitter a cli process can reach

MagicRbacHandlerIntegrationTest carried three tests for the #1953 rule: a match
rule whose dynamic variable resolves to null MUST emit the impossible predicate
rather than dropping the condition. They drove searchObjectsInRegisterSchemaTable
and asserted on rows, and they could not pass under PHPUnit at all. That path
emits through applyRbacFilters(), which returns early for an anonymous caller in
a cli process (4496c7f, 2026-06-10), so the branch under test was unreachable
and every row came back.

They now assert the same rule on buildRbacConditionsSql(), the UNION path
emitter. It is production code, it carries no cli carve-out, and an anonymous
caller reaches it from a cli process exactly as from the web. The emitted SQL is
asserted directly: the impossible predicate appears for the null-resolved half,
the static half is still ANDed in rather than lost, and a fully resolvable rule
picks up no denial at all. Removing the fail-closed branch reddens the two
denial tests and leaves the over-denial guard green.

What stays uncovered from a cli process is the single-table QueryBuilder
emitter. Narrowing that cli trust is a product decision, not a test repair:
SystemOperationContext exists for the scoping, but cron.php does not define
OC_CONSOLE, so a naive narrowing would blind every background job. The file says
so where the tests are, and the PR raises it.

39 of 42 passing with 3 failures before. 42 green after.

* test(organisations): seed the offset test instead of skipping on an empty instance

testFindAllRespectsOffset skipped when the instance held fewer than two
organisations, so it only ever ran on a database someone else had dirtied. It
creates the two rows it needs now, the way the limit test beside it already
does. The Database suite has no skips left.

* test(services): repair five fixtures that fell behind their production signatures

None of these five changed behaviour, they changed shape, and each test was
constructing or seeding against a version of the code that no longer exists.

- AggregationRunner (15 errors): the fixture created a register and a schema and
  never linked them. Naming a register is a boundary now, so
  RegisterScopedSchemaResolver refuses to resolve a slug against a register that
  carries no schemas rather than guessing at a same-slug schema elsewhere. The
  fixture links them, which is what its own error message asked for.
- RelationHandlerCircuitBreaker (7 errors): RelationHandler gained a
  registerMapper dependency.
- RegisterI18nPhase2 (2 errors): LanguageMiddleware gained a translationMapper
  and a logger. Its request stub also pinned getParam to _translations, and the
  middleware asks for _lang first, so the stub answers "not given" to any name.
- MailSmartPicker (1 error): ObjectReferenceProvider takes an
  ObjectPreviewFormatter now, where the test still passed the five collaborators
  the formatter was extracted from.

AggregationRunner 0 of 15, now 15 green. RelationHandler 0 of 7, now 7 green.
RegisterI18nPhase2 10 of 12, now 12 green. MailSmartPicker 5 of 6, now 6 green.

* test(object-service): retire the publish tests, scope the two that name a register

Three tests drove ObjectService::publishObjects(), depublishObjects() and
publishObjectsBySchema(). Object-level published metadata was retired in
12927d3 (2026-03-13) in favour of RBAC $now rules and those methods went with
the routes, so the tests are deleted and the file says where visibility over
time is covered instead (RbacOperatorMatchingIntegrationTest).

Two more were casualties of 5e7471b (2026-08-22, "make {register} a hard
boundary on every path that names one"). ObjectService is a singleton, so
testSetSchemaWithSlug resolved its slug against whichever register the previous
test had left set, and testSetSchemaWithNonexistentIdThrows expected the old
unscoped ValidationException where the boundary now raises the more specific
SchemaNotInRegisterException. Both name their register first and follow the
contract.

59 of 64 passing with 4 errors and 1 failure before. 61 tests green after.

* test(text-extraction): follow the Word reader into its own handler, and the unknown-method contract

TextExtractionWordExtractionTest reflected into resolveWordReader() and
walkWordElements() on TextExtractionService. Both moved to
TextExtraction\WordExtractor with the extract-god-class-services split, and
extractWord() is a one-line delegation to it now. The tests reflect into the
handler that owns the code; the source-reading guard on the failure-path log
reads WordExtractor.php for the same reason.

TextExtractionServiceIntegrationTest expected detectEntities() to throw
"Unknown detection method". 35d3d46 (2026-06-18) put resolveMethod() in front
of the match: an unrecognised name resolves to the effective backend method and
falls back to regex with a warning, so the throw arm is unreachable for a
caller-supplied name. That is deliberate, a typo in a request parameter must not
500, and the test pins the resolution instead.

Word extraction 4 of 8 with 3 errors and 1 failure, now 8 green.
TextExtractionService 96 of 97, now 97 green.

* fix(dashboard): make recalculate sizes actually walk the magic tables

DashboardService::recalculateSizes() called MagicMapper::findAll() with
["register" => id, "schema" => id] as FILTERS. A magic table is one table per
register and schema pair, so findAll() requires the register and schema
ENTITIES and answers an empty array with a warning when it does not get them.
The method therefore processed zero objects on every install and reported
success while doing nothing, which is what the restored test caught.

It now walks the register and schema pairs, narrowed by whichever id the caller
named, and re-saves each object through the pair its table belongs to. An
unresolvable pair counts as one failure and is logged instead of taking the
whole run down. Walking no pairs reddens the test.

Three more tests in ServicesIntegrationTest were following moved or changed
contracts rather than broken behaviour:

- authorizeJwt, authorizeOAuth and authorizeApiKey became protected in 3fd738a
  (2026-05-28), the orphan-auth gate pass, and no public route drives them. The
  three tests reach them by reflection, because "a malformed credential MUST
  raise AuthenticationException" is worth keeping pinned.
- McpToolsService::callTool() throws InvalidArgumentException for a tool nobody
  owns and McpServerController maps that to a JSON-RPC error; the test expected
  an isError envelope that predates the mapping.
- listTools() pinned a hard count of three. Tools are contributed by providers
  and there are seven now, so the test asserts the three canonical tools and the
  shape of every entry instead of a number someone has to bump.

101 of 108 passing with 1 error and 6 failures before. 108 green after.

* test(permissions): follow the fail-closed rule and the eight-argument signature

Three failures in ObjectHandlersIntegrationTest, none of them a broken behaviour.

- testHasGroupPermissionActionNotInAuth asserted that an action a non-empty
  authorization block does not list is GRANTED. PermissionHandler denies it: the
  empty-block default is the only one that still grants, and admin and owner
  have returned before that point. Asserting the old default-open answer would
  be asserting a hole, so the test follows the rule.
- testHasGroupPermissionComplexEntry passed nine arguments where
  hasGroupPermission() takes eight. The active organisation stopped being an
  argument: ConditionMatcher resolves $organisation through OrganisationService.
  The test logs in, reads the organisation the matcher will see, and passes the
  object organisation it should match, restoring the half that proves a dynamic
  match can grant.
- testBuildSearchQueryPublishedFilter expected _published=true to be normalised
  to a boolean. Version1Date20260313130000 (2026-03-13) dropped the _published
  column from every magic table with the published metadata system and no code
  reads the filter, so the test is deleted and the file says where visibility
  over time lives now.

72 of 75 passing before. 74 green after.

* fix(settings): count the object-cache entries that exist, and stop pinning a fixed bug

Two tests in SettingsHandlersIntegrationTest asserted that clearCache() throws a
TypeError, and said so in their names: the distributed cache reports "all"
rather than a number for cleared, and summing that into an int used to be fatal.
The sum guards with is_int() now, so the tests were failing because the bug they
pinned had been fixed. They asserted the repair instead: the call returns its
envelope, totalCleared is an int, and no error is collected. Removing the guard
reddens both.

Repairing them surfaced the next layer. clearObjectCache() computed its cleared
count as $beforeStats["entries"] - $afterStats["entries"], and
CacheHandler::getStats() has no entries key: it reports cache_size,
query_cache_size and name_cache_size. Every call raised two "Undefined array
key" warnings and reported 0 entries cleared, so the admin action always claimed
it had cleared nothing. It counts the three in-memory caches clearCache()
actually empties.

75 of 77 passing with 2 failures before. 77 green after, and the run is warning
free.

* test(mcp,schemas): follow the objects guard to its provider, and build the controller by signature

McpToolScopingIntegrationTest reflected into a private
McpToolsService::executeObjects(). The objects tool is contributed by
Mcp\BuiltIn\ObjectsToolProvider now and its invokeTool() raises the same
"Both register and schema IDs are required" guard, one level below callTool()s
try/catch exactly as the spec scenario asks. The provider is public, so the test
calls it directly instead of reflecting.

RuntimeSchemaReloadTest hand-listed twelve constructor arguments for
SchemasController, which takes seventeen now, so all four tests errored. It
builds the controller from the container BY SIGNATURE and splices in its own
stubbed request: resolving the controller straight from the container would
inject the real IRequest, and these tests drive it through a stub. A dependency
added tomorrow is picked up without editing the test.

Three more things that file was hiding:

- POST and PUT on /api/schemas are gated on manage permission, so with no
  session the controller answered 403 and the cache-invalidation contract under
  test was never reached. The fixture logs in as admin and tearDown restores the
  session.
- Its request stub answered null to every getParam(), including the ones the
  controller passes a default for. A real IRequest returns the default, so
  show() got null where it expected an array and died in in_array(). The stub
  honours the default now.
- The delete-guard test passed a Register positionally into saveObject()s
  ?array $extend and reported the resulting TypeError as "object persistence not
  available", skipping itself. Named arguments, and the test runs.

McpToolScoping 6 of 7, now 7 green. RuntimeSchemaReload 0 of 4 with 4 errors,
now 4 green with no skips.

* test(graphql): turn the admin bypass off so the reference-existence tests can see it

The two rejection tests wrote as admin, and reference-existence validation has
an operator escape hatch: an admin skips it entirely unless
reference_validation_admin_bypass is off (SaveObject, default true). So a
dangling reference was accepted and the tests reported exactly that, "Update
silently accepted".

The file turns the flag off for its own run and restores the previous value in
tearDown, which is the same thing an operator does to enforce validation for
everybody. Skipping the validation call reddens both tests.

1 of 3 passing before. 3 green after.

* test(fixtures): stamp the real organisation, and put the one a test pins back

Three tests failed only inside the full Service run, all on the same tenant
scoping.

ServicesIntegrationTest::createTestObject() stamped the literal string
"default" in the organisation column, which holds an organisation UUID and so
matched nothing. Every read that applies the tenant filter skipped those rows,
and two tests counted zero objects they had just created. The production
SaveObject path stamps the callers organisation; the fixture does the same now.

It only showed up once the account had an active organisation, and that is the
second half: AggregationRunnerIntegrationTest calls setActiveOrganisation(),
which writes a PERSISTENT user setting, not session state. It changed the admin
account for every later test, every later file and every later run. The file
captures the previous value and restores it, deleting the setting when there was
none.

Note for whoever meets this next: OrganisationService::getActiveOrganisation()
also assigns and persists one when the account has none ("Auto-set active
organisation to oldest"), by design, so an instance that has ever read it has
one. That is the state a fixture has to be correct under, which is why the stamp
matters more than the restore.

Run together, AggregationRunner then RbacScopeDiscovery then Services: 2
failures before, 128 tests green after.

* test(scopes): build the fixture as the caller the test acts as

testAdminBypassReturnsAllFiveActions failed inside the full Service run with an
empty scope list. SchemaMapper::insert() stamps the active sessions
organisation on the row, and ScopesController::index() resolves schemas with
multitenancy ON, deliberately. The fixture was built under whatever user the
previous file had left in the process-global session, so the two schemas landed
in another tenant: the tuples existed and the admin could not see them.

setUp establishes the admin session before creating the fixture; tearDown
already restored the previous user.

* test(unit): follow recalculate sizes to the pair walk, and count the cache sizes that exist

The dashboard fix on this branch walks the register and schema pairs instead
of passing ids to MagicMapper::findAll() as filters, and the cache fix counts
cache_size, query_cache_size and name_cache_size instead of an entries key
getStats() never had. Twelve unit tests still pinned the old shapes.

The recalculate tests now assert which pair tables were read for each filter,
that an unresolvable pair counts as one failure, and that a pair listing that
throws is reported. Removing the register filter reddens the register filter
test on its assertion.
… changes (#3707)

* feat(watchers): the watchers table and the subscription service

Task 1.1 and 1.2 of object-watchers. A watcher is a per-user, per-object
subscription stored outside the object, so following writes no audit entry
and no version on the object it follows. The unique index on
(user_id, object_uuid) makes subscribing idempotent.

WatcherService is the one place the three permission postures live: read to
watch (already proven by resolving the object), update to see the list, and
manage (owner or administrator, per ADR-010 Rule 4) to change somebody
else's subscription. A watcher may always remove themselves.

* feat(watchers): the watch routes, the @self markers and the watching lens

Task 2.1 and 2.2. Five routes under the object, in their own controller
rather than in the 5,000-line ObjectsController: every one resolves the
object through ObjectService first, so an object the caller cannot read is
a 404 and no watcher row can be written without read.

@self.watching is set for any signed-in reader and omitted for an anonymous
one; @self.watcherCount only for a reader with update. Both come from
WatcherService's per-request memo, so a page of objects costs one query for
the marker and one for the counts rather than one per row.

_watching=true narrows _ids rather than joining the filter grammar, and an
empty subscription set falls back to a literal no object can carry, so the
lens can never widen to the whole register.

* feat(watchers): the watchers recipient block, resolved with a read check

Task 3.1. A rule declares {"watchers": true} on the schema, per ADR-031, and
the dispatcher resolves it: no app dispatches a watcher notification itself.
The validator refuses anything but boolean true, so a rule that quietly
addresses nobody is caught when the schema is saved rather than at midnight.

The read check runs at dispatch, not at subscribe time, because group
membership changes after people subscribe. A watcher who has lost read gets
nothing and the subscription is removed, so the audience stops carrying
people who are not in it. An empty readable-user list means the schema's read
rule is open or could not be resolved, which is not evidence anyone lost
access, so nothing is dropped there.

Deleting an object prunes its watchers, or a re-created uuid would inherit
followers who never chose to follow it.

* test(watchers): unit, Newman and Playwright coverage for the subscription

Task 4.1 and 4.2. Four unit suites (the service, the lens, the resolver, the
validator), a self-cleaning Newman collection for the routes, and a Playwright
spec admitted to the CI floor by tests/e2e/ci/playwright.config.ts.

The e2e proves the capability is reachable: routes registered, auth attributes
admitting a non-admin, markers on an ordinary read, the lens narrowing a list,
and an unreadable object answering 404 rather than 403. It deliberately does
not assert notification DELIVERY: that runs through the queue and a background
job, so the assertion would be a timing race rather than coverage. The
resolver's read check and list healing are asserted in
NotificationRecipientResolverWatchersTest, which the spec's exclusions now
name by test rather than saying coverage lives somewhere unnamed.

* style(watchers): named parameters on every new call site

phpcs CustomSniffs.Functions.NamedParameters on the lines this change added:
88 findings, all of them an argument passed positionally. Applied with a
token-aware rewriter rather than a regex so a comma inside a nested call or
an array literal could not be read as an argument separator, then verified by
re-running php -l, the four suites (32 tests, 53 assertions, green) and phpcs
on the same files (zero findings).

* style(watchers): wrap the watcher route entries under the line-length rule

* fix(watchers): bump the app version, and clear the phpmd findings this change added

Three things check:strict caught that the diff check could not.

The migration would never have run. Nextcloud executes an app's migrations only
when <version> is greater than the installed_version it recorded, so a new
migration with an unmoved version makes `occ upgrade` answer 'No upgrade
required', exit 0, and create no table. Nothing would be logged and nothing
would fail; the first symptom would have been every watcher endpoint answering
500 on an instance that already had the app.

The entity's two new getters had no caller, and pushed ObjectEntity one over
PHPMD's public-member ceiling. The render layer reads the properties directly,
so they are gone rather than suppressed.

The prune listener now removes subscriptions through WatcherService rather than
reaching past it to the mapper, so 'what happens to a subscription when its
object goes' has one definition. The lens's lookup moved into its own method,
which is what its complexity was actually measuring. The two remaining PHPMD
findings on WatcherService carry reasons rather than a baseline entry.

Verified: phpmd both rulesets exit 0, the four new suites 32 tests / 53
assertions green, diff check GREEN (php -l, phpcs, phpstan, phpunit, eslint all
0 new).
…me (#3709)

* spec(discovery): the rules engine gets an operator surface

Cluster 19 of the round 4 discovery build plan, 17 candidates, decision D3
option 2 with the JSON AST for computed values. The three rule changes in
flight carry the vocabulary; this carries the inventory, the run log, the
ceiling, the dry run and the replay.

* spec(discovery): object dates publish a calendar feed

Cluster 9 of the round 4 discovery build plan, candidates C-deadlines-22
and C-communication-52, decisions D11 (publish the feed first) and D5. The
strongest single capability in the sweep: thirteen passers, twelve driven.

* spec(discovery): a bulk action is a job with a preview, progress and skips

Cluster 52 of the round 4 discovery build plan, eight candidates, five of
them must, no decision blocking. Kept in wave 1 under D6: the promotion
bar is relevance-led, so three driven passers is not a reason to drop it.

* spec(discovery): an object carries a read state per user

Cluster 62 of the round 4 discovery build plan, six candidates, seven
driven passers, dossiq no on all six. The bell empties because the work
was done, which is dimpact-zac's idea and nobody else's in the corpus.

* spec(discovery): code lists get a hierarchy, a validity window and fields of their own

Cluster 6 of the round 4 discovery build plan, 20 candidates, three of
them must, ledger rows 11.34, 11.40, 11.44 and 11.45. Builds on
property-code-list-from-concept-scheme, which stays as written.

* spec(discovery): the delete window is stated and the destruction is recorded

Cluster 39 of the round 4 discovery build plan, five candidates, four of
them must. Decision D10 as taken: no new recycle state, the existing soft
delete carries this, beside object-archive-state, and dossiq's delete
guard stays as it is.

* spec(discovery): archiving is a process with sign-off, and it lives here

Cluster 43 of the round 4 discovery build plan, 17 candidates, twelve of
them must. Decision D7 as taken: option 1, and the owner moved from filinq
to openregister, because the evidence has to sit where the objects are.
filinq keeps the formats, dossiq keeps the resultaattype.

* spec(discovery): publish the property vocabulary the validator already accepts

Cluster CT-1 of the round 4 case-type depth study, rows A1, A2, A3, A5,
A9, A11, A12, A13, B7 and B10. The openregister half: twenty types and six
constraint keys are validated and nothing publishes them, so every leaf app
writes a shorter list. Also records that x-openregister-extends-form uses
the platform namespace and the platform does not define it.

* spec(discovery): the JSON AST becomes the administered computed-value engine

Cluster CT-3 of the round 4 case-type depth study, row B2, ledger row
3.17. Decision D3's second half: the AST is auditable, diffable and safe
by construction, and Twig stays for schemas authored in code.

* spec(discovery): roles, provenance, deny and access inside the query

Clusters 11 and 54 of the round 4 discovery build plan, 24 candidates,
fifteen of them must, six matrix holes. Decision D22 option 1 names this
change, so it is extended rather than duplicated: the filter compiles into
the query, the record says what you may do, provenance reads both ways,
and a grant may be derived, scoped and given an end.

* spec(discovery): field rules gain a condition, states gain entry and exit, a choice needs a source

Clusters CT-2 and CT-4 of the round 4 case-type depth study. Both existing
changes are named by the build plan as the vehicle, so they are extended:
a rule may read any declared property and may make a field required (the
two gaps D3 names), a state carries entry and exit conditions, and a
choice property with no source of values is refused at schema save.

* spec(discovery): index wave 1 on the competitor parity umbrella

Nine new changes and three extensions, with cluster, candidates, size,
decision and dossiq consumer per row, the four decisions that differ from
the recommendations, the build order, and the extends-form finding.
…t is on (#3711)

* feat(timeline): every timeline entry says which side of the counter it is on

A note now carries a visibility of internal or public, stored in the
comment's own metadata, internal by default and internal when absent, so
a note written before the flag existed can never reach a citizen. Setting
or changing it takes update on the object, and a change is written to the
object's audit trail naming the note and both values.

Every merged feed row carries the flag too. NC Activity rows are always
internal. A caller without update on the object is served the public view
whatever it asks for, on the notes endpoint, the activity feed and the
generic dispatch of the two timeline leaves, so dropping the query
parameter is not a way around it.

* test(timeline): the default, the guard, the audit entry and the enforced filter

* test(timeline): the e2e spec for the four visibility scenarios, and tick the tasks

* style(tests): the coding standard on the lines this change adds

* refactor(notes): update() hands the decision to a helper, so it stays inside the complexity the standard allows
…3714)

* feat(calendar): validate every working-calendar write, and refuse a delete open timers depend on

WorkingCalendar::fromArray() was already the validating constructor, but it
only ran at ARM time. A calendar written through the objects API, the admin
page or a configuration import was accepted on save and refused days later on
an unrelated timer.

Two listeners on the object lifecycle close that. The validation listener runs
the same validator on ObjectCreatingEvent and ObjectUpdatingEvent for a
working-calendar object, so all three doors refuse identically with the
validator's own message (422). The delete guard refuses a calendar an armed or
suspended timer names, with the count and up to ten uuids; the delete handler
now honours a status a hook names, so that refusal reads 409 rather than a 422
telling the caller to fix a body that has nothing wrong with it.

The descriptor grows an authorization block on the register and both schemas
(read authenticated, create/update/delete administrator) and the repair step's
version moves to 1.1.0, without which the block would land on fresh installs
only.

* feat(calendar): the working calendar becomes an administered thing

The engine half has existed since flow-business-timers: a calendar is an
object in the flow-timers register and WorkingCalendarService resolves it.
Nothing administered it. The gap register's note on row 8.12 counted five
private implementations of a working day in the fleet, four different
definitions of one, and a grep for a holiday settings screen that returned
nothing.

This adds the surface, without adding a second source of truth.

A working calendars section on the OpenRegister admin settings page lists
every calendar and edits the working week, the hours in a day, the rules, the
closure days and the owning organisation. It writes through the objects API
like any other client and keeps no store of its own, so the validation the
form meets is the validation a script meets.

One endpoint of its own, and it is a read: POST /api/flow-timers/calendars/
preview runs the rules of an UNSAVED definition over a year and returns the
dates, so a rule can be read before it is committed to. It stores nothing.

The form carries the notice that armed deadlines keep the dates they were
given. Recomputing them belongs to calendar-change-recomputes-timers.

Tests. The arithmetic is pinned across the 2026/2027 year boundary, which is
the case a tabulated calendar gets wrong in the most expensive way: a term
that starts in December has to consult two years' holidays in one walk, and a
table that ran out reports a January full of working days with no error
anywhere. Every 2027 moving feast is pinned by a term that has to step over
it, because the competitor corpus found five fleet engines disagreeing on
exactly Goede Vrijdag, Tweede Paasdag, Hemelvaartsdag and Tweede Pinksterdag
and agreeing on the fixed dates. Koningsdag 2026 asserts the Sunday rule does
NOT fire on a Monday, which a 2025 fixture cannot catch.

* feat(calendar): validate every working-calendar write, and refuse a delete open timers depend on

WorkingCalendar::fromArray() was already the validating constructor, but it
only ran at ARM time. A calendar written through the objects API, the admin
page or a configuration import was accepted on save and refused days later on
an unrelated timer.

Two listeners on the object lifecycle close that. The validation listener runs
the same validator on ObjectCreatingEvent and ObjectUpdatingEvent for a
working-calendar object, so all three doors refuse identically with the
validator's own message (422). The delete guard refuses a calendar an armed or
suspended timer names, with the count and up to ten uuids; the delete handler
now honours a status a hook names, so that refusal reads 409 rather than a 422
telling the caller to fix a body that has nothing wrong with it.

The descriptor grows an authorization block on the register and both schemas
(read authenticated, create/update/delete administrator) and the repair step's
version moves to 1.1.0, without which the block would land on fresh installs
only.

* feat(calendar): the working calendar becomes an administered thing

The engine half has existed since flow-business-timers: a calendar is an
object in the flow-timers register and WorkingCalendarService resolves it.
Nothing administered it. The gap register's note on row 8.12 counted five
private implementations of a working day in the fleet, four different
definitions of one, and a grep for a holiday settings screen that returned
nothing.

This adds the surface, without adding a second source of truth.

A working calendars section on the OpenRegister admin settings page lists
every calendar and edits the working week, the hours in a day, the rules, the
closure days and the owning organisation. It writes through the objects API
like any other client and keeps no store of its own, so the validation the
form meets is the validation a script meets.

One endpoint of its own, and it is a read: POST /api/flow-timers/calendars/
preview runs the rules of an UNSAVED definition over a year and returns the
dates, so a rule can be read before it is committed to. It stores nothing.

The form carries the notice that armed deadlines keep the dates they were
given. Recomputing them belongs to calendar-change-recomputes-timers.

Tests. The arithmetic is pinned across the 2026/2027 year boundary, which is
the case a tabulated calendar gets wrong in the most expensive way: a term
that starts in December has to consult two years' holidays in one walk, and a
table that ran out reports a January full of working days with no error
anywhere. Every 2027 moving feast is pinned by a term that has to step over
it, because the competitor corpus found five fleet engines disagreeing on
exactly Goede Vrijdag, Tweede Paasdag, Hemelvaartsdag and Tweede Pinksterdag
and agreeing on the fixed dates. Koningsdag 2026 asserts the Sunday rule does
NOT fire on a Monday, which a 2025 fixture cannot catch.

* chore(release): 2.1.20-unstable.20260914110204

The seed repair step's REGISTER_VERSION moved to 1.1.0, so the app
version has to move too. Without it occ upgrade answers "No upgrade
required." and the new working-calendar descriptor never reaches an
instance that already ran the step.

* fix(calendar): the two findings check:strict caught on this branch

phpmd read an else expression in the validation listener. The chain
becomes three guard clauses, which is the shape the delete listener
already uses and reads the same way.

FlowTimerRepairStepsTest pinned the descriptor version at 1.0.0. The
seed step now passes 1.1.0, so the expectation moves with it.

* fix(calendar): clear the findings the gates and phpcs read on this diff

psalm lost the event type once the else went, because setErrors() is
declared on each write event rather than on a shared parent. The body
moves into validate(), which takes the union explicitly, and handle()
stays two guard clauses.

The watcher body carries its own @SPEC exclude: gate 16 reads handler()
as a method of its own, not as part of the watcher it sits in.

The importFromApp expectation is spread over several lines and names
the callback parameter, so the line this change had to touch no longer
breaks the line-length and named-parameter sniffs.

* chore(release): 2.1.21-unstable.20260914121746

The third merge of development brought its own stamp, which left this
branch level with development and moved nothing. The seed step's
REGISTER_VERSION is at 1.1.0, so the app version has to move or occ
upgrade never reads the step. This stamp sits above every open release
branch, so whichever merges first the version still goes forward.
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.

3 participants