← Tidelines/Teardowns

Teardown: your escalation matrix is one long line

Fourteen lines out of the ops wiki, six of them a routing table. The checker has sixteen things to say and none of them is an error, and the artifact has the whole matrix on one line.

by TypeGlish team8 min read#teardowns
Six rows. One line.

TL;DR A markdown table pasted into a prompt is six more lines of prose to the compiler and one line to the model: check exits 0 with 16 warnings, and the build joins the whole matrix into a single run of pipes. A fence gets the rows back and takes the grade from C (65/100) to C (77/100), but the values stay unreadable to every proof you own, so a row routing outages to a queue called nowhere in 1 year scores byte-identically to the real one. Only a typed input plus a $SWITCH arm per member makes the matrix checkable.

Every contact centre has this table. It lives in a wiki page called something like Escalation matrix v3 (FINAL), it has been argued over by three teams, and it is the single most reviewed artifact ops owns. So when the agent needs to know where a contact goes, the table gets copied out of the wiki and pasted under a heading in the system prompt, which is exactly the right instinct. The table is the policy. What nobody checks is what happens to a table on the way through a compiler.

§1The paste, and the sixteen warnings

Here is the file. Rae is first-line for a broadband ISP: identify the caller, acknowledge, route. Three rules and the matrix, which is how these prompts are actually shaped.

rae.tg - pasted out of the wiki✓ exits 0
# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
- ALWAYS ask for the account number before you discuss an account.
- MUST acknowledge every contact within 4 hours.
- MUST route every contact using the escalation matrix below.

# Escalation matrix
| Tier | Trigger          | Queue   | Response   |
|------|------------------|---------|------------|
| 1    | Billing question | billing | 4 hours    |
| 2    | Service outage   | network | 1 hour     |
| 3    | Legal threat     | legal   | 15 minutes |
Copied with the alignment padding, because that is how it sits in the wiki and nobody retypes a table.
tg check rae.tg - output
rae.tg:5:1    info   prompt/unintroduced-definite  "the account number" retrieves something this
  document never introduces - a model must guess which number is meant.
rae.tg:7:1    info   prompt/unintroduced-definite  "the escalation matrix" retrieves something this
  document never introduces - a model must guess which matrix is meant.
rae.tg:10:17  warn   structure/phantom-column  Phantom column: a space run doing alignment's job.
  One space between things — commentary belongs in a // comment, layout tables in a fence or <"name"> zone.
rae.tg:10:34  warn   structure/phantom-column  Phantom column: a space run doing alignment's job.
rae.tg:10:47  warn   structure/phantom-column  Phantom column: a space run doing alignment's job.
rae.tg:10:1   warn   structure/missing-period  Unterminated statement — end it with a period (or ! ?;
  a lead-in may end with ":"). Statement boundaries are a compile contract.
rae.tg:10:1   info   prompt/unregistered-doer  A bare generic doer never enters the world model -
  instruction to the agent, or background about users?
rae.tg:11:1   warn   structure/missing-period  Unterminated statement — end it with a period.
  ... 10 more on lines 12, 13 and 14 ...

 1 file — 0 error, 16 warning, 4 info
Trimmed: the repeated fix text, and the ten identical findings on the remaining three rows. Sixteen warnings, and the exit code is 0.

Two codes, repeated. structure/missing-period fires once per row, because a table row is a line of text with no full stop and a statement boundary is a compile contract. structure/phantom-column fires once per space run, and its message is the most useful sentence in the output: a space run doing alignment's job ... layout tables in a fence or a <"name"> zone. The compiler has read the alignment padding, worked out what it is for, and named the fix. It just cannot make you take it, because none of this blocks.

tg score rae.tg - output
rae.tg — C (65/100)  proven errors: none  tiers: base+z3
  planes  runtime 87 (what the model reads) · hygiene 0 (source only)
  facets  enforceability 80 x.21 · hardness 100 x.12 · directness 67 x.08 · consistency 100 x.17
          structure 0 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 67 x.08 · security 100 x.08
  lever   structure 0/100 (up to +12 overall) — Fix the ledger rows with structure/ codes; most
          carry a one-click fix.
