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.

Back to the home