Skip to content

Release: merge beta into main - #515

Closed
rubenvdlinde wants to merge 889 commits into
mainfrom
release/beta-to-main-20260830122914
Closed

Release: merge beta into main#515
rubenvdlinde wants to merge 889 commits into
mainfrom
release/beta-to-main-20260830122914

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Stable release. beta was 887 commits ahead of main.

Conflicts were resolved to beta's side, including its refactors: where beta had removed a file, the removal stands rather than resurrecting a stale copy from main. No conflicts.

git merge -X theirs settles content conflicts but leaves modify/delete ones unmerged — beta deleted the file, so there is no "theirs" blob to take. Those were resolved by honouring the deletion.

Verified before pushing: the commit has exactly two parents, and no conflicted path was left unresolved. Files main keeps that beta never had (archived openspec docs, whitespace-only differences) are preserved — "beta wins" governs conflicts, not additions.

A failing … / release check here is the App Store publish step, not a quality gate: 7 apps have no signing key and thematiq's certificate carries its old app id. The GitHub release and tag are still created.

rjzondervan and others added 30 commits August 17, 2026 15:50
/opsx-verify found the machine secret-request surface non-functional on its
happy path. A live probe of POST /api/v1/app/secret-requests -- provision a
keypair, register, approve, sign an RS256 assertion, exchange it -- returned
400 and then 422. All 11 controller tests and 9 service tests passed throughout,
because they mock the collaborators and never reach the code that failed.

Two layers, both fixed:

1. SecretService::createByApplication refused the keyless Secret shell that a
   secret request must create ("A secret requires a name and a key"). The shell
   has no value by design: a human supplies it later and the server must never
   hold a plaintext (ADR-003). On the user side the client encrypts a
   placeholder, so `key` is ciphertext and the case never arose. Now an explicit
   `allowUnfilled` opt-in permits an empty key for that one caller; a missing
   NAME is still an error, an ordinary write-back still cannot store a valueless
   secret, and the message is unchanged for existing callers.

2. With validation passing, Postgres rejected the row: null value in column
   "key" violates not-null constraint. Secret::$key defaults to '' and the
   Nextcloud Entity setter marks a field dirty only when the value CHANGES, so
   setKey('') on a fresh entity is a no-op and QBMapper omitted the column
   entirely. Version000033 gives the column an empty-string default, matching
   the entity contract. Structural rather than call-site because any future
   insert leaving `key` at its entity default would fail identically, and a
   caller cannot mark a field dirty.

Also corrected: fillLinkUrl pointed at the JSON API route, so an application
doing what the field name says -- hand the link to a person -- sent them raw
JSON including the vault's public certificate. It now resolves to the anonymous
SPA shell (verified: that form answers 200, /apps/doriath/share/request/{token}
answers 401), with fillApiUrl retained for machine polling.

INTEGRATION COVERAGE WAS DEAD, NOT MISSING

tasks.md required extending the machine-secret-api Newman collection, and two
spec scenarios cited it as their @e2e exclude justification. Beyond not being
written, the existing seeded folders could never have run: they sign RS256
assertions in a prerequest script via require('crypto'), which newman's sandbox
does not provide. Every seeded test took its pm.test.skip branch and the run
reported green -- locally and in CI. run-newman.sh now signs assertions where
real node exists and injects them; the collection prefers the injected value.
Section 6 adds 9 requests (create, list, inert userId, three input negatives,
public fill endpoint, jti replay), all verified executing and passing against a
live instance. Note this switches on sections 4-5 too, which have never run --
the openconnector-side CI may surface further findings.

Spec citations corrected: four cited test methods did not exist under those
names, one test name claimed a case its body never exercised (the
non-approved-application half is enforced at authentication, JwtAuthService
:221), and the DI-seam requirement still named SecretRequestService after the
extraction.

KNOWN LIMITATION, documented not hidden: the anonymous fill page renders blank
in a headless browser. It is not specific to this feature -- every route on the
/public shell renders empty for an anonymous visitor, on a freshly rebuilt
bundle -- so it is pre-existing and needs its own issue. The API surface is
fully working; the human hand-off leg is not.

Verified: 50 hydra gates green (gate 4 does not run locally), PHPUnit 982/3155,
vitest 551/551, phpcs 0 errors, php-cs-fixer clean, openspec validate --strict
passes, newman section 6 9/9 executed and passing.

Refs: #120

Assisted-by: ClaudeCode:claude-opus-5
Synced the delta specs into the main specs before archiving, because archiving
moves the deltas out of the active tree and none of the five requirements had
landed yet:

  secret-requests   + Session-less Application-Initiated Request Creation
  secret-store-api  + Machine Secret-Request Creation
                    + Machine Pending-Request Listing
                    + Machine Request Creation Own-Vault Scoping
                    + Machine Request Creation Hardening and Audit

The secret-requests requirement is inserted inside ## Requirements, before
## User Stories, rather than appended at EOF where it would have landed under
## Notes.

Archived to openspec/changes/archive/2026-08-17-application-secret-request-creation.

Verified after the move: the 8 @SPEC anchors in the new code now point into the
archive path, and all 50 gates still pass — the anchor gate tolerates archived
changes by design, which ~450 pre-existing anchors already relied on.

Not done, deliberately: no CHANGELOG.md was created. The archive workflow would
have created one starting at 0.2.28 containing only this change, which
misrepresents a project with dozens of archived changes behind it. Worth doing
as its own commit if changelogs are wanted going forward.

Pre-existing and untouched: openspec validate --specs reports
spec/encryption-suites failing (requirement 7 lacks SHALL/MUST). Both specs
touched here validate clean.

Refs: #120

Assisted-by: ClaudeCode:claude-opus-5
Every route on the /public shell rendered an empty page for an anonymous
visitor. Secret-request fills, link shares and ephemeral sends were all
affected: a recipient without a Nextcloud account could not complete any public
flow. Verified in a headless browser, and it reproduced on a freshly built
bundle, so it was not stale assets.

Root cause is a layout difference, not a routing one. NcContent (rendered by
CnAppRoot) teleports its accessibility skip-link into `#skip-actions`.
Nextcloud's AUTHENTICATED layout provides that element; PublicShellController
renders with RENDER_AS_BASE, whose layout does not. Confirmed directly: the
served HTML contains `skip-actions` once when authenticated and zero times on
/public.

Vue treats a null Teleport target as a hard error during the component update:

  [Vue warn]: Failed to locate Teleport target with selector "#skip-actions"
  [Vue warn]: Invalid Teleport target on mount: null
  Uncaught TypeError: Cannot set properties of null (setting '__vnode')

That aborted the update before CnAppRoot::mounted() reached the `finally` that
clears `capabilitiesLoading`, so the shell sat on its loading spinner forever
and the <router-view> never rendered. The DOM showed exactly one child:
`cn-app-root__capabilities-loading`.

The guard creates the element only when the layout did not, so the authenticated
page is untouched and no duplicate id appears. It is prepended to <body> because
a skip link that is not the first focusable element satisfies the Teleport but
defeats its own purpose. Placed at bootstrap rather than in the template because
the target is a client-side requirement of the component tree; doing it once
covers every controller that renders the shell with a non-user renderAs instead
of relying on each to remember.

Verified after the fix: the anonymous fill page renders "Fill in secret" with
its requested fields and a Submit button, the spinner is gone, and there are no
page errors.

The four new tests were checked against an injected defect (a guard that does
nothing): 3 of 4 fail, the fourth legitimately still passes because it covers
the already-exists path.

Two things noticed while verifying, NOT addressed here because they are UI
decisions rather than this defect: the anonymous page renders the full app
navigation (Dashboard, Vault, Certificates, Settings) and a "Support Doriath"
donation block, neither of which belongs in front of an external recipient, and
the fill inputs report no aria-label.

Verified: vitest 555/555 (80 files), eslint clean, prettier clean.

Assisted-by: ClaudeCode:claude-opus-5
Three defects in the request-creation dialog, all found while verifying the
machine surface end-to-end.

