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 admin, 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: admin
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. A permission that was never written down does not exist.
authorization:
- role: operator
grants:
- on: records/payment
actions:
- create
- list
- on: records/quote
actions:
- read
- list
- on: llm/services/primary
actions:
- use
- role: admin
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/primary
actions:
- use
- role: owner
grants:
- on: records/payment
actions:
- read
- list
- on: records/users
actions:
- read
- list
The data model, declared not migrated
Entities become real tables with real constraints. unique, pattern, min_length and format are enforced by the database, not by a prompt and not by application code you maintain.
entities:
- name: users
fields:
- name: name
type: string
required: true
constraints:
unique: true
pattern: ^[a-z0-9_-]+$
min_length: 3
max_length: 30
- name: full_name
type: string
required: true
- name: email
type: string
constraints:
unique: true
format: email
The value the next entity will be held to
A quote carries a cap. On its own it is unremarkable — it matters because the payment below is asserted 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
This is the whole point of the contract. 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; a redirected value is refused with a 422 at the write, not talked out of by a better prompt.
- 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.
llm:
services:
- name: primary
provider: gemini
model: gemini-2.5-flash
priority: 10
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 tier again separate from this one — fixed words, 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`: 0.65 is this strategy's own default, and a number
# restated is a number that goes wrong the day the strategy changes.
# 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 payment cannot be redirected however the model is persuaded to answer. And it is here, diffable, in the document the compliance reviewer reads.
# 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.
That was all of it
No application code was written. The database schema, the permission model, the value bindings, the provider and the conversational behaviour are the document you just read — which is why compliance can review it, why it can be diffed in a pull request, and why it runs identically for every tenant it is published to.