Bake-off: the number in the prompt vs. the number at runtime
Every number in an agent prompt is a candidate for lifting out into a variable, and the argument for lifting it is always good. This is what it costs, measured.
TL;DR A policy number written as a literal is a bound the prover holds, and a number lifted into a $REQUIRE variable is a hole: the same drift is 2 blocking logic/time-strength errors with within 5 days in the file, and 0 error, 0 warning, 0 info with within @{refund_sla} days, clean under --strict, unmoved by --vars, and the same artifact hash either way. Keep the numbers your rules reason about as literals, and let a typed input pick between literals.
The pitch for lifting a number out of the prompt is the same pitch as every other configuration change, and it is a good one: ops can change the refund window without a deploy, one value serves four channels, and nobody has to grep for the number when it moves. Nobody ever costs the other side. Two files, the same four rules, the same support policy, and one number written two ways.
§1One SLA, two spellings
A chat desk at a furniture retailer. Four things have to be true: refunds settle in five days and the agent says so, a case is opened through the refund tool before anybody confirms anything, and replies stay at three sentences. Route A writes the SLA where a reader can see it.
<$CONFIG> $IMPORT tool issue_refund </$CONFIG> # Role @@ role: chat desk for a furniture retailer, refund timings rail-guarded You are a chat support agent for Wickford Home, a furniture retailer. # Constraints @@ refund_sla: finance settles a card refund in five days and that is what we quote - You MUST issue a refund within 5 days. @@ refund_tool: a refund nobody recorded is a refund nobody makes - You MUST call @[issue_refund] before you confirm a refund. @@ brevity: a chat reply is read on a phone between other tasks - You MUST keep every reply to at most 3 sentences.
Route B fixes it. The SLA becomes a declared input, the rule points at it, and finance can move the number in a config file instead of in the prompt.
<$CONFIG> $IMPORT tool issue_refund $REQUIRE variable refund_sla: integer </$CONFIG> # Role @@ role: chat desk for a furniture retailer, refund timings rail-guarded You are a chat support agent for Wickford Home, a furniture retailer. # Constraints @@ refund_sla: finance settles a card refund in five days and that is what we quote - You MUST issue a refund within @{refund_sla} days. @@ refund_tool: a refund nobody recorded is a refund nobody makes - You MUST call @[issue_refund] before you confirm a refund. @@ brevity: a chat reply is read on a phone between other tasks - You MUST keep every reply to at most 3 sentences.
Both check. The score already disagrees with the review, quietly, by two points.
$ npx typeglish check literal.tg variable.tg
✓ 2 files — 0 error, 0 warning, 0 info
$ npx typeglish score literal.tg
literal.tg — A (98/100) proven errors: none tiers: base+z3
planes runtime 97 (what the model reads) · hygiene 100 (source only)
facets enforceability 90 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
$ npx typeglish score variable.tg
variable.tg — A (96/100) proven errors: none tiers: base+z3
planes runtime 94 (what the model reads) · hygiene 100 (source only)
facets enforceability 80 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
enforceability 90 against enforceability 80, on files that differ by eleven characters. The facet measures whether a rule carries a bound something can read, and @{refund_sla} is not one. Two points is not an argument. It is a receipt for what happens next.§2The edit that arrives in December
Nothing above is the bake-off. The bake-off is the fourth edit, three months later, by somebody who has not read the file. Peak season: the courier backlog pushes settlement out, and the desk needs to say ten days while the backlog lasts. One rule goes in, above the brevity line, in both files.
<$CONFIG> $IMPORT tool issue_refund $REQUIRE variable refund_sla: integer </$CONFIG> # Role @@ role: chat desk for a furniture retailer, refund timings rail-guarded You are a chat support agent for Wickford Home, a furniture retailer. # Constraints @@ refund_sla: finance settles a card refund in five days and that is what we quote - You MUST issue a refund within @{refund_sla} days. @@ refund_tool: a refund nobody recorded is a refund nobody makes - You MUST call @[issue_refund] before you confirm a refund. @@ peak_sla: in December the courier backlog pushes settlement out and we say so - You MUST issue a refund within 10 days. @@ brevity: a chat reply is read on a phone between other tasks - You MUST keep every reply to at most 3 sentences.
$ npx typeglish check peak-literal.tg peak-variable.tg peak-literal.tg:11:1 error logic/time-strength Conflicts with line 9. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. peak-literal.tg:15:1 error logic/time-strength Conflicts with line 7. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. ✗ 2 files — 2 error, 0 warning, 0 info $ npx typeglish check peak-variable.tg --strict ✓ 1 file — 0 error, 0 warning, 0 info $ npx typeglish build peak-literal.tg --out-dir dist typeglish build: peak-literal.tg refused — nothing written
--strict has nothing to escalate, because there is no finding to escalate.The grade goes the wrong way too. peak-variable.tg scores A (97/100) at enforceability 85, a point above the file it was edited from, because the new rule is the only one in it carrying a concrete duration. The edit that broke the policy improved the number, which is the same trick a rule hidden in a tool description plays in the bake-off on where a tool rule goes.
Route A cannot ship the mistake. Route B cannot see it.
§3What --vars does not buy back
The obvious answer is to hand the checker the values. There is a flag for that, and it does not do this.
$ npx typeglish check peak-variable.tg --vars '{"refund_sla":5}'
✓ 1 file — 0 error, 0 warning, 0 info
$ npx typeglish build peak-variable.tg --out-dir t1
✓ built t1/peak-variable.txt ← peak-variable.tg (e3c769d0f2f0, full)
$ npx typeglish build peak-variable.tg --vars '{"refund_sla":5}' --out-dir t2
✓ built t2/peak-variable.txt ← peak-variable.tg (e3c769d0f2f0, full)
$ cat t1/peak-variable.txt
# Role
You are a chat support agent for Wickford Home, a furniture retailer.
# Constraints
- You MUST issue a refund within {refund_sla} days.
- You MUST call issue_refund before you confirm a refund.
- You MUST issue a refund within 10 days.
- You MUST keep every reply to at most 3 sentences.
e3c769d0f2f0, with the flag and without it. A @{name} in prose is not a compile-time substitution: it lowers to a {name} runtime placeholder and the host fills it in the moment the conversation starts. The value never exists while anything is checking.That is the distinction worth keeping. The same @{refund_sla} is a compile-time read inside a $IF condition, a $SWITCH, a $DEFINE or a <tag when=…> guard, where the compiler evaluates it and the losing text never reaches the model. In prose it is a hole in the document, and the two look identical in a diff. Your system prompt has a compile time, and this is the line it runs along.
So the artifact ships a prohibition-free contradiction: within {refund_sla} days two lines above within 10 days, both unconditional, both addressed to the model, and whichever one the model picks it will pick freshly each conversation. That is the flakiness half the industry calls model variance.
§4Route C: a typed input picking between literals
The actual requirement behind route B was never make this number invisible. It was let the number differ. Those are separable, and the separation is a typed domain: the input decides which arm applies, and every arm still carries a number the prover can read.
<$CONFIG> $IMPORT tool issue_refund $REQUIRE variable region: one of uk, eu </$CONFIG> # Role @@ role: chat desk for a furniture retailer, refund timings rail-guarded You are a chat support agent for Wickford Home, a furniture retailer. # Constraints @@ refund_tool: a refund nobody recorded is a refund nobody makes - You MUST call @[issue_refund] before you confirm a refund. @@ brevity: a chat reply is read on a phone between other tasks - You MUST keep every reply to at most 3 sentences. @@ refund_sla: settlement is five days on a UK card and fourteen on a SEPA transfer $SWITCH ON @{region} - uk:: You MUST issue a refund within 5 days. - eu:: You MUST issue a refund within 14 days.
Route C has its own blind spot, and it is worth being blunt about. Add the standing ten-day line back, above the switch this time, and the file is still clean, because the arms are mutually exclusive and the prover will not cross a guard to look for a conflict.
$ npx typeglish check arms.tg arms-drift.tg ✓ 2 files — 0 error, 0 warning, 0 info $ npx typeglish resolve arms-drift.tg --vars '{"region":"uk"}' > uk.tg $ npx typeglish resolve arms-drift.tg --vars '{"region":"eu"}' > eu.tg $ npx typeglish check uk.tg eu.tg uk.tg:3:3 warn structure/unused-import Required variable "region" is never used. uk.tg:3:21 warn clarity/unused-variable $REQUIRE variable "region" is never used — no @{region} reference fills it. Remove it, or reference it in the prompt. uk.tg:16:1 error logic/time-strength Conflicts with line 11. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. uk.tg:17:1 error logic/time-strength Conflicts with line 10. One bound per slot — "within 5 days" already entails "within 10 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. eu.tg:3:3 warn structure/unused-import Required variable "region" is never used. eu.tg:3:21 warn clarity/unused-variable $REQUIRE variable "region" is never used — no @{region} reference fills it. Remove it, or reference it in the prompt. eu.tg:16:1 error logic/time-strength Conflicts with line 11. One bound per slot — "within 10 days" already entails "within 14 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. eu.tg:17:1 error logic/time-strength Conflicts with line 10. One bound per slot — "within 10 days" already entails "within 14 days"; the looser rule on "you issue a · refund" is dead weight. Keep one. ✗ 2 files — 4 error, 4 warning, 0 info
typeglish resolve applies the conditionals and prints the resolved source, one file per member. Check those and both arms turn out to be broken, in different directions: the UK arm because five days already entails ten, the EU arm because ten already entails fourteen. The two warnings per file are expected noise, since the resolved source no longer references @{region}. Gate on the error count, not the finding count.Each resolved file scores F (72/100) with consistency 0 and the grade capped at F. The file they were both resolved from reads A (93/100), 0 error, nothing to see. And on the clean version the builds come out per-region, which is the point of the whole arrangement: 093384f754f8 for uk, f4b894049369 for eu, and 24308bcd112f for the template build that ships both arms as conditional prose. Add a third region to the domain and arms.tg stops compiling until somebody writes its arm: structure/non-exhaustive-switch, @{region} can be us, but no arm handles it.
§5The verdict
Route C wins, and route B is not wrong so much as mis-sold. The useful way to decide is not literal or variable but does any rule in this file reason about this value.
- Keep it a literal when a rule compares it. Durations, caps, counts, thresholds, sentence and character budgets: every one of these is a slot, and the slot axiom is the only reason your prompt catches its own second opinion. A number you lift out is a slot the prover stops watching, permanently, on every command.
- Lift it when nothing in the file compares it. A customer name, an order id, an account balance, a store address, the agent's own display name. These are facts the prompt reports rather than reasons about, and there was never a proof to lose. Ops wants the hours changed by Friday is the same seam from the other side.
- Use a typed domain when the value varies by a known thing. Region, tier, channel, business hours state. Two approved literals behind a closed domain beats one open integer, and it survives somebody adding a third region, because the switch fails when the domain grows.
- Resolve before you gate. Whatever route you take,
typeglish resolve --varsinto a file and check that file, once per member of every domain your prompt branches on. A conditional prompt checked unresolved is a prompt where every guard is also a blindfold.
One last thing about the two-point score gap in §1. It is the only signal you get before the defect exists, and it is available on day one, in review, on a file where nothing is wrong yet. enforceability 90 versus enforceability 80 is the compiler telling you which of two approved files it will be able to defend in December.
§6Common questions
- Should policy values live in my system prompt or come in as variables?
- Keep the ones your rules reason about. A refund SLA written as
within 5 daysscoresA (98/100)atenforceability 90, and the same file with$REQUIRE variable refund_slaandwithin @{refund_sla} daysscoresA (96/100)atenforceability 80. Then add a second rule about the same slot: the literal file is 2 blockinglogic/time-strengtherrors and the build refuses, and the variable file is0 error, 0 warning, 0 infoand ships. Lift the values that change weekly and that nothing in the file compares, and keep the bounds, the caps and the clocks as literals. - Does typeglish check --vars validate my prompt with the input values?
- No. On a file whose rules read
@{refund_sla},check --vars '{"refund_sla":5}'reports0 error, 0 warning, 0 info, exactly like the run without it, and on a file with a$SWITCHit does not resolve the arms either.build --varsdoes not substitute a prose pointer either: the artifact ships{refund_sla}as a runtime placeholder and the hash ise3c769d0f2f0with and without the flag. To check a prompt the way it will actually reach the model, runtypeglish resolve <file> --vars '<json>'into a file and check that. - Why does my prompt score drop when I replace a number with a variable?
- Because
enforceabilitymeasures whether a rule carries a concrete bound, and@{refund_sla}is not one. The same four rules scoreenforceability 90withwithin 5 daysandenforceability 80withwithin @{refund_sla} days, which moves the file fromA (98/100)toA (96/100). The scorer is telling you the truth before the prover has to: a bound the compiler cannot read is a bound it cannot defend. - How do I check every branch of a conditional system prompt?
- Resolve it once per member of the domain and check the resolved sources. A file with a standing
within 10 daysrule plus a$SWITCH ON @{region}whose arms saywithin 5 daysandwithin 14 daysis0 erroron its own, because the arms are mutually exclusive and the prover will not cross them. Resolve it forukand foreuand check both, and the same file is4 error, 4 warningacross two files,F (72/100)withconsistency 0on each. Expect the two resolve warnings: the resolved source no longer references@{region}, so the declaration reads as unused. Gate on the error count.
The reason this one is worth an argument in review is that lifting a number out of a prompt is indistinguishable, in a diff, from every other good refactor. It removes a magic value, it names a thing, it moves configuration out of content, and it is the change a careful engineer makes on instinct. In a program that would all be true. In a prompt, the number was not a magic value: it was the only part of the sentence a prover could hold onto, and the refactor traded a proof for a config row. There is no diagnostic for it, because nothing is wrong yet, and the file that used to catch its own second opinion about the refund window will now quietly ship both. If you want one habit out of this post, make it the score line: read enforceability on the before and after of any edit that replaces a number with a pointer, and if it dropped, you just moved a slot out of the compiler's reach. Try again is not a number is the same lesson approached from a prompt that never had the number in the first place.