---
title: "ADR-214 historical context"
manual: "LLM Foundation"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-214-historical-context@main"
source: "Adr/History/Skill214Context.rst"
rendered: "2026-11-12T15:41:35+00:00"
---

# ADR-214 historical context {#adr-214-historical-context}

This appendix preserves the decision and its amendments. For present use,
follow [ADR-214: Approved skill versions instruct, load on demand and run processes](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-214@main) and its current guide links.

## Context {#adr-214-context}

Editors ask for guided processes in the chat: optimise the SEO of one page,
improve the content of one page. Such a process picks a page, analyses it,
gives an overview, then works through the findings one at a time. Each
finding gets a proposal with the current and the proposed text and three
answers: apply, another variant, skip. Nothing is saved without an explicit
confirmation. The editor sees the progress and the affected content element.
A skipped finding stays open for the next conversation.

The product owner wants such processes as editable text, not as one extension
per process. Skills ([ADR-035](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-035@main), [ADR-036](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-036@main)) are
the existing record for reusable prose, and the agent run already has the
suspension states a guided flow needs. Two product requirements are fixed:
a skill can be started by a slash command **and** loaded by the model when
its name and description match the request; and skills are enabled per
skill, like tools. Seven properties of the current code stand in the way.

### 1\. Every skill is data, never an instruction {#adr-214-context-framing}

`SkillComposer` wraps every admitted skill in the same frame: a guard
preamble that calls the block "UNTRUSTED task-reference DATA" and fence
markers that say
`Classes/Service/Skill/SkillComposer.php#do not follow as instructions`.
`SkillComposer::composeBlock()` applies that frame to every skill
`SkillComposer::effectiveSkills()` admits, whatever its
`SkillTrustLevel`. The trust level decides admission only: a skill below
`skills.minTrustLevel` is not composed at all
([ADR-061](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-061@main), item 1;
`ext_conf_template.txt#skills.minTrustLevel = untrusted`;
`SkillComposerFactory::minTrustLevel()`). The governance profiles expect
`community`, `verified` or `first_party` there, and `development` expects
`untrusted` (`GovernanceProfile::expectations()`). A `first_party`
skill is admitted under every profile and still reaches the model as text it is told
not to follow. ADR-036 item 3 puts the block into the first user message and
never into the system role; ADR-061 item 4 added the fence. Both were right for
third-party text fetched from the internet, and neither leaves room for text
the installation itself vouches for.

### 2\. The checksum covers the body only, and a backend edit invalidates it {#adr-214-context-sources}

The source types are `single_file`, `repo` and `marketplace`
(`SkillSourceType`), and every fetch goes through
`GitHubClient`, whose host allow-list names only GitHub hosts
(`Classes/Service/Skill/GitHubClient.php#ALLOWED_HOSTS`).

The sync computes `body_checksum` from the body alone
(`Classes/Service/Skill/SkillSyncService.php#hash('sha256', $parsed->body)`),
and compose re-verified it the same way (ADR-036 item 6); for a legacy row
without a version digest it still does
(`Classes/Service/Skill/SkillVersionDigest.php#hash('sha256', $skill->getBody())`).
The sync's change test compared that value only, until item 1 below replaced
it with the version digest.
The same sync also rewrites `name`, `description`, `allowed_tools`,
`support_status` and `raw_frontmatter` (`SkillSyncService::apply()`).
An upstream change to the frontmatter alone is therefore an `updated` skill:
it stays enabled, and nothing records that the text the model reads changed.
`allowed_tools` decides which tools a run may call, `name` heads the
composed section, and `support_status` decides whether asset references are
stripped from the body (`SkillComposer::renderSection()`).

The `body` of `tx_nrllm_skill` is an editable text field, but
`body_checksum` is read-only, so a body edit in the backend produces a skill
that is skipped on every call. `readOnly` is a FormEngine setting only; no
field of `tx_nrllm_skill` carries `exclude`, so a DataHandler write by a
group with `tables_modify` would reach `trust_level`, `body_checksum` and
`enabled` ([ADR-169](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169@main), section 1). A sync overwrites the record
in place, so no earlier version is kept.

### 3\. Every attached body is sent in full, and the tail is dropped {#adr-214-context-loading}