THE LINK DID NOT WORK FOR ITS RECIPIENT

The dialog built /apps/doriath/share/request/<token> and told the user to share
it. That URL answers 401 for anyone without a Nextcloud account — precisely the
person a fill link exists for. It now points at the anonymous shell,
/apps/doriath/public#/share/request/<token>, verified rendering the fill form.
The same defect was fixed on the machine surface in #265; this is the user-side
half. Two tests asserted the broken URL, so they were asserting the bug.

CANCEL LEFT THE PREVIOUS SECRET'S LINK IN THE DIALOG

The form reset was conditional on the "Done" button. Create a request, press
Cancel, reopen the dialog on a DIFFERENT secret, and it still showed the previous
secret's fill link, ready to copy and send to the wrong person. The reset is now
unconditional. With that, the `emitDone` argument controlled nothing, so it is
gone rather than left implying a difference that does not exist — parents already
learn of success from the `created` event.

THE FIELD LIST DID NOT MATCH WHAT THE BACKEND ACCEPTS

`url` was never offered even though secrets have always stored it, and an
additional field could only be requested when the secret ALREADY carried that
key — so a fresh or unfilled secret could not ask for a named extra at all, while
the machine API accepted any name. Both are closed: `url` is offered and labelled
"stored unencrypted" (it is searchable plaintext, and someone typing a value in
deserves to know), and a name can be typed for a field that does not exist yet.
Built-in names are refused there rather than silently accepted, because "key"
typed as a custom field would be routed to the ciphertext column and the user
would be requesting something other than what they typed.

The 6 new source strings are translated into all 36 locales, matching what this
repo does for new strings (64462ca put real translations in every locale rather
than English placeholders) and keeping the no-regression parity ratchet green.
Dutch, German, French, Spanish, Italian and the other major locales I am
confident in; Romansh, Luxembourgish, Maltese, Irish and Belarusian are my best
effort and worth a native check when Transifex next syncs. `l10n/en.json` also
gained "Version history entry", a pre-existing unregistered string the extractor
picked up — kept because removing it would re-break the source check.

Verified: vitest 560/560 (80 files), eslint 0 errors, prettier clean, both l10n
checks pass. tests/vitest/rsa-cross-runtime.spec.js failed once mid-run under
load and passes in isolation; it is untouched by this change.

Assisted-by: ClaudeCode:claude-opus-5
gate-16 requires @SPEC on changed methods. availableFields and addCustomField
implement the Requestable Fields requirement, so they carry real anchors.
onClose carries a documented exclude: no requirement describes when a dialog
resets its local state, and inventing an anchor to satisfy a gate would make the
annotation a lie.

Assisted-by: ClaudeCode:claude-opus-5
… to have

Artifacts only -- no implementation. The human secret-request flow violates a
MUST that has been in the spec since March:

  :12  "Doriath creates an unfilled Secret (a placeholder with no key value)"
  :46  "THEN the system MUST create an unfilled Secret and a SecretRequest"
  :222 "Each new SecretRequest creates its OWN unfilled Secret"

What ships instead: the only entry point is SecretDetail.vue,
SecretRequestCreateDialog requires a `secret` prop, and SecretService::create()
throws on an empty key -- so asking a colleague for a credential means first
inventing a value for the thing you do not have. The machine surface added in
#120 already complies via allowUnfilled, so the human path is the outlier.

The already-filled-fields complaint turned out to be the same bug. Because a
fresh request must aim at an existing, possibly populated Secret, and :147
requires every requested field to be submitted non-empty, a requester can compel
a recipient to overwrite a good value. Under the spec's model that cannot arise:
a fresh request targets a Secret that is empty by construction.

Two genuine requirement additions, the rest is correction:

  secret-requests  MODIFIED Create Secret Request -- a fresh request does not
                   name an existing Secret and the system creates the
                   placeholder (previously only implied by the Purpose section
                   and an acceptance criterion, which is how the implementation
                   drifted without failing a scenario)
                   ADDED     fresh requests do not re-ask for existing values,
                   decided client-side at creation and never disclosed to the
                   recipient
  secrets          ADDED     the keyless exception: a Secret MAY have an empty
                   key ONLY while a pending request targets it, as an explicit
                   opt-in that is never the default

That last one follows Robert's framing: a key IS required for secrets with no
request tied to them, so this is an invariant with a boundary rather than a
relaxed validation. The `secrets` spec never required a non-empty key at all --
the implementation invented that constraint, which is why the two capabilities
currently contradict each other.

Deliberately left open and documented rather than smuggled in: an expired
request stays `pending`, so its placeholder persists as a permanently empty
Secret until revoked. Auto-revoking on expiry changes the Optional Expiry
requirement and belongs in its own change.

Ordering: implementation must follow PR #265, which carries Version000033
(empty-string default on doriath_secrets.key). Without it every keyless insert
dies on the NOT NULL constraint.

18 task checkboxes (cap 20), acceptance criteria as plain bullets.
Verified: openspec validate --strict passes for the change and for both touched
specs. The one failing spec repo-wide (encryption-suites, requirement 7 lacks
SHALL/MUST) is pre-existing and untouched.

Assisted-by: ClaudeCode:claude-opus-5
…ret-request-creation

feat(secret-store-api): session-less secret requests for applications
…cycle

Artifacts only. Extends request-first-secret-requests with the outstanding-request
indicator and expiry handling Robert asked for.

CORRECTION TO MY OWN FRAMING

I said there was no expiry mechanism. That was wrong. Expiry IS enforced:
SecretRequestPolicy::requireOpenByToken() calls isExpired() and answers 408 once
expires_at has passed. What is missing is narrower -- nothing POPULATES the field
and nothing acts on it asynchronously. The limitation I wrote into design.md
yesterday also named the smaller of two litter sources (expired-but-pending)
rather than the larger one (requests that never had an expiry), which I would
have caught by tracing expires_at before writing it.

Tracing it is what shaped this: expires_at has exactly one source, a requester
typing into an optional datetime-local input that defaults to empty. So a job
that only sweeps expired requests would be nearly inert, and the litter sits in
the population the job must not touch.

WHAT THE ARTIFACTS NOW SAY

Expiry is set: the create surface pre-fills a suggested expiry the requester can
change or clear. A client-side default, not a policy -- no server semantics, no
admin setting, no migration, and still spec-legal because the requester remains
the one setting it. rotation-expiry-policies holds the heavier pattern (instance
default shipped disabled + per-user override) for a later change.

Expiry acts: a TimedJob transitions lapsed pending requests to a new terminal
STATUS_EXPIRED, invalidating the token and deleting the placeholder -- the same
cleanup revoke performs, because the keyless invariant does not care why the
request ended. Requests with NO expires_at are never touched. Follows
ExpireMachineLeasesJob (hourly).

Expiry stays distinguishable from cancellation: a terminal status rather than a
silent revoke, so a vault row never vanishes unexplained, and the audit actor is
the system rather than the requester who took no action.

A DEFECT THIS CHANGE WOULD OTHERWISE HAVE INTRODUCED

Robert's instruction -- always check expiry on access, not only status -- caught
it. The gate does check expiry, but INSIDE `case STATUS_PENDING`, and the switch
ends in `default: throw ... 'Request is in an unknown state', code: 500`. Adding
STATUS_EXPIRED without its own arm would answer every expired link with a 500
instead of saying it expired. The evaluation is therefore hoisted above the
switch, and `expired` gets an explicit 410-family arm. The job is cleanup, never
enforcement: between a request lapsing and the next hourly sweep its stored
status still reads pending, so the gate must refuse on expires_at alone.

The indicator is specced as required rather than polish: once asking for a
credential normally produces an empty placeholder, an unmarked vault gains rows
that read as broken secrets. It distinguishes "no values yet" from "re-request
outstanding" (the first is unusable, the second works until new values arrive)
and never carries the fill token.

