---
title: "ADR-169: Record management belongs to TYPO3's permission model"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169@0.35"
source: "Adr/Adr169RecordManagementUsesTypo3Permissions.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-169: Record management belongs to TYPO3's permission model

-   *Status:*

    Accepted (section 6's recommendation is the one exception — it is
    recorded as open, see [Disposition](https://docs.typo3.org/permalink/netresearch/nr-llm:disposition@0.35))

-   *Date:* 2026-08-13
-   *Accepted:* 2026-08-18
-   *Amends:*

    [ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35) (named constraint 4 reserved `tasks_manage`;
    it is retired rather than fulfilled) and [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35) (whose
    "`tasks_manage` still does not exist" bullet said the same)

-   *Authors:* Netresearch DTT GmbH

## Disposition

Accepted on 2026-08-18, section by section, on the personas
[ADR-171](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-171@0.35) derived. Those personas were still unvalidated at that
moment — NEXT-152 asks Stefan and Sandra to check them and had no answer. That
is stated here rather than hidden, because it names where a later answer would
have to land: persona 7, the management-grant holder, is the one this record
retires, and if it turns out to be real, section 5 is what an answer would
reopen.

Sections 1, 2, 3, 4, 5 and 7 are accepted as written.

**Section 6 is not.** It recommends reopening [ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119@0.35) and says
so as a recommendation "not done here". Reopening it creates a new top-level
backend section shared with nr_ai_search, nr_repurpose and the cowriter, so it
is not a side effect of a grant decision in this repository. It is issue `#812`,
with ADR-119 as its addressee. Nothing is misrepresented in the meantime:
ADR-119's own status already says the placement is unsettled, and its trigger —
the first cross-consumer editor surface — has not fired by [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)'s
own account.

## Context

Issue `#691` declines to add a `MANAGE` grant case until a management surface
exists, under [ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35)'s rule that a case arrives together with
its enforcement point (its Decision, "Grants, not roles"). That rule exists
because [ADR-023](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-023@0.35) shipped backend-group checkboxes that gated
nothing and [ADR-117](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-117@0.35) had to remove them: "a control that has to
be labelled 'has no effect' is worse than its absence"
([ADR-117](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-117-decision-alternatives@0.35)).

Issue `#768` asks seven questions that have to be answered together before that
surface is built. This record recommends answers and settles none of them.

**Version scoping.** Every claim about core reach is read off the TYPO3 version
resolved in this worktree — 14.3.5 (`Typo3Version`). The blocking
matrix is `^13.4 || ^14.3` (`composer.json`). 13.4 reaches the same
capability through `BackendUtility::isRootLevelRestrictionIgnored()`,
deprecated in 14.0 in favour of the Schema API; its call sites were not
enumerated here.

## Decision

### 1\. Where management-owned records live

`#768` asks how `rootLevel => -1` is solved technically. The prior question is
where the records should live.

**\`\`rootLevel => -1\`\` is not root-only.** `RootLevelCapability`
defines the three values: `TYPE_ONLY_ON_PAGES = 0`,
`TYPE_ONLY_ON_ROOTLEVEL = 1`, `TYPE_BOTH = -1 // does not matter`. All nine
`tx_nrllm_*` tables use `-1`, and none sets
`security.ignoreRootLevelRestriction` (zero hits repo-wide).

`DataHandler::isTableAllowedForThisPage()` splits on the pid, not on the
flag:

-   the rootLevel early return is skipped entirely for `TYPE_BOTH`;
-   at pid 0 the test is `isAdmin() || shallIgnoreRootLevelRestriction()`;
-   at a non-zero pid the only remaining test is
    `isRecordTypeAllowedForDoktype()`.

The permission check runs alongside it and splits the same way.
`hasPermissionToInsert()` passes `VirtualRecord::RootPage` only when
the pid is 0, and `DataHandler::process_datamap()` resolves the real page
record whenever `pid > 0`. `hasPageContextPermission()` then answers a
root page from the flag, but a real page from the web mount and the `perms_*`
bitmask.

| Where the record lives | Non-admin holding `tables_modify` |
| --- | --- |
| pid 0 | Refused. Needs `security.ignoreRootLevelRestriction`. |
| On a page in a web mount, with content-edit permission | Permitted today. No TCA change. |

**This is not theoretical for this extension.** The setup wizard renders a
"Storage Folder (Page ID)" input
(`Resources/Private/Templates/Backend/SetupWizard/Index.html#wizard.field.pid`),
whose value reaches `SetupWizardController::saveAction()` and is written
onto the provider by `persistWizardResult()`, onto every model by
`createModels()` and onto every configuration by
`createConfigurations()`, with no page check. All eight Extbase
repositories set `setRespectStoragePage(false)`, and the ninth table's
plain repository issues no pid predicate at all —
`McpServerRepository::findAll()`, `findEnabled()` and
`findByUid()`. A page-stored record is found by the runtime exactly like a
pid-0 one.

**Option R — open \`\`security.ignoreRootLevelRestriction\`\` and keep the records
at pid 0.** It is a table capability, so it answers for the whole installation
at once. Every core pid-0 branch opens with it: the FormEngine edit and create
forms, which set `Permission::ALL` on the root node
(`DatabaseUserPermissionCheck::addData()`), clipboard copy/cut/paste
(`Clipboard::isRecordAccessAllowed()`), the suggest wizard on any relation
field in any extension
(`SuggestWizardController::currentBackendUserMayAccessTable()`), record
information (`ElementInformationController::canAccessPage()`), history and
rollback (`ElementHistoryController::getEditLockFromElement()`,
`RecordHistory::hasPageAccess()`) and the list module's hide/show toggle
(`RecordListController::toggleRecordVisibilityAction()`).

**Option P — store management-owned records on a page inside a web mount.** It
needs no core-permission change and gives page-level granularity a global flag
cannot express: which group reaches which records is decided by web mounts and
`perms_*`, per page. It splits by table *and* by page, where the flag splits
only by table.

**Recommendation: Option P.** Two costs, both real:

-   Existing installations hold these records at pid 0. Moving them is a
    deliberate migration, not a side effect of shipping a module.
-   Page storage is a permission boundary, not a scoping boundary. Nothing filters
    by pid at runtime, so a configuration created on any page is live for the
    whole installation. A "storage folder" is a convention.

**Saying no** means Option R, and then the reach list above is the accepted
blast radius.

**What the corrected premise already opens.** An installation that used the
storage-folder field and granted a non-admin group `tables_modify` on a
`tx_nrllm_*` table has an editing surface nobody decided to open. `api_key`
and `auth_credential` are still gated there, because
`VaultFieldHelper::getSecureFieldConfig()` sets `exclude => true` and
`DataHandler::fillInFieldArray()` reads it. Nothing else is: the
extension's own TCA declares zero `exclude` keys, so
`endpoint_url`, `allowed_groups`, the `cost_*` fields and the
`max_*_per_day` caps travel with `tables_modify`. That is not acceptable,
and choosing a UI does not fix it — the remedy is the field boundary in
[section 4](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169-q4@0.35) and the upgrade note in
[Consequences](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169-consequences@0.35).

### 2\. Which records a non-admin may manage

**Recommendation: \`\`tx_nrllm_task\`\`, \`\`tx_nrllm_promptsnippet\`\` and
\`\`tx_nrllm_configuration\`\`, the third with its governance and spend fields
excluded.** Those three hold prompt text and task shape. The six exclusions rest
on different reasons, which matters when one of them is argued back in:

-   Provider, MCP server and skill source hold credentials and name the host the
    extension talks to. `trust_zone` on the provider is the ceiling forced
    context sources bind against ([ADR-164](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-164@0.35);
    `TrustZoneResolver::zoneForProvider()`).
-   `tx_nrllm_skill` is written by the sync and its `trust_level` is
    denormalised from the source, where the authoritative edit lives
    (`tx_nrllm_skill_source.php#trust_level`).
-   `tx_nrllm_model` holds no credential. It is out because
    `cost_input` / `cost_output` are what `Model::estimateCost()`
    multiplies when the provider reports no cost, in
    `UsageMiddleware::track()`. Halving them doubles everyone's effective
    spend without touching a budget record.
-   `tx_nrllm_user_budget` names a `be_user` and sets that user's ceiling.

Within `tx_nrllm_configuration`, `allowed_groups` is the one gate that
already works for non-admins (`LlmConfigurationService::hasAccess()`, per
[ADR-070](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-070@0.35)). Whoever can edit it can grant their own group access
to every configuration.

**Saying no** to the three-table subset means naming which of the six returns
and what replaces the reason above.

### 3\. What the existing write paths do not run

Both wizards write through Extbase repositories and `persistAll()`:
`TaskWizardController::wizardCreateAction()` and
`SetupWizardController::persistWizardResult()`. Nothing in `Classes/`
constructs a `DataHandler` for a
`tx_nrllm_*` table. Five tools construct one, and they write `tt_content`,
`pages` and `sys_file_metadata`: `CreateContentElementDraftTool`,
`MoveContentElementTool`, `CreateTranslationDraftTool`,
`SetFileAlternativeTextTool` and `UpdatePageMetadataTool`.

**Does not run on the repository path:** record permissions
(`DataHandler::hasPermissionToUpdate()`,
`DataHandler::hasPageContextPermission()`), the insert check
(`DataHandler::hasPermissionToInsert()`), exclude fields
(`DataHandler::fillInFieldArray()`), TCA `eval` / `required` /
`range` / `items`, `sys_history`, the reference index, and the
extension's own hook `ProviderEndpointNormalizationHook`.

**Does run:** whatever the controller wrote by hand. The same
`wizardCreateAction()` clamps temperature, `max_tokens`, `top_p` and
both penalties and allow-lists `category` and `output_format`; the same
`persistWizardResult()` truncates the label. Those are hand-rolled
equivalents of TCA `range`, `eval` and `items`, kept in step with the TCA
by nothing.

### 4\. FormEngine or a purpose-built UI, and the field boundary

**Option A — link into FormEngine.** What it buys is already written: 27
`required` declarations, 7 `eval` rules, 30 `select` fields with their
item sets, 3 MM relations, 3 `displayCond`, three `itemsProcFunc` providers
(`grep -c itemsProcFunc Configuration/TCA/tx_nrllm_configuration.php`), the
`modelIdWithFetch` render type (`tx_nrllm_model.php#modelIdWithFetch`), the
`modelConstraintsWizard` field wizard
(`tx_nrllm_configuration.php#modelConstraintsWizard`), `sys_history` with
rollback, the reference index, and the exclude boundary at
`DataHandler::fillInFieldArray()`.

**Option B — a purpose-built write path, TCA untouched.** One door, so an
nr_llm grant would be a real control. It costs every validation above,
re-expressed in PHP and kept in step by hand; it costs `sys_history`, so no
audit of who changed a system prompt and no rollback; and the exclude boundary
becomes an explicit field allow-list, because a generic field mapper writes
`github_token` precisely because FormEngine cannot see it
(`type => 'passthrough'`, `tx_nrllm_skill_source.php#github_token`).

**Option C — Option A plus \`\`exclude => true\`\` on the fields that must not
travel with \`\`tables_modify\`\`:** `tx_nrllm_configuration`'s
`system_prompt_data_class`, `max_requests_per_day`, `max_tokens_per_day`,
`max_cost_per_day`, `allowed_groups`, `allowed_tool_groups` and
`allowed_guardrails`, and `tx_nrllm_promptsnippet`'s `data_class`.

**Recommendation: Option C**, and it does not depend on section 1's outcome.
`TcaItemsProcessorFunctions::getGroupedExcludeFields()` skips a table only
when it is `TYPE_ONLY_ON_ROOTLEVEL` without the flag, so these `TYPE_BOTH`
tables already
appear in the be_groups picker. The flags are assignable today, and under
Option P they are the field boundary from the first day the surface exists.

Option C has a precondition. While `SetupWizardController` and
`TaskWizardController` write through `persistAll()`, the exclude flags
run on one path and not the other, and a reader of the TCA would assume
otherwise. Those two move onto the DataHandler or stay admin-only.

**Saying no** and choosing B means accepting the second list as the build scope
and naming who keeps the PHP validations in step with the TCA. Choosing plain A
means stating that `tables_modify` on `tx_nrllm_configuration` is an
acceptable grant of `allowed_groups`.

### 5\. The grant's name

`tasks_manage` was reserved in three places: [ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35)'s named
constraint 4, [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)'s "what stays out" bullet, and the class
docblock of `BackendUserGrant`. `#691` asks for something wider —
providers, models, configurations and tasks. All three now record the
reservation as retired; see [Disposition](https://docs.typo3.org/permalink/netresearch/nr-llm:disposition@0.35).

**Recommendation: neither name. Close \`#691\` as answered.** Section 2 removes
providers and models from the manageable set, and what remains is governed by
`tables_modify` and `non_exclude_fields`, not by an nr_llm grant. The wide
grant has no records to gate; the narrow one has none left that need it. The
reservation in ADR-130 and ADR-131 is retired rather than fulfilled.

**Saying no** — keeping a grant — requires naming the action it gates that
[section 7](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169-q7@0.35) does not already place elsewhere.

### 6\. Module placement

[ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119@0.35) decided "keep the modules under Administration for
now" ([ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119-decision@0.35)) and already carries the status
`Accepted (deferred — the placement is not finally settled, see Revisit)`. Its
trigger is "the first cross-consumer editor surface … a personal
usage-and-budget view, a personal run history, or a lead-editor view over a
group" ([ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119-decision@0.35)), and it pre-settles four answers for
that case ([ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119-revisit@0.35)): the section is called "AI"; the
identifier is
`netresearch_ai`, never a bare `ai`; the flat entries are grouped by subject
first; old routes keep working through kept identifiers plus
`'aliases' => ['nrllm']`.

**Has the trigger fired?** Not by ADR-131's own account. `nrllm_aitasks` ships
an actor-scoped run viewport, but the record states that "'Own runs' for editors
is mostly the approver's view", that agent runs "are currently started from
admin surfaces", and that the ownership filter "matters the moment any non-admin
path starts runs". A personal run history is
what that surface will become, not what it is.

What has changed is the count and the constraint. ADR-119 describes twelve
submodules; `nrllm` now has fourteen children, with `nrllm_aitasks`
registered outside it under `parent => 'web'`
(`Configuration/Backend/Modules.php#nrllm_aitasks`; \``grep -c "'parent'"
Configuration/Backend/Modules.php`` returns 16). It sits there for a verified
platform reason: the module menu drops every top-level module whose own access
check fails, so a child of the admin-only ``nrllm`` would be invisible to
non-admins
(:ref:`ADR-131 \<adr-131>\`, Context). A management surface has that same
constraint.

**Recommendation: reopen ADR-119 — recommended here, not done here.** Without
it, the management surface becomes the second flat `web`-parented module and
ADR-119's "twelve flat entries do not move as they are" arrives by accretion
instead of by decision. The `:Status:` and `:Amended:` edits belong to the
change that accepts the recommendation, per the lifecycle rule in
`Documentation/Adr/Index.rst`.

**Saying no** is defensible on cost. ADR-119's status already says it is not
settled, so nothing is misrepresented by leaving it.

### 7\. Enforcement points

`grep -rn 'denyNonAdmin()) instanceof' Classes/Controller/` returns 40 sites.
All 40, by what the action touches:

-   **Credential- or egress-bearing** (18): `SetupWizardController` —
    `detectAction()`, `testAction()`, `discoverAction()`,
    `generateAction()`, `saveAction()`;
    `ProviderController::testConnectionAction()`;
    `ConfigurationController::testConfigurationAction()`;
    `ModelController::verifyCapabilitiesAction()`;
    `ModelDiscoveryController` — `fetchAvailableModelsAction()`,
    `detectLimitsAction()`; `ModelTestController::testModelAction()`;
    `SpecializedTestController` — `translateAction()`,
    `generateImageAction()`; `SkillSourceController` — `syncAction()`,
    `setTokenAction()`; `McpServerController::namedServer()`;
    `LlmModuleController` — `reachabilityAction()`,
    `executeTestAction()`.
-   **Arbitrary table reads** (3), reserved admin-only by ADR-130 constraint 5:
    `TaskRecordsController` — `listTablesAction()`,
    `fetchRecordsAction()`, `loadRecordDataAction()`.
-   **Agent execution on the admin playground** (3, [ADR-038](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-038@0.35)):
    `ToolPlaygroundController` — `runAction()`, `resumeAction()`,
    `submitInputAction()`.
-   **Site-wide tool kill switch** (2, [ADR-039](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-039@0.35)):
    `ToolController` — `toggleToolAction()`, `toggleToolGroupAction()`.
-   **Record-state toggles duplicating a TCA field** (6):
    `ProviderController::toggleActiveAction()`; `ModelController` —
    `toggleActiveAction()`, `setDefaultAction()`;
    `ConfigurationController` — `toggleActiveAction()`,
    `setDefaultAction()`; `SkillSourceController::toggleSkillAction()`.
-   **Read-only lookups behind the admin gate** (6):
    `ModelController::getByProviderAction()`; `ConfigurationController`
    — `getModelsAction()`, `getModelConstraintsAction()`;
    `SpecializedTestController::translatorsAction()`;
    `PresetController` — `listPresetsAction()`, `diffAction()`.
-   **Bulk creation of AI-behaviour records, no credential** (2):
    `PresetController` — `importAction()`, `updateAction()`.

Two module-route actions write without a per-action gate, module-gated only, as
ADR-130 constraint 4 states: `TaskWizardController::wizardCreateAction()`
and `UseCasePackController::installAction()`, the latter writing through
`UseCasePackInstaller::install()`.

**Recommendation: no grant case is added; the last bucket moves onto the
DataHandler.** Once preset import and use-case-pack install write as the acting
user, `tables_modify` on the three manageable tables authorises them — the
same control as every other write.

**The tool kill switch does not fit that argument.** `tx_nrllm_tool_state` has
no TCA
(`ToolStateRepository.php#tx_nrllm_tool_state`; `Configuration/TCA/`
holds nine files, none of them this table), so `tables_modify` cannot express
it even in principle. It stays admin-only because [ADR-039](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-039@0.35)
decided it is a hard admin kill switch — an earlier decision, not a consequence
of this one. So the claim is not that TYPO3's permission model covers all 40:
each of the 40 is credential-bearing, reserved admin-only by an earlier ADR, or
duplicated by a TYPO3 permission on the same field. Retiring `tasks_manage`
survives on the full set.

## Which fields stay admin-only

Four extension-owned fields. Three are protected by where the code is, not by
the TCA.

`tx_nrllm_provider.api_key` and `tx_nrllm_mcp_server.auth_credential` carry
`exclude => true` from `VaultFieldHelper::getSecureFieldConfig()`, read
by `DataHandler::fillInFieldArray()`. That holds on the DataHandler path
only: `SetupWizardController::persistWizardResult()` and
`SetProviderApiKeyCommand::execute()` write `api_key` directly, while
`auth_credential` has no setter in `Classes/` at all, only a read
(`McpServerRepository::hydrate()`) — safe by the absence of a writer, which
is a fact about the current code, not a boundary.
`tx_nrllm_provider.endpoint_url` has no flag on either path
(`tx_nrllm_provider.php#endpoint_url`). `tx_nrllm_skill_source.github_token`
is `type => 'passthrough'` (`tx_nrllm_skill_source.php#github_token`) and
written only by `SkillSourceController::setTokenAction()` behind
`denyNonAdmin()`; a generic field
mapper would write it precisely because FormEngine cannot see it.

Two more are outside nr_llm's authority. Vault configuration lives in nr-vault's
modules, which are `access => 'user'` with per-action `VaultPermission`
assertions and a comment forbidding re-tightening to `admin`
(`nr-vault/Configuration/Backend/Modules.php`). Extension configuration is
edited in the Settings module, `'access' => 'systemMaintainer'`
(`cms-install/Configuration/Backend/Modules.php`) — stricter than admin.

## Routing policy: closed, not deferred

[ADR-140](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-140@0.35) decided a read-only view — decision item 4 is "No apply
path. See below — this is the actual decision"
([ADR-140](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-140@0.35)); [ADR-145](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-145@0.35) amends it as "the readout
gains consumers, not an apply path"; the code repeats it in the class docblocks
of `EffectivePolicyReadout`, `GovernanceProfileEvaluator` and
`GovernanceProfileDeviation`, and in
`LlmModuleController::executeTestAction()`. The policy mode itself is a
per-call argument (`RoutingDecisionService::decide()`), defaulting from
extension configuration in `RoutingDecisionService::policyMode()`, which
reads `ext_conf_template.txt#routing.policyMode` in the `systemMaintainer`
Settings module. A grant over it would sit in front
of a read-only view and a function argument. Dropped from scope.

## Consequences

If the recommendations are accepted as they stand:

-   [ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119@0.35) is **not** reopened by the accepting change. This
    bullet said it would be, and the acceptance deliberately did not — see
    [Disposition](https://docs.typo3.org/permalink/netresearch/nr-llm:disposition@0.35). ADR-119 keeps its `Accepted (deferred)` status and its four
    pre-settled answers stand unapplied.
-   [ADR-130](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35) named constraint 4, [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35)'s
    `tasks_manage` bullet and the class docblock of `BackendUserGrant` are
    amended together: the grant is retired, not pending. Issue `#691` closes as
    answered.
-   **Upgrade note.** A group holding `tables_modify` on a `tx_nrllm_*` table
    can already edit any record of that table sitting on a page it can edit —
    today, with no flag and no module. Such an entry is inert only where every
    record is still at pid 0. Option C's exclude flags narrow what it conveys, so
    the note names the fields that become gated and states that the entry itself
    is not new.
-   Under Option P, moving existing records off pid 0 is a separate, deliberate
    migration. Under Option R, section 1's reach list is the blast radius.

## Revisit when

-   A non-admin path starts writing `tx_nrllm_*` records outside FormEngine:
    Option C's precondition becomes a hole.
-   A field is added to one of the three manageable tables. The split is per
    field; a new governance or spend field needs `exclude => true` in the same
    change.
-   `tx_nrllm_model` is argued back in. It is out for the cost multipliers, not
    for credentials; if the usage path stops reading them, the reason expires.
-   Anything starts filtering `tx_nrllm_*` reads by pid — that turns page
    storage into a scoping boundary and changes section 1's second cost.
-   TYPO3 changes what `ignoreRootLevelRestriction` grants. Section 1 was read
    off 14.3.5 and this bullet used to say the 13.4 leg was not enumerated. It has
    since been read as well, and 13.4 answers identically: the same three
    `RootLevelCapability` constants with `TYPE_BOTH = -1`, the same skipped
    early return for `TYPE_BOTH` in
    `isTableAllowedForThisPage()`, the same
    `admin || shallIgnoreRootLevelRestriction()` at pid 0 and doktype-only
    above it, and the same split in `hasPageContextPermission()` between
    `VirtualRecord::RootPage` and the web mount plus the `perms_*` bitmask. So
    the trigger is a future core change, not an unchecked leg of the matrix.