structure 0, floored by eleven structure/ rows in the ledger. Note consistency 100: hold on to that number, it comes back in §3.

§2What the model gets

Warnings do not block, so the file builds. Build it, because the artifact is the whole teardown.

tg build rae.tg - the artifact✗ one line
 built .typeglish/dist/rae.txt ← rae.tg (731b5d87d0cc, full)

# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
- ALWAYS ask for the account number before you discuss an account.
- MUST acknowledge every contact within 4 hours.
- MUST route every contact using the escalation matrix below.

# Escalation matrix
| Tier | Trigger          | Queue   | Response   | |------|------------------|---------|------------| | 1    | Billing question | billing | 4 hours    | | 2    | Service outage   | network | 1 hour     | | 3    | Legal threat     | legal   | 15 minutes |
The alignment padding survived. The newlines did not. Six rows of reviewed routing policy reach the model as one line of pipes.

This is not a bug being reported against the compiler, it is the compiler doing the thing you want it to do everywhere else. A newline between two prose lines is a statement boundary, not content, and the build emits statements. It is the same rule that joins a hard-wrapped rule back together, running in the direction you did not ask for. Worth knowing that typeglish fmt rae.tg --check answers 1 file already formatted, so the formatter will not save you either: nothing here is malformed. It is just not a table any more.

Your ops team reviewed a grid. Your model reads a sentence made of pipes.

§3The table is not saying anything

Back to consistency 100. The first constraint promises acknowledgement within 4 hours. Row 3 of the matrix promises 15 minutes for a legal threat, which is a tighter clock on the same event, and row 2 promises an hour. In a file where those were rules, two clocks on one promise is exactly the shape the time layer proves. Here the scorecard says the rules are perfectly consistent, and it is right, because a table row is not a claim. Nothing in the matrix is on the checkable plane at all.

You do not have to take that on trust. Here is the controlled version: take rae.tg, change one row so that service outages route to a queue called nowhere with a response time of 1 year, keep the column widths identical so the only difference is meaning, and run both files.

tg check and tg score, original vs. nonsense - output
$ sed -n 13p rae-nonsense.tg
| 2    | Service outage   | nowhere | 1 year     |

$ diff <(typeglish check rae.tg) <(typeglish check rae-nonsense.tg)
$ diff <(typeglish score rae.tg) <(typeglish score rae-nonsense.tg)
$

rae-nonsense.tg — C (65/100)  proven errors: none  tiers: base+z3
   1 file — 0 error, 16 warning, 4 info
Byte-identical output from both commands, after normalising the file name. Same 16 warnings, same C (65/100), same facets. The compiler has no opinion about the contents of your routing table because it never read them as content.

That is the finding worth carrying out of this file. The sixteen warnings are about layout. Not one of them is about the fact that the destination of every escalation in the business is sitting in a region the checker treats as decoration.

§4The fence gets the rows back, and nothing else

Take the compiler's advice. Three backticks above the table and three below, changing no other character.

rae-fenced.tg - the matrix in a fence✓ 0 warnings
# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
- ALWAYS ask for the account number before you discuss an account.
- MUST acknowledge every contact within 4 hours.
- MUST route every contact using the escalation matrix below.

# Escalation matrix
```
| Tier | Trigger          | Queue   | Response   |
|------|------------------|---------|------------|
| 1    | Billing question | billing | 4 hours    |
| 2    | Service outage   | network | 1 hour     |
| 3    | Legal threat     | legal   | 15 minutes |
```
0 error, 0 warning, 2 info, and the artifact keeps the rows on separate lines. 16 warnings to 0 for six characters.

The grade moves with it: C (65/100) to C (77/100), structure 0 to 100, hygiene plane 0 to 50. Both remaining findings are the prompt/unintroduced-definite pair from the rules, untouched by any of this. If the goal was to stop a table being mangled in transit, the job is done and it took six characters.

