From f025e06a64240d764b31813d6f4045c4eb923a8d Mon Sep 17 00:00:00 2001 From: Alex Suarez Date: Sun, 23 Aug 2026 18:04:25 -0400 Subject: [PATCH 1/2] Improve AI-agent developer surface: llms-full.txt, robots, agent-rules, self-healing violations. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add llms-full.txt: playbook + all reference docs concatenated, generated by sync-llm-docs.mjs and served with the same text/plain+CORS headers as llms.txt. Large-context agents can ingest in one HTTP round-trip. - Add dashboard/app/robots.ts: named allows for GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot, anthropic-ai. Previously 404. - Add tags for both llms.txt and llms-full.txt so agents can auto-discover them. - docs/agent-rules/: drop-in presets for Cursor (.mdc), Windsurf, Copilot, plus portable AGENT.md and README index. - llm-integration.md §4 now leads with `contractgate deploy-contract`; the jq|curl pipeline is a fallback for shells without the CLI. §2 gains a `cg infer --from-stdin` note. - Canonicalise references on app.datacontractgate.com (README, marketing script) — the root domain was 308→307→200-hopping. - Extend Violation and BatchRecordViolation with optional received/expected/suggestion fields (skip_serializing_if = None, so wire format is backwards compatible). Populated at type/enum/range/pattern/length/required/date/completeness/freshness/uniqueness/leakage sites. Documented in docs/v1-ingest-reference.md. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 +- README.md | 2 +- dashboard/app/layout.tsx | 11 ++ dashboard/app/robots.ts | 27 ++++ dashboard/next.config.ts | 2 +- dashboard/scripts/sync-llm-docs.mjs | 42 +++++- docs/agent-rules/AGENT.md | 26 ++++ docs/agent-rules/README.md | 21 +++ docs/agent-rules/copilot-instructions.md | 21 +++ docs/agent-rules/cursor.mdc | 36 ++++++ docs/agent-rules/windsurf.md | 27 ++++ docs/llm-integration.md | 58 ++++++--- docs/llms.txt | 1 + docs/v1-ingest-reference.md | 29 ++++- scripts/marketing/rfc_to_content.py | 8 +- src/egress.rs | 6 + src/ingest.rs | 7 + src/scaffold/report.rs | 1 + src/tests.rs | 1 + src/validation.rs | 156 +++++++++++++++++++++-- 20 files changed, 445 insertions(+), 41 deletions(-) create mode 100644 dashboard/app/robots.ts create mode 100644 docs/agent-rules/AGENT.md create mode 100644 docs/agent-rules/README.md create mode 100644 docs/agent-rules/copilot-instructions.md create mode 100644 docs/agent-rules/cursor.mdc create mode 100644 docs/agent-rules/windsurf.md diff --git a/.gitignore b/.gitignore index 091550f..4fc0d34 100644 --- a/.gitignore +++ b/.gitignore @@ -24,10 +24,12 @@ dashboard/.env .idea/ dashboard/tsconfig.tsbuildinfo -# RFC-089: build-time copies of docs/llms.txt + docs/llm-integration.md. +# RFC-089: build-time copies of docs/llms.txt + docs/llm-integration.md, +# and the concatenated llms-full.txt bundle generated by sync-llm-docs.mjs. # Canonical source is docs/ — never edit these. dashboard/public/llms.txt dashboard/public/llm-integration.md +dashboard/public/llms-full.txt # Added by code-review-graph .code-review-graph/ diff --git a/README.md b/README.md index 32c4ca9..ca6b03c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ContractGate is a high-performance validation gateway that enforces rich semanti ## Using Claude, Cursor, or Codex? Paste this ``` -https://datacontractgate.com/llm-integration.md +https://app.datacontractgate.com/llm-integration.md https://github.com/nightmoose/contractgate implement this in my repo diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index b2ad9ea..02cf5ac 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -35,6 +35,17 @@ export const metadata: Metadata = { title: TITLE, description: DESCRIPTION, }, + // Machine-discoverable markdown surfaces for AI coding agents. Agents that + // scrape a page for `` find the + // LLM index and full documentation bundle without any heuristics. + alternates: { + types: { + "text/markdown": [ + { url: "/llms.txt", title: "LLM Index" }, + { url: "/llms-full.txt", title: "Full LLM Documentation" }, + ], + }, + }, }; export default function RootLayout({ diff --git a/dashboard/app/robots.ts b/dashboard/app/robots.ts new file mode 100644 index 0000000..41e7fee --- /dev/null +++ b/dashboard/app/robots.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; + +const SITE_URL = + process.env.NEXT_PUBLIC_APP_URL ?? "https://app.datacontractgate.com"; + +// Named AI crawlers are allowed explicitly so operators reading robots.txt can +// see intent, even though `User-agent: *` already covers them. The playbook +// and llms-full.txt are the primary discovery surface for these agents. +const AI_CRAWLERS = [ + "GPTBot", + "ClaudeBot", + "Claude-Web", + "PerplexityBot", + "Google-Extended", + "CCBot", + "anthropic-ai", +]; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { userAgent: "*", allow: "/" }, + ...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: "/" })), + ], + host: SITE_URL, + }; +} diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index 35c2c44..181bf69 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -19,7 +19,7 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: "/:path(llms.txt|llm-integration.md)", + source: "/:path(llms.txt|llms-full.txt|llm-integration.md)", headers: [ { key: "Content-Type", value: "text/plain; charset=utf-8" }, { key: "Cache-Control", value: "public, max-age=300" }, diff --git a/dashboard/scripts/sync-llm-docs.mjs b/dashboard/scripts/sync-llm-docs.mjs index 9602cf8..6777bf8 100644 --- a/dashboard/scripts/sync-llm-docs.mjs +++ b/dashboard/scripts/sync-llm-docs.mjs @@ -2,15 +2,20 @@ * RFC-089 — copy the agent-facing docs into public/ so they are served raw at * https://app.datacontractgate.com/llms.txt * https://app.datacontractgate.com/llm-integration.md + * https://app.datacontractgate.com/llms-full.txt * * Canonical source is docs/ at the repo root. The copies in public/ are * gitignored so the repo file can never drift from what is served. * + * llms-full.txt is a concatenation of the integration playbook plus every + * reference doc linked from llms.txt. Large-context agents (Claude, Gemini) + * can ingest it in one HTTP round-trip instead of following links. + * * The Docker demo image builds with context ./dashboard, so ../docs does not * exist there. That build is allowed to proceed without the copies; a Vercel * build is not (missing files there means the paste-a-URL flow 404s in prod). */ -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -20,7 +25,20 @@ const publicDir = join(here, "..", "public"); const FILES = ["llms.txt", "llm-integration.md"]; -const missing = FILES.filter((f) => !existsSync(join(docsDir, f))); +// Order matches the "Reference" section of llms.txt. Playbook first so agents +// hit the executable flow before the deep reference material. +const FULL_BUNDLE = [ + "llm-integration.md", + "v1-ingest-reference.md", + "deploy-contract-reference.md", + "csv-inference-reference.md", + "quarantine-replay-reference.md", + "pii-masking-reference.md", +]; + +const missing = [...new Set([...FILES, ...FULL_BUNDLE])].filter( + (f) => !existsSync(join(docsDir, f)), +); if (missing.length > 0) { const msg = `sync-llm-docs: missing in ${docsDir}: ${missing.join(", ")}`; @@ -37,3 +55,23 @@ for (const f of FILES) { copyFileSync(join(docsDir, f), join(publicDir, f)); console.log(`sync-llm-docs: docs/${f} -> public/${f}`); } + +const bundleHeader = `# 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/ + +`; + +const bundleBody = FULL_BUNDLE.map((f) => { + const body = readFileSync(join(docsDir, f), "utf8").trimEnd(); + return `\n\n---\n\n\n\n${body}\n`; +}).join(""); + +writeFileSync(join(publicDir, "llms-full.txt"), bundleHeader + bundleBody); +console.log( + `sync-llm-docs: bundled ${FULL_BUNDLE.length} docs -> public/llms-full.txt`, +); diff --git a/docs/agent-rules/AGENT.md b/docs/agent-rules/AGENT.md new file mode 100644 index 0000000..3a17e6c --- /dev/null +++ b/docs/agent-rules/AGENT.md @@ -0,0 +1,26 @@ +# ContractGate rules (generic agent instructions) + +Portable version for Claude Code, Codex, Aider, Antigravity, or any agent +whose instruction format is plain markdown. Paste into your project's agent +instructions file (e.g. `CLAUDE.md`, `AGENTS.md`, `.aider.conf.yml`). + +--- + +This repository ships event data behind ContractGate. Every event that leaves +a producer must satisfy a YAML contract in the `contracts/` directory. + +**When editing an event publisher, ingestion path, or schema that feeds one:** + +1. Read `contracts/.yaml` for the affected event. If none exists, infer + one: `contractgate infer --from-stdin --name --out + contracts/.yaml` (feed real sample events on stdin). +2. Reflect every new or renamed producer field in the contract in the same + change. +3. Run `contractgate test --contract contracts/.yaml --data ` + before committing. Fix violations by tightening the code, not by loosening + the contract. +4. To deploy: `contractgate deploy-contract contracts/.yaml --json`. + Never edit a deployed version in place — bump `version:` in the YAML and + redeploy. + +Full playbook: . diff --git a/docs/agent-rules/README.md b/docs/agent-rules/README.md new file mode 100644 index 0000000..9000d01 --- /dev/null +++ b/docs/agent-rules/README.md @@ -0,0 +1,21 @@ +# Agent rule presets + +Drop-in rule files for coding agents that operate on repos using ContractGate. +Each file tells the agent to consult the contracts in `contracts/` and run +`contractgate test` before committing changes that affect event publishers, +ingestion paths, or schemas. + +Copy the file that matches your tool into the destination path below. All +presets carry the same core rule — pick the one your team uses. + +| Agent | Preset | Destination | +|---|---|---| +| Cursor | `cursor.mdc` | `.cursor/rules/contractgate.mdc` | +| Windsurf | `windsurf.md` | `.windsurf/rules/contractgate.md` | +| GitHub Copilot | `copilot-instructions.md` | `.github/copilot-instructions.md` | +| Claude Code, Codex, others | `AGENT.md` | Paste into your project's agent instructions file | + +Every preset points the agent at the canonical playbook: +. Nothing here duplicates +that content — the presets are short by design so they can be dropped into +a repo without becoming a maintenance burden. diff --git a/docs/agent-rules/copilot-instructions.md b/docs/agent-rules/copilot-instructions.md new file mode 100644 index 0000000..5bb1ef1 --- /dev/null +++ b/docs/agent-rules/copilot-instructions.md @@ -0,0 +1,21 @@ +# ContractGate rules (GitHub Copilot) + +This repository ships event data behind ContractGate. Every event that leaves +a producer must satisfy a YAML contract in the `contracts/` directory. + +When you edit an event publisher, an ingestion path, or a schema that feeds +one of these: + +- Read the contract for the affected event in `contracts/.yaml`. If none + exists, infer one: `contractgate infer --from-stdin --name --out + contracts/.yaml` with real sample events on stdin. +- Any new or renamed field in the producer must be reflected in the contract + in the same change. +- Run `contractgate test --contract contracts/.yaml --data ` + before opening a PR. Fix violations by tightening the code, not by loosening + the contract. +- To deploy a contract change: `contractgate deploy-contract + contracts/.yaml --json`. Never edit a deployed version in place — bump + `version:` in the YAML and redeploy. + +Full playbook: . diff --git a/docs/agent-rules/cursor.mdc b/docs/agent-rules/cursor.mdc new file mode 100644 index 0000000..02467fc --- /dev/null +++ b/docs/agent-rules/cursor.mdc @@ -0,0 +1,36 @@ +--- +description: Enforce ContractGate contracts on event publishers and ingestion paths. +globs: + - "**/contracts/**/*.yaml" + - "**/*producer*" + - "**/*publisher*" + - "**/*ingest*" +alwaysApply: false +--- + +# ContractGate rules + +This repository ships event data behind ContractGate. Every event that leaves +a producer must satisfy a YAML contract in `contracts/`. + +## Before editing an event publisher or ingestion path + +1. Read the contract for the affected event in `contracts/.yaml`. If none + exists, infer one first: `contractgate infer --from-stdin --name + --out contracts/.yaml` (feed real sample events on stdin). +2. Any new or renamed field in the producer must be reflected in the contract + in the same change. Missing or misnamed fields are validation failures at + ingest, not code review comments. + +## Before committing + +Run `contractgate test --contract contracts/.yaml --data ` on +representative fixtures. Fix violations by tightening the code, not by +loosening the contract. + +## Deploying a contract change + +Use the CLI: `contractgate deploy-contract contracts/.yaml --json`. +Never edit a deployed version in place — bump `version:` in the YAML and +redeploy. See for the +full playbook. diff --git a/docs/agent-rules/windsurf.md b/docs/agent-rules/windsurf.md new file mode 100644 index 0000000..a626219 --- /dev/null +++ b/docs/agent-rules/windsurf.md @@ -0,0 +1,27 @@ +# ContractGate rules (Windsurf) + +Trigger: any change under `contracts/`, or to files whose name contains +`producer`, `publisher`, or `ingest`. + +This repository ships event data behind ContractGate. Every event that leaves +a producer must satisfy a YAML contract in `contracts/`. + +## Before editing an event publisher or ingestion path + +1. Read the contract for the affected event in `contracts/.yaml`. If none + exists, infer one first: `contractgate infer --from-stdin --name + --out contracts/.yaml` (feed real sample events on stdin). +2. Any new or renamed field in the producer must be reflected in the contract + in the same change. + +## Before committing + +Run `contractgate test --contract contracts/.yaml --data ` on +representative fixtures. Fix violations by tightening the code, not by +loosening the contract. + +## Deploying a contract change + +Use `contractgate deploy-contract contracts/.yaml --json`. Never edit a +deployed version in place — bump `version:` in the YAML and redeploy. Full +playbook: . diff --git a/docs/llm-integration.md b/docs/llm-integration.md index 83adaa5..9de36d7 100644 --- a/docs/llm-integration.md +++ b/docs/llm-integration.md @@ -97,6 +97,14 @@ response shape: | `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. --- @@ -180,29 +188,21 @@ Optional top-level keys you may set when the user asks for them, not by default: ## §4 — Deploy the contract as a stable version -``` -POST /contracts/deploy -X-Api-Key: cg_live_… -Content-Type: application/json +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 ``` -Build the JSON body from the file — never hand-escape YAML into a shell literal: +Then deploy: ```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 @- +contractgate deploy-contract contracts/user_events.yaml \ + --source app-backend --deployed-by "$USER" --json ``` -(No `jq`? Any equivalent works — the only requirement is that `yaml_content` is -the file's contents as a JSON string.) - -Request: `{ name, yaml_content, source?, deployed_by? }`. Response: +Response (identical to the HTTP endpoint): ```json { @@ -229,12 +229,28 @@ version (`deprecated_count`). Put it in the user's config or environment (e.g. `CONTRACTGATE_CONTRACT_ID`) — it is not a secret. -CLI alternative, if the user already has the binary -(`cargo install --git https://github.com/nightmoose/contractgate contractgate`): +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 -contractgate deploy-contract contracts/user_events.yaml \ - --source app-backend --deployed-by "$USER" --json +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 @- ``` --- diff --git a/docs/llms.txt b/docs/llms.txt index f029a67..5a1598e 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,6 +12,7 @@ Source: https://github.com/nightmoose/contractgate ## Start here - [Integration playbook for coding agents](https://app.datacontractgate.com/llm-integration.md): Paste this into Claude, Cursor, or Codex. End-to-end, executable: get a key, infer a contract from real samples, write and deploy the contract YAML, wire the producer to POST /v1/ingest, verify with a dry run. +- [Full documentation bundle](https://app.datacontractgate.com/llms-full.txt): One-shot ingestion for large-context agents — the playbook and every reference doc below, concatenated. Skip the per-link round-trips. ## Reference diff --git a/docs/v1-ingest-reference.md b/docs/v1-ingest-reference.md index 43fa86d..db79e60 100644 --- a/docs/v1-ingest-reference.md +++ b/docs/v1-ingest-reference.md @@ -108,10 +108,37 @@ Idempotency-Key: # optional | `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). | +| `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 | diff --git a/scripts/marketing/rfc_to_content.py b/scripts/marketing/rfc_to_content.py index a2f225b..ef9d239 100644 --- a/scripts/marketing/rfc_to_content.py +++ b/scripts/marketing/rfc_to_content.py @@ -63,7 +63,7 @@ would cringe reading it in someone else's post, cut it. - Every long-form artifact (blog, reddit) includes at least one code block or YAML snippet a reader could copy. -- Every long-form artifact links to https://datacontractgate.com/llm-integration.md +- Every long-form artifact links to https://app.datacontractgate.com/llm-integration.md in the closing paragraph, not the opener. - Never claim shipped-in-prod behavior the RFC does not describe. If the RFC is Draft, frame the post as design/plans, not release. @@ -89,7 +89,7 @@ - Structure: (1) problem in the wild (1-2 paras), (2) why existing tools don't solve it, (3) what we shipped and how it works (with 1+ code or YAML block), (4) the interesting engineering trade-off, (5) close with a "try it" line - linking to https://datacontractgate.com/llm-integration.md. + linking to https://app.datacontractgate.com/llm-integration.md. - Use "we" throughout. - Output the blog post ONLY. No preamble, no explanation, no trailing "Hope this helps!" — just the frontmatter + body ready to paste into dev.to. @@ -148,7 +148,7 @@ name — the rest of the time refer to it as "the gate we built" or similar. Include at least one code or YAML block. Close with: "Repo: https://github.com/nightmoose/contractgate — playbook for wiring it into an -existing stack: https://datacontractgate.com/llm-integration.md. Happy to +existing stack: https://app.datacontractgate.com/llm-integration.md. Happy to answer questions." No signup CTA. No "check us out".> Output the three sections above ONLY. No preamble. @@ -302,7 +302,7 @@ def write_drafts(rfc: RfcContext, drafts: dict[str, str], out_dir: Path) -> list □ HN title starts with "Show HN:" and is ≤80 chars. □ Every X tweet is ≤280 chars including the [k/N] prefix. □ Reddit body's first two paragraphs do not name ContractGate. - □ Closing link points to https://datacontractgate.com/llm-integration.md. + □ Closing link points to https://app.datacontractgate.com/llm-integration.md. □ Nothing claims prod behavior that isn't shipped (check RFC status). """ diff --git a/src/egress.rs b/src/egress.rs index c975d77..e208317 100644 --- a/src/egress.rs +++ b/src/egress.rs @@ -322,6 +322,11 @@ fn apply_egress_pii_pipeline( (egress_leakage_mode=fail); field stripped from response" ), kind: ViolationKind::LeakageViolation, + received: None, + expected: None, + suggestion: Some(format!( + "Either add '{field}' to ontology.entities in the contract YAML, or stop the upstream service from returning it." + )), }) .collect() } else { @@ -876,6 +881,7 @@ mod tests { field: "user_id".into(), message: "Required field 'user_id' is missing".into(), kind: ViolationKind::MissingRequiredField, + ..Default::default() }], validation_us: 8, } diff --git a/src/ingest.rs b/src/ingest.rs index 2843981..28c73ae 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -1028,6 +1028,9 @@ fn envelope_per_record_results( field: v.field.clone(), message: v.message.clone(), kind: v.kind.clone(), + received: v.received.clone(), + expected: v.expected.clone(), + suggestion: v.suggestion.clone(), }; match records { @@ -1080,6 +1083,7 @@ fn envelope_per_record_results( field: envelope_cfg.records_path.clone(), message: "Envelope produced no extractable records".into(), kind: crate::validation::ViolationKind::MissingRequiredField, + ..Default::default() }] } else { batch.violations.iter().map(to_violation).collect() @@ -1327,6 +1331,7 @@ mod envelope_persist_tests { field: "id".into(), message: "bad".into(), kind: ViolationKind::TypeMismatch, + ..Default::default() }], validation_us: 42, }; @@ -1351,6 +1356,7 @@ mod envelope_persist_tests { field: "success".into(), message: "must be boolean".into(), kind: ViolationKind::TypeMismatch, + ..Default::default() }], validation_us: 1, }; @@ -1373,6 +1379,7 @@ mod envelope_persist_tests { field: "".into(), message: "must be object".into(), kind: ViolationKind::TypeMismatch, + ..Default::default() }], validation_us: 1, }; diff --git a/src/scaffold/report.rs b/src/scaffold/report.rs index 6b52487..6dcabee 100644 --- a/src/scaffold/report.rs +++ b/src/scaffold/report.rs @@ -248,6 +248,7 @@ mod tests { field: field.to_string(), message: format!("{field} failed {rule}"), kind: ViolationKind::TypeMismatch, + ..Default::default() } } diff --git a/src/tests.rs b/src/tests.rs index 538db53..596f320 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2092,6 +2092,7 @@ mod rfc029_tests { field: "user_id".into(), message: "Required field 'user_id' is missing".into(), kind: ViolationKind::MissingRequiredField, + ..Default::default() }], validation_us: 8, action: "blocked", diff --git a/src/validation.rs b/src/validation.rs index c885609..bed9681 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -77,7 +77,12 @@ pub struct ValidationResult { } /// A single rule violation found during validation. -#[derive(Debug, Clone, serde::Serialize)] +/// +/// The three trailing fields (`received`, `expected`, `suggestion`) are +/// optional enrichment for agent self-healing (see RFC-090). They are omitted +/// from the JSON when unset so consumers written against the pre-enrichment +/// shape are unaffected. +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct Violation { /// Dot-separated path to the offending field (e.g. "user.address.zip") pub field: String, @@ -85,12 +90,23 @@ pub struct Violation { pub message: String, /// Machine-readable violation kind (for programmatic filtering) pub kind: ViolationKind, + /// The offending value as it appeared in the event, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received: Option, + /// Human-readable description of what the contract expected + /// (e.g. `"integer"`, `"one of [click, view, purchase, login]"`, `">= 0"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// Actionable one-liner an agent or human can follow to fix the mismatch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestion: Option, } -#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, serde::Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ViolationKind { MissingRequiredField, + #[default] TypeMismatch, PatternMismatch, EnumViolation, @@ -124,7 +140,10 @@ pub enum ViolationKind { // --------------------------------------------------------------------------- /// A single per-record violation entry in a `BatchValidationResult`. -#[derive(Debug, Clone, serde::Serialize)] +/// +/// The three trailing fields mirror `Violation` — see its docs for the +/// self-healing contract. +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct BatchRecordViolation { /// Zero-based index of the offending record in the unwrapped array. pub record_index: usize, @@ -134,6 +153,12 @@ pub struct BatchRecordViolation { pub message: String, /// Machine-readable violation kind. pub kind: ViolationKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestion: Option, } /// The outcome of validating a batch envelope (`{ data: [...], ... }`). @@ -297,6 +322,12 @@ pub fn validate(compiled: &CompiledContract, event: &Value) -> ValidationResult field: "".into(), message: "Event must be a JSON object".into(), kind: ViolationKind::TypeMismatch, + received: Some(event.clone()), + expected: Some("object".into()), + suggestion: Some( + "Wrap the payload in an object, e.g. { \"data\": ... }, or send NDJSON with one object per line." + .into(), + ), }], validation_us: 0, }; @@ -356,6 +387,11 @@ pub fn validate(compiled: &CompiledContract, event: &Value) -> ValidationResult field_name ), kind: ViolationKind::UndeclaredField, + received: obj.get(field_name).cloned(), + expected: None, + suggestion: Some(format!( + "Add '{field_name}' to ontology.entities in the contract YAML, or stop sending it from the producer." + )), }); } } @@ -407,6 +443,7 @@ pub fn validate_envelope_batch( field: "".into(), message: "Envelope payload must be a JSON object".into(), kind: ViolationKind::TypeMismatch, + ..Default::default() }], validation_us: t0.elapsed().as_micros() as u64, }; @@ -424,6 +461,7 @@ pub fn validate_envelope_batch( field: "success".into(), message: "Wrapper field 'success' is missing".into(), kind: ViolationKind::MissingRequiredField, + ..Default::default() }), Some(v) if !v.is_boolean() => wrapper_violations.push(BatchRecordViolation { record_index: 0, @@ -433,6 +471,9 @@ pub fn validate_envelope_batch( json_type_name(v) ), kind: ViolationKind::TypeMismatch, + received: Some(v.clone()), + expected: Some("boolean".into()), + suggestion: None, }), _ => {} } @@ -449,6 +490,9 @@ pub fn validate_envelope_batch( json_type_name(p) ), kind: ViolationKind::TypeMismatch, + received: Some(p.clone()), + expected: Some("object".into()), + suggestion: None, }), Some(pg) => { for int_field in &["page", "limit", "total"] { @@ -461,6 +505,7 @@ pub fn validate_envelope_batch( int_field ), kind: ViolationKind::MissingRequiredField, + ..Default::default() }), Some(v) if !v.is_number() => { wrapper_violations.push(BatchRecordViolation { @@ -472,6 +517,9 @@ pub fn validate_envelope_batch( json_type_name(v) ), kind: ViolationKind::TypeMismatch, + received: Some(v.clone()), + expected: Some("number".into()), + suggestion: None, }) } _ => {} @@ -487,6 +535,9 @@ pub fn validate_envelope_batch( json_type_name(has_more) ), kind: ViolationKind::TypeMismatch, + received: Some(has_more.clone()), + expected: Some("boolean".into()), + suggestion: None, }); } } @@ -504,6 +555,7 @@ pub fn validate_envelope_batch( field: cfg.records_path.clone(), message: format!("Envelope key '{}' is missing", cfg.records_path), kind: ViolationKind::MissingRequiredField, + ..Default::default() }); return BatchValidationResult { passed: 0, @@ -524,6 +576,9 @@ pub fn validate_envelope_batch( json_type_name(v) ), kind: ViolationKind::TypeMismatch, + received: Some(v.clone()), + expected: Some("array".into()), + suggestion: None, }); return BatchValidationResult { passed: 0, @@ -564,6 +619,9 @@ pub fn validate_envelope_batch( field: v.field, message: v.message, kind: v.kind, + received: v.received, + expected: v.expected, + suggestion: v.suggestion, }); } } @@ -612,9 +670,15 @@ fn validate_fields( None => { if field.required { violations.push(Violation { - field: path, + field: path.clone(), message: format!("Required field '{}' is missing", field.name), kind: ViolationKind::MissingRequiredField, + received: None, + expected: Some(field_type_label(&field.field_type)), + suggestion: Some(format!( + "Include '{}' in the event payload, or set `required: false` in contracts/*.yaml if this field can legitimately be absent.", + path + )), }); } // Optional and absent — nothing to validate @@ -648,15 +712,20 @@ fn validate_value( }; if !type_ok { + let expected = field_type_label(&field.field_type); + let got = json_type_name(value); violations.push(Violation { field: path.to_string(), message: format!( "Field '{}' expected type {:?}, got {}", - path, - field.field_type, - json_type_name(value) + path, field.field_type, got ), kind: ViolationKind::TypeMismatch, + received: Some(value.clone()), + expected: Some(expected.clone()), + suggestion: Some(format!( + "Change the producer to emit '{path}' as {expected}, or update the contract field to `type: {got}` if the producer is correct." + )), }); return; // Further checks on the wrong type make no sense } @@ -675,6 +744,9 @@ fn validate_value( min_len ), kind: ViolationKind::LengthViolation, + received: Some(value.clone()), + expected: Some(format!("string with length >= {min_len}")), + suggestion: None, }); } } @@ -689,6 +761,9 @@ fn validate_value( max_len ), kind: ViolationKind::LengthViolation, + received: Some(value.clone()), + expected: Some(format!("string with length <= {max_len}")), + suggestion: None, }); } } @@ -703,6 +778,11 @@ fn validate_value( path, s ), kind: ViolationKind::PatternMismatch, + received: Some(value.clone()), + expected: Some(format!("match regex /{}/", re.as_str())), + suggestion: Some(format!( + "Emit '{path}' so it matches the contract regex, or relax the `pattern:` in the YAML if the producer output is intentional." + )), }); } } @@ -718,6 +798,12 @@ fn validate_value( path, s ), kind: ViolationKind::PatternMismatch, + received: Some(value.clone()), + expected: Some("YYYY-MM-DD calendar date string".into()), + suggestion: Some( + "Convert the producer value to a YYYY-MM-DD string (real calendar date). ISO timestamps go in `type: string` fields, not `type: date`." + .into(), + ), }); } } @@ -730,6 +816,9 @@ fn validate_value( field: path.to_string(), message: format!("Field '{}' value {} is below minimum {}", path, n, min), kind: ViolationKind::RangeViolation, + received: Some(value.clone()), + expected: Some(format!(">= {min}")), + suggestion: None, }); } } @@ -739,6 +828,9 @@ fn validate_value( field: path.to_string(), message: format!("Field '{}' value {} exceeds maximum {}", path, n, max), kind: ViolationKind::RangeViolation, + received: Some(value.clone()), + expected: Some(format!("<= {max}")), + suggestion: None, }); } } @@ -748,15 +840,19 @@ fn validate_value( if let Some(allowed) = &field.allowed_values { if !allowed.contains(value) { let allowed_str: Vec = allowed.iter().map(|v| v.to_string()).collect(); + let joined = allowed_str.join(", "); violations.push(Violation { field: path.to_string(), message: format!( "Field '{}' value {} not in allowed set: [{}]", - path, - value, - allowed_str.join(", ") + path, value, joined ), kind: ViolationKind::EnumViolation, + received: Some(value.clone()), + expected: Some(format!("one of [{joined}]")), + suggestion: Some(format!( + "Emit '{path}' as one of the allowed values, or add the new value to the `enum:` list in contracts/*.yaml if it is a legitimate addition." + )), }); } } @@ -815,6 +911,9 @@ fn validate_metric(metric: &MetricDefinition, event: &Value, violations: &mut Ve metric.name, field_path ), kind: ViolationKind::MissingRequiredField, + received: value.cloned(), + expected: Some("number".into()), + suggestion: None, }); return; } @@ -829,6 +928,9 @@ fn validate_metric(metric: &MetricDefinition, event: &Value, violations: &mut Ve metric.name, n, min, field_path ), kind: ViolationKind::MetricRangeViolation, + received: value.cloned(), + expected: Some(format!(">= {min}")), + suggestion: None, }); } } @@ -841,6 +943,9 @@ fn validate_metric(metric: &MetricDefinition, event: &Value, violations: &mut Ve metric.name, n, max, field_path ), kind: ViolationKind::MetricRangeViolation, + received: value.cloned(), + expected: Some(format!("<= {max}")), + suggestion: None, }); } } @@ -886,6 +991,22 @@ fn json_type_name(value: &Value) -> &'static str { } } +/// Contract-YAML-friendly name for a `FieldType`, used in the `expected` field +/// of a `Violation` so agents don't have to translate `FieldType::Integer` → +/// `"integer"` themselves. +fn field_type_label(ft: &FieldType) -> String { + match ft { + FieldType::String => "string".into(), + FieldType::Integer => "integer".into(), + FieldType::Float => "number".into(), + FieldType::Boolean => "boolean".into(), + FieldType::Object => "object".into(), + FieldType::Array => "array".into(), + FieldType::Any => "any".into(), + FieldType::Date => "date (YYYY-MM-DD)".into(), + } +} + // --------------------------------------------------------------------------- // Quality rule helpers (per-event) // --------------------------------------------------------------------------- @@ -910,6 +1031,9 @@ fn check_completeness(rule: &QualityRule, event: &Value, violations: &mut Vec { @@ -920,6 +1044,9 @@ fn check_completeness(rule: &QualityRule, event: &Value, violations: &mut Vec {} // present and non-empty — passes @@ -961,6 +1088,9 @@ fn check_freshness(rule: &QualityRule, event: &Value, violations: &mut Vec Vec<(u key, rule.field ), kind: ViolationKind::UniquenessViolation, + received: Some(val.clone()), + expected: Some(format!("unique value for '{}' within the batch", rule.field)), + suggestion: None, }, )); } From 1126582e93fc00ef0f8f4a1eeff289e6ec857502 Mon Sep 17 00:00:00 2001 From: Alex Suarez Date: Sun, 23 Aug 2026 18:20:39 -0400 Subject: [PATCH 2/2] fix(ci): rustfmt uniqueness expected line CI's cargo fmt --check failed on one over-length format! in check_uniqueness_batch. --- src/validation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/validation.rs b/src/validation.rs index bed9681..152573e 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -1161,7 +1161,10 @@ pub fn check_uniqueness_batch(rules: &[QualityRule], events: &[Value]) -> Vec<(u ), kind: ViolationKind::UniquenessViolation, received: Some(val.clone()), - expected: Some(format!("unique value for '{}' within the batch", rule.field)), + expected: Some(format!( + "unique value for '{}' within the batch", + rule.field + )), suggestion: None, }, ));