Delta now: 3 requirements MODIFIED (Create Secret Request, Optional Expiry,
Revoke Request), 2 ADDED (no re-asking for existing values, outstanding-request
indicator), 23 scenarios. 19 task checkboxes (cap 20) after merging three pairs
that were one concern each. openspec validate --strict passes.

Assisted-by: ClaudeCode:claude-opus-5
Per ADR-032 and Robert's call that the expiry lifecycle matters less for a beta
than getting the concept of a secret request right.

  request-first-secret-requests     17 tasks -- the beta-relevant correction:
                                    a fresh request creates its own unfilled
                                    Secret, a vault-level entry point, no
                                    re-asking for filled values, and the
                                    outstanding-request indicator
  secret-request-expiry-lifecycle   14 tasks -- expiry is set (pre-filled,
                                    clearable), swept (TimedJob -> terminal
                                    `expired`), and enforced independently of
                                    the sweeper

Siblings, not a chain. Neither depends on the other: the expiry change operates
on requests as they exist today, and placeholder deletion is already exercised by
application-created requests from #120. Both edit
SecretRequestCreateDialog.vue, so whichever lands second absorbs a small
conflict; the expiry change touches one field, so it is the cheaper rebase.
Priority is recorded in both proposals.

The split also let the request-first tasks go back to being separate where I had
merged three pairs purely to fit under the 20-checkbox cap -- 17 now, so the
merging was cap pressure rather than genuine cohesion.

Stated rather than hidden: with the expiry change deferred, request-first ships
placeholders with no sweeper behind them, so an abandoned placeholder persists
until someone revokes it. Revoke does clean up (:189) and a lapsed request is
still refused on access, so the cost is vault clutter, not a correctness or
security gap. That is an accepted consequence of the ordering, now written into
request-first's Impact and Risks.

Also carried into the expiry change and worth keeping visible: hoisting the
expiry evaluation above the status switch is load-bearing, not tidying. The
switch ends in `default: 'Request is in an unknown state', code: 500`, so adding
STATUS_EXPIRED without its own arm would answer every expired link with a 500.
And the job is cleanup, never enforcement -- between a lapse and the next hourly
sweep the stored status still reads `pending`, so the gate must refuse on
expires_at alone or the job's schedule becomes part of the security boundary.

The concrete pre-filled interval is deliberately left open in both spec and
tasks: it is a product call, not an implementation detail to bury.

Verified: openspec validate --strict passes for both changes and for
secret-requests; 17 and 14 checkboxes, both under the cap; criteria are plain
bullets.

Assisted-by: ClaudeCode:claude-opus-5
  #267  request-first-secret-requests     17 tasks  feature/267/...
  #268  secret-request-expiry-lifecycle   14 tasks  feature/268/...

Base branch is `development` (the repo default). Labels openspec + doriath +
one per delta spec; all four already existed, so none were created. No trigger
label: there is no HYDRA_LABEL_PREFIX in ~/hydra/secrets/.env, so neither issue
is queued to Hydra.

The beta priority is written into the issue bodies rather than left in a
conversation: #268 states it sits below #267 and that nothing in #267 waits on
it, so whoever picks these up does not start with the wrong one. #268 also
carries the two things most likely to be missed when reading its tasks in
isolation -- that hoisting the expiry check is load-bearing (adding
STATUS_EXPIRED without its own switch arm answers every expired link with a 500)
and that the sweeper is cleanup rather than enforcement -- plus the one open
product decision, the concrete pre-filled expiry interval.

Renumbered request-first's tasks while building the plan: stripping the expiry
work had left gaps at section 2 and at 3.3 / 4.4, which read as if work had gone
missing. Sections are now 0-4 with consecutive sub-numbers; no content changed.

Verified live: both issues carry their full checkbox set (17 / 14), the
placeholder `## Branch` section resolved to the real feature branch on the
second write, and each plan.json now records tracking_issue, base_branch and
feature_branch for /opsx-apply.

Assisted-by: ClaudeCode:claude-opus-5
Backend half of #267 (tasks 0.1-1.4). Closes the MUST the human flow has been
violating since March: "the system MUST create an unfilled Secret and a
SecretRequest". Until now the only surface demanded a pre-existing Secret and
SecretService::create() refused an empty key, so asking a colleague for a
credential meant first inventing a value for it.

1.1 `SecretService::create()` takes an explicit `allowUnfilled`, default false.
    A NAME stays required in both modes; only the key requirement is relaxed,
    and only when the caller asks. The suppression mirrors #266's adjudication
    verbatim so this does not reintroduce the phpmd BooleanArgumentFlag failure
    that was just fixed on development.

1.2 `SecretRequestService::createForUserVault()` creates the placeholder and
    then the request, mirroring createForApplicationVault() including its
    rollback -- the shell exists only to receive this request. The suite id is
    read off the Secret the system just created rather than taken from a caller
    parameter, so it cannot drift from the Secret the values land in.

1.3 `secretId` is now optional on the create endpoint and a new MODE_FRESH
    dispatches to the above. A re-request without `secretId` is refused
    explicitly rather than failing deeper on a generic message, because the two
    flows differing by required input is what keeps them distinct.

1.4 Revoke now deletes the placeholder -- "the unfilled Secret MUST be deleted"
    was specified and never implemented; it only becomes reachable now that
    fresh requests create placeholders.

    The discriminator is EMPTINESS, not `isReRequest`. A plain request also
    carries isReRequest === false while targeting a Secret the USER picked, so
    keying the delete on that flag would destroy real credentials. Cleanup is
    fail-soft and runs after the status flip: an orphan empty Secret is a far
    better failure than a deleted Secret whose request is still pending.

Two mistakes the tooling caught, worth recording:

  - I guessed `SecretMapper::find()`; it is `findById()`. The existing decline
    test failed immediately with "Call to undefined method", which is the value
    of running the suite per task rather than at the end.
  - Adding a public method pushed the class to 11 against phpmd's threshold of
    10. Adjudicated in the class docblock rather than split, because the public
    surface IS the request state machine and separating the creation paths would
    scatter the rollback and the fresh-vs-re-request distinction the spec turns
    on.

The change artifacts are merged in from the openspec branch: the @SPEC anchors
this code carries point at the change delta, which did not exist on this branch
until now. #268's artifacts ride along in that merge.

Verified: 50 hydra gates green, PHPUnit 559/1696 in tests/Unit/Service, phpcs 0
errors, phpmd 0 violations on both touched services.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
…irst

Frontend half of #267 (tasks 2.1-2.5).

2.1 `SecretRequestCreateDialog`'s `secret` prop is optional. With no target it
    asks what you want and offers a folder, requires a name, and creates the
    placeholder through the fresh path. Heading now names which of the three
    acts it is rather than calling all of them "Request secret fill-in".

2.2 Already-filled fields are no longer pre-selected, and are labelled as
    holding a value. Decided client-side from key/login/url and the DECRYPTED
    additionalFields, because the server never decrypts that blob (ADR-003) and
    telling the anonymous recipient which fields are filled would hand vault
    metadata to an unauthenticated party. A re-request is exempt: replacing
    values is its purpose. It matters because the recipient cannot decline a
    requested field, so carrying a filled one in compels an overwrite.

2.3 "Ask for a credential" added at the vault level, reachable with an EMPTY
    vault. Mounted with no `:secret`, which is what makes it a fresh request.

2.4 The detail action says which thing it does -- "Ask for new values" on a
    Secret that holds one, "Ask someone to fill this in" on an empty
    placeholder -- and binds isReRequest to whether a value exists, so an
    overwrite is only ever called an overwrite.

2.5 Rows carry an outstanding-request badge, distinguishing "waiting for someone
    to fill this in" (unusable) from "new values requested" (usable until they
    arrive). It takes a STATE, never the request: a fill token in a list row
    travels into screenshots for no benefit. Clears when the request stops being
    pending.

A REAL BUG FOUND ON THE WAY, NOT PART OF THE PLAN

