Ga naar hoofdinhoud

Authorisation decisions

Every schema Filinq declares in lib/Settings/filinq_register.json carries an explicit authorisation decision. This file is the record of why, and tests/unit/Settings/SchemaAuthorizationCoverageTest.php fails if a schema exists without an entry here.

The rule this file exists to prevent breaking again: OpenRegister treats an unconfigured cascade as OPEN. _rbac: true means "apply the cascade"; it does not mean "a cascade exists". With authorization: null, applying the cascade permits everything. Absence of an opt-out is not the presence of a guard.

⚠️ The group ids below still say docudesk-, and that is deliberate. The app id was renamed docudesk → filinq; these Nextcloud GROUP ids were not, and must not be renamed as part of a rebrand. OpenRegister provisions a declared group create-only, so renaming docudesk-template-editors to filinq-template-editors does not rename anything — it creates a new, empty group while the existing one keeps every member and nothing reads it any more. An empty group denies everyone except admins and object owners, so the symptom is writes starting to 403 for exactly the people who were granted them, with no error at provisioning time and nothing in the log. Same failure shape as renaming an OpenRegister register slug. Renaming these groups requires its own membership migration, and that migration has not been written. The same reasoning pins the AVG Art. 30 processing-activity codes (docudesk-anonymisation, docudesk-ocr, docudesk-signing, docudesk-metadata-enrichment) in the register and in src/views/settings/Settings.vue: they are already recorded on written read-log rows, and renaming them would detach that history from the catalogue entry describing it.

What was measured​

Development instance, 2026-08-16, before the change:

  • 20 of 21 schemas declared no authorization cascade.
  • All three filinq register rows carried authorization = NULL, so the register-level cascade did not fill the gap either.
  • Two ordinary users in no groups and the same organisation: ddauth-bob read ddauth-alice's private template (HTTP 200, full content), overwrote its content via PUT (HTTP 200), and duplicated it.

That last point matters: this was a write exposure, not only disclosure.

The bound​

ddauth-carol, authenticated and in a different organisation (Gemeente Utrecht), requested the same template and received 404, while ddauth-bob in the same organisation received 200 for the identical request.

Multitenancy is a separate axis and remains enforced. The exposure was to authenticated users within one organisation — not anonymous, not cross-tenant. Both available one-line summaries are wrong in the same way: "it's an IDOR" reads as anonymous and drives panic-shaped work, and "RBAC covers it" is the belief that produced the gap.

The shape of the fix​

update and delete are restricted on every schema. That is where the proven harm was. OpenRegister's owner bypass is unconditional, so a creator keeps full control of their own objects — this closes cross-user writes with no read-path outage. Verified after deploy: ddauth-bob → 403 on PUT, ddauth-alice → 200 on the same object.

read is restricted only where the data justifies it, because an over-tight read cascade is a visible outage, and a read restriction I cannot verify is a guess. Where read stays authenticated, that is a recorded decision below, not an omission.

Reserved principals​

admin and the object owner bypass every rule — they are not listed anywhere. authenticated is any logged-in user; combined with multitenancy that means "anyone in this organisation". public is anonymous and is used by no Filinq schema.

Groups​

Five groups are named. OpenRegister provisions declared groups create-only on import, ahead of the content-hash skip, so they exist on every instance.

