Bake-off: a catch-all arm vs. one arm per member
The fallback branch is the most reasonable-looking row in a router, and it is insurance you buy once and then never think about, which is a problem, because the thing it insures against is you finding out.
TL;DR Whether your agent prompt should have a fallback branch is decided by the input, not by taste: on a typed input with every member covered a - otherwise:: row is a blocking structure/unreachable-otherwise error, on a partly covered typed input it is legal, and on an untyped input it is mandatory or the switch is a blocking structure/opaque-switch.
Two routers, same job, same four reason codes. One writes a row for three of them and a catch-all for whatever is left. The other writes a row per code. Both check clean, both score A (93/100), and if you diff them you will conclude that the catch-all version is the tidier file. Then a fifth reason code arrives, which is the only thing that ever really happens to a router, and the two files stop being interchangeable.
§1The two contenders
A contact router for a broadband provider. The host runtime already classifies the contact before the prompt runs, off an IVR selection or an intent model, and hands the result in as a typed input. That is the setup where the question is even askable: the input has a written-down domain, so the compiler knows what values exist.
Contender A covers three members and catches the rest. This is what most people write, and the reasoning behind it is sound: upgrades are rare, the general queue is staffed, and a row that handles everything you did not think of feels like the responsible choice.
<$CONFIG> $REQUIRE variable reason: one of bills, faults, moves, upgrades </$CONFIG> # Role @@ role: name the domain so "helpful" has a scope You are a contact router for Northwind Broadband. # Constraints @@ one_line: a router returns a destination, not a paragraph - You MUST reply with at most 1 sentence. @@ no_advice: a router that starts answering stops routing - You NEVER answer a customer question yourself. @@ route: one destination per reason code, decided by the compiler $SWITCH ON @{reason} - bills:: You MUST route to billing. - faults:: You MUST route to network. - moves:: You MUST route to moves. - otherwise:: You MUST route to general.
Contender B is the same file with the last row named. One character of difference in intent and four in text: otherwise becomes upgrades, and the destination becomes the queue that actually handles upgrades.
<$CONFIG> $REQUIRE variable reason: one of bills, faults, moves, upgrades </$CONFIG> # Role @@ role: name the domain so "helpful" has a scope You are a contact router for Northwind Broadband. # Constraints @@ one_line: a router returns a destination, not a paragraph - You MUST reply with at most 1 sentence. @@ no_advice: a router that starts answering stops routing - You NEVER answer a customer question yourself. @@ route: one destination per reason code, decided by the compiler $SWITCH ON @{reason} - bills:: You MUST route to billing. - faults:: You MUST route to network. - moves:: You MUST route to moves. - upgrades:: You MUST route to sales.
§2Round 1: nothing separates them
The referee has nothing to say, and this bake-off has to lead with that because it is a stronger tie than we usually get. check is clean on both. So is the score, to the grade and to seven of eight facets.
$ typeglish check fallback.tg table.tg ✓ 2 files - 0 error, 0 warning, 0 info $ typeglish score fallback.tg table.tg fallback.tg - A (93/100) proven errors: none tiers: base+z3 planes runtime 91 (what the model reads) · hygiene 100 (source only) facets enforceability 67 x.21 · hardness 100 x.12 · directness 99 x.08 · consistency 100 x.17 structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08 table.tg - A (93/100) proven errors: none tiers: base+z3 planes runtime 91 (what the model reads) · hygiene 100 (source only) facets enforceability 67 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
directness, 99 against 100, which is a rounding difference on the word otherwise and not an argument. Same grade, same overall, and nothing in CI will ever prefer one of these files.So the choice cannot be settled by pointing a tool at the file you have. It has to be settled by asking what happens to the file you will have, which is the honest way to compare any two prompt structures. The prompt is not the artifact; the prompt is the thing you edit for two years.
§3Round 2: the fifth reason code
Retention gets its own queue. Somebody adds cancelling to the reason codes, in the one place a reason code is declared, and pushes. The domain line is now five members long in both files. Nothing else is touched, which is exactly how this arrives in real life: the person who edited the domain was working on the IVR, not on the router.
$ typeglish check fallback5.tg table5.tg table5.tg:16:1 error structure/non-exhaustive-switch @{reason} can be cancelling, but no arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 2 files - 1 error, 0 warning, 0 info
Read the failure as the product it is. Contender B stopped the build and named the member: @{reason} can be cancelling, but no arm handles it. That is a work item with an owner. Contender A absorbed the new member into a row written eight months earlier by somebody who had never heard of a retention queue, and reported success.
The consequence is not abstract, and you do not have to reason about it, because you can read it. Bind the input and build both files.
$ typeglish build fallback5.tg --vars '{"reason":"cancelling"}' ✓ built .typeglish/dist/fallback5.txt ← fallback5.tg (e51fe841c042, full) # Role You are a contact router for Northwind Broadband. # Constraints - You MUST reply with at most 1 sentence. - You NEVER answer a customer question yourself. You MUST route to general. $ typeglish build table5fixed.tg --vars '{"reason":"cancelling"}' ✓ built .typeglish/dist/table5fixed.txt ← table5fixed.tg (dc34ddfb3a2e, full) # Role You are a contact router for Northwind Broadband. # Constraints - You MUST reply with at most 1 sentence. - You NEVER answer a customer question yourself. You MUST route to retention.
table5fixed.tg is contender B after somebody answered the error by adding the arm, which took one line and thirty seconds. Contender A never asked.A fallback does not handle the case you forgot. It stops you finding out you forgot it.
§4Round 3: you cannot have both belts
The obvious reaction, and the one we had, is to want both. Enumerate every member and keep the catch-all, so the file is explicit today and defended tomorrow. The language declines, and the wording of the refusal is the whole post.
both5.tg:22:3 error structure/unreachable-otherwise Every member of @{reason} has its own arm - "- otherwise::" can never run, and it would silently absorb members added to the domain later. Remove the row. ✗ 1 file - 1 error, 0 warning, 0 info
So the design space has exactly three states, and all three are enforced. Write them down as a table, because once you have it the question stops being a judgement call.
typed input, every member has an arm otherwise is FORBIDDEN structure/unreachable-otherwise typed input, members still uncovered otherwise is LEGAL (arms + fallback = total) untyped input, open domain otherwise is REQUIRED structure/opaque-switch
@{reason} has an open domain (untyped) - a $SWITCH on it needs a "- otherwise::" row (members can't cover an open domain). Drop the : one of ... from the declaration and the same file that was blocked for having a fallback is now blocked for not having one.That is the finding worth taking away, and it inverts the usual reading. The catch-all is not a safety habit that is sometimes redundant. It is a statement about the input: I do not know what values can arrive here. When that is true, the compiler insists on it. When it is false, the compiler treats saying it anyway as an error, because a wildcard over a set you have fully enumerated is a promise to never notice the set changing.
§5The honest column: when the catch-all wins
Three cases where contender A is right, none of which is it feels safer.
- The domain genuinely is open. Free text, a value from a system you do not control, an intent label from a model that can emit anything. Do not invent a closed domain you cannot enforce: leave the input untyped, take the mandatory fallback, and let the row carry the honest meaning.
- The set is large and the tail is uniform. Forty reason codes where thirty-five really do go to one queue is not a case for thirty-five identical arms. But then say so in the declaration: cover the five that matter and let the fallback be the documented default, which is the legal middle row of the table. You keep the property that adding a member is silent, so pair it with a review rule rather than pretending you have not.
- Branching on a predicate, not a member. A
$IFchain is the other spelling of the same engine, and it takes open conditions: thresholds, compounds, several variables at once. There the fallback is not optional, it is required, and leaving it off gets you a counterexample rather than a lecture.
$ typeglish check ifnoelse.tg ifnoelse.tg:9:1 error structure/non-exhaustive-switch This chain isn't exhaustive - no arm handles @{wait_minutes}=0.0. Add an arm or an ELSE. ✗ 1 file - 1 error, 0 warning, 0 info
is at least 20 and is at least 5, and the prover hands back the value that falls through. Add $ELSE and it is clean. Same word, opposite verdict from the $SWITCH case, and the difference is entirely what the compiler was told about the input.One last wrinkle from building these files, reported because it will bite you and it is not in the decision table. The arm keys are read as English words, so the member name itself can draw a finding: billing, outage, payments, complaints and renewals each produce a prompt/unregistered-doer info on their arm row, while bills, faults, moves, upgrades, refunds and returns are clean. That is the same behaviour that caught the voicemail arm in building a ticket-triage agent, prompt-first, and it is why the router in this post routes bills rather than billing.
§6The verdict
Contender B wins, and the margin is not in any number the tools printed today. Round 1 was a dead tie on both instruments, which means this decision is invisible to every gate you have. Round 2 is where it lands: one file turned a policy change into a build failure with a named member, and the other turned it into a customer trying to cancel a contract while talking to a general queue.
The rule is short enough to remember. Type the input, cover every member, and delete the catch-all, in that order. If deleting it makes you nervous, the nervousness is information: you do not believe the domain is closed, and the fix is to stop declaring that it is, not to keep a row that would hide the difference. And if you do keep a fallback because the tail is genuinely uniform, know what you bought, which is a prompt that will never tell you a new reason code arrived.
§7Common questions
- Should my agent prompt have a default or fallback branch?
- It depends on one thing, and it is not a style preference: whether the input you are branching on has a domain the compiler knows. If the input is typed and every member has an arm, a fallback is a blocking
structure/unreachable-otherwiseerror, because the row can never run. If members are still uncovered, a fallback is legal and the arms plus the fallback are what makes the switch total. If the input is untyped, a fallback is mandatory: a$SWITCHover an open domain is a blockingstructure/opaque-switchuntil you add one. So write the domain down first, and the fallback question answers itself. - What does structure/unreachable-otherwise mean?
- It means every member of the typed input already has its own arm, so the
otherwiserow is dead code that can never execute. The diagnostic also names the reason it is an error rather than a warning: the row would silently absorb members added to the domain later. It is the compiler removing your safety net on purpose, because on a closed domain the net is what stops a new member from ever being noticed. - Why did adding a value to my typed input break the build?
- Because a
$SWITCH ONblock over a typed input is proven total by construction, so a member with no arm is a blockingstructure/non-exhaustive-switchthat names the uncovered member. That failure is the feature. Every switch over that input, in every file, fails at once and hands you the list of places that need a decision, which is the opposite of a new reason code quietly inheriting whatever the fallback said. - Is a catch-all in a system prompt actually dangerous?
- It is dangerous in exactly one way, and it is easy to see in the artifact. Build the fallback router with the new reason code bound and the compiled prompt contains the single line
You MUST route to general. Build the enumerated router with the same input and it containsYou MUST route to retention. Same request, same green check, two different queues, and the difference was decided by a row somebody wrote as insurance eight months earlier. - When should I use $IF and $ELSE instead of $SWITCH arms?
- Use a
$IFchain when the conditions are open predicates rather than members of a closed set: thresholds, compounds, several variables at once. In a chain the fallback flips from forbidden to required, because a chain over open conditions cannot be proven exhaustive, and leaving$ELSEoff is a blockingstructure/non-exhaustive-switchthat hands you a counterexample, for instance no arm handles a wait of0.0minutes. Same word, opposite verdict, and the reason is what the compiler knows about the input.
Both of these diagnostics exist because a $SWITCH ON block is resolved by the compiler rather than weighed by the model, so coverage is a property that can be proven instead of hoped for, and the losing arms are absent from the artifact rather than outranked inside it. Prose conditionals vs $SWITCH for multichannel agents is the earlier round of that argument, and if unsure vs. if a fact is missing shows what it takes to get a trigger onto that plane in the first place. The one thing neither post says, and this one does: once you are on it, the fallback you brought with you is a liability, and the compiler will say so.