Every block on this page is verbatim from contracts/param_binding.yaml, in the order the file declares it. A test compares them to the file on every build, so this page cannot drift from the contract it claims to show.
What the document is
A version and a domain, then the comment the authors left for the next reader. This contract exists to demonstrate one thing: a payment whose recipient and amount cannot be redirected by contaminated content, because the enforcement is deterministic and happens at the write.
spec_version: "1.0"
metadata:
domain: param_binding
# ADR-0106 GTM demo — the canonical parameter-injection attack, blocked.
# A `payment` records a transfer whose:
# - recipient is server-bound to the authenticated actor (bind),
# - amount must not exceed the anchored quote's cap (assert),
# - quote anchor cannot be re-pointed once set (immutable_after_set).
# Contaminated retrieved content cannot redirect the recipient or inflate the
# amount: enforcement is deterministic at the write choke point.
# Quotes are issued by quote_issuer, never by the operator role the agent acts as: an
# assert is only as strong as its anchor, so the agent cannot set its own cap.
Who can exist
Two roles. auto_assign means a new user becomes an operator without anyone granting it. Everything else on this page is decided per role, so this short block is what the rest of the document refers to.
roles:
- name: operator
auto_assign: true
- name: quote_issuer
What each role may do
Authorization is a role-centric list, never inline on an entity. Each grant names a path (records/payment, llm/services/primary) and the actions allowed on it. Note what operator does not have: no read on records/payment, no access to records/users at all.
authorization:
- role: operator
grants:
- on: records/payment
actions:
- create
- list
- on: records/quote
actions:
- read
- list
- on: llm/services/backup
actions:
- use
- on: llm/services/primary
actions:
- use
- role: quote_issuer
grants:
- on: records/payment
actions:
- read
- list
- on: records/quote
actions:
- create
- read
- list
- on: records/users
actions:
- create
- read
- update
- list
- on: llm/services/backup
actions:
- use
- on: llm/services/primary
actions:
- use
- role: admin
grants:
- on: llm/services/backup
actions:
- use:
scope:
- contract
- runtime
- on: llm/services/primary
actions:
- use:
scope:
- contract
- runtime
- role: owner
grants:
- on: records/payment
actions:
- read
- list
- on: records/users
actions:
- read
- list
The data model, declared not migrated
Each entity and its fields are declared in the contract, with no migration to write. unique, min_length and format are enforced by the runtime at every write (unique by the database itself), not by a prompt and not by application code you maintain.
entities:
- name: users
fields:
- name: name
type: string
required: true
constraints:
unique: true
min_length: 3
- name: full_name
type: string
- name: email
type: string
constraints:
unique: true
format: email
The value the next entity will be held to
A quote carries a cap. It matters because the payment below is checked against it, and because the reference to it cannot be moved once written.
- name: quote
fields:
- name: cap
type: currency
required: true
config:
default_currency: USD
precision: 2
constraints:
minimum: 0
The three bindings, and the attack they close
bind means the server derives the recipient from the authenticated actor: a value the model proposes is overwritten, not rejected, so there is nothing to argue with. immutable_after_set anchors the quote so the assertion below cannot later be re-pointed at a different, cheaper quote. assert holds the amount to that anchored cap: an amount above it is refused with a 422 at the write, whatever the prompt says, and an amount redirected below it still passes, because an assert bounds a value without choosing it.
- name: payment
fields:
# bind — server derives the recipient from the authenticated actor; the
# LLM's proposed value is overwritten, not rejected.
- name: recipient
type: string
binding:
bind: actor.user_name
# immutable_after_set — anchor the quote so the assert below cannot be
# re-pointed at a different (RLS-visible) quote after the first write.
- name: quote
type: reference
references: quote
binding:
immutable_after_set: true
# assert — the amount must not exceed the anchored quote's cap; a value
# redirected by contaminated content is rejected 422 at the choke point.
- name: amount
type: currency
required: true
config:
default_currency: USD
precision: 2
constraints:
minimum: 0
binding:
assert: "value <= quote.cap"
The model is an interface
The provider and model are a declaration, and the runtime resolves credentials per deployment. Changing this block changes which model runs. Nothing above it moves: the bindings, the grants and the constraints are the same document whichever provider answers.
# Every turn starts on `primary`, Anthropic's `claude-sonnet-5-5` (#2151). `backup` is Gemini, whose
# credential the topic gate's embeddings need anyway: a turn reaches it on an outage of `primary`
# (408, 429, 5xx or no answer, #2008) or when `primary` holds no credential, never on another error.
llm:
services:
- name: primary
provider: anthropic
model: claude-sonnet-5-5
priority: 10
- name: backup
provider: gemini
model: gemini-3.5-flash-lite
priority: 20
What it may talk about, and who decides
strategy is a choice about what you pay to have topic decided. Keyword overlap is free and cannot tell “I want to send the money we agreed on” from a question about the weather (both match nothing), so it turns nothing away. This contract pays: embedding_similarity compares meaning, so it tells those two apart, at one embedding call per turn and a credential for the service declared above. Refusing a regulated category outright is a separate tier, with fixed words, and it fails closed.
# The embedding catalog the topic gate below embeds with. Separate from `llm`
# because it is a different model with its own credential: the secret keys to a
# service NAME here, so a declaration must exist for it to point at.
embeddings:
services:
- name: primary
provider: gemini
model: gemini-embedding-001
# `strategy` picks WHO decides what this assistant will discuss, and the choice is
# a cost. `auto_derived` scores keyword overlap: free, no network, and unable to
# tell a request phrased differently from an unrelated one — "quiero mandarle plata
# al proveedor" matches nothing here, exactly like a question about the weather —
# so it turns nothing away. This contract declares `embedding_similarity` instead:
# it compares meaning rather than words, so it can tell those two apart, and it
# costs one embedding call per turn plus a credential for the service above.
# `description` is what gets embedded; `off_topic_instruction` is the voice of a
# refusal. Both were inert under the previous strategy and both decide here.
# No `confidence_threshold`: this strategy's default is measured on this model,
# and a number restated is a number that goes wrong the day the default moves.
# Whatever the gate decides, the runtime still tells the model unconditionally to
# emit `conversation.out_of_scope` for a message outside scope, and the misuse
# detector counts those.
treatment:
topic_scope:
strategy: embedding_similarity
embedding_service: primary
description: Help operators record payments against approved quotes, under
bound, asserted, and immutable field constraints.
off_topic_instruction: Politely decline off-topic requests and redirect the user
to recording payments against approved quotes.
And the one block here that is not enforced
Everything else in this file is enforced: the database, the binding and the grant decide, whatever the model proposes. These lines are the exception, and the contract says so where they are. The persona and the rules are text handed to the model, and a model can ignore text: the shouted NEVER is a request, not a constraint. What makes it safe to keep persuasion in this document is that nothing above depends on it: the recipient cannot be redirected and the amount cannot pass its cap, however the model is persuaded to answer. And it is here, in the document the compliance reviewer reads, where every applied change raises the contract's version and is recorded.
# EVERYTHING BELOW IS NOT ENFORCED. The persona and the rules are text handed to
# the model, and a model can ignore text — the shouted NEVER is a request, not a
# constraint. That is safe here because nothing above depends on it: the bind and
# the assert hold whatever the model is persuaded to answer. It lives in this
# document, versioned and diffable, rather than in a prompt buried in code.
persona: |
You are an operations assistant for recording payments against approved quotes.
You record transfers accurately and never invent values.
base_response_rules:
- ALWAYS respond in the SAME LANGUAGE the user wrote their message in.
- NEVER ask the user for system-level information such as tenant ID, user
ID, or session ID.
data_integrity_rules:
- NEVER fabricate or assume data values that are not present in the result
data.
No application code
The database schema, the permission model, the value bindings, the provider and the conversational behaviour are the document you just read. That is why compliance can review it, why a pull request can show every change to it, and why it runs identically for every tenant it is published to.