ADR 214 · Accepted
ADR-214: Approved skill versions instruct, load on demand and run processes
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).
Decision
A skill version that an authorised person approved is composed as an instruction. The approval binds to a digest over everything the model reads of that version and to the source that vouches for it. Skills can be written in the backend, are listed in context and loaded when needed, and a conversation keeps the versions it started with until they are revoked. Process state and every write go through tools and the run, not through the prompt. No new process engine is built; a process is a skill. None of this is implemented at the time of writing; the records it amends keep describing the current code until the implementing changes land.
1. One digest names one version
The version digest is a sha256 over the body and the normalised frontmatter fields — every field that composition, the catalogue or AllowedToolsResolver reads: name, description, body, support_status, allowed_tools and the process marker of item 6. The input is a canonical serialisation of those fields under a format version: fixed key order, allowed_tools as null (no declaration) or as a sorted list without duplicates, after the same string splitting SkillSyncService::apply() applies. raw_frontmatter is not hashed as such, because its JSON depends on key order; a frontmatter key that nothing reads does not change the version. A field that a later change starts to read joins the digest under a new format version.
The digest lives in a new column (working name version_digest, holding the format version and the hex value). body_checksum is a varchar(64) (ext_tables.sql) and keeps its body-only meaning, because two readers still need exactly that: the signed manifest and the legacy check below.
The same digest is used wherever this extension compares versions:
the sync's change test, so a frontmatter-only change is a change: an enabled skill is disabled and audited as ingest_disabled_on_change (ADR-035 item 5), and no approval matches the new digest;
the compose-time integrity check of a synced skill, which recomputes the digest from the stored fields and compares it with version_digest (ADR-036 item 6, now covering the frontmatter fields as well);
approvals, revocations and pins (items 2, 6).
The signed manifest is the exception. SkillSyncService builds the per-source manifest from body hashes (Classes/Service/Skill/SkillSyncService.php#$manifest[$sourcePrefix . $parsedSkill->path] = hash('sha256', $parsedSkill->body)), and SkillManifestVerifier checks what upstream signed (ADR-061). That format belongs to the upstream publisher and is not changed here, so a signed manifest vouches for the body only; the frontmatter is covered by the digest and the approval, not by the signature.
Stored rows migrate without a mass re-review and without a blind window. A row with an empty version_digest is a legacy row. Compose keeps verifying it against body_checksum as today, and it can never be an instruction, because no approval names it.
The first sync after the upgrade compares like with like. For a legacy row it first computes the new-form digest from the stored fields, then compares that with the digest of the incoming version. A frontmatter change upstream — a widened allowed-tools included — is therefore a change on the very first sync and disables an enabled skill; an unchanged skill is not disabled merely because its row was legacy.
The upgrade wizard does not bless earlier edits. name and description are editable in FormEngine today (Configuration/TCA/tx_nrllm_skill.php#'label' => 'LLL:EXT:nr_llm/Resources/Private/Language/locallang_tca.xlf:tx_nrllm_skill.name'), so the stored fields of a synced row may differ from what the sync wrote. Before it writes version_digest, the wizard verifies the body against body_checksum and re-derives name, description and allowed_tools from the stored raw_frontmatter, which the sync wrote and FormEngine shows read-only. A row that fails either check is disabled, audited and left without a digest until the next sync rewrites it.
The synced name and description become read-only in FormEngine for synced sources, as the body's checksum already is: an edit to any of them now fails the integrity check, as a body edit does today.
2. The trust level decides the framing, and trust binds to one digest and one source
Two conditions make a skill an instruction: its provenance level (SkillTrustLevel) is at or above a new threshold, and an approval exists for its current digest and its current source and is not revoked. The threshold is a new extension setting (working name skills.instructionTrustLevel), default verified. A value below skills.minTrustLevel is read as skills.minTrustLevel, since a skill that is not admitted cannot instruct.
The provenance level for this check is read from the source record (tx_nrllm_skill_source.trust_level), for every source type, not from the column the sync denormalises onto the skill. A re-classified source takes effect at once instead of at its next sync.
Admission reads the source record too, for backend-authored skills. SkillComposer::effectiveSkills() admits by the denormalised tx_nrllm_skill.trust_level, whose only writer is the sync (Classes/Service/Skill/SkillSyncService.php#$skill->setTrustLevel($source->getTrustLevel())) and whose TCA default is untrusted. A backend-authored skill has no sync, so under every governance profile above development it would never be admitted. For the backend source, admission therefore reads the source record's level; the synced sources keep reading the denormalised column (ADR-061 item 1).
The threshold is checked whenever an instruction is composed and at every check of a pin (item 6), not only when the version is approved. It is one enum comparison per source.
An instruction skill is composed into the system message, as a labelled section after the configuration's own prompt and the snippet block, without the guard preamble and the fence. The model reads it as instructions of the installation.
Every other admitted skill keeps today's frame: first user message, guard preamble, BEGIN/END UNTRUSTED SKILL DATA fence, fence markers in the body neutralised.
Provenance and approval stay separate fields. The provenance level is what a source is (ADR-061, set on the source). The approval is a statement about one digest from one source. "Reset to untrusted" means "no unrevoked approval matches"; the provenance level is never rewritten for it.
An approval row is the snapshot of the version it approves. It holds the skill uid, the source uid, the digest, the body and the normalised fields that went into the digest, the provenance level at the time, the approver and the time. Binding the source means that moving an approved skill to another source — a first_party one, or the backend source, which has no stored-value integrity check — leaves it without a matching approval. No separate revision table is kept: the snapshot is what a pinned run composes (item 6), and it is what the approval form diffs the current version against.
The approval request carries the digest the approver saw. The approval form shows the current version and its diff against the most recent approved snapshot, and it posts the digest it rendered. The server recomputes the current digest and refuses the approval when the two differ, then shows the form again with the new state. This is the rule of ADR-184 applied to a skill version: an approval is a decision about the state it showed.
Revocation is per digest and wins. A revocation names one skill and one digest. A revoked digest becomes an instruction again only through a new, explicit approval of that digest, never because a sync or an edit reproduced the same bytes. A digest that was approved and never revoked is an instruction again when a later edit or sync reverts to exactly that version: it is the same text someone approved.
Approving needs a dedicated permission, separate from the right to edit or enable a skill. Until the mechanism is decided (see open questions) only administrators can approve or revoke. Every approval, revocation and refused approval is audited in tx_nrllm_skill_audit (ADR-061 item 5) as new SkillAuditEvent cases carrying the digest, with the same append-only guarantee.
3. Skills can be written in the backend
A further source type holds skills authored in the backend, next to the GitHub source types, which stay as they are. A backend-authored skill has the same fields and the same compose path. Its provenance level is set on its source record like any other source's, and both admission and the instruction threshold read it there (item 2).
The digest of a backend-authored skill is computed, not stored and trusted. Compose and the approval form compute it from the record's current fields and match it against the approvals. There is no stored-value comparison for this source, because an edit is the expected way the record changes. The stored-value check of item 1 stays for the synced sources, where the sync is the only legitimate writer and a backend edit is the tampering it exists to catch. No write path has to maintain the digest of a backend skill, so nothing relies on a DataHandler hook, which Extbase writes would skip (ADR-169, section 3).
Authoring goes through FormEngine and the DataHandler. That gives the record TYPO3's sys_history (who changed what, when, with a diff and a rollback) and the exclude boundary. Any nr_llm controller action that writes the content of a backend skill uses the DataHandler, not an Extbase repository.
A backend skill's tools come from its approved snapshot. AllowedToolsResolver reads the live allowed_tools declaration. For a synced skill that value is held by the integrity check and by the disable-on-change of the sync. A backend skill has neither: an author could widen the run's tools with an edit that needs no approval. A backend skill therefore contributes the allowed_tools of its most recent unrevoked approved snapshot, not the live field. An edit to an approved backend skill thus keeps the approved tools until the new version is approved. A backend skill that was never approved, or whose approvals are all revoked, contributes a declared empty list: it grants nothing, and it still counts as a declaration, so it can never leave a run unrestricted. A skill the run holds a pin for follows the pin rule of item 5 instead.
Eight fields are excluded with exclude => true. On tx_nrllm_skill: trust_level, body_checksum, version_digest, enabled, allowed_tools and source. On tx_nrllm_skill_source: trust_level and type. The implementing change excludes more fields that decide the same things: on the skill hidden, support_status, orphaned and raw_frontmatter; on the source hidden, enabled, url, ref, pinned_sha, expected_fingerprint and github_token (specs/012-approved-skill-versions/spec.md, item 11). readOnly keeps a field out of FormEngine; exclude keeps it out of a DataHandler write by a group that holds tables_modify but was not granted the field. source and the source type are excluded because moving a synced skill onto the backend source, or turning its source into a backend source, would switch off the stored-value integrity check; the approval's source binding of item 2 covers the case where an administrator does it. The approval rows and their audit are written only by the approval action, which is administrator-only until the permission is decided.
The approval rule of item 2 applies unchanged: an author who may edit a skill cannot thereby make it an instruction.
4. Skills load on demand: by slash command and by the model
Enabling stays per skill. The enabled flag of tx_nrllm_skill is the site-wide switch, the counterpart of tx_nrllm_tool_state for tools; attachment decides which configuration offers it.
An attachment carries a load mode, always or on_demand. Existing attachments migrate to always, which is today's full composition; new attachments default to on_demand.
A skill is named by its uid, not by its identifier. Identifiers are unique only per source; SkillComposer already keys skills by source and identifier because cross-source twins exist. Invocation, the load tool and the catalogue use the skill uid; the identifier is display text.
A body reaches a run in one of three ways:
Always. An always attachment is composed at run start, as today: an instruction skill as a system section, any other skill in the fenced block. A process skill is never composed this way (item 6).
Explicit invocation. The caller passes a skill uid when it starts or continues a run, from a slash command or a button (item 10). Any enabled skill attached to the configuration can be invoked, in either load mode. An instruction skill becomes a system section, any other skill goes into the fenced block.
Loading by the model, through a dedicated read-only tool that takes a skill uid from the run's catalogue. The catalogue lists the on_demand attachments whose current version is an instruction and which are not process skills: uid, name and description, from the approved version, so every catalogue text is covered by the digest someone approved. Page content, search results and tool output are untrusted and can ask the model to load something; restricting the catalogue to approved versions means the worst such a request achieves is adding text the installation already approved. A skill that is not an instruction never appears in the model's catalogue, so no third-party description sits outside the fence. The tool result confirms the load and does not carry the body: the loop writes the section into the run's system message before the next model call, so an instruction never travels in the tool role.
The load tool re-checks at load time. It refuses a uid that is not in this run's catalogue, and it re-checks every condition of the catalogue at the moment of the load: still attached on_demand, enabled, not orphaned, not a process, and an instruction (an unrevoked approval for its current digest and source, provenance at or above the threshold). A catalogue built at run start is a list of candidates, not a grant.
The catalogue and the load tool are offered only when the run's catalogue is not empty, which needs at least one on_demand attachment. A configuration whose attachments are all always — every configuration after the upgrade — gets neither, so existing runs send what they sent before.
Loading is idempotent per skill and digest. A load or an invocation of a skill whose section the conversation already holds, whether by an always attachment, an invocation or an earlier load, appends nothing and returns "already loaded". A skill whose current digest differs from the one the conversation holds is refused: it keeps the version it has (item 6).
One rule for the tail drop. An instruction section is never dropped from the tail, whether it came from an always attachment, an invocation or a load; the budget rule of item 7 admits it or refuses it. Every fenced skill, however it reached the run, stays in the fenced block, which skills.maxBytes keeps bounding by dropping from the tail as SkillComposer::composeBlock() does today (ADR-036 item 5).
5. The skill allow-list is fixed at run start and can never gain tools
The run's skill allow-list is resolved once, at run start, over every effective skill attached to the run — enabled, not orphaned and admitted, as SkillComposer::effectiveSkills() selects them: the configuration's attachments in both load modes, the forced skills of RunAugmentation, and the invoked skill. The load mode does not matter: under the union semantics of ADR-038 item 5, which stay, an on_demand skill's declaration grants its tools from run start, before its body is loaded, and loading it later changes nothing. This is the trade-off taken: attachment grants tools, a load grants nothing. Attaching or forcing a skill is an administrator's act; a load is something content can ask for. Content that talks the model into loading skill B therefore cannot grant tools the run did not already have. What changes against ADR-038 is the set the union is taken over, and that it is taken once.
The resolved list is stored with the run — on the run request a queued run persists and in SuspendedRunState — and ToolCallPolicy receives it instead of resolving the configuration again. decide() and explain() take the run's list as an argument; a caller with no run (the tool explanation in the backend) keeps today's configuration-only resolution and says so.
A resume intersects, it never re-derives from the configuration alone. The ADR-165 re-gating at resume resolves the live list over the run's own skill set — the configuration's attachments plus the forced and invoked skills, re-read by the uids the suspended state stores, the way ADR-165 already re-reads forced uids — and intersects it with the stored list, where null imposes nothing. A forced skill's tools therefore survive every resume, the stored list stays the upper bound, and a live null caused by a disabled declaring skill no longer widens a resumed run to every tool. A change while the run waits can take tools away or leave them, but can never add one through the skill allow-list. The other gates are re-read at resume as today: a tool switched on globally while the run waited is offered if the skill list admits it (ADR-039).
A pinned skill contributes from its pinned snapshot. A skill the run holds a valid pin for (item 6, which requires the skill to be still attached or forced on the same lineage) contributes the allowed_tools of the pinned snapshot, whether or not it is still enabled and whatever its current digest is — in the start-time union of every chat turn and in the live list a resume intersects with. A detached skill has no valid pin, so detaching takes its tools away at the next turn, as attachment granted them. Every chat turn is a new run, so without this rule a sync that disables a pinned skill, or an edit that leaves a pinned backend skill unapproved, would take away the tools the instruction still in the system message relies on. Unpinned skills follow the rules above and, for backend skills, item 3.
The load tool sits outside the skill allow-list. It is exempt from the skill-derived list and from the configuration's allowed_tool_groups gate (AllowedToolsResolver::applyGroupGate()), so a skill declaring allowed-tools: [] blocks every other tool but not the load tool. It still passes the global tool state (ADR-039) — an administrator who disables it switches model loading off — and the trust-zone gate. It reads only approved skill text and has no effect outside the run's system message.
6. A conversation holds pinned instructions, and a process binds it
A frontmatter marker (working name process: true) declares a skill to be a process. A process skill is started by explicit invocation only; it is never in the model's catalogue, so page content cannot start a process. A process skill whose current version is not an instruction cannot be started.
A process skill is composed only through an invocation. The guarantee is the compose-time skip: a process skill that reaches the always path or the forced path (RunAugmentation) is skipped, with a notice naming it, and reaches a run only through an invocation. A forced process skill is skipped the same way, not refused. The skip also covers a skill that turns into a process skill after it was attached, because the marker is part of the synced frontmatter. No notice is shown when a held pin already names the skill: the process is then composed from its pin, and the attachment or the forcing adds nothing. FormEngine additionally refuses the always load mode on the attachment of a process skill; that is a convenience on top of the skip, not the guarantee, because the Extbase write paths skip it (ADR-169, section 3). A process skill is attached on_demand; an invocation reaches either load mode. A consumer that wants a process for a whole conversation passes it as an invocation, never as a forced skill. So every process pin passes the start checks of item 10 — no service account, no four-eyes configuration, no second process pin.
Two guards keep a process run inside the chat. Both are enforced by nr_llm, not left to the consumer:
Only the chat card decides a process run. A run that holds a process pin can be decided — approved, denied, or answered — only by its initiator (AiActorContext::isInitiatorOf()). ResumeCoordinator::approve() and ResumeCoordinator::submitInput() refuse any other actor, administrators and holders of the approve grant (ADR-130) included, with a typed refusal naming the chat. The two backend surfaces that can reach a run refuse it server-side as well, for the initiator too: AgentRunController (its approveAction and submitInputAction, also in the non-admin editor module) and ToolPlaygroundController (its resumeAction() and submitInputAction(), which accept any run uuid for an administrator). The Agent Runs inbox lists such a run read-only, with a note that it is decided in the chat. That leaves the chat card as the one shipped surface; an @api caller acting as the initiator acts for the same person. Each turn of a process run therefore has one write call, decided on the card by the person the chat belongs to, and nothing is ever released out of band. Because nobody but the initiator can decide, a process run must have one who can: a service account, for which isInitiatorOf() is always false, cannot start a process skill, and nr_mcp_agent offers and starts process skills only for users who may approve in the chat (its permission to approve one's own changes, nr_mcp_agent pull request #200). A tour started without that permission would be deny-only, so it is not offered.
No process on a four-eyes configuration. Starting a process skill on a configuration with require_second_approver set is refused with a message that names the setting: under ADR-172 the editor could never apply a proposal, and the first guard leaves nobody else to. The pin check at start, pickup, every resume and every continuation includes it: a run whose configuration has switched the setting on since stops with the same message. On an approval, ResumeCoordinator::approve() applies this rule to a run holding a process pin in a fixed order: first the initiator check of the first guard, then the existing refusal of an unreadable suspended state before the claim, then the four-eyes stop, and only then its ADR-172 gate, which a process run therefore never reaches. Without the stop, the self-approval refusal would leave the run waiting, and under the first guard nobody else could decide it. The run is stopped with the four-eyes message through a terminal transition guarded on WAITING_FOR_APPROVAL like the guarded cancel of item 10, since this happens before any claim; a corrupt run keeps its state for the operator, as today. Four-eyes tours are an open question for a later decision.
Every instruction section is pinned, not only the process. A pin is a skill uid, a source uid and a digest, recorded for each section — from an always attachment, an invocation, a load or a forced skill — on the run request and in SuspendedRunState. A pinned section is always composed from the approval snapshot of its digest, not from the body the record holds by then.
Pins outlive the run that created them, and nr_llm carries them itself. A chat turn is a new run: the chat rebuilds the system message and sends the history on every turn, and it persists the final answer, not the tool messages (ChatService::runAgentTurn(), ChatService::applyResult()). A section loaded in turn 3 would otherwise be gone from turn 4's system message. Each run therefore stores its pins, its invocation and its subject record with the run record (uids and digests, no content). A continuation request names only its predecessor run, by run uuid (item 10), and the runtime derives the pins, the invocation and the subject record from that run. No caller can hand in a pin: a pin exists only because an earlier run of the same lineage admitted the section. Four rules bound the derivation:
Only the predecessor's initiator, or an administrator, may continue it. AiActorContext::mayActOnRun() with a scope is not used for this, because what it admits depends on the scope: for a human who is not an administrator it allows only their own run, except under agent:approve, where the approve grant opens other users' runs; a service account passes on any scope it holds. A continuation derives another run's subject record and instructions, which is more than reading or deciding it, so the runtime requires that the request's backend user is the predecessor's beUser or that the actor is an administrator: the existing AiActorContext::isInitiatorOf(), which already excludes service accounts and backs the four-eyes refusal in ResumeCoordinator::approve(), or an administrator actor.
A predecessor that holds a process pin must be terminal. nr_llm only checks this and refuses otherwise; it cancels nothing. Getting the predecessor to a terminal state is the chat's job (item 9): a new message cancels a waiting process run through the guarded cancel. A predecessor without a process pin keeps today's behaviour: it may still be waiting, its pins are derived all the same, and it stays releasable in the inbox, as an abandoned approval in an ordinary chat is today.
A missing or unreadable predecessor refuses. Finished runs are purged by age (AgentRunRepositoryInterface::purgeOlderThan(), governed by the agent-run retention setting) and conversations by inactivity, on separate clocks, so a conversation can outlive its last run. The request is then refused with a message telling the editor to start a new conversation; it is not silently turned into a start without pins.
A stopped run stores what still holds. When a pin fails, the run stops with the pin's message (below) and stores its pins minus the failed one. The next turn therefore continues without that section instead of failing on it for ever; one revoked skill never blocks the conversation.
The runtime re-composes each derived pin into the new run's system message, and the pinned snapshot also supplies the skill's tool declarations (item 5).
The pin rules, at start, at worker pickup, at every resume and at every continuation. A pin holds while an unrevoked approval for its digest and source exists, its snapshot still hashes to the digest, the skill record exists and is not orphaned, and the source's provenance is at or above the threshold. When one of these fails, the run stops with a message that names the skill and the reason. It does not continue without the section, and it does not fall back to the fenced frame. On a resume after an approval, the check runs before the approved write executes, next to the ADR-184 preview check, so a write is never carried out under a revoked instruction.
Attachment ends a pin without stopping the run. A pin also requires that the skill is still attached to the request's configuration, in either load mode, or was forced on a run of the same lineage. Detaching a skill drops its pin at the next turn, and a continuation whose configuration differs from its predecessor's drops all pins, process pin included, because the new configuration starts with its own attachments. Neither is silent: the run records a notice naming the dropped skills and the reason, which the chat shows before the answer, and the turn continues without them.
This is chosen over limiting revocation to new runs. A revocation is the brake for text that is already acting as an instruction, and a run suspended for an approval can wait for days; the transcript it replays already contains the composed text, so without the check a revoked instruction would keep steering it. The check is one lookup per pin.
The enabled flag gates new use; revocation stops running use. A sync that changes an enabled skill disables it (ADR-035 item 5) so the new version is reviewed. Pins carried by a run or a continuation keep working, because that text is still approved: a sync during a tour does not end the tour at the next message. A disabled skill cannot be invoked, loaded or started as a process without a pin. An administrator who wants running tours stopped revokes the digest.
A newer approval does not replace a pin. A conversation keeps the version it started with until it ends or the pin fails; a new conversation gets the current version. An older unrevoked version therefore stays usable by the conversations that already hold it, and only by them. An administrator who wants it gone everywhere revokes it.
A process pin ends with the process. The progress builtin of item 9 has a completion report. When the model reports completion in a turn without a pending approval, the run releases the process pin. In a turn that also holds a pending call, the completion report gets an error result ("report completion after the decision") and releases nothing, so a last "Andere Variante" still runs under the process. On release, the next turn composes no process section and the one-approval rule of item 9 no longer applies. The other pins of the conversation stay. While a process pin is held, a second process invocation is refused with a message naming the running process; there is never more than one process pin.
Fenced, non-instruction skills keep today's behaviour: composed from the current record, checked by the integrity check, and re-gated by ADR-165 when forced.
7. Appended system text is charged, and admission does not strand a conversation
Every text appended to a system message is charged by ContextWindowManager::fit(), whatever the first message holds: the snippet block of item 8 and the instruction sections. In the agent loop, sections are written into the transcript's system message at assembly or at load, so they are part of the measured messages. On the path where the shaping stage appends them after fit(), they are passed to fit() as charged text, the way the fenced block already is. Nothing is appended after fit() uncharged.
Admission is checked against the smallest send the fit can reach. A new section — an always attachment at the start of a conversation, an invocation, a load — is admitted only when the head (system message with every section already held, plus the new one, and the first user message) together with the newest turn stays at or below the budget minus a headroom of 10 % of the budget (working value). That is the floor fit() reaches by dropping older turns (ADR-107), so a section that passes can always be sent. Otherwise nothing is appended: a load returns an error result naming the skill, and a start fails before the first provider call with a message naming the skill.
A section the conversation already holds at the same digest is not re-admitted. A pin derived from the predecessor run is composed without a new admission check. Without that rule a long tour would fail permanently once its history grew past the headroom, although fit() could still send it by dropping old turns.
fit() itself is unchanged: it keeps dropping the oldest turns when the transcript grows, and the system message, which carries the sections, is part of the head it never drops. This is why ADR-107 is not amended: its drop rule stays as it is, and only the admission of a new section, which ADR-107 does not govern, is decided here.
8. Snippets reach a caller's system message only when marked and opted in
The snippet block is kept apart from the configuration's system prompt until the messages are shaped. When the caller sent no system message, nothing changes: snippets are appended to the configuration's system prompt (ADR-031). When the caller already sent one, the configuration's own system prompt stays suppressed (per-call precedence, as ADR-031 and ADR-139's characterisation tests pin), and a snippet is appended to the caller's first system message only when both hold:
the snippet's composed block is marked by an administrator. What reaches a system message is the block PromptSnippetComposer::composeSections() emits: the snippet's name as a label, then its text. Both fields are editable without exclude, so the mark (working name caller_system_digest on tx_nrllm_promptsnippet, exclude => true) stores a sha256 of that composed block, label and text. The marking form shows the block and posts the digest of the block it rendered; the server recomputes it and refuses the mark when the two differ, the rule item 2 applies to skill approvals. The snippet is appended only while the digest of its current block matches. An edit of the name or the text by anyone who may edit them — under ADR-169 a non-administrator — takes it out of the chat's system message until an administrator marks the new block; and
the configuration opts in (working name snippets_in_caller_system_message on tx_nrllm_configuration, exclude => true, default off).
A mark on the record alone was rejected: the snippet and name fields are not excluded, so a non-administrator could keep a marked record and replace its text or its label. A fixed label on the caller-system path was considered and rejected: the name is how a snippet is told apart in the system message, and digesting it costs nothing. Admin-only TCA for the whole snippet table was rejected as well: it would reverse ADR-169's recommendation that non-administrators manage snippets, for the sake of one path. With the text-bound mark, a non-administrator can still write snippets that reach the configuration's own system prompt, as today, but cannot route text into the chat's system message.
The configuration's system_prompt keeps its reach: it is still suppressed by a caller's system message, so this decision routes no new text through it. Editing it already is an instruction-level privilege wherever it is sent; whoever is granted tables_modify on tx_nrllm_configuration under ADR-169 gets that privilege. This decision does not change that grant.
This item can ship first. Item 2 appends instruction sections to the caller's system message through the same mechanism, but that path is governed by the approval, not by the snippet mark.
9. Process state lives in tools and the run
A process skill describes the steps. What the editor sees and what persists comes from tools and the run, so that it does not depend on the model following prose. nr_llm provides the generic capabilities; nr_mcp_agent renders them and owns the open points.
Progress. A read-only builtin reports the process's position (for example "point 3 of 7") or its completion, as integers and a flag, not as prose; it records the report as an event of its own kind, so the last report survives the metadata privacy level and is readable through the run's events and status. A completion report releases the process pin (item 6).
Highlight. A read-only builtin names the record the current point is about. It accepts only targets the run registered from the invocation's subject record, which every continuation derives from its predecessor run (item 6), for example the content elements of the selected page; the UI maps a target to its element. The model never supplies a CSS selector or any other markup. It records the target as an event of its own kind holding the table name and uid, record identity in the sense of ADR-185 and no field values.
One approval-bound call per turn in a process run. An approval decides the whole pending turn (ADR-132; ToolLoopService::resume() applies one decision to every pending call), and today the loop suspends before any call of the turn executes (ADR-084). In a run that holds a process pin, the runtime counts the calls for which ToolApprovalRule::requiresApproval() is true — declared writes, approval-bound reads (ADR-202) and remote tools that require approval — not the write-declaring ones. Before it suspends:
the pending call is the first approval-bound call that declares a write (ToolEffectResolver::effectFor()). Only when the turn holds no such call is it the first approval-bound call of any kind, for example an external fetch whose approval is on (ADR-202) or a remote tool; it alone is the pending set and the preview of the card;
every further approval-bound call gets an error result ("one approval per turn in a process") appended to the transcript, so the model sees it and the pending set does not contain it;
an input-requiring call in the same turn gets the error result it would get at resume today, appended the same way;
a call that needs no approval, that ToolEffectResolver::effectFor() resolves as a read and that is not a RemoteToolInterface executes, in turn order, and its result is appended. Builtin reads declare no effect and resolve as reads by default — none of the 17 builtins that implement ToolEffectInterface declares a read — so they keep working next to a proposal. A remote tool never runs early, even one that declares no effect. Progress and highlight are builtin reads, so they are visible while the editor decides, and a denial reaches only the one pending call;
every remaining call — one that needs no approval but is not a read, for example a tool of an MCP server whose operator switched approval off (ADR-134), which resolves as NON_IDEMPOTENT_WRITE — gets an error result ("not executed while a proposal waits for approval") appended, and does not run. An unknown tool name resolves as a write too (ToolEffectResolver::effectFor()), so it never runs early.
This keeps ADR-132's invariants: the turn digest is computed over the pending calls (PendingTurnDigest::forState()), which are now exactly the one approval-bound call, and one decision still covers the whole pending set. It amends ADR-084's "check the whole turn before executing any of its calls" for process runs, and only for reads: nothing that writes runs before the editor's answer, so "nothing is saved without an explicit confirmation" holds. The calls of one turn are parallel tool calls with no order between them on the provider side, and only reads run early, so executing them first changes no state the pending write could depend on. Outside process runs the turn suspends before any call, as today.
One approval card per proposal; approve means apply. A proposal is the pending write call itself. Its approval card shows the preview lines of ADR-136 (current and proposed text), bound by ADR-184. A pending call that is not a write is not a proposal: its card shows a plain approve and deny, without the three answers and without a denial reason. For a write, the three answers map onto the one pause:
"Übernehmen" approves the call, which writes.
"Andere Variante" denies it with the reason variant.
"Überspringen" denies it with the reason skip.
The reason is a closed type: a string-backed enum (working name ApprovalDenialReason, cases variant and skip) on ApprovalDecision, which is an @api value object with a public constructor. The constructor rejects a reason together with approved = true. The reason travels through ResumeCoordinator::approve() into ToolLoopService::resume(), and the denial result renders it through a match over the enum cases, as decided_by is rendered today, as a fixed token beside decided_by (ADR-200). The model either proposes a new variant — a new write call and a new card — or moves on to the next point; the open point is recorded by the chat, not by the model (below). A denial without a reason keeps today's text. No pause combines input and approval: the ADR-134 ban stays, and nothing about the answer is collected through the input path.
Writes need an approval, always. No process skill, trusted or not, can switch that off (ADR-134).
A new message withdraws a waiting process run. The conversation states are those of nr_mcp_agent: AwaitingApproval for a card, and AwaitingInput for an input pause, which nr_mcp_agent pull request #197 adds together with its submitInput() endpoint. When a message arrives while the conversation is in either state and its run holds a process pin, nr_mcp_agent's queueTurn() acts in this order:
it claims the conversation row first, with its compare-and-set updateIf(), and answers 409 when the claim fails. A decision the chat has made is never in this window: deciding a card records the decision and moves the conversation to Processing in one compare-and-set from AwaitingApproval (ChatService::recordDecision(), which writes through updateIf()), and a message to a conversation in Processing, or one that still records a decision (hasPendingApprovalDecision()), is answered 409 before any cancel. The outcome of the chat's own decision always arrives through the result path;
it then cancels the run through the guarded cancel of item 10, which succeeds only from WAITING_FOR_APPROVAL or WAITING_FOR_INPUT and reports the run's status after the attempt;
if the cancel won, or the run was already terminal although the chat made no decision on it — which under the first guard of item 6 means an operator cancelled it — the turn is dispatched with the run as its predecessor. The chat records nothing for the withdrawn proposal and shows only that it was discarded ("Vorschlag verworfen"). A run that no longer exists records nothing either, and the turn follows the missing-predecessor rule of item 6;
if the run is QUEUED or RUNNING — the chat's own decision is being carried out — it puts the row back as it was, status and run uuid, through updateIf() from the status it claimed, and answers 409. A failed put-back means the worker's result already landed in the row, so it writes nothing; the result path (applyResult() writes the whole row) wins either way.
Because the first guard of item 6 leaves the chat card as the only way to decide a process run, a run that is not waiting can only be one the chat itself released, so the guarded cancel never withdraws a proposal somebody else approved. Runs without a process pin are not cancelled; queueTurn() keeps leaving them waiting, as today.
Applied means the chat's own approved call wrote everything it planned. The chat decides every card of a process run itself, one write call per turn, and gets the result back through the normal result path: the run result that its approve() returns, or that its worker hands to applyResult(). Its steps carry the segment's trace on every outcome (AgentRunResult), also when it ends in the next suspension. The approved call's step is the first tool step of that result, with no call id needed: every resume segment starts a fresh trace (AgentRunExecutor::executeResume()), and ToolLoopService::resume() records the pending calls — under the one-approval rule exactly one — before it re-enters the loop. Provider call ids could not serve anyway, since some restart per response (the Ollama adapter synthesises call_<index>).
Which reading applies. When the result holds the approved call's first tool step, the chat reads one of the three states below from that result alone; that includes a run that failed after the call ran. "Vorschlag verworfen" applies only to the endings without such a step: a waiting run withdrawn by a new message or cancelled by an operator, a resume refused before the call ran (the four-eyes stop, a failing pin), and a purged run. A stale preview is none of these: the resume re-suspends with a fresh card (ADR-184), and the editor decides again.
Exactly one of the three applies:
Applied. The first tool step's toolIsError (RunStep) is false, and it is followed by the write step (RunStep::KIND_WRITE, ADR-182) of that call with the completeness COMPLETE and without the hook-failure flag (below).
Approved, check the record. The first tool step is OK and one of these holds: the write step carries the hook-failure flag, whatever its completeness; the write step's completeness is not stated; or no write step follows it while the run's outcome is CANCELLED, LEASE_LOST or FAILED. RunTrace::recordToolResult() appends the tool step and lets its listener run before it appends the write step, and the listener AgentRunExecutor installs can throw in between — the cancellation probe, the lease renewal, the audit persist — so the write may have happened without a write step to show it. The ADR relies on this display state and does not require reordering RunTrace::recordToolResult(); appending both steps before any listener runs would narrow the window, and the display state stays correct either way.
Not applied. Every other case: an error on the first tool step, a write step with the completeness PARTIAL and without the hook-failure flag, or a tool that leaves no write step on a run that did not abort, such as a remote tool, which is shown as approved but not applied.
Only the applied state closes an open point; the other two record nothing.
Completeness is stated by every write, not listed. A list of partial branches cannot be complete: a coverage test can only find the flags that are set, and a branch that forgets one looks like success. So ToolResult::withWriteTarget() gains a completeness argument, a string-backed enum (working name WriteCompleteness) with the cases COMPLETE and PARTIAL, and RunTrace::recordToolResult() copies it onto the write step it already records. Each tool decides per return. Known PARTIAL branches today, as examples and not as the definition:
Classes/Service/Tool/Builtin/UpdateContentElementTool.php#in part: %s took. (some planned fields did not take);
Classes/Service/Tool/Builtin/DeleteRecordTool.php#but not completely: (records that should have gone with it are still there);
Classes/Service/Tool/Builtin/MovePageTool.php#Not completely: (translations stayed behind);
Classes/Service/Tool/Builtin/ReplaceFileReferenceTool.php#The translations are not settled: (appended to both of its success returns), and Classes/Service/Tool/Builtin/ReplaceFileReferenceTool.php#did not take on the new reference (a text the replacement was asked to set);
Classes/Service/Tool/Builtin/CreateTranslationDraftTool.php#The text was only PARTLY machine-translated: (only partly translated).
A hook failure after the write is reported beside the completeness, not as it. ToolLoopService prefixes a result with hookFailureNote() when a DataHandler hook of the installation failed after the last write of the call; a failure during the writes is rethrown and ends the call as failed, unless the tool catches it and reads back, as create_translation_draft does for its text write (CreateTranslationDraftTool, the catch around its text write in translateTexts()); the outcome stays the tool's to state (ADR-206). This decision keeps that: the completeness the tool stated is not changed. Today the signal exists only as text in the result, which the chat does not parse, so the write step gains a boolean next to the completeness (working name hookFailedAfterWrite). The loop sets it on the result at the place where it adds the note, from the same ToolDataHandler::takeFailures() call, and RunTrace::recordToolResult() copies it onto the write step. A write step with the flag set is shown as "approved, check the record" and closes no open point, whatever its completeness. ADR-206's rule is not changed, so this decision does not amend it.
Public API. ToolResult is on the frozen @api surface (ADR-182), and a required argument would break every third-party tool that calls withWriteTarget(). The argument therefore defaults to null, meaning "not stated", which the chat shows as "approved, check the record" and which closes no open point. Every builtin passes an explicit value: a coverage test on the pattern of ToolEffectCoverageTest asserts that every withWriteTarget( call under Classes/ passes a completeness argument. The test skips the method's own declaration and docblock in ToolResult.php, and it accepts any expression of the enum type, not only a literal case, because some tools decide in a helper — ReplaceFileReferenceTool decides inside its settleTranslations() step. What it refuses is an omitted argument or an explicit null. The hook-failure flag is set only by the loop, through a new transformation on ToolResult that leaves every other member as it is. Both additions are additive on the frozen surface, and the API-surface snapshot records them.
The completeness must survive ToolResult::withBoundedChannels(), the transformation every tool result passes through in ToolLoopService: it rebuilds the result by constructor position, so the docblock's earlier claim that a property added later is carried by default did not hold, and the completeness and the hook flag would have been dropped there. The rebuild of a successful result passes both on (Classes/Domain/ValueObject/ToolResult.php#$this->writeCompleteness,), an error result carries neither, and ToolResultTest asserts that every member survives.
Nothing is inferred from persisted events, and no other path marks a point applied.
Pure choices use a choice builtin. A choice without a write — which page, which finding first, whether to continue — is a WAITING_FOR_INPUT suspension of a generic builtin that implements RequiresInputInterface with an enumerated schema built from its arguments (ADR-105). It declares no write effect, so the ADR-134 ban does not apply to it, and it never stands in for the approval of a write.
Open points persist in nr_mcp_agent, recorded server-side. Recording must follow the card decision, and a model call cannot guarantee that: the model may skip it or make it for a point the editor applied. So no tool records them. nr_mcp_agent records an open point in exactly one case: the editor presses "Überspringen" on the card. The chat decides that card synchronously and knows the target from the card it rendered. Nothing else records an open point — not a proposal withdrawn by a new message, not a guarded or operator cancel, not the four-eyes stop, not a resume that fails after the claim, not a purge. The chat shows each of those by the precedence rule of the applied state (above): one of the three states where the approved call ran, "Vorschlag verworfen" where it did not. That is a limitation of this decision: a finding lost that way is found again the next time the process runs on the record. The key is the pending write itself: the process skill uid, the subject record, and the target record and field names of the pending call. These are a new obligation, not something the preview provides: ToolPreviewInterface returns prose lines only. An approval-bound write tool gains a method that returns, for a pending call, the target table, uid and field list as structured values (working name pendingTarget()), or no target for a call that creates its record, and nr_llm exposes them next to the ADR-136 preview the card shows, so the chat parses no prose. Two findings on the same record therefore stay apart when they touch different fields, and a proposal is always keyed to what the card showed, not to an earlier highlight. A pending call without a structured target — a remote tool, or a tool that has not implemented the method — records nothing. A write that names a record but no field, such as a move, a publish or a delete, is keyed by the record with an empty field list. A write that creates its record has no uid before it runs, so pendingTarget() returns no target for it, and a skipped create records no open point; this is a limitation of this decision, not an oversight. Only an applied write to the same record and field closes an open point: a card for that record and field that the chat approved and whose call came back as applied (above). The reason variant records nothing, because a new proposal follows. Open points are kept in a table nr_mcp_agent owns; listing them for the model stays a read tool nr_mcp_agent registers, and they are offered again when the process is started on that record. nr_llm stores no open points.
10. The public API
Working names; the shapes are the decision.
Start with a skill. AgentRunRequest gains an optional skill invocation: the skill uid and an optional subject record (table and uid). AgentRuntimeInterface::run() and enqueue() (@api) accept it. At start the runtime checks that the skill is attached to the configuration and enabled, that the request's actor may use the configuration, that the subject record is readable by that actor, for a process that the version is an instruction, that no process pin is held, that the configuration does not set require_second_approver, and that the actor is not a service account.
The checks are actor-scoped. LlmConfigurationService::hasAccess() reads the global backend user, which is wrong for a run the worker executes after enqueue() and for an @api caller acting for someone else. The access check is the existing ConfigurationResolver::actorMayUse(), which takes the request's AiActorContext and which nr_mcp_agent already calls, and the subject-record check runs against the backend user the run executes as (ExecutionIdentity), at start and again at worker pickup.
Continue from a predecessor run. AgentRunRequest gains an optional predecessor run uuid instead of any pin data. The runtime loads that run under the four rules of item 6 (initiator or administrator, terminal when it holds a process pin, refusal when it is missing, stored pins minus a failed one) and derives the pins, the invocation and the subject record from it. A continuation waives exactly the conditions that gate new use: enabled, and for a loaded skill the catalogue conditions (on_demand attachment, not a process). It re-checks the rest on every continuation: the actor's access to the configuration (ConfigurationResolver::actorMayUse()), the subject record's readability for the backend user the run executes as, and the pin rules of item 6: revocation, a failing snapshot, an orphaned record or a provenance drop stops the run, and a detached skill or a changed configuration drops the pin with a notice. A request that names a new invocation and a predecessor is a new start for that invocation and a continuation for the rest. An invocation whose skill uid a derived pin already names is not a new start: it is the idempotent case of item 4, so a chat that passes the conversation's process skill as an invocation on every turn does not trip the "no process pin is held" check. Passed as a forced skill instead, a process skill is skipped with a notice, not refused, unless a held pin already names it (item 6). Inside one run, approve() and submitInput() continue as today.
Catalogue for slash commands. An @api service returns the skills an actor may invoke on a configuration — uid, identifier, name, description, load mode, whether it is a process and whether its current version is an instruction — filtered by attachment, enabled, the actor's access to the configuration (the actor-scoped check above), and optionally to process skills only. A backend AJAX route serves the same list as JSON for the slash-command menu. Descriptions of non-instruction skills are flagged as untrusted text for the consumer to escape.
Guarded cancel and pending-write facts. AgentRuntimeInterface gains a cancel that succeeds only from WAITING_FOR_APPROVAL or WAITING_FOR_INPUT and returns whether it won, together with the run's status after the attempt (working name cancelIfWaiting()), under the initiator-or-administrator rule of item 6. It returns nothing about the proposal it withdrew, and nr_llm keeps no pending target after a run leaves the waiting state. Today's AgentRunPersister::cancel() also ends a queued or running run and stays the operator's tool. A run's status exposes whether it holds a process pin and, while it waits for approval, the pending write's structured target from the write tool's pendingTarget() (item 9), which is what the chat renders on the card.
Process runs refuse other deciders. ResumeCoordinator::approve() and submitInput() refuse an actor who is not the initiator of a run holding a process pin; approve() then checks an unreadable state and then the four-eyes pin rule, before its ADR-172 gate. AgentRunController (approveAction, submitInputAction) and ToolPlaygroundController (resumeAction(), submitInputAction()) refuse such a run server-side, and the inbox shows it read-only (item 6).
Approval decision. ApprovalDecision gains the optional ApprovalDenialReason of item 9; the rendered tokens sit on the @api ToolLoopServiceInterface with the existing decided_by constants, so the API-surface snapshot guards them.
Builtins. nr_llm ships the load tool, the progress and highlight builtins and the choice builtin; nr_mcp_agent ships the read tool that lists open points. Until nr_llm ships progress and highlight, nr_mcp_agent pull request #201 provides them as its own tools declaring READ_ONLY; the one-approval rule of item 9 treats them like any read. Their only write is the chat's own run-state bookkeeping, no TYPO3 record. That highlight accepts any content element of the conversation's page the user may see, which is wider than the registered targets of item 9; the narrower rule applies once nr_llm ships the builtin.
11. Tasks keep their one-shot contract
A task stays one prompt and one answer. Process skills run in agent runs. No workflow engine, step table or state machine is added.
Considered alternatives
Keep the untrusted frame for every skill and add a separate "tour" record. A new record type would hold the steps and be composed as instructions. Rejected: it creates a second kind of reusable prose next to skills, with its own source, review and audit, and it still needs the approval, loading and pinning of this decision for that record.
A trust flag without version binding. Mark a skill or a source as trusted once. Rejected: the next sync or the next backend edit would turn new text into instructions without anyone reading it. ADR-035 already disables an enabled skill whose body changed, for the same reason; an instruction is a stronger privilege than being enabled.
A checksum over the body alone. Today's value. Rejected: the frontmatter carries the tool allow-list, the section heading and the catalogue text, and an upstream change to it would keep a skill approved and enabled.
A versioned value in the existing checksum column. Rejected: the column is varchar(64), and the signed manifest and the legacy check still need the body-only hash. A separate column keeps both meanings.
A revision table with every version. Rejected in favour of the approval snapshot: only approved versions are ever composed as instructions or pinned, and the snapshot holds exactly those. Backend edits are in sys_history. Unapproved synced versions are not kept, as today.
Checking the provenance threshold once, at approval. Simpler on paper. Rejected: a source downgraded after approval would keep instructing, and the check at compose and resume is one comparison per source.
Limiting revocation to new runs. Rejected; see item 6.
Pins carried in the request. The continuation request listing the pins it holds, as the second version of this record had it. Rejected: an @api caller could hand in any approved version, unattached, disabled, a process without its invocation, or an older digest. nr_llm derives the pins from the predecessor run instead.
Releasing a process run in the Agent Runs inbox. The earlier versions of this record let an inbox release reach the chat through a hand-back, and every review round since found a new defect in that path: the hand-back missed new messages, a cancel raced with the release, several approvals in one run were told apart wrongly, and open points under four-eyes never closed. Rejected: process runs are decided only on the chat card, and four-eyes tours are left to a later decision.
Progress and highlight only in an earlier turn. Keeping ADR-084's suspend-before-any-call rule and asking the process skill to report progress in a turn of its own. Rejected: it depends on the model following prose, and the decision moves process state into tools precisely to avoid that.
Loads that last one turn. Defining a load as valid for the run that made it, so the model loads again on every chat turn. Rejected: the model would re-load at whatever digest is current, so a tour could change version mid-conversation, and every turn would pay the load again.
Admitting a section only with every current turn kept. The first version of this record. Rejected: every chat turn is a new run carrying the whole history, so a long tour would fail permanently once its history passed the headroom, although fit() could still send it.
A denial reason per call. Letting one card carry a reason for each of several write calls. Rejected for process runs: an approval decides the whole turn (ADR-132), and one proposal per card is also what the editor reads. One write per turn keeps the existing turn semantics.
Let the model load any skill, trusted or not. Simpler, and the usual shape of skill systems. Rejected: the model reads page content, and a page could ask it to load a skill the editor never chose. For an untrusted skill that is fenced text and mostly cost; for a process it would start a flow by content injection.
Deferring model loading, the catalogue and load modes. Suggested to keep the first version small. Rejected by the product requirement: a skill must be reachable both by slash command and by the model matching its description.
Reply buttons as an input pause, followed by the write approval. Rejected: two pauses for one decision, and the variant that collects the answer in the approval pause is what ADR-134 bans. Approve-means-apply with an enumerated denial reason is one pause.
Admin-only TCA for snippets, or a mark on the record. Rejected; see item 8.
A workflow engine. Steps, transitions and state as data, with the model filling in the text. Rejected: it is a new runtime next to the agent loop, and every process would need a schema change rather than a text change, which is what the product owner asked to avoid. The run's existing suspensions (ADR-105, ADR-134) cover the interaction points.
One extension per process. Rejected by the product owner: every process would need a release and a deployment, and editors could not adjust one.
Processes as prompt snippets. Snippets are always on, unversioned and unapproved. Rejected for processes; kept for shared context (item 8).
Consequences
● Editors and administrators write a process as a skill in the backend, see its history in sys_history and the diff against the last approved version, and an approved version guides the chat without a release.
● The instruction privilege is bound to a digest over everything the model reads of a version and to the source that vouches for it. A changed body or frontmatter, from a sync or an edit, or a move to another source, loses it until someone with the approval permission approves again, and every approval and revocation is in the append-only audit.
● A conversation cannot silently continue with an instruction that was revoked, detached or replaced: every instruction section is pinned, derived by nr_llm from the predecessor run, and checked at start, pickup, resume and continuation. No caller can hand a pin in.
● A loaded skill cannot widen the tool allow-list, a backend author cannot widen it without an approval, and a resumed run cannot gain tools through the skill allow-list.
● Instruction sections are no longer cut from the tail and are admitted against the smallest send fit() can reach, so a section that does not fit is refused visibly and a long tour does not strand on the budget. A tour whose last run was purged by retention cannot be continued; the editor is told to start a new conversation.
● Marked snippets, such as an editorial profile, reach the chat on configurations that opt in, and only in the text an administrator marked.
◐ The governance profiles gain an expectation for the new threshold. Proposed: verified for local_only, controlled_cloud and development, first_party for enterprise_strict. skills.minTrustLevel keeps its default and its profile values.
◐ Upgrade note. What changes on an existing installation, and what does not:
No approval exists after the upgrade, so every skill keeps the fenced frame. Existing attachments migrate to always, so no catalogue and no load tool appear on existing configurations.
The snippet path changes nothing until an administrator marks a snippet and opts a configuration in.
Legacy rows keep verifying against body_checksum until the sync or the upgrade wizard writes version_digest. The first sync compares the new-form digest of the stored fields with the incoming one, so an unchanged skill is not disabled and a frontmatter change is caught. The wizard disables and audits a synced row whose stored name, description or allowed_tools no longer match its raw_frontmatter.
After the migration, an upstream change to a skill's frontmatter alone disables an enabled skill, which today it does not. Expect more re-reviews for sources that change their frontmatter.
Forced skills' allowed-tools now count for the run they are forced onto; today they count for nothing. The change goes both ways: when the configuration's skills declare nothing, a declaring forced skill narrows the run from every tool to its list; when they declare a list, the forced skill's tools are added to it. Forcing is an administrator's act in the playground, so the widening follows the rule that attachment grants tools.
A backend edit of a synced skill's name or description now fails the integrity check, like a body edit; FormEngine shows those fields read-only for synced sources.
Eight fields gain exclude => true (item 3). A non-admin group that was granted tables_modify on the skill tables loses write access to them until an administrator grants the fields; the extension ships those tables in an admin-only module (ADR-035 item 7), so no shipped surface changes.
A process run that requests two approval-bound calls in one turn gets an error for the second; its reads execute before the card appears, and any other call gets an error and does not run. Outside process runs nothing changes.
In a process tour, a new chat message while a card or an input pause waits withdraws it: the waiting run is cancelled and the proposal shown as discarded, with no open point. Only "Überspringen" on the card records an open point. Runs of ordinary chats, without a process pin, are not cancelled and stay releasable in the inbox, as today.
Process runs appear in the Agent Runs inbox read-only and cannot be decided there, by administrators neither.
A process skill cannot be started on a configuration with require_second_approver set.
A backend skill that was never approved contributes a declared empty tool list. Attached to a configuration whose other skills declare nothing, it turns off every tool except the load tool until a version is approved. That is the price of never letting unreviewed text grant or lift a restriction.
✕ An approved skill is an instruction with the acting user's reach. It can steer which tools the model calls and with what arguments, within the run's allow-list and the user's TYPO3 permissions. The controls that bound it are the approval of the digest, the allow-list fixed at run start and the global tool state, and the write approval of ADR-134, which no skill can switch off. Approving a version is therefore a privileged act and gets its own permission.
✕ Content can still ask the model to load a skill. The catalogue lists only approved instruction skills that are not processes, and the load tool re-checks each at load time, so the worst outcome is approved text in the wrong place, not foreign text as instructions, and no change to the tool allow-list. Message role remains defence in depth, not a trust boundary (ADR-036): an untrusted, fenced skill can still influence output, as today.
✕ A signed manifest vouches for the body only. The frontmatter of a signed source is covered by the digest and the approval, not by upstream's signature.
✕ The work spans nr_llm (digest column and migration, compose path, approval, load tool, run-start allow-list, pins and continuation, budget charge, snippet path, denial reason, one-approval rule, guarded cancel, the process-run decider and four-eyes guards, pendingTarget(), the write completeness on every write tool and the loop-level hook-failure flag on the write step, progress, highlight and choice builtins, catalogue API, actor-scoped access check) and nr_mcp_agent (slash commands, the approval card's three answers, the input pause, naming the predecessor run, the withdraw order in queueTurn(), progress, highlight, open-point table and listing tool). The process use case works only when both ship.
Open questions
The approval permission. ADR-117 withdrew nr_llm's own capability checkboxes, and ADR-169 routes record management through TYPO3's permission model. Which mechanism carries "may approve a skill version" is open; until it is decided, administrators only.
Who may author backend skills. ADR-035 item 7 keeps skills in an admin-only module, and ADR-169 keeps tx_nrllm_skill out of non-admin management because the sync writes it. Backend-authored skills are written by people, so that has to be revisited for the new source. The fields that must not travel with tables_modify — on the skill and on its source — are the eight that item 3 excludes; whether more belong there is part of that revisit.
Names. skills.instructionTrustLevel, version_digest, the process frontmatter key, the load modes, the snippet mark, the configuration opt-in, ApprovalDenialReason, cancelIfWaiting(), pendingTarget(), WriteCompleteness and hookFailedAfterWrite are working names.
Catalogue size. Whether the catalogue needs its own byte cap, or the admission rule of item 7 covers it.
Headroom. Whether 10 % of the budget is the right reserve, measured on the first processes that run.
Flipping existing attachments to on_demand. Whether and when existing always attachments move to on_demand, which changes what an existing configuration sends.
Four-eyes tours. Whether and how a process can run on a configuration that requires a second approver: the second person would have to decide on a surface other than the chat card, which this decision forbids for process runs.
Process completion without a report. A tour the model abandons without a completion report keeps its process pin until the conversation ends or the configuration changes. Whether the chat needs an explicit "end process" action is open.