← Tidelines/Deep dives

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.

by TypeGlish team10 min read#deep-dives
Unbound reads as uk.

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.

care.tg - the baseline✓ B (85/100)
<$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>
Six inputs, five references, one deliberate orphan. Nothing here is clever and nothing here is wrong.
tg check + tg score care.tg - output
$ 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
Both warnings are about 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.

tg check drop/ - one declaration missing each✗ 9 error
$ 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
The ten warnings are the same two rows about 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.

mini.tg - the shape on its own✗ 2 error
<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>
Seven lines, two blocking errors. A guard has to resolve at compile time, so the declaration behind it is not decoration.

Now the scores, which say the same thing in one column.

tg score drop/*.tg - one line each
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
Five capped at F, and one that goes up. 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.

tg build care.tg --vars - one key missing each✓ exit 0, six times
$ 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)
Six omissions, six artifacts, six zero exit codes, and not one diagnostic. There is no --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.

diff bind/full/care.txt bind/no_*/care.txt
=== 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.
> 
Read the last two rows as a support manager rather than an engineer. An Irish customer is told to complain to the UK regulator. An unverified caller gets the rule that only exists for verified ones. Both prompts are well formed, both built clean, and neither carries any trace of the value that went missing.

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.

tg build care.tg - four bindings, five omissions
$ 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)
Nine builds, four distinct hashes. The four bound combinations account for all four; the five omissions land exactly on top of them.
  • A missing region is byte-identical to region: uk. Rows five and six reproduce rows one and two exactly. The $DEFINE reads an input that is not there, evaluates false, and the chain takes its $ELSE arm, which is the arm for the other country.
  • A missing verified is byte-identical to verified: true. Rows seven and eight reproduce rows one and three. A section guard whose condition cannot be resolved ships the section, with the when= 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 uk and true: 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.

tg test care.tg --dry - with one case added
$ 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
The assert names 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.

dist/care.agent.json - the declared contract
$ 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" }
  ]
}
Reformatted onto one line per input; the field names and values are as written. Note that 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.
inputs-gate.mjs - the ten lines
// 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);
The second half matters as much as the first. An 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 - before and after the release that dropped two keys
$ 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
The two files differ by the two keys whose absence produced a clean build and a wrong prompt in §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 vars object per build, so the row for f2c0b12014e4 says exactly which keys were present and what they held. That is the receipt the artifact hash cannot be, and it costs nothing because build writes it anyway.
.typeglish/build-manifest.json - the row that knows
{
  "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"
}
One row, one build. The 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 region built the Ofcom arm for an Irish customer, and a missing verified shipped 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 variable is actually supplied at build time?
No, and that is the boundary worth knowing. $REQUIRE variable declares the contract; nothing enforces the other side of it. typeglish build --vars with keys missing exits 0 and writes an artifact. What the toolchain does give you is the contract in machine-readable form: build --bundle writes an inputs array naming every declared input and its type, and the build manifest records the exact vars object 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 variable in prose is a runtime hole, not a compile-time inline. In every build of the prompt in this post, including the one whose bag bound agent_name to Dana, the role line reached the artifact as You are {agent_name}, a care agent for a broadband provider. --vars resolves conditionals, so a $IF chain or a $SWITCH block 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-import reading Required variable "tier" is never used and clarity/unused-variable naming the missing @{tier} reference, and check --strict turns both into blocking errors. Deleting that declaration was the only one of six deletions that improved the file: 0 error, 0 warning, 0 info and B (85/100) up to A (90/100) with consistency and structure both back to 100. Every other declaration is load-bearing, and removing it is 1 or 2 blocking errors.
Field note

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.

∿ washed up Aug 22, 2026 ∿