diff --git a/.changeset/training-truth-of-claim.md b/.changeset/training-truth-of-claim.md new file mode 100644 index 0000000000..fa4c33b1cc --- /dev/null +++ b/.changeset/training-truth-of-claim.md @@ -0,0 +1,25 @@ +--- +--- + +feat(training-agent): truth-of-claim verifier — closes #3802 + +Completes the provenance enforcement work landed in #3792. Adds the seller-side truth-of-claim verifier that calls `get_creative_features` against the seller's `accepted_verifiers` allowlist, reconciles the result against the buyer's `digital_source_type` claim, and emits `PROVENANCE_CLAIM_CONTRADICTED` with audit-safe `error.details` when the verifier refutes the claim. + +`server/src/training-agent/task-handlers.ts`: +- `handleGetCreativeFeatures` — governance-agent-shaped handler returning deterministic AI-detection results. Detection encoded in the creative manifest's asset URL pattern (substring `ai-generated-true` / `ai_gen_true` → `ai_generated: true`; `ai-generated-false` / `ai_gen_false` → `false`). When neither URL pattern matches, derives from buyer-claimed `digital_source_type` via the canonical AI_TRUE_DST set. Storyboards drive contradiction outcomes from the fixture without per-test stateful bookkeeping. +- `runProvenanceVerifier` — in-process verifier-call helper invoked by `enforceProvenancePolicy` after the structural-rejection cascade. Selects buyer-nominated verifier when on-list, falls back to first on-list entry (with `substituted_for` audit trail). Threshold: `ai_generated=true` with confidence ≥ 0.9 against a non-AI claim → contradiction. +- `enforceProvenancePolicy` is now async; emits `PROVENANCE_CLAIM_CONTRADICTED` with `error.details` constrained to the `error-code.json` allowlist: `{ agent_url, feature_id, claimed_value, observed_value, confidence, substituted_for? }`. + +`v6-sales-platform.ts` / `v6-creative-platform.ts`: +- Thread `ctx.account.ctx_metadata.brand_domain` through the `syncCreatives` shim. Without this fix, the v6→v5 shim landed in #3713 was stripping brand from the args, causing `sessionKeyFromArgs` to route to `open:default` while seeded `creative_policy` lived on `open:`. Result: `aggregateCreativePolicy` returned null and the entire enforcement cascade silently no-opped. Fix restores conformance for `media_buy_seller/provenance_enforcement` (which had been silently passing post-#3713 because no rejection was firing) and unblocks the new `media_buy_seller/provenance_truth_of_claim`. + +`static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml`: +- Fleshed out from skeleton to full 3-phase scenario: discover_verifier → reject_contradicted_claim (asset URL `ai-generated-true.jpg` triggers verifier verdict that contradicts `digital_capture` claim → `PROVENANCE_CLAIM_CONTRADICTED` with audit-safe details) → accept_consistent_claim (asset URL `ai-generated-false.jpg` confirms claim → accepted). + +Removed from `KNOWN_FAILING_STORYBOARDS` in `server/tests/manual/run-storyboards.ts`. + +Local conformance on `/creative` tenant: +- `media_buy_seller/provenance_enforcement` ✓ 6P / 1S / 0N/A +- `media_buy_seller/provenance_truth_of_claim` ✓ 3P / 1S / 0N/A + +Refs: #3468, #3777, #3792, #3713. diff --git a/package-lock.json b/package-lock.json index dd5b5efb69..b8e9efa895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -867,7 +867,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -914,7 +913,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1498,7 +1496,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -3713,8 +3710,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@mintlify/prebuild/node_modules/emoji-regex": { "version": "8.0.0", @@ -4762,8 +4758,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@mintlify/scraping/node_modules/emoji-regex": { "version": "8.0.0", @@ -5172,7 +5167,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -5529,7 +5523,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5862,7 +5855,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@radix-ui/number": { "version": "1.1.1", @@ -6608,7 +6602,6 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -8497,6 +8490,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -8702,7 +8696,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8830,7 +8823,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9194,7 +9186,6 @@ "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9263,7 +9254,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10456,7 +10446,8 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/clean-stack": { "version": "4.2.0", @@ -11092,7 +11083,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/csv-parse": { "version": "6.2.1", @@ -11470,8 +11462,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -12417,7 +12408,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14564,7 +14554,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -14864,7 +14853,6 @@ "integrity": "sha512-2CbJAa7XeziZYe6pDS5RVLirRY28iSGMQuEV8jRU5NQsONQNfcR/BZHHc9vkMg2lGYTHTM2pskxC1YmY28p6bQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -15943,7 +15931,6 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -19281,7 +19268,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -19546,7 +19532,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20303,7 +20288,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -21351,7 +21335,6 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -21490,7 +21473,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/secure-json-parse": { "version": "4.1.0", @@ -23074,7 +23058,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23268,7 +23251,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -23442,7 +23424,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23526,7 +23507,6 @@ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -24003,7 +23983,6 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -24785,7 +24764,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 5895cbdc66..9b9ac8a0ad 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -710,9 +710,12 @@ interface CreativeManifestView { interface CreativeForEnforcement { creative_id: string; provenance?: Record; + // sync_creatives carries assets directly on the creative entry + // (alongside format_id, click_url, etc.). + assets?: Record }>; manifest?: CreativeManifestView; - // sync_creatives also carries provenance directly on the creative-asset - // and on a separate `creative_manifest` field per the spec. + // build_creative / preview_creative use the nested `creative_manifest` + // shape per the spec; sync_creatives does not. creative_manifest?: CreativeManifestView; } @@ -752,7 +755,8 @@ function provenanceError( | 'PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING' | 'PROVENANCE_DISCLOSURE_MISSING' | 'PROVENANCE_EMBEDDED_MISSING' - | 'PROVENANCE_VERIFIER_NOT_ACCEPTED', + | 'PROVENANCE_VERIFIER_NOT_ACCEPTED' + | 'PROVENANCE_CLAIM_CONTRADICTED', message: string, field: string, ): TaskError { @@ -781,10 +785,10 @@ function provenanceError( * of scope for this initial implementation — the structural codes are * sufficient to exercise the wire contract end to end. */ -function enforceProvenancePolicy( +async function enforceProvenancePolicy( creative: CreativeForEnforcement, policy: CreativePolicyView | null, -): TaskError | null { +): Promise { if (!policy) return null; const provenance = resolveManifestProvenance(creative); // creative_id is buyer-controlled — sanitize before interpolating into @@ -879,6 +883,29 @@ function enforceProvenancePolicy( } } + // 6. Truth-of-claim: invoke the verifier when accepted_verifiers is set + // and reconcile its result against the buyer's digital_source_type + // claim. Closes adcp#3802. Returns the verifier-emitted contradiction + // metadata (audit-safe allowlist only — no detail_url, no extension + // fields) for error.details. + const contradiction = await runProvenanceVerifier(creative, policy); + if (contradiction) { + const err = provenanceError( + 'PROVENANCE_CLAIM_CONTRADICTED', + `Verifier ${sanitizeForError(contradiction.agent_url, 256)} (feature ${sanitizeForError(contradiction.feature_id, 64)}) returned ai_generated=${contradiction.observed_value} with confidence ${contradiction.confidence.toFixed(2)} — contradicts buyer claim digital_source_type="${sanitizeForError(contradiction.claimed_value, 64)}".`, + `${fieldRoot}.digital_source_type`, + ); + (err as TaskError & { details?: Record }).details = { + agent_url: contradiction.agent_url, + feature_id: contradiction.feature_id, + claimed_value: contradiction.claimed_value, + observed_value: contradiction.observed_value, + confidence: contradiction.confidence, + ...(contradiction.substituted_for ? { substituted_for: contradiction.substituted_for } : {}), + }; + return err; + } + return null; } @@ -2454,7 +2481,7 @@ export async function handleSyncCreatives(args: ToolArgs, ctx: TrainingContext) // accepted_verifiers BEFORE persisting the creative. Per-creative failure // is surfaced as action: 'failed' + errors[]; the surrounding session and // any other creatives in the batch are unaffected (best-effort processing). - const policyError = enforceProvenancePolicy(creative as unknown as CreativeForEnforcement, effectivePolicy); + const policyError = await enforceProvenancePolicy(creative as unknown as CreativeForEnforcement, effectivePolicy); if (policyError) { results.push({ creative_id: creativeId, @@ -3745,6 +3772,170 @@ interface ReportUsageArgs extends ToolArgs { }>; } +// ── get_creative_features (truth-of-claim verifier; closes #3802) ── +// +// Governance-agent-shaped handler the training agent exposes so the +// seller-side sync_creatives enforcement path can verify buyer-claimed +// provenance. Returns deterministic AI-detection results derived from the +// creative manifest's asset URLs — encoded in the URL filename pattern so +// storyboards can drive both "claim confirmed" and "claim contradicted" +// outcomes from the fixture without per-test stateful bookkeeping. +// +// URL pattern detection (case-insensitive substring match on each asset URL): +// - Contains "ai-generated-true" or "ai_gen_true" -> ai_generated: true, conf 0.95 +// - Contains "ai-generated-false" or "ai_gen_false" -> ai_generated: false, conf 0.95 +// - Otherwise: derive from buyer-claimed digital_source_type (verifier +// "agrees" with the claim by default). digital_capture / digital_creation / +// human_edits / composite_capture / data_driven_media -> ai_generated: false. +// trained_algorithmic_media / composite_with_trained_algorithmic_media / +// composite_synthetic / algorithmic_media -> ai_generated: true. +// - When neither URL pattern matches and digital_source_type is absent, +// return ai_generated: false with low confidence (0.3) — agnostic on +// missing claim. The storyboard's structural-rejection contract catches +// missing digital_source_type via PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING +// before we reach the truth-of-claim path. +// +// The handler is documented and stable for storyboard authors: pick the URL +// pattern that drives the test outcome, no fixture mutation needed. +interface GetCreativeFeaturesArgs { + creative_manifest?: { provenance?: Record; assets?: Record }> }; + feature_ids?: string[]; +} + +interface CreativeFeatureResult { + feature_id: string; + value: boolean | number | string; + confidence?: number; +} + +const AI_TRUE_DST = new Set([ + 'trained_algorithmic_media', + 'composite_with_trained_algorithmic_media', + 'composite_synthetic', + 'algorithmic_media', +]); + +function detectAiFromManifest(creative_manifest: GetCreativeFeaturesArgs['creative_manifest']): { value: boolean; confidence: number } { + const assets = creative_manifest?.assets ?? {}; + for (const asset of Object.values(assets)) { + const url = typeof asset?.url === 'string' ? asset.url.toLowerCase() : ''; + if (url.includes('ai-generated-true') || url.includes('ai_gen_true')) return { value: true, confidence: 0.95 }; + if (url.includes('ai-generated-false') || url.includes('ai_gen_false')) return { value: false, confidence: 0.95 }; + } + const dst = creative_manifest?.provenance?.digital_source_type; + if (typeof dst === 'string') { + if (AI_TRUE_DST.has(dst)) return { value: true, confidence: 0.85 }; + return { value: false, confidence: 0.85 }; + } + return { value: false, confidence: 0.3 }; +} + +export async function handleGetCreativeFeatures(args: ToolArgs, _ctx: TrainingContext) { + const req = args as unknown as GetCreativeFeaturesArgs; + const requested = req.feature_ids?.length ? new Set(req.feature_ids) : null; + const ai = detectAiFromManifest(req.creative_manifest); + + const allFeatures: CreativeFeatureResult[] = [ + { feature_id: 'ai_generated', value: ai.value, confidence: ai.confidence }, + { feature_id: 'ai_modified', value: false, confidence: 0.6 }, + { feature_id: 'ai_confidence', value: ai.confidence }, + ]; + const results = requested ? allFeatures.filter(f => requested.has(f.feature_id)) : allFeatures; + return { results }; +} + +/** + * Run the seller-side truth-of-claim verifier on a creative manifest. + * In a real seller, this would invoke `get_creative_features` over the + * network against a governance agent on the seller's `accepted_verifiers` + * allowlist. The training agent acts as both seller and verifier in one + * process, so we call the handler directly. The wire contract is identical: + * the seller obtains a feature result and reconciles it against the buyer's + * provenance claim. + * + * Returns the verifier identity + result for `error.details` on + * PROVENANCE_CLAIM_CONTRADICTED, or null when the claim is confirmed. + */ +async function runProvenanceVerifier( + creative: CreativeForEnforcement, + policy: CreativePolicyView, +): Promise<{ agent_url: string; feature_id: string; claimed_value: string; observed_value: boolean; confidence: number; substituted_for?: string } | null> { + const provenance = resolveManifestProvenance(creative); + const claimed = provenance?.digital_source_type; + if (typeof claimed !== 'string') return null; // no claim to refute; structural codes handle absence + if (!policy.accepted_verifiers?.length) return null; + + // Pick the buyer's nominated verifier when on-list, else the first + // on-list entry the seller would use. The seller is the verifier-of- + // record per #3468 — buyer-asserted URLs are hints, not authoritative. + const buyerNominated = pickBuyerNominatedVerifierUrl(provenance); + const allowed = policy.accepted_verifiers; + const buyerCanonical = buyerNominated ? canonicalizeAgentUrl(buyerNominated) : null; + let chosen = allowed.find(v => buyerCanonical && canonicalizeAgentUrl(v.agent_url) === buyerCanonical); + let substituted: string | undefined; + if (!chosen) { + chosen = allowed[0]; + substituted = buyerNominated ?? undefined; + } + const featureId = chosen.feature_id ?? 'ai_generated'; + + // In-process call to the verifier handler. Real sellers do this over + // the network; the contract result is the same. Synthesize a manifest + // from whichever shape the creative carries: sync_creatives puts + // assets at the top level, build_creative / preview_creative nest them + // under creative_manifest. Either path resolves to the same input. + const synthesized = creative.creative_manifest ?? creative.manifest ?? { + provenance: creative.provenance, + assets: creative.assets, + }; + if (!synthesized.assets && creative.assets) { + synthesized.assets = creative.assets; + } + const features = await handleGetCreativeFeatures( + { creative_manifest: synthesized, feature_ids: [featureId] } as unknown as ToolArgs, + {} as unknown as TrainingContext, + ) as unknown as { results?: CreativeFeatureResult[] }; + const result = features.results?.find(r => r.feature_id === featureId); + if (!result) return null; + + const verifierSaysAi = result.value === true; + const claimsAi = AI_TRUE_DST.has(claimed); + const confidence = typeof result.confidence === 'number' ? result.confidence : 0; + + // Contradiction: buyer says non-AI but verifier sees AI with confidence + // above the seller's threshold. The reverse direction (buyer claims AI + // but verifier sees non-AI) is NOT a contradiction — buyers may + // conservatively over-disclose. Threshold matches the storyboard + // assertion: 0.9 confidence is the established line for high-signal + // detector verdicts (see PROVENANCE_CLAIM_CONTRADICTED docstring). + const CONFIDENCE_THRESHOLD = 0.9; + if (verifierSaysAi && !claimsAi && confidence >= CONFIDENCE_THRESHOLD) { + return { + agent_url: chosen.agent_url, + feature_id: featureId, + claimed_value: claimed, + observed_value: verifierSaysAi, + confidence, + ...(substituted ? { substituted_for: substituted } : {}), + }; + } + return null; +} + +function pickBuyerNominatedVerifierUrl(provenance: Record | undefined): string | null { + type Layer = { verify_agent?: { agent_url?: unknown } }; + const layers: Layer[] = []; + const e = provenance?.embedded_provenance; + if (Array.isArray(e)) layers.push(...(e as Layer[])); + const w = provenance?.watermarks; + if (Array.isArray(w)) layers.push(...(w as Layer[])); + for (const layer of layers) { + const url = layer.verify_agent?.agent_url; + if (typeof url === 'string' && url.length > 0) return url; + } + return null; +} + export async function handleReportUsage(args: ToolArgs, ctx: TrainingContext) { const req = args as unknown as ReportUsageArgs; const session = await getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); diff --git a/server/src/training-agent/v6-creative-platform.ts b/server/src/training-agent/v6-creative-platform.ts index 212f7edc7a..955bfcdb25 100644 --- a/server/src/training-agent/v6-creative-platform.ts +++ b/server/src/training-agent/v6-creative-platform.ts @@ -134,7 +134,15 @@ export class TrainingCreativePlatform return translateV5Result(result); }, syncCreatives: async (creatives, ctx) => { - const result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account)); + // Thread brand domain through so sessionKeyFromArgs in the v5 + // handler resolves to the same session the test-controller seeded + // creative_policy against. Without this, the sync lands in + // open:default while seeded products live on open:, and + // aggregateCreativePolicy returns null — provenance enforcement + // silently no-ops. + const brandDomain = (ctx.account as { ctx_metadata?: { brand_domain?: string } } | undefined)?.ctx_metadata?.brand_domain; + const args = brandDomain ? { creatives, brand: { domain: brandDomain } } : { creatives }; + const result = await handleSyncCreatives(args as unknown as ToolArgs, buildTrainingCtx(ctx.account)); // v5 returns wire-wrapped `{ creatives: [...] }`; v6 wants rows. const wrapped = translateV5Result<{ creatives?: unknown[] }>(result); return (wrapped.creatives ?? []) as SyncCreativesRow[]; diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index 94c304d339..d3213a7d5d 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -171,7 +171,15 @@ export class TrainingSalesPlatform }, syncCreatives: async (creatives, ctx) => { - const v5Result = await handleSyncCreatives({ creatives } as unknown as ToolArgs, buildTrainingCtx(ctx.account)); + // Thread brand domain through so `sessionKeyFromArgs` in the v5 + // handler resolves to the same session the test-controller seeded + // creative_policy against. Without this, sync_creatives lands in + // `open:default` while the seeded products live on `open:`, + // and `aggregateCreativePolicy` returns null — provenance + // enforcement silently no-ops. + const brandDomain = (ctx.account as { ctx_metadata?: { brand_domain?: string } } | undefined)?.ctx_metadata?.brand_domain; + const args = brandDomain ? { creatives, brand: { domain: brandDomain } } : { creatives }; + const v5Result = await handleSyncCreatives(args as unknown as ToolArgs, buildTrainingCtx(ctx.account)); // v5 returns wire-wrapped `{ creatives: [...] }`; v6 SalesPlatform // wants rows directly — framework re-wraps. const wrapped = translateV5Result<{ creatives?: unknown[] }>(v5Result); diff --git a/server/tests/manual/run-storyboards.ts b/server/tests/manual/run-storyboards.ts index f56a09c51c..bd28ec93e1 100644 --- a/server/tests/manual/run-storyboards.ts +++ b/server/tests/manual/run-storyboards.ts @@ -125,14 +125,6 @@ const KNOWN_FAILING_STORYBOARDS: ReadonlyMap = new Map([ // Tracked upstream as adcp#3429; remove once the storyboard is migrated to // `envelope_field_present` AND the framework wraps capabilities responses. ['v3_envelope_integrity', 'adcp-client#1045 / adcp#3429 — storyboard asserts envelope status, framework capabilities tool returns unenveloped payload'], - // Skeleton scenario for the truth-of-claim half of provenance enforcement - // (PROVENANCE_CLAIM_CONTRADICTED). The training agent does not yet - // implement get_creative_features against accepted_verifiers, so the - // verifier-driven contradiction path can't run end to end. Tracked in - // adcp#3802; remove once the training agent ships truth-of-claim - // verification and the storyboard's placeholder phase is fleshed out - // with the full negative + positive paths. - ['media_buy_seller/provenance_truth_of_claim', 'adcp#3802 — training agent does not yet invoke get_creative_features against accepted_verifiers (truth-of-claim path); storyboard is a registered skeleton'], ]); /** diff --git a/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml b/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml index e68b719e3e..5828d14d39 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/provenance_truth_of_claim.yaml @@ -1,38 +1,41 @@ id: media_buy_seller/provenance_truth_of_claim -version: "0.1.0" +version: "1.0.0" title: "Seller refutes a buyer's provenance claim via on-list verifier" category: media_buy_seller -summary: "Buyer attaches a digital_source_type claim. Seller calls an on-list governance agent via get_creative_features; the verifier returns a result that contradicts the claim, and the seller rejects with PROVENANCE_CLAIM_CONTRADICTED. Skeleton scenario — not yet runnable end-to-end (the training agent does not implement get_creative_features against verifiers)." +summary: "Buyer attaches a digital_source_type claim. Seller invokes get_creative_features against an on-list verifier (creative_policy.accepted_verifiers); when the verifier contradicts the claim, seller rejects with PROVENANCE_CLAIM_CONTRADICTED carrying audit-safe error.details." track: creative required_tools: - get_products - sync_creatives - - get_creative_features narrative: | This is the truth-of-claim half of the provenance enforcement contract. The structural-rejection half (media_buy_seller/provenance_enforcement) - exercises the PROVENANCE_*_MISSING / PROVENANCE_VERIFIER_NOT_ACCEPTED codes - — failures the seller can detect by inspecting the submission against + exercises the PROVENANCE_*_MISSING / PROVENANCE_VERIFIER_NOT_ACCEPTED + codes — failures the seller detects by inspecting the submission against creative_policy. This scenario exercises PROVENANCE_CLAIM_CONTRADICTED: the seller calls a governance agent from creative_policy.accepted_verifiers - via get_creative_features, the verifier returns a result that contradicts + via get_creative_features; the verifier returns a result that contradicts the buyer's provenance claim (e.g., buyer claims digital_source_type: digital_capture but the AI-detection feature returns ai_generated: true above the seller's confidence threshold), and the seller rejects. - This is a SKELETON. The training agent does not yet: - 1. Stand up a governance-agent endpoint on its own URL - 2. Invoke get_creative_features against accepted_verifiers entries - 3. Reconcile feature results against the buyer's provenance claim - 4. Emit PROVENANCE_CLAIM_CONTRADICTED with the audit-safe error.details - allowlist (agent_url, feature_id, claimed_value, observed_value, - confidence, substituted_for) + Three phases: + 1. Discover — get_products surfaces accepted_verifiers + 2. Reject contradicted — buyer claims digital_capture (non-AI) but + attaches an asset URL the verifier flags as AI-generated. Seller + invokes the verifier, observes contradiction, returns + PROVENANCE_CLAIM_CONTRADICTED with error.details carrying the + audit-safe allowlist. + 3. Accept consistent — buyer claims digital_capture and attaches an + asset URL the verifier confirms as non-AI. Seller invokes the + verifier, observes agreement, accepts. - Tracked via the issue referenced from KNOWN_FAILING_STORYBOARDS in - server/tests/manual/run-storyboards.ts. When the training agent ships - the truth-of-claim path, remove the KNOWN_FAILING entry and flesh out - this storyboard's phases (currently a single placeholder step). + The training agent's verifier is deterministic: substring "ai-generated-true" + in any asset URL drives the verifier to return ai_generated:true with + confidence 0.95; "ai-generated-false" drives ai_generated:false. This + removes any per-test stateful bookkeeping — the storyboard fixture's + asset URLs are the contract. agent: interaction_model: stateful_push @@ -49,8 +52,9 @@ prerequisites: description: | Seller publishes a product with creative_policy.accepted_verifiers pointing at a governance agent that implements get_creative_features. - The training agent's get_creative_features path is not yet implemented; - this storyboard is a skeleton tracked as known-failing. + The training agent acts as both seller and verifier in-process; the + accepted_verifiers entry's agent_url is a sentinel value that the + seller maps to its internal verifier. test_kit: "test-kits/acme-outdoor.yaml" controller_seeding: true @@ -58,7 +62,7 @@ fixtures: products: - product_id: "test-product-truth-of-claim" name: "Provenance Truth-of-Claim Test Product" - description: "Sandbox display inventory exercising verifier-driven contradiction of buyer provenance claims" + description: "Sandbox display inventory exercising verifier-driven contradiction of buyer provenance claims via accepted verifier allowlist" delivery_type: "non_guaranteed" channels: ["display"] creative_policy: @@ -77,30 +81,29 @@ fixtures: currency: "USD" phases: - - id: placeholder - title: "Skeleton phase — not yet runnable" + - id: discover_verifier + title: "Discover the verifier allowlist" narrative: | - This phase is a placeholder so the storyboard parses and registers in - the conformance inventory. The full flow will be: get_products to read - accepted_verifiers, sync_creatives with a contradicted claim, expect - action: failed with creatives[0].errors[0].code == - PROVENANCE_CLAIM_CONTRADICTED and error.details limited to the safe - allowlist. Track in run-storyboards.ts KNOWN_FAILING_STORYBOARDS. + The buyer reads creative_policy.accepted_verifiers from get_products + so it knows which on-list governance agent it can attach to its + embedded_provenance entries. This is the same get_products flow as + the structural-rejection storyboard. steps: - - id: get_products_truth_of_claim - title: "Discover the truth-of-claim product" + - id: get_products_with_accepted_verifiers + title: "Read accepted_verifiers from creative_policy" task: get_products schema_ref: "media-buy/get-products-request.json" response_schema_ref: "media-buy/get-products-response.json" doc_ref: "/media-buy/task-reference/get_products" stateful: false expected: | - Skeleton — the storyboard is on KNOWN_FAILING until the training - agent implements truth-of-claim verification. + Return the seeded product with creative_policy.accepted_verifiers + populated. Buyer reads the agent_url for use on its + verify_agent reference in the next phase. sample_request: buying_mode: "brief" - brief: "Provenance Truth-of-Claim display inventory — verifier-driven AI detection" + brief: "Provenance Truth-of-Claim display inventory — verifier-driven AI detection on accepted verifier allowlist" brand: domain: "acmeoutdoor.example" account: @@ -112,3 +115,180 @@ phases: validations: - check: response_schema description: "Response matches get-products-response.json schema" + - check: field_present + path: "products[0].creative_policy.accepted_verifiers[0].agent_url" + description: "Seller publishes at least one accepted verifier" + - check: field_value + path: "context.correlation_id" + value: "provenance_truth_of_claim--get_products" + description: "Context correlation_id returned unchanged" + + - id: reject_contradicted_claim + title: "Sync with contradicted claim — rejected" + narrative: | + The buyer claims digital_source_type: digital_capture (non-AI) but + attaches an asset URL containing "ai-generated-true" — which drives + the seller's deterministic verifier to return ai_generated:true with + confidence 0.95. The seller invokes the verifier (via + get_creative_features against the on-list accepted_verifiers entry), + observes the contradiction, and rejects the per-creative entry with + PROVENANCE_CLAIM_CONTRADICTED. + + error.details carries only the audit-safe allowlist defined in + error-code.json: agent_url, feature_id, claimed_value, observed_value, + confidence (and substituted_for when the seller substitutes a + different on-list agent than the buyer nominated). No detail_url, no + verifier extension fields — that's the trust boundary that prevents + verifier responses from leaking cross-tenant data through the seller. + + steps: + - id: sync_creatives_contradicted + title: "Submit creative whose claim contradicts the verifier's verdict" + task: sync_creatives + schema_ref: "creative/sync-creatives-request.json" + response_schema_ref: "creative/sync-creatives-response.json" + doc_ref: "/creative/task-reference/sync_creatives" + stateful: true + expected: | + The seller invokes the on-list verifier against the manifest's + asset URLs, observes ai_generated:true with confidence >= 0.9, + and refutes the buyer's digital_capture claim. Per-creative + result: action: failed, errors[0].code: PROVENANCE_CLAIM_CONTRADICTED. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + creatives: + - creative_id: "acme_truth_of_claim_probe_001" + name: "Acme truth-of-claim probe — contradicted" + format_id: + agent_url: "https://your-platform.example.com" + id: "display_300x250" + assets: + headline: + asset_type: "text" + content: "Outdoor gear, photographed live" + image: + asset_type: "image" + url: "https://test-assets.adcontextprotocol.org/acme-outdoor/ai-generated-true.jpg" + width: 300 + height: 250 + click_url: + asset_type: "url" + url: "https://acmeoutdoor.example/spring" + provenance: + digital_source_type: "digital_capture" + declared_by: + role: "agency" + disclosure: + required: false + embedded_provenance: + - method: "provenance_markers" + provider: "Encypher" + verify_agent: + agent_url: "https://governance.encypher.seller.example" + feature_id: "ai_generated" + idempotency_key: "$generate:uuid_v4#provenance_truth_of_claim_reject_contradicted_sync" + context: + correlation_id: "provenance_truth_of_claim--reject_contradicted" + validations: + - check: response_schema + description: "Response matches sync-creatives-response.json schema" + - check: field_value + path: "creatives[0].action" + value: "failed" + description: "Per-creative action is failed when verifier contradicts the claim" + - check: field_value + path: "creatives[0].errors[0].code" + value: "PROVENANCE_CLAIM_CONTRADICTED" + description: "Per-creative error code is PROVENANCE_CLAIM_CONTRADICTED" + - check: field_present + path: "creatives[0].errors[0].details.agent_url" + description: "error.details carries the verifier's agent_url for audit" + - check: field_present + path: "creatives[0].errors[0].details.feature_id" + description: "error.details carries the queried feature_id" + - check: field_value + path: "creatives[0].errors[0].details.claimed_value" + value: "digital_capture" + description: "error.details carries the buyer's claimed digital_source_type" + - check: field_value + path: "creatives[0].errors[0].details.observed_value" + value: true + description: "error.details carries the verifier's observed ai_generated value" + - check: field_value + path: "context.correlation_id" + value: "provenance_truth_of_claim--reject_contradicted" + description: "Context correlation_id returned unchanged on rejection" + + - id: accept_consistent_claim + title: "Sync with consistent claim — accepted" + narrative: | + The buyer's claim and the verifier's observation agree: buyer claims + digital_source_type: digital_capture (non-AI), asset URL contains + "ai-generated-false" which drives the verifier to return + ai_generated:false. Seller's verifier-call confirms the claim; the + creative enters the seller's review lifecycle. + + steps: + - id: sync_creatives_consistent + title: "Submit creative whose claim matches the verifier's verdict" + task: sync_creatives + schema_ref: "creative/sync-creatives-request.json" + response_schema_ref: "creative/sync-creatives-response.json" + doc_ref: "/creative/task-reference/sync_creatives" + stateful: true + expected: | + The seller invokes the verifier, observes ai_generated:false, + confirms the claim, and accepts. Per-creative result: action + created or updated; no per-creative errors[]. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + creatives: + - creative_id: "acme_truth_of_claim_probe_002" + name: "Acme truth-of-claim probe — consistent" + format_id: + agent_url: "https://your-platform.example.com" + id: "display_300x250" + assets: + headline: + asset_type: "text" + content: "Outdoor gear, photographed live" + image: + asset_type: "image" + url: "https://test-assets.adcontextprotocol.org/acme-outdoor/ai-generated-false.jpg" + width: 300 + height: 250 + click_url: + asset_type: "url" + url: "https://acmeoutdoor.example/spring" + provenance: + digital_source_type: "digital_capture" + declared_by: + role: "agency" + disclosure: + required: false + embedded_provenance: + - method: "provenance_markers" + provider: "Encypher" + verify_agent: + agent_url: "https://governance.encypher.seller.example" + feature_id: "ai_generated" + idempotency_key: "$generate:uuid_v4#provenance_truth_of_claim_accept_consistent_sync" + context: + correlation_id: "provenance_truth_of_claim--accept_consistent" + validations: + - check: response_schema + description: "Response matches sync-creatives-response.json schema" + - check: field_value + path: "creatives[0].action" + allowed_values: ["created", "updated"] + description: "Per-creative action is created or updated when verifier confirms the claim" + - check: field_value + path: "context.correlation_id" + value: "provenance_truth_of_claim--accept_consistent" + description: "Context correlation_id returned unchanged"