← Tidelines/Deep dives

Gold and silver conflict. Priority and standard do not.

Every contact centre prompt is full of closed sets: tiers, queues, statuses, regions, plans. The checker can prove two of them cannot both hold, and whether it does comes down to whether marketing picked words that were already a set in English.

by TypeGlish team9 min read#deep-dives
One slot. Two members.

TL;DR A closed set is only closed if you declare it: naming one loyalty tier twice is 2 blocking logic/contradiction errors at F (68/100) while the tiers are called gold and silver, the same file with the tiers renamed to priority and standard is 0 error at C (75/100) and builds to 243a626460df with both answers in the artifact, and one line, %Loyalty tier% ARE one of priority, standard, essential., puts the proof back as 2 logic/redeclaration errors.

There is a class of prompt defect that has nothing to do with how you write rules. It comes from a fact stated twice, in two sections, by two people, with two values. Which tier gets free returns. Which queue takes an out-of-hours escalation. Which region the refund cap applies to. None of these is a rule, so none of them shows up in a rule review, and the model resolves them the way it resolves anything you left underspecified: per conversation.

TypeGlish can prove that class. It proves it by knowing that some words are members of one set and therefore exclude each other. What is worth an afternoon of your attention is exactly which words those are, because the answer decides whether your prompt is checked or merely checked-looking.

§1Two sections, one fact, two answers

The prompt below belongs to an outdoor-gear retailer. Two sections were written six months apart by two people, and both of them wrote down which loyalty tier gets free returns.

returns-gold.tg - the loyalty fact, twice✗ 2 error
<$CONFIG>
  $IMPORT tool get_account
</$CONFIG>

# Role
You are a support agent for Corvid Outdoor.

# Constraints
- You MUST greet the customer by name.
- You MUST keep every reply to at most 4 sentences.
- You MUST NOT promise a delivery date.

# Returns
The %free returns tier% IS gold.
- You MUST call @[get_account] before quoting a return cost.

# Loyalty
The %free returns tier% IS silver.
- You MUST NOT offer a tier upgrade.
The %…% pair is a name literal: it binds free returns tier as one name, because an unquoted three-word definite subject binds nothing and the compiler says so. Everything else here is an ordinary support prompt.
tg check returns-gold.tg - output
$ npx typeglish check returns-gold.tg
returns-gold.tg:14:1  error  logic/contradiction  Conflicts with line 14. Contradiction — "free returns tier.metal" is assigned "gold" and "silver" — both name a metal, and a single-valued slot holds one.
returns-gold.tg:18:1  error  logic/contradiction  Conflicts with line 11. Contradiction — "free returns tier.metal" is assigned "gold" and "silver" — both name a metal, and a single-valued slot holds one.

 1 file — 2 error, 0 warning, 0 info
# exit 1
Read the slot key, because it is the whole article: free returns tier.metal. The checker did not conclude that two tier names conflict. It concluded that one object was assigned two metals, and a metal slot holds one. The word tier appears nowhere in its reasoning.

§2Then marketing renamed the tiers

Gold, Silver and Bronze went out with the March rebrand, replaced by Priority, Standard and Essential. The prompt edit was three words in two lines, made by a tooling script, reviewed in ninety seconds, and it is the only difference between the two files.

returns-renamed.tg - the same file, new tier names✓ 0 error
<$CONFIG>
  $IMPORT tool get_account
</$CONFIG>

# Role
You are a support agent for Corvid Outdoor.

# Constraints
- You MUST greet the customer by name.
- You MUST keep every reply to at most 4 sentences.
- You MUST NOT promise a delivery date.

# Returns
The %free returns tier% IS priority.
- You MUST call @[get_account] before quoting a return cost.

# Loyalty
The %free returns tier% IS standard.
- You MUST NOT offer a tier upgrade.
Two lines changed. The defect is untouched: the prompt still answers one question two ways, and the answer a customer gets still depends on which sentence the model weighs.
tg score - the two files side by side
returns-gold.tg — F (68/100)  proven errors — grade capped at F  tiers: base+z3
   L11 logic/contradiction: Conflicts with line 14. Contradiction — "free returns tier.metal" is assigned "gold"
   L14 logic/contradiction: Conflicts with line 11. Contradiction — "free returns tier.metal" is assigned "gold"
  planes  runtime 74 (what the model reads) · hygiene 50 (source only)
  facets  enforceability 88 x.21 · hardness 100 x.12 · directness 96 x.08 · consistency 0 x.17
          structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 100 x.08 · security 100 x.08