The dialog built its payload in snake_case (`secret_id`, `requested_fields`)
while the store forwarded camelCase (`payload.secretId`, ...). Every value
arrived `undefined`, so the POST went out empty and the endpoint answered
400 "requestedFields cannot be empty" -- the plain create path has never worked
from the UI. Proven live against both shapes before changing anything. The unit
test asserted the snake_case payload against a mocked store, so it passed while
the feature was broken: the same mock-hides-integration shape as the 400 in #265.
That assertion is corrected and now also asserts the absence of `secret_id`.

Also fixed here: an earlier insertion of mine had stranded availableFields'
docblock above isFreshRequest, so it documented the wrong method and gate-16
caught the missing @SPEC.

VERIFIED LIVE, not only in unit tests
  - fresh request -> 201, placeholder auto-created, token minted, all three
    field kinds echoed (key, url, zgw-client-id)
  - the row in the database: name set, `key` EMPTY, owner user/admin, request
    pending, is_re_request false
  - revoke -> placeholder deleted (0 rows) while all 6 filled secrets survive,
    which is the hazard the emptiness discriminator exists to avoid

Verified: 50 hydra gates green, vitest 560/560 (80 files), eslint 0 errors,
frontend build clean.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
Tests and quality for #267 (tasks 3.1-4.2). All 17 tasks now complete.

3.1-3.3 PHPUnit: create() refuses an empty key by default, accepts one with the
    opt-in, and still requires a name in both modes; a fresh request creates its
    own placeholder and two do not share one; a failed creation rolls the shell
    back; revoke deletes an unfilled placeholder, NEVER a filled Secret, and
    never reaches across an ownership boundary.

    Both new guarantees were checked by injecting the defect they exist to catch:
    dropping the key requirement fails testCreateStillRefusesAnEmptyKeyByDefault,
    and deleting any linked Secret on revoke fails
    testRevokeNeverDeletesAFilledSecret. A test that cannot fail is not coverage.

3.4 Vitest: the dialog submits with no `secret` prop and sends `name` instead of
    `secretId`, refuses a nameless fresh request, does not pre-select a filled
    field, reads member-level filled-ness from the decrypted blob, and a
    re-request still pre-selects the filled key. The row badge distinguishes
    awaiting-fill from re-request, disappears without a request, and renders no
    32-hex string -- the token has no path into a list row.

3.5 Every scenario in the delta now carries a reason-bearing @e2e exclude naming
    the test that actually drives it. Two scenarios had no test, so I wrote them
    rather than cite fiction: the two-fresh-requests invariant, and
    SecretRequestFillControllerTest::testShowNeverDisclosesWhichFieldsAreAlreadyFilled,
    which asserts the fill payload as a CLOSED set so a future field cannot leak
    filled-ness to an anonymous recipient unnoticed.

4.1 13 new UI strings translated into all 36 locales (455 entries), keeping the
    no-regression parity ratchet green. Dutch, German, French, Spanish, Italian,
    Portuguese and the Nordic/Slavic majors I am confident in; Romansh,
    Luxembourgish, Maltese, Irish and Belarusian are best effort and deserve a
    native pass at the next Transifex sync.

A MISTAKE I MADE THREE TIMES, WORTH NAMING

Inserting a method by anchoring on `methodName() {` drops the new code BETWEEN an
existing docblock and its function, silently reassigning that docblock -- and its
@SPEC -- to the wrong method. It happened to availableFields, addCustomField and
onCredentialRequested. gate-16 caught all three. Worse, one of my repair anchors
matched the first `@return {Promise<void>}` in the file and landed an
outstanding-request-indicator @SPEC on `onBulkDone()`, an unrelated pre-existing
method; that false annotation is removed. Anchor on the docblock, not the
signature.

Recorded as a sync-time action rather than left to rot:
openspec/specs/secret-requests/spec.md:9 carries a FILE-LEVEL @e2e exclude
justified as "No secret-request UI is built in v0.1". This change builds that UI,
so the justification is now false and must be replaced on sync.

Verified: 50 hydra gates green, PHPUnit 993/3186, vitest 569/569, phpcs 0,
phpmd 0, psalm clean, php-cs-fixer clean, eslint 0 errors, frontend build
successful, both l10n checks exit 0, openspec validate --strict passes.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
…created

Robert asked where the URL to fill a request comes from. It came from nowhere:
the requester could not obtain it through the UI at all.

The dialog builds it correctly and shows it in a read-only field with a Copy
button and a Done action -- an arrangement that only makes sense if you are meant
to linger. submit() sets `fillUrl` and THEN emits `created`. Both parents closed
the dialog on that event, and both mount it with v-if, so the component was
destroyed one tick after computing the link:

  SecretList::onCredentialRequested   credentialRequestOpen = false   (mine, 2.3)
  SecretDetail::onRequestCreated      requestDialogOpen = false       (pre-existing)

The second one's docblock stated the behaviour as the intent -- "Close the dialog
after the new request was created" -- so the fillUrl block and the Copy button
have been unreachable code since the feature shipped. The fallback route does not
work either: SecretRequestList renders the token deliberately TRUNCATED
(truncateToken -> "abcd1234…"), so it cannot be reconstructed from the list, even
though the API returns the full token. After creating a request the only ways to
reach the link were the database and a raw API call.

That means the user-side secret-request feature has never worked end to end. It
is very likely part of why the flow felt wrong in the first place.

Neither parent closes on `created` now; the user dismisses the dialog when they
have the link. SecretDetail's handler stays as the hook, with the reasoning in its
docblock so the next reader does not "tidy" the close back in.

The regression test was checked by reinstating the old line: it fails with the
close, passes without it.

Verified: 50 hydra gates green, vitest 570/570, eslint 0 errors, frontend build
successful, l10n source check exit 0.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
Two live failures Robert hit while exercising the flow, plus the copy-link action
he agreed to.

500 ON EVERY FIRST FILL — the same NOT NULL trap as Version000033, one table over

Filling a placeholder answered 500 "Unable to fulfil request". `update()` snapshots
the PRE-update row; for a placeholder that row's `key` is empty, and
SecretVersion::$key defaults to '' — so the Nextcloud Entity setter never marked
it dirty, QBMapper omitted the column, and the NOT NULL constraint on
doriath_secret_versions.key rejected the insert:

  ERROR: null value in column "key" of relation "oc_doriath_secret_versions"
         violates not-null constraint

Unreachable before, because no keyless Secret could be updated. Request-first made
it reachable on the very first fill of every placeholder. Fixed on both levels:

  Version000034  gives the column an empty-string default, matching the entity
                 contract. Version000033 did exactly this for doriath_secrets.key
                 and should have prompted a sweep of every table with the same
                 shape; it did not, and this is the cost.
  shouldSnapshot() skips the snapshot when the pre-update row held NO values at
                 all. A first fill has no earlier value to return to, so the row
                 would be a version saying "nothing". Narrow on purpose: a real
                 credential change still snapshots, which a second test pins.

Verified live end to end: create 201, fill 200 -> fulfilled, the Secret holds the
key, url and additionalFields blob, and zero junk version rows.

THE FILL LINK CAN NOW BE RECOVERED

The link was shown once and the token is rendered truncated by design, so closing
the dialog stranded the request — revoke and start again was the only remedy. The
list now offers "Copy fill link" per row, using the SAME url builder as the
dialog: src/utils/fillLink.js is the single place that string is constructed,
because the dialog has already shipped two wrong variants (the JSON endpoint, and
the authenticated path that 401s for the recipient). A second consumer cannot
reintroduce a third.

Offered only where the link would work: fulfilled, declined and lapsed requests
get nothing, and expiry is judged on `expires_at` rather than status because
nothing sweeps until secret-request-expiry-lifecycle lands. The row still shows
the token truncated; the full value reaches the clipboard only on request.

Also here: the Fill Link Recovery requirement the new @SPEC anchors referenced —
it did not exist yet, so gate-46 would have caught the dangling anchor.

NOT A BUG: 'Cannot read properties of undefined (reading importKey)'

