Cookbook
These recipes chain the nine MCP tools into agent workflows you can ship: a governed coding assistant, an export gate, a CI gate, a RAG ingestion gate, a red-team harness, and the request-and-retry loop that turns a blocked answer into a resolved one. Answers describe your workspace's published governance state, and your agent or pipeline decides what to do with them.
Two posture notes before you start. Every recipe here except the last uses only read and advisory tools — they never change your governance. The last one calls request_access, which writes: it files a review request in a human's inbox. Confirm with your user before calling it, and read Request access first.
State a purpose. Every authorize_use and validate_query_context call in these recipes passes purpose_key where a usage-guidance rule is in play. That is not decoration: on assets governed by permitted-uses rules, a purpose-blind call fails closed to review_required rather than riding an allow. See Purpose keys.
Every decision-bearing answer is typed, so wire your reactions up front: answered carries a decision you can act on, review_required means published rules exist but a human must decide, and not_enough_published_state means the workspace has not published enough governance to answer — with a reason code naming exactly what is missing. The first five recipes follow one rule: only an answered decision drives automation; every other state stops it. The sixth is what to do when it stops. See Typed answer states for the full model.
Each recipe is grounded in a runnable notebook from the metatate-examples repository. Snippets use that repo's thin Python client, whose methods map 1:1 onto tool calls, so the same sequences work from any connected client. They assume a workspace serving a current publication; the sample workspace answers every recipe out of the box, which is why the snippets reference master assets:
def asset(table, column=None):
product_tables = {"support_tickets", "product_usage_events", "ml_feature_store"}
database = "product" if table in product_tables else "master"
ref = {"database": database, "schema": "public", "table": table}
if column:
ref["column"] = column
return ref
A governance-aware coding assistantDirect link to A governance-aware coding assistant
Give a coding assistant a decision layer: before it writes code against a table, it learns what the data means and whether the intended use is allowed. Chain discover_context → inspect_data_meaning → authorize_use:
governed = client.discover_context() # what is governed, with scenario keys
facts = client.inspect_data_meaning(asset("customers", "email"))
answer = client.authorize_use(
asset("customers"),
use="build a churn analytics dashboard",
scenario_key="purpose.allowed_use",
purpose_key="analytics.reporting",
)
The meaning call returns facts only — classification, sensitivity, PII flag, masking — never a decision. The authorize call returns state: answered, decision: allow, can_proceed_now: true, plus two chainable ids: a decision_id for the winning published rule and an authorization_id for the evaluation itself. Pass either to explain_why — the second replays which purpose and which token role were used. Drop purpose_key from that call and the same published rule answers review_required, because the permitted-uses rule will not settle a purpose-blind question. The same asset under purpose.prohibited_use ("launch a marketing campaign on customer contact data") is an answered deny citing the policy. For a one-call overview of a table, get_decision_context returns every applicable instruction ranked, the effective decision, and the published business context — owner, steward, domain, purpose — so the assistant can name a data owner instead of guessing one.
React by state: on allow, generate the code and keep the decision_id; on deny, refuse and quote the reason; on review_required or not_enough_published_state, say governance is unresolved instead of guessing. Notebook: 01_decision_layer_cookbook.
Purpose-bound agent data windowsDirect link to Purpose-bound agent data windows
Use a declared purpose and the token's bound role to obtain an exact data
window, then prove the SQL stays inside it. The sample's master policy is
rolling (research 90 days, commercial 30); its product policy is date-of
anchored with the same durations:
context = {"as_of": "2026-08-01T00:00:00Z"}
answer = client.authorize_use(
asset("product_usage_events"),
use="research product behavior as of the reporting cutoff",
scenario_key="access.read",
purpose_key="research.general",
data_access_context=context,
)
verdict = client.validate_query_context(
"SELECT * FROM product_usage_events "
"WHERE occurred_at >= TIMESTAMPTZ '2026-05-03T00:00:00Z' "
"AND occurred_at <= TIMESTAMPTZ '2026-08-01T00:00:00Z'",
default_database="product",
default_schema="public",
scenario_key="access.read",
purpose_key="research.general",
data_access_context=context,
)
The authorization returns the exact time_column, type, duration, anchor, and
lower bound. Missing purpose or as-of context yields
access_window_context_required; a missing, wider, ambiguous, OR, negated,
or wrong-column SQL bound fails rather than being approximated. Notebook:
16_purpose_bound_agent_data_windows.
Transfer governance before an exportDirect link to Transfer governance before an export
Before an export job runs, ask the transfer question for the actual destination. authorize_use accepts operation, destination {system, jurisdiction}, and consumer_jurisdiction, and the server evaluates the authored transfer rules per destination at read time:
answer = client.authorize_use(
asset("customers"),
use="sync approved customer fields to the CRM",
scenario_key="residency.cross_border_transfer",
operation="export",
destination={"system": "SALESFORCE", "jurisdiction": "US"},
consumer_jurisdiction="EU",
)
On the sample workspace this is state: answered, decision: conditional with structured conditions (approval_required, anonymize_first) and can_proceed_now: false; an advertising platform or external LLM vendor destination is a deny; a destination no rule names falls to the policy's authored default effect — nothing is silently allowed. Gate the job on can_proceed_now, hold conditional exports until the conditions are satisfied, and chain explain_why(answer["decision_id"]) into the approval record. Notebook: 03_transfer_governance_before_export.
A CI gate for data and AI changesDirect link to A CI gate for data and AI changes
Stop pull requests that ship queries or AI workflows governance would reject. For each change in the PR's change set, call validate_query_context with the change's SQL and its canonical scenario_key:
answer = client.validate_query_context(
"SELECT customer_name, email FROM customers WHERE region = 'EU'",
scenario_key="purpose.allowed_use",
purpose_key="analytics.reporting",
default_database="master",
default_schema="public",
)
# answer["verdict"] -> "warn": the query references a masked column
Map verdicts to gates: pass merges, warn becomes required controls ("mask or drop email"), fail blocks. Findings cite the participating instructions per referenced table, so the gate comment names the policy. Two rules keep the gate honest: a governed table with no published instruction state arrives as an explicit not-enough-state finding — fail it in strict pipelines rather than silencing it — and the query_parse_failed / query_unresolved_identifier errors mean the query was not validated, so fail the gate (see Troubleshooting). Notebook 06_ci_gate_for_data_ai_changes packages this as pass / needs_controls / fail gates with reason codes, and its strict runner's exit code blocks the merge.
A pre-ingestion gate for RAG and embeddingsDirect link to A pre-ingestion gate for RAG and embeddings
Nothing should enter a retrieval index or embedding store without a decision. For each candidate corpus, authorize the AI scenario that matches how the data will be used (ai.training, ai.inference, ai.embedding_storage, …) and ingest only on an answered allow:
answer = client.authorize_use(
asset("support_tickets"),
use="fine-tune a support assistant on ticket text",
scenario_key="ai.training",
)
ingest = answer["state"] == "answered" and answer.get("decision") == "allow"
On the sample workspace, training on raw ticket text is a deny, while LLM inference over customer accounts is an allow — the gate keeps the corpus honest in both directions. Treat anything that is not answered-allow as a skip: deny, conditional, review_required, and not_enough_published_state all keep data out of the index until governance says otherwise. Then validate the retrieval query that feeds the index with validate_query_context and require a pass. Notebook: 07_governed_rag_embedding_ingestion_gate.
A red-team evaluation harnessDirect link to A red-team evaluation harness
Prove, repeatably, that risky prompts stay denied and a safe control stays allowed. Each case is a tool call plus the exact label it must produce, where the label is the decision when the state is answered and the state itself otherwise:
CASES = [
("marketing exfil", lambda: client.authorize_use(
asset("customers"),
use="launch a marketing campaign on customer contact data",
scenario_key="purpose.prohibited_use"), "deny"),
("safe control", lambda: client.authorize_use(
asset("customers"),
use="build a churn analytics dashboard",
scenario_key="purpose.allowed_use",
purpose_key="analytics.reporting"), "allow"),
("purpose-blind control", lambda: client.authorize_use(
asset("customers"),
use="build a churn analytics dashboard",
scenario_key="purpose.allowed_use"), "review_required"),
]
for name, call, expected in CASES:
answer = call()
label = answer["state"] if answer["state"] != "answered" else answer["decision"]
assert label == expected, f"{name}: expected {expected}, got {label}"
The full matrix also asserts denials for fine-tuning on ticket text and for an external-LLM-vendor export. Any mismatch fails the run — including a not_enough_published_state appearing where a deny was expected, which is a coverage regression caught before an agent meets it. The purpose-blind control is worth keeping permanently: it proves the fail-closed rule still holds, so a future policy edit cannot quietly turn an unstated purpose into an allow. Run the harness in CI and after every policy change. Notebook: 05_agent_red_team_evaluation_harness.
Turning a blocked answer into a resolved oneDirect link to Turning a blocked answer into a resolved one
The other five recipes stop when governance says no. This one keeps going — through a human, not around one.
It is the only recipe that writes. Read Request access before shipping it, and note the two prerequisites: the token needs the request scope (a {read} token is refused with insufficient_scope before its input is parsed), and your client must confirm with its user before the write. Metatate performs no server-side confirmation; the honest readOnlyHint: false annotation is what a compliant client prompts on.
answer = client.authorize_use(
asset("customers"),
use="sync approved customer fields to the CRM",
scenario_key="residency.cross_border_transfer",
operation="export",
destination={"system": "SALESFORCE", "jurisdiction": "US"},
consumer_jurisdiction="EU",
)
if answer["state"] == "answered" and answer.get("can_proceed_now"):
run_export()
elif confirm_with_user(answer): # your prompt, not Metatate's
filed = client.request_access(
source={"kind": "authorization", "id": answer["authorization_id"]},
note="Quarterly CRM sync; approved fields only.",
)
React to what request_access returns:
state: "submitted"— hold the work and recordrequest_id.eligibility: "exception_grantable"means a waiver is a legal remedy here;not_waiverablemeans it is not, and the honest outcome will be a policy change.resubmitted: truemeans an identical live request already existed and yours was absorbed into it.state: "not_eligible"withuse_not_blocked— the estate moved and the use is now proceedable. Re-runauthorize_useand continue; nothing was filed.state: "not_eligible"withopen_request_limit— this token holds 100 open requests. Drain them before filing more.
Then poll on a human timescale and retry only on a real, active grant:
status = client.check_request(request_id=filed["request_id"])
if status["resolution_kind"] == "exception_granted" and status["exception"]["state"] == "active":
retry = client.authorize_use(
..., # the SAME original arguments
satisfied_conditions=[{"kind": "approval",
"exception_id": status["exception"]["exception_id"]}],
)
if retry["can_proceed_now"]:
run_export()
Three rules keep this honest:
- Gate on
can_proceed_now, never on holding anexception_id. The server verifies every cited exception in SQL — subject, liveness, scope — and a failed verification returnsnot_verifiedwith the answer unchanged. Holding an id proves nothing. - Retry with the original arguments. The exception's scope is bound to the request's asset, scenario, and purpose category. Change the question and it stops matching.
- Stop on the other two resolutions.
deniedis terminal, though re-filing later is allowed if published state changes.policy_change_requiredmeans the remedy is authoring and republishing — retry after the next publication, and never read it as a soft yes. Conflicted and insufficient-state answers can only ever resolve todeniedorpolicy_change_required; no exception can override them.
Revocation is immediate: if a steward revokes the exception, the next retry echoes not_verified and can_proceed_now returns to false. Design the loop to re-check rather than to cache a verdict.
The steward's half of this loop lives in the app, on the Review requests tab of the Activity module (/[tenantSlug]/activity/review-requests, admins and owners only) — see Review requests.
Offline and live modeDirect link to Offline and live mode
The notebooks run offline by default, replaying answers recorded from a live workspace — no account needed, and the payloads you study are shaped exactly like the live endpoint's, typed states and all. The recordings are internally consistent, so even decision_id chaining into explain_why works offline. Live mode runs the same notebooks against your own workspace: grab the endpoint from the Connect tab and a token from the Tokens tab of the workspace MCP module, then export the settings before starting Jupyter:
export METATATE_EXAMPLES_MODE=live
export METATATE_MCP_URL=https://<your-workspace-mcp-host>/mcp
export METATATE_SAAS_MCP_TOKEN=mtt_... # Tokens tab, shown once
For the full catalog of examples — including framework runtimes for LangGraph, OpenAI Agents, and LlamaIndex — see Examples.