Skip to content

Release: merge development into beta - #1711

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

Release: merge development into beta#1711
github-actions[bot] wants to merge 2743 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 15, 2026 07:55
…ommission)

Hermiq is the agent engine's canonical home (hydra ADR-034 Amendment
2026-07-05). This makes the #305 compat proxy the default answerer,
removes OR's own AI Chat + Agents SPA, and fixes the getChatStats
multi-tenant leak in the remaining fallback engine.

- ChatProxyHandler: chat.proxyTo defaults to 'hermiq' when unset;
  operators opt out with any other value (e.g. 'off'). Fail-open
  fall-through to OR's local engine is preserved (#305).
- getChatStats: no active organisation -> zeros, never instance-wide
  totals (multi-tenant information leak).
- Removed OR chat/agents SPA: chat + agents manifest pages and nav,
  ChatIndex/AgentsIndex, ChatSideBar, agent modals, conversation/agent
  stores, message/conversation/agent entities, ui#chat route,
  UiController::chat(), AgentsController::page().

BREAKING CHANGE: OR no longer ships its own chat/agents UI; users chat
via the fleet AI companion (hermiq) and manage agents in hermiq. The OR
chat REST routes remain as a proxied compat window. Full engine deletion
(ChatService/Chat handlers/tables) is a tracked follow-up.

NOTE: hermiq's MigrateAgentData still reads the legacy OR chat tables,
so they are intentionally retained until the migration window closes.
- ImportServiceMigrationPackTest: ImportService gained a container param
  (live-updates flush) after this test was written on a parallel branch.
- ObjectServiceTest::testFindSkipsRenderingWhenRenderFalse: stubbed the
  getCachedEntities no-op that the request-hygiene branch removed; schema
  resolution now goes through SchemaMapper::find directly.
- bootstrap-unit-local: load NextcloudInternalStubs (OC\Hooks\Emitter et al)
  like bootstrap-unit does — mocking IRootFolder no longer depends on suite
  load order (clears ~900 order-dependent errors in the bare-container run).
The fast list path now redacts write-only fields through
RenderObject::redactWriteOnlyFromRows; the test's OC server stub must
resolve it like it already does MagicMapper.
…+ stub load-order fix' (#415) from fix/post-merge-test-integration into development
Add encryption-at-rest for schema-flagged object property values as an
OpenRegister data-platform primitive, so every app inherits it instead of
reimplementing crypto per app. Reuses OCP\Security\ICrypto (the same
primitive TenantKeyService / SourcesController / WorkflowEngineRegistry
already use for credentials) — no new crypto.

- Schema flag x-openregister-encrypted: true (Schema::hasEncryptedProperties/
  getEncryptedProperties, mirroring the writeOnly helpers).
- FieldEncryptionHandler: versioned envelope (openregister:enc:v1:<ciphertext>)
  so mixed plaintext/ciphertext during rollout is unambiguous; idempotent.
- Encrypt-on-save in SaveObject::prepareObjectData() (last step before persist).
- Decrypt-on-read in RenderObject::renderEntity()/redactWriteOnlyFromRows(),
  strictly AFTER the writeOnly/property-authorization redaction and only on
  properties still present — an unauthorized caller never sees ciphertext or
  plaintext, only the same absence every redacted field gets.
- Search/facet exclusion: no dedicated magic-table column for a flagged
  property; excluded from full-text scan and facetable fields; filtering on it
  throws EncryptedFieldFilterException -> HTTP 400 via a new middleware, never
  a silent zero-row result (explicit rethrows added to MagicMapper's
  catch-and-degrade layers so fail-loud survives them).
- Migration: occ openregister:encrypt-field --property=<name> [--dry-run]
  [--register] [--schema], idempotent, fails loud on per-row failure.
- Decryption failure never silent: structured @openregister_decryption_error
  marker + ERROR log on the read path, or a thrown FieldDecryptionException
  for strict callers (the migration command).

No behaviour change for any schema that does not use the flag.