crypto.subtle is undefined outside a secure context. Measured in a headless
browser: http://nextcloud.local -> isSecureContext=false, subtle=undefined;
https://nextcloud.local -> true, object. Robert's browser had chosen http.
Environmental, no code change.

MY OWN RECURRING MISTAKE, FOURTH OCCURRENCE

Inserting a method by anchoring on its neighbour's signature drops the new code
between an existing docblock and its function, silently reassigning that docblock.
It hit fieldsChanged this time; phpcs caught it. availableFields, addCustomField
and onCredentialRequested were the earlier three. Anchor on the docblock.

Verified: 50 hydra gates green, PHPUnit 995/3190, vitest 573/573, phpcs 0,
phpmd 0, psalm clean, php-cs-fixer clean, eslint 0 errors, build successful,
both l10n checks exit 0 (2 new strings x 36 locales), openspec validate --strict.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
…ncrypt

The fill page is the one surface handed to strangers, and outside a secure context
it died on `crypto.subtle.importKey` with "Cannot read properties of undefined" --
meaningless to the recipient, and giving them no reason to suspect the URL's
scheme. They cannot fix the instance, but they CAN open the https:// form of the
same link, so the page now says that.

Checked before every other state, because every path below encrypts in the
browser. Both `isSecureContext` and the presence of `crypto.subtle` are tested: a
browser could in principle report one without the other, and the failure being
prevented is a raw TypeError shown to an outsider. The form is not rendered at
all -- offering a submit that cannot succeed is worse than explaining why.

Measured rather than assumed, in a headless browser against this instance:

  http://nextcloud.local    isSecureContext=false  crypto.subtle=undefined
  https://nextcloud.local   isSecureContext=true   crypto.subtle=object

which is exactly the environment Robert's browser had chosen. No server change:
the instance's overwrite.cli.url is already https, and overwriteprotocol is
deliberately unset so generated links follow the scheme actually in use.

The test sets isSecureContext=false and asserts both that the explanation renders
and that no input is offered.

2 new strings translated into all 36 locales.

Verified: 50 hydra gates green, vitest 574/574, eslint 0 errors, build
successful, both l10n checks exit 0.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
…ents

gate-16 flagged it and I committed 844d641 while that gate was red -- the
pre-commit hook is not executable in this checkout, so nothing stopped me. The
previous commit should have waited.

Assisted-by: ClaudeCode:claude-opus-5
Filling a requested secret in a private browser showed the "Support Doriath"
note. Those pages are opened by people who are not our users at all — someone
submitting a credential we asked them for, or opening a share — and an appeal
mid-task is at best noise, at worst a phishing tell on a page that is about to
receive a secret.

CnAppRoot documents the opt-out (`supportDialog: false`), so this is a host
decision, not a library change.

The first attempt did not work, and why is worth recording: I keyed it on a
computed over `$route.name`. CnAppRoot reads `props.supportDialog` ONCE inside
setup() to decide whether to wire the dialog at all, and that runs before the
router resolves the initial navigation — so the value it read was always the
pre-navigation one. Verified by measuring the rendered page, not by reasoning: the
donation text was still present after the first fix.

It now answers from the URL, which is correct at that moment.
`isPublicSurface(location)` lives in router/guards.js beside `isPublicRoute()`,
because that module already owns what "public" means and a second definition
would drift: `/apps/doriath/public` is the anonymous shell, plus the recipient
hash prefixes for the same routes on the authenticated shell.

The library's own guard is not sufficient here. It opens the note only on a
DEFINITIVE "not seen" answer, but the preference request 401s for an anonymous
visitor and a 401 is not a definitive no.

Verified in a headless browser on the anonymous shell: donation text absent, the
fill form and Submit still present.

Still there and NOT addressed, because it is a separate UI decision: the
recipient page renders the full app navigation (Dashboard, Vault, Certificates,
Settings), none of which they can use.

Verified: 50 hydra gates green, vitest 578/578 (80 files), eslint 0 errors, build
successful, l10n source check exit 0. tests/vitest/rsa-cross-runtime.spec.js
failed once mid-run under load and passes in isolation; it is untouched here.

Refs: #267

Assisted-by: ClaudeCode:claude-opus-5
…equests

Artifacts only. An application can ask a human to submit a credential into its
own vault and no person on the instance can see that it did.

Measured, not inferred:

  created_by                                        count
  admin                                               9
  application:b177cd1a-aa48-41b6-8728-7940a6527181     2
  application:446992fb-4e51-426d-adae-a6ff5eab17de     2
  application:fe52a684-9d68-4feb-8041-3ea3c57801a7     2

Six invisible requests. The cause is one scoping decision:
SecretRequestController::index() -> listByUser() -> findByCreatedBy(), an exact
match, and application requests carry created_by = 'application:<uuid>'. The
target Secrets are application-owned so they appear in no vault; the only lister
is the application's own Bearer endpoint; the admin views have no request
surface. Creation IS audited (6 application.secret_request_created rows), so the
events exist with nowhere to read them as state.

Admin-scoped rather than registrar-scoped, decided in design.md: an
application's vault belongs to no single user, registration is a historical act
rather than continuing responsibility, and the registrar may have left. Tying
audit visibility to it would make "who can see this" depend on who clicked
Register months ago.

The user-side listing is deliberately left alone. A single method that sometimes
means "mine" and sometimes "this application's" is how scoping bugs happen.

Follows the precedent set by Machine Pending-Request Listing, which exists so a
fill link is retrievable after creation for an application; this is the same
argument for the administrator accountable for that application.

Risks stated rather than discovered: an admin now routinely sees requested field
names (plaintext by design, already audited, but a real widening of who reads
them); an admin revoke can break an integration mid-flow, which is the intended
power and should read as consequential; and the shared list component will serve
two authorities, so the scope must come from the endpoint rather than a prop.

13 task checkboxes. openspec validate --strict passes for the change and for
application-mgmt.

Assisted-by: ClaudeCode:claude-opus-5
Artifacts only. An owner can SEE their additional fields but has no way to add or
change one -- every layer supports them except the two dialogs an owner uses.

  controller / service / store encrypt+decrypt / SecretDetail render   support it
  SecretCreateDialog / SecretEditDialog                                zero refs

So the only writers are other actors: the write-for-application dialog, the share
dialogs (pass-through), import, and a secret-request fill submitted by an EXTERNAL
RECIPIENT. Today the only way an owner gets an additional field onto their own
secret is to ask a stranger to put it there, or to import it. That is how it
surfaced -- the request flow lets a requester name arbitrary members and the fill
page stores them happily.

The omission is in the specification, not only the code. Nine specs discuss
additional fields; secrets-write-ui, the one capability that owns creating and
editing a secret, never mentions them, and both of its requirements enumerate the
collectable fields and leave this one out. Hence MODIFIED rather than ADDED.

Design decisions worth the paper: reuse the request dialogs reserved-name
validation rather than write a third copy of a rule that matters (a member named
"key" would look like a second value field while the real key goes to its own
column); refuse reserved names rather than remap them, because silently routing a
member called "url" to the url column inverts the plaintext/ciphertext
distinction for that value; and removing the last member yields an EMPTY blob so
"none" stays distinguishable from "not loaded".

Stated rather than discovered: every member edit rewrites the whole blob, so an
edit from a stale decrypted copy drops members another session added. Inherent to
the single-blob design the secrets and import specs deliberately require.

12 task checkboxes. openspec validate --strict passes for the change and for
secrets-write-ui.

Assisted-by: ClaudeCode:claude-opus-5
This spec was auto-generated when implement-secrets-write-ui was archived and
never got the Status / OpenSpec-changes block every other spec carries, so
registering a change against it had nowhere to go.

Added, with both changes listed. Its Purpose is still the generated placeholder
("TBD - created by archiving change ... Update Purpose after archive"); left as
found rather than invented here, but it is a real gap in a spec that owns the
secret create and edit surface.

Assisted-by: ClaudeCode:claude-opus-5
  #271  admin-application-request-visibility   13 tasks
  #272  owner-editable-additional-fields       12 tasks

