Release: merge development into beta - #1711
Merged
Merged
Conversation
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.
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 |
…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.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated PR to sync development changes to beta for beta release.
Merging this PR will trigger the beta release workflow.