The goal was not that. Run the nonsense mutation again, on the fenced file this time.

tg check, fenced original vs. fenced nonsense - output
$ diff <(typeglish check rae-fenced.tg) <(typeglish check rae-fenced-nonsense.tg)
$

rae-fenced-nonsense.tg — C (77/100)  proven errors: none  tiers: base+z3
Still identical, and now at a better grade. The fence did not make the table readable to the checker; it made the checker stop reading it on purpose, which is what a verbatim zone is for.

So the fence is an honest trade and you should know which side of it you are on. It buys transport: the layout reaches the model byte for byte. It costs analysis: a verbatim zone is exempt by design, the same property that lets an <examples> block hold a literal ASCII receipt. The wrap-up format guide makes the same trade in the other direction, where a fence was unambiguously the right answer because the thing being protected really was a literal shape. A routing matrix is not a literal shape. It is a set of decisions.

Here is the cost in one pair. A matrix that has grown for two years always ends up with the same trigger listed twice, usually because two teams added a row in different quarters. Fenced, that is invisible:

matrix-dupe.tg - two rows, one trigger✓ 0 findings
# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
- MUST route every contact using the escalation matrix below.

# Escalation matrix
```
| Tier | Trigger        | Queue      | Response   |
|------|----------------|------------|------------|
| 2    | Service outage | network    | 1 hour     |
| 3    | Service outage | complaints | 15 minutes |
```
Two destinations for one trigger, contradicting each other on both the queue and the clock. 0 error, 0 warning, 1 info.

§5Rows become arms

The fix is not a formatting decision, it is a modelling one. The Trigger column is not prose, it is a closed set of values that arrive with the contact. Declare it as one, and the rows stop being layout.

routed-dupe.tg - the same duplicate, as a switch✗ 2 errors
<$CONFIG>
  $REQUIRE variable contact_reason: one of bills, faults, legal
</$CONFIG>

# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Routing
$SWITCH ON @{contact_reason}
  - bills:: You MUST transfer to the billing queue within 4 hours.
  - faults:: You MUST transfer to the network queue within 1 hour.
  - faults:: You MUST transfer to the complaints queue within 15 minutes.
  - legal:: You MUST transfer to the legal queue within 15 minutes.
The identical policy defect, one representation later: structure/duplicate-case twice, blocking, Member "faults" already has an arm — only the first can run. The build refuses.

Nothing about the trigger changed. What changed is that a member key is a name the compiler holds and a table cell is not. The same move buys the other property a matrix is supposed to have, which is completeness: add a fourth reason code to the domain six months later, the way somebody working on the IVR would, and the switch fails loudly rather than falling through.

tg check after a fourth reason code - output
rae-routed4.tg:17:1  error  structure/non-exhaustive-switch  @{contact_reason} can be moves, but no
  arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file — 1 error, 0 warning, 0 info
The counterexample is named. Whether you want that failure or a deliberate catch-all is a real decision with a real cost, argued out in the catch-all bake-off.

Here is the file rebuilt. The Trigger column became a typed input, the Queue and Response columns became the arm bodies, the acknowledgement rule that used to shadow-conflict with row 3 is gone because the clocks now live per destination where they always belonged, and every rule carries a reason.

rae-routed.tg - rebuilt✓ A (90/100)
<$CONFIG>
  $REQUIRE variable contact_reason: one of bills, faults, legal
</$CONFIG>

# Role
@@ role: first-line triage for a broadband ISP; Rae routes, she does not resolve
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
@@ identify: the account number unlocks account context, so it comes before anything account-shaped
- You MUST ask for an account number before you discuss an account.
@@ no_promises: the receiving queue owns the fix date, so first line never quotes one
- You MUST NOT give a repair date.

# Routing
@@ routing: one arm per reason code, so a fourth code fails the build instead of falling through
$SWITCH ON @{contact_reason}
  - bills:: You MUST transfer to the billing queue within 4 hours.
  - faults:: You MUST transfer to the network queue within 1 hour.
  - legal:: You MUST transfer to the legal queue within 15 minutes.
