← Tidelines/Best practices

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.

by TypeGlish team11 min read#best-practices
Sixteen digits, already in the log.

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

pii.tg - the section as written✗ D (59/100)
# 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.
tg check pii.tg - seven findings, nothing blocking✗ 0 error, 3 warning, 4 info
$ 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
Note the tick and the exit code. Seven findings and not one of them blocks a build, which is the correct behaviour and also the reason this section has survived two years: nothing in your pipeline has ever refused it. The lowest-value line is the third one, 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.
tg score pii.tg - the ledger✗ D (59/100)
$ 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.

tg check and score - one line removed✓ C (69/100)
$ 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)
Ten points of grade for a deletion, and 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.

the branch, in the plane that can prove it
$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.
A $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.

tg check - the value in the artifact twice✗ 1 warning
  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
The last sentence is the diagnostic doing arithmetic on your behalf: it has worked out that one binding of the input puts both copies of the URL in one prompt. The fix is to pick which copy the customer needs. The rule can point at the page by name (%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.

pii-shipped.tg - detection, branch, wording, tool✓ A (98/100)
$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"
tg check, score and test --dry - pii-shipped.tg✓ A (98/100)
$ 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.
tg build --vars and --bundle - two branches, two artifacts✓ 406 / 291 bytes
$ 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}" } ] }
406 bytes and 291 bytes against the 412 the original built to (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: boolean with a $IF chain over it, a quoted apology, and a $TOOL bound 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 security facet covers credential literals and injection-shaped text, which is why a shipped prompt carrying 4111 1111 1111 1111 inside a $TEST input still reports security 100 and no security/ 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. and MUST NOT emit the card number. normalise to the same key, and the checker reports both lines as clarity/duplicate reading suppress the card number. is forbidden in 2 places. Deleting one of them took the prompt in this post from D (59/100) to C (69/100) with consistency going 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_seen set to true and you get 406 bytes ending in the apology and the tool call, build it false and you get 291 bytes ending in NEVER ask a customer for a card number., and neither document contains the other branch.
Field note

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.

∿ washed up Sep 9, 2026 ∿