Tests: 41 new unit tests across FieldEncryptionHandler, Schema, SaveObject,
RenderObject (authorized/unauthorized/decryption-failure/mixed-rollout),
MagicSearchHandler filter rejection, and the migration command.
Full openspec change + threat model in design.md.
…enregister-encrypted)' (#417) from wip/field-level-object-encryption into development
Move the completed change to openspec/changes/archive/2026-07-14-field-level-object-encryption/
and materialise the new field-level-encryption capability into
openspec/specs/. Quality-gate tasks (7.x) marked done: PHPCS/PHPMD/PHPStan/Psalm
clean on changed files, 41 new tests green, full suite diffed byte-identical
against a pristine origin/development baseline (zero new failures).
…tion change' (#418) from wip/archive-field-encryption into development
…413) from feat/or-chat-engine-removal into development
… wipe the whole configuration (#419)

Schema::validateConfigurationArray() validated every configuration key in a
single pass and let the first InvalidArgumentException propagate out of
setConfiguration(). On a direct API save that surfaces as a 400, but on APP
IMPORT the throw is silently swallowed upstream — so a single invalid value
(observed: pipelinq `lead` carried linkedTypes `["mail","decidesk-decisions"]`,
and `decidesk-decisions` is not a valid Nextcloud linked type) caused the
schema to lose its ENTIRE configuration, including x-openregister-mcp,
x-openregister-lifecycle, x-openregister-calculations and mailObjectTemplate.
The visible symptom was lead deriving no MCP tools at all.

Extract the per-key if-chain into validateConfigurationEntry() and wrap the
call in a try/catch inside the loop: an invalid value now drops only the
offending key (recorded in droppedKeys so SchemaMapper can warn) and every
other key is preserved. Adds a regression test asserting a bad linkedTypes
value drops only linkedTypes while objectNameField and x-openregister-mcp
survive.
… one bad value can't wipe the whole config (#419)' (#421) from wip/fix-fragment-config-merge into development
While the dashboard is scoped to a register+schema via the sidebar
filter, DashboardIndex holds one or-collection subscription through the
package object store's liveUpdatesPlugin (same API as the object list
view). Events are refetch hints only: a 750ms trailing debounce
coalesces bulk-import bursts into a single refetch of the
object-CRUD-derived widget data (register totals, objects-by-* charts,
audit-trail statistics). Subscription re-scopes on filter change and is
released on unmount, with the proven pending-type + epoch race guards.

Unscoped ('all registers') view, register/schema CRUD counters and
search-trail widgets stay one-shot — no matching push event key exists
in the dialect; documented in the openspec change.

Refs openregister#410
Adopts the beta.212 default-on liveUpdatesPlugin in createObjectStore.
Our explicit liveUpdatesPlugin() instance in src/store/modules/object.js
wins the by-name dedupe, so existing ObjectsList/ObjectDetails subscribe
wiring (PR #402) is unchanged. Also picks up the transport fix where the
first subscription was stranded when the push probe reports unavailable.
Resolve all referential-integrity CASCADE targets with one batched
cross-table lookup, soft-delete them with one CASE-UPDATE per magic table
via MagicMapper::softDeleteMultipleObjectEntities (per-object
updating/updated event pairs preserved), and write one multi-row audit
INSERT via AuditTrailMapper::insertAuditTrails with rows carrying the
canonical cascade-context fold (triggerObject/triggerSchema/action_type/
property) — instead of a full cross-magic-table scan and a single-row
audit INSERT per target inside the delete transaction.

Targets the batch lookup cannot resolve, and every target when the
batched resolve or write fails, fall back to the unchanged per-object
pipeline (kept verbatim as applyPerObjectCascadeDelete). RESTRICT,
SET_NULL and SET_DEFAULT handling and the SET_NULL -> SET_DEFAULT ->
CASCADE (deepest first) execution order are untouched. The previously
unused organisationId parameter now feeds the deletion attribution
metadata.

Closes #409
Fixes openregister#406. sealRow() and sealRows() both read a predecessor
hash and then UPDATE, so interleaved passes could chain a boundary row
over a stale predecessor and trigger a false tamper alarm at the next
verifyChain(). Both now take the same exclusive ILockingProvider lock
(key openregister/audit-seal) around the whole critical section, with
bounded fail-soft acquisition: on sustained contention a warning is
logged and rows stay unsealed for a later pass instead of blocking the
write path; the lock is always released, including on throw.

getHashBefore() now returns the nearest PRIOR SEALED row's hash
(filtering NULL/empty), exactly mirroring verifyChain()'s skip-null
walk — sealing after an unsealed fail-soft leftover no longer chains
from genesis and permanently breaks verification at that link. This
also fixes ranged verification starting right after an unsealed row.

New tests cover lock acquire/release, lock-unavailable fail-soft for
both seal paths, release-on-throw, retry after a transient conflict,
and unsealed-predecessor chaining verified end-to-end against
verifyChain(). OpenSpec change harden-audit-seal-concurrency records
the delta on the audit-hash-chain spec (validated --strict).
Fixes openregister#407. The class had zero callers in lib/ and
appinfo/ — SaveObjects performs bulk chunking through its own internal
processObjectsChunk(), making the handler an orphaned capability:
implemented, tested, DI-resolvable, invoked by nothing. Removes the
class, its unit tests, and its section in the SaveObject handlers
integration test, and corrects the object-lifecycle REQ-004,
data-import-export, and method-decomposition spec references that
presented it as the bulk chunking implementation.
… descriptors

Adds two host-locked credential-broker providers for api.anthropic.com so
Hermiq's anthropic chat provider can inject the secret via BrokerHttpClient:
- 'anthropic' — API key, injected as x-api-key (authMode: api_key)
- 'anthropic-oauth' — Claude Max/Pro OAuth token, injected as Authorization:
  Bearer (authMode: oauth). PERSONAL-scope only per the Anthropic ToS.
Both allowRule POST /v1/messages + GET /v1/models. Catalogue version 1.4.0 -> 1.5.0.
89 credential unit tests green.
…oker providers' (#428) from feat/anthropic-credential-provider into development
…ionally (#420)

The #389 fix made the writeOnly BODY strip a hard, unconditional render-boundary
rule, but the `relations` search-index mirror (surfaced as `@self.relations`) was
only stripped on the older `_rbac === true` path. OpenRegister mirrors any
reference-shaped scalar property (a UUID/URL value) into that index, so a
`writeOnly: true` secret whose value is reference-shaped still leaked via
`@self.relations` on an admin / `_rbac: false` read — the same class of bug as
#389, one copy deeper.

Reproduced live (NC34, admin GET+create): a writeOnly `secretToken` was absent
from the body but returned in full under `@self.relations`. After the fix both
create and GET return `@self.relations` without the value, while a genuine
non-writeOnly reference still appears there.

Fix: in the unconditional writeOnly path in `renderEntity()`, also run
`stripWriteOnlyProperties()` over `getRelations()` and write it back — same
boundary as the body strip. Two regression tests go through the real
`renderEntity()` `_rbac` gate (not the handler helper directly): one asserts the
mirror is stripped under `_rbac=false`, one asserts a non-writeOnly relation
survives.
…ions mirror unconditionally (#420)' (#429) from wip/fix-writeonly-relations-mirror into development
Design + spec delta for capturing the acting context at dispatch time and
re-establishing it inside chunk-level QueuedJobs, unblocking the three
session-entangled listeners the optimize-write-path-performance audit could
not defer. Extends ADR-009 Rule 5 (async outbound I/O) with an explicit
identity-forwarding contract: impersonate the captured user, always restore
in finally, never resurrect stale organisation authority, at-least-once
delivery with stale no-op idempotency.
#408)

- DeferredListenerContext: serializable acting-context snapshot (userId,
  active organisation uuid, per-object entries) that round-trips loss-free
  through oc_jobs.argument; malformed payloads degrade to logged no-ops.
- ListenerDeferralService: captures the actor once per request, buffers
  per-job-class entries and enqueues chunk-level jobs (flush at chunk size
  and at request shutdown), with per-entry dedupe keys and an
  openregister/listenerDeferral kill switch (inline restores synchronous
  behaviour). Fail-soft: capture/enqueue failures never break the save.
- ActorForwardedJob: abstract QueuedJob that re-establishes the captured
  user via IUserManager + IUserSession::setUser() and ALWAYS restores the
  previous session user in a finally block so a cron worker never leaks
  identity across jobs; unresolvable users skip (never run under a wrong
  identity); organisation drift is logged, jobs run under the user's
  CURRENT authority.
- DeferredEntryObjectResolver: stale-safe re-fetch by (uuid, register,
  schema); gone or soft-deleted objects resolve to null so at-least-once
  re-runs converge instead of resurrecting state.
…ns and threshold evaluation to actor-forwarded jobs (#408)

The three listeners the write-path audit flagged as session-entangled now
run their heavy work in background jobs under the forwarded actor; each
keeps only a request-cached schema-config gate inline:

- TranslationProjectionListener: created/updated/transitioned projection
  moves to TranslationProjectionJob (translator attribution now correct in
  cron); delete-time purge stays inline (bounded DELETE, entity not
  re-fetchable post-delete); schemas without translatable properties keep
  the inline stale-row prune for behaviour parity.
- AnnotationNotificationListener: rule evaluation + delivery (sync HTTP /
  mail) move to AnnotationNotificationDispatchJob; updated entries carry
  the pre-update snapshot (old state is unrecoverable later) while new data
  is re-fetched at run time; the updated + calculatedChange dispatch pair
  is preserved.
- AggregationThresholdListener: evaluation logic extracted verbatim into
  ThresholdEvaluationService (shared by job, delete path and kill switch);
  created/updated/transitioned evaluations move to AggregationThresholdJob
  with entries deduped per (register, schema) so a bulk save of one schema
  triggers one evaluation; deletes evaluate inline (delete-driven lt/lte
  crossings would otherwise be lost).

All jobs are idempotent reconciliations against current state (stale
entries no-op); duplicate delivery on at-least-once re-runs is suppressed
by the dispatcher's dispatch-log dedup and the distributed rising-edge
state cache.
…tionService (#408)

Ports the former listener rising-edge tests (fire on below-to-above only,
no refire while above, refire after dip, non-threshold triggers ignored) to
the extracted service and adds hasThresholdNotifications gate coverage and
evaluation-failure logging.
…rom nearest sealed row; drop dead chunk handler' (#425) from feat/audit-seal-hardening into development
…er-object fallback' (#424) from feat/integrity-cascade-batch into development
…heavy save listeners off the write path' (#430) from feat/actor-forwarded-jobs into development
…oped dashboard widgets' (#422) from feat/dashboard-live-updates into development
rubenvdlinde and others added 30 commits July 26, 2026 17:43
…ter-resolver integration collections (#2142)

All three tests/integration/*.postman_collection.json collections were red
against NC stable32, live-verified on a disposable NC32 instance:

- apphost-observability: real bug. GET /api/health and /api/metrics both
  503'd with "App controller is not enabled" — Application.php's
  registerAppHostObservability() wired every supporting AppHost service
  (ManifestLoader, HealthCheckExecutor, MetricsEngine, metric sources) but
  never registered the GenericHealthController/GenericMetricsController
  aliases routes.php's 'AppHost\Controller\GenericHealth#index' /
  'AppHost\Controller\GenericMetrics#index' route names resolve to, so
  self-dogfooding of OpenRegister's own ADR-006/ADR-040 observability
  contract was dead on arrival. Fixed by registering both controllers
  under the exact DI key NC's RouteParser derives. Also fixed the
  collection's health_path/metrics_path, which pointed at a
  never-existed /api/apphost/health path and the bare (no /index.php/)
  URL form that 404s on the CI PHP built-in server.

- magic-mapper-import: env/setup gap + stale test. The collection
  referenced dev-only fixtures never committed to the repo
  (softwarecatalogus_register_magic.json, magic-mapper-data/module.csv)
  and posted to a route that no longer exists
  (/configurations?force=true; the real route is
  /api/configurations/import). Rewritten to self-provision its own
  register + schema and exercise RegistersController::import's actual
  status codes (400 missing schema, 404 unknown register, 200 success)
  against a committed CSV fixture, then verifies the imported rows via
  GET /api/objects (object storage is unconditionally backed by
  OpenRegister's per-schema MagicMapper tables per
  SettingsService::getSearchBackendConfig()). Added a .gitignore
  exception so the CSV fixture (blocked by the blanket `*.csv` rule)
  ships with the app.

- register-resolver: real bug + URL fix. GET /api/registers/{unknown}
  threw an uncaught DoesNotExistException, 500ing with an HTML error
  page instead of a clean 404 (the assertion already tolerated 500,
  masking it). RegistersController::show() had no try/catch, unlike the
  identical DoesNotExistException guard SchemasController::show() already
  has. Added the same catch(DoesNotExistException -> 404) /
  catch(Exception -> 500) pattern, tightened the collection's assertion
  to require the clean 404, and updated the unit test
  (testShowThrowsWhenNotFound -> testShowReturns404WhenNotFound) that had
  documented the broken behaviour as expected. Also fixed every request
  URL: base_url is overridden by CI to a bare host
  (http://localhost:8080), so paths must hardcode
  /index.php/apps/openregister/api/... the way every other collection in
  this directory does — this collection's base_url default baked in the
  app path instead, which the CI override silently stripped.

All three collections pass 0 failures on a disposable nextcloud:32-apache
instance, run exactly as the Code Quality "Integration Tests (Newman)"
job invokes them. tests/Unit/Controller/RegistersControllerTest.php and
tests/Unit/AppHost/* (152 + 121 tests) pass; phpcs clean on both touched
lib/ files.
Sync the 3 new requirements into the canonical data-import-export spec and
archive the change directory per repo convention.
…ug-uniqueness

fix(import): scope schema slug uniqueness to a register's own set, not global/per-app
…andler (#2151)

The Newman "openregister-crud" and "rbac" integration collections passed in
isolation but failed in the CI job's combined sequential run (all ~14
collections against one shared NC32 instance, no DB reset). Root cause was a
register-resolution gap in the authorization path, exposed by cross-collection
state pollution.

PermissionHandler::getRegisterForSchema() and getRegisterAuthorization() loaded
the register via RegisterMapper::find($id) with the default org-scoped
multitenancy filter. Their sibling id lookup
(getAllRegisterIdsWithSchema) is deliberately global/unscoped, so once the id is
known the entity fetch must be too — it is an authorization-policy read, not a
tenant-scoped data read. When an earlier collection (openregister-crud) left
multitenancy enabled and switched the admin's active organisation (never
restoring it, and deleting its test org), a legitimately-linked register whose
organisation no longer matched the caller's active-org pointer became
unresolvable: find() threw DoesNotExistException, which was re-thrown as
AuthorizationUnresolvableException and FAIL-CLOSED — denying even a non-admin
creating/reading their OWN object in an OPEN schema (404s, IDOR fail-closed
firing on a legit register).

Fix: load the register with _rbac:false, _multitenancy:false in both resolvers.
This does NOT weaken tenant isolation — object-row multitenancy is enforced
separately on the data reads (organisation column filters + MagicRbacHandler's
active-organisation condition). Verified: rbac section 3 (cross-org isolation,
"admin org-Z object filtered out", "cross-org GET denied 404 no existence leak")
still passes. The fail-closed remains intact for genuinely-unresolvable schemas
(no register link → null → open baseline is unchanged; real DB errors still
throw).

Verified: full 14-collection sequential run on a fresh NC32 = 0 failures
(baseline reproduced rbac failing). Unit tests: 114 PermissionHandler +
RegisterMapper tests green on nextcloud:34.
…uses) (#2152)

Both the openregister-crud and rbac Newman collections failed in the Code
Quality "Integration Tests (Newman)" job. Reproduced exactly as CI (newman on
the host runner, CWD = tests/integration, one fresh NC32+Postgres container,
no reset). Root causes and fixes:

1. sabre/xml vendor-shadowing (the real cause of BOTH failures)
   OpenRegister's composer.lock pinned sabre/xml 4.1.0 (pulled transitively by
   the sabre/vobject dev-dep). 4.1.0's XmlSerializable declares
   `xmlSerialize(): void`; core Nextcloud ships sabre/xml 2.2.11 (no return
   type). OR's autoloader registers 4.1.0 first, so core's CalDAV classes fail
   the signature check with a PHP fatal the moment any calendar is provisioned.
   - rbac: POST /ocs/v1.php/cloud/users (create e2euser) -> calendar home
     provisioning -> CalDAV -> fatal -> HTTP 500 (S0).
   - crud: "File 1: Create File on Object" is the first file op on a fresh
     install, so OR creates its "openregister" system user on the fly ->
     same calendar fatal -> 500, and File 3-6 then 404.
   Fix: pin sabre/xml to ^2.2 (matches the platform) in composer.json and
   regenerate the lock (sabre/xml 2.2.11, sabre/uri 2.3.4). sabre/vobject 4.5.8
   is unaffected (it allows sabre/xml ^2.1).

2. rbac 1a: core first-login skeleton-copy lock race
   e2euser's first authenticated request triggers Nextcloud core's one-time
   skeleton copy (completeLogin -> copySkeleton), which under the DB locking
   provider (CI has no Redis) raises a LockedException on
   "/<uid>/files/Documents" -> 500 on whatever endpoint is first. Added an
   "S0b" warm-up request in the rbac Setup folder that logs e2euser in once
   (tolerant of any status) so the RBAC assertions see a settled home. No RBAC
   behaviour is weakened; 1a still asserts 403/404.

3. crud file-upload fixture path (hygiene)
   The Import requests referenced an absolute container path
   (/var/www/html/custom_apps/...) that never resolves on the CI host runner,
   so the file silently failed to load and Import tested nothing. Committed a
   small tests/integration/test-import.csv fixture (with a .gitignore
   exception) and switched the form-data src to a collection-relative path;
   Import now returns 200.

4. UserService quota query (real bug, fired in CI on every /user/me)
   getUsedSpaceMemorySafe queried a non-existent oc_storages.size column and
   joined oc_storages.id (varchar) = oc_mounts.storage_id (bigint), which
   PostgreSQL rejects (SQLSTATE[42883] operator does not exist). Rewrote it to
   read oc_filecache.size for the user's home-mount storage root (bigint join,
   no cast needed, works on Postgres/MySQL/SQLite) and hardened the catch to
   \Throwable so a stray Error can never turn a quota lookup into a 500. Fixes
   36 pre-existing UserServiceTest errors.

Verified from a CI-faithful host-CWD reproduction: crud 194/194 and rbac 41/41
pass, full 14-collection sequence 0 failures, UserService unit suites green.
…tandard (#2154)

The change captured only the initial design (5 requirements, most tasks open). It
now reflects everything shipped and merged across the fleet:

- 6 new requirements — schema-marker mechanism (REQ-FCS-006), Ed25519 signing +
  verification (007), per-org group RBAC (008), config sets (009), repo-creation +
  topic-tagging + fetch bridge (010), and installable config-set repositories /
  OpenBuild convergence (011). Each with scenarios; `openspec validate --strict`
  passes.
- tasks.md updated to the shipped reality with PR references across openregister
  (#2134/#2135/#2140/#2147/#2148/#2149), nldesign #192, the 8 app markers, hermiq
  #126/#127, openbuild #181, nc-vue #242 — plus the real-GitHub end-to-end
  verification and the two external follow-ups.
…t config (#2155)

Two gaps in the store's governance/usability:

- **Publish now honors visibility.** `publish(..., private: bool)` and the
  controller's `visibility: private` param let a freshly created store repo be
  private (previously every store repo was forced public). Defaults to public
  (backward-compatible); the token needs rights to create private repos.
- **The trust controls are API-manageable, not occ-only.** New admin-gated
  `GET/PUT /api/federated-config/trust` read and write the org's source allowlist,
  trusted publisher keys, and publish/install group lists — plus a `trustKey`
  convenience that appends a public key to the trusted-keys list (idempotent). Non
  admins get 403; an unknown field is a 400. This is the backend a governance
  settings UI needs (the config keys previously required `occ`).

Unit: trust read/write round-trip (set fields, append keys idempotently, unknown
field throws). Live-verified on 8080: admin GET/PUT + trustKey append work,
non-admin GET is 403, unknown field is 400.
End-user + admin guide for the federated configuration store: concepts (types, bundles, topics, provenance), choosing the store credential, publish, discover/fetch/install, the admin trust/governance API (allowlist, trusted keys, publish/install groups), and configuration sets / installable app repos. Auto-listed via the Docusaurus autogenerated sidebar.
…ects (create/update/upsert/guarded delete)

The flow engine's nine built-in nodes transform items but none persists anything;
'no code, just flows' cannot materialise an object. Adds openregister.object-write:
per-item create/update/upsert/delete with composite match keys, a per-step write cap
(default 1000, fail-on-exceed), RBAC + attribution through the normal ObjectService
path (fail-closed on ownerless runs — see #2158), and explicit item-level error
semantics. Hardens the existing ObjectService::patchObject() (uuid-safe, RBAC
forwarded, acting user, merge semantics) and widens deleteObject() for sessionless
attribution. First consumers: hydra-cache maintenance + triage results
(hydra-console chain); sibling change: openconnector-flow-nodes.
…elete objects

Adds ObjectWriteNode, the built-in flow node that lets a graph flow write back
to OpenRegister rather than only read and branch. Supports create, update,
upsert and a guarded delete, resolving its target by uuid/slug/id scoped to a
register+schema pair.

Threads an explicit acting user through the write path so a sessionless caller
(flow run, cron, import pipeline) is attributable rather than anonymous:

- ObjectService::deleteObject() takes an optional $currentUser and evaluates the
  RBAC `delete` check against it instead of passing userId: null.
- ObjectService::patchObject() is rewritten from a thin array_merge facade into
  the fleet's supported PATCH-semantic write path: RFC 7386-shaped recursive
  merge (absent key preserves, explicit null clears, lists replace wholesale),
  register/schema scoping, and no int cast on the identifier (the #1638 defect
  class). The merged result still goes through saveObject(), so validation,
  audit trail and events all apply.

Registers the node on FlowNodeRegistrationListener alongside the other built-ins.

Covered by ObjectWriteNodeTest and ObjectServicePatchObjectTest.

Change: or-flow-object-write-node
fixed webpack config so building does not crash
…Y (or#2164)

The per-schema agent-context allowlist was absent from ANNOTATION_VOCABULARY, so
validateConfigurationEntry() silently dropped it on save. Because Hermiq's
AgentContextBuilder (and its JS twin src/utils/agentContext.js) read exactly that
key, EVERY agent leaf on EVERY schema fleet-wide resolved an EMPTY context.

Fail-closed, so never a data leak — an absent allowlist means 'expose nothing' —
but the capability was wholly inert, and invisible by construction: the schema
saved with HTTP 200, no validation error reached the caller, and the only signal
was a log line.

This is the FOURTH recurrence of the same defect class in this list, after
x-openregister-processing, x-openregister-contextchat and x-openregister-shareable,
each of which carries its own comment saying so. Adds two regression tests (flat
allowlist and the per-property refinement form) asserting both that the key
survives the round-trip and that it is not reported as dropped.

Verified live: PUT of an allowlist onto the hydra-cache 'finding' schema now reads
back intact where it previously returned ABSENT.
fix(schema): x-openregister-agent-context was dropped at save — every agent leaf resolved an empty context
…node

docs(openspec): or-flow-object-write-node — flows can write objects (create/update/upsert/guarded delete)
…forge label writes

Three linked fixes that together unblock a governed app commanding a forge.

or#2158 — FlowMcpToolProvider::runFlow() called FlowRunService::queue() without
the acting user, so triggeredBy was null on every agent-dispatched run. Resolves
the user from IUserSession and passes it through; null stays null rather than a
fabricated uid.

or#2158 (deeper cause) — FlowRunService::execute() built the node context from
runUuid + resuming and NEVER copied the run's owner into it. Nodes read
context['triggeredBy'] to attribute what they do: ObjectWriteNode refuses to
write without it, SubFlowNode propagates it to child runs, Hermiq's agent node
runs the turn as that user. Nothing in lib/ ever wrote that key, so EVERY
trigger reached its nodes ownerless and only hand-injected contexts (tests,
harnesses) worked — which is why object-write appeared verified while the
natural path would have refused. An explicit context value still wins, so a
caller can attribute a run to someone other than whoever queued it.

or#2159 — runFlow and flowRunStatus now declare ADR-063 readOnlyHint /
destructiveHint / idempotentHint / scope, so consumers classify them by
declaration rather than by the fail-closed fallback.

or#2165 — the github provider's allowRules permitted no issue-label write, so
the broker refused label-driven forge automation even with a valid PAT. Adds
POST/DELETE on issue labels plus PATCH on the issue, and the GitLab equivalent
(one PUT, since GitLab sets labels as issue fields rather than a sub-resource).

SECURITY-INVARIANT CHANGE, needs an explicit reviewer look: the catalogue
previously enforced 'no provider grants DELETE' as a hard test. That invariant
is retained but narrowed to an allowlist of exactly one sanctioned rule
(github issue-label removal); any other DELETE still fails the test.

Tests: new guards proven to fail without each fix. Full suite delta 0 errors /
0 failures against a CI-red baseline (117/17 before and after).
fix(flow,credentials): attribute flow runs to their owner + permit forge label writes
FlowRunController::test() queued its run without the acting user — the same
defect as or#2158 in FlowMcpToolProvider::runFlow(), in the one dispatch path
that has a session by definition.

Consequence: the run was ownerless, so context['triggeredBy'] was null and every
attribution-requiring node refused. ObjectWriteNode returned 'This flow run has
no owner (triggeredBy); an object write must be attributable.' The interactive
'run this flow now' button could therefore never exercise a write node — the
only runs that appeared to work were harness runs with a hand-injected context.

Live-verified end to end after this fix, on a naturally triggered run with no
injected context: status=completed, triggeredBy=admin, log shows
write1 completed itemsIn=1 itemsOut=1, and the object landed in hydra-cache
(findings 7 -> 8, owner=admin) read back through the API.
fix(flow): attribute interactive test runs to the caller (3rd instance of the ownerless-run defect)
…gistered

Nextcloud writes oc_jobs only when an app is INSTALLED or UPGRADED. Add a <job>
to info.xml without bumping the app version and it is silently never registered
— no error, no warning, and there is no occ background-job:add to correct it.

Measured on a live instance: 24 of 31 declared jobs were absent. Not only flows —
scheduled workflows, schedule reconciliation, sync, webhook retries, notification
flushing, archival retention and DBAL introspection were all inert. 59 flow runs
sat queued and could never execute.

It hides well: the synchronous counterparts keep working. POST /api/flow-runs/test
calls FlowRunService::execute() directly, so interactive flow testing was green
while every asynchronous trigger queued into a void, staying "queued" forever
rather than failing.

This repair step parses the <job> declarations out of info.xml and adds any that
IJobList does not already have. Because it is a repair step it runs on
occ maintenance:repair, so an instance can be corrected without inventing a
version bump. Idempotent, and one unresolvable job is logged and skipped rather
than aborting the run — the point of the step is to recover an instance.

Two traps this hit while being written, both worth knowing:

1. SimpleXML cannot reach a hyphenated element as $xml->background_jobs; it
   needs $xml->{'background-jobs'}. The mismatch returns nothing rather than
   erroring.

2. simplexml_load_file() SILENTLY RETURNS FALSE inside the Nextcloud runtime.
   NC installs a restrictive libxml external-entity loader at boot, and PHP 8
   routes file access through it. The same parse works in a bare php -r process
   and fails under require lib/base.php — so the step reported "no <job>
   declarations found" against an info.xml holding 31 of them. Fixed by reading
   the file and parsing a string. Any app code parsing XML by path is suspect
   for the same reason.

Verified by controlled experiment on a live instance: deleted FlowScheduleWorker
from oc_jobs, ran the step, and it reported "Registered missing background job:
OCA\OpenRegister\Cron\FlowScheduleWorker / 1 added, 0 skipped, 31 declared" with
the row restored. Separately, a version bump plus occ upgrade took the instance
from 9 to 34 distinct registered job classes.

Refs or#2170.
fix(jobs): reconcile declared background jobs Nextcloud never registered (24 of 31 were missing)
Adds `executionMode: async|sync` so a trigger can run a flow inline, and a run-level FlowToken that propagates into sub-flows, returns to the parent, and survives pause/resume.

The token is a mutable object at context['token']: an object handle survives the by-value array copy, so nodes write to it with ZERO change to the IFlowNode signature — all registered nodes and both leaf apps are untouched. Pause/resume came free: persistResult() already wrote context back on the SUSPENDED path.

Verified: 21 unit tests, 236/236 flow suite after merging development, phpcs 0 errors, openspec --strict, and Playwright e2e 4/4 live on 8080.

Live testing also surfaced ConductionNL/hermiq#53 — hermiq's resolver claimed every OR flow, so OR's own resolver was never asked and executionMode was silently dropped.
Fourth instance of the or#2158 defect class, after FlowMcpToolProvider::runFlow(),
FlowRunService::execute() and FlowRunController::test().

FlowScheduleService::fire() queued with no user, so every natively-scheduled flow
ran ownerless: context['triggeredBy'] was null and every attribution-requiring
node refused. ObjectWriteNode returns 'This flow run has no owner (triggeredBy);
an object write must be attributable.' A scheduled flow could therefore never
write anything.

A scheduled run has no session, so the owner comes from the flow object itself —
the person who created and enabled it, matching the decision that a dispatch is
owned by whoever made and activated the flow.

This became urgent rather than theoretical: FlowRunWorker and FlowScheduleWorker
were registered for the first time in #2172, so scheduled flows now actually
execute. Without this fix they would execute and refuse, producing a steady
stream of failed runs.

Test asserts queue() receives the owner; 7/7 green in FlowScheduleServiceTest.
…tion

fix(flow): scheduled runs were ownerless — 4th instance of the attribution defect
… credential providers

apphost-schedule-flow-action — manifest schedules[] can only ever run ONE thing:
ScheduleActionAllowList::MAP has a single entry, openconnector:synchronization.
A virtual app therefore cannot schedule a flow, even though the flow engine can
now both call external APIs (openconnector.source-call) and write objects
(openregister.object-write). Adds a flow-run action while keeping the closed
allow-list — the point of that design is that an app cannot name an arbitrary
class. Attribution is mandatory and fail-closed: a scheduled run has no session,
so the owner comes from the schedule declaration and the action refuses rather
than running ownerless.

Writing this spec found the same defect a fourth time in the adjacent scheduler
(FlowScheduleService::fire queued with no user) — fixed separately in #2173.

app-declared-credential-providers — the provider catalogue is runtime-immutable,
so an app author cannot register the credentials their app needs. Two costs
observed while building hydra-console: no codeberg/forgejo provider exists, and
the github provider permitted no issue-label write (fixed in #2165 only because
we could edit OpenRegister itself).

The design keeps the security boundary real rather than removing it. Two lanes:
a narrowing declaration (same host and auth scheme, provably-subset allow-rules)
is auto-admitted because it grants strictly less than the base provider the app
could already use; anything introducing a new host or path requires
administrator approval. Approval is pinned to a per-entry content digest, so an
app that ships benign, gets approved, then widens is returned to pending.
Declarations are always app-scoped and namespaced so they cannot shadow or be
borrowed from, and inject_only declarations are rejected outright — a declared
inject_only entry would be unbounded secret egress authored by the app receiving
the secret.

NEEDS PO CONFIRMATION: whether the narrowing lane should really skip approval.
Recorded as the first deferred question rather than buried in the design.
docs(openspec): schedulable flows + app-declared credential providers
)

15454 tests: 51 errors + 2 failures -> 0 and 0.

25 errors: AttributeMcpDiscoveryTest assigned \OC::$server to a service-less fake and never restored it, so every later test saw a locator whose get() returns null and Response::getHeaders() died on $request->getId(). All passed in isolation — one leak reading as 21 broken tests. Restored in tearDown so a failing test cannot leak either.

19 errors: no stubs for OCP\ContextChat\* (optional app seam, absent from a bare composer install). Added guarded stubs beside the Doriath ones, surface taken from the call sites.

4 errors: FlowRunController gained an IUserSession param; its test still passed five arguments.

2 errors: CardDavBackend stub lacked getAddressBookById(), which ContactService calls.

1 error: bootstrap STDERR output corrupted a @runInSeparateProcess worker's result channel. Emitted once now, with the existing skip switch set for inherited children.

1 failure — REAL PRODUCTION BUG: RelationsController documents that a missing/disabled app is silently skipped, but Server::get() returns NULL rather than throwing, and probing null was recorded in $errors — so a fully successful response still carried an _errors key for all 13 leaf integrations. Resolution is now separated from the call.

phpcs clean; each fix verified in isolation before the full run.
…pes (#2177)

FlowNodeRegistry::palette() already returned exactly what a flow builder needs and nothing exposed it over HTTP — FlowController shipped only eventCatalog(), so a client could discover what STARTS a flow but not what a flow can DO.

A flow's edges[].type names a registered node, so with no palette endpoint a builder hardcodes the list, and it goes stale silently: an unknown type only surfaces when the flow runs. App contributions (openconnector's source-call/synchronization-run, hermiq's agent-step) were invisible to every HTTP client.

Adds GET /api/flow/node-catalog mirroring the trigger catalog's envelope and reusing palette() unchanged. Scope-filtered — ?scope=user returns only what a non-admin may actually run.

Closes #2176. 5 unit tests; phpcs clean; 15460 Unit tests, 0 errors, 0 failures.
…P surface (#2178)

The registry assertion skipped because OpenRegister exposed no palette endpoint — palette() was in-process only. #2177 added /api/flow/node-catalog, so the check is now real.

It immediately earned its keep: openconnector's leaves were silently failing to register on a clean install (class_exists load-order guard in register(), openconnector#1076) and no test in either repo could see it.

4/4 against an isolated NC 34 + Postgres instance on merged development.
…ork (#2182)

* wip: preserve uncommitted working tree (AppHost settings plane, flow wiring, schema dedup)

Snapshot of the local working tree taken before reconciling with
origin/development, which had moved 69 commits ahead.

Much of this tree is already upstream (27 of 36 new PHP files and 13 of 49
modified files are byte-identical to origin/development). The genuinely new
work preserved here is:

- lib/AppHost/{Controller,Service}: generic settings plane
  (GenericSettingsControllerBase, GenericSettingsService, RegisterConfigResolver)
- lib/Command/DedupCollidedSchemasCommand.php
- unit tests for the above plus HandlesExceptionsTrait
- four openspec changes (apphost-settings-plane, apphost-schedule-flow-action,
  app-declared-credential-providers, or-flow-object-write-node)
- lib/Settings/flow_register.json and flow e2e coverage

Committed on a stale base on purpose so the subsequent merge of
origin/development is a real three-way merge rather than a tree overwrite.

* docs(openspec): audit-seal-backlog-repair — drain the unsealed audit backlog

Measured on the shared dev instance: 108,151 of 227,063 audit rows (48%) carry
no hash, interleaved across the whole id range (31,518..290,493 = min..max id).

Three defects, all evidenced:

1. insertHashChained() seals per row under a global exclusive advisory lock
   (3 attempts x 50ms, fail-soft). Under any concurrency each insert pays up to
   150ms and then abandons the seal anyway. Measured ~152 rows/min during the
   maintenance:repair that had to be killed after 74 minutes with ~12h left.
   The batched sealRows() path already exists but single inserts never reach it.

2. The backfill that specs/audit-hash-chain/spec.md:105 requires does not exist.
   harden-audit-seal-concurrency (12/12 complete) made the lock fail-soft on the
   explicit promise that a "later seal pass chains them" -- that pass was never
   built, so every contended write permanently degrades the chain.

3. verifyChain() skips ANY null hash and still returns valid: true. The comment
   claims "pre-migration entries" but no cutover marker exists in lib/, so the
   tamper-evidence check currently passes over a table that is 48% unverified.

The change specifies a windowed driver around sealRows() (one lock per window,
not per row), a two-phase read so sealed rows do not have their ~5.3KB payloads
fetched just to contribute a chain link, a partial index for the backlog cursor
(today it pkey-scans with a filter at 784ms/2000 ids), a hard-capped background
job plus an occ command, and a cutover marker so unsealed rows stop hiding
behind a passing verification.

Design records why a naive bulk call is impossible: sealRowsLocked() SELECTs *
over [min,max], which for this backlog is ~227k rows x 5,270B ~= 1.14GB in PHP.

* docs(perf): plan to bring object writes under 500ms + fix features.json drift

A single-object create currently takes 13-99s on the dev instance (six runs on
larpingapp/character, two-field payload each time: 13.6/17.8/20.4/41.0/62.8/99.1s).
This is NOT the CloudEvent storm — that was openconnector's inert recursion
guard, fixed separately, and a create now emits 1 event rather than 255. The
remainder is our own write path.

Counter deltas across one HTTP 201 create (the 41.0s run):

  sequential scans of oc_openregister_schemas   5,135
  sequential scans of oc_openregister_registers     6
  transactions committed                       12,541

Four measured costs:

  1. 5,135 schema resolutions against a 1,917-row table. SchemaMapper::find()
     HAS a request cache and is a shared service, so this is either a cache
     key too specific (rbac/multitenancy flags multiply it 4x) or an uncached
     sibling on the hot path. The query is a seq scan by construction —
     SELECT * hydrates a ~2KB properties blob and LOWER(slug) defeats any
     index ('Rows Removed by Filter: 1916'). ~4ms x 5,135 = ~20s, half the run.

  2. Resolving an object reference whose table is unknown emits a UNION ALL
     with one branch per magic table. At 2,728 tables that is 690KB of SQL:
     planning 3,404.9ms, execution 546.1ms. 86% of the cost is PARSING a
     statement that returns zero rows, so no index can help — and it is
     usually avoidable, since character's six relation properties each
     already declare their target schema.

  3. 12,541 commits for one create: essentially every statement autocommits,
     and the request waits on each fsync.

  4. CloudEvent fan-out, audit-trail sealing (228,932 rows), notification
     history and oc_activity all run before the response is returned.

Target p95 <500ms with the 2,728-table shape unchanged — fixing the write
path, not shrinking the dataset. Task 1 is deliberately 'attribute the 5,135
calls before changing anything': guessing at a hot path is how the CloudEvent
guard stayed inert for so long.

Also fixes an unrelated red gate: openspec/specs/saved-search-views/spec.md was
missing the blank line before '## Requirements', so the features-manifest
generator swallowed the heading into the feature summary and docs/features.json
drifted. 'quality / Features Check' fails on development because of it.

* style(dedup): satisfy phpcs on the schema-dedup command

Conduction's standard requires named arguments on internal calls, forbids
inline IFs, and has its own spacing///end conventions — the command shipped
with 21 violations and turned 'quality / PHP Quality (phpcs)' red. 14 fixed by
phpcbf; the rest by hand:

  setName/setDescription/addOption -> named arguments (four calls)
  splitOne/splitOneLocked/findCollisions/pickOwner call sites -> named arguments
  the two ternaries in execute() -> explicit if blocks

All 11 PHP files changed on this branch now pass phpcs.

* ci: regenerate docs/features.json from openspec/specs/ [skip ci]

* style(phpcs): clear the 15 pre-existing violations that kept phpcs red

lib/ was 15 errors over 9 files before this branch, so 'quality / PHP Quality
(phpcs)' failed on development and on every PR opened against it.

  6x curl_close($ch)     deprecated since PHP 8.0 and a no-op — CurlHandle is
                         an object freed when it leaves scope, not a resource
                         needing an explicit close. Removed, with a comment so
                         nobody re-adds them.
  3x missing @param      FlowRunController::__construct($userSession),
                         FederatedConfigService::publish($private),
                         FlowScheduleService::fire($owner)
  6x file header         ReconcileDeclaredBackgroundJobs.php put its docblock
                         AFTER declare(strict_types=1), so phpcs read it as a
                         stray inline block rather than the file header; tag
                         order was @author/@license/@copyright. Moved above the
                         declare and reordered to @author/@copyright/@license.

phpcs now reports 0 errors across all 72 files in lib/. phpstan is unchanged —
the same 7 findings exist with and without this commit (verified by stashing),
so nothing here introduced or masked one.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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.

4 participants