Skip to content

Release: merge development into beta#1711

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

Release: merge development into beta#1711
github-actions[bot] wants to merge 2593 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 and others added 29 commits July 12, 2026 21:35
…cked change proposal(s)

Derived from the 2026-07-12 Specter deep-research on scholiq (competitors,
user wishes, flows, standards, Nextcloud ecosystem; evidence persisted in
the intelligence DB, tag gap-2026-07-12). Changes: notification-delivery-windows
…(scholiq gap-wave 2026-07-12)' (#352) from specs/scholiq-gap-wave-20260712 into development
…, derived provider, McpTool attribute (ADR-063)' (#353) from wip/mcp-platform-abstraction-spec into development
…s, critical bypass

Implements openspec change notification-delivery-windows: QueuedNotification
table + mapper + migration, dispatcher gate that queues (never drops)
suppressed non-critical notifications, per-rule fixed-time digest schedules,
critical:true bypass, IANA timezone evaluation with server fallback,
NotificationQueueFlushJob; removes dead BatchNotificationJob/NotificationDigest.
Tests 14118->14180, 0 new errors/failures; hydra gates pass for touched code.
…able digest queue (wip/notification-delivery-windows)' (#354) from wip/notification-delivery-windows into development
Adds the declarative x-openregister-mcp dialect: registers the key in
Schema::ANNOTATION_VOCABULARY so it folds into schema configuration on
import, adds McpAnnotationValidator (shape/type checks for enabled,
the fixed five-verb CRUD template, per-verb description/scope/hints,
and search-only filters cross-checked against real properties), and
wires validateMcpAnnotation() into SchemaMapper::cleanObject() after
validateHandoffAnnotation(). Default OFF, opt-in per schema; emits no
MCP tool and changes no serving surface (that lands in the follow-up
or-mcp-derived-tool-provider change). Documents the dialect in
docs/features/ai-and-mcp.md and the changelog.
Moves the completed change to
openspec/changes/archive/2026-07-12-or-mcp-schema-dialect/ and syncs
its spec delta (REQ-DIALECT-001/002/003) into openspec/specs/ai-mcp/spec.md,
marking or-mcp-schema-dialect shipped in the ADR-063 in-flight-changes
list while keeping or-mcp-derived-tool-provider and or-mcp-tool-attribute
noted as in-progress.
… validator (ADR-063 chain 1/3)' (#355) from wip/or-mcp-schema-dialect into development
…DR-063 chain 2/3)' (#360) from wip/or-mcp-derived-tool-provider into development
- AnnotationNotifier: setIcon() only accepts absolute http(s) URLs since
  NC requires them for desktop/mobile clients; the relative imagePath()
  fallback threw InvalidValueException (extends InvalidArgumentException)
  on every render of every openregister notification without an originApp
  route — ~43 notifications x every poll = ~10,450 deprecation warnings
  per 2h, and those notifications never reached any client. Wrap in
  getAbsoluteURL().
- McpToolsService discovery: probing OCA\{App}\Mcp\{App}ToolProvider for
  every enabled app logged a warning per app per MCP request (~1,600/2h).
  Absence is the expected case: class-miss and alias NotFoundException
  drop to debug, successful discovery drops to info; genuine resolve
  failures stay warning.
- PropertyValidatorHandler / AggregationAnnotationValidator: union types
  and array metrics hit string interpolation/casts, emitting PHP 'Array
  to string conversion' warnings (~140/2h). Arrays now render as JSON in
  the validation message / fail the metric check cleanly.
…s (ADR-063 chain 3/3)

Net-new PHP attribute marks a public service method for MCP exposure with
inputSchema/outputSchema inferred from type hints + docblocks. A reflection
scanner discovers attributed methods on an app's declared scannable service
classes (opt-in via IMcpScannableServices::<appId>) and registers one tool
per method, id {appId}.{toolName}, into the same catalog and both existing
serving surfaces as built-in and schema-derived tools — no new serving code.

