Skip to content

Consolidation: AI chat companion + 7 scholiq-deps features + 8 more clean/approved branches (PHPUnit green)#1496

Merged
WilcoLouwerse merged 60 commits into
developmentfrom
integration/scholiq-deps-and-companion
May 15, 2026
Merged

Consolidation: AI chat companion + 7 scholiq-deps features + 8 more clean/approved branches (PHPUnit green)#1496
WilcoLouwerse merged 60 commits into
developmentfrom
integration/scholiq-deps-and-companion

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

@rubenvdlinde rubenvdlinde commented May 12, 2026

What this is

A single consolidation branch off development (integration/scholiq-deps-and-companion, 58 commits ahead) folding together the ready / approved in-flight OpenRegister work — plus the test-suite cleanup that makes the whole thing green (PHPUnit was red on development itself with ~49 errors + 1 failure; fixed here).

Folded in

Stream PR(s) Status on its own branch
AI chat companion orchestrator (IMcpToolProvider, McpToolsService provider-aggregator refactor, BuiltIn/{Registers,Schemas,Objects}ToolProvider, POST /api/chat/stream SSE + /api/chat/health, Message.context migration + tests) #1466 green
feat/scholiq-deps/*appendOnly flag, per-tenant HMAC audit key API, calculatedChange notification trigger, idempotencyKey notification dedup, dateDiff calculation primitive, cross-schema aggregation joins, runtime.user.* manifest context #1476#1482 (already bundled in integration/scholiq-deps-all, the base of this branch)
Features & Roadmap menu — xwiki integration plan-to-issues #1465 green
Collaborative-editing patterns doc #1458 green
Platform capabilities catalog + declarative-annotation deltas #1353 approved
mail-sidebar: body-mount + attachment drag-drop + @author email typo fix #1285 approved
Specs: entity-relation-grondslagen #1494 green
Specs: text-extraction-eml #1495 green
i18n: wrap bare strings + Dutch→English keys (with an eslint --fix pass for the indentation it touched) #1273 green

Conflict resolutions

  • lib/Migration/Version1Date20260511100000.php (add/add) — kept the scholiq-deps openregister_tenant_keys migration at that name; renamed the AI-companion Message.context migration to Version1Date20260511130000.
  • lib/Db/Schema.php — kept both the new ANNOTATION_VOCABULARY constant and the VALID_LINKED_TYPES @deprecated notice.
  • src/modals/webhook/EditWebhook.vue — kept the headersPlaceholder computed (the inline t('…\n…') would reintroduce the documented \n-in-template SyntaxError).
  • CHANGELOG.md — merged the two ### Breaking Changes lists.
  • lib/Service/Object/SaveObject.php — added the @param docblock entry for the new $folderManagementHandler constructor parameter introduced upstream.

Test-suite cleanup (the green-making part)

development itself was red on PHPUnit (~49 errors + 1 failure). All fixed here:

Fix Clears
ReferentialIntegrityService + RegisterService: get_class($db->getDatabasePlatform())get_debug_type(…) — null-safe (a mocked IDBConnection returns null), degrades to MySQL syntax instead of fatal-erroring ~42 errors (ReferentialIntegrityServiceTest ×3 dirs + RegisterServiceTest)
TransitionEngine: removed final so TransitionControllerTest can double it (ClassIsFinalException); note added re: extracting an interface if sealing is reintroduced 6 errors (TransitionControllerTest)
ManifestServiceTest: ObjectEntity::getUuid/getRegister/getSchema/getOwner/getCreated/getUpdated are @method magic via Entity::__call, so createMock() can't configure them — build the profile mock with getMockBuilder()->onlyMethods(['getObject'])->addMethods([the magic getters]) 2 errors
AppendOnlyTest: dropped ->willReturn(null) on the mocked void SaveObject::clearAllCaches() (PHPUnit 10 IncompatibleReturnValueException); setHardValidation(false) in the schema helper so the mocked ValidateObject doesn't short-circuit saveObject() with an empty ValidationException; stubbed the RenderObject mock's renderEntity() since saveObject() returns renderEntity($savedObject, …) not the handler's return directly 4 errors + 2 failures
AnnotationNotificationDispatcherTest: the dispatcher resolves the local base URL (emitTalk/emitWebhook) and expression recipient resolvers (resolveExpressionRecipients) via IServerContainer::get(); the test mocked the container but never stubbed get(), so emitTalk fataled resolving IConfig and resolvers registered via \OC::$server->registerService weren't visible. Added a $serverServices map seeded with an IConfig mock; stubbed serverContainer->get() to read it; resolver tests register under their DI tag 1 error + 1 failure
McpToolsServiceTest rewritten as the provider-aggregator test + new tests/Unit/Mcp/BuiltIn/{Registers,Schemas,Objects}ToolProviderTest (the registers/schemas/objects CRUD logic moved into the BuiltIn providers) 63 errors
phpcs cleanups surfaced by the merges (migration block-comments, SaveObject docblock) + an eslint --fix pass for i18n's indentation (phpcs/eslint)

CI

workflow_dispatch run on this branch (commit b29b6abe): all green — phpcs, eslint, psalm, phpstan, phpmd, phpmetrics, stylelint, license ×2, security ×2, Newman, PHPUnit on both PHP 8.3 and 8.4. (Push doesn't auto-trigger CI for integration/** branches — this PR's pull_request event does.)

Deliberately NOT folded in (need work before they can join)

Note on risky tests

RelationHandlerTest, Tmlo*, ObjectReferenceProviderTest, CrossSchemaAggregationRunnerTest report PHPUnit risky ("executed code not listed as covered") — phpunit.xml has failOnRisky="false", so they don't gate. Tightening their @uses annotations is a separate low-priority cleanup.

rubenvdlinde and others added 30 commits May 7, 2026 09:34
…-translations

# Conflicts:
#	lib/Db/MagicMapper/MagicSearchHandler.php
#	src/mail-sidebar/MailSidebar.vue
Co-authored-by: Cursor <cursoragent@cursor.com>
Address review #3200778886: nl.json regression where keys whose casing was
normalised in this PR (e.g. "Clear Filters" -> "Clear filters") lost their
Dutch translation. Recover them via case-insensitive lookup against
origin/beta:l10n/nl.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…} object plurals

Address review #3200779796: the plurals catalog was missing entries for two
n() call sites: ObjectCard.vue ('{count} email') and RestoreMultiple.vue
('Successfully restored {count} object'). Without these, Dutch users would
see English plural forms regardless of locale.

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

Address review #3200779536: the confirm() message was a single-line t()
call broken across two source lines (literal newline) with lowercase
sentence starts ('you', 'this', 'are'). The l10n catalog already contains
the correctly-cased key with '\n\n' escapes for the paragraph break, so
runtime lookup fell back to the raw broken-cased English. Rewrite the
t() call to a single-line string with proper '\n\n' so it matches the
catalog key.

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

Address review #3201893333 (IDOR concern): document in the docblock that
the endpoint is anchored to the session user via TaskService's use of
IUserSession::getUser()->getUID(); the `assignee` request parameter is a
free-text description filter, not an identity claim, and cannot be used
to read another user's tasks.

No code change: the underlying behaviour is already safe (calendars are
fetched for the current user's principal). This commit only makes the
authorization model explicit so future readers and gate checks can
verify the binding without re-deriving it from the service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…al.vue

Address review #3201893357 (ADR-004 / hydra-gate-13 modal-isolation):
the "Switch Active Organisation" NcModal was defined inline inside
OrganisationsIndex.vue, coupling the dialog to the parent's lifecycle
and bloating the parent component.

- New: src/modals/organisation/SwitchOrganisationModal.vue
  - Props: show, organisations, activeOrganisationUuid
  - Emits: close, switch
  - Owns the modal markup and the .organisationSwitcher styles
- OrganisationsIndex.vue: replace inline <NcModal> with
  <SwitchOrganisationModal>, drop NcModal from imports/components,
  drop the orphaned styles. Behaviour is preserved: parent still owns
  showOrganisationSwitcher state and the switchToOrganisation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nisation.php

PHP lint failed with `Cannot redeclare OCA\OpenRegister\Db\Organisation::$mail`:
the linked-entity properties (mail, contacts, notes, todos, calendar, talk,
deck) were declared twice — once with single-line `/** ... */` docblocks
that phpcs also rejected (14 errors), and again with proper multi-line
docblocks. Drop the first (duplicate, malformed) set; the second has the
correct documentation.

Resolves the PHP Quality (lint) check and 14 of the 21 phpcs errors in
this file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last remaining phpcs error after the Organisation.php duplicate-property
cleanup (commit 0ed1ad1). Auto-fixed by phpcbf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves the 57 eslint errors on the PR branch:

- 14 .vue files had duplicate \`@nextcloud/l10n\` imports (line-2
  \`<script setup>\` plus a redundant copy in the Options API \`<script>\`).
  Vue SFC merges both blocks into one module scope, so the second
  import is unnecessary AND triggers \`no-redeclare\` /
  \`import/no-duplicates\`. Drop the second import in each file.
- ApprovalStepList.vue: same pattern with axios + NcButton +
  generateUrl. Drop the duplicate Options API imports.
- ApiTokenConfiguration.vue, OrganisationConfiguration.vue: missing
  \`>\` to close the \`<NcSettingsSection>\` opening tag, which made
  the next-line comment / element parse as further attributes
  (5 errors and 6 errors respectively, all stemming from the missing
  closing bracket).
- EditWebhook.vue: \`t()\` placeholder string contained a literal
  newline (\`X-Custom-Header: value\\nauthorization: bearer token\`)
  spanning two source lines. Replace with an escaped \\n so the
  JS string is syntactically valid.

Files:
- src/components/workflow/ApprovalStepList.vue (drop dup imports)
- src/views/configuration/ConfigurationsIndex.vue
- src/views/organisation/OrganisationDetails.vue
- src/views/organisation/OrganisationsIndex.vue
- src/views/settings/sections/SolrConfiguration.vue
- src/views/settings/sections/ApiTokenConfiguration.vue (close tag)
- src/views/settings/sections/OrganisationConfiguration.vue (close tag)
- src/modals/configuration/{Import,Preview}Configuration.vue
- src/modals/deleted/RestoreMultiple.vue
- src/modals/file/UploadFiles.vue
- src/modals/object/MassDeleteObject.vue
- src/modals/organisation/{Edit,Join}Organisation.vue
- src/modals/register/ExportRegister.vue
- src/modals/settings/{DeleteCollectionModal,FacetConfigModal,FileWarmupModal}.vue
- src/modals/webhook/EditWebhook.vue (escape \\n in placeholder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves the composer security audit failure on the PR branch:

- GHSA-r7cg-qjjm-xhqq (high): unbounded recursion in parser causes
  stack overflow on crafted nested input. Fixed in 15.32.3.
- GHSA-fc86-6rv6-2jpm (high): quadratic validation cost in
  OverlappingFieldsCanBeMerged via inline fragments. Fixed in 15.32.2.

Composer constraint in composer.json (\`^15.0\`) already permits this
upgrade — only composer.lock changes. Verified with \`composer audit\`:
"No security vulnerability advisories found."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second-pass cleanup after the first CI run:

phpcs (3 files, 27 errors total — 21 auto-fixed, 6 manual):
- lib/Db/AuditTrailMapper.php (16 errors): mostly auto-fixable spacing/
  alignment in getProcessingActivities() / findByIdentifier(); plus 4 manual
  fixes — two inline-comment full-stops + two `!isset()` -> `isset(...) === false`
  per the no-bang-operator sniff.
- lib/Service/TaskService.php (10 errors): default-value spacing on 4 method
  parameters + missing `//end try`, all auto-fixed; one manual fix for the
  ternary `is_string($comp) ? ...` -> explicit `=== true` comparison.
