# ContractGate — Full LLM Documentation Bundle > One-shot ingestion for large-context agents. Concatenates the integration > playbook and every reference doc linked from llms.txt in the order an agent > is likely to need them. For a lightweight index, use llms.txt instead. Source of each section: https://github.com/nightmoose/contractgate/blob/main/docs/ --- # ContractGate — Integration Playbook for Coding Agents **Read this file top to bottom, then execute it.** It is written for an AI coding agent (Claude, Cursor, Codex, Copilot) working inside a user's repository. Every step is concrete; no step requires the user to read documentation. - **Product:** ContractGate — semantic data-contract enforcement at ingestion. Rejects bad events *before* they reach a warehouse, lakehouse, or ML pipeline. - **Repo:** - **API base URL:** `https://app.datacontractgate.com` - **Machine index:** **What you are going to build:** a contract YAML committed to the user's repo, deployed as a `stable` version, and their event producer calling the validation gateway — verified with a dry run before anything writes. > **MCP first.** If your host has the ContractGate MCP server connected > (`@contractgate/mcp-server` in Cursor, Claude Desktop, Windsurf, Copilot, > Codex, …), skip the `curl` recipes below and use the typed tools: > `infer_contract`, `validate_events`, `deploy_contract`, `get_quarantine`, > `list_contracts`. They wrap the same endpoints, with argument validation > and no shell-escaping. Setup: . > The rest of this playbook stays authoritative for the flow, contract shape, > and semantics — only the transport changes. --- ## §0 — Preconditions 1. The user needs an API key. If they don't have one: sign in at , then **Account → API keys → Create key**. Keys look like `cg_live_` + 48 hex chars. 2. The key must be in the environment, never in the repo: ```bash export CONTRACTGATE_API_KEY="cg_live_..." ``` **Rules you must follow:** - **Never** write the raw key into source, config, `.env` that is tracked, a commit message, or a chat message. Read it from `CONTRACTGATE_API_KEY`. - If `CONTRACTGATE_API_KEY` is unset, stop and ask the user for it. Do not invent a key, and do not fall back to the self-hosted path silently. - Add `.env` / `.env.local` to `.gitignore` if the user's project stores it there. Every request below sends the key as the `X-Api-Key` header. (MCP tools read the same key from the host process environment.) --- ## §1 — Find the event shape in the user's repo You need 5–20 **real** sample events. Do not invent them. Search the repo for where structured events are produced or written: | Look for | Typical location | |---|---| | `JSON.stringify(` / `json.dumps(` / `serde_json::to_string` | producer / client code | | Kafka `produce(`, `send(`, `KafkaProducer` | streaming publishers | | `INSERT INTO` / warehouse client `insert_rows` / `copy_from` | warehouse loaders | | Webhook or API route handlers that accept a payload | `routes/`, `api/`, `handlers/` | | Existing fixtures: `*.json`, `*.ndjson`, `fixtures/`, `testdata/` | tests | Prefer real fixtures or logged payloads over hand-written examples — inference quality is bounded by sample quality. Record, for later steps: - the **event name** (snake_case, e.g. `user_events`, `property_listings`); - the **file and line** where the event is sent; - whether events go out **one at a time** or **in batches**. --- ## §2 — Infer a draft contract from the samples ```bash curl -sS -X POST "https://app.datacontractgate.com/contracts/infer" \ -H "X-Api-Key: $CONTRACTGATE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "user_events", "description": "Contract for user interaction events", "samples": [ { "user_id": "u_123", "event_type": "purchase", "timestamp": 1714000000, "amount": 49.99 }, { "user_id": "u_456", "event_type": "login", "timestamp": 1714000001 } ] }' ``` Request body: `{ name, description?, samples: [ ... ] }` — every sample must be a JSON **object**. Response: ```json { "yaml_content": "version: \"1.0\"\nname: user_events\n...", "field_count": 4, "sample_count": 2 } ``` Body cap on inference routes is **10 MB**. Other source formats, same auth and response shape: | Route | Input | |---|---| | `POST /contracts/infer` | JSON sample events | | `POST /contracts/infer/csv` | CSV (header row + rows) | | `POST /contracts/infer/url` | a public data URL to fetch and profile | | `POST /contracts/infer/avro` | Avro schema | | `POST /contracts/infer/proto` | Protobuf schema | | `POST /contracts/infer/openapi` | OpenAPI spec | CLI alternative — runs entirely locally, no key needed, pipes into stdout or a file: ```bash curl -sS "https://api.example.com/events?limit=50" \ | contractgate infer --from-stdin --name user_events --out contracts/user_events.yaml ``` Inference is a **starting point, not the answer.** Continue to §3. --- ## §3 — Write the contract file Write `contracts/.yaml` in the user's repo. Use exactly this structure — this format is locked; do not introduce keys that are not listed in the field reference below. ```yaml version: "1.0" name: "user_events" description: "Contract for user interaction events" ontology: entities: - name: user_id type: string required: true pattern: "^[a-zA-Z0-9_-]+$" - name: event_type type: string required: true enum: ["click", "view", "purchase", "login"] - name: timestamp type: integer required: true - name: amount type: number required: false min: 0 glossary: - field: amount description: "Monetary amount in USD" constraints: "must be non-negative" metrics: - name: total_revenue formula: "sum(amount) where event_type = 'purchase'" ``` ### Field reference (`ontology.entities[]`) | Key | Applies to | Notes | |---|---|---| | `name` | all | Field name exactly as it appears in the JSON event. | | `type` | all | One of `string`, `integer`, `number` (alias for `float`), `boolean`, `object`, `array`, `date`, `any`. `date` = `YYYY-MM-DD` string, real calendar date. Use `any` sparingly — it weakens the contract. | | `required` | all | **Defaults to `true` when omitted.** Write it explicitly on every field so intent is visible in review. | | `pattern` | `string` | Regex the value must match. | | `enum` | `string`, `integer` | Allowed value set. | | `min` / `max` | `integer`, `number` | Inclusive numeric bounds. | | `min_length` / `max_length` | `string` | Length bounds. | | `properties` | `object` | Nested list of field definitions, same shape. | | `items` | `array` | Element constraints — a single field definition. | | `transform` | `string` only | PII transform applied *after* validation (`kind: mask`/`hash`, optional `style`). A transform on a non-string field is a load-time error. | `glossary[]` takes `field`, `description`, and optional `constraints` — documentation only, not enforced. `metrics[]` and `quality[]` are optional; leave them out rather than guessing. ### Tighten the inference before committing Do this pass explicitly — it is where the contract stops being a schema and starts being a contract: 1. **Enums:** if a field has a small closed set of values in the samples and the repo confirms it (an enum type, a constant list, a DB check constraint), declare `enum`. If the samples merely happen to show 3 values, do not. 2. **Patterns:** add `pattern` for IDs and codes with an obvious shape. 3. **Required:** a field absent from any single sample must be `required: false`. 4. **Bounds:** add `min: 0` to amounts, counts, and durations. 5. **Types:** prefer `integer` over `number` for epoch timestamps and counts. Optional top-level keys you may set when the user asks for them, not by default: `compliance_mode: true` (reject events containing undeclared fields). --- ## §4 — Deploy the contract as a stable version Preferred: use the `contractgate` CLI. It reads the YAML file directly, so there is nothing to escape and no `jq` dependency. Install once: ```bash cargo install --git https://github.com/nightmoose/contractgate contractgate ``` Then deploy: ```bash contractgate deploy-contract contracts/user_events.yaml \ --source app-backend --deployed-by "$USER" --json ``` Response (identical to the HTTP endpoint): ```json { "contract_id": "…uuid…", "version_id": "…uuid…", "name": "user_events", "version": "1.0", "source": "app-backend", "deployed_by": "claude-code", "deployed_at": "2026-08-06T12:00:00Z", "deprecated_count": 0 } ``` What this does: finds or creates the contract identity by `name`, inserts the version from the YAML as **`stable`**, and deprecates any previously stable version (`deprecated_count`). - **`409 Conflict`** — that `(name, version)` already exists. Bump `version:` in the YAML (e.g. `"1.1"`) and re-deploy. Never edit a deployed version in place. - Deploy is refused while the contract has pending quarantined events. **Save `contract_id`.** It is the only value from this step you need next. Put it in the user's config or environment (e.g. `CONTRACTGATE_CONTRACT_ID`) — it is not a secret. HTTP fallback if the CLI is not available (Windows without a Rust toolchain, sandboxed CI, etc.): ``` POST /contracts/deploy X-Api-Key: cg_live_… Content-Type: application/json ``` Body: `{ name, yaml_content, source?, deployed_by? }`. `yaml_content` is the file contents as a JSON string; a language runtime is the reliable way to escape it. Any shell with `jq` works too: ```bash jq -n \ --arg name "user_events" \ --rawfile yaml "contracts/user_events.yaml" \ '{name: $name, yaml_content: $yaml, source: "app-backend", deployed_by: "claude-code"}' \ | curl -sS -X POST "https://app.datacontractgate.com/contracts/deploy" \ -H "X-Api-Key: $CONTRACTGATE_API_KEY" \ -H "Content-Type: application/json" \ --data-binary @- ``` --- ## §5 — Wire the producer to the gateway ``` POST /v1/ingest/{contract_id} X-Api-Key: cg_live_… Content-Type: application/json # or application/x-ndjson Idempotency-Key: # optional, 24-hour at-most-once window ``` Body: a JSON **array** of event objects, a **single** object (treated as a 1-event batch), or NDJSON (one object per line). Query parameters: | Param | Default | Meaning | |---|---|---| | `version` | latest `stable` | Pin a specific contract version. | | `dry_run` | `false` | Validate only — no audit row, no quarantine, no forward, no metered usage. | | `atomic` | `false` | All-or-nothing: if any event fails, nothing is persisted. | Response body (`V1IngestResponse`): ```json { "total": 2, "passed": 1, "failed": 1, "dry_run": false, "atomic": false, "resolved_version": "1.0", "version_pin_source": "default_stable", "results": [ { "index": 0, "passed": true, "violations": [], "validation_us": 31, "forwarded": true, "contract_version": "1.0", "quarantine_id": null, "transformed_event": { "…": "…" } }, { "index": 1, "passed": false, "violations": [ { "field": "amount", "kind": "…", "message": "…" } ], "validation_us": 27, "forwarded": false, "contract_version": "1.0", "quarantine_id": "…uuid…", "transformed_event": { "…": "…" } } ] } ``` **HTTP status encodes the batch outcome:** `200` all passed · `207 Multi-Status` partial · `422 Unprocessable Entity` all failed. Treat `207` and `422` as "contract violations", not as transport errors — read `results[].violations`. ### TypeScript ```ts const CG_URL = "https://app.datacontractgate.com"; export async function validateAndSend(events: unknown[]) { const res = await fetch( `${CG_URL}/v1/ingest/${process.env.CONTRACTGATE_CONTRACT_ID}`, { method: "POST", headers: { "X-Api-Key": process.env.CONTRACTGATE_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify(events), }, ); // 200 = all passed, 207 = partial, 422 = all failed. const body = await res.json(); if (body.failed > 0) { for (const r of body.results.filter((r: any) => !r.passed)) { console.error("contract violation", r.index, r.violations); } } return body; } ``` ### Python (first-party SDK) ```bash pip install contractgate ``` ```python import os from contractgate import Client cg = Client( base_url="https://app.datacontractgate.com", api_key=os.environ["CONTRACTGATE_API_KEY"], ) result = cg.ingest(contract_id=os.environ["CONTRACTGATE_CONTRACT_ID"], events=events) for r in result.results: if not r.passed: for v in r.violations: print(v.field, v.kind, v.message) ``` The SDK also ships a pure-Python local validator (`Contract.from_yaml(...)`) for unit tests and pre-commit hooks — no network required. Use it to gate CI. ### Where to put the call Insert validation at the boundary where events **leave** the user's system — immediately before the Kafka produce, warehouse insert, or outbound POST you found in §1. Do not scatter it across call sites; wrap the existing send in one function like the above and call that. --- ## §6 — Verify with a dry run (do this before removing `dry_run`) Send one event you know is **good** and one you know is **bad** (wrong enum value, missing required field, negative amount): ```bash curl -sS -X POST \ "https://app.datacontractgate.com/v1/ingest/$CONTRACTGATE_CONTRACT_ID?dry_run=true" \ -H "X-Api-Key: $CONTRACTGATE_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "user_id": "u_1", "event_type": "purchase", "timestamp": 1714000000, "amount": 10 }, { "user_id": "u_2", "event_type": "not_a_real_type", "timestamp": 1714000001 } ]' ``` Expect `207`, `passed: 1`, `failed: 1`, and a violation on `event_type` for index 1. If **both** pass, your contract is too loose — go back to §3 and tighten enums, patterns, and `required`. If **both** fail, read the violations: usually a type mismatch (epoch seconds typed as `string`) or a `required` field the samples didn't have. Only when the dry run behaves correctly, remove `dry_run=true`. --- ## §7 — Confirm in the dashboard At → **Contracts → your contract**: - **Audit** — every validated event with its decision and contract version. - **Quarantine** — rejected events, replayable after you fix the contract or the producer. - **Usage** — metered event counts against the plan. --- ## §8 — Errors you will actually hit | Status | Meaning | Fix | |---|---|---| | `401` | Missing or invalid `X-Api-Key`. | Check `CONTRACTGATE_API_KEY` is exported and not truncated. | | `403` | Key is not authorized for this contract (keys can be scoped to a contract set). | Use a key scoped to this contract, or widen the key's scope in the dashboard. | | `404` | Unknown `contract_id`, or it belongs to another org. | Re-read `contract_id` from the §4 response. | | `409` | `(name, version)` already deployed. | Bump `version:` in the YAML. | | `413` | Body too large. | 1 MB on most endpoints; 10 MB on `/contracts/infer/*` and `/v1/ingest/*`. Split the batch. | | `422` | Every event in the batch failed validation. | Read `results[].violations` — this is a data or contract problem, not a transport problem. | | `429` | Rate limited. | Back off and retry; batch events instead of sending one per request. | --- ## §9 — Do not - **Do not invent contract fields** that are not in the samples or confirmed by the repo. A wrong contract rejects good data. - **Do not "fix" failing validation by removing the gate**, widening a field to `any`, or deleting the offending constraint. Report the violations to the user and ask which side is wrong — the contract or the producer. - **Do not commit API keys**, or interpolate one into a code sample, README, or chat message. - **Do not use `POST /playground/validate`** for production wiring — that is the dashboard's scratchpad endpoint. - **Do not edit a deployed version in place** — deploy a new `version:`. - **Do not skip §6.** Dry-run first is the only reason a mistake here is reversible. --- ## Reference docs | Doc | Covers | |---|---| [`docs/v1-ingest-reference.md`](https://github.com/nightmoose/contractgate/blob/main/docs/v1-ingest-reference.md) | Full `/v1/ingest` semantics: NDJSON, idempotency, atomic batches | [`docs/deploy-contract-reference.md`](https://github.com/nightmoose/contractgate/blob/main/docs/deploy-contract-reference.md) | Deploy endpoint + CLI, version promotion rules | [`docs/csv-inference-reference.md`](https://github.com/nightmoose/contractgate/blob/main/docs/csv-inference-reference.md) | CSV and URL inference | [`docs/quarantine-replay-reference.md`](https://github.com/nightmoose/contractgate/blob/main/docs/quarantine-replay-reference.md) | Reviewing and replaying rejected events | [`docs/pii-masking-reference.md`](https://github.com/nightmoose/contractgate/blob/main/docs/pii-masking-reference.md) | `transform:` field masking and hashing | [`/openapi.json`](https://app.datacontractgate.com/openapi.json) | Machine-readable route inventory | **Self-hosting instead?** `git clone https://github.com/nightmoose/contractgate && make demo` runs the real gateway locally at `http://localhost:3000` with no account, no key, and no cloud dependency. The steps above still apply — swap the base URL and drop the `X-Api-Key` header. --- # ContractGate MCP Server **RFC-090.** Official Model Context Protocol server for Cursor, Claude Desktop, Windsurf, VS Code Copilot, Codex, and any other MCP host. The server is a thin stdio client of the existing gateway. It does not run validation itself. Auth is the same API key the CLI and playbook already use. ## Install Add this to the host's MCP config (`~/.cursor/mcp.json`, Claude Desktop `claude_desktop_config.json`, etc.): ```json { "mcpServers": { "contractgate": { "command": "npx", "args": ["-y", "@contractgate/mcp-server"], "env": { "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" } } } } ``` The package is published at . `npx -y` fetches the latest release on first use, so no separate install step is needed. Restart the host after editing the config. ## Environment | Variable | Required | Default | |---|---|---| | `CONTRACTGATE_API_KEY` | yes | — | | `CONTRACTGATE_BASE_URL` | no | `https://app.datacontractgate.com` | Never put the raw key in the config file. Reference the environment variable the way your host supports (`${CONTRACTGATE_API_KEY}` in Cursor; Claude Desktop reads the process environment). Get a key at . ## Tools ### `infer_contract` `POST /contracts/infer`. Draft YAML from real sample events. Does not persist. | Argument | Type | Required | |---|---|---| | `name` | string | yes | | `samples` | object[] | yes, ≥1 | | `description` | string | no | Write the returned `yaml_content` to `contracts/.yaml` and review it before deploying. Inference is a starting point. ### `validate_events` Validate events against a deployed contract or against in-flight YAML. | Argument | Type | Required | |---|---|---| | `events` | object[] | yes, ≥1 | | `contract_id` | uuid | exactly one of `contract_id` / `yaml_content` | | `yaml_content` | string | exactly one of `contract_id` / `yaml_content` | | `dry_run` | boolean | no, default `true` | - `contract_id` → `POST /v1/ingest/{contract_id}`. Default `dry_run=true` (no audit row, no quarantine, no metered usage). Set `dry_run=false` only after a dry run has passed. - `yaml_content` → `POST /playground/validate` per event. Never persists. `dry_run` is ignored. `200` / `207` / `422` all return the body. Read `results[].violations` — entries may include `received`, `expected`, and `suggestion` so you can fix the producer or the YAML without guessing. ### `deploy_contract` `POST /contracts/deploy`. Finds-or-creates the contract by `name`, inserts the YAML as `stable`, deprecates prior stable versions. Refused while quarantine is pending. `409` if that `(name, version)` already exists — bump `version:` in the YAML and retry. | Argument | Type | Required | |---|---|---| | `name` | string | yes | | `yaml_content` | string | yes | | `source` | string | no | | `deployed_by` | string | no (defaults to `mcp`) | Save the returned `contract_id`. It is not a secret. ### `get_quarantine` `GET /quarantine`. Source quarantine rows for the caller's org, newest first. | Argument | Type | Required | |---|---|---| | `contract_id` | uuid | no | | `limit` | int | no, default 100, max 500 | | `offset` | int | no | ### `list_contracts` `GET /contracts`. Identities the key can see. ## Prompt `integrate-contractgate` — loads the agent playbook URL () as the instruction to follow. Use it when wiring ContractGate into a repo for the first time. ## What this server will not do - Live ingest by default (`validate_events` defaults to dry-run). - Kafka / Kinesis / billing / collaborator management. - Invent contract fields that were not in the samples. Full executable flow without MCP: . --- # POST /v1/ingest/{contract_id} — Endpoint Reference The bulk HTTP ingest endpoint is ContractGate's universal connector: anything that can make an HTTP POST can validate events against a contract. --- ## Request ``` POST /v1/ingest/{contract_id} Host: app.datacontractgate.com X-Api-Key: cg_live_ Content-Type: application/json # or application/x-ndjson Idempotency-Key: # optional ``` ### Path parameter | Parameter | Type | Required | Description | |---------------|------|----------|--------------------------------------| | `contract_id` | UUID | Yes | ID of the contract to validate against. | ### Query parameters | Parameter | Type | Default | Description | |-----------|---------|-----------------|------------------------------------------------------------------| | `version` | string | latest `stable` | Semver pin (e.g. `1.2.0`). Defaults to the latest stable version. | | `dry_run` | boolean | `false` | Validate without persisting to audit log, quarantine, or forward. | | `atomic` | boolean | `false` | All-or-nothing semantics: if any event fails, nothing is persisted. | ### Request headers | Header | Required | Description | |-------------------|----------|----------------------------------------------------------------------------------------------| | `X-Api-Key` | Yes | API key in `cg_live_…` format. Must be authorized for the contract's project. | | `Content-Type` | Yes | `application/json` or `application/x-ndjson`. | | `Idempotency-Key` | No | Opaque string (max 255 chars). Guarantees at-most-once processing within the 24-hour window. | ### Body formats **JSON array** (`application/json`): ```json [ { "user_id": "u_123", "event_type": "purchase", "timestamp": 1714000000, "amount": 49.99 }, { "user_id": "u_456", "event_type": "login", "timestamp": 1714000001 } ] ``` **Single JSON object** (`application/json`) — treated as a 1-event batch: ```json { "user_id": "u_123", "event_type": "login", "timestamp": 1714000001 } ``` **NDJSON** (`application/x-ndjson`) — one object per line: ``` {"user_id":"u_123","event_type":"purchase","timestamp":1714000000,"amount":49.99} {"user_id":"u_456","event_type":"login","timestamp":1714000001} ``` ### Limits | Limit | Value | Error code | |--------------------|---------|-------------------| | Max request body | 10 MB | `body_too_large` | | Max events / batch | 1 000 | `batch_too_large` | | Max per event | 1 MB | `event_too_large` | --- ## Response ### Success (200 / 207) ```json { "total": 2, "passed": 2, "failed": 0, "dry_run": false, "atomic": false, "resolved_version": "1.2.0", "version_pin_source": "default_stable", "results": [ { "index": 0, "passed": true, "violations": [], "validation_us": 312, "forwarded": true, "contract_version": "1.2.0", "quarantine_id": null, "transformed_event": { "user_id": "u_123", "event_type": "purchase", "timestamp": 1714000000, "amount": 49.99 } } ] } ``` | Field | Type | Description | |----------------------|---------|----------------------------------------------------------------------| | `total` | integer | Total events submitted. | | `passed` | integer | Events that passed validation. | | `failed` | integer | Events that failed validation. | | `dry_run` | boolean | Whether this was a dry run. | | `atomic` | boolean | Whether atomic mode was requested. | | `resolved_version` | string | Contract version actually used. | | `version_pin_source` | string | `"query_param"` or `"default_stable"`. | | `results[].index` | integer | Zero-based position in the submitted batch. | | `results[].passed` | boolean | Whether this event passed. | | `results[].violations` | array | Validation violations (empty on pass). See the violation shape below. | | `results[].quarantine_id` | UUID \| null | ID of the quarantine row for rejected events. Use with the replay API. | | `results[].transformed_event` | object | Post-transform payload that was persisted (RFC-004). | ### Violation shape Each entry in `results[].violations` is a JSON object with these fields. The three actionable fields (`received`, `expected`, `suggestion`) are omitted when the check doesn't have a natural value for them, so consumers must tolerate their absence. ```json { "field": "timestamp", "message": "Field 'timestamp' expected type Integer, got string", "kind": "type_mismatch", "received": "2026-08-23T15:00:00Z", "expected": "integer", "suggestion": "Change the producer to emit 'timestamp' as integer, or update the contract field to `type: string` if the producer is correct." } ``` | Field | Type | Description | |---------------|-----------------|--------------------------------------------------------------------------------------------| | `field` | string | Dot-separated path to the offending field (e.g. `user.address.zip`). | | `message` | string | Human-readable explanation. | | `kind` | string enum | Machine-readable category. One of `missing_required_field`, `type_mismatch`, `pattern_mismatch`, `enum_violation`, `range_violation`, `length_violation`, `metric_range_violation`, `undeclared_field`, `leakage_violation`, `completeness_violation`, `freshness_violation`, `uniqueness_violation`. | | `received` | any (optional) | The offending value as it appeared in the event. | | `expected` | string (optional) | Contract-YAML-friendly description of the required value (e.g. `"integer"`, `">= 0"`, `"one of [click, view]"`). | | `suggestion` | string (optional) | One-line remediation an agent or human can act on directly. | ### HTTP status codes | Code | Meaning | |------|----------------------------------------------------------------------| | 200 | All events passed. | | 207 | Mixed — some passed, some failed. | | 400 | Malformed body, empty batch, size limit exceeded. | | 401 | Missing or invalid API key. | | 413 | Body > 10 MB or a single event > 1 MB. | | 422 | All events failed, `atomic` rejection, idempotency conflict, or deprecated version pin. | | 429 | Per-key rate limit exceeded. See `X-RateLimit-*` headers. | ### Rate-limit headers (always present) | Header | Value | |-------------------------|--------------------------------------------------| | `X-RateLimit-Limit` | Requests per second allowed for this key. | | `X-RateLimit-Remaining` | Tokens remaining after this request. | | `X-RateLimit-Reset` | Unix timestamp when the bucket has ≥ 1 token. | Default limits: **100 req/sec sustained, 1 000 burst**. Contact us for design-partner overrides. --- ## Idempotency Add the `Idempotency-Key` header to make any request idempotent within a 24-hour window. The key is opaque — use a UUID, transaction ID, or any unique string (max 255 chars). **Same key + same body → cached response:** ``` HTTP/1.1 200 OK X-Idempotency-Replay: true ``` **Same key + different body → 422 conflict:** ```json { "error": "idempotency_conflict", "detail": "A different request body was already submitted with this Idempotency-Key." } ``` Dry-run requests (`?dry_run=true`) are never stored in the idempotency cache. --- ## Quarantine and replay Rejected events are automatically quarantined. The `quarantine_id` in each failing event result is the UUID of the quarantine row. Pass it to the replay endpoint when the underlying data has been fixed: ``` POST /contracts/{contract_id}/quarantine/replay ``` --- ## Version pinning By default the endpoint validates against the latest `stable` version of the contract. Pin a specific version with the `?version=` query parameter: ``` POST /v1/ingest/{contract_id}?version=1.0.0 ``` Pinning a `deprecated` version quarantines the entire batch with a `deprecated_contract_version` violation. --- ## OpenAPI spec The machine-readable spec is available at `/openapi.json` (no auth required). --- # deploy-contract Reference **RFC:** 028 — Contract Queryability **Since:** nightly-2026-05-14 --- ## Overview `deploy-contract` promotes a contract YAML directly to `stable` in a single atomic operation. It is the production deploy path — use `push` for iterative development (draft versions) and `deploy-contract` for CI-gated releases. On success it: - Finds or creates the contract identity by name. - Inserts the version as `stable` with `parsed_json`, `source`, `deployed_by`, and `deployed_at` populated. - Deprecates all previously-stable versions for this contract. On failure it: - Returns an error (no DB changes) if pending quarantine events exist for the contract. - Returns a 409 if the `(name, version)` pair already exists. --- ## CLI Usage ``` cg deploy-contract [OPTIONS] ``` ### Arguments | Argument | Description | |----------|-------------| | `FILE` | Path to the contract YAML file. | ### Options | Flag | Env | Description | |------|-----|-------------| | `--source ` | — | PMS vendor or logical feed name (e.g. `yardi`, `realpage`, `entrata`). | | `--deployed-by ` | `CONTRACTGATE_DEPLOYED_BY` | CI job ID or username recorded on the version row. | | `--dry-run` | — | Parse and validate locally without sending to the gateway. | | `--json` | — | Emit machine-readable JSON. | | `--api-key ` | `CONTRACTGATE_API_KEY` | Gateway API key (service-role required). | ### Examples ```bash # Deploy from CI, recording source and job ID cg deploy-contract contracts/orders.yaml \ --source yardi \ --deployed-by "$CI_JOB_ID" # Dry-run: validate YAML without touching the gateway cg deploy-contract contracts/events.yaml --dry-run # Machine-readable output for downstream scripts cg deploy-contract contracts/leases.yaml --json | jq .deprecated_count ``` --- ## API Endpoint ``` POST /contracts/deploy Authorization: x-api-key Content-Type: application/json ``` ### Request Body ```json { "name": "orders", "yaml_content": "...", "source": "yardi", "deployed_by": "ci-job-42" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | yes | Contract name — must match `name:` in the YAML. | | `yaml_content` | string | yes | Raw YAML content. Parsed server-side. | | `source` | string | no | PMS vendor or feed name. | | `deployed_by` | string | no | CI job ID or username. | ### Response `201 Created` ```json { "contract_id": "uuid", "version_id": "uuid", "name": "orders", "version": "1.2.0", "source": "yardi", "deployed_by": "ci-job-42", "deployed_at": "2026-05-14T10:30:00Z", "deprecated_count": 1 } ``` ### Error Responses | Status | Condition | |--------|-----------| | 400 | Pending quarantine events exist for this contract. Resolve them first. | | 400 | YAML is invalid or unparseable. | | 409 | `(name, version)` already exists in `contract_versions`. | | 401 | Missing or invalid API key. | --- ## Supabase Queryability After deploy, the following SQL queries work against Supabase: ```sql -- What contract version was active during incident window X? SELECT c.yaml_content FROM audit_log a JOIN contract_versions c ON c.contract_id = a.contract_id AND c.version = a.contract_version WHERE a.event_id = 'evt_abc123'; -- Which active contracts allow monthly_rent = 0? SELECT c.name, cv.version FROM contract_versions cv JOIN contracts c ON c.id = cv.contract_id WHERE cv.state = 'stable' AND (cv.parsed_json -> 'ontology' -> 'entities' @> '[{"name":"monthly_rent","min":0}]'); -- Per-contract violation rates SELECT contract_id, contract_version, passed, count(*) FROM audit_log GROUP BY 1, 2, 3 ORDER BY 4 DESC; ``` ### `active_contracts_public` View Auditors and external stakeholders can be granted SELECT on this view without full DB access: ```sql -- Grant auditor access GRANT SELECT ON active_contracts_public TO ; -- Query active contracts SELECT name, version, source, deployed_at, deployed_by FROM active_contracts_public; ``` Columns: `contract_id`, `name`, `version`, `source`, `deployed_at`, `deployed_by`, `parsed_json`. --- ## Admin-Only Deprecation Only the `deploy-contract` path (service-role API key) can deprecate stable versions. Regular authenticated users cannot call `POST /contracts/deploy` — the standard auth middleware enforces this via org-scoped RLS. To manually deprecate a version without deploying a replacement, use the existing endpoint: ``` POST /contracts/{id}/versions/{version}/deprecate ``` This requires a service-role key and is blocked if the version has pending quarantine events. --- # CSV Contract Inference — API Reference **RFC:** 035 **Status:** Accepted **Added:** 2026-05-24 **Plan:** Growth+ (see [plan-gating-reference.md](plan-gating-reference.md)) --- ## Overview `POST /contracts/infer/csv` accepts a CSV document and returns a draft YAML contract describing its column types. The same shared inference engine powers the existing JSON inference endpoint — CSV values are coerced to JSON types first, then the engine runs identically. The first row of the CSV must be the header row. Up to 1 000 data rows are sampled; additional rows are silently ignored. --- ## Endpoint ``` POST /contracts/infer/csv ``` **Auth:** not required (inference does not write to the database). ### Request body (JSON) | Field | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Name embedded in the generated contract's `name` field. | | `description` | string | No | Optional description string in the generated contract. | | `csv_content` | string | One of `csv_content` or `base64` | Raw CSV as UTF-8 text. | | `base64` | string | One of `csv_content` or `base64` | Base64-encoded CSV for binary-safe transport. | | `delimiter` | string | No | Override auto-detection. Accepts `","`, `";"`, `"\t"`, or `"tab"`. | Exactly one of `csv_content` or `base64` must be provided. If both are supplied, `csv_content` takes precedence. ### Response `200 OK` ```json { "yaml_content": "version: \"1.0\"\nname: \"my_contract\"\n...", "field_count": 5, "sample_count": 847 } ``` | Field | Type | Description | |---|---|---| | `yaml_content` | string | Complete draft YAML contract. | | `field_count` | integer | Number of columns inferred. | | `sample_count` | integer | Number of data rows sampled (≤ 1 000). | ### HTTP status codes | Status | Meaning | |---|---| | `200 OK` | Inference succeeded. | | `400 Bad Request` | Missing required fields, invalid base64, empty CSV, CSV > 10 MB, unsupported delimiter, duplicate column names, or CSV parse error. | --- ## Delimiter auto-detection When `delimiter` is not specified the endpoint sniffs the first 4 096 bytes (up to 20 lines) and scores comma, tab, and semicolon by column-count consistency across those lines. The delimiter with the most consistently uniform column count wins. Tiebreak order: comma > tab > semicolon. If no candidate delimiter appears in the sniffed region the endpoint returns `400 Bad Request` — use the explicit `delimiter` field. --- ## Type coercion order CSV values are strings on the wire. Each value is coerced to a JSON type before inference runs: | Wire value | Coerced to | |---|---| | Empty string or whitespace-only | `null` (treated as absent) | | `true` or `false` (any case) | `boolean` | | Parses as 64-bit integer | `integer` | | Parses as 64-bit float | `float` | | Anything else | `string` | After coercion the inference engine applies the same pattern/enum detection used for JSON samples: UUID detection, ISO date format, and enum collapse when a string column has ≤ 10 distinct values across the sample. A column is marked `required: false` if any row in the sample has an empty (coerced-to-null) value for that column. --- ## Limits | Limit | Value | |---|---| | Max CSV body | 10 MB | | Max sampled rows | 1 000 | | Sniff window for delimiter detection | first 4 096 bytes, first 20 lines | --- ## Examples ### Minimal — comma-separated inline ```bash curl -X POST https://your-instance/contracts/infer/csv \ -H "Content-Type: application/json" \ -d '{ "name": "user_events", "csv_content": "user_id,event_type,amount\nu1,purchase,49.99\nu2,click,\n" }' ``` ```json { "yaml_content": "version: \"1.0\"\nname: user_events\nontology:\n entities:\n - name: user_id\n type: string\n required: true\n - name: event_type\n type: string\n required: true\n - name: amount\n type: float\n required: false\n", "field_count": 3, "sample_count": 2 } ``` ### Base64-encoded CSV ```bash CSV_B64=$(echo -n "id,score\n1,9.5\n2,8.0\n" | base64) curl -X POST https://your-instance/contracts/infer/csv \ -H "Content-Type: application/json" \ -d "{\"name\": \"scores\", \"base64\": \"$CSV_B64\"}" ``` ### Tab-separated with explicit delimiter ```bash curl -X POST https://your-instance/contracts/infer/csv \ -H "Content-Type: application/json" \ -d '{ "name": "tsv_data", "csv_content": "col_a\tcol_b\n1\thello\n2\tworld\n", "delimiter": "tab" }' ``` --- ## Edge cases - **Header row required.** The first row is always treated as column names. A CSV with no header row produces columns named after the first data row's values — use a proper header row. - **Duplicate column names** return `400 Bad Request`. - **All-null column.** If every value in a column is empty, the column is inferred as `type: string`, `required: false`, with no pattern constraint. - **Mixed types in a column.** The inference engine picks the most common type. A column with 900 integers and 100 strings becomes `type: string` (widening to the broadest compatible type). - **Large files.** Files over 10 MB are rejected. Trim the file to a representative sample before sending, or use the `base64` field with server-side streaming if you have a custom ingress layer. --- ## Related - [url-inference-reference.md](url-inference-reference.md) — fetch a remote CSV or JSON endpoint and infer a contract. - [RFC-035](rfcs/035-csv-contract-inference.md) — design rationale. - [RFC-037](rfcs/037-api-source-contract-creation.md) — URL-based inference. --- # Quarantine + Replay API Reference **Last updated:** 2026-07-14 (RFC-081) Events that fail contract validation at ingest are written to the **quarantine** store instead of being forwarded. This API lists them, replays them against a target contract version, and reports per-attempt history. It backs the dashboard Quarantine tab. All routes are **org-scoped**: results are limited to contracts owned by the caller's org (resolved from the API key or Bearer JWT — see [`auth-reference.md`](./auth-reference.md)). Ids belonging to another org are treated as not-found and never surfaced. In production a request with no resolvable org returns **401**. There is also a per-contract variant of replay (`POST /contracts/{id}/quarantine/replay`, `GET /contracts/{id}/quarantine/{quar_id}/replay-history`) retained from RFC-003; the routes below are the org-wide equivalents and share the same replay engine. --- ## `GET /quarantine` List source quarantine rows for the caller's org, newest first. Query parameters: | Param | Type | Default | Notes | |---|---|---|---| | `contract_id` | uuid | — | Restrict to one contract. Omit for all of the org's contracts. | | `limit` | int | 100 | Clamped to 1–500. | | `offset` | int | 0 | For pagination. | Only **source** rows are returned (the failed-replay children of a replay attempt are excluded; they show up under replay-history). Response is an array of: ```json { "id": "uuid", "contract_id": "uuid", "contract_version": "1.0.0", "raw_event": { "...": "the stored (post-transform) payload" }, "violation_details": [ { "field": "...", "rule": "...", "message": "..." } ], "violation_count": 1, "source_ip": "203.0.113.10", "quarantined_at": "2026-07-14T12:00:00Z", "replay_count": 0, "last_replayed_at": null, "last_replay_passed": null } ``` `replay_count` / `last_replayed_at` / `last_replay_passed` summarize replay attempts against the row: count of attempts, the most-recent attempt time, and whether that most-recent attempt passed (`true`), failed (`false`), or there were none (`null`). --- ## `POST /quarantine/replay` Re-validate quarantined events against a target contract version. Request body: ```json { "event_ids": ["uuid", "uuid"], "version": "2.0.0", "contract_id": "uuid" } ``` - `event_ids` (required): 1–1000 quarantine row ids. May span multiple contracts; they are grouped by contract and each group is replayed against its own resolved target version. - `version` (optional): pin a target version. If omitted, each contract's latest stable is used. Draft targets are allowed. - `contract_id` (optional): assertion — if present, every `event_id` must belong to this contract, else **400**. Response: ```json { "replayed": 1, "outcomes": [ { "event_id": "uuid", "version": "2.0.0", "passed": true, "violations": [], "replayed_at": "..." }, { "event_id": "uuid", "version": "2.0.0", "passed": false, "violations": [ ... ], "replayed_at": "..." } ] } ``` `replayed` is the count of events that passed on this attempt. `outcomes` has one entry per input `event_id`, in input order. A passing event lands in the audit log and fires the contract's forward destination (same as fresh ingest); a failing event writes a new quarantine row linked to the source, and the source is left untouched. Ids that are not found, already replayed, purged, or belong to another org come back with `passed: false` and no violations. --- ## `GET /quarantine/replay-history` Attempt history for a single quarantined event. | Param | Type | Default | Notes | |---|---|---|---| | `event_id` | uuid | — | The source quarantine row id. | | `limit` | int | 100 | Clamped to 1–500. | Returns an array of `ReplayOutcome` (same shape as `outcomes` above), one per replay attempt (passes and fails), newest first. An unknown or cross-org `event_id` returns an empty array. --- ## Notes - Replay is idempotent on success: once a source row is stamped `replayed`, a second replay of the same id is a no-op (`passed: false`, reported as already replayed). A race between two concurrent replays of the same id resolves to exactly one winner. - The stored `raw_event` is already in post-transform form (PII masking from RFC-004 is applied at ingest and carried forward on replay). - **RFC-086:** replay requires a stored body. If event-payload storage was off for the contract (Free plan, org/contract opted out, or the body was purged), the quarantine row is returned with `payload_redacted: true` and `raw_event: null`, and replay reports it as `redacted` (non-replayable) rather than re-validating an empty body. See [event-payload-storage-reference.md](event-payload-storage-reference.md). --- # PII Masking & Egress Leakage Guard Reference ContractGate enforces a two-sided PII guarantee: - **Ingest (RFC-004):** raw PII never lands in durable storage. - **Egress (RFC-030):** raw PII and undeclared internal fields never leave the API. Both directions reuse the same transform engine (`src/transform.rs`) and the same per-contract `pii_salt`, so a value hashed on ingest produces an identical hash on egress — downstream joins on hashed keys stay consistent. --- ## Field-level transforms Declared in the contract YAML under `ontology.entities[*].transform`: ```yaml ontology: entities: - name: user_email type: string required: true transform: kind: mask # or: hash | drop | redact style: opaque # only for mask; omit for default (opaque) ``` | `kind` | Ingest result | Egress result | |----------|----------------------------------------|----------------------------------------| | `mask` | `"****"` (opaque) or same-length scramble (format_preserving) | Same — identical output for same input | | `hash` | `"hmac-sha256:"` keyed on `pii_salt` | Same hash — salt continuity guaranteed | | `drop` | Field removed from stored payload | Field absent from response | | `redact` | `""` | `""` | Transforms apply to **top-level string fields only** in v1. A non-string field with a `transform:` block is rejected at contract compile time. ### Mask styles | `style` | Behavior | |-----------------------|----------| | `opaque` (default) | Replace entire value with `"****"`. Length does not leak. | | `format_preserving` | Preserve length + character class per position (digit→digit, letter→same-case letter, symbols unchanged). Deterministic per `(salt, field_name)`. | --- ## Egress leakage guard (RFC-030) Applies to `POST /egress/{contract_id}` after field-level transforms. Controlled by `egress_leakage_mode` at the contract-version level. ```yaml # Set at the root of the contract YAML egress_leakage_mode: strip # off | strip | fail ``` | `egress_leakage_mode` | Behavior on an undeclared field in the outbound payload | |-----------------------|----------------------------------------------------------| | `off` (default) | Field passes through untouched. Backwards-compatible. | | `strip` | Field removed from response. Name recorded in `stripped_fields` on the per-record outcome. No violation raised. | | `fail` | Field removed from response **and** a `LeakageViolation` is raised. The record is then subject to the RFC-029 disposition (`block` / `fail` / `tag`). | > **Note on `fail` + `tag` disposition:** even in `tag` disposition (records > pass through flagged), undeclared fields are still stripped. The leakage > guarantee holds regardless of disposition. ### Per-record outcome fields When leakage is active the egress response includes per-record `stripped_fields`: ```json { "outcomes": [ { "index": 0, "passed": false, "action": "blocked", "stripped_fields": ["cost_basis", "debug_trace"], "violations": [ { "field": "cost_basis", "message": "Undeclared field 'cost_basis' found in egress payload ...", "kind": "leakage_violation" } ] } ] } ``` `stripped_fields` is omitted from the JSON when empty (no undeclared fields were stripped), keeping responses compact in the common case. --- ## Pipeline order ``` outbound payload │ ▼ ┌─────────────────────┐ │ validate() │ ← sees raw values; rule checks run on original data └────────┬────────────┘ │ ▼ ┌─────────────────────┐ │ apply_transforms() │ ← RFC-004: drop/hash/mask/redact declared fields │ + leakage guard │ RFC-030: strip/fail undeclared fields └────────┬────────────┘ │ ▼ cleaned payload returned to caller + audit_log / quarantine (post-transform form, direction='egress') ``` **The payload returned by `POST /egress/...` is always the post-transform, post-leakage payload.** Raw PII never appears in any API response. --- ## Database migration Apply `supabase/migrations/018_egress_leakage_guard.sql` to add the `contract_versions.egress_leakage_mode` column: ```bash psql $DATABASE_URL -f supabase/migrations/018_egress_leakage_guard.sql ``` Existing rows default to `'off'` — no behavior change for deployed contracts until they opt in. --- ## Salt continuity `contracts.pii_salt` (introduced by RFC-004) is reused verbatim on egress. A value hashed on ingest: ``` "hmac-sha256:a3f8c2..." ``` produces the same output when hashed on egress for the same contract and same input. This means downstream analytics joins on hashed user IDs, order IDs, or other keying fields work correctly across ingest and egress paths without any additional configuration. The salt is never serialized, never returned by any API endpoint, and is not included in any response body.