Invocation resolves the owning app's already-DI-resolved service instance
and calls the method in-process (ADR-041, no cross-app RPC); the method
alone owns authorization/IDOR. Every invocation is audited identically to
the derived provider (one hash-chained record, params digest not raw args).
Collision policy: hand-written wins silently over attributed; attributed
colliding with a schema-derived id is rejected at discovery with a logged
error.
ADR-063 chain 3/3 shipped — chain now COMPLETE (dialect + derived provider +
attribute). Deltas synced into ai-mcp and mcp-discovery capability specs.
Adds GET /api/v2/audit and /api/v2/audit/export so admins can query and
export audit-entry objects (e.g. procest's aiAuditEntry) across every
app/register/schema from one place, instead of each app building its
own audit UI. AuditQueryService resolves candidate register/schema
pairs (explicit filters, or a schema-agnostic "looks like audit"
naming fallback when none are given) and searches them via
ObjectService, clamping limit to 1-200. AuditQueryController gates
both actions admin-only and streams CSV or JSON exports.
…ge paths

@SPEC openspec/changes/{name}/... goes stale the moment a change is
archived — hydra-gate-spec-anchor-existence (gate-46) correctly flagged
both the new or-mcp-tool-attribute references and a pre-existing
or-mcp-derived-tool-provider reference in Application.php now that the
file is touched. Point all of them at openspec/specs/ai-mcp/spec.md, the
living spec both chains' requirements were synced into.
…ner (ADR-063 chain 3/3)' (#363) from wip/or-mcp-tool-attribute into development
- MagicTableHandler::handleExistingTable compared required columns (keyed
  by camelCase property name) against live columns (keyed by snake_case
  physical name) with array_diff_key, so any schema with a multi-word
  property (allowedApps -> allowed_apps) was flagged as missing columns
  on every request and force-synced forever without converging
  (~6,600 warnings/2h). New MagicMapper::findMissingColumns() resolves
  the physical name first — the same resolution addMissingColumns()
  already used.
- ImportHandler Pass 2: schemas skipped by Pass 1 as up-to-date are
  never in schemasMap; the per-schema map-miss log is the expected case
  on every boot of an unchanged config (~4,800/2h) — downgraded to
  debug.
RBAC fails closed for anonymous principals (#1955) — correct for
requests, but Nextcloud boots apps BEFORE session resolution and
webcron never has a user, so every app-initiated maintenance write
(importing the app's own shipped register config, repair-step
migrations, listener writes on webcron-created objects) is denied as
'Anonymous' on every boot. The existing trust only covered
PHP_SAPI === 'cli'.

- SystemOperationContext::run(callable): scoped, code-only elevation
  with depth counting; released in finally so exceptions cannot leak it.
- MultiTenancyTrait::hasRbacPermission and PermissionHandler honour the
  scope exactly where they already trust CLI.
- ConfigurationService::importFromApp runs inside the scope: fixes
  docudesk/petstore-style 'Access denied: update register entities' on
  every boot (~120 errors/2h).
- ObjectService::runAsSystem(callable): public API for consuming apps'
  repair steps and event listeners (openbuild, docudesk, pipelinq,
  softwarecatalog adoption follows per app).
- Unit tests for scope semantics (active/released/exception/nesting).
…(notifier icon, MCP probe, array-to-string)' (#361) from fix/log-noise-phase1 into development
…ss 2 map-miss noise (log-cleanup phase 2)' (#364) from fix/log-noise-phase2 into development
…user-less app maintenance (log-cleanup phase 4 platform)' (#365) from fix/system-operation-context into development
hasRbacPermission() has two anonymous guards: an early
getCurrentUserId()===null check and a later userSession->getUser()===null
check. #365 added the SystemOperationContext::isActive() trust only to the
later block, but the early guard returns false first — so entity-level
(register/schema) writes from a scoped system operation without a session
(docudesk config import, webcron repair steps) were still denied as
Anonymous. Add the same trust to the early guard, mirroring its CLI branch,
so it short-circuits before the deny. Object-level PermissionHandler was
already correct (checks isActive at the top).
…nonymous RBAC guard (hotfix on #365)' (#366) from fix/system-context-early-guard into development
…l spec

Archives the implemented+merged change to archive/2026-07-13-* and merges its
delta into the notificatie-engine canonical spec (+1 added, ~3 modified).
184 canonical specs validate --strict.
rubenvdlinde and others added 30 commits July 16, 2026 17:22
…o canonical specs

Deterministic, comment-only repointer retargets @SPEC docblock anchors that
pointed at archived change dirs (openspec/changes/<slug>/tasks.md#task-N) to
their canonical openspec/specs/<cap>/spec.md[#requirement-<slug>] home.

- 3,643 anchors repointed across 695 files (896 anchor-level, 2,747 file-level)
- comment-only: 0 non-@SPEC changed lines / 7,030; every file ins==del (1:1)
- gate-46 re-verify: OR broken 5,051 -> 1,408
- 1,408 residual-dangling anchors filed for human triage
- tool + unit test committed under the change dir
…from wip/spec-anchor-repair into development
…aths

OR resolved `writeOnly` from top-level schema properties only. A secret
nested inside an untyped `object` property was therefore impossible to
protect: `writeOnly: true` is a JSON Schema keyword and can only attach to
a property the schema declares, and blanket-`writeOnly` on the parent
breaks the editors that legitimately read the rest of it back. Live
instances (openconnector#235, the openconnector#147 residual):

  - source.configuration.authentication.{client_secret,username,password}
  - rule.configuration.authentication.keys  (apiKey -> userId impersonation map)

Both were returned in cleartext on every read, for everyone, with no way
to declare otherwise.

Declaration: a schema-level annotation listing dot-paths, e.g.
`x-openregister-writeonly-paths: ["configuration.authentication.keys"]`.
It composes with the existing x-openregister-* machinery and reuses the
dot-path vocabulary the relations mirror is already keyed by. A declared
path strips the value AND its whole sub-tree, which is what covers
`...authentication.keys` whose leaf segments are caller-supplied apiKeys
and cannot be enumerated in advance.

Enforcement rides the SAME hard render boundary as top-level writeOnly —
unconditional, admin included, NOT _rbac-gated (#389: an admin HTTP GET
renders with _rbac: false). Applied at every site that strips top-level
writeOnly, all of which funnel through
PropertyRbacHandler::stripWriteOnlyProperties():
  - RenderObject::renderEntity() body strip + @self.relations mirror
  - RenderObject::redactWriteOnlyFromRows() cheap list path (both row shapes)
  - PropertyRbacHandler::filterReadableProperties() (property-authz path)
Gates now ask RenderObject::schemaHasWriteOnlyRule() so a caller cannot
check one spelling and forget the other. `_render: false` still bypasses
rendering entirely and returns the raw value (the credential migration and
CallService depend on it).

@self.relations: SaveObject::scanForRelations() flattens nested values into
LITERAL dot-path keys, so a nested secret is mirrored there under its full
path — the nested equivalent of the #429 gap. Stripped by exact key and by
`<path>.` prefix.

Validation fails loudly. This is the only configuration key exempt from
#419's per-key isolation: for every other key "drop the bad key, keep the
rest" degrades safely (the feature just doesn't fire), but for this one it
is fail-OPEN — the schema would save, look annotated to a reviewer, and
serve the secret. A malformed path, or one rooted at an undeclared property
(`configuratio.authentication.keys`), aborts the whole save.

Also fixes two pre-existing defects found on the way:
  - testSystemContextReadIsNotRedacted asserted the PRE-#389 contract and
    had been RED at HEAD, documenting a leak as intended behaviour.
  - the canonical spec still said `_rbac: false` MUST return the secret,
    which is what the stale test was pinned to. Split the bypass
    requirement: property authorization.read bypasses, writeOnly never does.

Tests: 20 new (12 Schema validation, 8 render). Mutation-tested — removing
the nested strip fails 5 tests showing the plaintext across body, mirror,
_rbac:false, fields re-widen and cheap path. Zero new failures vs pristine
HEAD across tests/Unit/Db + tests/Unit/Service/Object (13 pre-existing,
1 fixed). phpcs unchanged vs HEAD (0 errors, 41 warnings).

Consumers: openconnector#235, openconnector#147. Neither is closed —
openconnector must still ADOPT the annotation for its rule/source schemas.

Refs openconnector#235, openconnector#147, openregister#389, openregister#429
…only-paths' (#459) from wip/nested-writeonly-paths into development
#460)

An admin LIST/search read returned every `writeOnly` secret in cleartext.

#389 established that writeOnly is a hard render-boundary rule that strips
unconditionally, admins included, and hardened `RenderObject::renderEntity()`.
Its sibling `redactWriteOnlyFromRows()` — the cheap list/search path — kept the
pre-#389 bypass:

    if ($_rbac === false || SystemOperationContext::isActive() === true ...) return;

Its comment claimed "same bypass as renderEntity's read-strip", which had been
stale since #389 removed exactly that bypass. `ObjectsController` derives
`$rbac = ($isAdmin === false)`, so an ADMIN list arrived here with `_rbac: false`
and hit the early-return — the disclosure #389 was filed to close, still open on
the list path.

`_rbac: false` on an HTTP read means "this caller bypasses WHICH OBJECTS it may
see", never "this caller may see secrets". Conflating those was the bug.

Fix: mirror renderEntity's split. writeOnly (top-level and nested
`x-openregister-writeonly-paths`, #459) strips unconditionally via the shared
`schemaHasWriteOnlyRule()`/`stripWriteOnlyProperties()` choke point, including
the `@self.relations` mirror (#429). Property `authorization.read` stripping and
field decryption REMAIN `_rbac`/SystemOperationContext-gated — unchanged.

Consumer investigation (the ocon#215/#226 regression risk): no consumer depends
on the bypass. `redactWriteOnlyFromRows()` is reachable ONLY via
`searchObjectsPaginated`; every internal caller (GraphQLResolver, ObjectsProvider,
ContextRetrievalHandler, SearchController, SettingsController) passes `_rbac: true`
or the default. The only `_rbac: false` callers are the three ObjectsController
list sites, which serialize straight to a JSONResponse. The engines the comment
named batch-read via `findAll()`/`find()`, which never enter this method; the
sanctioned raw read stays `_render: false`. Nothing to migrate.

Tests: the defect hid because tests exercised the helper (with `_rbac: true`, the
one value that did strip) or mocked RenderObject out of the controller entirely.
Adds ObjectsControllerWriteOnlyListLeakTest — the REAL controller list path with a
REAL RenderObject, asserting on the serialized admin response (top-level, nested,
and the relations mirror). Mutation-verified: restoring the early-return fails 6
tests, printing `"apiKey":"SECRET_APIKEY_MUST_NOT_LEAK"` in an admin list response.
Inverts testCheapPathRowRedactionSkipsSystemContext, which pinned the leak as
intended behaviour, and pins that authorization.read gating is untouched.

Suites: 4682 tests (was 4675, +7); the 13 reds are byte-identical to an
origin/development baseline — all pre-existing, none new.

Refs #389, #429, #459. Closes #460.
Fixes three tool defects found by fleet-wide use: CRLF normalisation, raw
fragment emission (produced anchors gate-46 still rejects), and the missing
self-heal path for already-canonical targets. Adds regression tests for each.

gate-46: 1411 -> 1408. Comment-only: every changed lib/src line is an @SPEC tag.
…teOnly secrets — strip unconditionally on the cheap path (#460)' (#462) from wip/fix-list-writeonly-leak-460 into development
…y fleet-wide use (gate-46 1411 to 1408)' (#461) from wip/spec-anchor-repair-round2 into development
`writeOnly` stripped secrets on every read but nothing restored them on save,
so the natural client round-trip destroyed the stored value: GET returns the
object with the secret stripped (correct), the client edits one field and PUTs
the body back without the secret it was never shown, and the PUT-semantic
null-fill in prepareObjectForUpdate() nulls it.

This is live, not theoretical. nc-vue's generic CnFormDialog spreads the loaded
form data into its submit body and has no writeOnly awareness at all, so editing
ANY field of an OpenConnector Source wiped its credentials (openconnector#245).
It also blocked openconnector#147, whose remaining fix is to declare
rule.configuration.authentication.keys write-only.

Adds the save-side half of the contract, symmetric with the read-side strip and
living next to it in PropertyRbacHandler:

- collectOmittedWriteOnlyPaths() reports which declared locations an incoming
  update payload omits. A top-level `writeOnly: true` property is treated as a
  single-segment dot-path, so both declaration surfaces (#459) collapse into one
  rule and cannot drift apart.
- restoreWriteOnlyValues() carries the stored values forward for exactly those
  locations, writing the LEAF into the incoming sub-tree rather than restoring
  the whole parent — so preserving configuration.authentication.client_secret
  does not revert the operator's edit to configuration.endpoint.

absent vs explicit null: only an ABSENT location is preserved; an explicit null
clears. Conflating them would make clearing a secret impossible — settable and
rotatable but never removable. This also matches PATCH, which already merges the
payload over the stored object. Both verbs agree: omit to keep, send null to clear.

Ordering in prepareObjectForUpdate() is load-bearing in both directions:
- detection runs on the RAW payload, before setDefaultValues() can materialize an
  omitted key and mask the omission;
- restoration runs AFTER prepareObjectData() (that is where encryptProperties()
  runs, and the stored value of an encrypted property is ciphertext — restoring
  earlier would double-encrypt it) and BEFORE fillMissingSchemaPropertiesWithNull()
  (which materializes every absent property as null, erasing the distinction).

The stored value is read from the raw ObjectEntity::getObject() snapshot already
captured in the method. A rendered read would be useless by construction, and
`_rbac: false` is NOT sufficient — the writeOnly strip is schema-gated and
deliberately ignores _rbac (#389, #460), so an _rbac: false read still comes back
stripped.

Scope: covers the single-object path, which also serves saveObjectsStreaming()
since it delegates per row. The bulk path (SaveObjects::saveObjects() ->
ultraFastBulkSave upsert-by-UUID, which never reads the existing row) has the
same class of exposure by a different mechanism and is documented in the spec as
a tracked gap — it needs a chunk-level batched fetch and is a narrower vector,
since bulk payloads come from import files rather than a stripped GET.

Nothing on the read boundary is weakened: it stays unconditional (#389/#460) and
authorization.read stays _rbac-gated. Secrets are never logged.

Tests: 22 new (17 handler contract + 5 wiring through the real update path),
including the round-trip that IS the bug — the real strip generates the payload
that is fed back as the update, so strip and preserve cannot drift apart.
Mutation-tested: removing the restore call fails the round-trip with the secret
nulled, while the overwrite/explicit-null/ordinary-field tests still pass.

Closes #463
Refs #459, #389, #460, openconnector#245, openconnector#147
…omitted

Only call restoreWriteOnlyValues() when there is actually something to restore,
mirroring how the read path gates its strip on RenderObject::schemaHasWriteOnlyRule().

Caught by a real regression, not by inspection: ObjectHandlers\SaveObjectTest::
testSaveObjectWithExistingUuidUpdatesObject went red because it mocks
PropertyRbacHandler, so the unconditional restoreWriteOnlyValues() call returned
PHPUnit's default for an `array` return type — an empty array — which wiped
$preparedData and emptied the saved object. The sibling getUnauthorizedProperties()
call never had this problem because it sits behind a hasPropertyAuthorization()
gate that the mock answers false.

Patching the test would have hidden a genuine fragility. The overwhelming majority
of schemas declare no write-only location, and for those this rule should be
provably inert rather than merely harmless — so the fix is the short-circuit, which
also keeps the update hot path doing zero work for them.

Re-verified: mutation test still fails with the secret nulled when the restore is
disabled, so the guard does not weaken the contract.
…on save — a load/edit/save round-trip no longer destroys the secret (#463)' (#468) from wip/writeonly-save-side-restore-463 into development
…ent with or#463 write-only preserve

Reunites the two diverged `development` lineages. GitHub carried one stranded
commit (2de1328, wave-12 engine-primitive enforcement, ~7 weeks orphaned by the
Codeberg migration); Codeberg carried the 1510 commits of subsequent work,
including or#463/PR #468 (save-side write-only preserve).

Both sides are security. Neither was dropped.

Kept from wave-12:
  * PermissionHandler default-closed policy (`enforce_default_closed`, opt-in,
    BC default false) + per-object `_authorization` override (Fix 5).
  * ValidateObject::validateReadOnlyConstraints() read-only write-path
    enforcement, wired from ObjectService.
  * SaveObjects::applyBulkSafeguards() per-row bulk safeguards.
  * ConditionMatcher dot-syntax.
  * SaveObject::callerIsAdmin().

Kept from or#463:
  * SaveObject's save-side write-only preserve (collectOmittedWriteOnlyPaths on
    the raw payload, restoreWriteOnlyValues after prepareObjectData and before
    the PUT-semantic null-fill). Byte-for-byte unchanged.

Semantic resolutions:
  * PermissionHandler::evaluatePermission — combined BOTH: the fail-closed
    try/catch on AuthorizationUnresolvableException AND wave-12's `object:`
    argument for the per-object override.
  * PermissionHandler unlisted-action branch — kept this lineage's unconditional
    deny, which strictly subsumes wave-12's narrower flag-gated deny.
  * PermissionHandler constructor — wave-12 added IAppConfig as an optional
    trailing arg while this lineage already had it as a REQUIRED arg; the naive
    merge produced a duplicate parameter (fatal). Kept the required one and
    dropped wave-12's now-unreachable null guard.
  * SaveObject::setSelfMetadata — kept this lineage's admin-OR-verified-member
    organisation gate (wave-12's admin-only gate is a subset; membership is
    verified, so SB1 stays closed) and routed its admin arm through wave-12's
    callerIsAdmin() so the helper is not orphaned. Retained the @self.folder
    access-control block.
  * SaveObjects constructor — kept this lineage's parameter order (a superset
    adding eventDispatcher + auditTrailMapper); wave-12 fixtures rebound by name.
  * ValidateObject — union: this lineage's memoized validator/prepared-schema
    caches plus wave-12's read-only methods.
  * appinfo/info.xml — version bumped to 0.2.17-unstable.15 (above both parents);
    kept licence EUPL-1.2 (GitHub's `agpl` predates the relicence).

Test fixtures adapted (no enforcement weakened):
  * Wave12BulkSafeguardsTest / Wave12PermissionHandlerDefaultClosedTest /
    SaveObjectTest admin case — wave-12's positional constructor lists would now
    bind to the wrong parameters; rebound with named arguments.
  * PermissionHandlerRbacTest — its IAppConfig stub returned true for EVERY key,
    which silently switched wave-12's opt-in default-closed policy on and broke
    two default-open assertions. Made the stub key-aware (production reads a
    real per-key value defaulting to false).

Verification: wave-12 suites (Bulk 7, DefaultClosed 10, ReadOnly 9, DotSyntax 12)
and or#463 suites (SaveObjectWriteOnlyPreserve 5, PropertyRbacHandlerWriteOnly-
Preserve 17) all pass. tests/Unit/Service/Object + tests/Unit/Db fail exactly the
same 13 tests, by name, as pristine origin/development — zero new failures.
phpcs: 0 errors on all changed lib/ files.
Reconcile diverged development: land wave-12 engine-primitive enforcement alongside or#463 write-only preserve
…side prerequisite

Adds a thin wire so ObjectService::searchObjectsPaginated() can opt into
matching on document body text via ChunkMapper::searchByKeyword() — the
storage layer (openregister_chunks + PostgreSQL tsvector GIN) already
ships from the merged hybrid-document-search change; only missing piece
is the query-path exposure.

Spec-proposal only — no runtime code in this commit:

- proposal.md — why + what changes + cross-refs (kind: code, no depends)
- design.md — 5 decisions with alternatives + risks + rollback
- tasks.md — 7-item checklist through impl + tests + archive
- specs/zoeken-filteren/spec.md — ADDED ZKN-CONTENT-001 / -002 / -003

`openspec validate expose-content-search-in-object-service` → is valid;
`openspec status` → 4/4 artifacts complete (proposal + design + tasks +
specs; applyRequires satisfied).

Downstream consumer (HARD-blocked on this): opencatalogi #136
add-document-content-search (WOO-517). Its proposal frontmatter carries
`depends_on: openregister:expose-content-search-in-object-service`; the
slug matches this change so Hydra's dep-block will lift once this PR
merges to development.

Refs WOO-517 (OR-side prerequisite subtask).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…-service (WOO-517 OR-side, tracking issue #472)

Written by the plan-to-issues flow. Contains:
- tracking_issue: 472 (Codeberg openregister #472)
- base_branch: development
- feature_branch: feature/472/expose-content-search-in-object-service
- 7 parsed tasks from tasks.md with spec-refs + acceptance criteria

Hydra label wilco-ready-to-build attached to the tracking issue.
Downstream opencatalogi #139 (WOO-517) is HARD-blocked on this
change merging — Hydra's depends_on gate on that change lifts once
this PR merges to development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… (WOO-517 OR-side prerequisite)' (#471) from feat/expose-content-search-in-object-service into development

Reviewed-on: https://codeberg.org/Conduction/openregister/pulls/471
…d register/schema

enforceReadOnlyOnUpdate() loaded the prior record with a bare
$this->objectMapper->find($uuid, ...), leaving find() to rely on the request's
URL scope. Under a stale scope that path falls back to the deliberate
cross-table search (openregister#1520). We are on the save path here with
$this->currentRegister and $this->currentSchema already resolved, so pass them
and take the scoped register/schema-table lookup directly — no reason to risk
the fallback on a write-path method that runs on every update.

Defensive hardening, not a hot N+1: in the common save flow the URL scope
matches and the bare find() already hits the scoped path. This removes the
dependency on that being true.

Behaviour is unchanged — Wave12ReadOnlyEnforcementTest (9 cases) still passes;
find() is mocked, so the arguments do not alter the tested contract. Split out
of the now-closed #470, whose only real delta over the #2020 readOnly port was
exactly this call. See #2015.
perf(objectservice): scope the readOnly guard's find() to the resolved register/schema
…get()

getUses()/getUsedBy() resolved RegisterMapper and MagicMapper through the
service locator (\OC::$server->get(...)) at call time. Under unit test
\OC::$server is not a real container, so get() returned null and the next
$registerMapper->findAll() threw 'Call to a member function findAll() on null'
— four RelationHandlerTest cases errored, and the code was untestable by
construction (the test authors left a comment acknowledging the workaround).

MagicMapper was already constructor-injected as $this->objectEntityMapper, so
only RegisterMapper needed adding. Both service-location call sites now use the
injected instances. No behavioural change in production — the container returns
the same singletons — but the dependency is now explicit and mockable.

RelationHandlerTest: 71 pass / 4 error → 75 pass / 0 error. Full
tests/Unit/Service/Object/ drops from 4 errors to 0 (the 2 remaining
PermissionHandlerCustomScope failures are unrelated, tracked in #2023).

No manual construction of RelationHandler exists (pure DI, auto-wired by type),
so the new parameter resolves automatically. Part of #2023.
fix(relationhandler): inject RegisterMapper instead of \OC::$server->get()
…henticated grant

The custom-scope listener dispatch (CustomScopeEvaluatingEvent — how a consuming
app votes allow/deny on a custom verb it owns, e.g. ZGW besluit_nemen) sat AFTER
the 'authenticated' pseudo-group grant in evaluatePermission(). For a schema with
no authorization block that grant is default-open, so it returned true and
short-circuited before any listener was consulted.

Net effect: on exactly the unconfigured-schema case the mechanism exists to serve
— a schema that declares no rules for its custom verb — the veto was DEAD. A
listener voting deny was silently ignored; the action was granted to any
authenticated user. This is the orphaned-capability shape: dispatched, wired,
covered by a test, and never actually reached on the path that mattered.

PermissionHandlerCustomScopeTest proved it: both listener-voting tests failed
with zero events dispatched — the dispatch was never reached.

Fix: move the custom-scope dispatch above the 'authenticated' grant. Ordering is
now: admin bypass → custom-verb listener vote → authenticated/default-open →
delegation → per-group → public-inherit. Admin bypass still precedes the veto
(admins remain un-vetoable, matching the canonical-action short-circuit).

SEMANTIC CHANGE — flagged for review: a listener verdict now takes precedence
over BOTH the default-open grant AND an explicit 'authenticated' grant, for
custom (non-canonical) verbs only, and only when a listener actively votes. This
is the intended 'the app that owns the verb has final say' contract. When no
listener votes, dispatch returns null and evaluation falls through unchanged —
so behaviour is identical for every schema that has no custom-scope listener.

Verified: PermissionHandlerCustomScopeTest 3/5→5/5; all 114 PermissionHandler
tests green; full tests/Unit/Service/Object/ 2 failures→0. Closes the cluster-B
half of #2023.

Not live-verified against a running instance (shared dev off-limits).
fix(authz): consult the custom-scope veto before the default-open authenticated grant
…elopment

Uncurated preservation snapshot. The 8080 openregister checkout was on
wip/public-audit-query-endpoint (241 commits behind origin/development) with 40
modified + 25 untracked files in the working tree — a mix of work already landed
on development in a different form (stale copies) and possible genuine local WIP
(scheduled-reports, notification delivery windows, MCP attribute tooling,
McpProviderBridge/ToolRegistry edits).

Committed verbatim so nothing is lost while the checkout is brought in line with
origin/development for live testing. NOT reconciled against development — several
files here are older than their development counterparts, so this must be
diffed against development and cherry-picked deliberately, never union-merged
(the union-merge-drops-modifications hazard). Scratchpad audit notes excluded.

Snapshot only — do not fast-forward development onto this.
…spection fix

DBAL virtual registers (type:database Sources introspected into a register)
served object reads live but had no aggregation path: the AggregationRunner
only knows the Postgres magic-table fast path + a PHP fallback that hydrates
magic-table rows, so every /api/objects/aggregations/{register}/{schema}/
value|timeseries|grouped call over an external register returned
{"value":0,"backend":"php-fallback"}. Dashboard KPI/stats-block + chart
widgets bound to such a register therefore read 0 while their list/detail
pages showed live data.

- DbalObjectSourceProvider::aggregate() computes count/sum/avg/min/max, a
  categorical groupBy, or a date_trunc time-bucket series live in the external
  DB, reusing the read path's source/table/column resolution, quoting and
  allowlisting (Postgres native; MySQL DATE_FORMAT for fixed-width gaps).
  Filter vocabulary matches the native path (eq + in/notIn/gt/gte/lt/lte/ne),
  restricted to allowlisted columns. Fails closed (503/502) like the reads.
- AggregationRunner::runAdhoc() delegates to it (tryDbalAggregation) before the
  magic-table paths when the schema carries x-openregister-object-source
  provider 'dbal-source'. A provider error degrades to null (empty widget,
  never a 500). The schema-level list RBAC gate still applies.
- SqlTypeMapper: map bytea (BINARY/BLOB) to string with no format instead of
  the unsupported format:'binary', which OR's PropertyValidatorHandler rejects
  — introspecting any table with a bytea column (e.g. tender_raw_json.
  raw_json_gz) previously 502'd. bytea columns are already non-filterable.

Verified live against an external Postgres (spectr → intelligence-db): KPI
counts and monthly timeseries return backend:dbal-source with real values
(tenders 81950, requirements 157991, competitors 1468, ...). phpcs clean.
…-aggregations

feat(dbal): aggregations for external virtual registers + bytea introspection fix
… of 500

An object detail page loads its integration tabs via
GET /api/objects/{register}/{schema}/{id}/integrations/{integrationId}.
For a DBAL virtual (external) register the object rows live in an external
database, so both the eager object load (ObjectService::setObject → MagicMapper)
and the integration providers resolve through a magic table that does not exist
(relation "oc_openregister_table_<reg>_<schema>" does not exist → 500). Every
integration tab on a detail page bound to an external register therefore 500'd.

ObjectIntegrationsController::index now detects an object-sourced schema
(Schema::getObjectSource() !== null) BEFORE guardObjectAccess — whose setObject
would throw — and returns an empty PaginatedResult. External read-only objects
carry no OR-native integration links, so the result is a constant empty set and
skipping the per-object RBAC guard leaks nothing. Registered SchemaMapper on the
controller's manual DI factory in Application.php.

Verified live (spectr → intelligence-db): the 9 integration endpoints
(deck/polls/bookmarks/shares/email/openproject/xwiki/cospend/sync-contract) that
returned 500 now return 200-empty; detail-page console errors dropped 28 → 19
(remaining are frontend 404 probes for other NC-native tabs). phpcs clean.
…ternal-register-guard

fix(integrations): return empty for external-register objects instead of 500
… fully superseded

Reconciliation of the 8080 local working-tree snapshot against development:
  * 0 files new to development
  * 14 identical (already landed)
  * 12 stale (3-way merge adds nothing over dev)
  * 39 conflicting — local and dev edited the same regions, and on every one
    development is larger and was actively developed 07-14..07-16, AFTER the
    wip's 07-13 base. The 542 local-only lines are divergent phrasing, not new
    logic. The only method absent from dev (ScheduledReportService::exportBytes)
    is a private helper dev already implements as runExport(). Both local
    migrations already exist on dev.

Development independently built more complete versions of every feature in the
snapshot (scheduled reports, notification delivery windows, MCP attribute
tooling, credential providers). Taking any of this branch's content would REVERT
development to older code, so this is a '-s ours' merge: the branch is recorded
as merged and retired; development's tree is kept verbatim. Nothing is lost —
the snapshot remains reachable in history for reference.
…aggregation

- DbalObjectSourceProvider: introspect an external DBAL connection into a
  virtual register (Source type: database), reading objects/aggregations
  directly from the external schema.
- AggregationRunner: DBAL aggregation execution path for external sources.
- ObjectIntegrationsController: schema-aware integration endpoint; inject
  SchemaMapper via DI (Application.php registration).
- SqlTypeMapper: external column-type mapping adjustments.

Live-verified 2026-07-18 (spectr <-> intelligence-db external Postgres).
…r-source-provider

feat(dbal): external database-backed register source provider + DBAL aggregation
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