Rewrite: the customer just pasted their card number
Every support prompt has a section about sensitive data, and it is usually seven lines of nobody's best work. Here is what happens when you compile one, and what it looks like when the three jobs inside it are separated.
TL;DR A data-handling section is three jobs wearing one coat: detection belongs to a filter ($REQUIRE variable card_number_seen: boolean), the branch belongs to the compiler ($IF / $ELSE, so the losing arm never ships), and only the wording and the tool call belong to the prompt; separating them took a real seven-line section from D (59/100) to A (98/100), and the first two points came from deleting a line, because MUST suppress the card number and MUST NOT emit the card number are the same rule to the compiler.
Stanbrook Travel takes balance payments on a secure page. Its chat support agent is not supposed to be anywhere near a card, and several times a day a customer pastes sixteen digits into the chat window anyway, because the customer is trying to be helpful. What happens next is governed by a section of the system prompt titled # Security that was written in about four minutes, has never been reviewed, and is the only place in the company where this behaviour is specified.
§1Before: seven lines, zero errors, D
# Role You are a chat support agent for Stanbrook Travel. # Security - SHOULD try to avoid storing sensitive customer information where possible. - MUST handle personal data carefully. - ALWAYS be careful with card details. - MUST suppress the card number. - MUST NOT emit the card number. - If a customer sends card details, you should politely tell them not to. - NEVER save the card number in the case note.
$ typeglish check pii.tg pii.tg:5:10 info prompt/hedging Hedging - "try to" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD). pii.tg:5:62 info prompt/hedging Hedging - "where possible" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD). pii.tg:7:1 warn prompt/unmeasurable Unmeasurable rule - no observable action or bound, so nothing can check compliance. Name a concrete action ("cite the source", "at most 3 sentences") or move it to a prose block. pii.tg:8:1 warn clarity/duplicate Duplicate rule - "suppress the card number." is forbidden in 2 places (also line 8). State it once and reference it. pii.tg:9:1 warn clarity/duplicate Duplicate rule - "suppress the card number." is forbidden in 2 places (also line 7). State it once and reference it. pii.tg:10:1 info typeglish/if-then IF needs a THEN - write IF <condition> THEN <action>. pii.tg:11:1 info prompt/unintroduced-definite "the case note" retrieves something this document never introduces - a model must guess which note is meant. Introduce it on another line ("You manage a note.") or name it outright. ✓ 1 file - 0 error, 3 warning, 4 info # exit 0, and it builds
ALWAYS be careful with card details, and prompt/unmeasurable says why in eleven words: no observable action or bound, so nothing can check compliance. It is a sentence that makes a reviewer feel better and gives a model nothing to do.$ typeglish score pii.tg pii.tg - D (59/100) proven errors: none tiers: base+z3 planes runtime 64 (what the model reads) · hygiene 46 (source only) facets enforceability 72 x.21 · hardness 79 x.12 · directness 89 x.08 consistency 25 x.17 · structure 91 x.12 · annotation 0 x.12 style 34 x.08 · security 100 x.08 lever consistency 25/100 (up to +13 overall) L7 prompt/unmeasurable -1 Unmeasurable rule - no observable action or bound L8 clarity/duplicate -1 Duplicate rule - "suppress the card number." is L9 clarity/duplicate -1 Duplicate rule - "suppress the card number." is L5 prompt/hedging -0.25 Hedging - "try to" turns this instruction into a L5 prompt/hedging -0.25 Hedging - "where possible" turns this instruction L10 typeglish/if-then -0.25 IF needs a THEN - write IF <condition> THEN <action> L11 prompt/unintroduced-definite -0.25 "the case note" retrieves something this document
security 100, on the security section. That is not irony, it is a definition worth internalising: the security facet is about credential literals and injection-shaped text in the file, per typeglish --explain security, and it has no opinion whatsoever about whether your data-handling rules work. Nothing in this toolchain, or any other, scores a policy. It scores the document.Two of the seven findings are the same story as the rules that say usually, so I will not relitigate hedging here. The interesting one is the pair on lines 8 and 9.
§2Two of your rules are one rule
Suppress the card number and do not emit the card number are, to a human reviewer, a belt and braces. To the compiler they are one key. The emission verbs form a single polarity system: a negative-pole verb folds onto its positive head, so a requirement to suppress and a prohibition on emitting normalise to the same rule, and the checker reports it in the vocabulary of the prohibition.
redact, withhold, suppress, hide, omit, exclude, block, skip. Every one of them is emit with a minus sign, and your prompt probably uses three.
Delete one line. Nothing else.
$ typeglish check pii-folded.tg # the two lines collapsed to: NEVER emit a card number. ✓ 1 file - 0 error, 1 warning, 4 info $ typeglish score pii-folded.tg pii-folded.tg - C (69/100) proven errors: none tiers: base+z3 planes runtime 77 (what the model reads) · hygiene 45 (source only) facets enforceability 65 x.21 · hardness 76 x.12 · directness 88 x.08 consistency 100 x.17 · structure 89 x.12 · annotation 0 x.12 style 25 x.08 · security 100 x.08 lever annotation 0/100 (up to +12 overall)
consistency from 25 to 100: the facet whose job is comparing rules to each other is satisfied the moment no two rules claim the same key. Two facets went the other way, which is the honest part. enforceability dropped from 72 to 65 and style from 34 to 25, because both are ratios over the lines that remain, and the line that left was one of the two in the section carrying a real verb and a real object. Removing dead weight makes the remaining weight a larger share of the file.§3The trigger is a filter's job
Now the line that cannot work. If a customer sends card details, you should politely tell them not to asks the model to notice sixteen digits, decide they are a card, and respond. Set aside the missing THEN and the should. The structural problem is that by the time the model is in a position to notice the digits, the digits have been through your transport, your logging, and probably your analytics. Detection is not a decision. It is a regular expression that has to run before the turn does.
So the prompt does not detect. It receives a verdict and branches on it, at compile time, the same way your policy remembers and your agent does not: whatever the host already knows should arrive as a typed input rather than as something the model is asked to work out.
$REQUIRE variable card_number_seen: boolean $IF @{card_number_seen}: ALWAYS reply with "I can't take card details in chat, so I have removed that message. You can pay securely on our payment page." ALWAYS call @[redact_message]. $ELSE: NEVER ask a customer for a card number.
$IF chain is a compiler directive, not a sentence the model weighs. One arm is written into the artifact and the other one does not exist in the document that reaches the model, which means the branch cannot be reasoned around. The $ELSE arm is not filler either: on the overwhelming majority of contacts, where no digits have appeared, the only card-related instruction the agent gets is the one that stops it asking.§4The wording is signed, and the URL is a fact
The apology is compliance copy. Inside straight double quotes nothing is parsed as an operator or a declaration, so the sentence travels as approved rather than as a paraphrase, and the quotes strip at compile time. The payment page is different: it is a fact, so it goes in the Role block as a declaration, which in this language both binds the name and renders as an instruction.
Do both carelessly and you get a finding that is easy to miss and expensive in tokens.
The %payment page% is stanbrook.example/pay. ... ALWAYS reply with "... You can pay securely at @{payment page}." $ typeglish check pii-url.tg pii-url.tg:19:1 warn prompt/duplicated-declaration The model reads this value twice - this declaration renders AND @{payment page} expands it (line 20). If the repetition is deliberate emphasis, keep it; otherwise reword this line or the reference so the value appears once. A single serve can hold both - e.g. when card_number_seen = true. ✓ 1 file - 0 error, 1 warning, 0 info
%payment page% is a name literal, several words bound as one token), and the apology can say on our payment page, leaving exactly one URL in the artifact, in the Role block, where a person changing it can find it.§5After: three planes, one section
The finished file is longer than what it replaced and most of the new length is scaffolding the model never sees: an input declaration, a service, a tool, annotations, and two tests.
$REQUIRE variable card_number_seen: boolean $SERVICE contacts - base:: https://api.stanbrook.example - headers:: - Authorization:: Bearer @{env.CONTACTS_KEY} $TOOL redact_message - description:: Removes a customer message from the transcript and leaves a redaction marker in its place. - input:: - contact_id:: string - reason:: one of card_number, bank_details, other - request:: POST contacts /contacts/@{contact_id}/redact # Role @@ role: the addressee every rule below is about You are a chat support agent for Stanbrook Travel. @@ payment_page: stated once, so the rule and the reply copy cannot drift apart The %payment page% is stanbrook.example/pay. # Constraints @@ never_emit: the whole data-handling section, folded to the one rule the compiler reads - NEVER emit a card number. @@ payment_route: a card payment belongs on the payment page, never in a chat turn - ALWAYS direct a payment request to the %payment page%. @@ brevity: three sentences keeps a chat reply scannable - MUST keep every response to at most 3 sentences. @@ redaction: the filter saw the digits, so the arm is decided before the model reads anything $IF @{card_number_seen}: @@ apology_copy: compliance signed this wording, so it is quoted and not paraphrased ALWAYS reply with "I can't take card details in chat, so I have removed that message. You can pay securely on our payment page." @@ redact: the transcript is evidence, so the digits come out of it before the case closes ALWAYS call @[redact_message]. $ELSE: @@ no_solicit: the agent must never be the reason a card number arrives NEVER ask a customer for a card number. $TEST card_pasted - input:: Here is my card, 4111 1111 1111 1111, please take the balance. - expect:: - contains "pay securely" - matches /^(?!.*[0-9]{6})/ - at most 3 sentences $TEST asks_how_to_pay - input:: How do I pay the rest of my balance? - expect:: - contains "stanbrook.example/pay"
$ typeglish check pii-shipped.tg ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish score pii-shipped.tg pii-shipped.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 99 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 100 x.12 style 100 x.08 · security 100 x.08 lever enforceability 90/100 (up to +2 overall) $ typeglish test pii-shipped.tg --dry ✓ pii-shipped.tg coverage: 2/6 rules exercised · card_pasted - "Here is my card, 4111 1111 1111 1111, please take the balanc" (not run) ✓ contains "pay securely" ✓ matches /^(?!.*[0-9]{6})/ ✓ at most 3 sentences · asks_how_to_pay - "How do I pay the rest of my balance?" (not run) ✓ contains "stanbrook.example/pay" ✓ 1 prompt - 0 failed
D (59/100) to A (98/100). And look at the test input: a live card number sits in this file, and the file still reports security 100 with no security/ finding, because that facet is about credentials and injection scaffolding. The assert that does the work is the negative lookahead, matches /^(?!.*[0-9]{6})/, which is the closest an offline check gets to the reply contains no card: it proves the shape of the answer without needing a model.$ typeglish build pii-shipped.tg --vars '{"card_number_seen":true}' --bundle ✓ built .typeglish/dist/pii-shipped.txt ← pii-shipped.tg (c6048f7709c4, full) # Role You are a chat support agent for Stanbrook Travel. The payment page is stanbrook.example/pay. # Constraints - NEVER emit a card number. - ALWAYS direct a payment request to the payment page. - MUST keep every response to at most 3 sentences. ALWAYS reply with I can't take card details in chat, so I have removed that message. You can pay securely on our payment page. ALWAYS call redact_message. $ typeglish build pii-shipped.tg --vars '{"card_number_seen":false}' ✓ built .typeglish/dist/pii-shipped.txt ← pii-shipped.tg (1649909603b7, full) # Constraints - NEVER emit a card number. - ALWAYS direct a payment request to the payment page. - MUST keep every response to at most 3 sentences. NEVER ask a customer for a card number. $ cat .typeglish/dist/pii-shipped.agent.json "name": "redact_message", "params": [ { "name": "contact_id", "required": true, "type": "string" }, { "name": "reason", "required": true, "type": "string", "enum": [ "card_number", "bank_details", "other" ] } ], "binding": { "method": "POST", "url": "https://api.stanbrook.example/contacts/@{contact_id}/redact", "headers": [ { "name": "Authorization", "value": "Bearer @{env.CONTACTS_KEY}" } ] }
18859539978a), so the version that does more is smaller than the version that said less. The bundle is where the reason code stops being a string: one of card_number, bank_details, other compiles to an enum, so a redaction logged against a reason nobody defined is a schema failure rather than a data-quality problem discovered in an audit six months later. The API key stays a reference, which is the only shape it is allowed to have; the reason why is why your agent will read out your API key.What the rewrite does not do is promise the digits never arrive. Nothing in a prompt can promise that. It draws a line: the filter owns detection, the tool owns removal, the artifact owns the words, and each of those is testable by whoever owns it. The seven-line version owned all three jobs and could not do any of them, which is the shape of most sections titled after a risk rather than after an action.
§6Common questions
- What should my AI agent do if a customer sends their card number in chat?
- Three things, and only one of them is a sentence. A filter outside the model detects the digits and hands the prompt a boolean; the prompt branches on it at compile time, so the arm the model reads is decided before the turn starts; and the reply is signed copy in straight double quotes plus a call to a redaction tool that takes the message out of the transcript. In TypeGlish that is
$REQUIRE variable card_number_seen: booleanwith a$IFchain over it, a quoted apology, and a$TOOLbound through a$SERVICE. The prompt cannot be the detector, because by the time the model is reading the digits they are already in your logs. - Does TypeGlish detect a card number in my prompt?
- No, and it is worth being precise about that. The
securityfacet covers credential literals and injection-shaped text, which is why a shipped prompt carrying4111 1111 1111 1111inside a$TESTinput still reportssecurity 100and nosecurity/finding at all. What the checker does to a data-handling section is what it does to any other rules: it finds the hedges, the unmeasurable claims and the rules that duplicate each other. Detecting cardholder data in a live transcript is a filter's job on the host side, and the prompt's job is to have one arm ready for the moment that filter fires. - Why does my prompt have two rules that mean the same thing?
- Because English has two ways to say one policy and the compiler only has one. The emission verbs are a single polarity system, so a negative-pole verb folds onto its positive head:
MUST suppress the card number.andMUST NOT emit the card number.normalise to the same key, and the checker reports both lines asclarity/duplicatereading suppress the card number. is forbidden in 2 places. Deleting one of them took the prompt in this post fromD (59/100)toC (69/100)withconsistencygoing 25 to 100, without changing the policy by a word. Grep your own rules for redact, withhold, suppress, hide, omit, exclude and block, because every one of them is the same verb as emit with a minus sign. - Should a PII rule live in the system prompt or in the runtime?
- Both, with different jobs, and the split is the whole design. The runtime owns detection and redaction, because those are deterministic and the prompt cannot see the transcript. The prompt owns the words the customer reads and the tool the agent calls, because those are the parts a model produces. The test of whether you have drawn the line correctly is the artifact: build with
card_number_seenset to true and you get 406 bytes ending in the apology and the tool call, build it false and you get 291 bytes ending inNEVER ask a customer for a card number., and neither document contains the other branch.
Sections named after a risk age worse than sections named after an action. # Security, # Compliance, # Data handling: each one becomes a place to put a sentence, and a sentence in one of them is never deleted, because deleting a line under a heading like that feels like removing a control. So the section grows, every quarterly review adds a synonym, and the compiler eventually tells you that four of the lines are two rules. Two habits stop the drift. Name the section after what happens in it, so a new line has to describe an action to belong. And before adding a rule, run the file: if the finding that comes back is clarity/duplicate, the rule you were about to write is already there in somebody else's vocabulary, and what you actually wanted was to be sure, which is what the tests are for.