Repository navigation
Description
Overview
A routed turn whose matched skills all link to their schemas is held to an offered set of types (#3419, ADR-038's 2026-10-02 amendment). The hold reads a call's type argument, so it covers create_node, search_nodes, resolve_query and update_schema. update_node takes a node id and no type, so it is not held: on a held turn it can change a node of any type the model has an id for.
The node's type is knowable. The id resolves to a node, and the node has a type. This issue holds update_node by looking that type up at dispatch.
Problem Statement
Tool::existing_type_parameter(packages/agent/src/local_agent/tools.rs) returnsNoneforupdate_node, with the note "it takes a node id, and the node's type is whatever that node already is".tools::off_menu_typetherefore never refuses it.- The model can reach an id outside the offered set without a held tool: from
search_semantic, fromget_node, fromget_related_nodes, from the MENTIONED ENTITIES block, or from an earlier turn of the chat. - So a turn held to, say,
warranty_claimcan still rewrite acompanyor ataskthroughupdate_node. The original issue listedupdate_nodeamong the tools to hold; it was left out only because there is no argument to put anenumon. - The schema decision record has the same blind spot.
decisions::selected_schemareadsupdate_node's arguments, finds no type, and records no selection. The turn loop'sselection_heldthen has to special-case it so the record is not markedenforced.
Proposed Solution
- A tool can name the node it acts on. Add
Tool::held_node_id_parameter(self) -> Option<&'static str>besideexisting_type_parameter, as an exhaustive match so a new tool forces the decision. It returnsSome("id")forupdate_nodeandNonefor every other tool, eachNonefor a stated reason (see Non-Goals for the id-based tools left out). - The executor can say what type a node is. Add
async fn node_type(&self, id: &str) -> Option<String>toAgentToolExecutor(packages/agent/src/agent_types.rs), defaulting toNone.GraphToolExecutorimplements it with one read through the node service.Nonemeans the node could not be found or read. - Dispatch refuses an
update_nodeon a node outside the set. In the guard chain inagent_loop.rs, next to the existing off-menu branch: on a turn with an offered set, resolve the call's node id, look up its type, and refuse when the type is not in the set.- The set already contains the linked types' subtypes, so the test is plain membership.
- A node that cannot be found (
None) is not refused. The call goes to the executor, which reports its own not-found error. - Read the id the way the executor will:
AgentUpdateNodeParamsacceptsnode_idas an alias ofid, and ids arrive asnodespace://URIs (tools::strip_node_uri). A check that reads onlyidis bypassed bynode_id. - No lookup happens on a turn without an offered set.
- The refusal names the node's type and the allowed ids. It reuses the
type_not_offerederror code andallowed_types, with its own message: this node is a<type>, this request covers only<set>, and nothing was changed. It must not tell the model to "re-send with one of them", since a node's type cannot be chosen. It ends withroute_clarifywhen that tool is on the turn's surface and "say so in your reply" when it is not, as the existing refusal does. - The log line and the decision record follow.
Tool executedalready carriestype_refusedandoff_menu_ran. Both must be right forupdate_node:type_refused=trueon the refusal, andoff_menu_ranworked out from the looked-up type and from whether the call was dispatched.- On a held turn, a round whose first call is
update_noderecords the node's type as the schema decision's selection, sooff_menuandenforcedmean the same thing for it as for the other held tools. Theselection_heldspecial case then no longer needs to excludeupdate_node.
- No tool-schema change. There is no parameter to put an
enumon, so the model learns the limit only from the refusal. The tool definitions stay byte-identical.
Architecture Impact
- ADR-038 (
../nodespace-docs/decisions/038-skill-mediated-tool-routing.md), amendment dated 2026-10-02, "Tools the set does not hold": it saysupdate_node"takes a node id and names no type: the node's type is whatever that node already is". This issue extends that amendment:update_nodebecomes held, by lookup. Companion docs change required: edit that paragraph and the "At dispatch" bullet to describe the lookup, and add the consequence below. ../nodespace-docs/components/seeded-nodes.md, "Routed turns are held to the linked set": the "In the tool schema" bullet saysupdate_node"names no type". Update it to say it is held at dispatch by the target node's type.- ADR-064 (
decisions/064-instruction-channel-doctrine.md): consistent. The refusal is a tool result. There is no schema channel available for this constraint, which the ADR amendment should say. - ADR-056 (
decisions/056-gemma-4-e4b-locked-native-model.md): unaffected.
Consequence to record in the ADR: the check uses the node's own type. A held turn can then no longer update a text child under one of its records (a note in a claim's body), unless a skill links to text. A skill that edits body content links to the content types it edits.
Acceptance Criteria
-
Tool::held_node_id_parameterexists as an exhaustive match; onlyupdate_nodereturns a parameter (unit test overTool::ALL, and a test that the parameter is declared in the tool's schema) -
AgentToolExecutor::node_typeexists with aNonedefault, andGraphToolExecutorreturns the stored type for an existing node andNonefor a missing one (test) - On a held turn,
update_nodeon a node whose type is outside the set is not executed, and the result carriestype_not_offered, the allowed ids and the node's type (test asserting the executor was not called) - On a held turn,
update_nodeon a node whose type is in the set runs, including a node of a subtype of a linked type (test) - The id is read as the executor reads it:
id, thenode_idalias, and anodespace://URI all resolve to the same node (test per form) - A node id that resolves to nothing is not refused for its type; the executor's own error is returned (test)
- On a turn without an offered set (unlinked, mixed, unrouted, fail-open, routing disabled) no lookup is made and
update_nodebehaves as today (test assertingnode_typewas never called) - The full composed refusal message is pinned, with and without
route_clarifyon the surface, and does not offer a re-send on another type -
Tool executedlogstype_refused=true off_menu_ran=falsefor the refusal, andoff_menu_ran=truewhen the refusal branch is disabled (mutation-checked, as for the argument-based hold) - On a held turn, the schema decision for a round opening with
update_noderecords the node's type as its selection and isenforced - The decision fixture gains a scenario: a held turn asked to change a record of a type outside the set, scored as held, nothing off-menu run and the record unchanged
-
eval:decisionson the locked model, before and after, one rep each on a purged database: the twelve unheld scenarios keep their verdicts, and the new scenario passes after. Results posted on this issue - ADR-038's amendment and
seeded-nodes.mdare updated and committed to nodespace-docs -
bun run test:changedpasses;bun run quality:fixclean
Technical Specifications
Reference Files
- Dispatch guard chain:
packages/agent/src/local_agent/agent_loop.rs. The existing branch is the one callingsuper::tools::off_menu_type;offered_typesis computed once per turn above it;off_menu_type_refused_resultbuilds the refusal;dispatchedandoff_menu_ranfeed theTool executedline;selection_heldgatesenforced. - Per-tool declarations:
packages/agent/src/local_agent/tools.rs.Tool::existing_type_parameter,existing_type_parameter_tool,off_menu_type,AgentUpdateNodeParams(thenode_idalias),strip_node_uri,exec_update_node. - Executor trait:
packages/agent/src/agent_types.rs,AgentToolExecutor.task_status_valuesanduser_type_namesare the pattern for a narrow, defaulted read method. - Offered set:
packages/agent/src/local_agent/routing.rs,offered_types. - Decision record:
packages/agent/src/local_agent/decisions.rs,selected_schema,schema_scoped,DecisionRecord::enforced. - Tests to extend: the "Offered types" block in
agent_loop.rs's tests (type_naming_tools,billing_candidate,run_scripted_turn,RoutingToolExecutor::executed_handle,turn_log). - Eval fixture:
scripts/eval/fixtures/decisions.ts(seedLinkedSkills,assertHeldToOfferedTypes,held-off-menu-type),scripts/aichat.ts(thetype_refused/off_menu_ranscrape).
Implementation Notes
- The lookup needs an
awaitinside the guard chain, which is already async. Do it only after the cheaper guards that would stop the call anyway (theroute_clarifysupersede and the cross-turn duplicate), where the existing off-menu branch sits. off_menu_ranis computed at the log site from the call's arguments today. Forupdate_nodeit needs the looked-up type. Carry the type resolved at dispatch to the log site instead of looking it up twice.- The decision record is written at the top of the round, before dispatch. Recording the node's type there means one lookup for the round's first call, on held turns only.
- For the eval scenario, the seeded
Northwind Tradingcompany is a record outside the linked set that the turn can resolve by name, and the three linkedwarranty_claimskills already hold the turn. The request has to stay in the linked skills' vocabulary so retrieval still leads with them. - Check the fixture's age before the eval run, and that no other eval is using the machine.
Non-Goals
- The other tools that act on a node by id.
update_task_status,delete_node,create_relationship(two ids),get_node,get_related_nodesand the conflict tools stay unheld. Each gets an explicitNonearm inheld_node_id_parametersaying so. Holding reads would stop a held turn following a relationship to a related record, anddelete_nodealready has its own score bar and confirmation. Widening the hold to other writes is a separate decision. - Allowing a node by its ancestry. A
textchild under an offered record is judged by its own type. - Any change to when a turn is held, or to what the offered set contains.
- A tool-schema or prompt change. Nothing model-facing changes except the refusal's text.
Dependencies
- Hold a routed turn's node_type to the schemas its skills link to #3419 (the offered set, the argument-based hold,
type_refusedandoff_menu_ran)
Activity
malibio commentedon Oct 3, 2026
Baseline — Frontend: 309 test files passed, 5606 tests passed
malibio commentedon Oct 3, 2026
eval:decisionsbefore and after (gemma-4-e4b-q4km, n_ctx 32768, one rep each, purged database, isolatedNODESPACE_HOME)b1d0e287daemon)held-off-menu-node(new)Standing failures in both runs:
outcome-list-types,skill-instance-request.The new scenario. A held turn (three skills linked to
warranty_claim) asked to change the signed date on the seededcompany_sold_torecord:update_node {"field_values":{"filed_date":"2025-05-02"},"id":<Northwind>}ran. It wrote a warranty-claim field onto the company, and the reply said "The signing date for Northwind Trading has been updated to May 2nd, 2025."type_refused=true off_menu_ran=false, result{"error":"type_not_offered","allowed_types":["warranty_claim"],"node_type":"company_sold_to", ...}. The reply: "I was unable to update the date for Northwind Trading because it is a company_sold_to node, and I can only make changes to nodes of type "Warranty Claim"." The schema decision for that round recordedselected: company_sold_to, off_menu: true, enforced: true.Two things worth knowing about these runs
update_node, and passed on the baseline daemon too. With an open prior turn that looks the company up, the model sends the call. The fixture comment records this.route_clarify, though the message names it and it was on the surface.Hold update_node on a held turn to the offered types, by the target n…