Skills attach to a configuration and to a task through MM tables
(ADR-036 item 8). The admin playground can force further skills onto one run
(`RunAugmentation`). A queued run persists those as uids only
(`AgentRunRequestCodec`), so the worker composes whatever body the record
holds when it picks the run up. A suspended run replays the transcript stored
in `SuspendedRunState`, which already contains the composed text, and
re-reads the forced skills by uid only for the ceiling check
([ADR-165](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-165@main)). Nothing records which version of a skill a run
was composed with. Every admitted body is composed in full. When the block
exceeds `skills.maxBytes` (default 24 000), whole skills are dropped from
the end, task skills first (ADR-036 item 5). There is no way to list a skill
without sending its body, to load it when it is needed, or to invoke one
explicitly.

### 4\. Snippets do not reach a caller that brings its own system message {#adr-214-context-snippets}

Prompt snippets ([ADR-031](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-031@main)) are appended to the
`system_prompt` option. `MessageShaper::applySystemPrompt()` returns
the message list unchanged when it already holds a system message
(`Classes/Service/MessageShaper.php#if ($isSystem)`), and ADR-031 records
this as intended: "a caller-supplied system *message* still suppresses the
configuration's system prompt — and with it the snippet block". The chat of
the `nr_mcp_agent` extension builds its own system prompt
(`ChatService::buildSystemPrompt()`), which always starts with a fixed
identity text, and `ChatService::runAgentTurn()` prepends it as a system
message whenever it is not empty. Read together, the code says snippets never
reach the chat. This is inferred from the code of both extensions; no run was
measured.

Neither `tx_nrllm_promptsnippet` nor `tx_nrllm_configuration.system_prompt`
is restricted to administrators, and [ADR-169](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169@main) section 2
recommends that non-administrators manage both tables. Whoever edits text that
reaches a system message holds an instruction-level privilege.

### 5\. Text appended to an existing system message is not charged {#adr-214-context-budget}

`ContextWindowManager::fit()` charges the configuration's system prompt
only when the first message is not a system message
(`ContextWindowManager::missingSystemPromptTokens()` returns 0 otherwise),
plus the fenced skill block passed as `$injectedText`. When the budget is
exceeded, `ContextWindowManager::drop()` removes the oldest whole turns
([ADR-107](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-107@main)). Text that a later stage appends into an existing
system message is not charged at all, and a larger head is paid for by
evicting earlier turns, not by a refusal.

### 6\. The tool allow-list is resolved from the configuration alone, live {#adr-214-context-tools}

`AllowedToolsResolver::resolve()` returns the union of the
`allowed-tools` declarations of the effective skills, or `null` ("no
restriction") when none declares. `ToolCallPolicy` calls it with the
configuration and no task
(`Classes/Service/Tool/ToolCallPolicy.php#$this->allowedTools->resolve($configuration)`),
in `ToolCallPolicy::decide()` and in `ToolCallPolicy::explain()`.
Task skills and forced skills therefore restrict nothing at run time, although
[ADR-038](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-038@main) item 5 names the task. A resume re-resolves the list
live (`ToolLoopService::resolveOfferedNames()`): if the only declaring
skill is disabled while the run waits, the resumed run gets `null`, which
is every tool. The resolver's own docblock names the same widening for the
budget drop and rejects it there.

### 7\. The run suspends for input or for approval, never for both {#adr-214-context-runs}

A tool can suspend a run in `WAITING_FOR_INPUT` and resume it with input
validated against the schema the tool declared ([ADR-105](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-105@main)).
No production tool implements `RequiresInputInterface` today; only test
fixtures do. A builtin that declares a write effect requires a human approval
([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@main)), and the approval binds to the preview lines the
approver saw: a resume recomputes them and bounces when they differ
([ADR-184](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-184@main)). ADR-134 bans a write tool that also requires input,
because the approval resume carries no input
(`ToolLoopService::resume()` takes a boolean). A denial feeds the model a
fixed text that says who declined and nothing about why
(`Classes/Service/Tool/ToolLoopService.php#private function approvalDeniedResult(`,
[ADR-200](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-200@main)). On a configuration with
`require_second_approver` the run owner cannot approve their own write
([ADR-172](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-172@main);
`Classes/Controller/Backend/AgentRunController.php#runs.error.selfApproval`).