- lib/Listener/MailAppScriptListener.php (1 error): convert `if (!($event
  instanceof ...))` to `=== false`.

eslint (4 files):
- .vue files where the previous duplicate-import cleanup left a double
  blank line between the @nextcloud/vue import block and the
  vue-material-design-icons imports. Collapse the runs of >2 \\n into
  exactly 2. Affects RestoreMultiple, MassDeleteObject, JoinOrganisation,
  ExportRegister.

Local verification: \`vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0\`
reports 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolutions:
- appinfo/routes.php: take development tag-route requirements (matches surrounding routes' style)
- lib/Db/AuditTrailMapper.php: take development (PHPCS/PHPMD cleanup + new findByActor() method)
- lib/Db/MagicMapper/MagicSearchHandler.php: take development (column-name quoting fixes for ILIKE / MySQL hardening)
- lib/Listener/MailAppScriptListener.php: take development (isMailRenderEvent() helper covers same fix plus the legacy OCA\Mail\* path)
- lib/Service/TaskService.php: take development (richer VTODO type-safety)
- package.json: take development (whitespace-only)
- src/views/object/ObjectDetails.vue: combine — keep development's new entity-relations tabs (Emails, Events, Contacts, Deck, Relations) and the PR's t('openregister', 'Audit Trails') translation wrap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T5: capitalise sentence after period/!/? in 40 catalog keys + their
Vue callsites. Both en.json and nl.json kept symmetric (1887 keys).
Affected callsites: 22 Vue files updated to match new keys.

T8: wrap loading-message in 5 SettingsSection siblings that the i18n
pass missed (N8n, LLM, Retention, Statistics, Cache). Catalog keys
added with proper Dutch translations.

T13: register missing dynamic-value catalog entries flagged by review
(Approved, Rejected, completed, in_progress, list, execute, view) so
ApprovalStepList and PermissionMatrix render translated labels in NL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds docs/Patterns/collaborative-editing.md as the canonical pattern
doc that ties together OpenRegister's two existing primitives:

- Push events (or-object-{uuid}) — already documented in
  Integrations/OpenRegister.md
- Pessimistic locks — already documented in Features/objects.md

The new page explains why they complement each other (subscribe
without lock = silent overwrites; lock without subscribe = surprise
at save), the end-to-end flow, failure modes, and when NOT to use
the pattern (CRDT editing, bulk imports, read-only views).

Cross-links to the @conduction/nextcloud-vue lib composables that
implement the pattern as defaults (useObjectSubscription,
useObjectLock, CnLockedBanner). The lib spec lives at
nextcloud-vue/openspec/changes/collaborative-editing-defaults/ and
is in flight on the beta branch.
…lan-to-issues)

Generate the structured tracking artifact for the integration-xwiki
leaf change. Parses the 16 tasks in openspec/changes/integration-xwiki/
tasks.md into plan.json, and syncs the GitHub tracking issue (#1326)
with checkbox lists derived from those tasks so opsx-apply can update
them as work progresses.

What this adds:
- openspec/changes/integration-xwiki/plan.json — 16 tasks across 6
  sections (Backend, Tab, Widget, Registration, Quality, Acceptance),
  each with id, title, spec_ref pointing into the spec.md scenarios,
  and files_likely_affected hints.
- Issue #1326 body now includes a "## Tasks" section with all 16
  tasks as checkbox lines (- [ ] **N.M Title**) plus sub-checkboxes
  for acceptance criteria.
- Issue body also gains a "Real-world consumer" section describing
  DeskDesk's wrong-shape placeholder (custom knowledge_article schema
  + sync) that the proper provider pattern will replace.

What this does NOT do:
- No implementation. plan.json is metadata for tracking, not code.
- Doesn't touch the umbrella (#1307); the umbrella's plan-to-issues
  is a separate exercise (69 tasks).
- Doesn't unblock the issue from its umbrella dependency. The
  ⚠️ Blocked section + the "blocked" label stay until the umbrella
  ships.

Refs: #1326, #1307 (umbrella dep), #1300 (spec PR)
Implements the OR-side of the ai-chat-companion 3-spec chain
(hydra ADR-034). Modifies the existing chat-ai capability:

NEW:
- OCA\OpenRegister\Mcp\IMcpToolProvider PHP interface — per-app
  MCP tool registration contract; tool ids namespaced as
  {appId}.{toolName}, mechanically enforced by McpToolsService.
- Built-in providers (lib/Mcp/BuiltIn/) for registers, schemas,
  objects — the existing static tools migrated onto the new contract.
- POST /api/chat/stream SSE controller (ChatStreamController) emitting
  the six-event envelope per hydra spec: token, tool_call, tool_result,
  heartbeat (15s cadence), final, error. v1 emits a single final after
  the synchronous LLM call; the non-streaming-provider clause of the
  contract covers it. Token-by-token streaming via LLPhant hooks is a
  follow-up.
- GET /api/chat/health (ChatHealthController) — lightweight public
  probe the nextcloud-vue widget hits at mount to decide whether to
  render.
- Migration adds Message.context JSON column (default empty object).

REFACTOR:
- McpToolsService now enumerates IMcpToolProvider implementations
  in-process per turn instead of a hard-coded tool list (126 added
  / 493 removed LOC).

FIX:
- ResponseGenerationHandler: skip OpenAIChat construction for Ollama
  (the old unconditional `new OpenAIChat($config)` type-errored when
  $config was OllamaConfig — Ollama chat was broken on Nextcloud
  instances configured with Ollama as chatProvider).

The openspec change directory ships proposal, design, specs delta
(MODIFIED chat-ai), and tasks (45 tasks across 11 groups; the
Fireworks streaming spike is task 1).

Depends on the hydra ai-chat-companion spec landing first.
…omplete-translations

# Conflicts:
#	.phpunit.cache/test-results
… messageId

Two follow-up fixes for PR #1466 surfaced during end-to-end testing:

1. ConversationManagementHandler::generateConversationTitle() had the
   SAME LLPhant `OpenAIChat::__construct(OllamaConfig)` type-error as
   ResponseGenerationHandler. Wrapped the OpenAI-default instantiation
   in `if ($chatProvider !== 'ollama')` so the dedicated Ollama branch
   later in the method handles the title call for qwen/ollama models.

2. ChatService::processMessage() now propagates the persisted assistant
   message id to its return array. The historyHandler->storeMessage()
   call returned a Message entity that was being discarded; capture it
   and surface `messageId` via `$assistantStored->getId()`. The
   ChatStreamController already reads `$result['messageId']` to
   populate the SSE `final` event — without this fix it shipped an
   empty string and the nextcloud-vue widget couldn't render the
   assistant bubble (it relies on messageId as a Vue render key).

Verified end-to-end: real LLM round-trip through the SSE endpoint now
returns a populated messageId (e.g. "55") and the widget renders the
assistant bubble in decidesk's chat panel.
…agentUuid

The nextcloud-vue widget opens a fresh chat without an agentUuid — it
has no agent-picker in v1 (per hydra ADR-034 'one global thread per
(user, agent)'). Previous behaviour returned missing_agent and the
chat panel could never start a conversation.

Now: when both conversationUuid and agentUuid are empty, look up the
user's first available Agent via AgentMapper::findAll(limit: 1) and
use that. Falls through to the original missing_agent error if no
agent exists at all (operator needs to configure one in OR settings).

The widget remains a thin client — agent resolution is a server-side
concern that can grow into per-user / per-app preferred-agent logic
without a widget update.
Add idempotency-key deduplication to the notification dispatch engine.
Schema annotations can now declare an `idempotencyKey` template string
(e.g. `${@self.id}-T30-${@self.dueDate}`); the engine resolves the
template per-object and deduplicates dispatches by
(notification_slug, resolved_key) over a 24 h window stored in a new
`openregister_notif_dispatch_log` table.

Closes #1470 §4.
Implements the `dateDiff` JSON-logic primitive in CalculationEvaluator
and CalculationAnnotationValidator per openregister#1470 §5.

- Supports 7 units: years, months, weeks, days, hours, minutes, seconds
- Dict-based syntax: { "dateDiff": { "from": "...", "to": "...", "unit": "..." } }
- "now" sentinel, @self.<field> references, and prop() refs are all accepted
- Calendar units (years/months) use DateInterval for leap-year accuracy
- Sub-day/week units use timestamp delta so DST transitions are consistent
- Returns null (not exception) when either operand is an unparseable date
- Validator checks required keys and rejects unknown unit literals at schema-save time
- 42 new unit tests covering positive/negative diffs for all 7 units,
  leap years, end-of-month months, DST boundaries, null propagation,
  @self refs, "now" sentinel, and validator integration
Introduces TenantKeyService with getCurrentTenantKey(tenantId) and
rotateTenantKey(tenantId) for tamper-detectable audit-trail evidence
signing (ADR-022 audit-hash-chain row).

- Keys are 256-bit CSPRNG values stored encrypted via OCP\Security\ICrypto
- Bootstrap on first call; subsequent reads return same key until rotated
- Rotation retires the old row (retained for re-verification), inserts fresh active row
- Service is internal-only — no REST exposure, no admin endpoint wired
- Migration Version1Date20260511100000 creates openregister_tenant_keys table
- 11 PHPUnit tests cover bootstrap, idempotency, rotation, and storage encryption
…holiq deps #7)

Add `from` field support to `x-openregister-aggregations` enabling cross-schema
aggregations. When a spec declares `from: <schema-slug>`, the engine queries the
named schema's table rather than the parent schema's table.

Key changes:
- AggregationRunner: delegate to runCrossSchema() when `from` is present; supports
  @self.<field> parent-reference resolution in `where` clauses; applies eq/ne/gt/gte/
  lt/lte/in operators via the existing applyFilter path; uses Postgres-native fast path
  for the target schema table; double-gates RBAC on both parent and target schemas;
  caches independently per parent-row field values.
- AggregationRunner: adds `select`/`where` aliases for `metric`/`filter` (new DSL) on
  intra-schema specs too; adds optional `parentRow` param to run() for @self resolution.
- AggregationAnnotationValidator: accepts cross-schema specs (`from` key) with lighter
  validation (field-existence skipped for target schema); supports `select`/`where`
  aliases on intra-schema specs.
- Tests: 7 validator unit tests for cross-schema DSL + 6 runner unit tests covering
  @self ref resolution, in-operator filtering, schema-not-found, register-not-found,
  malformed-where, and RBAC gate on target schema.
…tics

Implements the calculatedChange notification trigger described in issue
#1470 §3. When a materialised calculation field crosses a configurable
numeric boundary the notification fires; subsequent saves that remain on
the same side of the boundary are suppressed (debounce).

Key changes:
- AnnotationNotificationDispatcher: adds calculatedChange trigger
  matching in matches() plus a numericConditionMatches() helper that
  evaluates lt/lte/gt/gte/eq/ne operators against the field value.
- AnnotationNotificationListener: on ObjectUpdatedEvent, additionally
  dispatches trigger=calculatedChange with _newData/_oldData in context
  when the old object is available.
- NotificationAnnotationValidator: adds calculatedChange to VALID_TRIGGERS
  and validates trigger.field, trigger.condition, and trigger.previously.
- Tests: CalculatedChangeTriggerTest covers the four debounce scenarios
  (first-save-below, above-then-below crossing, below-still-below, and
  condition-clause failure). Five new validator tests added.
…a calculations

Adds ManifestService + ManifestController (GET /api/manifest/{appId}) that:
- Returns host-app manifest unchanged when no currentUserSchema declared.
- Injects runtime.user=null for anonymous requests.
- Injects runtime.user={id,roles:["learner"]} fallback when no profile object found.
- Injects full runtime.user context (profile payload + non-materialised calculations)
  when the user has a profile object matching ncUserId in the declared schema.

Closes scholiq deps #8 — replaces cancelled nc-vue roleAware page resolver.

4 PHPUnit tests cover all four runtime.user scenarios.
Schemas with appendOnly: true permit INSERT operations but reject
UPDATE and DELETE with HTTP 405 + structured error SCHEMA_APPEND_ONLY.

Changes:
- Schema entity: appendOnly boolean field, DB type registration,
  isAppendOnly() accessor, jsonSerialize() output
- Migration Version1Date20260511100000: adds append_only column (default false)
- AppendOnlyException: structured 405 exception with toResponseBody()
- ObjectService::saveObject(): rejects when UUID present + schema isAppendOnly
- ObjectService::deleteObject(): rejects when schema isAppendOnly
- ObjectsController: catches AppendOnlyException in update/patch/postPatch/destroy
  and returns HTTP 405 with SCHEMA_APPEND_ONLY error body
- AppendOnlyTest: 7 unit tests covering insert allowed, update rejected,
  delete rejected, ordinary schema unchanged, exception structure, entity getter
@rubenvdlinde rubenvdlinde changed the title Consolidation: AI chat companion orchestrator + 7 scholiq-deps features (PHPUnit green) Consolidation: AI chat companion + 7 scholiq-deps features + 8 more clean/approved branches (PHPUnit green) May 12, 2026
…mposer-patches

Replaces the manual vendor/ edit (dev-container only, untracked) with a
proper cweagans/composer-patches entry so every composer install gets it:

- patches/llphant-ollama-think-keepalive.patch — injects
  `$json = $json + ['think' => false, 'keep_alive' => -1];` at the top of
  OllamaChat::sendRequest(). `think: false` makes qwen3/qwen3.5 skip the
  hidden reasoning trace (~5x faster); `keep_alive: -1` keeps the model
  resident until the daemon restarts (no 5-minute idle-unload wedge on
  serial requests). Both are silently ignored by models that don't
  recognise the field.
- composer.json — adds cweagans/composer-patches ^1.7 to require,
  allow-plugins it, declares the patch under extra.patches, and sets
  composer-exit-on-patch-failure (a loud failure on a future LLPhant bump
  is the signal to drop the patch once upstream exposes a per-call
  keep_alive/think model option).
Comment thread lib/Service/Aggregation/AggregationRunner.php
Comment thread lib/Controller/ChatStreamController.php
Comment thread lib/Service/ManifestService.php
Comment thread lib/Service/TenantKeyService.php
Comment thread lib/Controller/ChatHealthController.php
Comment thread lib/Db/NotificationDispatchLogMapper.php
Comment thread lib/Service/Aggregation/AggregationRunner.php
Comment thread lib/Service/Chat/ResponseGenerationHandler.php
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (7)

  • resolveConversation loads conversation by UUID without ownership check (IDOR) (lib/Controller/ChatStreamController.php:549)
    resolveConversation() calls $this->conversationMapper->findByUuid(uuid: $conversationUuid) without verifying that the resolved conversation belongs to $userId. Any authenticated user can supply any conversationUuid and read or append to another user's conversation thread. After findByUuid, assert $conversation->getUserId() === $userId and throw/emit an error SSE event with code: forbidden if they differ.
  • Exception message leaked verbatim to client in SSE error event (lib/Controller/ChatStreamController.php:507)
    $e->getMessage() is emitted directly in the SSE error event. For an LLM/DB exception this may contain connection strings, internal paths, or API key fragments. The logger already records the full error; the client only needs a generic message. Replace the SSE payload with a static 'An internal error occurred.' and keep $e->getMessage() only in the logger call.
  • resolveUserProfile calls findAll with _rbac:false, _multitenancy:false — bypasses ALL access controls (lib/Service/ManifestService.php:3722)
    ManifestService::resolveUserProfile() calls $this->objectService->findAll() with _rbac: false and _multitenancy: false. The profile lookup ignores role-based access control and multi-tenancy boundaries. If the same Nextcloud UID exists in multiple tenants, this can return a profile belonging to a different tenant. Use the default RBAC and multitenancy flags, or at minimum add an explicit organisation filter when multitenancy is disabled.
  • buildUserContext seeds runtime.user with raw profile->getObject() data with no allowlist (lib/Service/ManifestService.php:3755)
    buildUserContext() merges raw $profile->getObject() ?? [] into $context unconditionally. Every stored field from the profile — including any fields marked internal by the schema — is returned verbatim. There is no allowlist or field-level filtering. This leaks PII or sensitive schema fields to the caller's browser/apps consuming the manifest. Add an explicit field allowlist driven by a schema annotation (e.g. x-openregister-manifest-user-fields).
  • RegistersToolProvider exposes all-tenant register list without user/org filtering (lib/Mcp/BuiltIn/RegistersToolProvider.php:1712)
    invokeTool() dispatches to listRegisters() which calls $this->registerService->findAll(limit, offset) with no user or organisation context. In a multi-tenant deployment this may return registers belonging to all organisations. The IMcpToolProvider interface docblock states implementations MUST check IDOR boundaries. Pass the current user context into the service calls or confirm the service already applies per-org scoping and document it here. Same issue likely in SchemasToolProvider and ObjectsToolProvider.
  • Agent picker falls back to first agent in DB without filtering by user — cross-user data exposure (lib/Controller/ChatStreamController.php:441)
    When neither agentUuid nor conversationUuid is supplied, the controller calls $this->agentMapper->findAll(limit: 1) and uses the first returned agent. This ignores which user is making the request. In a multi-user deployment the first agent could belong to a different user. Either require a caller-supplied agentUuid, or filter the findAll() by userId: $userId.
  • Unique index TOCTOU window allows duplicate dispatch under concurrency (lib/Migration/Version1Date20260511120000.php:2513)
    The migration creates a unique index on (notification_slug, idempotency_key), but NotificationDispatchLogMapper::record() uses INSERT without ON DUPLICATE KEY handling. isDuplicate() is called before record(), so two concurrent dispatches can both pass isDuplicate() and both emit notifications before either records the log row. Use INSERT IGNORE (MySQL) / INSERT ... ON CONFLICT DO NOTHING (Postgres), or insert-first then catch unique-constraint violation to abort the send.

🟡 Concerns (10)

🟢 Minor (1)

  • stream() declares return type Response but always exits (lib/Controller/ChatStreamController.php:396)
    The method signature declares : Response but always calls exit; — never returns a value. Static analysers may warn. Either return new Response() on the unreachable last line, or use void if the framework allows it.

Reviewed by WilcoLouwerse via automated batch review.

Implements the 7 blockers, 10 concerns and 1 minor from the
automated security review. Each fix is explained inline near the
code it covers; the high-level summary:

Blockers
- ChatStreamController.resolveConversation: verify the supplied
  conversationUuid belongs to the calling user before returning the
  row (IDOR).
- ChatStreamController: stop emitting raw $e->getMessage() in SSE
  error frames; log the exception, surface a generic message.
- ManifestService.resolveUserProfile: drop _rbac:false /
  _multitenancy:false; the profile lookup must respect tenant scope.
- ManifestService.buildUserContext: no longer merges raw
  profile->getObject() into runtime.user; only fields named by the
  schema's x-openregister-manifest-user-fields allowlist (plus
  materialised calculations and {id, roles}) flow through.
- RegistersToolProvider / SchemasToolProvider / ObjectsToolProvider:
  pass _rbac:true / _multitenancy:true explicitly + document the
  IDOR boundary so a service-default refactor cannot silently widen
  these MCP queries across tenants.
- ChatStreamController fallback-agent picker: iterate accessible
  agents instead of returning the first row of openregister_agents.
- NotificationDispatchLogMapper.record(): catch the unique-index
  violation, raise DuplicateDispatchException; dispatcher now claims
  the slot BEFORE sending so the index is the authoritative
  serialisation point under concurrency.

Concerns
- ChatStreamController.stream(): single termination helper
  emitAndExit() commits any open DB transaction before exit; so the
  SSE-bypass-of-NC pipeline no longer risks leaking connections
  under PHP-FPM.
- McpToolsService.callTool/invokeTool: catch \Throwable, not
  \Exception, so TypeError/Error subclasses don't escape the MCP
  envelope.
- AggregationRunner.findRegisterForSchema: cast both sides to int
  and use strict in_array; document that the cross-schema RBAC
  gate's objectOwner: null is correct for a list-level check.
- ChatStreamController: tracks Message.context spec gap with a
  clear TODO referencing the chat-ai spec section.
- ManifestService: validates currentUserSchema slug (length +
  charset) and fails closed when invalid.
- TenantKeyService: drops the dead ISecureRandom dependency and
  documents that random_bytes() is the deliberate source of
  entropy.
- ChatHealthController: returns config_error (logged) for
  unexpected \Throwable from getLLMSettingsOnly, distinct from
  no_provider.
- NotificationDispatchLogMapper.isDuplicate/pruneExpired and
  record(): UTC throughout for the dedup window timestamps.
- ResponseGenerationHandler: clarify that $response is
  unconditionally seeded before the provider branches so a new
  provider can't introduce an undefined-variable regression.

Minor
- ChatStreamController.stream(): return new Response() on the
  unreachable post-emitAndExit line to satisfy static analysers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@WilcoLouwerse
Copy link
Copy Markdown
Contributor

Addressed every note from the security review in 864dd83. One commit, 15 files touched, +584 / −121.

🔴 Blockers (7)

# Issue Fix
1 resolveConversation IDOR ChatStreamController::resolveConversation() now asserts $conversation->getUserId() === $userId and emits a forbidden SSE frame on mismatch.
2 Exception message leaked over SSE stream()'s top-level catch logs the full exception but emits a static "An internal error occurred." over the wire.
3 resolveUserProfile bypasses RBAC / multitenancy Dropped _rbac: false and _multitenancy: false; the (schema, ncUserId) filter is now narrowing, not a tenant-scope substitute.
4 buildUserContext leaked raw getObject() Replaced the blanket merge with an allowlist (x-openregister-manifest-user-fields on the schema config + materialised calc names + {id, roles}).
5 MCP tool providers all-tenant exposure RegistersToolProvider / SchemasToolProvider / ObjectsToolProvider now pass _rbac: true / _multitenancy: true explicitly and document the IMcpToolProvider IDOR boundary.
6 Fallback agent picker pickFallbackAgentForUser() now iterates the first 20 agents and returns the first one canUserAccessAgent() clears — no more "first row of openregister_agents regardless of owner".
7 TOCTOU on (notification_slug, idempotency_key) NotificationDispatchLogMapper::record() catches the unique-index violation and raises DuplicateDispatchException; dispatcher's claimIdempotencyKey() now claims first, sends after — the unique index is the authoritative serialisation point.

🟡 Concerns (10)

# Issue Fix
1 exit; leaks DB connection under PHP-FPM New emitAndExit() helper commits any open transaction via IDBConnection::commit() before exit;. All termination paths funnel through it.
2 McpToolsService catches \Exception not \Throwable Switched to \Throwable in both callTool() and invokeTool() so \TypeError/\Error subclasses don't escape the MCP envelope.
3 Loose in_array in findRegisterForSchema Both sides cast to (int), strict mode flag set to true.
4 Message.context never persisted Added an explicit TODO referencing the spec section — the storeMessage path needs a follow-up to thread context through.
5 schemaSlug not validated New MAX_SLUG_LENGTH + regex (/^[A-Za-z0-9_\-]{1,128}$/) gate at the top of getEnrichedManifest(); fails closed to runtime.user = null.
6 ISecureRandom is dead code Removed the parameter from TenantKeyService (constructor, DI registration, and test). Docblock now explains why random_bytes() is the deliberate choice.
7 Health probe masks config errors ChatHealthController now logs the exception at warning level and returns {status: 'config_error'} (still HTTP 503) so operators can distinguish from a fresh no_provider instance.
8 Dedup cutoff in local time All three of isDuplicate(), record(), pruneExpired() now anchor to new DateTimeZone('UTC').
9 Cross-schema RBAC objectOwner: null Documented as the deliberate list-level form — added a comment explaining the PermissionHandler null-pair semantics.
10 Ollama branch leaves $response uninitialised Clarified — $response = '' is now flagged in a comment as the deliberate seed before the provider branches, so future provider additions can't regress to undefined-variable.

🟢 Minor (1)

Issue Fix
stream() declares Response return type but always exits Added an unreachable return new Response(); after the top-level catch for static-analyser comfort.

Notes

  • lib/Db/DuplicateDispatchException.php is a new typed exception class so callers can react specifically to the unique-index race.
  • php -l clean and phpcs reports zero errors on touched lib files (the remaining warnings are line-length and pre-existing).
  • Local PHPUnit can't run without NC bootstrap; CI on this branch was green prior to the change and the edits parallel existing patterns.

🤖 Generated with Claude Code

WilcoLouwerse
WilcoLouwerse previously approved these changes May 12, 2026
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review ✅

All 7 🔴 blockers, 10 🟡 concerns, and 1 🟢 minor from the prior review are addressed in 864dd83. Verified each fix against the diff:

  • IDOR / RBAC / multi-tenancy fixes are correct and fail-closed (resolveConversation strict !==, resolveUserProfile flags removed, allowlist-driven buildUserContext, MCP providers documented + flagged)
  • TOCTOU is genuinely claim-then-send via NotificationDispatchLogMapper::record() + unique-index catch + new DuplicateDispatchException
  • SSE error path no longer leaks $e->getMessage(); emitAndExit() commits DB transactions before exit;
  • All UTC/strict-type/validation concerns resolved; ISecureRandom dead code removed
  • C4 (Message.context persistence) accepted as tracked TODO with spec link; C9/C10 are documentation-only as proposed and accurate

No new 🔴 or 🟡 issues introduced. Three documented trade-offs (claim-then-send leaves stale claim on send failure; claimIdempotencyKey fail-open on non-constraint DB errors; ObjectsToolProvider relies on ObjectService::findAll defaults rather than passing flags explicitly) are acceptable.

All 8 prior threads resolved.

Reviewed by WilcoLouwerse via /review-pr (Strict mode re-review).

rjzondervan
rjzondervan previously approved these changes May 15, 2026
@bbrands02
Copy link
Copy Markdown
Contributor

Few comments, not sure if they should be blocking:

  1. Blocker: /api/chat/stream is NoCSRFRequired while mutating state.
    It creates/updates conversations/messages via authenticated POST, so removing CSRF protection is risky unless this endpoint is strictly token/API-client only. Route is authenticated POST, but controller has NoCSRFRequired.
  2. Blocker/spec mismatch: Message.context is added but not actually persisted.
    The controller comment says ChatService/HistoryHandler currently has no context parameter, so the new column is not populated yet. That means the PR claims context support, but runtime behavior only forwards it through a shim.
  3. Risk: safeShutdown() commits unknown open transactions after errors.
    If ChatService::processMessage() throws mid-write and leaves a transaction open, the controller emits an error and then commit()s any open transaction. That can persist partial/failed state. Prefer rollback on error path, or only commit transactions explicitly started by this controller.
  4. Concern: tenant key service returns plaintext old/new HMAC keys on rotation.
    rotateTenantKey() returns both old and new raw keys. That is okay only if no controller/API exposes this directly. If exposed to clients, it leaks audit signing material and should instead return metadata only.

Three of the four points raised by @bbrands02 in
#1496 (comment)
require code changes; the fourth (Message.context not persisted) is
already tracked as a deferred TODO in ChatStreamController and is not
touched here.

1. CSRF on /api/chat/stream — drop #[NoCSRFRequired]
   ChatStreamController::stream() is an authenticated POST that
   creates/updates Conversation and Message rows, so removing CSRF
   middleware exposed every logged-in user to drive-by chat-thread
   forgery from a third-party site. The "SSE / EventSource can't carry
   requesttoken" justification does not apply: the client invokes this
   endpoint via fetch() with a JSON body, which can attach the
   requesttoken header normally. The attribute, the docblock @ tag, and
   the now-unused NoCSRFRequired import are all removed; the docblock
   now explains why CSRF is required.

2. safeShutdown() commits on the error path — rollBack instead
   The previous helper unconditionally commit()ed any open transaction
   before exit; so partial writes left behind by a failed
   ChatService::processMessage() were persisted alongside the
   user-visible "error" SSE frame. safeShutdown() now takes a $rollback
   flag; emitAndExit() passes true when $eventType === 'error' so error
   paths roll back and only the final/heartbeat paths commit.

3. TenantKeyService::rotateTenantKey() — return metadata only
   The public method previously returned {old, new, rotated_at} with
   plaintext HMAC key material. No controller/route/CLI invokes it
   today (only unit tests do), but the API shape was a foot-gun for any
   future wiring: the file-level docblock already states "Keys are
   NEVER exposed through any REST endpoint." The return shape is now
   {tenant_id, rotated_at, retired_key_id} — plaintext material is
   reachable only via getCurrentTenantKey() after rotation. Tests are
   updated to assert the new contract and verify the post-rotation key
   via getCurrentTenantKey().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@WilcoLouwerse WilcoLouwerse dismissed stale reviews from rjzondervan and themself via ffe4321 May 15, 2026 09:11
@WilcoLouwerse
Copy link
Copy Markdown
Contributor

Thanks @bbrands02 — three of the four are actionable and have been pushed as ffe43219; the fourth is already tracked as a deferred TODO.

1. CSRF on /api/chat/stream — agreed, fixed. Dropped #[NoCSRFRequired] (and the now-unused import). The "SSE / EventSource can't carry the requesttoken header" justification doesn't apply here — the endpoint is POST + fetch() with a JSON body, which can attach the requesttoken normally. The docblock now spells out why CSRF is required.

2. Message.context not persisted — confirmed; already tracked as a deferred TODO (discussion_r3228238293). The __cn_ai_context__ ragSettings shim is intentional v1; threading context through ChatService::processMessage()HistoryHandlerMessageMapper is the follow-up. The TODO in ChatStreamController.php already references the spec section so the gap stays visible.

3. safeShutdown() commits on error path — agreed, fixed. safeShutdown() now takes a bool $rollback flag; emitAndExit() passes true when $eventType === 'error', so partial writes left behind by a failed ChatService::processMessage() are rolled back rather than persisted alongside the user-visible error frame. Success path still commits.

4. rotateTenantKey() plaintext old/new — agreed, fixed (no live exposure today, but tightened anyway). Verified via git grep: no controller, route, or CLI invokes it; only the unit tests do. The public return is now metadata-only — {tenant_id, rotated_at, retired_key_id}. Callers that legitimately need the new active key call getCurrentTenantKey() after rotation, so audit signing material never crosses the public API surface (matches the file-level docblock's "Keys are NEVER exposed through any REST endpoint."). Tests updated to assert the new contract.

phpcs/psalm clean on the touched lib/ files.

@WilcoLouwerse WilcoLouwerse merged commit f86e83f into development May 15, 2026
1 check passed
@WilcoLouwerse WilcoLouwerse deleted the integration/scholiq-deps-and-companion branch May 15, 2026 09:40
rubenvdlinde added a commit that referenced this pull request May 15, 2026
Brings #1466 (chat-companion orchestrator, /api/chat/health route),
the consolidated #1496 deps work, journeydoc tutorials, and the
LLPhant Ollama think:false+keep_alive patch into the rollup so
CnAiCompanion FAB renders when this branch is deployed.

Conflicts resolved:
- tests/Unit/Controller/AuditTrailControllerTest.php: took dev's
  version (more precise mock with ->with('admin') + useful comment).
- tests/Unit/Service/ObjectServiceTest.php, ObjectServiceDeepTest.php,
  Object/PermissionHandlerCacheTest.php: took dev's versions
  wholesale via 'git checkout --theirs'. Rationale: dev has the
  most recent test fixes (commits 5504377, 7987a97, 8d17464)
  from the consolidation merge that recently passed PHPUnit on dev.
  HEAD's PHPCS-reformatted versions from #1273's quality-fix commit
  diverged structurally enough that the line-based merge produced
  duplicated class bodies.
- package-lock.json: took dev's; will npm install separately if
  needed once we touch JS deps.
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