returns-renamed.tg — C (75/100)  proven errors: none  tiers: base+z3
  planes  runtime 96 (what the model reads) · hygiene 13 (source only)
  facets  enforceability 88 x.21 · hardness 100 x.12 · directness 96 x.08 · consistency 100 x.17
          structure 25 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 100 x.08 · security 100 x.08
  L14  structure/split-declaration  −1  Split declaration — "free returns tier" is declared in 2 separate statements (
  L18  structure/split-declaration  −1  Split declaration — "free returns tier" is declared in 2 separate statements (
The rebrand raised the grade seven points and took consistency from 0 to 100. Nothing about the prompt got better. A rename moved a proven defect into a class the prover cannot reach, and every instrument downstream of the proof agreed.

Look at what survives the rename: structure/split-declaration, twice, as a warning worth one point each. Its message is the tell. It reads Multiplicity must be spelled in ONE statement: "free returns tier IS priority and standard." The checker can see that one name was declared in two places, and its best guess at what you meant is a list. Priority and standard as two things the tier is at once. That is a perfectly reasonable reading of two words it has never met, and it is the opposite of what you meant.

tg build returns-renamed.tg - what reaches the model
$ npx typeglish build returns-gold.tg
typeglish build: returns-gold.tg refused — nothing written

$ npx typeglish build returns-renamed.tg
 built .typeglish/dist/returns-renamed.txt ← returns-renamed.tg (243a626460df, full)

# the artifact, verbatim, from "# Returns" down:
# Returns
The free returns tier IS priority.
- You MUST call get_account before quoting a return cost.

# Loyalty
The free returns tier IS standard.
- You MUST NOT offer a tier upgrade.
The metal version refuses to build. The renamed version ships, and the deployed prompt contains both answers six lines apart, with the second one closer to the end of the context. That is the production behaviour: the tier that gets free returns depends on the conversation.

§3What the checker actually knows

The coordinate proof runs off a catalog of domains: sets of words English already treats as alternatives to each other. Four pairs in one file, checked once, shows the line where it stops.

catalog.tg - four facts, each stated twice✗ 6 error, 2 warning
# Role
You are a support agent for Corvid Outdoor.

# Constraints
- You MUST greet the customer by name.

# Facts
The %free returns tier% IS gold.
The %free returns tier% IS silver.
The %support channel% IS voice.
The %support channel% IS chat.
The %billing plan% IS enterprise.
The %billing plan% IS starter.
The %escalation queue% IS priority.
The %escalation queue% IS standard.
Structurally identical, four times over: one name, two values, one contradiction each. The checker disagrees.
tg check catalog.tg - output
$ npx typeglish check catalog.tg
catalog.tg:8:1   error  logic/contradiction  Conflicts with line 7. Contradiction — "free returns tier.metal" is assigned "gold" and "silver" — both name a metal, and a single-valued slot holds one.
catalog.tg:9:1   error  logic/contradiction  Conflicts with line 6. Contradiction — "free returns tier.metal" is assigned "gold" and "silver" — both name a metal, and a single-valued slot holds one.
catalog.tg:10:1  error  logic/contradiction  Conflicts with line 9. Contradiction — "support channel.channel" is assigned "voice" and "chat" — both name a channel, and a single-valued slot holds one.
catalog.tg:11:1  error  logic/contradiction  Conflicts with line 8. Contradiction — "support channel.channel" is assigned "voice" and "chat" — both name a channel, and a single-valued slot holds one.
catalog.tg:12:1  error  logic/contradiction  Conflicts with line 11. Contradiction — "billing plan.plan tier" is assigned "enterprise" and "starter" — both name a plan tier, and a single-valued slot holds one.
catalog.tg:13:1  error  logic/contradiction  Conflicts with line 10. Contradiction — "billing plan.plan tier" is assigned "enterprise" and "starter" — both name a plan tier, and a single-valued slot holds one.
catalog.tg:14:1  warn   structure/split-declaration  Split declaration — "escalation queue" is declared in 2 separate statements (priority · standard) (line 13). Multiplicity must be spelled in ONE statement: "escalation queue IS priority and standard." — or a list.
catalog.tg:15:1  warn   structure/split-declaration  Split declaration — "escalation queue" is declared in 2 separate statements (priority · standard) (line 12). Multiplicity must be spelled in ONE statement: "escalation queue IS priority and standard." — or a list.

 1 file — 6 error, 2 warning, 0 info
# exit 1
Three domains named in the output: metal, channel, plan tier. Loyalty metals conflict. Voice and chat conflict, because the channel set is part of the language a prompt compiler has to know. Enterprise and starter conflict, because SaaS plan tiers are in there too. Priority and standard are two adjectives with no shared axis, so the fourth pair is a warning about list syntax.
The catalog covers the vocabulary English already coordinates. Your own vocabulary is where your policy lives, and it arrives as two unrelated words.

This is not a gap to be embarrassed about, it is the shape of the problem. No checker can be shipped knowing that Priority excludes Standard at Corvid Outdoor while Priority and Standard are a perfectly good pair of independent flags at the company next door. The set is yours. It has to come from your file.

§4Write the set down

One statement restores the proof, and the quantifier is ARE one of: a closed, mutually exclusive set in which two distinct members conflict. Same two contradicting lines as before, one line added above them.

returns-set.tg - the set declared, the conflict still there✗ 2 error
<$CONFIG>
  $IMPORT tool get_account
</$CONFIG>

# Role
You are a support agent for Corvid Outdoor.

%Loyalty tier% ARE one of priority, standard, essential.

# Constraints
- You MUST greet the customer by name.
- You MUST keep every reply to at most 4 sentences.
- You MUST NOT promise a delivery date.

# Returns
The %free returns tier% IS priority.
- You MUST call @[get_account] before quoting a return cost.

# Loyalty
The %free returns tier% IS standard.
- You MUST NOT offer a tier upgrade.
Note that the set is declared on a different name than the one being assigned. Membership is value-based, not owner-scoped: one declaration anywhere in the file makes priority and standard alternatives everywhere in it.
tg check returns-set.tg - output
$ npx typeglish check returns-set.tg
returns-set.tg:16:1  error  logic/redeclaration  Conflicts with line 15. Mutually-exclusive values — this line asserts "priority" while line 15 asserts "standard", and both belong to one closed "one of" set (an object holds exactly ONE member). Keep one assertion, or widen the declared set.
returns-set.tg:20:1  error  logic/redeclaration  Conflicts with line 12. Mutually-exclusive values — this line asserts "standard" while line 12 asserts "priority", and both belong to one closed "one of" set (an object holds exactly ONE member). Keep one assertion, or widen the declared set.

 1 file — 2 error, 0 warning, 0 info
# exit 1
Two errors, same count as the metal version, different code. logic/contradiction is what the catalog proves; logic/redeclaration is what your declaration proves. The second is the one you can rely on across a rebrand, because it does not depend on the words.

The version that ships keeps one assertion and the set line, and gets its @@ notes while somebody is in the file anyway. The pointed detail is what the set declaration does at compile time: it is a declaration, so it binds and it renders. The line does two jobs for one line of source.

returns-final.tg then tg build - the set in the artifact✓ A (96/100)
<$CONFIG>
  $IMPORT tool get_account
</$CONFIG>

# Role
You are a support agent for Corvid Outdoor.

@@ tiers: the closed set marketing renamed in March, so a second value is a proven conflict
%Loyalty tier% ARE one of priority, standard, essential.
@@ free_returns: one fact, one place - both sections read it from here
The %free returns tier% IS priority.

# Constraints
@@ greeting: the name is in the CRM record every session opens with
- You MUST greet the customer by name.
@@ brevity: four sentences keeps a chat reply scannable
- You MUST keep every reply to at most 4 sentences.
@@ no_dates: a quoted date binds the company and the carrier does not honour it
- You MUST NOT promise a delivery date.

# Returns
@@ lookup: the tier decides the return cost, so read it before quoting one
- You MUST call @[get_account] before quoting a return cost.

# Loyalty
@@ no_upsell: upgrades are a marketing journey, not a support one
- You MUST NOT offer a tier upgrade.

$TEST tier_fact
  - input:: Which loyalty tier gets free returns?
  - expect::
    - contains "priority"
    - at most 3 sentences
0 error, 0 warning, 0 info. A (96/100), consistency 100, annotation 88, and coverage: 2/5 rules exercised from the one test case.
tg build returns-final.tg - the set line renders
$ npx typeglish build returns-final.tg
 built .typeglish/dist/returns-final.txt ← returns-final.tg (71804f06ab52, full)

# Role
You are a support agent for Corvid Outdoor.

Loyalty tier ARE one of priority, standard, essential. The free returns tier IS priority.

# Constraints
- You MUST greet the customer by name.
- You MUST keep every reply to at most 4 sentences.
- You MUST NOT promise a delivery date.
The %…% sigils strip and the set arrives in the deployed prompt as a sentence: the tier vocabulary, stated to the model, in the same line that gave the prover its set. Both declarations reflowed onto one line, which is prose behaviour, not a bug.

A tier that varies per conversation does not belong on that plane at all. It belongs on the machine plane, as a typed input, where the closed domain is what the compiler switches on. That version proves something the prose set cannot: coverage.

returns-runtime.tg - the tier as a typed input✓ 0 error
<$CONFIG>
  $IMPORT tool get_account
  $REQUIRE variable caller_tier: one of priority, standard, essential
</$CONFIG>

# Role
You are a support agent for Corvid Outdoor.

@@ tiers: the closed set marketing renamed in March, so a second value is a proven conflict
%Loyalty tier% ARE one of priority, standard, essential.

# Constraints
@@ brevity: four sentences keeps a chat reply scannable
- You MUST keep every reply to at most 4 sentences.
@@ no_dates: a quoted date binds the company and the carrier does not honour it
- You MUST NOT promise a delivery date.
@@ lookup: the tier decides the return cost, so read it before quoting one
- You MUST call @[get_account] before quoting a return cost.

# Returns
$SWITCH ON @{caller_tier}
  - priority:: Say that this return is free.
  - standard:: Say that the return label costs 3 pounds.
  - essential:: Say that the return label costs 5 pounds.
0 error, 0 warning, 3 info, B (83/100). Every member has an arm, and the compiler resolves exactly one of them, so the artifact never contains two return prices at once.
tg check - after a fourth tier joins the domain
$ npx typeglish check returns-runtime.tg
 1 file — 0 error, 0 warning, 3 info

# the trade tier launches; one word added to the domain, nothing else touched
$ npx typeglish check returns-runtime.tg
returns-runtime.tg:21:1  error  structure/non-exhaustive-switch  @{caller_tier} can be trade, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file — 1 error, 0 warning, 3 info
# exit 1
This is the payoff for typing the input rather than declaring the set in prose: the next tier your company launches fails the build at every switch that has not been updated. It is the same two-way lock that catches a plan you retired that the prompt still sells, run in the other direction.

§5What the set does not do

Two honest limits, both worth knowing before you go and add ARE one of lines to nine prompts.

It closes members against each other and nothing else. A closed set proves that one object cannot hold two members. It does not reject a value from outside the set, and it does not reach the rule plane, so two rules that gate on two different members of the same set are never compared.

limits.tg - a declared set, and three things it does not catch✓ 0 error, 0 warning, 0 info
# Role
You are a support agent for Corvid Outdoor.

%Loyalty tier% ARE one of priority, standard, essential.

# Constraints
- You MUST greet the customer by name.
- IF the caller is priority THEN waive the return label fee.
- IF the caller is standard THEN waive the return label fee.
The %free returns tier% IS trade.
Clean. Two guards that between them waive the fee for two thirds of the customer base draw nothing, because a rule that mentions a member is not an assignment. And trade, a value the declared set does not contain, is assigned without comment: the set says which members exclude each other, not which values are legal.

The prose declaration is the mechanism that fires. There is a $CONFIG domain command that takes a - members:: row, and its schema is enforced: leave the row off and you get config/missing-param. In 0.9.0 it is not the input the coordinate proof reads. The same two declarations that ARE one of turns into two blocking errors stay at 0 error under a declared $CONFIG domain.

tg check - $CONFIG domain against the same two lines
$ npx typeglish check config-domain.tg
config-domain.tg:11:1  warn   structure/split-declaration  Split declaration — "free returns tier" is declared in 2 separate statements (priority · standard) (line 10). Multiplicity must be spelled in ONE statement: "free returns tier IS priority and standard." — or a list.
config-domain.tg:12:1  warn   structure/split-declaration  Split declaration — "free returns tier" is declared in 2 separate statements (priority · standard) (line 9). Multiplicity must be spelled in ONE statement: "free returns tier IS priority and standard." — or a list.

 1 file — 0 error, 2 warning, 0 info
# exit 0, with $CONFIG domain loyalty_tier and - members:: priority, standard, essential declared
Write the set in the prompt, not in the config block. It is the shorter line anyway, and it has the side benefit of telling the model what the tiers are.

The habit that comes out of all this is smaller than it sounds, and it is not really about tiers. Every closed set your policy owns should be written down once, near the top, as a set. Queues, statuses, regions, plan names, severity bands, disposition codes. It costs a line each. What you get back is that the next time somebody states one of those facts twice, in a section you do not review, the build fails instead of the conversation.

§6Common questions

Why does my agent give two different answers about the same tier?
Because the prompt states the fact twice with two values, and nothing in the file says the two values are alternatives. A support prompt whose Returns section says the free returns tier is priority and whose Loyalty section says it is standard checks at 0 error, builds, and hands the model both sentences in one artifact. The model is not being inconsistent, it is picking. Declare the set once with a line like Loyalty tier ARE one of priority, standard, essential and the same two lines become 2 blocking logic/redeclaration errors.
How do I declare a closed set of tiers in a system prompt?
One statement with the ARE one of quantifier: Loyalty tier ARE one of priority, standard, essential. It is a declaration, so it binds the closed set for the checker and it also renders, arriving in the artifact as a sentence that tells the model the tier vocabulary. Membership is value-based rather than owner-scoped, so one such line anywhere in the file makes any later assignment of two members to one slot a proven conflict. For a tier that varies per conversation, declare it on the machine plane instead: $REQUIRE variable caller_tier: one of priority, standard, essential, then switch on it.
Why did renaming our loyalty tiers raise the TG score?
Because the old names were in the checker's coordinate catalog and the new ones are not. Gold and silver both name a metal, so two assignments to one slot are proven contradictions: 2 errors, consistency 0, grade capped at F (68/100). Rename the same three words to priority and standard and the proof disappears with them: 0 error, consistency 100, C (75/100), and the file builds. The rename changed nothing about the defect, only whether it was provable, which is the argument for writing the set down rather than relying on the vocabulary you happened to pick.
Does declaring the set catch conflicts between rules as well as facts?
No. A closed one of set proves that one object cannot hold two members, so it fires on declarations. It does not compare rules that merely mention members, and it does not reject a value from outside the set. A file that declares Loyalty tier ARE one of priority, standard, essential, then waives the same fee for a priority caller and a standard caller, and then assigns the tier the non-member value trade, is 0 error, 0 warning, 0 info. The set closes the members against each other and nothing else.
Field note

What makes this one uncomfortable is the direction the score moved. Every other defect we write about here gets worse-looking as it gets worse. This one got seven points better, and the audit trail for the change reads rename loyalty tiers per March brand guidelines, which is true and complete and tells you nothing. The prompt engineer did not make a mistake; the tooling script did not make a mistake; the proof was a side effect of a word choice nobody made on purpose in the first place. That is the argument for declaring your sets even when the check is currently green: a proof you did not write down is a proof somebody can delete without knowing it existed. The same reasoning is why two names for one agent is a blocking error rather than a warning, and why a per-conversation fact like a caller's tier is better off as a typed input than as free text. Write down the sets, then rebrand as often as marketing likes.

∿ washed up Aug 31, 2026 ∿