GroupOwns
docudesk-template-editorstemplates, versions, house style, and the correspondence/document audit rows they produce
docudesk-policy-adminsWoo Art. 5 grounds, anonymisation recognisers, consent, prohibition overrides, dossiers
docudesk-financial-adminsinvoice/receipt extraction and GL-account mapping
docudesk-signing-adminssigning requests, signers, sessions, signing audit
docudesk-privacy-officersubject erasure requests and the certificates that close them. Introduced by the erasure change (#1128).
docudesk-woo-officersWoo redaction review: confirming a human looked at the detected entities before a redacted copy may be written. Introduced by the redaction change (#1124); recorded here because a misspelt group denies everyone and logs nothing, so it is indistinguishable from a working access control.
docudesk-final-document-adminsunfreezing a final document, and declaring which record-type states freeze one

They ship empty on purpose. An empty group denies everyone except admins and object owners. That is the correct default and it is immediately visible, which is better than back-filling 21 schemas' worth of grants nobody chose. Populate them deliberately.

Per-schema decisions​

Schemareadcreateupdate / deleteWhy
templateauthenticatedauthenticatedtemplate-editorsTemplates exist to be used across the organisation, so read stays open; the measured defect was a foreign user rewriting one.
templateVersionauthenticatedauthenticatedtemplate-editorsSnapshots follow their template. create must be as open as template create, because a version row is written as a side effect of editing.
huisstijlauthenticatedtemplate-editorstemplate-editorsBranding must be readable or nothing renders; changing the organisation's house style is a managed act.
correspondenceauthenticatedauthenticatedtemplate-editorsAudit row written as a side effect of generating, so create follows the generator.
generatedDocumentauthenticatedauthenticatedtemplate-editorsSame.
batchCorrespondenceJobauthenticatedauthenticatedauthenticated / template-editorsJob state is advanced by the worker running as the submitting user, so update must stay open; deletion is not part of running a job.
financialExtractionfinancial-adminsauthenticatedfinancial-adminsCarries supplier IBAN, KvK and BTW numbers. Not organisation-wide readable. create stays open so an ordinary user can scan a receipt — they then own the row and can read it back.
glAccountBookingfinancial-adminsauthenticatedfinancial-adminsLedger-mapping feedback derived from the above.
glAccountMappingRuleauthenticatedfinancial-adminsfinancial-adminsMust be readable at extraction time to be applied at all; maintained by finance.
customDictionaryauthenticatedpolicy-adminspolicy-adminsA recogniser list must be readable when matching runs.
customDictionaryTermauthenticatedpolicy-adminspolicy-adminsSame.
baseauthenticatedpolicy-adminspolicy-adminsWoo Article 5 grounds are public-law reference data, and must be readable to explain why something was redacted.
dossierauthenticatedauthenticatedpolicy-adminsA dossier points at a Nextcloud folder; Nextcloud's own share permissions govern the contents.
anonymizationLinkauthenticatedauthenticatedpolicy-adminsWritten by the anonymiser running as the acting user.
anonymizationBatchpolicy-adminsauthenticatedpolicy-adminsWorking state of one multi-file anonymisation run. read is restricted because the record lists the filenames of a user's private documents, and the batch owner reaches their own batch through the owner bypass. Filinq's own reads and writes go through BatchStateRepository with _rbac: false (recorded below) and are ownership-checked in BatchStateService::getBatch() instead, so this cascade governs only direct OpenRegister API access. Added with the schema in register v7.10.0 rather than omitted — an omitted cascade is OPEN, so shipping a new schema without one re-opens exactly the hole v7.9.0 closed.
documentRegistrationauthenticatedpolicy-adminspolicy-adminsThe post register is a shared record: colleagues across the organisation need to see what came in and what went out, so read stays open to authenticated users. Writing is restricted, because a registration is a numbered claim about an official act and its number is drawn from a shared sequence: a number allocated and then abandoned leaves a gap in a series meant to read end to end, and an unexplained gap reads as a lost document. delete is restricted for the same reason, and withdrawnReason exists so a gap can be explained rather than hidden.
subjectErasureRequestprivacy-officer + policy-adminsprivacy-officer + policy-adminspolicy-adminsA request names a person and what they asked to have erased, so reading it is reading a subject-access matter: restricted to the officers who handle them. delete is narrower than update on purpose — a request may be corrected while it is handled, but removing the fact that somebody asked is a different act.
erasureCertificateprivacy-officer + policy-adminsprivacy-officer + policy-adminsnobody⚠️ update and delete are declared as EMPTY LISTS deliberately, which OpenRegister reads as denied for everyone but admins and object owners (MagicRbacHandler::hasPermission() returns false on an empty rule list). That is the point: the change is titled "a certificate cannot be amended", and a certificate that can be edited afterwards is not evidence of what was erased. Naming a group here to satisfy a coverage check would GRANT the action the schema exists to forbid.
redactionReviewMarkauthenticatedwoo-officers + policy-adminspolicy-adminsThe mark is the evidence a person looked at the detected entities before a redacted copy was written, so create is the act being evidenced and belongs to the officers who perform it. read stays open because the mark is what lets anybody else trust the redacted copy, and a review nobody can see proves nothing.
downloadAgreementauthenticatedauthenticatedpolicy-adminscreate is deliberately open: the agreement records that THIS person accepted these terms, so the person accepting has to be able to write it. Restricting it would mean somebody else recording an acceptance on their behalf, which is the one thing the record exists to rule out. update and delete are restricted, because an acceptance that can be edited afterwards is not evidence of anything.
publicationConsentpolicy-adminsauthenticatedpolicy-adminsGDPR consent records about identified people. create stays open because a data subject records their own consent and then owns it.
publicationProhibitionconsent grouppolicy-adminspolicy-adminsUnchanged — decided before this change.
prohibitionOverrideAuditpolicy-adminsauthenticatedpolicy-admins⚠️ create is deliberately open. Register v7.7.0 records that restricting it re-breaks the fail-closed override path for the ordinary operator who performs the anonymise: the write is explicitly "if the audit write fails we MUST NOT proceed", so a denied create raises 500 on every acknowledged override and no override can ever be committed.
signingRequestsigning-adminsauthenticatedsigning-adminsA signing request names its signers. Safe to restrict: the signer portal resolves records with _rbac: false behind its own token binding (see below), so the signer flow does not depend on this cascade.
signerRecordsigning-adminsauthenticatedsigning-adminsIdentifies an individual signer. Same portal reasoning.
signingAuditEntrysigning-adminsauthenticatedsigning-adminsDeprecated, retained read-only history.
signingSessionsigning-adminsauthenticatedauthenticated / signing-adminsupdate stays open because the session is advanced by the signer themselves as they progress.
documentVersionauthenticatedauthenticatedauthenticated / final-document-adminsThe record says whether a version is final, and everyone who can open the document needs to read that. update stays open because making a document final is an ordinary handler's act, and the service refuses a second write to a version that is already final. Only a final-document admin may delete the record, because deleting it is how a freeze would otherwise be undone without a trace.
documentFinalityRuleauthenticatedfinal-document-adminsfinal-document-adminsThe rule says which record-type states freeze a document, so it is applied on every state change and must be readable. Declaring one is an administrative act: a wrong rule freezes documents nobody meant to freeze.

| intakeDocument | authenticated | authenticated | authenticated / admins | A document waiting for a record is read by every clerk who might assign it, and assigning or rejecting it is an ordinary clerk's act. The service checks write rights on BOTH this schema and the record the clerk aims at, so the cascade here is the floor and not the whole check. Only an admin deletes one, because deleting a waiting document is how an arrival disappears with nobody having decided to reject it. | | intakeDefaultRule | authenticated | admins | admins | The rule stamps metadata on everything that arrives, so it is read on every arrival. Writing one is administrative: a wrong rule mislabels every document from a channel, and the stamped values look like somebody's decision. | | intakeRoutingRule | authenticated | admins | admins | The declaration says who inbound documents on a record type go to. It is read at every assignment; changing it redirects other people's work, which is not an ordinary handler's act. | | intakePartyCorrection | authenticated | authenticated | admins | Accept, edit and reject are recorded by the clerk who decided, so create is open to them. The corpus is what the next suggestion is ranked against, so an entry is not edited afterwards and only an admin may remove one. | | uploadPolicy | authenticated | admins | admins | Every write path reads the policy to decide whether a file may be stored, so it must be readable. Writing one changes what the whole instance accepts, including what it refuses to accept. |

| pageLayout | authenticated | admins | admins | Every generated document renders through a layout, so it is read on every render. A layout is the paper the organisation goes out on; editing one is administrative, and an edit makes a new version rather than changing the one existing documents name. | | periodicDocument | authenticated | admins | admins | The schedule says which template renders over which saved view, on what cadence. It is read by the run and by anyone looking at what the list is made of; changing it changes a document the whole organisation reads. | | archiveJob | authenticated | authenticated | admins | A bundle is asked for by an ordinary handler, so create is theirs, and the job records who asked because the archive holds what THAT person may read. The job is the evidence of what was handed over, so only an admin may remove one. |

| mergeJob | authenticated | authenticated | admins | A merge is an ordinary handler's act, so create and update are theirs; the job records who asked because every input is read as that person and the result holds only what they could read. Only an admin may delete one: the job is the trace of what was bundled and handed over. |

| publicationLogEntry | authenticated | admins | admins | The pipeline writes an entry as the handler who took the step, so create is theirs. An entry is written once: the schema is immutable and no route changes or removes one, so update and delete stay with admins for repair only. The publication API shows a log only to someone who can open the document. |

| publicationRecord | authenticated | authenticated | admins | A handler starts and moves a publication for a document they can open, so create and update are theirs; the publication API refuses anyone who cannot open the document (404, the same as not found). Only an admin deletes one: the record is the trace of what was handed to the publication platform. |

| ocrResult | authenticated | authenticated | admins | A handler runs OCR on a file they can open, and the anonymisation pipeline runs it for them, so create and update are theirs. The OCR routes only answer for a file in the caller's own folder (404 otherwise), and the row holds no text: confidence, settings and length only. Only an admin deletes one. | | conformanceReport | authenticated | authenticated | admins | A handler checks a PDF they can open, and document validation and PDF/A-3 conversion check it for them, so create and update are theirs. The conformance routes only answer for a file in the caller's own folder (404 otherwise), and the row holds no text from the document: the PDF/A level, the verdict, rule references (standard, clause, test) and font names. Only an admin deletes one. |

| bulkSigningBatch | owner and admins | authenticated | owner and admins | A bulk send's report names people who were not sent a request and why, so it is the initiator's and an admin's only: read, update and delete are empty lists, which leaves OpenRegister's owner and admin bypasses and nobody else. Any signed-in user may start one (create), exactly as any signed-in user may create a single signing request. The sending job runs as the initiator, so it updates the batch as its owner. BulkSigningService repeats the initiator-or-admin check on every endpoint and answers anybody else with 404. | | signingEnvelope | owner and admins | authenticated | owner and admins | An envelope names its signers and its documents, so through OpenRegister only its sender (the owner) and admins reach it: read, update and delete are empty lists. Any signed-in user may send one, as with a single signing request. A signer the envelope names reads it, and the rolled-up status is written back, only through SigningEnvelopeService, which checks sender, named signer or admin first and answers anybody else with 404. | | legalHoldCase | admins | admins | admins | A legal matter and the records it freezes. Whether a lawsuit exists is not everybody's business, so the REST API offers cases to admins only; Filinq serves the hold register itself after LegalHoldAuthority (admins plus legal_hold_authority_groups, fail-closed; see the bypass table). | | entitySearchLog | admins | admins | nobody (update, delete) | The processing log of the entity search (AVG article 30). Only admins read it through the REST API. Nobody may change or delete a row. Filinq writes it itself, past the cascade, after EntitySearchGate (see the bypass table). It holds the sha256 of each query, never the query. | | pseudonymMap | admins | admins | admins | The key that turns a reversibly anonymised copy back into names. Nobody but an admin reaches it through the OpenRegister API, and an admin gets the metadata only: mappings is writeOnly and encrypted with the server secret. Filinq reads and writes it itself, past the cascade, after its own checks (see the bypass table). | | documentContract | filinq-contract-managers | filinq-contract-managers | filinq-contract-managers, delete admins | A contract carries its value and the people and companies it binds, so the whole schema belongs to the contract managers group, not to every signed-in user. The contract routes (renew, terminate, suggestions) read the contract as the caller first and answer 404 for one they cannot read. Only an admin deletes one: a contract is a record, ended by terminating or renewing it. | | sanitizationRecord | authenticated | authenticated | nobody updates, delete admins | Written by the sanitize action and the anonymiser as the acting user. It holds counts per category and file ids, never removed content. Nobody may change a record: it is evidence that a file was cleaned, and the publication hand-off reads it. | | emailDocument | admins | nobody (the system writes) | nobody (the system writes), delete admins | One row per email filed from a watched inbox into a dossier. It names sender, recipients and subject, so the REST API offers it to admins only: every list is empty, which leaves OpenRegister's admin bypass and nothing else. The ingestion job runs as the system and writes past the cascade; the status page and its routes are served by EmailIngestionController to admins and delegated admins of the filinq settings only (see the bypass table). The email itself is a file in the dossier folder and follows that folder's permissions. | | wizardDefinition | signed-in users | template editors (docudesk-template-editors) | template editors | The interview in front of a template. Clerks read it to run the wizard; the group that edits templates writes it, like the template schema. WizardService also refuses a save while another user holds the template's lock (423) and a second active wizard for the same template (409). It holds questions, never answers: the answers of a run are stored on the generatedDocument record. | | classificationResult | admins | nobody (the system writes) | nobody (the service writes), delete admins | One suggestion per inbound file: a document type, at most one sender name and a dossier reference. It names who a document is from, so the REST API offers it to admins only: every list is empty, which leaves OpenRegister's admin bypass and nothing else. InboundClassificationService writes it from the enrichment path and ClassificationDecisionService records a person's decision; both go through ClassificationResultRepository (see the bypass table). A reviewer reaches a record only through api/classification, and only for a file they can open in their own Nextcloud folder. |

| printJob | authenticated | authenticated | admins | A handler sends their own letters to print, and a print service reports back with that handler's account, so create and update are theirs. The endpoints only show a job to the person in requestedBy or an admin, and the list is always the caller's own. Only an admin deletes one: the job is the trace of what went to the printer. |

| scanBatch | authenticated | authenticated | admins | The batch is created by the watched-folder job on behalf of the instance and read by the clerk who sorts out what came off the scanner, so both are open. Only an admin deletes one: the batch is the trace that says which documents a delivered PDF was cut into, and a missing segment is only findable through it. |

Deliberate RBAC bypasses​

A cascade only guards callers that go through it. ObjectService::find() and findAll() accept _rbac: false, and Filinq passes it at 35 call sites in 14 files. Each is paired with a compensating control rather than being an oversight, and the coverage test pins the set: a new bypass fails the test until it is added here with a reason.

FileSitesCompensating control
Service/PolicyCrudService.php5requirePolicyPermission() asserts membership in docudesk-policy-admins / docudesk-standing-consent-admins for every action including read, before the lookup runs.
Service/CustomDictionaryRepository.php4Recogniser lists are read during anonymisation, which runs on behalf of a user who may not hold the maintainer group. Read-only; no caller-supplied id reaches it.
Service/DossierObjectRepository.php3Dossier contents are governed by Nextcloud folder permissions, which are checked before the repository is reached.
Service/EmailIngestion/EmailDocumentRepository.php3The email ingestion job files mail as the system from a cron tick, with no user to check, and the idempotency reads must see every record of a dossier. The only other callers are the api/email-ingestion routes, which EmailIngestionController guards with #[AuthorizedAdminSetting] before the repository is reached. No caller-supplied filter reaches OpenRegister beyond status and dossier.
Service/BatchStateRepository.php3Batch state is Filinq's own working state, written and read only by BatchStateService — no OpenRegister API caller reaches this class. Ownership is enforced one layer up in BatchStateService::getBatch(), which returns null (not a distinct error) for a batch belonging to another non-admin user, so a guessed batch id is indistinguishable from an absent one. The bypass is required, not convenient: the cascade restricts update/delete to docudesk-policy-admins, which ships empty, so an ordinary user's own extraction run would fail closed at its first status write.
Service/Classification/ClassificationResultRepository.php2The suggestion is written from the enrichment path, which runs as the system inside another app's save, and the one-active-record-per-file read must see every record of a file. The schema grants admins only; the people who decide reach a record through ClassificationDecisionService alone, which first resolves the file in the caller's own folder and answers 404 for a file they cannot open, the same answer as for a file without a suggestion.
Service/Classification/ClassificationSources.php1Reads the dossier names to propose a dossier on an exact name match. Only the matched dossier's uuid is stored on the suggestion, never its content, and confirming one moves the file only into a dossier folder the reviewer can open themselves; otherwise the filing is recorded, not done.
Service/PolicyMatchService.php2Matching must see every prohibition regardless of the acting user, or an anonymisation silently under-redacts. Fail-closed by design.
Service/ConsentPolicyReferentValidator.php2Validation-only; returns a boolean, never record content.
Service/PolicyRetroactiveService.php2Background re-application over records the triggering user may not own.
Service/BaseLabelResolver.php1Resolves a legal-basis label for display; base is organisation-readable anyway.
Service/BasesResolverService.php1Same.
Service/LegalBasisCatalog.php1Static Woo Art. 5 catalogue.
Service/LegalHold/LegalHoldCaseRepository.php2The schema grants admins only. The app reads and writes cases only from LegalHoldCaseService, and every public method there starts with LegalHoldAuthority::assertAuthority() (admins plus legal_hold_authority_groups; a setting that does not parse refuses everyone). The status route reads cases only to name the matter to someone who passed the same check.
Service/EntitySearch/EntitySearchLog.php1Creates one entitySearchLog row per entity search or detail view, after EntitySearchGate::assertMaySearch() (admins plus entity_search.allowed_groups, fail closed). The searcher need not be allowed to read the log they add to, and a lookup whose row does not land is refused. Create only: no update or delete call exists.
Service/LegalHold/LegalHoldRecordFreeze.php1Loads a record in a case's scope to place or lift OpenRegister's legal hold on it, after the authority check; the case owner is not the record owner, so the read cannot be the caller's. The one read for somebody without authority (heldAsSeenBy) keeps RBAC on.
Service/Pseudonymisation/PseudonymMapRepository.php5The schema grants admins only, so the REST API offers the key to nobody else. The app writes it during an anonymise the operator is already allowed to run, deletes it with its link, and reads the ciphertext only inside PseudonymRestoreService, after PseudonymRestoreGate (admins plus pseudonymisation_restore_allowed_groups, fail-closed) and a check that the caller can open the anonymised copy, and after the audit trail has taken the grant. findForSource() (the fifth) serves only PseudonymMapService::forgetValues(), which a subject erasure calls after SubjectErasureAuthority; it returns rows without mappings and the erasure only removes entries, never reads a value out.
Service/SigningEnvelope/SigningEnvelopeRepository.php3The schema grants the sender and admins only. The repository reads an envelope (find, list) and stores it, including the roll-up a signer's visit writes back, for SigningEnvelopeService alone, which lets the sender, a signer the envelope names or an admin read, only a named signer sign through it and only the sender or an admin cancel; anybody else gets 404. The member requests are read and signed through SigningService with their own checks.
Service/Redaction/AnonymizationLinkReader.php1storedObject() reads one anonymisation link only for its OpenRegister ids (id, uuid, register, schema), which PseudonymRestoreAudit writes the restore entry on. The object never reaches a caller or a response, so no link data is disclosed; reading past RBAC is what lets a denied caller's attempt land on the same object as a granted one.
Service/SubjectErasure/SubjectErasureStore.php3The subjectErasureRequest and erasureCertificate schemas grant the erasure groups only. Every call follows SubjectErasureAuthority::assertMayErase() (admins plus subject_erasure_groups, default the two groups the schemas grant, fail closed on an unreadable setting), and each refusal is audited. storedRequest() reads the request only for the ids an audit entry sits on.
Controller/PortalSigningReceiverController.php1The signer portal is anonymous by design and binds a signerRecord to a token and an email and a signing-request id, refusing with the same null for a wrong email, a wrong request, and an unresolvable register — so no new signal is exposed.

Service/ConsentCrudService.php contains the string _rbac: false in a comment that forbids the bypass, and is not a bypass. It ends:

"Do not delete either as 'redundant with OpenRegister RBAC' — measured, it is not redundant."

That comment exists because someone had already reasoned their way to deleting a real control. It is why no guard in this change was added merely to make a gate green: an unjustified guard is that control with the reasoning stripped out, and it gets deleted the same way.

Deploying a change to this file​

A cascade edit reaches a running instance only if it is imported. info.version in the register must be bumped; the importer additionally compares schema content (properties, required, authorization) so an authorisation-only edit is not silently skipped, but the version bump is what moves the app-level configuration gate. Verify against the live instance — a cascade present in the file and absent from oc_openregister_schemas is a fix-shaped commit, not a fix.

product — the rate card​

product carries rate-card data: what a service is called and what it costs. Its authorization reads:

{ "read": ["authenticated"],
"create": ["docudesk-template-editors"],
"update": ["docudesk-template-editors"],
"delete": ["docudesk-template-editors"] }

Read is deliberately open to authenticated users. A rate card is quoted back to clients; a price nobody inside the organisation may read is a price nobody can quote, and the data is not personal. Multitenancy still applies on the row, so "authenticated" means authenticated within this organisation, not everyone on the instance.

Writes are narrowed to docudesk-template-editors, the same group that owns templates, because a rate card is priced content of exactly that kind: changing it changes what every future quotation says.

⚠️ It is deliberately not searchable. Only template and signingRequest carry manifest deep links, and a searchable schema without one produces Unified Search hits that lead nowhere — a result the user can see and cannot open. The schema shipped searchable: true without a deep link and three tests caught it; this records the decision so the next person does not restore the flag on the assumption it was an oversight in the other direction.