Skip to content

Release: merge development into beta - #1711

Merged
rubenvdlinde merged 3361 commits into
betafrom
development
Aug 20, 2026
Merged

Release: merge development into beta#1711
rubenvdlinde merged 3361 commits into
betafrom
development

Conversation

@github-actions

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.

@github-actions
github-actions Bot requested a review from a team as a code owner May 23, 2026 08:09
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
The standing 'Release: merge development into beta' PR (#1711) has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened.

Same fix as openconnector#1158.
rubenvdlinde added a commit that referenced this pull request Aug 6, 2026
…ne (#2361)

The standing 'Release: merge development into beta' PR (#1711) has head_ref
'development', so its pull_request run rendered the same concurrency group as a
push to development. cancel-in-progress killed the push run, which is the only
carrier of the push-only jobs (Coverage Baseline Check, SBOM, Features
Extract). Those jobs report 'skipped' on the surviving PR run, which renders
like a pass, so the gate never produced a verdict.

Suffixes -push on the group for main/development pushes only; feature-branch
dedup is unchanged. No gate weakened.

Same fix as openconnector#1158.
rubenvdlinde and others added 2 commits August 14, 2026 19:21
Third scoping, and the pattern is the lesson: each widening of what to check
found another honest file, never a defect.

  1. every tracked .json  -> tsconfig/eslint JSONC
  2. lib/**/*.json        -> openbuild ships an app TEMPLATE, .vscode included
  3. now: composer.json, package.json, appinfo/, lib/Settings/

What is left is what OpenRegister actually loads, which is where a bad merge
breaks something. Everything else was a gate failing on correct files — and a
gate that does that gets switched off, taking the working checks with it.
…2494)

41 publicly reachable methods carried no rate limit. 40 of them declare
themselves public with the LEGACY @publicpage ANNOTATION rather than the
#[PublicPage] attribute, which is why the fleet sweep that reported this app
fully throttled did not see them: that sweep line-anchored the attribute form
and excluded docblock matches.

The annotation is not a docblock mention. It is a live declaration, proven
against the running server rather than argued:

    POST /apps/openregister/api/graphql   (no auth) -> 200
    {"data":{"__typename":"Query"}}

AUTHORISATION IS NOT THE GAP, AND SAYING SO NEEDED A CONTROL

An anonymous caller reaching these endpoints does not get data:

    GET /api/objects            anon: 0 results     admin: many
    GraphQL totalCount          anon: 0/0/0/0       admin: 1/2/10/221
    FilesController::delete     calls ensureObjectAccess() (ADR-005, gate-7)

That distinction only exists because the anon-vs-admin comparison was run.
"totalCount: 0" on its own reads equally well as an empty register and as
correct filtering.

What remains exposed is reachability without a ceiling, plus GraphQL schema
introspection: an anonymous caller can enumerate 2,292 queryable connections
and drive a complexity-bounded query engine without limit.

CHOICE OF CONTROL

AnonRateLimit only, not BruteForceProtection. These endpoints check no
credential, and brute-force protection without a paired registerAttempt() is
the inert half of a two-half mechanism - a mistake already made once in this
programme. AnonRateLimit also leaves authenticated server-to-server traffic
untouched, so integrations cannot be throttled by this change.

Limits follow the values already in use in this app: reads 120/60, writes
30/60, GraphQL 30/60 anon + 240/60 user, health 240/60 (a ceiling that trips
on a normal probe cadence turns the health check into the outage it detects),
login 20/60.

UserController::login already has SecurityService::checkLoginRateLimit(),
keyed username + IP with a progressive delay - finer-grained than anything an
attribute can express. The attribute bounds what reaches that logic; it is not
a claim that the login was unprotected. That distinction is written at the
call site, because getting it wrong is exactly the correction openconnector
#1251 had to make.

Verification: php -l clean on all 11 files; diff is 61 added lines and 0
removed; gate-82 (.github#460) goes 41 findings -> 0 on this tree.

Not changed here: whether anonymous GraphQL introspection should be available
at all. Disabling it alters an API contract and is a product decision, not a
throttling fix.
Comment on lines +25 to +44
runs-on: ubuntu-latest
# Observed fleet-wide across 170 executions: median 0.5 min, max 3.2 min
# (app-versions). No job in this workflow declared a timeout, so a hang ran
# to GitHub's 6-hour default. 20 min leaves room for a slow `npm ci`.
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci --no-audit --no-fund

- name: Validate specs (json-strict + manifest + register)
run: npm run check:specs
Comment on lines +9 to +10
uses: ConductionNL/.github/.github/workflows/sync-to-beta.yml@main
secrets: inherit
rubenvdlinde and others added 24 commits August 14, 2026 22:18
…ed it

openregister.iterate is registered, dispatched and documented, and carried no
test at all — while its sibling LoopNode, which only splits a list into
batches, had one. The asymmetry matters because the two are easy to confuse by
name: the one with tests is not the one that loops.

Pins the contract a flow author depends on:

  * the body runs once per iteration, in declared order, each step fed the
    previous one's output;
  * the loop stops the moment the source returns nothing — the single
    termination rule, which is what makes pagination fall out for free
    (a page past the end IS the empty answer);
  * context['iteration'] carries index and first, so the source can ask for
    the next page;
  * maxIterations bounds it and onLimit decides whether overrunning is a
    failure or a quiet stop. That pair is the difference between a while and
    a for, expressed in one node — which is the answer to 'does the engine
    support both'. It does, and now it is measured.

Controlled: mutating two expectations turned exactly those two assertions red
(the iteration-index sequence and the bounded-for count), and restoring them
returned 5/5, 8 assertions.

Run with tests/bootstrap.php inside the container — bootstrap-unit.php cannot
build an OCP\IL10N mock, which is why the whole Flow suite errors there.
…one (#2496)

FILES TRANSFERRED OWNERSHIP AND FOLDERS NEVER DID.

transferFileOwnershipIfNeeded had two live call sites (CreateFileHandler:216,
UpdateFileHandler:500). transferFolderOwnershipIfNeeded had none - four
commented TODOs in FolderManagementHandler. So a register's files ended up
owned by the OpenRegister user while the folder containing them stayed owned
by whoever created it: two halves of one register with different owners, and
when that user is deleted, different lifetimes. The folder also counted
against an individual's quota.

THE TODOS COULD NOT HAVE BEEN DONE AS WRITTEN

Each said "Call $this->fileService->transferFolderOwnershipIfNeeded() once
FileOwnershipHandler is extracted". The extraction happened long ago, but the
FACADE EXPOSED NEITHER ownership-transfer variant, so that literal call would
have been a fatal. This adds the missing delegation on FileService and then
makes the four calls.

The sharing handler is passed explicitly because the ownership handler's own
docblock requires it: without it the transfer moves the folder away from the
current user WITHOUT sharing it back, which takes their access away instead of
preserving it. That is the difference between this working and this locking
users out of their own registers.

Safe to run on every folder creation: the method catches Exception, logs, and
deliberately does not rethrow ("Don't throw the exception to avoid breaking
folder operations"), so a transfer failure degrades to a log entry. Checked
BEFORE wiring it into four hot paths - a method that threw would have turned
every folder create into a potential 500.

Scope: applies to folders created from now on. Folders already created under
the old behaviour keep their original owner; a backfill is a migration and
belongs in its own change.

Verification: php -l clean; 4 TODOs to 0 with 4 live calls; tests/Unit/Service/
File 319 tests green; full unit suite 16,440 tests and 36,774 assertions with
0 failures on PHP 8.4 in the container.

Closes #2495.
The branch's base was 9 commits behind, and phpstan said so in a way that
reads as a defect in this PR: FlowRunService::$stateMapper 'is never read,
only written'. On development it IS read, twice. A stale base reddens a PR
while development is green.
phpstan: 'FlowRunService::$stateMapper is never read, only written.' It was
right, and it only became true on this branch: the state reads moved into
FlowStateBinding, which the constructor builds from the mapper, so the
promoted property survived with nothing left to read it.

Demoted to a plain parameter. The binding still receives the mapper; the
class simply stops holding a second reference it never consults.
* fix(flow): a wait must not suspend the run on an EMPTY firing

Suspending is a RUN-level act, not a branch-level one — `FlowSuspension`
stops the whole run and stores its marking — but `WaitNode` threw it
unconditionally on its first pass, including when it fired with no items at
all.

A transition fires empty routinely: a gate sent every item down another
branch, or that branch had no work this pass. In a flow whose branches are
ALTERNATIVES that is harmless. In one whose branches are PRIORITIES it loses
the work outright.

That is how it was found. Hydra's sequencer checks for an already-running
stage before it considers starting a new one, so a tick that finds one routes
its single item to the collect branch and leaves the dispatch branch empty.
The empty branch's wait suspended the run before the branch holding the item
could advance; on resume the marking had moved on, and every remaining
transition fired empty. The run log read as a clean pass: forty transitions,
all `completed`, all `in=0 out=0`, no error anywhere — while the item had
simply ceased to exist.

Nothing is skipped by returning early. With no items there is nothing to
delay, and a later pass that DOES carry items reaches the node and suspends
then. The test pins both directions, and the positive control is the point:
without an assertion that a wait carrying items STILL suspends, the same
suite would pass against a node that had stopped suspending entirely — which
would make every wait in every flow a silent no-op. Verified by mutation:
restoring the unconditional throw fails exactly the two empty-firing tests
and leaves the control green.

This is the same class of defect as the empty-firing item loss fixed in
#2489: an empty firing is not a normal firing with zero rows, and treating it
as one destroys state that no log line reports.

* spec(flow-engine): the empty-firing suspend rule, and point execute() at it

gate-16 was right and it was mine: comparing the gate sets base-to-PR, this
branch INTRODUCED spec-coverage — one changed method with no @SPEC — while
gate-7, gate-25 and gate-26 fail identically on development and are not.

There was no canonical requirement to point at. Wait and suspend had none at
all, so rather than cite a change directory this writes the rule where it
belongs: suspending is a RUN-level act, an empty firing has nothing to wait
for, and pausing on one stops the branch that did carry an item. The three
scenarios are the three the unit test pins, including the positive control —
a wait carrying items must STILL suspend, without which the rule reads as
permission to never wait at all.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…at-node proof (#2497)

* feat(schemas): resolve schema slugs within their register

Schema slug resolution was instance-global, so two registers could not
own a schema of the same slug. Scope resolution to the register and add
the openspec change describing it.

* feat(flow): resume state per node, and a way to wake a run waiting on a signal

The engine could already suspend and resume: the marking is held, items and
context are stored, FlowToken is rehydrated. But WaitNode was the ONLY thing in
the codebase that ever threw FlowSuspension, and all a resumed node learned
about itself was `context.resuming` — a boolean that is true for every node
downstream of a suspension.

That is enough for a wait, whose wait is over by construction, and nothing else.
A crawl parked on a rate limit has to come back to its page; measured 08-13,
three runs of a twelve-shard crawl 65s apart each returned the same 641
repositories, because every run began again at the first shard.

So: FlowResumeState, keyed per node, persisted alongside items and rehydrated
with the token. The dispatcher scopes it to whichever node it is about to call,
so a node reads its own progress without naming itself and cannot reach
another's — a flow with two sync nodes would otherwise have them overwrite each
other's page, and only the second one would be wrong.

The slot lives from a suspension to the resume that answers it and no longer:
the dispatcher clears it when a node RETURNS, so a second pass through the same
node inside a loop is not handed a finished node's cursor.

Also fixes a live bug rather than only adding to it. FlowSuspension has always
documented a null resumeAt as "waits for an external signal", and nothing could
deliver one: findDue() excludes null resume_at, findStale() reads only
`running`, and no endpoint existed. Worse, hasActiveRun() counts `suspended`, so
such a run also stopped its flow from ever being scheduled again — the
documented case silently retired the flow that used it. Now there is
POST /flow-runs/{uuid}/resume behind the same ownership check as retry, the
payload is consumed by the walk it wakes so two awaiting steps cannot read each
other's answer, and the worker reaps signals that never arrive rather than
leaving the flow shut.

AwaitSignalNode is the human-action case: it suspends until someone approves or
rejects, carries the decision onto every item so later steps can route on it,
and treats a resume with no decision as a nudge rather than an answer. It also
heartbeats, because a delivery can fail or arrive before the run has suspended,
and either would lose the only wake-up it was going to get.

Verified: 951 Flow unit tests green. The clear-on-return line was mutated out to
confirm its test discriminates — exactly one test went red, and the right one.

* refactor(flow): split flow-state handling out, and repair 84 tests pinned to a moved signature

QUALITY GATES. The resume-state work pushed three phpmd thresholds: FlowRunService
to complexity 54 and execute() to exactly 100 lines, FlowRunMapper to exactly 50.
A positive control confirmed they were mine — phpmd on the pre-change copies of
both files reports nothing.

Fixed by splitting rather than suppressing. Flow state moves to FlowStateBinding,
which is a real cohesion win rather than a lint dodge: the token dies with its
run, flow state outlives every run of the flow, and a class handling both
lifetimes is how you end up with a per-run COPY of flow-level state that a
resumed run restores over whatever later runs had written. execute()'s context
assembly moves to nodeContextFor(), where the three handles can be documented
against each other.

FlowRunService's CONSTRUCTOR IS DELIBERATELY UNCHANGED. Three test suites
construct it explicitly, and the binding needs nothing the service was not
already given, so it is built in the constructor body. Inserting a parameter
would have shifted every later slot for those suites — which is exactly the bug
the rest of this commit repairs.

FlowRunMapper stays baselined: it is a bag of sixteen query methods, each
irreducibly worth at least one point, and one more reaper query is the straw.
The rule is already baselined 31 times in this repo, so this is the project's
own mechanism, not an invention. Refactoring a mapper the worker depends on to
buy one point of lint would be the worse trade.

84 PRE-EXISTING RED TESTS, none of them mine. `SchemasController::__construct()`
gained `$registerMapper` at ARGUMENT #5 in 3a37619 — inserted, not appended —
so every later argument shifted and the MagicMapper mock landed on the
RegisterMapper parameter. 75 errors in SchemasControllerTest alone, and the
TypeError names the production constructor rather than the test, which is why
they sat there. Also carried the CyclomaticComplexity entry that commit left for
resolveSchema(): splitting it traded one violation for TooManyMethods, so the
honest record is a baseline entry rather than churning another feature's code.

Verified: phpmd exit 0 across lib/, phpcs exit 0 across lib/, 951 Flow unit tests
green, full unit suite 109 errors -> 25. The remaining 25 are missing test
FIXTURE classes (Doriath, AppHost) — a local autoload gap, not a code failure,
and untouched here because local and CI are different layers.

* ci: this branch has been running without checks, and so has every `feat/` branch

Same hole as openconnector, found by checking rather than assuming it was that
repo's bad luck — and here it is worse, because it is hitting right now.

Code Quality's push filter is an allow-list of branch prefixes. It lists
`feature/**`. This branch is `feat/register-scoped-schema-slug-resolution`.
`feat/` matches nothing, so every push to it — including three commits of flow
resume-state work today — ran no CI at all. The branch's status was not red or
green; there was nothing there, which on every dashboard reads the same as fine.

An allow-list of branch NAMES fails silently by construction. A missing prefix
is indistinguishable from a passing build unless someone goes looking for a run
that was never created.

`merge-hygiene.yml` (shared with openconnector) runs on `**` and checks only what
takes seconds: no committed conflict markers, every PHP file parses, every JSON
file parses. That is the check that would have caught openconnector's conflicted
merge within seconds of the push — verified against the offending commit, where
it finds all eight markers.

Code Quality keeps its allow-list, with `feat/**` and the other live prefixes
added, because it is expensive. The comment says plainly that this is not the
durable fix: branch protection requiring a PR into development is, and the
pull_request trigger already gates that correctly.

* perf(bulk): the existence probe reads every column to answer a yes/no question

Before each chunk the bulk upsert asks which uuids already exist, so rows can be
classified created-vs-updated:

    SELECT * FROM <magic table> WHERE _uuid IN (?, ... 500 times)

The full rows are not gratuitous — they populate `$preUpdateRows`, which gives an
audit entry or an update event its old-vs-new changeset. But a caller with both
switched off reads nothing except the presence of the uuid, and every
synchronisation is such a caller. That is why `sideEffects` measures 0 ms on that
path while the probe still drags every column across.

It changes the PLAN, not just the byte count. Measured on this instance:

    SELECT *      Seq Scan,        width 2347   1.574 ms / 100 rows
    SELECT _uuid  Index Only Scan, width    6   0.537 ms / 100 rows

The narrow form never touches the heap — it is answered from the `_uuid` index
that already exists. The ratio grows with row width: the benchmark table is
2.3 KB a row, while real tables here run to 4.2 KB (84 MB / 19,822 rows), and the
contract table is 453,126 rows / 373 MB.

HOW BIG IS IT, HONESTLY. At the query level it is ~3x. End to end it is about
1%: 500-row chunks make the probe ~7.9 ms wide against ~2.7 ms narrow, so four
chunks save ~20 ms of a ~2,000 ms bulk write. A paired A/B could not resolve it —
wide 3026/1715 ms against narrow 1973/2045 ms, with a ±600 ms spread for one
config. So this is committed as a correct, cheap read that will matter on wide
tables and under load, NOT as a measured end-to-end win. Anyone re-measuring it
needs n≥5 paired runs to see past the noise on this instance.

The flag is threaded through four layers because only `SaveObjects` knows whether
events or audit will run; every layer below would have to guess. It is APPENDED
last at each level and defaults to TRUE, so a caller that says nothing keeps the
wide read and keeps its changeset. `$preUpdateRows` is left EMPTY on the narrow
path rather than filled with uuid-only rows — a changeset built from those would
read as "every field became null", which is worse than having none.

⚠️ THIS SHIPPED BROKEN ONCE, IN THE TEN MINUTES BEFORE THIS COMMIT. The new local
was called `$columns`, which is already the table's column list for the INSERT
below; shadowing it with a string made `implode()` a TypeError and 500'd every
bulk save. The benchmark reported `fetched=0` and a clean-looking run, and only
the exception log said why. Renamed to `$existsColumns`, and the test asserts
both the new name and the ABSENCE of the old one.

Verified: 491 tests green, phpcs 0 errors, phpmd clean (the two boolean-flag
findings carry justifications — the flag is a fact about the caller, not a mode).

* ci: scope the marker check to code — prose that documents a conflict is not one

openbuild failed this gate on an agent-eval artifact,
.claude/skills/create-pr/evals/.../summary.md, which legitimately CONTAINS
conflict markers as captured sample output. A correct file.

That matters more than the miss it allows: a gate that fails on correct files
gets switched off and takes the working checks with it, while a marker in a
markdown file breaks nothing. Now scoped to code extensions, excluding .claude,
evals and fixtures.

Positive control re-run: still finds all markers in the merge this gate was
built for (12), still clean on HEAD.

* ci: the JSON check reached a template's editor settings

Third scoping, and the pattern is the lesson: each widening of what to check
found another honest file, never a defect.

  1. every tracked .json  -> tsconfig/eslint JSONC
  2. lib/**/*.json        -> openbuild ships an app TEMPLATE, .vscode included
  3. now: composer.json, package.json, appinfo/, lib/Settings/

What is left is what OpenRegister actually loads, which is where a bad merge
breaks something. Everything else was a gate failing on correct files — and a
gate that does that gets switched off, taking the working checks with it.

* test(flow): the repeat node is the engine's while, and nothing asserted it

openregister.iterate is registered, dispatched and documented, and carried no
test at all — while its sibling LoopNode, which only splits a list into
batches, had one. The asymmetry matters because the two are easy to confuse by
name: the one with tests is not the one that loops.

Pins the contract a flow author depends on:

  * the body runs once per iteration, in declared order, each step fed the
    previous one's output;
  * the loop stops the moment the source returns nothing — the single
    termination rule, which is what makes pagination fall out for free
    (a page past the end IS the empty answer);
  * context['iteration'] carries index and first, so the source can ask for
    the next page;
  * maxIterations bounds it and onLimit decides whether overrunning is a
    failure or a quiet stop. That pair is the difference between a while and
    a for, expressed in one node — which is the answer to 'does the engine
    support both'. It does, and now it is measured.

Controlled: mutating two expectations turned exactly those two assertions red
(the iteration-index sequence and the bounded-for count), and restoring them
returned 5/5, 8 assertions.

Run with tests/bootstrap.php inside the container — bootstrap-unit.php cannot
build an OCP\IL10N mock, which is why the whole Flow suite errors there.

* refactor(flow): stateMapper is a constructor argument, not a property

phpstan: 'FlowRunService::$stateMapper is never read, only written.' It was
right, and it only became true on this branch: the state reads moved into
FlowStateBinding, which the constructor builds from the mapper, so the
promoted property survived with nothing left to read it.

Demoted to a plain parameter. The binding still receives the mapper; the
class simply stops holding a second reference it never consults.

* docs(flow): point the resume-state work at the specs it already had

gate-46 was right: AwaitSignalNode cited
openspec/changes/or-flow-resume-state/specs/flow-resume-state/spec.md, a
change directory that does not exist in this repository. gate-16 was right
too: the resume-state classes carried no @SPEC at all.

⚠️ I nearly fixed both by WRITING the requirements, and they already existed.
Listing the flow-engine requirements with `head -12` showed the first twelve
of twenty-one, and the two that mattered are at lines 708 and 759 — 'A node
MUST be able to resume from where it stopped' and 'A run suspended on an
external signal MUST be reachable'. I added duplicates of both before
checking the whole file, which would have left the spec asserting the same
rule twice in two wordings — the shape that makes a spec stop being an
authority. Removed; the 50 lines are gone and the count is back to 19.

So this only points at what was there: the resume-state classes at the
resume requirement, AwaitSignalNode at the signal one. Verified by resolving
every @SPEC target on the branch's nine new files against the actual headings
— 0 unresolved.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
`clearLogs()` tombstones an expired audit row by blanking six columns to NULL.
Two of them — `changed` (json) and `user` — are NOT NULL, so the statement threw
on every single run, and the method's own `catch (\Exception)` logged it and
returned false. From the outside a broken job and a job with nothing to do are
the same thing.

Measured on the dev instance: `purged_at` was non-null on 0 of 3,254,448 rows,
with 13,472 already past their `expires`, and the table at 3,404 MB — 28% of a
12 GB database — unpruned since 2026-05-29.

`changed => '{}'` and `user => ''` destroy the payload and the identifier as
completely as NULL while satisfying the constraints, which keeps this a data
change rather than a migration. Proven with a rolled-back control pair against
the live table: the old shape errors on the NOT NULL, the new shape reports
UPDATE 1.
…efinition instead of eight (#2498)

* feat(contract): publish ObjectService/ObjectEntity interfaces for consuming apps

A leaf app cannot mock what it cannot load. OpenRegister's concrete classes
are absent from a leaf app's standalone composer test environment, so every
consuming app either reaches through an untyped ContainerInterface -- hiding
the dependency from readers and tooling alike -- or hand-rolls a double.
Measured across the fleet: exactly one of the sixteen consumers ships such a
double, and it declares 10 of ObjectService's 88 methods. ADR-083's rollout
needs seven more; that would be seven doubles drifting from a class none of
them own.

This publishes the interfaces from OpenRegister so there is one definition.

Scope is measured, not guessed, and the first measurement was wrong in a way
worth recording. Ranked per CALL, eight methods look like the whole story.
Ranked per CLASS -- which is what matters, since a class can only type-hint
the interface if EVERY method it calls is on it -- those eight cover 587 of
1001 consumer classes (58%). The 414 that would have had to keep their
container hop were invisible in the per-call ranking.

Re-measured with the receiver resolved to OpenRegister's ObjectService (two
apps ship a local class of the same name, which the first count silently
folded in): 829 classes call 28 distinct methods. ObjectServiceInterface
declares 25 of them and covers 819 (98.8%). The four omissions and the ten
classes they hold back are named in the file rather than left to be found.

Types: parameters are narrowed to identifiers, never OpenRegister entities --
PHP's parameter contravariance lets the implementation go on accepting
Register|Schema objects, so nothing inside OpenRegister changes. Returns are
the entity INTERFACE, satisfied covariantly by ObjectEntity.

saveObject() takes array, not array|ObjectEntityInterface: the implementation
accepts array|ObjectEntity, and array|ObjectEntity is NARROWER than
array|ObjectEntityInterface, which would have made OpenRegister's own class
illegal. A probe that declares the classes on PHP 8.4 caught that -- php -l
never checks inheritance. The same probe was confirmed able to fail by
planting a widened return type, which it rejected.

ObjectEntity's five contract getters become explicit. They were @method
annotations over Entity's __call, and a magic method does not satisfy an
interface -- PHP checks declared methods. Their now-redundant @method shadows
are removed so annotation and declaration cannot drift.

Verified: phpcs clean on all four files; phpstan clean at the fleet's level 5
(level 8 reports 38 iterable-type findings in the same paths, confirming the
files were actually analysed).

Publishing this as a standalone composer package needs its own repo plus
Packagist registration, which is a separate decision. Until then the
interfaces live in lib/, where every quality tool already scans them and the
existing psr-4 mapping already autoloads them.

* docs(contract): correct the stub count — ten apps ship a double, not one

The claim that exactly one consumer hand-rolled an ObjectService double came
from looking for one PATH (openconnector's tests/stubs/OCA/OpenRegister/...).
Searching for the DECLARATION instead finds ten, in six different layouts:

    opencatalogi 13   docudesk 12   openbuild 12   openconnector 10
    hermiq        8   pipelinq  7
    softwarecatalog, zaakafhandelapp, scholiq, decidesk: 0

Against a real class of 88 methods, with a union of 23. Four declare zero
methods -- empty shells that satisfy a type-hint and check nothing.

The correction strengthens the case rather than weakening it: the drift this
interface prevents is not a future risk, it is the current state, ten times
over.

An earlier pass also reported that docudesk's and openbuild's doubles declared
methods the real class lacks. That was an extraction bug -- the class body was
read to the end of the namespace block, swallowing neighbouring stub classes.
Bounded by braces, no double declares a phantom method.

* fix(tests): use onlyMethods() for the getters that are no longer magic

CI found what my local checks could not: declaring the five contract getters
makes PHPUnit reject every `addMethods(['getUuid', ...])` on an ObjectEntity
mock, because addMethods() is only for methods the class does NOT have. 88
errors across all six PHPUnit cells.

32 call sites in 15 files. Where every listed method is now declared, the call
becomes onlyMethods(). Where the list mixes declared and still-magic methods
(getRetention, getDeleted, getCreated, getOrganization -- American spelling, a
different accessor) it is split, and where the chain already had an
onlyMethods() the declared names are merged into it rather than added as a
second call.

Also restores the six @method annotations this branch had removed. Removing
them is correct -- a declared method makes them redundant -- but it surfaces
214 phpstan findings, because the annotations had been hiding the real return
types from static analysis. phpstan's own baseline says so directly: "expected
to occur 3 times, but occurred 5 times". That is genuine debt and deserves its
own change with its own baseline, exactly as openregister#2288 did for lib/Db,
rather than riding along on a PR about publishing a contract. The reason is
recorded next to the getters so it is not rediscovered.

Base comparison: development itself fails Hydra Gates and Quality Report. The
nine checks this branch added -- phpstan, psalm, phpmd and six PHPUnit cells --
are the ones addressed here.

* fix(tests): stub the types the entity can actually hold

Declaring the contract getters makes PHPUnit enforce their return types, and 27
tests turn out to have been stubbing values ObjectEntity cannot hold:

    17  getRegister -> int      declared ?string
     6  getSchema   -> int      declared ?string
     2  getSchema   -> stdClass declared ?string
     2  getSchema   -> array    declared ?string

`register` and `schema` are addType(…, 'string') fields backed by ?string
properties, so the ints are simply wrong; they went unnoticed for as long as
these getters were magic and therefore untyped. Fixed at source — three of the
files funnel through a single mock factory, so the 23 int cases are four edits,
not 23.

The four array/stdClass cases are a different finding and are NOT fixed here.
ObjectsController::logs() carries ~30 lines handling getSchema() returning an
array or an object, and a real entity can return neither. Those branches are
unreachable, and the four tests were covering dead code -- coverage that read as
safety. The tests said so themselves: "Use a mock to return a stdClass from
getSchema (real entity is typed ?string)".

Removing production branches is a behavioural change and wants its own PR, so
the tests are SKIPPED rather than deleted, with the reason on a class constant
pointing at openregister#2501 — where whoever picks it up will find the
reasoning rather than a bare skip.

Correction to the previous commit message: it called the second onlyMethods()
call in a chain a defect. It is not — PHPUnit's onlyMethods() does
`array_merge($this->methods, $methods)`, so the calls accumulate. The 19 chains
with two calls are redundant, not broken.

* fix: psalm, phpmd and the two remaining assertions

PHPUnit is down to 2 failures from 88, and both are the same finding as the 27
before them: `assertSame(5, $result['metadata']['schema_id'])` expected an int,
but the handler passes getSchema() straight through and that is `?string`. The
int expectation was only satisfiable while the mock could return an int the
entity cannot hold. Now asserting what production actually emits.

psalm, three errors, each real:

  * ObjectEntity's `@method array|null getObject()` is LESS specific than the
    declared method's own `@psalm-return array{id: …}`. Removed — the method has
    been explicitly declared for a long time, so the annotation was redundant
    before this branch and wrong as well. This is the ONE shadow worth removing
    here; the other five stay, for the 214-finding reason recorded next to them.
  * ObjectEntityInterface::getObject declared `array<string, mixed>`, which is
    MORE specific than the implementation's `array<array-key, mixed>` — the
    payload's keys come from stored JSON and are not guaranteed to be strings.
    Now `array<array-key, mixed>`.
  * CacheHandler called `$object->getOrganisation();` as a bare statement and
    discarded the result, under a comment reading "Track organization for future
    use". Dead since it was written. psalm could only see it once the getter was
    declared and therefore known to be pure. Removed.

phpmd: the interface now carries the same three suppressions ObjectService
itself already carries, because a contract cannot differ from the signatures it
describes — splitting saveObject() into flag-free methods here would just make
the interface unimplementable. `$_rbac` and `$_multitenancy` are load-bearing
under ADR-022, not incidental flags, which is why they are on the contract.

Verified locally: phpmd clean on lib/Contract, phpcs clean on all changed
files, and the variance probe still accepts every signature on PHP 8.4.

* fix: restore the getObject shadow and take interfaces out of coverage scope

Two CI findings, both instructive.

1. Removing `@method array|null getObject()` was a trap in both directions.
   psalm reported it as LessSpecificImplementedReturnType, so it looked like an
   obvious deletion. But that finding is ALREADY in psalm's baseline: removing
   the line traded a suppressed warning for an `UnusedBaselineEntry` ERROR, and
   it re-opened the same phpstan array-shape findings the other five shadows
   are keeping closed. Restored, with the trap written down next to it so the
   next person does not repeat it.

   Lesson worth keeping: a static-analysis finding that is already baselined is
   not evidence of a problem to fix on this branch. Check the baseline before
   "fixing" what it already accepts.

2. The PHPUnit leg that failed had ZERO failures and ZERO errors -- 16,449
   tests, "OK, but there were issues!". The job failed on the COVERAGE RATCHET:
   "coverage dropped against the merge base by less than 0.01%".

   Part of that is unavoidable by construction. An interface declares
   signatures and has no executable body, so no test can ever cover
   lib/Contract/. Leaving it in the denominator makes every method added to a
   published contract an automatic coverage drop -- the ratchet would be
   punishing the act of publishing an interface rather than measuring untested
   code. Excluded from both phpunit.xml and phpunit-unit.xml, kept in step so
   the two configs cannot measure different things and report one number.

   The implementations in lib/Db and lib/Service stay fully in scope. This is a
   scope correction, not a way around the guard.

   The remaining part is real and explained: four tests in ObjectsControllerTest
   are skipped because they covered unreachable branches (openregister#2501), so
   the lines they touched are genuinely no longer covered. That is a loss with a
   reason attached, not a silent one.

* test(contract): assert the published contract is actually satisfied

The coverage ratchet failed this branch claiming "This change adds 276
statements. Adding code without tests drops coverage." Measured from the run's
own clover artifact, that is not what happened:

  * lib/Contract/ does not appear in the report AT ALL — an interface has no
    executable body, so it contributes zero statements. The exclusion added in
    the previous commit changed the totals by exactly nothing, which is the
    proof.
  * The three lib files this branch touches account for ~4 statements between
    them, not 276.
  * The head run executed 16,449 tests and the merge-base run 16,395. The two
    sides measured different file sets, which is where the 276 comes from —
    +158 of them covered, a 57% ratio matching the codebase average rather than
    the 0% of genuinely untested new code.

So the guard's sentence is a conclusion it did not verify. The DROP is real
though, and it has an honest cause: four tests in ObjectsControllerTest are
skipped because they covered unreachable branches (openregister#2501), so those
lines are no longer covered.

This test is the right answer to that rather than a suppression, because it is
the test that should have existed from the start.

PHP already refuses to declare a class whose signatures do not satisfy its
interface — proven by planting a removed getUuid() declaration, which produced
a fatal at autoload, not a test failure. What the language does NOT catch is a
method removed from BOTH sides, and that is exactly the drift worth guarding:
ObjectEntity extends Nextcloud's Entity, whose __call answers any getX(), so a
contract getter could quietly revert to magic and every caller would keep
working — right up until a consumer tries to `implements` it or mock it with
onlyMethods(). These five getters WERE magic before ADR-084.

Verified in the container on PHP 8.4: 3 tests, 14 assertions, green; and red
when the declaration is removed.

* test(logs): cover the REACHABLE schema match with a real entity

Completes the diagnosis of the coverage ratchet, which is worth writing down
because the guard's own message was a conclusion it had not verified: "This
change adds 276 statements. Adding code without tests drops coverage."

Comparing against development's OWN CI run of the merge-base commit settles it:

                        tests    statements
  development @ base    16449    85622/145144
  base measured IN-JOB  16395    85460/144872   <-- 54 tests and 272 statements short
  this branch           16452    85618/145148

The head measurement is 4 statements above development's real number, which is
exactly what this branch adds — measured independently from the run's own clover
artifact, where lib/Contract does not appear at all because an interface has no
executable body. So 272 of the "276 added statements" are the guard's BASE side
under-measuring, not code added here.

The honest residual is 4 covered statements, lost when four tests were skipped
for reaching logs() through a mock returning an array or stdClass that
ObjectEntity cannot produce (openregister#2501).

This recovers them the right way round: their 404 siblings already use a real
entity, so this is the matching 200 case. The reachable branch of that
comparison is now covered by something that cannot go stale the way an
impossible mock did.

Verified in the container on PHP 8.4: 1 test, 1 assertion, green.
…forcement (#2503)

openregister#2498 added lib/Contract/, but the shipped copy it must stay in step
with only exists from conduction/hydra-gates v1.8.0. Until this lock moves,
gate-67 reports:

    [gate-67] openregister-contract-parity: NOT APPLICABLE — lib/Contract/
    exists but no shipped copy was found to compare it against. NOT a pass —
    nothing was verified.

which is the correct thing for it to say and no use to anyone. With v1.8.0
installed the gate actually compares, and drift in either direction fails the
build.

Verified against the real installed layout (lib/Contract/ vs
vendor/conduction/hydra-gates/hydra-gates/contracts/, unpacked from the
published v1.8.0 archive): `checked 2 file(s)`, exit 0. The published bytes are
byte-identical to lib/Contract/ on development — confirmed by cmp, not assumed.

Only one package moves: conduction/hydra-gates v1.7.3 -> v1.8.0.
Spec only. Adds a `forms` register with three schemas, the API that drives a
run, named formats, and a retention job. Programme context: hydra ADR-085.

WHY. tilburg-woo-ui implements citizen and supplier intake as ~13,640 lines of
hand-written React across seven wizards, each re-implementing step state,
per-index validation, progress display and submission. None is resumable —
closing the tab loses everything.

The declarative half already exists here and in nc-vue: manifest v2 carries
config.steps[], visibleWhen and a closed fieldValidation, and CnFormPage
renders them. What is missing is the primitive that spans MORE THAN ONE FORM:
multiple objects, branching between forms, lookup-and-prefill, a review step,
and somewhere to keep a half-finished submission.

NAMING. `journey`, not `flow`. OpenRegister already owns `flow` for the
automation engine (ADR-065, ~40 change specs, FlowController, flow runs, a
visual canvas). A journey is a UI-facing sequence; it may trigger a flow, and
it is not one.

WHAT THE SPEC PINS

- A form's config is validated by the SAME validateManifestV2() path an app
  manifest is — not a subset schema maintained alongside it. If the two can
  drift, they will.
- Branching reuses $defs.visibleWhen verbatim. A second condition grammar is
  the most likely way this design rots, so it is forbidden and gated.
- Nothing is written until a step declares writes[]. This preserves the
  property the React wizards have by accident — an abandoned registration
  leaves no half-built organisation — while adding the resumability they lack.
- A resume token is a bearer credential for someone's half-filled form: it
  must not act as an existence oracle, so a token/run mismatch and an unknown
  run return identical responses.
- Retention ships WITH the schema. A journeyRun holds names, addresses, e-mail
  and uploads before any of it is a record. The purge reports its row count,
  because a job that deletes nothing must be distinguishable from one that
  never ran — this instance's audit purge had never executed while looking
  exactly like a purge with nothing to do.
- Named formats (email, website, nl-phone) replace the per-app validator forks,
  pinned against the cases tilburg's form-validations.js handles today
  including its explicit rejections.
…ode-proof

# Conflicts:
#	lib/Service/Flow/FlowNodeResumeState.php
#	lib/Service/Flow/FlowResumeState.php
#	lib/Service/Flow/FlowStateBinding.php
#	lib/Service/Flow/Nodes/AwaitSignalNode.php
Measured on the dev instance: `oc_openregister_audit_trails` was 3,404 MB — 28%
of a 12 GB database — and ONE row's `changed` payload was 61,910,691 bytes. A
62 MB audit entry is a copy of an object, not a record of a change, and it is
charged to every backup, every replica and every TOAST read.

A per-property value over 64 KB is replaced by a descriptor recording that it
was elided and how large it was. The entry still says THAT the property changed
and roughly how much; only the bytes go. The property list, action, actor and
hash chain are untouched — which is what an audit trail is for.

Applied in buildAuditTrail(), the single point every object audit passes
through, rather than at the call sites.

⚠️ Retention does not solve this on its own: clearLogs() only prunes rows that
have EXPIRED, so an unexpired 62 MB row sits for its whole retention period.
The two changes are complementary — that one made the purge work at all, this
one bounds what accumulates until it runs.

Verified with a control pair: an ordinary {old,new} property survives byte for
byte, a 200 KB one is elided with `bytes: 200019` recorded, and the payload goes
from 200,058 to 132 bytes.
fix(audit): the retention purge had never once run, plus the flow-resume branch
…g was a no-op (#2502)

`ObjectEntity::lock()` returns true and locks nothing.

`setLocked()` is a magic method: an `@method` annotation over Nextcloud's
`Entity::__call`. Five call sites in this file invoke such setters with a NAMED
argument:

    $this->setLocked(locked: [...]);

PHP passes named arguments to `__call` as an ASSOCIATIVE array, so `$args`
arrives as `['locked' => [...]]`, and `Entity::setter()` reads `$args[0]`. The
result is "Undefined array key 0" and the property is left untouched. Proven by
probe: after `setLocked(locked: [...])` the backing property is NULL and
`isLocked()` is false, while the identical positional call works.

This is live, not theoretical:

    ObjectService::lockObject()
      -> LockHandler::lock()
        -> MagicMapper::lockObjectEntity()
          -> ObjectEntity::lock()          <-- sets nothing

and `lockObjectEntity()` then persists the entity, logs "Locked object in
register+schema table", and dispatches ObjectLockedEvent. So the audit trail
records a lock that does not exist and concurrent writers are not excluded.

Five sites fixed by dropping the argument name:

    lock()             x2  the create branch AND the same-user extend branch
    unlock()           x1  setLocked(null) — releasing did nothing either
    delete()           x1  soft-delete metadata was never recorded
    hydrateObject()    x1  drops the ENTIRE object payload; probe shows
                           getObject() returning only ['id' => …] after
                           hydrating a title and body. No caller in lib/, so
                           this one is latent rather than live.

Tests cover the branch that was never reached: `lock()` on an ALREADY-locked
object — the half that decides whether a second caller extends or is refused,
which is the half that protects anything. Found by reading the uncovered lines
out of the CI coverage artifact: lines 1132-1144 were a contiguous block of
twelve unexecuted statements.

Against the unfixed file those tests give 3 failures and a stream of "Undefined
array key 0" warnings; with the fix, 4 tests / 15 assertions green on PHP 8.4.

Surfaced while publishing the ObjectService contract (ADR-084, openregister#2498)
and it is the same lesson: a magic method has no signature, so nothing checks
how it is called. Roughly 100 further named-argument setter calls exist under
lib/; each is only a bug where the receiver's setter is magic, which needs
per-receiver checking rather than a sweep.
…earch

Two changes, researched before either was written.

rbac-default-authenticated — ABSENT AUTHORIZATION MEANS AUTHENTICATED, NEVER
PUBLIC.

PropertyRbacHandler says it in as many words: "If no authorization is defined
for this property... return true" and "If action is not configured, property is
accessible", while userQualifiesForGroup() returns true for `public`
unconditionally. So "nobody said anything" and "everybody may read this" are
the same state, reached by writing nothing.

Surveyed 2026-08-15: 321 of 368 declared schemas (87%) carry no authorization
block — scholiq 118, shillinq 114, decidesk 34, pipelinq 26, docudesk 20,
portaliq 9. Four apps did the work; six did not, because omission has never had
a consequence.

Not every surface is open today: the HTTP object API refuses anonymous callers
outright (measured, total=0 anonymous vs total=8 admin on the same query, with
_rbac=false&_multitenancy=false making no difference). The exposure is the
IN-PROCESS path — a leaf app calling ObjectService from a #[PublicPage]
controller — measured on portaliq's content API returning 6 pages to an
anonymous caller from an unmarked schema. And that path is about to widen:
portal-public-search puts anonymous full-text search over OR objects.

Task order is load-bearing: audit by name, mark the intended-public schemas,
THEN flip. Flipping first turns six apps' public surfaces blank and calls it a
security fix. A legacy-open migration flag is deliberately not offered — it
would be set once during an upgrade and never unset.

unified-search-file-content — OR EXTRACTS FILE TEXT AND ITS SEARCH PROVIDER
DOES NOT LOOK AT IT.

TextExtractionService writes chunks (sourceType/sourceId/textContent/embedding/
owner/organisation) and ChunkMapper::searchByKeyword() searches them, but
lib/Search/ObjectsProvider.php contains ZERO chunk references (measured). So a
term appearing only inside an attached PDF finds nothing, while OR holds that
text indexed.

The fix is one flag: pass `_content_search: true`, which ObjectService already
merges through the same RBAC/multitenancy pipeline, returning the owning OBJECT
— the thing with a URL — rather than a bare chunk. No new provider: OR's own
docblock states it is the single fleet-wide provider and leaf apps must not
register their own.

Scope stated because it cannot be otherwise: NC unified search CANNOT serve
anonymous callers. IProvider::search(IUser $user, ...) is non-nullable,
UnifiedSearchController carries no #[PublicPage], and a live probe returns 401
anonymous / 200 authenticated. Anonymous portal search is a different mechanism
and lives in portaliq/openspec/changes/portal-public-search.

The one plausible disclosure route is specified and tested directly rather than
reasoned about: excerpts must keep deriving from the RENDERED object, or a
redacted field could leak through an excerpt built from file text while the
object itself stays correctly filtered.
…it was wrong

Task 1 of rbac-default-authenticated: every schema declaring no authorization,
listed BY NAME with a proposed intent. A count cannot be reviewed, and the
review is the entire point of this task.

THE FIRST SURVEY WAS WRONG, AND WRONGLY REASSURING
--------------------------------------------------
It said 321 of 368 schemas across six apps. It globbed
`lib/Settings/*register*.json | head -1` — ONE register file per app.
openregister ships 14; procest ships 2.

Corrected: 504 of 571 (88%), across FIFTEEN apps. The undercount hid 183
schemas and nine entire apps, including openconnector (39 unmarked) and hermiq
(28), both of which had appeared to have none at all. procest went from "6 of 6
marked, exemplary" to 85 unmarked.

I published the wrong figures in this PR's body and in portaliq#114 before
catching it. Both are corrected. The error made the exposure look smaller,
never safer.

A SECOND METHODOLOGICAL FLAW, RECORDED BECAUSE IT WILL RECUR
------------------------------------------------------------
The survey reads whatever branch each shared checkout happens to be on, and
other workstreams move them. At survey time portaliq sat on
`fix/local-lib-guard-semver`, which predates its CMS work — so `page`, `menu`,
`glossaryTerm` and `portal` are absent from its nine rows. Re-read from a known
ref, or record the branch.

WHAT THE FIRST PASS SAYS
------------------------
432 authenticated · 68 restricted · 4 public candidates, from 504.

The classifier is deliberately biased toward refusal: anything naming a
session, account, token, secret, audit record, salary, invoice, submission or
person is proposed `restricted`. Only four schemas in 504 are even proposed as
public — and two of those (`ContactDetail`, `contact`) were flagged purely
because their names begin with "contact" and plainly hold personal data. That
pair is the clearest argument for why this column needs a human and not a
regex.

The document says so explicitly: each app's maintainers decide their own rows,
and an unreviewed `authenticated` is a default rather than an answer.
…2506)

The same bug as openregister#2502, in the last two places the audit could
confirm. `setDeletedAt` is `@method`-only, so:

    $this->setDeletedAt(deletedAt: new DateTime());   // softDelete()
    $this->setDeletedAt(deletedAt: null);             // restore()

both reach Entity::__call with an ASSOCIATIVE array and Entity::setter() reads
$args[0]. Neither wrote anything. `softDelete()` returned $this and left the
conversation undeleted; `restore()` was equally inert, so the two agreed with
each other and with every test that only checked the return value.

Audit method, for the record: resolve each `->setX(x: ...)` call's RECEIVER from
its property or local declaration, then ask whether that class DECLARES setX or
only `@method`s it. A word-based sweep would have rewritten correct code — 151
classes in one leaf app alone legitimately take the arguments in question.

Across openregister/lib after #2502: 2 confirmed, now 0. 106 call sites have a
receiver the audit could not resolve statically (a container `get()`, a
factory, a chained call) and are listed in the issue for a human pass rather
than guessed at.
…arch-file-content T1/T2)

One line of behaviour: `$searchQuery['_content_search'] = true`.

Before it, the fleet-wide unified-search provider searched object METADATA
only, so a term appearing solely inside an attached PDF found nothing — while
OpenRegister held that text indexed in `openregister_chunks` and
`ChunkMapper::searchByKeyword()` could find it. Measured 2026-08-15: this file
contained ZERO chunk references.

WHY IT IS SAFE TO TURN ON HERE
------------------------------
The fan-out is not a second query path around the guard rails. `QueryHandler`
forwards `_rbac` and `_multitenancy` into `augmentWithChunkMatches()`, so a
chunk hit on an object the caller may not read is filtered by the same pipeline
that filters a metadata hit. The provider still applies no second access filter
of its own, and the row appended is the OWNING OBJECT — the thing with a URL,
icon and title — not a bare chunk.

The class docblock is updated to say that excerpts deriving from the RENDERED
object is now load-bearing: with file text in scope, an excerpt built from
chunk text could surface a value the reader is redacted out of while the object
itself stayed correctly filtered.

TESTS, AND THE NEGATIVE CONTROL
-------------------------------
Three added. Two assert the flag reaches the pipeline alongside `_rbac` and
`_multitenancy` still true — widening the MATCH must never widen the
ENTITLEMENT. The third pins that a schema opted out of search stays opted out,
so content search cannot reach around the `searchable = true` narrowing.

Proved they measure the change: removing the line in a container-local copy
breaks exactly those two tests ("Failed asserting that null is true"), and
restoring it returns 33/33. The third passes either way by design — it asserts
the narrowing is unaffected.

WHAT IS NOT DONE, AND CANNOT BE HERE
------------------------------------
Task 3 (bound + measure) is NOT complete. The bound already exists
(`ContentSearchHandler::CHUNK_CANDIDATE_LIMIT = 50`), but the rig holds
**0 rows in oc_openregister_chunks** — there is no extracted file text on it at
all. A latency comparison would be measuring an empty table, and an end-to-end
"a term inside a PDF finds its object" test cannot run. That needs a corpus,
and is left open rather than reported as passing.

So this is verified AT THE SEAM: the provider asks for content search, with
RBAC intact. The fan-out itself is ContentSearchHandler's own tested behaviour.

phpcs 0 errors. phpunit 33/33 via bootstrap-unit-standalone.php. phpstan not run
— it cannot resolve nextcloud/ocp against the copied vendor in this clone, so
its result here would say nothing about the code.
The app pinned the exact prerelease 2.2.0-vue3.16, which is now deprecated:
the Vue 3 line was folded into the mainline 2.x release series and ships as
2.3.0 on the `latest` dist-tag. A caret range replaces the exact pin so
future 2.x releases roll out without a per-app edit.

Drops the `overrides.@conduction/nextcloud-vue.eslint` entry. That override
existed only because the old prerelease declared `eslint: ^8.56.0 || ^9.0.0`
and could not see the app's eslint 10; 2.3.0 declares `|| ^10.0.0` itself,
so the peer resolves without help.

No API change: 252 components in and 252 out, no export removed, one added
(the BSN validators). Peer ranges are otherwise identical.
spec+impl: fail-closed RBAC default; file text in the fleet's NC search
… names

Task 1 proposed intent by KEYWORD and produced four public candidates out of
504. That asks what a schema is called, not who reads it, and it missed every
app whose public surface is named in a controller rather than in a noun.

Second instrument, same tree: for each app, which schema names appear in a
controller file declaring `#[PublicPage]` or `@PublicPage`? Measured
2026-08-15: 24 unmarked schemas across 9 apps — decidesk 8, procest 8,
scholiq 2, openconnector 2, hermiq 1, shillinq 1, petstore 1, launchpad 1.

Those 24, not the four keyword hits, are what goes blank when the default
flips — so they are Task 2's work-list. They cut both ways: `Person` and
`Credential` are on it, and if those really are reachable anonymously today
then the flip is a fix rather than an outage. Either reading demands a
decision, which is the point.

Recorded as a FLOOR with its blind spots named: it sees one file (procest's
public endpoints name three schemas that live in `ori_register.json`, not in
its own register), it only reads registers an app ships itself, and it matches
case-insensitively but not by plural. shillinq is the worked example of the
last one — its controllers quote 'slots'/'services'/'appointment' and its
register declares no such schemas, checked rather than assumed, because an
unexplained gap between two instruments means one of them is wrong.

opencatalogi, softwarecatalog and pipelinq measure zero unmarked hits. That is
the shape the other nine are being asked to reach.

The survey ships next to the document so the number can be re-measured instead
of quoted.
Reading the endpoints behind the 24 rows splits them into two opposite kinds,
and the split does not follow the attribute.

decidesk's eight are genuinely anonymous: `OriController` serves ORI 1.4
JSON-LD for council-information transparency, and `Meeting`/`AgendaItem`/
`Decision`/`Minutes`/`Vote`/`Report` are that standard's published entities.

procest's eight are the opposite. Its public controllers are the ZGW statutory
APIs — ZRC, BRC, AC, Subsidieregister — and EVERY method opens with
`validateJwtAuth($this->request)`. They are `#[PublicPage]` because they carry
no Nextcloud SESSION, not because they are open.

Both directions bite:

  * marking `case`/`decision`/`document` public would hand statutory case data
    to anonymous callers, and it would be filed as a security improvement;
  * the flip breaks them anyway — a JWT-authenticated request has no NC user,
    so an unmarked schema resolved as `authenticated` refuses a caller that IS
    authorized, just not by Nextcloud.

So the population is not two-valued. A third kind exists — an endpoint that
authenticates itself and then reads OpenRegister with no Nextcloud identity —
and neither `public` nor `authenticated` describes it. Task 3 cannot land
safely until that kind has one fleet-wide answer; deciding it per app, twelve
times, is how twelve different answers happen.
The previous commit ended on an open question: an endpoint that authenticates
itself and then reads OpenRegister with no Nextcloud identity fits neither
`public` nor `authenticated`, and Task 3 could not land until it had one
fleet-wide answer.

hydra ADR-085 ("Externally-Authenticated API Surface Belongs to
OpenConnector", amending ADR-081 §2) resolves it by REMOVING the population
rather than assigning it an RBAC value: such a surface belongs to
OpenConnector, its logic becomes an OpenRegister flow, and what stays in the
app is a register, a schema and configuration. An OpenConnector endpoint then
resolves a real identity BEFORE touching OpenRegister, so every schema is
`public` or `authenticated` and nothing falls between them — precisely the
precondition Task 3 needs.

The deciding argument there is not code duplication: ZGW, StUF, DSO,
Notificaties, iWmo/iJw and Berichtenbox are NATIONAL standards, and a leaf app
that implements one only works in one country.

Sequencing recorded explicitly: Task 3 waits on that population having a
MIGRATION PATH, not merely on the ADR being accepted.
rubenvdlinde and others added 29 commits August 20, 2026 11:48
…260820093628

chore(release): 1.1.5-unstable.20260820093628
…260820095029

chore(release): 1.1.5-unstable.20260820095029
…260820100544

chore(release): 1.1.5-unstable.20260820100544
Two user-visible defects are fixed by this bump.

TWO AI-COMPANION HEXES ON EVERY PAGE. The companion singleton landed in
2.7.0. Below that the host app's own companion never stands down, so any
page of this app rendered a second hex 8px from hermiq's — measured on a
running instance: openconnector (2.7.1) showed ONE, openbuild (2.6.3)
showed TWO, both visible at 52x60, from two separate mounts.

THE DETAIL PAGE RECLOSED ITS SIDEBAR WHILE HYDRATING. CnDetailPage set
sidebarSeeded and never read it, so 'open' was re-applied on every sync
and each reactive change during hydration reset it to the prop default.
Fixed in nextcloud-vue#711; that is the cause behind openbuild#268 and,
on the evidence, #188.

Lockfile only — the existing caret already allowed this. Three-line diff
(version, resolved, integrity).
…260820102058

chore(release): 1.1.5-unstable.20260820102058
…260820103623

chore(release): 1.1.5-unstable.20260820103623
…260820105642

chore(release): 1.1.5-unstable.20260820105642
chore(deps): take @conduction/nextcloud-vue 2.8.2 (was 2.8.0)
…260820111522

chore(release): 1.1.5-unstable.20260820111522
…260820113511

chore(release): 1.1.5-unstable.20260820113511
…260820115241

chore(release): 1.1.5-unstable.20260820115241
…260820120253

chore(release): 1.1.5-unstable.20260820120253
…260820121249

chore(release): 1.1.5-unstable.20260820121249
…260820122257

chore(release): 1.1.5-unstable.20260820122257
…260820124137

chore(release): 1.1.5-unstable.20260820124137
…260820125943

chore(release): 1.1.5-unstable.20260820125943
@rubenvdlinde
rubenvdlinde merged commit 91b7466 into beta Aug 20, 2026
34 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants