Skip to content

Release: merge development into beta - #29

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

Release: merge development into beta#29
github-actions[bot] wants to merge 518 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.

rubenvdlinde and others added 30 commits July 16, 2026 16:46
Two more settings-section pages had their heading rendering under the
Nextcloud navigation toggle (same class of issue as the dashboard/store/
guardrail pages — the manifest-v2 renderer provides no toggle clearance,
so a page's own top heading collides with the 44px toggle):

- McpTools (type:custom): heading text started at 23px → now 79px.
- Compliance (type:index) below-header widget ComplianceOperations: its
  FIRST section heading ("Incidents") started at 21px → now 77px. Scoped
  with `:first-of-type` so only the topmost heading shifts; "EU AI Act
  audit export" and "Retention" keep full width (verified 21px, unmoved),
  matching nc-vue's "only the header shifts right" rule.

Algorithm register needs no fix — it's a pure index page with no colliding
top heading.
…+ Compliance headings' (#115) from wip/hermiq-settings-heading-margins into development
The AI companion widget sends no agentUuid (no agent picker in v1), so
ChatStreamController picks one server-side. Until now that was purely
'the first agent in register order the user can access' — arbitrary on a
busy instance, and it can land on a misconfigured agent (e.g. one whose
model is not available on the active provider, which then fails silently
with an empty reply).

Add an app-config key 'companion_agent_uuid'. When set AND the current
user may access it, pickFallbackAgentForUser() returns that agent; a
missing/inaccessible configured agent logs a warning and falls through to
the existing access-scoped register-order scan (unchanged behaviour when
unset). Set with:

  occ config:app:set hermiq companion_agent_uuid <agent-uuid>

Verified live: with the key pointed at a claude-opus-4-8 agent, the
companion turn dispatched with model=claude-opus-4-8 (the configured
agent) instead of the register-order qwen2.5 it picked before.

Tests: configured-agent-preferred + unconfigured-falls-back-to-first;
full ChatStreamController suite 8/8 green.
…debar

The chat page is legitimately custom (streaming SSE + tool-call consumption,
not expressible via index/detail page types), but its conversation-history
sidebar rendered each conversation as a bordered card with a primary-outlined
active state — visually inconsistent with the flush index-sidebar look used by
cases/skills/agents.

Restyle the rows to the nc-vue index-sidebar / NC app-navigation convention:
no per-item border, subtle rounded hover, a filled active pill, and tighter
2px row spacing. Purely visual — the chat behaviour is unchanged.

Live-verified against procest /cases; 0 console errors.
… the nc-vue index sidebar' (#117) from wip/hermiq-chat-sidebar-parity into development
Proposes how document-shaped context (a project design.md, standards doc,
persona brief) reaches an agent, building on the existing Context/
ContextAssembler model.

Core proposal:
- Three distinct concepts, one assembly seam: Skill (capability), Context
  (situation/project reference), Memory (learned state) — all converge in
  one budgeted preamble at run start.
- design.md-style context becomes a first-class Context 'documents' source
  (inline markdown, {name, body, format, description}) — self-contained,
  versioned (OR AuditTrail), authored with the SKILL.md markdown editor
  pattern — distinct from bare NC 'files' refs.
- No new trust surface: context is untrusted prompt input, subject to the
  org guardrail policy; inherits run identity (ADR-023).
- Reuse the SkillFormModal editor pattern; GitHub-shareable context +
  conversational context-creator parked as follow-ons.

Status: proposed — no code until accepted. If accepted, builds as a
config→code chain (Context.documents schema; ContextAssembler + editor).
…ed)' (#118) from wip/hermiq-adr-context-concepts into development
Two feature docs for the Docusaurus site:
- approvals.md — human-in-the-loop gates: what they are, the four gate
  points (scheduled/flow/webhook/tool, incl. guardrail 'confirm'
  classification), the requested→decided→consumed lifecycle, and where to
  find the Approvals inbox.
- incidents.md — human-authored records of agent malfunctions under EU AI
  Act art.12: the fields, how they link to agents/runs, the three-layer
  audit story (runs + approvals + incidents), and the Compliance audit
  export.

Grounded in the real schemas + ApprovalService/agent-lifecycle-governance
behaviour. Autogenerated sidebar picks them up by position (6, 7).

NOTE: hermiq.conduction.nl deploys from the 'documentation' branch, which
needs a human push — these land on development as the source.
…nduction.nl)' (#119) from wip/hermiq-docs-approvals-incidents into development
…ifacts

Flip ADR-024 to accepted; add the config→code chain implementing it:
- hermiq-context-documents-schema (config): Context schema gains a
  'documents' array ({name, body, format=markdown, description}) — inline
  document-shaped context (design.md et al), distinct from files/objectQueries.
- hermiq-context-documents (code, depends_on ^): ContextAssembler gains
  resolveDocuments() (titled sections, same budget); a Context editor modal
  (mirrors SkillFormModal) + a Contexts management page.

Both validate via status; task counts 4 and 6.
… core)