Base branch development. Created the secrets-write-ui label, which did not exist
yet -- that spec had never had a change tracked against it.

Both issue bodies carry the measured evidence rather than a description: the six
invisible application requests with their created_by values for #271, and the
layer-by-layer table showing where additional-field support stops for #272.
Whoever picks these up should not have to rediscover why they exist.

Assisted-by: ClaudeCode:claude-opus-5
Implements #268 except its l10n/sweep tasks. Expiry was checked on access but
never set by default and never swept, so a request that lapsed months ago still
sat pending holding an unfillable placeholder.

Expiry is now SET: the create dialog pre-fills 14 days ahead (confirmed with the
product owner rather than defaulted here), clearable so a perpetual link stays one
action away. `expires_at` has exactly one source -- that field -- so while it
defaulted to empty almost nothing expired and a sweeper would have had nothing to
act on. Formatted from LOCAL date parts, not toISOString(): datetime-local carries
no timezone, so a UTC string would display a time the user never chose.

Expiry is now ACTED ON: a TimedJob (hourly, following ExpireMachineLeasesJob)
transitions lapsed pending requests to the new terminal STATUS_EXPIRED,
invalidating the token and deleting an unfilled placeholder while preserving a
re-request's Secret and values. Requests with NO expires_at are never touched --
the mapper predicate says `expires_at IS NOT NULL` explicitly rather than resting
that promise on SQL three-valued logic. Attributed to the SYSTEM via forSystem(),
because the requester took no action.

VERIFIED LIVE: running the job swept three genuinely lapsed seeded requests,
recorded request.expired with actor_type=system and no actor_id, and deleted the
placeholder Secret.

A CLAIM OF MINE THAT WAS TOO STRONG, NOW CORRECTED

I wrote -- in the spec, in a code comment and in earlier commit messages -- that
without hoisting the expiry check above the status switch, the sweeper's schedule
would become part of the security boundary. That is false, and my own test proved
it: with the check nested back inside the pending branch,
testLapsedButUnsweptRequestIsRefused still passed, because that branch already
checked expiry. A lapsed pending request was ALWAYS refused.

What hoisting actually buys, now stated accurately in all three places: expiry
takes precedence over every other status -- a locked request whose expiry passed
reports "expired" rather than "temporarily unavailable", which is truer because
locked invites a retry that can never succeed -- and a status added later cannot
bypass expiry by omission. testExpiryTakesPrecedenceOverATemporaryStatus is the
test that fails when the hoist is reverted; the others do not.

The genuinely load-bearing half is the explicit `expired` switch arm: without it
the new status falls to `default` and answers 500 "unknown state" to a recipient
whose link simply ran out. testExpiredStatusReportsExpiryRatherThanAServerError
asserts 410 and explicitly NOT 500.

Also here: SecretRequestPolicy had NO direct tests, which is how a gate structured
that way went unnoticed. It has 7 now. Task 3.3 needed no work -- the fill view
already handled `expired`.

Adjudicated rather than split: findLapsedPending pushes SecretRequestMapper to 11
public methods against phpmd's threshold of 10. A mapper's public surface is its
query set, and splitting queries over one table across two classes is how a
scoping rule gets applied in one and not the other.

Verified: PHPUnit 1005/3212, vitest 581/581, phpcs 0 errors (lib), phpmd 0,
psalm clean, php-cs-fixer clean, eslint 0 errors, l10n source check exit 0,
openspec validate --strict passes.

Two gates remain red and are addressed on the parent branch, not here: gate-46
(an @SPEC anchor I mistargeted in guards.js) and gate-19 (5 pre-existing
secrets-write-ui scenarios pulled into scope by a header edit).

Refs: #268

Assisted-by: ClaudeCode:claude-opus-5
The quality run on #270 named two real gaps.

`src/router/guards.js` anchored its `@spec` at the change's delta spec, which
disappears on archive. Retargeted at the requirement in the main spec, which is
where a reader will still find it next month.

`SecretRequestController` had no test file at all while carrying the mode
selection between a fresh request, a re-request, a plain one and an application
one. That absence is why the payload-shape defect survived: the dialog sent
snake_case, the store forwarded camelCase, and the only test that touched
creation mocked the store, so nothing asserted what the controller received.
Six tests now pin each mode, including the explicit refusal of a re-request
without a secretId.

Four tests cover the SecretRequestService paths nothing exercised: the empty
userId guard, and the three fail-soft catches. The rollback ones matter beyond
coverage — they decide whether a failed request leaves an orphan placeholder
Secret behind, and one of them asserts a failing rollback still surfaces the
ORIGINAL error rather than the cleanup's.

Assisted-by: ClaudeCode:claude-opus-5
Reviewer point on #270, and it is right: `deletePlaceholderIfUnfilled()` asked
whether a Secret was ever filled by testing `key === ''` alone. A Secret can
hold a login, a custom-field blob or a url with an empty key, and the delete it
guards is hard — it takes the version history with it.

Reachable through the UI with no race, because this PR is what made `key`
optional at the checkbox level: ask for a login only, have it filled, open a
second request on that Secret, revoke it, and the credential is gone.

The same PR already carried the wider predicate for a different decision
(`SecretService::hadNoValues()`), which is the tell. They stay separate
functions: being too eager there writes a junk version row, being too eager
here destroys a credential, so this one errs toward keeping the Secret and the
residue is the orphan-placeholder tidiness the expiry cleanup handles.

The reviewer's suggested remedy — a provenance flag recording that the Secret
was created as a placeholder — is not needed. `create()` refuses an empty key
unless `allowUnfilled` is set and `update()` refuses one outright, so a keyless
user-owned Secret is already provably a placeholder. Filled-ness was the
missing half of the test, not provenance, so no column and no migration.

Three tests reproduce the loss (login-only, additionalFields-only, url-only)
and failed against the old guard for the right reason: delete called once where
it must never be called. A fourth pins that a genuinely empty placeholder is
still cleaned up, so widening the test did not disable what it guards.

The existing `testRevokeNeverDeletesAFilledSecret` missed all of this because it
only ever filled `key`.

Assisted-by: ClaudeCode:claude-opus-5
core.hooksPath points at .githooks, but the file was not executable, so the
features.json regeneration it exists to do never ran on this machine. It is
best-effort and never blocks a commit, which is exactly why nobody noticed.

Assisted-by: ClaudeCode:claude-opus-5
rubenvdlinde and others added 26 commits August 28, 2026 10:52
.github#597 set cancel-in-progress on the shared quality.yml, but a
caller-level concurrency cancels the whole run before the called
workflows setting can apply -- so that fix reached only the apps that
declare no concurrency of their own.

Measured 2026-08-28 over push runs on development since #597 merged:

    caller silent          0 of 11 cancelled
    caller says true       7 of 13 cancelled  (54%)

