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 and its current guide links.

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, ADR-036) 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 

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, 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 

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, 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 

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). 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 

Prompt snippets (ADR-031) 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 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 

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). 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 

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 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 

A tool can suspend a run in WAITING_FOR_INPUT and resume it with input validated against the schema the tool declared (ADR-105). No production tool implements RequiresInputInterface today; only test fixtures do. A builtin that declares a write effect requires a human approval (ADR-134), and the approval binds to the preview lines the approver saw: a resume recomputes them and bounces when they differ (ADR-184). 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). On a configuration with require_second_approver the run owner cannot approve their own write (ADR-172; Classes/Controller/Backend/AgentRunController.php#runs.error.selfApproval).