Your prompt cannot tell a missing input from a real one
Two ablations over the same six declared inputs. Take one out of the file and the compiler refuses. Take the same one out of the bag your host sends and it builds clean, twice to a hash you have already archived under a different answer.
TL;DR A declared input is load-bearing inside the file and unenforced outside it: deleting a $REQUIRE variable line is blocking in five of six cases (and the sixth deletion raises the grade), while dropping the same key from the bag the host sends is silent in six of six, exit 0 every time. Two of those omissions produce an artifact byte-identical to a real answer, so the hash you archive cannot tell a bound value from a missing one. The only thing in the toolchain that can is build --bundle, whose inputs list is the contract to diff against your host.
Every prompt in this series that varies by customer does it the same way: declare what the host must supply, then branch on it. That is the right shape. What nobody measures is how much of it is actually held up by anything, so this is a leave-one-out over the declarations rather than the rules, run twice, from both ends of the contract.
§1Six inputs, and the one already condemned
The subject is an ordinary care prompt for a broadband provider. Six declared inputs, and each one is referenced by a different mechanism on purpose: a prose pointer, a $SWITCH, a numeric $IF, a $DEFINE, a section guard, and nothing at all.
<$CONFIG> $REQUIRE variable agent_name: string $REQUIRE variable channel: one of voice, chat, email $REQUIRE variable open_cases: integer $REQUIRE variable region: one of uk, ie $REQUIRE variable verified: boolean $REQUIRE variable tier: one of silver, bronze </$CONFIG> $DEFINE word Ofcom $DEFINE word ComReg // the Irish desk answers to a different regulator $DEFINE eu AS @{region} is equal to ie <role> @@ persona: the host fills the name from the rota You are @{agent_name}, a care agent for a broadband provider. </role> <constraints> @@ brevity: three sentences keeps a care reply scannable - You MUST keep every reply to at most 3 sentences. @@ one_ask: a customer answers one question at a time - You MUST ask at most 1 question in each reply. </constraints> <callback> @@ callback: the promise the channel can actually keep $SWITCH ON @{channel} - voice:: You MUST return a call within 15 minutes. - chat:: You MUST answer in one message. - email:: You MUST reply within 4 hours. </callback> <workload> @@ case_number: a second open case makes the reference load-bearing $IF @{open_cases} is at least 2: You MUST name a case number in every reply. $ELSE: You MUST open a case before you close a contact. </workload> <regulator> @@ complaints: naming the wrong regulator is a reportable error $IF @{eu}: You MUST name ComReg as a complaints body. $ELSE: You MUST name Ofcom as a complaints body. </regulator> <escalation when=@{verified}> @@ handover: only a verified caller gets a warm transfer - You MUST transfer a customer to a human agent on request. </escalation>
$ npx typeglish check care.tg care.tg:7:3 warn structure/unused-import Required variable "tier" is never used. care.tg:7:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. ✓ 1 file — 0 error, 2 warning, 0 info $ npx typeglish score care.tg care.tg — B (85/100) proven errors: none tiers: base+z3+spell planes runtime 91 (what the model reads) · hygiene 67 (source only) facets enforceability 81 x.21 · hardness 100 x.12 · directness 99 x.08 · consistency 84 x.17 structure 84 x.12 (hygiene) · annotation 50 x.12 (hygiene) · style 100 x.08 · security 100 x.08 $ npx typeglish check care.tg --strict care.tg:7:3 error structure/unused-import Required variable "tier" is never used. care.tg:7:21 error clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. ✗ 1 file — 2 error, 0 warning, 0 info
tier, and --strict makes them blocking, so the dead input is the one thing here a CI gate already refuses. Worth noticing before the ablation starts: the checker has answered the question "which of these do I still need?" for one input out of six, in advance, for free.§2Leave one declaration out
Six variants in drop/. Each is care.tg with exactly one $REQUIRE variable line deleted and nothing else touched, which is the edit that happens when somebody tidies a config block they were told is stale.
$ npx typeglish check drop drop/agent_name.tg:6:3 warn structure/unused-import Required variable "tier" is never used. drop/agent_name.tg:6:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. drop/agent_name.tg:17:11 error structure/undefined-ref Dangling pointer — @{agent_name} names nothing. Declare it with "$REQUIRE variable agent_name" (a runtime variable), or "agent_name IS …" (a fixed value). drop/channel.tg:6:3 warn structure/unused-import Required variable "tier" is never used. drop/channel.tg:6:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. drop/channel.tg:29:14 error structure/undefined-ref Dangling pointer — @{channel} names nothing. Declare it with "$REQUIRE variable channel" (a runtime variable), or "channel IS …" (a fixed value). drop/channel.tg:29:3 error structure/undeclared-input This $SWITCH reads @{channel}, which isn't a declared input or $DEFINE. drop/open_cases.tg:6:3 warn structure/unused-import Required variable "tier" is never used. drop/open_cases.tg:6:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. drop/open_cases.tg:37:7 error structure/undefined-ref Dangling pointer — @{open_cases} names nothing. Declare it with "$REQUIRE variable open_cases" (a runtime variable), or "open_cases IS …" (a fixed value). drop/open_cases.tg:37:3 error structure/undeclared-input This arm's condition reads @{open_cases}, which isn't a declared input or $DEFINE. drop/region.tg:6:3 warn structure/unused-import Required variable "tier" is never used. drop/region.tg:6:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. drop/region.tg:13:15 error structure/undefined-ref Dangling pointer — @{region} names nothing. Declare it with "$REQUIRE variable region" (a runtime variable), or "region IS …" (a fixed value). drop/region.tg:13:1 error structure/undeclared-input $DEFINE eu reads @{region}, which isn't a declared input or $DEFINE. drop/verified.tg:6:3 warn structure/unused-import Required variable "tier" is never used. drop/verified.tg:6:21 warn clarity/unused-variable $REQUIRE variable "tier" is never used — no @{tier} reference fills it. Remove it, or reference it in the prompt. drop/verified.tg:51:18 error structure/undefined-ref Dangling pointer — @{verified} names nothing. Declare it with "$REQUIRE variable verified" (a runtime variable), or "verified IS …" (a fixed value). drop/verified.tg:51:1 error structure/undeclared-input Guard on <escalation> reads @{verified}, which isn't a declared input or $DEFINE. ✗ 6 files — 9 error, 10 warning, 0 info program: 6 independent files — no $IMPORT compositions
tier, repeated in the five files that still declare it; drop/tier.tg contributes nothing to this report at all. Every live deletion is blocking, and four of the five report twice: once as a dangling pointer, once at the construct that reads it. The prose pointer reports once, because there is no chain or guard behind it to also complain.The $DEFINE row is the one worth reading twice. Deleting region reports at line 13, the $DEFINE eu line, and nowhere else: the two $IF @{eu} arms further down are silent, because eu itself is still declared and still resolves. One hop, one report, which is the tidy behaviour and also the one that makes the finding easy to fix in the wrong place.
<role> You are a care agent for a broadband provider. </role> <escalation when=@{verified}> - You MUST transfer a customer to a human agent on request. </escalation>
Now the scores, which say the same thing in one column.
drop/agent_name.tg — F (81/100) proven errors — grade capped at F tiers: base+z3+spell drop/channel.tg — F (77/100) proven errors — grade capped at F tiers: base+z3+spell drop/open_cases.tg — F (77/100) proven errors — grade capped at F tiers: base+z3+spell drop/region.tg — F (77/100) proven errors — grade capped at F tiers: base+z3+spell drop/tier.tg — A (90/100) proven errors: none tiers: base+z3+spell drop/verified.tg — F (77/100) proven errors — grade capped at F tiers: base+z3+spell
drop/tier.tg is 0 error, 0 warning, 0 info with consistency and structure both restored to 100, five points above the baseline it was cut from. Deleting the orphan is the only edit in the set that pays.So the first column is unanimous and slightly boring. Inside the file, a declaration is exactly as load-bearing as the reference pointing at it, the checker knows which is which, and it will tell you unprompted. Which is why the second column is worth running.
§3Leave one binding out
A declared input has two ends. The file end is what §2 ablated. The other end is a key in a JSON object somebody assembles in a different repository, on a different release cadence, usually by a different team, and that is the end that actually breaks: a field gets renamed, a feature flag retires a lookup, a new caller path forgets to populate the record.
Same six variables, same leave-one-out, on the bag instead of the file. Start from a full bag where every branch has been deliberately steered away from its default: region is ie, verified is false, channel is email, open_cases is 3.
$ npx typeglish build care.tg --vars '{"agent_name":"Dana","channel":"email","open_cases":3,"region":"ie","verified":false,"tier":"silver"}'
✓ built bind/full/care.txt ← care.tg (f2c0b12014e4, full)
# then the same command with exactly one key removed
✓ built bind/no_agent_name/care.txt ← care.tg (f2c0b12014e4, full)
✓ built bind/no_channel/care.txt ← care.tg (d4245df0c6d9, full)
✓ built bind/no_open_cases/care.txt ← care.tg (54218a0ea899, full)
✓ built bind/no_region/care.txt ← care.tg (fff9f6d7fc43, full)
✓ built bind/no_verified/care.txt ← care.tg (a0fa4f73f65a, full)
✓ built bind/no_tier/care.txt ← care.tg (f2c0b12014e4, full)
--strict for this and no flag that makes it refuse: $REQUIRE variable declares the contract, and nothing enforces the half of it that lives outside the file.The hashes are the interesting part, and they split three ways. Two of the six are byte-identical to the full build. Two produce something visibly unresolved. And two produce a different, complete, entirely plausible prompt.
=== no_agent_name === (no difference) === no_tier === (no difference) === no_channel === 11c11 < You MUST reply within 4 hours. --- > If channel is voice: You MUST return a call within 15 minutes. If channel is chat: You MUST answer in one message. If channel is email: You MUST reply within 4 hours. === no_open_cases === 15c15,18 < You MUST name a case number in every reply. --- > If open_cases is at least 2: > You MUST name a case number in every reply. > Otherwise: > You MUST open a case before you close a contact. === no_region === 19c19 < You MUST name ComReg as a complaints body. --- > You MUST name Ofcom as a complaints body. === no_verified === 20a21,24 > > <escalation> > - You MUST transfer a customer to a human agent on request. >
The first two rows have a cause worth naming, because it is the one people get wrong when they first reach for --vars. tier is identical because nothing reads it. agent_name is identical because a $REQUIRE variable in prose is a runtime hole rather than a compile-time inline: every build in this post, including the one whose bag bound it to Dana, ships the role line as You are {agent_name}, a care agent for a broadband provider. --vars resolves conditionals; it does not fill prose. That is the same mechanism a property pointer with no compile-time target lowers through, and if you want a value baked in you bind it with a copula instead of declaring it as an input.
The two middle rows are the failure mode this blog has recommended watching for: the unbound template, shipping every arm as prose for the model to weigh. It is bad, and it is legible. Anyone reading the artifact sees an If channel is voice: in a prompt that should have picked one.
§4Missing reads as a value
Which leaves the two that answered anyway. Build the same file across every combination of the two, then across the same combinations with one or both omitted, and read the hashes.
$ npx typeglish build care.tg --vars '...,"region":"uk","verified":true}' ✓ built sweep/care.txt ← care.tg (e22f25e22479, full) $ npx typeglish build care.tg --vars '...,"region":"uk","verified":false}' ✓ built sweep/care.txt ← care.tg (fff9f6d7fc43, full) $ npx typeglish build care.tg --vars '...,"region":"ie","verified":true}' ✓ built sweep/care.txt ← care.tg (a0fa4f73f65a, full) $ npx typeglish build care.tg --vars '...,"region":"ie","verified":false}' ✓ built sweep/care.txt ← care.tg (f2c0b12014e4, full) $ npx typeglish build care.tg --vars '...,"verified":true}' ✓ built sweep/care.txt ← care.tg (e22f25e22479, full) $ npx typeglish build care.tg --vars '...,"verified":false}' ✓ built sweep/care.txt ← care.tg (fff9f6d7fc43, full) $ npx typeglish build care.tg --vars '...,"region":"uk"}' ✓ built sweep/care.txt ← care.tg (e22f25e22479, full) $ npx typeglish build care.tg --vars '...,"region":"ie"}' ✓ built sweep/care.txt ← care.tg (a0fa4f73f65a, full) $ npx typeglish build care.tg --vars '...}' ✓ built sweep/care.txt ← care.tg (e22f25e22479, full)
- A missing
regionis byte-identical toregion: uk. Rows five and six reproduce rows one and two exactly. The$DEFINEreads an input that is not there, evaluates false, and the chain takes its$ELSEarm, which is the arm for the other country. - A missing
verifiedis byte-identical toverified: true. Rows seven and eight reproduce rows one and three. A section guard whose condition cannot be resolved ships the section, with thewhen=attribute stripped, so the artifact does not even record that a guard was there. - Both missing is the all-defaults corner. The last row is
ukandtrue: the domestic regulator and the transfer rule, from a bag containing neither fact.
The hash is a receipt for the artifact. It was never a receipt for the question.
This is the part that should change a habit. Building once per member of a typed domain and archiving the hashes is a good practice and this blog has recommended it, most recently as the evidence pack for a compliance review. It survives this ablation with a hole in it: an artifact built from an incomplete bag is indistinguishable from a correct one, because it is a correct one, for a question nobody asked. Four hashes, two of them reachable two ways.
And nothing else in the loop reaches it either. check and score read the file, which is unchanged and fine. The offline suite cannot help: typeglish test takes --dry, --no-judge and --model, and no --vars, so a case has no binding to vary. Add one to this file and it reports honestly on the rules and says nothing about the bag.
$ npx typeglish test care.tg --dry ✓ care.tg coverage: 2/7 rules exercised · regulator — "I want to make a formal complaint about my broadband. Who do" (not run) ✓ contains "ComReg" ✓ at most 3 sentences ✓ 1 prompt — 0 failed
ComReg, which is the arm that only exists when region is ie. Nothing in the command says which region is being tested, because nothing in the command can.§5What can actually gate this
The gate has to compare two things the prompt repository normally never sees together: what the file declares, and what the host sends. build --bundle writes the first half as JSON, which is the whole reason this is a ten-line check rather than a project.
$ npx typeglish build care.tg --out-dir dist --bundle --vars '...' ✓ built dist/care.txt ← care.tg (f2c0b12014e4, full) $ cat dist/care.agent.json { "config": {}, "tools": [], "inputs": [ { "name": "agent_name", "type": "string" }, { "name": "channel", "type": "enum(voice|chat|email)" }, { "name": "open_cases", "type": "number" }, { "name": "region", "type": "enum(uk|ie)" }, { "name": "verified", "type": "bool" }, { "name": "tier", "type": "enum(silver|bronze)" } ], "defines": [ { "name": "eu", "condition": "@{region} is equal to ie" } ] }
tier is in the list. The bundle reports what the file declares, not what it uses, so a dead input keeps being demanded of the host until somebody acts on the warning from §1.// Every input the prompt declares has to be in the bag the host sends.
import { readFileSync } from 'node:fs';
const [, , bundle, bag] = process.argv;
const declared = JSON.parse(readFileSync(bundle, 'utf8')).inputs.map((i) => i.name);
const sent = Object.keys(JSON.parse(readFileSync(bag, 'utf8')));
let fail = 0;
for (const name of declared) {
const ok = sent.includes(name);
if (!ok) fail = 1;
console.log(`${ok ? 'bound ' : 'UNBOUND '} ${name}`);
}
for (const name of sent) if (!declared.includes(name)) console.log(`EXTRA ${name}`);
process.exit(fail);
EXTRA key is the host sending something the prompt stopped reading, which is the same drift running the other way and the thing that leaves an orphan declaration behind.$ node inputs-gate.mjs dist/care.agent.json host-vars.json bound agent_name bound channel bound open_cases bound region bound verified bound tier exit 0 $ node inputs-gate.mjs dist/care.agent.json host-vars-2.json bound agent_name bound channel bound open_cases UNBOUND region UNBOUND verified bound tier exit 1
§4. This is the only instrument in the loop that separates them.Two supporting habits make the gate honest rather than decorative.
- Keep the bag in version control, next to the prompt. The check needs something to compare against, and a fixture the host team edits when they change the payload is the cheapest version of that. If the bag is assembled dynamically, dump one representative payload at CI time and gate on that.
- Archive the manifest, not the hash. The build manifest records a
varsobject per build, so the row forf2c0b12014e4says exactly which keys were present and what they held. That is the receipt the artifact hash cannot be, and it costs nothing becausebuildwrites it anyway.
{
"source": "care.tg",
"sourceSha256": "467f106f93b2bc47486a18b06c04effd9f6e81873abf617d709da57aef41fdf8",
"artifact": "dist/care.txt",
"artifactSha256": "f2c0b12014e4cdfa8a05ba4d6f374e892d20bcfeb9677562f821523fbbca2e91",
"vars": {
"agent_name": "Dana",
"channel": "email",
"open_cases": 3,
"region": "ie",
"verified": false,
"tier": "silver"
},
"typeglish": "0.9.0",
"checkMode": "full",
"report": { "ok": true, "counts": { "error": 0, "warn": 2, "info": 0 }, "strict": false },
"builtAt": "2026-08-22T09:37:13.891Z"
}
report block carries the two tier warnings forward as well, so the manifest is also where a --strict gate would find them.§6Common questions
- What happens if my host stops sending a variable my system prompt requires?
- Nothing you can see from the prompt side. Across six leave-one-out builds of a six-input care prompt, every one exited 0 with no diagnostic. Two of the six changed nothing in the artifact, two shipped the unresolved conditional as prose the model has to weigh, and two silently resolved to a definite answer: a missing
regionbuilt the Ofcom arm for an Irish customer, and a missingverifiedshipped the escalation section that was gated on verification. The check, the score and the test runner all read the file, and the file is fine. - Does TypeGlish check that a
$REQUIRE variableis actually supplied at build time? - No, and that is the boundary worth knowing.
$REQUIRE variabledeclares the contract; nothing enforces the other side of it.typeglish build --varswith keys missing exits 0 and writes an artifact. What the toolchain does give you is the contract in machine-readable form:build --bundlewrites aninputsarray naming every declared input and its type, and the build manifest records the exactvarsobject each artifact was built from. Comparing those two is a ten-line CI check and it is the only thing in the loop that catches an unbound input. - Why is
@{my_variable}still in the built prompt after I passed it to--vars? - Because a
$REQUIRE variablein prose is a runtime hole, not a compile-time inline. In every build of the prompt in this post, including the one whose bag boundagent_nametoDana, the role line reached the artifact asYou are {agent_name}, a care agent for a broadband provider.--varsresolves conditionals, so a$IFchain or a$SWITCHblock collapses to one arm, but a prose pointer stays a brace-wrapped hole for the host to fill. If you want the value baked in, bind it with a copula (agent_name IS Dana) instead of declaring it as an input. - How do I tell whether a declared input in my prompt is still being used?
- The checker tells you without being asked. An input nothing references draws two warnings at once,
structure/unused-importreading Required variable "tier" is never used andclarity/unused-variablenaming the missing@{tier}reference, andcheck --strictturns both into blocking errors. Deleting that declaration was the only one of six deletions that improved the file: 0 error, 0 warning, 0 info andB (85/100)up toA (90/100)withconsistencyandstructureboth back to 100. Every other declaration is load-bearing, and removing it is 1 or 2 blocking errors.
The reason this one is worth a gate rather than a note in a runbook is the direction the error points. Most prompt defects fail towards noise: an unresolved switch ships three contradictory arms, a dangling pointer ships a visible {hole}, a contradiction refuses to build at all. Somebody notices. A missing binding fails towards a confident, fluent, single answer, and it picks that answer by taking whichever branch a false reads into. That is the default for a $DEFINE, the include for a section guard, and neither of them is your policy. There is no facet for it and no exit code for it, because the compiler is being asked about a file and the file is correct. Half your prompt arrives at runtime, and so does half the reason it is wrong: the join is where prompts break, and the join is the one place nothing in the prompt repository can see.