This repo is in the second group. pull_request keeps cancelling, where
superseding really is correct.
The skip-discipline gate now runs here (hydra-gates 1.10.0, #472) and
reports one V3 finding — an exclusion with no reason recorded.

The reason exists and is a good one; it sits in a block comment above the
test, and `test.fixme(title, fn)` records no description in report.json.
The body was a single line pointing AT that comment:

    // Intentionally empty — see block comment for the precise blocker.

which tells a human where to look and a machine nothing.

The reason moves onto the fixme itself, and the parenthetical leaves the
title. Same test, still excluded, now attributable.

Verified: npm ci rc=0, npm run lint rc=0 (0 errors), prettier clean, and
`playwright test --list` compiles all 8 tests with the exclusion intact.

Part of ConductionNL/.github#609.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* refactor(manifest): the flow pages are an index and a flow

`flows` and `flow-detail` are deprecated aliases. `flows` predates named index
sources: a flow lives in OpenRegister's native flow table rather than a
register/schema pair, so an object-backed index had nothing to bind to and the
list needed a page type of its own. `config.entitySource` closes that, so the
list is an ordinary `index` and only the EDITOR still needs its own type, now
named `flow`.

Behaviour is unchanged - both aliases resolve to the same components. The list
page's `_note` is updated in the same commit: it asserted the old rationale
(that an index "cannot address" a flow), which this change disproves.

`config.app` is untouched and is load-bearing: the editor stamps it on a flow
created there and the index filters on it.

Requires @conduction/nextcloud-vue 2.21, where a named source's columns and
create button are actually read.

* chore(deps): bump @conduction/nextcloud-vue to ^2.21.0

The flow pages need 2.21.0: earlier releases DECLARE a named index source's
columns, create button and row actions without reading them, so the migrated
page renders a columnless table with no working create action.

The lock is the part that matters. CI installs with `npm ci`, which honours
package-lock.json and ignores how permissive the caret is — bumping the range
alone would change nothing about what actually installs.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Adds `keepiq-compose.yaml` and a setup page describing it.

The compose brings up Postgres and Nextcloud, installs openregister (required),
thematiq and integriq (optional) and keepiq from release tarballs, and enables
them in dependency order. Nothing is bind-mounted: Nextcloud installs an app by
deleting its directory and extracting an archive over it, so pointing that at a
checkout deletes the working tree — measured on a development machine on
2026-08-27, where an app-store update fired on a container restart and removed
every top-level file including .git.

Release tarballs rather than a clone for a second reason: a tarball is a
complete app carrying vendor/ and the built js/, and an app with no vendor/
does not fail loudly — it warns once and keeps loading, so it looks installed
while every service needing a dependency is absent.

The openregister dependency is not declared in appinfo/info.xml — no app in the
fleet declares an <app> dependency — so the compose encodes what the manifest
does not.

Verified: docker compose config parses and interpolates; the same generated
file was booted end to end for portaliq, which produced 17 registers, 86
schemas and 13 magic tables for its own register, with the portal content API
returning a real site rather than an empty shell.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two defects, both measured against a booted demo rather than inferred from
the code.

The verification step told you to run

  curl -s -o /dev/null -w '%{http_code}' http://localhost:PORT/apps/APP/

and described a pass. That request is unauthenticated, and a Nextcloud app
page requires a login, so it prints 401 on a perfectly healthy demo. A reader
following the page would conclude the demo was broken. The command now sends
the demo credentials and says in words that a bare 401 is expected.

Thematiq additionally has no app route at all — it declares
<admin-section>theming</admin-section> — so /apps/thematiq/ answers 404 even
authenticated. Its page now opens Settings -> Administration -> Theming.

Verified on two booted demos: the corrected commands return 200.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…484)

* feat(walkthrough): a first-visit tour built from this app's own menu

ADR-043. The app had no walkthrough, so a new user arrived on a shell with no
orientation at all.

Every step targets a route READ FROM THIS MANIFEST, never invented.
CnWalkthrough resolves target {kind: nav-item, ref} by querying
[data-cn-route=<ref>], which CnAppNav emits from the ROUTE - so a tour pointing
at a menu id that differs from its route still renders, as a centred anchorless
coachmark that points at nothing. Verified: every ref resolves to a page this
manifest declares.

Where a menu label is a translation KEY rather than a sentence, the copy uses
the key's last segment as a word. Printing launchpad.menu.dashboards at a user
is worse than useless, and falling back to the route gives 'Click
dashboard-detail in the menu' - an internal identifier read aloud.

Verified: manifest validates against schema 2.26.0, every target ref resolves,
and no raw translation key reaches the copy.

* fix(walkthrough): point the tour at flows, and translate its copy

gate-70 walkthrough-flows-stop - this app ships a flows page and no tour step
targeted it. The gate's own measurement is why it exists: across 20 manifests,
12 apps shipped a flows page and exactly ONE tour mentioned it, so the
automation surface was discoverable only by someone who already knew.

Detected the way the gate detects it - type:'flows' OR the migrated form, an
index over config.entitySource:'flows'. Matching on type alone would miss every
app the flow-page migration touched, which is all of them.

The step advances on route-match and sets allowManualNext. The same gate fails a
step advancing only on object-created: that turns 'here is where flows live'
into 'build an automation before you may continue', and a tour nobody can finish
is worse than one that never mentions flows.

gate-102 manifest-l10n-coverage - the tour's titles, bodies and tasks had no
l10n/nl.json key. Added, and the browser catalogue rebuilt where the app ships
one, because the browser reads nl.js rather than nl.json.

* style(walkthrough): drop the em-dash from the flows step

gate-96 (manifest-copy-style) fails this: voice.md section 8 is unambiguous —
no em-dashes, Conduction strips them fleet-wide.

The nl.json KEY is the English string, so it is renamed with it; leaving the
key behind would orphan the Dutch translation of a string that still ships.
The Dutch VALUE carried the same em-dash and gets the same treatment — the
gate only counts manifest strings, but it is the same user-facing sentence.

Note for a later sweep: this app's nl.json still holds 16 other em-dash
strings from Vue copy. They are outside this gate's scope (it reads manifest
strings) and outside this PR's.

* fix(l10n): refresh the browser catalogue after the em-dash rename

check:l10n-js reported 'Stale browser catalogue: l10n/nl.js'. Renaming the key
in nl.json without nl.js leaves the two halves of the same catalogue
disagreeing, and the runtime reads the .js one.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
The walkthrough merge broke `vault-unlock.spec.ts`: a locked vault issued
`GET /api/preferences/walkthrough_completed_version`, and that spec asserts —
on the wire, not the DOM — that a locked vault requests NO Keepiq API
endpoint. It is an allowlist that fails closed, precisely so a new request
behind the lock screen is a deliberate act with a reason.

The router guard could not have caught this, and says so: it works by refusing
to resolve a route, so it only covers what mounts inside the `<router-view>`.
CnAppRoot reads `manifest.walkthrough` and fetches the tour's completion
preference when the SHELL mounts, before any route resolves — a shell-level
sibling, which that guard's own note warns needs its own gating.

So the manifest handed to the shell now omits `walkthrough` while locked.
Withheld, not disabled: `isLocked` is read inside the render function, so the
tour returns the moment the vault is unlocked and still runs on a user's first
unlocked visit. Drawing a product tour over a locked vault would be wrong
regardless of the request.