0 error, 0 warning, 0 info and A (90/100): structure 0 to 100, annotation 0 to 100, style 67 to 100. The lever left is enforceability 55, which is honest, because three of five rules are now conditionals and a conditional is harder to defend than a bound.

The member names are bills, faults and legal rather than billing, outage and legal, and that is not styling. Arm keys are read as English words, so billing and outage each draw a prompt/unregistered-doer info on their own row while bills and faults are clean. The bake-off hit the same edge and lists which words go quiet.

Then the last figure, which is the one to show anybody who thinks this was a formatting argument. Build the rewrite with the input bound, the way the host will call it.

tg build --vars '{"contact_reason":"faults"}' - the artifact
 built .typeglish/dist/rae-routed.txt ← rae-routed.tg (08e5dddc9d41, full)

# Role
You are Rae, the first-line support agent for Northwind Broadband.

# Constraints
- You MUST ask for an account number before you discuss an account.
- You MUST NOT give a repair date.

# Routing
You MUST transfer to the network queue within 1 hour.
The matrix that arrived as one line of pipes leaves as one sentence: the row that applies, and nothing else. The unbound template build (436fc3ba47cb) keeps all three arms as conditional prose, which is the right artifact when the host has not resolved the reason code yet.

§6Common questions

Can I put a markdown table in a system prompt?
You can, and it will not arrive as a table. A prompt file is a sequence of statements, not a markdown document, so each row of a pasted table is read as one more line of prose and the build joins them into a single line. Six rows of routing policy reach the model as one run of pipes and dashes. If the layout matters, put the table inside a triple-backtick fence, which is a verbatim zone: the rows survive as rows, the 16 warnings go to 0, and the grade moves from C (65/100) to C (77/100). If the content matters, a fence is not enough, because nothing inside a verbatim zone is checked.
Why did my table lose its line breaks in the prompt?
Because a newline between two prose lines is not content, it is a statement boundary, and the compiler emits the statements rather than your whitespace. That is the same rule that joins a hard-wrapped sentence back together, working against you here. You can see it in one command: run typeglish build and read the artifact. A 15-line file with a 6-row table builds to an artifact whose last line is the whole table, pipes and alignment padding intact, newlines gone. Wrap the table in a fence and the same file builds with the rows on separate lines.
Does a code fence in a system prompt get checked?
No, and that is the trade. A fence is a verbatim zone, so its interior is exempt from rule analysis: the layout reaches the model byte for byte and nothing inside is parsed, counted, or proven. The proof is a controlled pair. Take a fenced escalation matrix, change one row so it routes service outages to a queue called nowhere with a response time of 1 year, and both typeglish check and typeglish score return byte-identical output to the original, C (77/100) either way. A fence buys transport, not meaning.
How do I write an escalation matrix an AI agent will actually follow?
Stop writing it as a table and write the trigger column as a typed input. Declare $REQUIRE variable contact_reason: one of bills, faults, legal, then give each member its own arm in a $SWITCH ON @{contact_reason} block with the destination and the clock in the arm body. The rows are now statements the compiler holds: two rows for the same trigger is a blocking structure/duplicate-case error rather than a silent row, a fourth reason code added to the domain later is a blocking structure/non-exhaustive-switch error naming the uncovered member, and a build with the input bound resolves the whole matrix to the one line that applies.
Field note

The reason this one is worth a teardown rather than a lint rule is that the table was the most governed thing in the prompt. It had owners, a review cycle and a version number, which is more than the rules around it had, and all of that governance ended at the wiki page. The pattern to watch for is a region of your prompt that carries decisions but is shaped like data: a matrix, a tier grid, a price list, a hours-of-operation block. Every one of them will check clean, because the checker is scrupulously honest about what it can read, exactly as it was with five unfilled placeholders. Ask the same question here as there: what could the compiler actually see? Then run typeglish build and read what you shipped.

∿ washed up Aug 9, 2026 ∿