A tool-carrying cli turn was refused outright (link 2's fail-loud), because
`claude -p` accepts no tool schema — custom tools reach a vendor CLI ONLY
via MCP. This gives the CLI tools WITHOUT any governance leaving Hermiq.

Hermiq now serves a governed MCP endpoint the containerised CLI dials:

- McpRunController (POST /api/mcp/run) — JSON-RPC MCP server. tools/list
  returns exactly ToolGrantResolver::resolve(agent.tools, catalog) (default
  deny, wildcards read-only); tools/call dispatches through FacadeToolInvoker
  so guardrails, the approval gate, redaction, model-policy, budgets and
  tracing all still apply — no second execution path. Identity comes from the
  run token only; the body cannot redirect the run. Guardrail-deny / pending
  approval / ungranted tool => 200 with result.isError, nothing executed.
- RunTokenService — 256-bit ISecureRandom tokens bound to (runId,agentId,
  userId), ~150s TTL in ICache, hash_equals compare, consumed in a finally,
  never logged.
- EgressAuthorizeController (POST /api/egress/authorize) — the egress PDP,
  answering straight from WebResearchEgressGuard::assertSafe(), the SAME
  policy source hermiq.webFetch uses. Default DENY.
- ProviderFactory cli branch — mints the token, assembles the MCP server
  config, dispatches. Fails LOUDLY (503, naming the cause) when the endpoint
  is unreachable, the token cannot be minted, or the granted tool set is
  EMPTY — never a silent text-only downgrade (the trap at :624-635).
- Runner — writes the token-bearing MCP config to a 0600 scratch file (never
  argv), and locks the CLI down with --tools "" --strict-mcp-config
  --mcp-config <file> --allowedTools mcp__hermiq__*; refuses to spawn if the
  boundary flags are missing.

Both new routes are #[PublicPage]+#[NoCSRFRequired]: the per-run token IS the
authorization (no NC session exists in the container), stated in-docblock so
semantic-auth review reads it as intentional.

Task 1 (verify the CLI's http-MCP client BEFORE building) is recorded in
discovery.md as verified fact: type:"http" works and custom headers ARE
forwarded, but the CLI's MCP client is a STRICT schema validator — it rejected
a whole tools/list over one argument-less tool serialising
inputSchema.properties as [] instead of {} (fixed centrally in
openregister#456). McpRunController therefore emits properties as an object.

Also corrects the superseded llm-cli-runner-exapp spec: the tool-schema
dispatch requirement is impossible and is gone, and 'no Nextcloud access' is
narrowed to exactly the token-gated Hermiq origins.

Tests: 16/16 new (McpRun, EgressAuthorize, RunToken); ProviderFactory suite
72/210 green (http + text-only cli paths bit-for-bit unaffected); runner 16/16
incl. argv lockdown, 0600 config, token-off-argv. openspec validate --strict
passes.

Task 8 (egress proxy sidecar + container network + docs) intentionally NOT in
this commit — it is the network-layer backstop and is tracked separately.
Implements hermiq-context-documents-schema (config, chain head).

Document-shaped context — a project design.md, standards doc, persona
brief — becomes a first-class Context source: `documents` is an array of
{ name, body, format(default markdown), description } authored inline on
the Context bundle. Self-contained and versioned (OR AuditTrail), and
distinct from `files` (Nextcloud file refs) and `objectQueries` (live
data), per ADR-024's concept split (Skill=capability, Context=situation
reference, Memory=learned state).

Config only — no code. The ContextAssembler render + editor are the
dependent change (hermiq-context-documents).

Register 0.14.0 → 0.15.0, Context schema 0.1.0 → 0.1.1, app 0.1.78 →
0.1.79 (both version fields gate the re-import).

Live-verified: `documents` present in the deployed Context schema;
imported_config_hermiq_version = 0.15.0.
…ts schema' (#121) from wip/hermiq-context-documents-specs into development
linkToRouteAbsolute() returns the URL Nextcloud publishes to BROWSERS
(overwrite.cli.url / trusted domain). The CLI dials the governed MCP
endpoint from INSIDE the runner container, where that host routinely is
not Nextcloud: a stock dev instance publishes 'http://localhost', which
inside the container is the CONTAINER — so every tool call would fail with
a connection error that reads like a broken endpoint, not a wrong URL.

AppAPI already records the container-facing origin (its daemon's
nextcloud_url, e.g. http://nextcloud). Add an app-config override
'mcp_run_base_url' to pin the same value; unset keeps the published URL
(correct whenever Nextcloud's public origin IS container-reachable).

  occ config:app:set hermiq mcp_run_base_url --value=http://nextcloud

Found by live deployment, not unit tests — the tests mock IURLGenerator,
so no amount of unit coverage would have caught it.
…ix AppAPI auth' (#109) from fix/llm-runner-exapp-deploy into development
link 3 structurally depends on hermiq#109: without it the image has no
/app/deploy (the egress-jail entrypoint dies with exit 127) and the runner
still speaks the wrong AppAPI auth (AA-SIGNATURE HMAC instead of AppAPI 34's
base64 AUTHORIZATION-APP-API), so AppAPI could never authenticate to it.

test.sh conflict: both branches appended an '(e)' section. Kept both — the
/enabled lifecycle tests stay (e), the governed-MCP argv tests become (f).
Runner suite 18/18 green.
Implements hermiq-context-documents (code half of the ADR-024 chain).

- ContextAssembler gains resolveDocuments(): each `documents[]` entry is
  rendered as a titled section merged into the SAME $sections array as
  files/objectQueries, so it inherits the existing charBudget/
  needsConsolidation contract — no new budget. Prefix is byte-identical to
  resolveFiles' ("Source: {name}\n{body}") so all three source kinds render
  uniformly. Malformed entries are skipped non-fatally (one bad document
  must never blank the preamble). No per-document byte cap; `format` is
  carried but not branched on (all treated as plain text for now).
- ContextFormModal.vue (mirrors SkillFormModal's #form-dialog contract):
  name/description/charBudget + a documents list (name + CnMarkdownEditor
  body + description, add/remove) + files and objectQueries editors. Edit
  merges the existing payload so viewRefs/needsConsolidation survive.
- Contexts management page: type:index over schema `context` + a "Contexts"
  nav item, mirroring SkillsCatalog.

No new trust code — guardrail input filters already apply to the assembled
preamble (ADR-024 Rule 3). No schema fields added (the schema is the
dependency change).

Live-verified end-to-end: created a Context with a design.md document via
the editor; it persisted with name/format=markdown/body intact (newlines
preserved). Suite 1062 tests green (3 new ContextAssembler tests incl. the
budget nudge), phpcs + phpstan clean, 0 console errors.
… editor (ADR-024)' (#122) from wip/hermiq-context-documents-code into development
Archives hermiq-context-documents-schema + hermiq-context-documents; spec
deltas merged into context-documents. 43 specs validate.

Fixed a real validation error the ff missed: the schema change's requirement
put its MUST on line 3 — the validator only scans the FIRST line after the
'### Requirement:' header, so it read as having no MUST/SHALL. Added a
leading MUST sentence. (The '## Why' proposal warning is non-blocking: this
repo's conduction schema uses Summary/Motivation, as every archived change
does.)
…ents chain' (#123) from wip/hermiq-context-documents-archive into development
…rified

The spec's flag lockdown was self-defeating and no unit test could see it:
every layer was green while the model still answered 'I don't have that
tool'. Two flag facts, both VERIFIED against the real CLI (2.1.x):

1. `--tools` EXCLUDES MCP tools. It selects from the BUILT-IN set only, so
   `--tools ""` yields NO tools at all — MCP included — and --allowedTools
   does not rescue them. The spec required exactly this, so the transport
   was destroying the tools it exists to deliver, silently: exit 0, empty
   stderr, model says it has no such tool. Replaced with an explicit
   `--disallowedTools <builtins>` denylist, which strips shell/filesystem/
   native-web while LEAVING MCP intact. assertGovernedArgs now REFUSES
   `--tools` outright so the regression cannot come back.

2. The CLI DEFERS MCP tools and loads them on demand via ToolSearch. The
   first denylist denied ToolSearch — which silently made every governed
   MCP tool unreachable even though tools/list served them correctly.
   ToolSearch is now deliberately NOT denied, with a comment saying why; it
   can only surface servers named by --strict-mcp-config (Hermiq's endpoint
   alone), so it grants no extra reach.

A denylist is weaker than an allowlist-of-none: a NEW built-in arrives
un-denied. That residual risk is carried by the container (no default route
off the egress allowlist, read-only fs, no mounts, non-root) — the backstop
layer the design already argues for.

LIVE END-TO-END VERIFIED: a chat turn on a Claude Max subscription, through
the official CLI in the jailed runner, called Hermiq's governed MCP endpoint
(run-token verified, tools/list = the agent's grant, tools/call via
FacadeToolInvoker) and CREATED an OpenBuild schema —
'mcp-test-app-production-author' (schema id 4598, required [name]),
confirmed in the database, not merely claimed by the model.

Tests: 88 PHP / 250 assertions; runner 18/18 (argv lockdown now asserts
--disallowedTools and refuses --tools).
… escalate

Two silent-degradation defects on the grant-resolution path, both found while
closing the governed-CLI gaps.

1. PRIVILEGE ESCALATION. resolveFunctions() passed the RESOLVED id set straight
   to ToolRegistryFacade::listTools(). An empty whitelist means "all tools
   allowed" there, so a grant set that expanded to nothing — e.g. a wildcard
   whose derived verb ids the catalog does not carry — silently became a grant
   of the ENTIRE catalog, destructive tools included. It now returns the honest
   empty set instead.

2. SILENT ZERO. An agent whose grants were configured but matched nothing
   produced the same empty function list as a deliberately tool-less agent, so
   the turn continued text-only while every layer reported success — the exact
   degradation the governed-CLI transport exists to prevent. "No tools on
   purpose" is now a first-class sentinel (ToolGrantResolver::NO_TOOLS_SENTINEL,
   which AssistantService's copy aliases rather than re-spells), letting
   ToolLoop tell the two apart and raise ToolGrantResolutionException naming the
   unresolved ids. expandLegacyIds() leaves the sentinel alone — expanding it to
   openregister.__none__ would have made valid tool-less agents throw.

The empty($functions) gate in ProviderFactory's cli branch could never see this
distinction (both cases arrive as an empty array), which is why it is enforced
at the resolution site instead; its comment now says so.

Three ToolLoop tests returned [] from listTools() as a don't-care value that now
carries meaning; they assert which whitelist reaches the facade, so they return a
resolving descriptor and keep their original intent.

Also fixes pre-existing phpcs errors in ProviderFactory (the $appConfig param
added for mcp_run_base_url was undocumented).

1089 tests / 3347 assertions green; phpcs clean.
…them

Two defects found live, both making a governance surface useless in silence.

1. TOOL GRANTS WERE READ-ONLY FOR EVERYONE. AgentToolGovernanceWidget read
   `agent.owner`, but the store returns an OpenRegister object whose own
   properties are the agent's schema fields — the owner lives in `@self`. So
   isOwner compared `undefined === uid` for every user and canEdit was always
   false: the grant editor shipped permanently read-only, including for the one
   person it is meant to be writable for. Verified live: the agent's owner IS
   "admin" at `@self.owner` while `agent.owner` is absent, and feeding the
   computed the resolved value flips isOwner false→true and enables Grant/Save.

2. THE FAIL-LOUD WAS MUTE TO THE USER. ChatStreamController masks every
   exception message behind "An internal error occurred." — correct by default,
   since a message can carry a connection string or key fragment. But a
   ToolGrantResolutionException message is safe by construction: it names the
   agent's own grant ids, configuration its owner already reads in the grant
   editor. Masking it left the owner with a generic error for a misconfiguration
   only they can fix — loud in the log but mute to the person who can act is not
   loud enough. Now caught by type and emitted verbatim under a distinct
   `tool_grants_unresolved` code; every other failure stays masked.

Verified live through the chat UI: an agent granted `openregister.schemas`
(plural — the tool is `openregister.schema`) now answers in the thread with
"This agent's tool grants resolve to no tools: openregister.schemas. Check the
ids against the tool catalog ..." where it previously ran text-only and reported
success.

1089 tests / 3347 assertions green; phpcs clean. Note: hermiq has no frontend
test tooling, so the Vue fix is covered by live verification, not a unit test.
The one message this controller shows a user verbatim was English-only. It is
now rendered through IL10N at the CONTROLLER — the right seam: the exception
carries structured data (getGrants()), and a service throwing it has no business
knowing the reader's locale. The grant ids are interpolated, never translated;
they are identifiers.

Key is the English source string (per convention), added to l10n/en.json and
l10n/nl.json. The literal is concatenated only to satisfy the 150-char line
limit — the assembled runtime string is byte-identical to the catalogue key,
verified by extracting it from the source and comparing; drift there would make
every locale silently fall back to English.

Also adds the test that locks the security boundary this touches: the new
`tool_grants_unresolved` frame names the ids and the remedy and is NOT masked,
while the existing test still proves every other exception IS masked (an
`sk-SECRET` in an exception message never reaches the wire).

That test immediately earned its keep: it caught a missing
`use ToolGrantResolver` import in the code above, which resolved
`ToolGrantResolver::NO_TOOLS_SENTINEL` to `OCA\Hermiq\Controller\...` — a
runtime fatal on the exact path that exists to help the user.

Two test files constructed the controller positionally and broke on the new
param (ArgumentCountError) — both updated; the ChatStreamControllerTest mock
interpolates like the real translator so assertions read the user's message.

1090 tests / 3352 assertions green; phpcs clean.
…(Task 8)

Completes the last unbuilt task of link 3, and MIGRATES the deployment off the
old Option A iptables jail rather than adding a second option beside it.

Why the migration: the jail carried its own copy of the allowlist. A second copy
is a second policy — it drifts from the one the agent's webFetch tool obeys, and
iptables cannot express "may THIS run reach that host?" because it has no idea
what a run is. Now the runner sits on an `internal: true` network with NO default
route, and its only way out is a sidecar that asks Hermiq's PDP about every
single connection. WebResearchEgressGuard stays the one policy source for both
layers. The runner needs no NET_ADMIN and no root any more: there is no jail to
install because there is no route to jail.

The proxy is deliberately dependency-free (Node stdlib only) — the component
every connection passes through should not carry a supply chain of its own — and
DENIES by default: `allowed: true` is the only permit signal, and an
unreachable / slow / erroring / unparseable PDP all deny. With no EGRESS_PDP_URL
it refuses to start rather than run as an open relay. Each of those paths has a
test; a truthy-but-not-true `allowed` ("yes") is tested too, since JS would
happily read that as a permit.

🔥 EVERY cli turn now mints a run token, not just a tool-requiring one: the token
is also the identity the proxy presents to the PDP, and without it a text-only
turn cannot reach api.anthropic.com at all. The two mints differ in strictness on
purpose — and that distinction is load-bearing: minting with the GOVERNED rules
everywhere would have demanded an agent, and conversation-title generation calls
this path with `agentId: null` (ConversationManagementHandler), so every title
would have 503'd the moment executionMode:cli was switched on. Text-only turns
get a tolerant egress-only identity (agentId '' ⇒ resolves zero tools ⇒ fails
closed) that never blocks a turn it cannot help. Regression test added.

The run token reaches the CLI as `HTTPS_PROXY=http://run:<token>@host:port` in
its ENVIRONMENT — never argv, where the process table would expose it — and is
assigned after the passthrough so a stray static proxy var cannot shadow it.
NO_PROXY is never set: an exemption list would be a hole in the only route out.

Docs state the revised model in full: non-root, no mounts, no default route,
per-call env-only credentials, the two token-gated Hermiq origins, the two
complementary layers, the CONNECT host-granularity limitation (and why
terminating TLS to fix it is a worse trade), the migration steps with a
verification that actually proves the route is gone, and that Claude Max is
PERSONAL-SCOPE ONLY per the Anthropic ToS.

1091 PHP tests / 3353 assertions; 19/19 runner checks (14 new egress cases).
phpcs clean; `openspec validate --strict` passes.
…g notes

The docs assumed the reader already knew what an agent, skill, memory,
context, MCP or RAG is. Nothing explained them, so anyone new had nowhere
to start — and intro.md opened with "manifest-first Vue 2 frontend rendered
by CnAppRoot", which is a sentence for us, not for a reader.

Add a Concepts section, written for someone who has never used an AI system:

- concepts/index.md — "Agentic concepts, explained". The overview: starts
  from the problem (a chatbot forgets; you want a colleague), a one-line
  table of every concept with an everyday analogy, how they fit together as
  "hiring someone", and the two distinctions people trip over (skill vs
  context, memory vs context).
- concepts/agents.md — what an agent is made of; the prompt as a job
  description; choosing a model; why an agent is a record, not a setting.
- concepts/skills.md — reusable instruction sheets; agentskills.io; both
  authoring routes; the quarantine review gate and why it exists.
- concepts/memory.md — what persists between conversations; memory vs
  context; per-user isolation; why it gets consolidated.
- concepts/context.md — documents vs files vs object queries and when each
  is right; the budget; context is material, never instructions.
- concepts/tools-and-mcp.md — what MCP actually means and what it buys you;
  tools are granted, not assumed; auto/confirm/deny risk classification.
- concepts/rag.md — retrieve → augment → generate; RAG vs context vs
  memory; search modes; sources spend budget; it cannot see past your
  permissions.
- concepts/runs-and-schedules.md — the four triggers; what happens in a run;
  why unattended runs need delivery, gates and cost limits.

intro.md now points newcomers at the overview first.

Also unpublish four internal engineering notes that were live on the public
docs site (draft: true — files kept, reversible):
- canonical-files.md, fleet-extras-audit.md, fleet-drift-deeper.md — fleet
  template/audit notes with ZERO mentions of hermiq across 664 lines.
- PORT-PLAN.md — the internal Hermes→Nextcloud port plan.
openregister.conduction.nl 404s on the same paths, confirming the fleet norm
is not to publish them.

Live-verified: 8 concept pages 200, 4 internal notes now 404.
docs: explain the agentic concepts + unpublish internal engineering notes
Answers the question everyone actually has, and leads with it:

  "What if I want an assistant that summarises my mailbox every morning
   — but can't read my files, and can't delete my mail?"

The architecture, stated as the design it is:
- The model runs in a hardened container on your own hardware.
- That container has NO outside access and needs none — no internet, no
  files, no tools, no network. It only thinks. A brain in a jar.
- Hermiq has the hands. Every file read, record lookup, internet fetch and
  tool call goes through the Hermiq layer, which does it AS YOU and only if
  you granted it.

So "summarise my mail but never touch my files" is enforceable configuration
the model cannot see or change — not a hope written into a prompt. Two
independent reasons it cannot go around: it has no network (the egress jail
DROPs outbound traffic at the kernel before privileges drop), and it has no
tools (access is something Hermiq performs, not something the model holds).

Includes a mermaid diagram of exactly that mailbox assistant (granted:
mail; not granted: files, records, internet), a second diagram of the
grant → guardrail → approval → audit chain, a concrete build table for the
mailbox case, the three model-hosting paths, and why it adds up to EU AI
Act compliance.

Diagrams are deliberately emoji-free: the meaning is carried by colour,
line style and words, so it cannot degrade to tofu boxes on a viewer
lacking emoji fonts (verified: this environment renders emoji at exactly
the tofu width, i.e. not at all — so their appearance was unverifiable).

Live-verified in a browser: both diagrams render as real SVGs (820x459,
820x213), zero parse errors.
docs: add The safe setup — sovereign architecture with diagrams
Hermiq is the AI assistant across the fleet — the CnAiCompanion floating
button and chat surfaces already use Material Design "Creation" (the
two-star sparkle), but the app itself showed a generic document glyph in
the apps menu and header. Use the same icon so Hermiq reads as the AI
everywhere.

Kept white-fill: Nextcloud recolours app-menu icons and expects a
single-colour white glyph.
Picks up the two releases that landed after 2.2.0-vue3.1:

  2.2.0-vue3.2  four dashboard defects — date-range chip shows its dates and
                calendar-aligned presets, a dangling labelResolve no longer
                renders a raw UUID, and the table's "View all" pins to the
                bottom instead of scrolling away
  2.2.0-vue3.3  gridstack's stylesheet now ships with the library that
                requires it; CnFormDialog splits over-long schema descriptions
                behind an info popover; CnContextMenu closes again on outside
                press and stops hijacking every popper with a cursor transform

Lockfile regenerated with npm 10.8.2 to match CI's node 20 toolchain — local
npm 11 prunes optional entries that do not apply to the current platform, which
makes CI's `npm ci` fail with "Missing: ... from lock file". Running `npm ci`
locally does not reproduce it, because npm 11 accepts its own lockfile.

Verified: `npx npm@10.8.2 ci --dry-run` exits 0, and `USE_LOCAL_LIB=false
npm run build` exits 0 with no unresolved modules and no reference to a sibling
nextcloud-vue checkout. USE_LOCAL_LIB=false is load-bearing: webpack aliases
@conduction/nextcloud-vue to ../nextcloud-vue/src when that sibling exists, so
a plain build can silently compile the sibling instead of the package under test.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ cbd3944

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 14:54 UTC

Download the full PDF report from the workflow artifacts.

…failing CI (#157)

v1.0.1 is `f4d9756` (2026-08-03) and predates three gate fixes, so every
Hydra Gates run this repo has ever made executed a script in which 16
gates reported PASS when their helper never ran (ConductionNL/.github#147),
gate-33 had no axe report to read and never said so (#148), and gates 6
and 7 reported PASS on an empty scope (#149). The tick was identical
either way, which is why nothing in this repo's history shows it.

That pin is now also RED, and the mechanism is worth writing down.
quality.yml is referenced `@main` while this package is PINNED, so the
two can desync. #164 flipped `hydra-gates-require-full-coverage` to
default true in the shared workflow, and that flag requires a gate to
DECLARE itself not-applicable. v1.0.1 contains ZERO `_skip` calls; v1.3.0
has 36. v1.0.1 has no vocabulary to declare, so every absent prerequisite
became "DID NOT RUN" and failed the job — for gates the repo has no
subject matter for.

Measured on this branch, diff-scoped against origin/development exactly
as CI scopes it, in a private mount namespace with a private tmpfs (the
runner's ~50 /tmp/hydra-gate-*.log paths are shared state and two
concurrent runs corrupt each other's counts, .github#158 item 6):

  v1.0.1  exit 98  FAIL — "GATES THAT DID NOT RUN: 24 33"
  v1.3.0  exit 0   PASS — those gates named NOT APPLICABLE, with reasons

Independently confirmed end-to-end: doriath#160 changed this one line and
nothing else, and its Hydra Gates job went failure -> success.

v1.3.0 is `f7eaf2a` = .github@main at the time it was cut.

Refs ConductionNL/.github#159
hermiq registers an integration leaf on BOTH faces — the `hermiq-agent`
`LeafDescriptor` contributed through `RegisterLeafProvidersEvent`, and the
`registerIntegration({ id: 'hermiq-agent' })` mount pair in
`src/integration-leaf.js` — but shipped no `scripts/check-integration-parity.sh`,
so hydra gate-24 reported:

  [gate-24] integration-parity: SKIPPED (structural) — ... server↔JS leaf
  parity (ADR-066 Decisions 4/7 ...) is UNVERIFIED

Nothing correlated the two halves. The listener's own comment says the
`surfaces` list is written out on both halves "so the cross-layer parity gate
(gate-24) has two explicit sets to compare" — there was no gate comparing them.

This adds the checker gate-24 invokes. It is SELF-CONTAINED on purpose: the
canonical Node check in @conduction/nextcloud-vue validates that library's own
built-ins, its ADR-066 cross-reference is WARN-only, and its `scripts/` dir is
not published to npm — so the historic wrapper shape resolves nothing in CI and
exits 0 having checked nothing. Every way this one can fail to check exits
non-zero with a named reason instead.

Rules, all hard (ADR-019 AD-11/AD-13, ADR-066 decisions 4 and 7):
  R1 complete render pair for the declared renderMode (mount+unmount / tab+widget)
  R2 server↔JS id correlation, both ways (phantom leaf / orphan registration)
  R3 renderMode agreement across layers under a shared id
  R4 metadata agreement (label, icon, group, requiredApp, referenceType, surfaces)
  R5 a spread-inherited identity must come from the leaf-owning package
  R6 an offlineConfig must name schemas/properties the repo actually declares

hermiq exercises R1 (1), R2 (2), R3 (1) and R4 (5) — the counts are printed, so
"verified" and "nothing to verify" are distinguishable in the log. A run where
every rule has zero subject matter fails rather than passes.

Positive control (proved it can fail, then restored):
  * renderMode drift — PHP RENDER_MODE_MOUNT -> RENDER_MODE_COMPONENT
    => exit 1, "[R3 renderMode] leaf "hermiq-agent" declares renderMode
       "component" server-side ... but "mount" in its JS registration"
  * surfaces drift — dropped 'single-entity' from the PHP SURFACES const
    => exit 1, "[R4 metadata] leaf "hermiq-agent" field `surfaces` mismatch
       across layers"
Both restored; the check passes on the unmodified tree.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 5eb4ad2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 18:55 UTC

Download the full PDF report from the workflow artifacts.

fix(gates): a real server↔JS leaf parity check — gate-24 was verifying nothing
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 0c24e31

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 19:27 UTC

Download the full PDF report from the workflow artifacts.

…o its own ruleset (#155)

* fix(phpmd): scope the lib/Migration UnusedFormalParameter exclusion to its own ruleset

The nested <exclude-pattern> inside the UnusedFormalParameter <rule> was inert:
PHPMD 2.15 honours exclude-patterns only as direct children of <ruleset>, so
lib/Migration was scanned by the very rule the pattern was written to spare.

Hoisting the pattern to the top level of phpmd.xml would have worked but is
applied at file-collection time, dropping lib/Migration from EVERY rule and
silently swallowing real complexity, StaticAccess and method-length findings.

UnusedFormalParameter now lives alone in phpmd-unusedparams.xml with a
top-level */Migration/* exclude, and the phpmd composer script runs both legs
keeping the worst exit code.

* fix(phpmd): narrow the exclude-pattern to */lib/Migration/*

*/Migration/* matches any directory segment named Migration, so it would also
exempt ordinary classes under lib/Service/Migration/ and similar, which have no
interface-mandated signature and must stay analysed. Measured on openconnector,
that broader form hides a genuine UnusedFormalParameter finding in
lib/Service/Migration/. Only the app's own lib/Migration/ holds IMigrationStep
implementations, so only that directory is exempted.

Re-verified after the change: the lib/Migration probe is still excluded, the
non-UnusedFormalParameter Migration probe is still reported by leg 1, and the
retired-finding counts are unchanged.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 015d653

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-05 21:12 UTC

Download the full PDF report from the workflow artifacts.

…d refactors (#160)

CnPageRenderer only looks a component up in `customComponents` for a page
whose `type` is "custom"; every other type is resolved from the lib's
`defaultPageTypes`. Three components were left registered for, or written
against, page shapes that no longer route to them, so they were bundled
(or not even imported) but could never mount.

- `views/AlgorithmRegister.vue` — the `AlgorithmRegister` manifest page is
  `type: "index"`, so this key is never consulted. Its Publish/Withdraw
  write path is not lost: `views/AiFeatureRegister.vue`, mounted live from
  `settings.js` -> `AdminRoot.vue`, offers both actions over the identical
  `publishAiFeature()` / `withdrawAiFeature()` endpoints.
- `utils/algoritmeregisterReadiness.js` — sole importer was the above.
  AiFeatureRegister carries its own readiness logic.
- `views/FeaturesRoadmap.vue` — the `FeaturesRoadmap` page is
  `type: "roadmap"`, a built-in that mounts the lib's
  CnFeaturesAndRoadmapPage. That page reads the very same
  `features_roadmap_repo` / `_features` / `_disabled` initial-state keys,
  so the wrapper added nothing. It was never imported at all.
- `components/settings/AgentCredentialsSettings.vue` — never imported by
  anything, so never bundled. Both surfaces it was written to provide are
  already live: `scope="personal"` in `App.vue`, `scope="organisation"` in
  `AdminRoot.vue`.

`views/ComplianceDashboard.vue` is deliberately KEPT despite being equally
unmountable: it is the only caller of `getComplianceExport()`, the
compliance auditor's-pack export whose route `/api/compliance/export` is
still registered. The `compliance-operations` widget that replaced the page
ships the EU AI Act audit export, a different endpoint. Removing it would
silently drop a user-facing capability, so the decision to re-home or retire
it is left open and documented in `customComponents.js`.

Evidence: production build before/after. `algorithm-register__heading`
disappears from `hermiq-main.js` (-21128 bytes); `agent-credentials-settings`
was absent both times; `features_roadmap_repo` is unchanged because it comes
from the library, not this wrapper. Live markers `run-flow-dialog`,
`ai-feature-register__heading` and `compliance-operations` are all still
present. A deliberate break (keeping the import while the file is gone)
fails the build with "Can't resolve ./views/AlgorithmRegister.vue", so the
green build is a real signal.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 57e295f

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 05:15 UTC

Download the full PDF report from the workflow artifacts.

…ere it goes (#159)

* feat(flows)!: the card says what the node does, the line says only where it goes

The canvas still read the PRE-inversion model after the documents had been
migrated. Nothing errored. It looked for `type` on each EDGE, found none — which
is exactly what a correctly migrated flow looks like — and rendered the words
"No step type" onto all 16 lines of the Hydra sequencer, while the cards showed
place names for places that no longer exist. The engine ran the flow perfectly
the whole time, which is why nothing below the DOM ever went red.

WHAT MOVED

  node card    the STEP the node runs (its catalogue name), plus one line of
               the config it actually reads, so two object-reads are told apart
               without opening either. A node with no type is called out as a
               warning instead of drawn as an ordinary card — the engine
               refuses such a document.
  line         its own title, and nothing else. The place a step used to arrive
               at became the line's title on migration, so the words authors
               wrote ("scoped", "Gates passed") are what show. An untitled line
               draws no chip rather than an empty one.

PORTS

Role is carried by the ABSENCE of a port — a start has no in-port, an exit has
no out-port — so which end of the flow you are looking at survives greyscale
and does not depend on telling two hues apart (WCAG 1.4.1). Colour stays as
redundant encoding, never as the only carrier.

A routing node exposes one NAMED out-port per branch, read from the keys the
ENGINE reads (`config.rules[].output` plus `config.default`). `config.routes` is
deliberately NOT honoured: it is the most common way to author the node wrong,
and drawing ports for it would make a broken flow look correct.

The registered type is `openregister.route`, not `...router`. Guessing the id
from the class name (RouterNode) yields a type no flow uses, which would have
meant branch ports silently never rendered — caught by reading the stored
document rather than the class list.

A loop's body hangs off the TOP as a visible sub-list, kept clear of the
left-to-right run of the main chain.

SAVE WARNING AND LAST RUN

The save response now carries OpenRegister's own connectivity verdict, so a
dead end is reported at the moment of saving rather than on the next run —
which for a scheduled flow could be hours later and would present as "it ran and
did nothing". The dialog says the save SUCCEEDED first: an author who reads
"cannot finish" and assumes rejection will redo the work.

The flow list gains Last run and Status. Neither could be answered there before:
a flow refused for a dead end produces no run at all, so "refused" and "nobody
has triggered it" looked identical.

E2E

`flow-builder-dialect.spec.ts` was already failing on current data — it asserts
17 places and 19 lines, which is the pre-inversion shape of a document that now
has 16 nodes and 16 connections. Rewritten around the inverted model, with the
fixture's numbers MEASURED from the stored document rather than assumed: one
start (`scope`), three sinks (`release` via `exit: true`; `stop-idle` and
`stop-full` via the terminal type), three routes with two branches each. Both
ways of ending a path are therefore covered by real nodes.

The test that asserted "configures the step, not the place" is dropped: it
pinned the old model, where a node carried no configuration.

* i18n(flows): the strings the port canvas and the dead-end dialog added

Fifteen new source strings, extracted with the repo's own writer so the key is
the English source, plus Dutch for all of them — this is a Dutch
government-facing product and an untranslated warning dialog is exactly the
surface where that shows.

`%n entry` / `%n entries` are a plural pair (the config summary counts array
entries), so both forms are present rather than one string with a number
interpolated into it.

* docs(spec): the canonical flow-canvas spec, and @SPEC on the methods that implement it

gate-16 flagged 15 changed methods with no @SPEC. There was nowhere correct to
point them: hermiq had no canonical spec for the canvas, and a @SPEC must target
openspec/specs/, never a change dir — a change dir is archived and the link
rots.

So the spec is written where it belongs, covering the surface this PR builds:
the card names the step, the line carries only its title, role is expressed by
the ABSENCE of a port, a routing node names each branch, the save warns without
refusing, and the list distinguishes 'never run' from 'refused'.

The e2e spec's own @SPEC is repointed from the change dir to the canonical path
for the same reason.

* fix(gates): scenario-level @e2e traceability, and regenerate features.json

gate-19 reads SCENARIOS, not requirements. The @e2e pointers were on the
requirement headings, where the gate never looks, so all four covered scenarios
counted as untraced. Moved to where the gate reads them:

  - the four scenarios the browser really asserts are referenced from the e2e
    file itself as '// @e2e flow-canvas::<slug>', which is the direction the
    chain is meant to run — the TEST claims the scenario, so a deleted test
    breaks the link instead of silently leaving a spec pointing at nothing
  - the two that cannot be asserted in a browser carry a reason-bearing
    '@e2e exclude' on the scenario line, naming what covers them instead
    (OpenRegister's FlowDeadEndTest and Newman)

features.json was stale because the pre-commit hook could not fetch the
extractor (offline), which it warned about and I did not act on. Regenerated
with the shared script.

* ci(quality): move hydra-gates-ref v1.3.0 -> v1.5.0, the pin the floating workflow needs

The Hydra Gates job failed with a message that says outright it is not about
this repository:

  hydra-gates-ref 'v1.3.0' does not contain: scripts/axe-run.cjs
  scripts/lib/check_spec_anchors.py scripts/lib/check_form_labels.py
  scripts/lib/check_license_triangle.py

The reusable workflow floats on @main and calls those scripts BY PATH inside the
PINNED package. So a pin older than the scripts cannot run the gates that
implement them, and the job fails on the pin while reporting nothing about the
code. A pinned ref is a silent expiry date on every upstream change.

v1.5.0 is the first tag containing all four — verified by reading each path at
that tag rather than assuming the newest tag has everything.

This also un-blocks the two gates the previous commit fixed: gate-19
(e2e-coverage) and gate-16 (spec-coverage) were already satisfied, and were not
what this run was failing on.

* fix(gate-19): put the @e2e excludes on their own line, where the gate reads them

Appending '@e2e exclude ...' to the '#### Scenario:' heading left both scenarios
untraced — the gate expects the marker as its own line beneath the heading. The
count went 4 -> 2 after the file-side references landed, and these are the
remaining 2.

Both are excluded as pure-backend API contracts, with the reason naming what
covers them instead: the save warning and the refused-flow fields are produced
by OpenRegister and asserted by FlowDeadEndTest and Newman. The browser only
renders a verdict the response already made, so a Playwright test here would
assert the rendering of a fixture, not the behaviour.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ ec1401d

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 06:23 UTC

Download the full PDF report from the workflow artifacts.

* chore(ci): move hydra-gates-ref v1.3.0 -> v1.4.0

A pinned `hydra-gates-ref` is a silent expiry date on every upstream fix:
this repo cannot receive a gate-package change until this line moves.

v1.4.0 is the latest tag and the first one that carries
`hydra-gates/scripts/axe-run.cjs` (verified absent at v1.3.0), so it is
also the first that has ConductionNL/.github#168 axe DOM scoping and
ConductionNL/.github#165 gate-46 fix.

`enable-axe` is deliberately NOT enabled in this commit. Ordering matters:
the ref lands first, enabling axe is a separate decision.

* chore(ci): stop pinning hydra-gates — track the package at @main

Removes the `hydra-gates-ref` input from the `quality.yml` caller. The
shared workflow already defaults it to `main`, and this repo consumes
`quality.yml` itself at `@main`, so dropping the override makes both
sides move together: a gate-package fix lands here without a commit here.

A pin is a silent expiry date on every upstream fix, and we have paid for
that twice already:

  - .github#159 — 22 repos sat on v1.0.1, which predated the gate fixes.
    16 gates were dead fleet-wide and every single one reported PASS. A
    gate that never runs emits a tick identical to one that did, so
    nothing in any repo's history showed it.

  - .github#173 — the shared side flipped a default at @main while the
    package stayed pinned per caller. Old runners lacked the coverage
    accounting the new default assumed, so they went red on gates they
    had no subject matter for.

Removing the pin closes both shapes at once. Rolling back is a revert on
ConductionNL/.github main, which reaches the whole fleet in one commit;
holding this one repo still is still possible by setting the input
explicitly, with a reason.

`enable-hydra-gates: true` is unchanged. `enable-axe` remains unset.
The comment block that justified the pin is replaced with a short note
saying why there is no pin.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ c1b66f5

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 08:43 UTC

Download the full PDF report from the workflow artifacts.

…ale copy (#163)

`quality.yml` probes `scripts/coverage-guard.php --capabilities` for the word
`against` before it will trust the ratchet, and errors out when the probe comes
back empty:

    ##[error]scripts/coverage-guard.php predates merge-base comparison.
    Update it from ConductionNL/.github before enabling the ratchet.

hermiq's copy was the pre-`--against` 59-line version, so the probe exited 2
with no output and `PHPUnit (PHP 8.3, NC stable33)` failed on every pull
request — job 92563882183 of run 31085325766, and identically on the re-run, so
it is not a flake.

This is the floating-caller / stale-callee shape one layer up from the version
pins: `quality.yml` is consumed `@main` and moved on, the per-repo script is a
copy that did not. Every other repo adopted the new script (openbuild#137,
pipelinq#716, scholiq#286, decidesk#415, portaliq#49, shillinq#448, hrmq#71);
hermiq is the last one.

The file is byte-identical to the copy on openbuild `development` and scholiq
`development`, so there is one version of this script in the fleet again.

Verified locally, both directions:

  --capabilities            -> against/update-baseline/capabilities, exit 0
                              (the stale copy: no output, exit 2)
  499/1000 vs base 500/1000 -> FAIL, exit 1
  501/1000 vs base 500/1000 -> OK,   exit 0
  base with 0 statements    -> exit 2, refuses to set the floor to 0%

Nothing is silenced: no baseline entry, no continue-on-error, no re-pin.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ dd7e332

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 09:14 UTC

Download the full PDF report from the workflow artifacts.

Comment on lines +32 to +36
uses: ConductionNL/.github/.github/workflows/release-beta.yml@main
with:
app-name: hermiq
channel: dev
secrets: inherit
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ efae6ff

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 11:27 UTC

Download the full PDF report from the workflow artifacts.

…ommand injection) (#165)

quality / Security (composer) is red on every PR here as of today:

    Advisory ID: PKSA-rdkp-vv9z-mjkg
    CVE: CVE-2026-67434  —  OS Command injection
    Affected versions: <3.13.6|>=4.0.0,<4.0.2
    Reported at: 2026-08-05T23:53:11+00:00

The advisory was published YESTERDAY and roave/security-advisories installs
as dev-latest each run, so the same lockfile was clean on 2026-08-05 and is
vulnerable on 2026-08-06 with no commit in between. The last green run is
evidence of when it ran, not that the lockfile is safe.

composer.json's existing constraint already permits the fixed version, so
this is a lockfile move only: 1 update, 0 installs, 0 removals. Verified the
diff touches exactly two lines, both the version string, and no other file.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 9706cf2

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️
Hydra gates

Quality workflow — 2026-08-06 12:11 UTC

Download the full PDF report from the workflow artifacts.

#166)

OS command injection in PHP_CodeSniffer, GHSA-hmqg-cxww-wqhq, reported
2026-08-05. Affected: <3.13.6 | >=4.0.0,<4.0.2. This repo was on 3.13.5.

All 16 repos checked across the fleet are on 3.13.5 and equally affected. The
advisory is live in the audit DB, so this repo's Security (composer) gate is
failing until this lands.

Verified
- composer audit --locked: 'No security vulnerability advisories found' (was 1).
- vendor/bin/phpcs --version -> 3.13.6.
- composer phpcs: rc=0.
- Positive control: a deliberately non-conforming file under lib/ made phpcs
  exit 2 with 13 findings, so the green above is a real pass and not a checker
  that no-ops. Probe removed; only composer.lock is modified.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 8437220

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-06 12:43 UTC

Download the full PDF report from the workflow artifacts.

…that made either visible (#162)

* feat(hermiq): rail-based agent detail, fuller dashboard, and the CSS that made either visible

The layout the manifest already described was never reaching the screen. Hermiq
shipped no GridStack stylesheet: nc-vue imports it as a side-effect import from
an aliased package and webpack tree-shakes those away, and it is absent from
dist/nextcloud-vue.css too (0 occurrences of its base rules, while leaflet's 174
ARE there). GridStack >= 11 sets left/top INLINE but takes `position: absolute`
on items and `position: relative` on the container from that sheet, so every
widget fell into normal flow: correct widths, no horizontal placement, all 11
cells stacked in one column down the left third of a 1600px viewport. Measured
before the fix: gs-x="3" carried `left: calc(3 * var(--gs-column-width))`
resolving to 25% while `position` computed to `static`, and every item reported
left=371px. procest (same renderer, gridstack 10) positioned correctly, which is
what localised it to hermiq.

The stylesheet is fixed IN THE LIBRARY, not here — @conduction/nextcloud-vue's
src/css/index.css now @imports it so postcss-import folds it into the extracted
sheet, covering both the published-dist and aliased-to-src resolution paths. An
app-level import would duplicate those rules in every consumer. main.js carries
only a note recording why it is deliberately absent.

Layout, on top of that:
- AgentDetail adopts procest CaseDetail's 8/4 rail — an 8-column main column
  (Configuration, Run operations, Memory) beside a persistent 4-column rail (the
  four KPI tiles stacked, Skills, Eval baseline), both closing exactly at row 16.
  33 rows -> 28, zero unfilled cells, and zero widgets whose scrollHeight exceeds
  their cell (ADR-062), all re-measured in a browser.
- Tool governance halves from gridHeight 14 to 8: its two surfaces are peers over
  one capability, so they are now tabbed rather than stacked, with a real tablist
  (roving tabindex, aria-selected/aria-controls, arrow-key nav). It draws its own
  heading and sets showTitle:false, because with the chrome title on that title is
  a SIBLING of the widget root inside a display:block grid item, so height:100%
  resolved against the full box and ignored it — a permanent 6px overflow no
  gridHeight could remove.
- Dashboard closes its grid (the bottom row left 6 of 12 columns empty) and gains
  a runs-by-agent chart, an agents table, header actions and a KPI drilldown.
  12 rows, zero unfilled cells.

AnalyticsService enriches perAgent with `name`: the aggregate carried only
agentId, and a chart labelled with UUIDs is unreadable. This instance has zero
runs, so the chart was verified by a positive control — the endpoint was
intercepted with a synthetic payload and it rendered 6 bars across 3
name-labelled categories.

Also fixes a pre-existing 500: AgentsController::stats bound PHP's (string)false
— the empty string — against a boolean column, so every call died with
SQLSTATE[22P02] and the agent counters had never worked. Booleans are now
normalised to 'true'/'false'. Verified against DB ground truth with a
deliberately-inactive probe agent (18/17/1), since with 17/17 active a broken
filter and a working one are indistinguishable.

Security postures declared rather than inherited (hydra gates 5/7/9/30):
- SettingsController::create, SetupController::saveConfig and ::runAction were
  admin-only by Nextcloud's default for an un-attributed method, which is silently
  lost the moment anyone adds #[NoAdminRequired]. Now
  #[AuthorizedAdminSetting(AdminSettings::class)]. runAction matters most:
  test-llm makes the server issue an outbound request, so unauthenticated it is
  an SSRF primitive.
- MetricsController::index stays admin-authed per ADR-006 ("/api/metrics
  Prometheus text, admin auth" vs "/api/health JSON, public") and gains
  #[NoCSRFRequired], which is what a scraper actually needs.
- Four endpoints taking no caller-supplied object id carry reason-bearing
  @no-admin-idor-exempt tags. create() was checked first: `owner` and
  `organisation` are in PROTECTED_KEYS and assigned server-side.

Accessibility: scope="col" on every column header across four components, a
visually-hidden label on a bare <th />, and prefers-reduced-motion fallbacks
(WCAG 2.2 AA 2.3.3). The chat typing indicator is an infinite bounce — the
pattern that triggers vestibular symptoms — and is replaced with a static
opacity rather than removed, so "assistant is typing" survives the motion.

The e2e login no longer waits for 'networkidle', which never settles on
Nextcloud (ADR-074 rule 4): the wait always ran to its timeout and only worked
because .catch() swallowed it — a disguised fixed delay, not a readiness signal.
It now waits for the submit control to be present and enabled.

* test(agents): pin the normalised filter, which stats() depends on

testStatsUsesPaginatedTotals still matched `$query['active'] === true`, the
contract from BEFORE countAgents() started normalising booleans. The branch
sends the string 'true', so that arm never matched, the callback fell through to
the inactive branch, and the assertion read:

    total 10, active 4, inactive 4

counts that do not add up — which is the tell that the mock, not the code, was
wrong.

The normalisation is the fix, not the defect. A bool bound as a query parameter
casts to '1' for true and the EMPTY STRING for false, and Postgres rejects '' on
a boolean column with SQLSTATE[22P02] — so stats() was a hard 500 on every call
and the dashboard's agent counters showed nothing.

The callback now asserts the value is a string rather than only matching one, so
a regression to raw booleans fails here with a readable message instead of
quietly returning the wrong count.

* fix(gates): the four findings gate v1.5.0 surfaced — labels, spec anchors, exception translation, icons

All four ran for the first time on this repo when hydra-gates-ref moved
v1.3.0 -> v1.5.0, so this is newly-VISIBLE debt rather than newly-created.

gate-40 form-label-association — two raw <textarea> elements in Chat.vue (the
feedback box and the message composer) carried only a :placeholder. A
placeholder is not an accessible name: it disappears the moment the user types,
so anyone on a screen reader loses the field's description mid-entry (WCAG
3.3.2, 4.1.2). Both now carry an aria-label; the placeholder stays as the hint
it always was.

gate-46 spec-anchor-existence — three unresolved targets:
  - MetricsController pointed at openspec/changes/example-change/tasks.md, a
    directory that does not exist. Repointed to the canonical
    observability spec's REQ-OBS-001, which is exactly what the controller
    implements (Prometheus metrics, admin only).
  - AgentsController and its test used #task-4-1, which is a LIST ITEM, not a
    heading, so it never resolved. The file's own convention is number +
    kebab-cased heading (#1-port-the-chat-engine resolves today), so these
    become #4-mirror-the-routes.
Also fixed HealthController's three example-change refs while here — same
broken placeholder, and it is outside this diff only by accident of which files
this PR touched. REQ-OBS-002 is the health endpoint.

gate-49 controller-exception-translation — SettingsController::index/create/load
had no try/catch and no @throws. An uncaught throwable there becomes a framework
500 with a stack trace: no use to the caller, and index() is #[NoAdminRequired],
so it leaks internals to a non-admin. All three now translate to a JSON error
and log why. This needed a logger on the constructor, so the hand-built
controller in SettingsControllerTest gains a mock — checked for, rather than
discovered by a red build.

gate-55 detail-page-discipline — six widget icons named MDI glyphs the shared
registry cannot render, so each drew the "?" fallback (ADR-062 rule 8). Mapped
to registry entries by MEANING, not by nearest spelling: PackageVariantClosed ->
Package, CogOutline -> Cog, ChartTimelineVariant -> Timeline ("Total runs
recorded"), SchoolOutline -> School ("Learnings"), PlayCircleOutline ->
RocketLaunch (the run-operations card — the registry has no play glyph).

Verified by running check_detail_page_discipline.py locally: 6 findings -> 0,
and the manifest still passes Ajv validation.

* i18n: the accessible names added for gate-40

'Feedback details' and 'Message' are now the accessible names of the two chat
textareas, so they are user-facing strings and need translating like any other.
Dutch supplied — an aria-label left in English is exactly the string a Dutch
screen-reader user hears.

* fix(lint): drop src/Chat.vue — a stray duplicate my own 'git add -A' swept in

src/Chat.vue is an older copy of src/views/Chat.vue. I had looked at it earlier,
established it is NOT the file the app loads (src/registry.js imports
./views/Chat.vue) and is strictly worse — it lacks the data-testid the e2e suite
selects on and the prefers-reduced-motion block — and decided to leave it
untracked.

Then I staged the gate fixes with 'git add -A src' and committed it anyway. Its
relative imports are written for src/views/, so from src/ none of the seven
resolve, and eslint failed with exactly those seven import/no-unresolved errors.

Untracked again and added to .gitignore, so the same accident cannot repeat. The
file stays on disk — it is untracked, so git could not give it back.

* test(settings): exercise the three catch paths the coverage guard flagged

The coverage guard failed the PR: 24 statements added, coverage 68.25% -> 68.20%.
The added statements were the try/catch blocks from the gate-49 fix, and nothing
exercised them.

That is the right complaint. An untested catch block is indistinguishable from
no catch at all — it is the 'a check that never runs looks exactly like one that
passed' shape, and the whole point of the block is what happens on the path
nobody normally takes.

Each test now asserts the TRANSLATED response, not merely that the method
survives:

  index()   500 + an 'error' key, and the internal exception message is NOT
            echoed back — that leak is what the translation exists to stop, and
            index() is #[NoAdminRequired] so it would reach a non-admin
  create()  success:false, the shape the UI already branches on
  load()    says nothing was changed, which is the one fact the admin who
            clicked reload needs

* chore(gates): record the gate-49 opt-out where the gate actually reads it

[hydra-gate-controller-exception-translation exclude] SettingsController::index/create/load each catch \Throwable, return a translated JSON error and log the cause; the gate matches only nine named domain exceptions and does not recognise \Throwable, which is strictly broader and covers all nine. Upstream: ConductionNL/.github#204.

The same text is in the PR body, but a re-run did not pick it up there — the
commit-message path is the one that reliably reaches the gate.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 208ab09

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-07 09:50 UTC

Download the full PDF report from the workflow artifacts.

)

The 32 floor was raised on the premise that nothing tested below it. That is
false here: this repo's own CI runs stable31, and min-version is enforced at
install time, so occ app:enable refuses on 31 and the e2e seed fails with
"is not installed or enabled".

The original reason for a 32 floor no longer holds either. It came from
openregister implementing OCP\ContextChat\IContentProvider, an interface
absent before NC 32. openregister#2372 removed every eager reference to that
class, so it is only loaded inside interface_exists() guards and the header is
never read on an older server. openregister#2380 restored its own 28 floor on
that evidence.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ d025781

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-07 11:14 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Quality Report — ConductionNL/hermiq @ 311b7de

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-specs
check-manifest
test-l10n
composer ✅ 117/117
npm ✅ 744/744
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-07 11:30 UTC

Download the full PDF report from the workflow artifacts.

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