Fails closed like the guard beside it — only an explicit `false` unlocks — so
a store that failed to initialise withholds the tour rather than shipping it.
Both properties are mutation-verified: reading the check as `!store?.isLocked`
fails the fail-closed test, and removing the withholding fails the first.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…t a leak (#488)

The first-visit tour added in #484 made CnAppRoot resolve
`walkthrough_completed_version`, and that fetch runs before any route
resolves — so it fires behind the lock screen and the vault-unlock
invariant failed closed, exactly as designed.

Judged rather than silenced. It is the same shell layer, the same
GenericPreferencesController and the same `pref_`-namespaced session-user
UI flag as the `support-dialog-seen` entry directly above it, and it
carries no vault material. The a11y snapshot from the failing run shows
the bare unlock form with no walkthrough card, so only the probe reaches
the wire; a tour that painted over the lock screen would still fail this
test on its own traffic.

The `\b` anchor keeps the entry from widening onto neighbouring paths.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
… instead (#490)

Two sessions fixed the same failure minutes apart. #486 withheld the
walkthrough from the manifest while the vault is locked, so CnAppRoot never
resolves the completion preference and the probe cannot reach the wire.
#488 then allowlisted that probe — which the file's own note calls pure
masking surface: an entry that cannot fire would silently swallow a real
regression of exactly this shape. #486 is the better fix; the entry goes.

What is NOT fixed by #486 is the order-dependence. The tour's seen state is
per user, not per test, so whichever spec runs first wears it — every spec
here unlocks, and the tour is offered on the first unlocked visit by design.
audit-trail.spec.ts started needing a retry the moment #484 landed, having
never retried in the three preceding development runs. Seeding the marker
in global-setup, as dossiq already does, removes that.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
2.22.1 carries the theme app-id fix (nextcloud-vue#840). CnAppRoot calls
useScopedTheme() with no slug, so this app resolved theme tokens, the
token-set catalogue and the contrast check through a hardcoded 'nldesign'
app id. thematiq is renaming to 'thematiq', and every path in that
composable degrades to default styling by design — so once a renamed build
is installed this app would render unthemed with nothing in any log.

The LOCK is what moves here. A caret range alone changes nothing, because
npm ci installs what package-lock.json pins.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Two independent faults, either of which alone stops the docs site updating.

TRIGGER. This listened on a branch called `documentation`. Nobody has pushed
to one since 2026-05-25, so every docs change merged to `development` passed
review and published nothing.

SECRETS. A reusable workflow receives no secrets by default. With none mapped,
the callee's publish step finds CF_API_TOKEN empty and skips itself on its own
guard, and the run finishes GREEN having changed nothing. Fixing only the
trigger would have produced exactly that.

The worker name is now pinned. Deriving it is the documented way to get a green
run that reaches nobody: wrangler creates the derived worker and publishes
there while the custom domains keep routing to the real one.

Where the app was renamed, `canonical-host` turns the retired hostname from a
second live copy of every page into a 301 to the same path on the current one.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
* feat(nav): a Flows surface in this app, on the shared page types

ADR-110 Decision 4. A flow is app-specific — it operates on this app's objects —
so the authoring surface belongs here rather than behind a deep link to another
app's list. The ENGINE stays single (ADR-065): these pages are a scoped view
onto OpenRegister's one native flow store, not a per-app store.

Two manifest pages and one settings entry, no component files: `type: "flows"`
and `type: "flow-detail"` are shipped page types in @conduction/nextcloud-vue
2.19.0, scoped by `config.app`.

Note the layout of the diff: entries are appended textually rather than by
reserialising the manifest. A `json.dump` round-trip rewrote pipelinq's file as
a 3,950-line diff for a 20-line addition — correct output, unreviewable change.

* build(deps): @conduction/nextcloud-vue 2.19.0 for the flows page types

Required by the manifest change: `type: "flows"` / `type: "flow-detail"` are
rejected by the compiled validator in earlier versions, and CI installs with
`npm ci` — so the LOCK is what decides, not the `^2.x` range. Several of these
locks were pinned many minors back, which is why some lockfile diffs are large:
npm restructures the nested tree (mostly @esbuild platform binaries under
@nextcloud/vue) to satisfy 2.19.0's peers. No direct dependency other than
@conduction/nextcloud-vue changes.

* test(router): classify the flow routes as vault-protected

keepiq denies by default and asserts that every manifest page is explicitly
classified, so adding two pages without classifying them fails — which is the
test doing its job.

Flows and FlowDetail are PROTECTED, not public. A flow in this app can read and
write vault secrets, so the authoring surface must sit behind the lock exactly
as the secret list does: a locked vault that still lets someone edit the
automation over it is not locked.

656 unit tests green.

* fix(icons): register Sitemap, or the Flows entry renders with no icon

An icon name a manifest uses but src/icons.js does not register renders as
NOTHING — not a fallback (ADR-077 rule 3). The Flows menu entry this PR adds
uses `Sitemap`, and this app never registered it, so the entry would have
shipped with an empty icon slot.

Caught by gate-60 icon-vocabulary. I had checked `Sitemap` was registered in
dossiq and carried the assumption to the fleet; each app keeps its own icons.js,
and six of the twelve did not have it. The six failing gate runs were exactly
those six apps.

Both halves are required: the import alone is dead code, the registry entry
alone does not resolve.

* feat(flows): give the flow-detail canvas its sidebar

The manifest _note claimed the controls rendered in the NC app sidebar,
but the sidebarComponent field it described did not exist. Every
#/flows/:id -- and #/flows/new, the same route with the literal id -- drew
a bare canvas: savable and runnable, but with no way to name, describe,
trigger or step-edit the flow, because those controls all live in
CnFlowSidebar. Mirrors pipelinq#1490. ADR-110 Decision 4.
beta held 32 commit(s) development did not. Merged with -s ours:
development's tree is kept BYTE FOR BYTE and only the ancestry is
recorded. That is the payload -- without it the merge base never moves
and the next development -> beta promotion conflicts on the version file
exactly as before. 13 of 19 promotion PRs were CONFLICTING for this
reason.

Nothing is silently imported. What beta holds and development does not,
and which this deliberately does NOT bring over:

  .forgejo/.beta-release-trigger .forgejo/issue_template/bug-report.yml .forgejo/issue_template/feature-request.yml .forgejo/issue_template/technical-task.yml .forgejo/issue_template/user-story.yml .forgejo/workflows/documentation.yml .forgejo/workflows/release-beta.yml .forgejo/workflows/release-stable.yml CODE_OF_CONDUCT.md CONTRIBUTING.md README.md SECURITY.md SUPPORT.md docs/scripts/validate-ai-baseline.mjs

Those are dead Forgejo/Codeberg CI (removed from development on
2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI
configuration'), generated Docusaurus build output, and community-health
files that never existed on development. Each can be added deliberately
if wanted; resurrecting them as a side effect of a sync is how a merge
silently undoes a decision.
…260830084410

chore(sync): carry beta back into development
…0841

chore(sync): record beta's ancestry on development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Dexie refuses to run twice in one page: it throws "Two different versions of
Dexie loaded in the same app". Nextcloud loads openregister's global
integration script and hermiq's agent leaf on every page, alongside whichever
leaf app you are in, so all three have to agree on one dexie.

After the dependabot sweep on 2026-08-30 they did not. openregister resolved
4.4.4 while hermiq resolved 4.4.5, and the throw happened before the leaf app
mounted, so every app page rendered as bare Nextcloud chrome with no content.

This pins the floor at ^4.4.5 and regenerates the lock, matching the apps that
were already there.

Verified in the browser: the Dexie error is gone from the console and app pages
render their navigation and content again.
Release: merge development into beta
Dependabot proposed stylelint 17 on its own, which cannot work: it takes
coordinated bumps, and the one that actually blocks it is easy to miss.

- @nextcloud/stylelint-config -> ^3.2.2, the version that peers stylelint ^17.9.1
- stylelint -> ^17.9.1
- stylelint-config-recommended-scss -> ^17.0.1 and -recommended-vue -> ^1.6.1
  where this app pins them
- stylelint-webpack-plugin -> ^5.1.0 where this app uses it. This is the
  blocker: 5.0.1 peers stylelint only to ^16, and 5.1.0 is the first release
  that accepts ^17.

The findings stylelint 17 then reports are fixed rather than silenced:
`word-break: break-word` is deprecated and `overflow-wrap: break-word` is what
it actually meant, the deprecated `clip` property becomes `clip-path`, and
stylelint --fix's logical-property rewrites (`text-align: left` -> `start`) are
correct for RTL.

Verified: npm ci, npm run stylelint and npm run build all exit 0.
Release: merge development into beta
beta is the release candidate, 887 commits ahead of main. Every conflict
resolved to beta's side, including its refactors: where beta had removed a
file the removal stands rather than resurrecting a stale copy from main.

Conflicts: 2 (0 took beta's content, 2 removed per
beta's refactor).
git merged package.json and package-lock.json independently. Both merged
without conflict, yet the PAIR came out inconsistent, so `npm ci` refused:

  npm error `npm ci` can only install packages when your package.json and
  package-lock.json are in sync.
  npm error Missing: node-fetch@3.3.2 from lock file

On buildiq that failed the 'Install app npm dependencies' step, so the E2E
suite never ran at all and reported as a red E2E check rather than as
absent coverage.

A manifest and its lockfile are one artefact and must be taken from the
same side. beta wins here, as everywhere in this release.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ b11ae9d

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-30 13:25 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 3700357

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 111/111
npm ✅ 536/536
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-30 14:08 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Superseded. A newer beta-to-main promotion for this repo has merged, and beta is now an ancestor of main with no content difference between them. The large file count shown on this pull request is the stale merge base, not content it still carries: diffing a promotion against its target measures the wrong thing.

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