execution-state-preflight
Move the verdict outside the model. Slot-based preconditions for irreversible tool calls — execution reads a verdict record, never a conditional.
Install / Use
claude mcp add Jang-woo-AnnaSoft -- npx -y github:Jang-woo-AnnaSoft/execution-state-preflightIf the server publishes to npm under a different name, use that package instead — check the repo README.
MCP Server
Model Context Protocol server
Quality Score
Category
AutomationSupported Platforms
Skill content
View source on GitHubexecution-state-preflight
A verification layer that runs before an MCP tool call.
Three checklists say what has to be true before a call goes out. Two gates enforce them. Neither gate produces a value or a justification — both only look things up, and either one can stop the call.
This is not a wall in front of your agent. It fills, with a stated source, the blanks that guessing used to fill. Execution is still the goal.
Status: a specification, not a library. createPreflight refuses to build without six injected hooks. See (execution-state-preflight).
Start here. Who Fills In the Form is the argument — why the list of what to check has to sit outside the model, and what changes when it does. design.md is the implementation notes: the structure, the hook contracts, and what this does and does not cover. This README describes the reference skeleton itself.
Read in that order if you are deciding whether this is worth doing. Start here if you already are.
Eight problems
Getting to three checklists and two gates meant working through eight of them.
- Separating execution from verification — and separating who verifies (system / provider / user)
- Verifying conditions, not just values
- The system deciding what counts as unknown, not the model
- Human involvement guaranteed by the structure rather than by good intentions
- Per-field provenance records, as raw material for auditing and for assigning responsibility
- Rules becoming data attached to the tool instead of code, so they change without a deploy
- Failures having names — an instruction gap (the user's instruction was incomplete) and an action definition gap (the model reached for the wrong tool, or for one that doesn't exist yet)
- What can't run now being held rather than discarded
The three checklists
The rules an action needs split by who defines them. This split is the whole design — everything below is machinery for enforcing it.
Fixed checklist — which tool are we picking, and are the execution conditions met (when/case)? Tool-independent, and identical for every execution. In the record these are c1_when_case, c2_user_action_name, c3_provider_action_name.
Provider checklist — required fields, type and format, pre-execution checks, prohibited conditions, extra confirmation conditions. Changes per tool. Splits again on enforceability: inputSchema.required can be gated, while description is prose and can't be, so it's recorded as advisory and passed to the model as context.
User checklist — user intent, current context, execution limits, pre-execution checks, preferences. Changes with the user's environment rather than with the tool. Each item needs a stable id; without one there's no key to reattach an answer to, and the run holds.
The fixed checklist is what Gate 1 asks. The provider and user checklists are what Gate 2 asks.
The two gates
instruction (trust-labeled segments)
│
├─ Gate 1 Fixed checklist → tool_undetermined → ask_user
│ is this the right tool? when does it run?
│
├─ Gate 2 Provider + User checklists → unknown_fields → ask_user
│ where did each value come from? unverified_checklist
│ are the user's conditions verified?
│
└─ both clear → execute → executed
Gate 1 sits above everything the provider supplies. Move it lower and an undetermined tool's required fields and description ride into the gate with it — you'd be validating arguments for a call that shouldn't happen at all.
Gate 1 — the fixed checklist
Tool selection accuracy is never going to hit 100%. Wrong picks are inevitable, so the first job is a structure where a wrong pick doesn't reach execution.
confirmToolNameMatchesIntent compares what the user called the action (c2) against the tool that was selected (c3). Anything other than an explicit { approved: true } stops here — a hook that returns undefined, throws, or omits the field is not approving. Silence is not approval.
{
"schema_version": "1.4",
"action_key": "u_01:clean-up",
"phase": "at_trigger",
"fixed": {
"c1_when_case": "immediate",
"c2_user_action_name": "clean up the old invoices",
"c3_provider_action_name": "records.delete_all"
},
"fields": null,
"advisory_notes": "",
"unknown_count": null,
"gate": {
"kind": "tool_undetermined",
"user_message": "I could not determine which tool to use. Please restate what you want to do.",
"_diag": {
"candidate_tool": "records.delete_all",
"user_action": "clean up the old invoices",
"reason": "scope mismatch: user action is bounded, tool is unbounded"
}
},
"execution_decision": "ask_user",
"reason": "ask_user: tool undetermined (scope mismatch: user action is bounded, tool is unbounded)"
}
Four things in that record are deliberate:
The user message doesn't name the candidate tool. Show someone records.delete_all and the question stops being "what did you want" and becomes "approve this?" — people pick what they're shown. The candidate lives in _diag, which goes to logs and never to the user.
It's ask_user, not hold. An undetermined tool isn't a defect. It's a thing to ask about.
fields is null, not []. Null means no decision was made; [] would mean the lookup ran and came out empty. Same for unknown_count. This is why the caller contract is if (decision !== "execute") and never if (unknown_count > 0) — null > 0 is false, and a not-yet-computed state would sail through.
Re-entry replaces the tool, not the answers. The response to tool_undetermined doesn't go into userAnswers. You swap mcpTool and call again. The skeleton deliberately doesn't read the "user already reselected" flag, because reading it would turn it into a bypass switch; only the name is fixed (input.tool_reselected_by_user) so the adopting system can implement it consistently. Cap the retries — two or three under the same action_key, then hold.
If you have few enough tools to present a list, present it flat. No default selection, no "recommended" marker.
The other half of the fixed checklist is c1_when_case, which decides the phase: immediate runs the rest now, anything else defers it to trigger time. An out-of-enum value holds rather than falling through to immediate — see Values now, conditions at trigger time.
Gate 2 — the provider and user checklists
Everything below is reached only after the tool is determined.
Where each value came from
A validator can't tell an account number the user typed from one the model invented. Worse, a required field is pressure on the model to produce something. So this layer doesn't validate arguments — it looks up where each one came from.
| # | Source | Meaning |
|---|--------|---------|
| 0 | user_answer | answered by the user after an ask_user |
| 1 | instruction | taken from a trusted segment, with a span |
| 2 | pre_set_data | settled earlier through a decision path |
| 3 | measured_data | observed from the environment |
| 4 | prior_state | inherited from a prior executed record |
This is a lookup order, not a ranking by trustworthiness. If an earlier tier has the answer, the value is already decided; if it doesn't, you go down one. All five get checked. All five empty means unknown.
Given an incomplete instruction, unknown is not an error. It's the correct output.
Whether the user's conditions hold
The user checklist is verified in the same run, right before execution, and anything the hook didn't actively confirm comes back unverified. The default hook confirms nothing — it doesn't trust the incoming status either, so an item arriving pre-marked verified still fails.
The gate clears only when both counts are zero.
{
"schema_version": "1.4",
"action_key": "u_01:bank.transfer",
"phase": "at_trigger",
"fixed": {
"c1_when_case": "immediate",
"c2_user_action_name": "send money to my landlord",
"c3_provider_action_name": "bank.transfer"
},
"fields": [
{
"name": "from_account", "value": "1102534471",
"status": "known", "source": "pre_set_data", "origin_source": "pre_set_data",
"resolved_at": "2026-08-11T09:12:03.114Z"
},
{
"name": "amount", "value": 500000,
"status": "known", "source": "instruction", "origin_source": "instruction",
"resolved_at": "2026-08-11T09:12:03.118Z"
},
{
"name": "to_account",
"status": "unknown", "source": null, "origin_source": null,
"resolved_at": "2026-08-11T09:12:03.121Z"
}
],
"advisory_notes": "Transfers are final. Confirm the recipient before calling.",
"user_checklist": [
{ "id": "chk_limit", "description": "within daily transfer limit", "status": "verified", "source": "measured_data" }
],
"unknown_count": 1,
"unverified_checklist_count": 0,
"gate": {
"unknown_fields": [{ "name": "to_account", "note": null }],
"unverified_checklist": []
},
"execution_decision": "ask_user",
"reason": "ask_user: unknown_fields=1, unverified_checklist=0"
}
advisory_notes carries the provider's description. It's recorded and handed to the model, and it is not part of the gate — natural language can't be enforced, so pretending otherwise would put an unverifiable condition in a verifying position.
Three properties hold across the chain:
Values are read, never produced. Whether a condition holds is answered by observation, not by the model's reasoning.
Provenance is not self-reported. A pre-execution step queries the defined source and fills the value in. Leave it to self-reporting and invented values get a source attached too. The model must not manufacture the grounds for its own execution.
The first source is never erased. Tier 4 overwrites source with prior_state but inherits origin_source. Overwrite both and you've opened a laundering path: ask once, execute once, and from then on any value can claim a clean lineage.
Records accumulate under one action_key: ask_user → execute → executed. Only executed becomes a baseline for tier 4 on the next run.
Quick start
const { createPreflight } = require("./execution-state-preflight");
const preflight = createPreflight({
hooks: {
classifyWhenCase, // → "immediate" | "scheduled" | "conditional" | "recurring"
extractUserActionName, // → what the user calls this action
confirmToolNameMatchesIntent, // → { approved, reason }
extractFromInstruction, // → { value, segment_index, span } | undefined
measureFromEnvironment, // → { valid, value } | undefined
buildActionKey, // → opaque string; sets the blast radius of prior_state
},
storage, // { persist, load } — append-only, or preserve `executed` separately
strict: true, // also reject the three non-verifying defaults
});
const state = await preflight.runPreflightAndRecord({
userId: "u_01",
instruction: [
{ text: "send 500,000 won to my landlord", trust: "user" },
{ text: "<forwarded email body>", trust: "untrusted", origin: "gmail:msg_881" },
],
mcpTool,
preSetData,
measured_data,
priorExecutionState,
userChecklist,
});
// Decide on the allow condition, never on a count.
if (state.execution_decision === "execute") {
await preflight.executeIfReady(state, mcpTool, callMcpTool);
} else {
// state.gate says exactly what is missing, and in which shape
}
instruction is an array of trust-labeled segments, not a string. Pass a string and tier 1 dies closed — every field stays unknown. That's deliberate: untru
Truncated for display — read the full file on GitHub.
Related Skills
momen-cursurrules-prompt-file
40.7kCursor rules for building custom frontends with Momen.app as headless BaaS with GraphQL API, actionflows, AI agents, and Stripe integration.
pyspark-etl-best-practices-cursorrules-prompt-file
40.7kCursor rules for PySpark ETL development with code style, joins, window functions, map operations, and Iceberg patterns.
semiotic-react-dataviz-cursorrules-prompt-file
40.7kCursor rules for Semiotic data visualization library with 30+ chart types, MCP server, and AI-assisted chart generation.
Agent-Reach
75.3kGive your AI agent eyes to see the entire internet. Read & search Twitter, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu — one CLI, zero API fees.
