diff --git a/.changeset/close-3-2-governance-loop.md b/.changeset/close-3-2-governance-loop.md new file mode 100644 index 0000000000..05e551fb72 --- /dev/null +++ b/.changeset/close-3-2-governance-loop.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Complete cross-role governance conformance coverage for AdCP 3.2 with exact task-and-mode capability gates, positive and negative rights and paid-creative proofs, a universal discoverability index, and matching reference-agent enforcement. diff --git a/docs/building/verification/conformance.mdx b/docs/building/verification/conformance.mdx index 2b6d876f52..332c85c21b 100644 --- a/docs/building/verification/conformance.mdx +++ b/docs/building/verification/conformance.mdx @@ -117,6 +117,12 @@ Every agent satisfies the universal layer. Each `supported_protocols` claim adds Agents MUST NOT declare a capability whose storyboards they do not pass. See the [Compliance Catalog](/docs/building/verification/compliance-catalog) for the full taxonomy and [Validate Your Agent](/docs/building/verification/validate-your-agent) for how to run the suite locally. +The non-executable [cross-role governance index](/compliance/latest/universal/governance.yaml) +maps the shared governance contract to capability-gated media-buy, signals, +rights, and creative proofs. It is discoverability metadata, not an additional +universal storyboard; only an agent's exact +`adcp.governance_enforcement.tasks[]` claims select those role-specific tests. + ## Universal conformance Every agent MUST pass every storyboard below. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index c60655fc75..3b80a15886 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -209,9 +209,10 @@ The declaration is part of the experimental `governance.campaign` surface, so an Advertising a task also commits the service to deterministic applicability. It resolves the commercial account from the request or existing resource before the side effect; when it cannot do so, it returns [`ACCOUNT_REQUIRED`](/docs/building/verification/compliance-catalog#error-code-account-required). It never treats a missing token as evidence that the buyer is ungoverned. Conditional task annotations use `trigger_overrides_exemption`: any commitment-increasing part of a mixed atomic update requires governance. -Current conformance runners continue to grade the legacy `media_buy.governance_aware` create path. Task-scoped grading for this new block will land with role-specific observable workflows; until then, registries MUST NOT present the experimental declaration as a certified claim. - -The legacy `media_buy.governance_aware` boolean remains the compatibility claim for the historically graded `create_media_buy` consultation path. Agents use the core block for explicit task-scoped claims. +AdCP 3.2 conformance selects role-specific governance workflows from exact +entries in `adcp.governance_enforcement.tasks[]`. The legacy +`media_buy.governance_aware` boolean remains a compatibility signal for older +runners, but it is not the 3.2 certification gate. ### adcp.attestations diff --git a/docs/reference/migration/cross-role-governance-enforcement.mdx b/docs/reference/migration/cross-role-governance-enforcement.mdx index 1f166eff37..b75ef0d62d 100644 --- a/docs/reference/migration/cross-role-governance-enforcement.mdx +++ b/docs/reference/migration/cross-role-governance-enforcement.mdx @@ -61,3 +61,23 @@ Validate generated SDK signers and verifiers against the published [`governance- Until an SDK release contains this schema bundle, its generated `check_governance` validators are stale. The training agent deliberately uses passthrough MCP tool schemas with framework request/response validation disabled for this experimental tenant, then validates in the source-aligned handler. Production integrations MUST regenerate or pin a compatible SDK rather than copying that compatibility overlay. Pin experimental integrations to the matching 3.2 schema bundle until the surface graduates. + +## Conformance coverage + +The [cross-role governance index](/compliance/latest/universal/governance.yaml) +connects this contract to the executable proof for each service role. It is an +index, not one universal workflow: each service storyboard runs only when the +agent advertises the exact task and mode in +`adcp.governance_enforcement.tasks[]`. + +| Service role | Governed task | Capability-gated proof | +|---|---|---| +| Media buy | `create_media_buy` | [signed approval plus tamper rejection](/compliance/latest/protocols/media-buy/scenarios/governance_approved.yaml); agents also claiming `online_execution_check` run the [conditions](/compliance/latest/protocols/media-buy/scenarios/governance_conditions.yaml) and [denied](/compliance/latest/protocols/media-buy/scenarios/governance_denied.yaml) paths | +| Signals | `activate_signal` | [signed activation and persisted-deployment proof](/compliance/latest/specialisms/signal-marketplace/scenarios/governance_approved.yaml), plus [unauthorized paid activation and no-upstream-call proof](/compliance/latest/specialisms/signal-marketplace/scenarios/governance_denied.yaml) | +| Brand rights | `acquire_rights` | [signed grant and persisted-state proof](/compliance/latest/specialisms/brand-rights/scenarios/governance_approved.yaml), plus [unauthorized acquisition and no-grant proof](/compliance/latest/specialisms/brand-rights/scenarios/governance_denied.yaml) | +| Creative | `build_creative` | [signed paid-render artifact proof](/compliance/latest/specialisms/creative-transformers/scenarios/governance_approved.yaml), plus [unauthorized paid render and no-upstream-call proof](/compliance/latest/specialisms/creative-transformers/scenarios/governance_denied.yaml) | + +Governance providers and enforcing services remain separate roles. Provider +responses prove verdict semantics; service storyboards prove that signed intent +is checked before a role-specific side effect. Merely returning or echoing +`governance_context` is not enforcement evidence. diff --git a/package.json b/package.json index 580e671e64..6328928f8c 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:governance-runtime-attestations": "node --test --test-force-exit --test-timeout=30000 tests/governance-runtime-attestations.test.cjs", "test:governance-conditions-storyboard": "node --test --test-force-exit --test-timeout=30000 tests/governance-conditions-storyboard.test.cjs", "test:signal-governance-storyboard": "node --test --test-force-exit --test-timeout=30000 tests/signal-governance-storyboard.test.cjs", + "test:governance-task-slices": "node --test --test-force-exit --test-timeout=30000 tests/governance-task-slices.test.cjs", "test:audience-evidence": "node --test --test-force-exit --test-timeout=30000 tests/audience-evidence.test.cjs", "test:status-as-of-vectors": "node --test --test-force-exit --test-timeout=30000 tests/media-buy-status-as-of-vectors.test.cjs", "test:storyboard-scoping": "node --test --test-force-exit --test-timeout=30000 tests/lint-storyboard-scoping.test.cjs", @@ -132,7 +133,7 @@ "audit:oneof": "node scripts/audit-oneof.mjs", "test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check", "fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs", - "test": "npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:language-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", + "test": "npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:language-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:governance-task-slices && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 240 npm run precommit:server-unit && npm run typecheck", diff --git a/scripts/generate-compliance-snippets.mjs b/scripts/generate-compliance-snippets.mjs index ea49534c3e..14398053ff 100644 --- a/scripts/generate-compliance-snippets.mjs +++ b/scripts/generate-compliance-snippets.mjs @@ -36,6 +36,7 @@ const ERROR_CODE = /^[A-Z][A-Z0-9_]*$/; const RECOVERY_VALUES = new Set(['correctable', 'transient', 'terminal']); const UNIVERSAL_SUPPORT_ARTIFACTS = new Set([ 'fictional-entities', + 'governance', 'runner-output-contract', 'storyboard-schema', ]); diff --git a/scripts/lint-storyboard-contradictions.cjs b/scripts/lint-storyboard-contradictions.cjs index afc0995070..515e4ff85d 100644 --- a/scripts/lint-storyboard-contradictions.cjs +++ b/scripts/lint-storyboard-contradictions.cjs @@ -490,6 +490,7 @@ function classifyOutcome(step) { check === 'envelope_field_pattern' || check === 'field_value' || check === 'field_value_or_absent' || + check === 'field_greater_than' || check === 'http_status' || check === 'http_status_in' ); diff --git a/scripts/lint-storyboard-sample-request-schema.cjs b/scripts/lint-storyboard-sample-request-schema.cjs index f5eafc4b1d..3700e98888 100644 --- a/scripts/lint-storyboard-sample-request-schema.cjs +++ b/scripts/lint-storyboard-sample-request-schema.cjs @@ -179,6 +179,7 @@ function placeholderFor(schema, depth = 0) { if (Array.isArray(resolved.enum) && resolved.enum.length > 0) return resolved.enum[0]; if (typeof resolved.const !== 'undefined') return resolved.const; if (resolved.pattern === '^sha256:[A-Za-z0-9_-]{43}$') return SHA256_DIGEST_PLACEHOLDER; + if (resolved.pattern === '^[A-Z]{3}$') return 'USD'; // Object variant nested inside a oneOf/anyOf at a location where the // author's substitution will resolve to that shape at runtime. Synthesize // the concrete shape instead of returning a string that fails required. diff --git a/scripts/lint-storyboard-validations-paths.cjs b/scripts/lint-storyboard-validations-paths.cjs index 04a7b5893c..a90838a37d 100644 --- a/scripts/lint-storyboard-validations-paths.cjs +++ b/scripts/lint-storyboard-validations-paths.cjs @@ -50,6 +50,7 @@ const PATH_BEARING_CHECKS = new Set([ 'field_absent', 'field_pattern', 'field_contains', + 'field_greater_than', 'all_fields_in_context_array', 'envelope_field_present', 'envelope_field_absent', diff --git a/scripts/lint-universal-storyboard-doc-parity.cjs b/scripts/lint-universal-storyboard-doc-parity.cjs index e08b24e5c2..16d47c6245 100644 --- a/scripts/lint-universal-storyboard-doc-parity.cjs +++ b/scripts/lint-universal-storyboard-doc-parity.cjs @@ -21,7 +21,7 @@ * * "Graded" means the YAML has a `phases:` array. Filters out non-graded * fixtures (storyboard-schema.yaml, runner-output-contract.yaml, - * fictional-entities.yaml) which live alongside graded storyboards but + * fictional-entities.yaml, governance.yaml) which live alongside graded storyboards but * aren't run by the suite. * * Slug forms differ across the two docs by design (existing convention, diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index 169547241a..c3e4abc5bb 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -275,6 +275,7 @@ function createSession(): SessionState { creatives: new Map(), signalActivations: new Map(), buildVariantTargets: new Map(), + buildVariantGovernance: new Map(), usageRecords: [], complyExtensions: { accountStatuses: new Map(), @@ -501,6 +502,7 @@ function deserializeSession(data: Record): SessionState { proposalLifecycleLinks: asMap(hydrated.proposalLifecycleLinks, fresh.proposalLifecycleLinks), proposalRefinementRecords: asMap(hydrated.proposalRefinementRecords, fresh.proposalRefinementRecords), buildVariantTargets: asMap(hydrated.buildVariantTargets, fresh.buildVariantTargets), + buildVariantGovernance: asMap(hydrated.buildVariantGovernance, fresh.buildVariantGovernance), usageRecords: Array.isArray(hydrated.usageRecords) ? hydrated.usageRecords : [], complyExtensions: { accountStatuses: asMap(hydratedComply.accountStatuses, fresh.complyExtensions.accountStatuses), diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 1419b14f53..4ec70a7b50 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -10279,17 +10279,11 @@ export async function handleGetAdcpCapabilities(args: ToolArgs, ctx: TrainingCon ...(!isThreeZeroStoryboardCompat(ctx) ? ['query_provenance_audit_observations'] : []), ]; const governanceEnforcementTasks = ctx.tenantId === 'sales' - ? [ - { task: 'create_media_buy', modes: ['signed_context'] }, - { task: 'update_media_buy', modes: ['signed_context'] }, - ] + ? [{ task: 'create_media_buy', modes: ['signed_context'] }] : ctx.tenantId === 'signals' ? [{ task: 'activate_signal', modes: ['signed_context'] }] : ctx.tenantId === 'brand' - ? [ - { task: 'acquire_rights', modes: ['signed_context'] }, - { task: 'update_rights', modes: ['signed_context'] }, - ] + ? [{ task: 'acquire_rights', modes: ['signed_context'] }] : ctx.tenantId === 'creative' || ctx.tenantId === 'creative-builder' ? [{ task: 'build_creative', modes: ['signed_context'] }] : []; @@ -10988,6 +10982,7 @@ export async function handleGetCreativeDelivery(args: ToolArgs, ctx: TrainingCon interface BuildCreativeArgs { account?: unknown; + mode?: 'execute' | 'estimate'; creative_id?: string; creative_manifest?: { format_id?: FormatID; @@ -11074,6 +11069,42 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): const formats = getFormats(); const rawGovCtx = (req as unknown as Record).governance_context; const governanceContext = typeof rawGovCtx === 'string' && rawGovCtx.length <= 4096 ? rawGovCtx : undefined; + const parentGovernance = req.refine_from_build_variant_id + ? session.buildVariantGovernance.get(req.refine_from_build_variant_id) + : undefined; + const effectiveTransformerId = req.transformer_id ?? parentGovernance?.transformerId; + const selectedTransformer = effectiveTransformerId + ? getTransformers().find(transformer => transformer.transformer_id === effectiveTransformerId) + : undefined; + const selectedPricing = parentGovernance + ? { unit_price: parentGovernance.unitPrice, currency: parentGovernance.currency } + : selectedTransformer?.pricing_options?.find(option => { + const unitPrice = (option as { unit_price?: unknown }).unit_price; + return typeof unitPrice === 'number' && unitPrice > 0; + }) as { unit_price?: number; currency?: string } | undefined; + const effectiveAccount = (parentGovernance?.account ?? req.account) as AccountRef | undefined; + // The training voiceover transformer emits fixed 30-second audio. Match the + // renderer's axis precedence and fan-out clamp so token verification uses + // the same paid leaf count the handler will actually produce. + const governedVariantCount = Math.min( + Math.max(1, req.variant_axis?.values?.length ?? req.max_variants ?? 1), + TRANSFORMER_MAX_VARIANTS_LIMIT, + ); + const governedAmount = (selectedPricing?.unit_price ?? 0) * 30 * governedVariantCount; + const isPaidExecution = req.mode !== 'estimate' && governedAmount > 0; + if (isPaidExecution && !effectiveAccount) { + return buildCreativeCompleted({ + errors: [{ + code: 'ACCOUNT_REQUIRED', + message: 'account is required to determine whether governance applies to paid creative execution.', + }], + }); + } + const registeredGovernanceAgents = resolveGovernanceAgentsForAccount( + creativeSessionKey(req as unknown as ToolArgs, ctx), + ctx.principal, + effectiveAccount as AccountRef | undefined, + ); if (governanceContext) { const commitmentError = await governedCommitmentError( governanceContext, @@ -11081,14 +11112,29 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): 'build_creative', `${getCanonicalBase()}/${ctx.tenantId === 'creative-builder' ? 'creative-builder' : 'creative'}`, governedRequestPayload(ctx, req as unknown as Record), - 0, - 'USD', + governedAmount, + selectedPricing?.currency ?? 'USD', ); if (commitmentError) { return buildCreativeCompleted({ errors: [{ code: commitmentError.code, message: commitmentError.message }], }); } + } else if (isPaidExecution && registeredGovernanceAgents.length > 0) { + const message = 'Paid creative execution requires governance approval. Call check_governance first — a governance agent is registered for this account.'; + return buildCreativeCompleted({ + errors: [{ + code: 'PERMISSION_DENIED', + message, + details: { + findings: [{ + category_id: 'governance_context', + severity: 'critical', + explanation: message, + }], + }, + }], + }); } const validFormatIds = new Map(formats.map(f => [f.format_id.id, f])); const canonicalBuildsEnabled = includeThreeOneFields(ctx); @@ -11262,6 +11308,14 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): if (!wantsVariantShape && req.transformer_id) { const singleVariantId = `bv_${idemSeed}_0`; session.buildVariantTargets.set(singleVariantId, target); + if (selectedPricing?.unit_price !== undefined && effectiveTransformerId) { + session.buildVariantGovernance.set(singleVariantId, { + transformerId: effectiveTransformerId, + ...(effectiveAccount ? { account: effectiveAccount } : {}), + unitPrice: selectedPricing.unit_price, + currency: selectedPricing.currency ?? 'USD', + }); + } return buildCreativeCompleted({ creative_manifest: transformerManifest(target, ``, usesCanonicalTargets || !usesLegacyTargets), build_variant_id: singleVariantId, @@ -11285,6 +11339,14 @@ export async function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): const variants = Array.from({ length: variantCount }, (_unused, i) => { const variantId = `bv_${idemSeed}_${i}`; session.buildVariantTargets.set(variantId, target); + if (selectedPricing?.unit_price !== undefined && effectiveTransformerId) { + session.buildVariantGovernance.set(variantId, { + transformerId: effectiveTransformerId, + ...(effectiveAccount ? { account: effectiveAccount } : {}), + unitPrice: selectedPricing.unit_price, + currency: selectedPricing.currency ?? 'USD', + }); + } const leaf: Record = { build_variant_id: variantId, creative_manifest: transformerManifest(target, ``, usesCanonicalTargets || !usesLegacyTargets), diff --git a/server/src/training-agent/tenants/account-tools.ts b/server/src/training-agent/tenants/account-tools.ts index d1469cea14..bb6edf3506 100644 --- a/server/src/training-agent/tenants/account-tools.ts +++ b/server/src/training-agent/tenants/account-tools.ts @@ -1,35 +1,15 @@ -import { z } from 'zod'; +import { TOOL_REQUEST_SCHEMAS } from '@adcp/sdk/schemas'; import { customToolFor } from './custom-tool-helper.js'; -import { handleListAccounts } from '../account-handlers.js'; +import { handleListAccounts, handleSyncGovernance } from '../account-handlers.js'; import type { TrainingContext } from '../types.js'; -const ACCOUNT_REF_SCHEMA = z.union([ - z.object({ - account_id: z.string(), - }), - z.object({ - brand: z.object({ - domain: z.string(), - brand_id: z.string().optional(), - }), - operator: z.string(), - sandbox: z.boolean().optional(), - }), -]); +const SYNC_GOVERNANCE_SCHEMA = TOOL_REQUEST_SCHEMAS.sync_governance.shape; function listAccountsSchema(storyboardCompat?: TrainingContext['storyboardCompat']) { - return { - ...(storyboardCompat?.version === '3.0' ? {} : { account: ACCOUNT_REF_SCHEMA.optional() }), - status: z.enum(['active', 'pending_approval', 'rejected', 'payment_required', 'suspended', 'closed']).optional(), - sandbox: z.boolean().optional(), - pagination: z.object({ - max_results: z.number().int().min(1).max(100).optional(), - cursor: z.string().optional(), - }).optional(), - idempotency_key: z.string().optional(), - context: z.any().optional(), - ext: z.any().optional(), - }; + const { account, ...baseShape } = TOOL_REQUEST_SCHEMAS.list_accounts.shape; + return storyboardCompat?.version === '3.0' + ? baseShape + : { ...baseShape, account }; } export function listAccountsTool(storyboardCompat?: TrainingContext['storyboardCompat']) { @@ -44,3 +24,17 @@ export function listAccountsTool(storyboardCompat?: TrainingContext['storyboardC }, ); } + +export function syncGovernanceTool(storyboardCompat?: TrainingContext['storyboardCompat']) { + return customToolFor( + 'sync_governance', + 'Register one governance agent endpoint on each account. Uses replace semantics and supplies the relationship used to require signed authorization before governed commitments.', + SYNC_GOVERNANCE_SCHEMA, + handleSyncGovernance, + { + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + enforceIdempotency: true, + ...(storyboardCompat && { trainingContext: { storyboardCompat } }), + }, + ); +} diff --git a/server/src/training-agent/tenants/brand.ts b/server/src/training-agent/tenants/brand.ts index 0e7da72a9f..9286ec636c 100644 --- a/server/src/training-agent/tenants/brand.ts +++ b/server/src/training-agent/tenants/brand.ts @@ -17,7 +17,7 @@ import type { TenantConfig } from '@adcp/sdk/server'; import { TrainingBrandPlatform } from '../v6-brand-platform.js'; import { getTenantSigningMaterial } from './signing.js'; import { customToolFor } from './custom-tool-helper.js'; -import { listAccountsTool } from './account-tools.js'; +import { listAccountsTool, syncGovernanceTool } from './account-tools.js'; import { handleCreativeApproval, handleSearchBrands } from '../brand-handlers.js'; import { handleComplyTestController } from '../comply-test-controller.js'; import { verifyBrandClaimHandler, verifyBrandClaimsHandler } from '../brand-claim-handlers.js'; @@ -117,6 +117,7 @@ export function buildBrandTenantConfig(host: string, options: { storyboardCompat serverOptions: { customTools: { ...(options.storyboardCompat?.version !== '3.0' && { + sync_governance: syncGovernanceTool(options.storyboardCompat), search_brands: customToolFor( 'search_brands', 'Search the agent roster and return public brand stubs with canonical relationship trust state.', diff --git a/server/src/training-agent/tenants/creative-builder.ts b/server/src/training-agent/tenants/creative-builder.ts index 5982ff8db7..0e3bbcf285 100644 --- a/server/src/training-agent/tenants/creative-builder.ts +++ b/server/src/training-agent/tenants/creative-builder.ts @@ -12,7 +12,7 @@ import { } from '../v6-creative-builder-platform.js'; import { getTenantSigningMaterial } from './signing.js'; import { buildCreativeComplyConfig } from './comply.js'; -import { listAccountsTool } from './account-tools.js'; +import { listAccountsTool, syncGovernanceTool } from './account-tools.js'; import { validateInputTool } from './validate-input-tool.js'; import { listTransformersTool } from './list-transformers-tool.js'; import type { TrainingContext } from '../types.js'; @@ -41,6 +41,7 @@ export function buildCreativeBuilderTenantConfig(host: string, options: { storyb customTools: { list_accounts: listAccountsTool(options.storyboardCompat), ...(options.storyboardCompat?.version === '3.0' ? {} : { + sync_governance: syncGovernanceTool(options.storyboardCompat), validate_input: validateInputTool({ tenantId: TENANT_ID, creativeBillsThroughAdcp: false, diff --git a/server/src/training-agent/tenants/creative.ts b/server/src/training-agent/tenants/creative.ts index 5d44166708..71fd2995be 100644 --- a/server/src/training-agent/tenants/creative.ts +++ b/server/src/training-agent/tenants/creative.ts @@ -10,7 +10,7 @@ import { } from '../v6-creative-platform.js'; import { getTenantSigningMaterial } from './signing.js'; import { buildCreativeComplyConfig } from './comply.js'; -import { listAccountsTool } from './account-tools.js'; +import { listAccountsTool, syncGovernanceTool } from './account-tools.js'; import { reportUsageTool } from './report-usage-tool.js'; import { validateInputTool } from './validate-input-tool.js'; import { listTransformersTool } from './list-transformers-tool.js'; @@ -42,6 +42,7 @@ export function buildCreativeTenantConfig(host: string, options: { storyboardCom list_accounts: listAccountsTool(options.storyboardCompat), report_usage: reportUsageTool({ creativeBillsThroughAdcp: false }), ...(options.storyboardCompat?.version === '3.0' ? {} : { + sync_governance: syncGovernanceTool(options.storyboardCompat), validate_input: validateInputTool({ tenantId: TENANT_ID, creativeBillsThroughAdcp: false, diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index d78504549a..2256bf987f 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -255,7 +255,7 @@ function tenantMcpHandler( setCORSHeaders(res); wrapTenantToolDiscoveryProjection(req, res, tenantId, storyboardCompat); - wrapSalesCapabilitiesProjection(req, res, tenantId, storyboardCompat); + wrapTenantCapabilitiesProjection(req, res, tenantId, storyboardCompat); // Bridge `res.locals.trainingPrincipal` (set by the upstream // `requireAuth` middleware) onto `req.auth` so the framework's MCP @@ -612,7 +612,7 @@ async function tryHandleLocalComplyScenario( return true; } -function wrapSalesCapabilitiesProjection( +function wrapTenantCapabilitiesProjection( req: Request, res: Response, tenantId: string, @@ -635,7 +635,7 @@ function wrapSalesCapabilitiesProjection( (res as unknown as { end: (...args: unknown[]) => Response }).end = (chunk?: unknown, ...rest: unknown[]) => { if (chunk !== null && chunk !== undefined) chunks.push(toBuffer(chunk)); const body = Buffer.concat(chunks); - const patched = projectSalesCapabilities(body, tenantId, storyboardCompat); + const patched = projectTenantCapabilities(body, tenantId, storyboardCompat); if (patched !== body && !res.headersSent) { res.setHeader('content-length', String(patched.length)); } @@ -776,7 +776,7 @@ function stripContentLengthOnWriteHead(res: Response): void { }; } -function projectSalesCapabilities( +function projectTenantCapabilities( body: Buffer, tenantId: string, storyboardCompat?: TrainingContext['storyboardCompat'], @@ -816,6 +816,29 @@ function projectSalesCapabilities( ? adcp.supported_versions : [...TRAINING_AGENT_SUPPORTED_RELEASE_VERSIONS], }; + if (storyboardCompat?.version !== '3.0') { + const governanceTasks: Record> = { + sales: [{ task: 'create_media_buy', modes: ['signed_context'] }], + signals: [{ task: 'activate_signal', modes: ['signed_context'] }], + brand: [{ task: 'acquire_rights', modes: ['signed_context'] }], + creative: [{ task: 'build_creative', modes: ['signed_context'] }], + 'creative-builder': [{ task: 'build_creative', modes: ['signed_context'] }], + }; + const tasks = governanceTasks[tenantId]; + if (tasks) { + structured.adcp = { + ...structured.adcp, + governance_enforcement: { tasks }, + }; + const experimentalFeatures = Array.isArray(structured.experimental_features) + ? structured.experimental_features.filter((feature): feature is string => typeof feature === 'string') + : []; + if (!experimentalFeatures.includes('governance.campaign')) { + experimentalFeatures.push('governance.campaign'); + } + structured.experimental_features = experimentalFeatures; + } + } if ((tenantId === 'creative' || tenantId === 'creative-builder') && storyboardCompat?.version !== '3.0') { const creative = structured.creative && typeof structured.creative === 'object' ? structured.creative diff --git a/server/src/training-agent/tenants/sales.ts b/server/src/training-agent/tenants/sales.ts index 41412502c0..8d8f1d48e0 100644 --- a/server/src/training-agent/tenants/sales.ts +++ b/server/src/training-agent/tenants/sales.ts @@ -15,12 +15,11 @@ import { } from '../v6-sales-platform.js'; import { getTenantSigningMaterial } from './signing.js'; import { buildSalesComplyConfig } from './comply.js'; -import { listAccountsTool } from './account-tools.js'; +import { listAccountsTool, syncGovernanceTool } from './account-tools.js'; import { reportUsageTool } from './report-usage-tool.js'; import { validateInputTool } from './validate-input-tool.js'; import { buildCreativeTool, previewCreativeTool } from './creative-tools.js'; import { customToolFor } from './custom-tool-helper.js'; -import { handleSyncGovernance } from '../account-handlers.js'; import { handleSyncCatalogs } from '../catalog-event-handlers.js'; import type { TrainingContext } from '../types.js'; @@ -57,21 +56,6 @@ const SYNC_CATALOGS_SCHEMA = { ext: z.any().optional(), }; -const SYNC_GOVERNANCE_SCHEMA = { - accounts: z.array(z.object({ - account: ACCOUNT_REF, - governance_agents: z.array(z.object({ - url: z.string(), - authentication: z.object({ - schemes: z.array(z.string()), - credentials: z.string(), - }), - })).min(1), - })), - idempotency_key: z.string().optional(), - context: z.any().optional(), -}; - export function buildSalesTenantConfig( host: string, options: { storyboardCompat?: TrainingContext['storyboardCompat'] } = {}, @@ -117,12 +101,7 @@ export function buildSalesTenantConfig( // and fail the older response schema. Gate it off 3.0 like the // creative tools below. (/signals keeps it across versions.) ...(options.storyboardCompat?.version === '3.0' ? {} : { - sync_governance: customToolFor( - 'sync_governance', - 'Register governance agent endpoints on accounts. The seller calls these agents via check_governance during media buy lifecycle events. Uses replace semantics: each call replaces previously synced agents on the specified accounts.', - SYNC_GOVERNANCE_SCHEMA, - handleSyncGovernance, - ), + sync_governance: syncGovernanceTool(options.storyboardCompat), build_creative: buildCreativeTool({ tenantId: TENANT_ID, creativeBillsThroughAdcp: false, diff --git a/server/src/training-agent/tenants/signals.ts b/server/src/training-agent/tenants/signals.ts index 9eeccba91b..f4b21cf73f 100644 --- a/server/src/training-agent/tenants/signals.ts +++ b/server/src/training-agent/tenants/signals.ts @@ -13,38 +13,14 @@ * those registered governance agents. */ -import { z } from 'zod'; import type { TenantConfig } from '@adcp/sdk/server'; import { TrainingPlatform } from '../v6-platform.js'; import { getTenantSigningMaterial } from './signing.js'; -import { customToolFor } from './custom-tool-helper.js'; -import { listAccountsTool } from './account-tools.js'; -import { handleSyncGovernance } from '../account-handlers.js'; +import { listAccountsTool, syncGovernanceTool } from './account-tools.js'; import type { TrainingContext } from '../types.js'; const TENANT_ID = 'signals'; -const ACCOUNT_REF = z.object({ - account_id: z.string().optional(), - brand: z.object({ domain: z.string().optional() }).passthrough().optional(), - operator: z.string().optional(), -}).passthrough(); - -const SYNC_GOVERNANCE_SCHEMA = { - accounts: z.array(z.object({ - account: ACCOUNT_REF, - governance_agents: z.array(z.object({ - url: z.string(), - authentication: z.object({ - schemes: z.array(z.string()), - credentials: z.string(), - }), - })).min(1), - })), - idempotency_key: z.string().optional(), - context: z.any().optional(), -}; - export function buildSignalsTenantConfig(host: string, options: { storyboardCompat?: TrainingContext['storyboardCompat'] } = {}): { tenantId: string; config: TenantConfig; @@ -61,12 +37,7 @@ export function buildSignalsTenantConfig(host: string, options: { storyboardComp serverOptions: { customTools: { list_accounts: listAccountsTool(options.storyboardCompat), - sync_governance: customToolFor( - 'sync_governance', - 'Register governance agent endpoints on accounts. The seller calls these agents via check_governance during signal activation. Uses replace semantics: each call replaces previously synced agents on the specified accounts.', - SYNC_GOVERNANCE_SCHEMA, - handleSyncGovernance, - ), + sync_governance: syncGovernanceTool(options.storyboardCompat), }, }, }, diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index 447d3b0c17..24bc417aba 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -23,6 +23,9 @@ import { clearSiSessions } from '../si-handlers.js'; import { clearForcedTaskCompletions } from '../comply-test-controller.js'; import { getAgentUrl } from '../config.js'; import { projectV1ProductToV2 } from '@adcp/sdk/v2/projection'; +import { TrainingBrandPlatform } from '../v6-brand-platform.js'; +import { TrainingCreativeBuilderPlatform } from '../v6-creative-builder-platform.js'; +import { TrainingCreativePlatform } from '../v6-creative-platform.js'; process.env.PUBLIC_TEST_AGENT_TOKEN = 'test-token'; @@ -151,6 +154,22 @@ describe('tenant routing smoke', () => { stopSessionCleanup(); }); + it('keeps authenticated no-account sandbox resolution public while retaining principal identity', async () => { + const principal = 'authenticated-no-account-caller'; + const platforms = [ + new TrainingBrandPlatform(), + new TrainingCreativePlatform(), + new TrainingCreativeBuilderPlatform(), + ]; + + for (const platform of platforms) { + const account = await platform.accounts.resolve(undefined, { + authInfo: { clientId: principal }, + }); + expect(account?.authInfo).toEqual({ kind: 'public', principal }); + } + }); + it('serves brand.json with tenant public keys', async () => { const { baseUrl, close } = await bootServer(); try { @@ -290,6 +309,320 @@ describe('tenant routing smoke', () => { } }, 15000); + it('validates and idempotently applies the sync_governance write boundary', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/signals/mcp`; + await initializeTenant(url); + const account = { + brand: { + domain: 'tenant-sync-governance.example', + industries: ['outdoor-recreation'], + data_subject_contestation: { + url: 'https://tenant-sync-governance.example/privacy/contest', + email: 'privacy@tenant-sync-governance.example', + languages: ['en'], + }, + }, + operator: 'pinnacle-agency.example', + }; + await callTenantTool(url, 2, 'sync_accounts', { + accounts: [{ ...account, billing: 'operator', payment_terms: 'net_30' }], + idempotency_key: 'tenant-sync-governance-accounts', + }); + const payload = { + accounts: [{ + account, + governance_agents: [{ + url: 'https://governance.tenant-sync-governance.example/mcp', + authentication: { + schemes: ['Bearer'], + credentials: 'gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + }], + }], + idempotency_key: 'tenant-sync-governance-idempotency', + }; + + const missingKey = await callTenantTool(url, 3, 'sync_governance', { + accounts: payload.accounts, + }) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } }; + expect(missingKey.result?.isError).toBe(true); + expect(missingKey.result?.content?.[0]?.text).toContain('idempotency_key'); + + for (const [id, governanceAgent] of [ + [4, { url: 'http://governance.example/mcp', authentication: payload.accounts[0].governance_agents[0].authentication }], + [5, { url: 'https://governance.example/mcp', authentication: { schemes: ['Basic'], credentials: 'short' } }], + ] as const) { + const invalid = await callTenantTool(url, id, 'sync_governance', { + ...payload, + idempotency_key: `${payload.idempotency_key}-${id}`, + accounts: [{ account, governance_agents: [governanceAgent] }], + }) as { result?: { isError?: boolean } }; + expect(invalid.result?.isError).toBe(true); + } + + const invalidAccount = await callTenantTool(url, 10, 'list_accounts', { + account: { + brand: { domain: 'Not A Canonical Domain' }, + operator: 'pinnacle-agency.example', + }, + }) as { result?: { isError?: boolean } }; + expect(invalidAccount.result?.isError).toBe(true); + + const first = await callTenantTool(url, 7, 'sync_governance', payload) as { + result?: { structuredContent?: { accounts?: Array<{ status?: string }>; replayed?: boolean } }; + }; + const replay = await callTenantTool(url, 8, 'sync_governance', payload) as { + result?: { structuredContent?: { accounts?: Array<{ status?: string }>; replayed?: boolean } }; + }; + expect(first.result?.structuredContent?.accounts?.[0]?.status).toBe('synced'); + expect(first.result?.structuredContent?.replayed).toBeUndefined(); + expect(replay.result?.structuredContent?.replayed).toBe(true); + + const conflict = await callTenantTool(url, 9, 'sync_governance', { + ...payload, + accounts: [{ + account, + governance_agents: [{ + ...payload.accounts[0].governance_agents[0], + url: 'https://different-governance.example/mcp', + }], + }], + }) as { result?: { structuredContent?: { adcp_error?: { code?: string } } } }; + expect(conflict.result?.structuredContent?.adcp_error?.code).toBe('IDEMPOTENCY_CONFLICT'); + } finally { + await close(); + } + }, 30000); + + it('advertises exact governance-enforcement task claims on each enforcing tenant', async () => { + const { baseUrl, close } = await bootServer(); + try { + const expected: Record = { + sales: ['create_media_buy'], + signals: ['activate_signal'], + brand: ['acquire_rights'], + creative: ['build_creative'], + 'creative-builder': ['build_creative'], + }; + for (const [index, [tenant, tasks]] of Object.entries(expected).entries()) { + const url = `${baseUrl}/${tenant}/mcp`; + await initializeTenant(url); + const response = await callTenantTool(url, 20 + index, 'get_adcp_capabilities', {}) as { + result?: { structuredContent?: { + adcp?: { governance_enforcement?: { tasks?: Array<{ task?: string; modes?: string[] }> } }; + experimental_features?: string[]; + specialisms?: string[]; + } }; + }; + const capabilities = response.result?.structuredContent; + expect(capabilities?.adcp?.governance_enforcement?.tasks).toEqual( + tasks.map(task => ({ task, modes: ['signed_context'] })), + ); + expect(capabilities?.experimental_features).toContain('governance.campaign'); + if (tenant === 'creative-builder') { + expect(capabilities?.specialisms).toContain('creative-transformers'); + } + } + } finally { + await close(); + } + }, 30000); + + it('rejects a governed rights acquisition without persisting a grant', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/brand/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-rights-gov.example' }, + operator: 'pinnacle-agency.example', + }; + await callTenantTool(url, 2, 'sync_accounts', { + accounts: [{ ...account, billing: 'operator', payment_terms: 'net_30' }], + idempotency_key: 'tenant-rights-gov-sync-accounts', + }); + await callTenantTool(url, 3, 'sync_governance', { + accounts: [{ + account, + governance_agents: [{ + url: 'https://governance.tenant-rights-gov.example/mcp', + authentication: { + schemes: ['Bearer'], + credentials: 'gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + }], + }], + idempotency_key: 'tenant-rights-gov-sync-governance', + }); + const catalog = await callTenantTool(url, 4, 'get_rights', { + buyer: { domain: 'pinnacle-agency.example' }, + query: 'commercial rights', + uses: ['commercial'], + }) as { result?: { structuredContent?: { rights?: Array<{ + rights_id?: string; + pricing_options?: Array<{ pricing_option_id?: string }>; + }> } } }; + const rightsId = catalog.result?.structuredContent?.rights?.[0]?.rights_id; + const pricingOptionId = catalog.result?.structuredContent?.rights?.[0]?.pricing_options?.[0]?.pricing_option_id; + expect(rightsId).toBeDefined(); + expect(pricingOptionId).toBeDefined(); + + const denied = await callTenantTool(url, 5, 'acquire_rights', { + account, + rights_id: rightsId, + pricing_option_id: pricingOptionId, + buyer: { domain: 'pinnacle-agency.example' }, + campaign: { + description: 'Governance denial smoke test', + uses: ['commercial'], + countries: ['US'], + estimated_impressions: 1_000_000, + start_date: '2099-04-01', + end_date: '2099-06-30', + }, + revocation_webhook: { + url: 'https://pinnacle-agency.example/webhooks/revocation', + authentication: { schemes: ['Bearer'], credentials: 'revocation-token-xxxxxxxxxxxxxxxx' }, + }, + idempotency_key: 'tenant-rights-gov-acquire-denied', + }) as { result?: { structuredContent?: { rights_status?: string; reason?: string } } }; + expect(denied.result?.structuredContent?.rights_status).toBe('rejected'); + expect(denied.result?.structuredContent?.reason).toMatch(/governance approval/i); + + const update = await callTenantTool(url, 6, 'update_rights', { + account, + rights_id: rightsId, + paused: true, + idempotency_key: 'tenant-rights-gov-no-grant-update', + }) as { result?: { structuredContent?: { + errors?: Array<{ code?: string }>; + adcp_error?: { code?: string }; + } } }; + const updateError = update.result?.structuredContent?.adcp_error?.code + ?? update.result?.structuredContent?.errors?.[0]?.code; + expect(updateError).toBe('REFERENCE_NOT_FOUND'); + } finally { + await close(); + } + }, 30000); + + it('rejects paid creative execution without governance authorization', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/creative-builder/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-creative-gov.example' }, + operator: 'pinnacle-agency.example', + }; + await callTenantTool(url, 2, 'sync_accounts', { + accounts: [{ ...account, billing: 'operator', payment_terms: 'net_30' }], + idempotency_key: 'tenant-creative-gov-sync-accounts', + }); + await callTenantTool(url, 3, 'sync_governance', { + accounts: [{ + account, + governance_agents: [{ + url: 'https://governance.tenant-creative-gov.example/mcp', + authentication: { + schemes: ['Bearer'], + credentials: 'gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + }], + }], + idempotency_key: 'tenant-creative-gov-sync-governance', + }); + const transformers = await callTenantTool(url, 4, 'list_transformers', { + account, + include_pricing: true, + }) as { result?: { structuredContent?: { transformers?: Array<{ + transformer_id?: string; + output_capability_ids?: string[]; + }> } } }; + const transformer = transformers.result?.structuredContent?.transformers?.[0]; + expect(transformer?.transformer_id).toBeDefined(); + expect(transformer?.output_capability_ids?.[0]).toBeDefined(); + + const denied = await callTenantTool(url, 5, 'build_creative', { + account, + mode: 'execute', + transformer_id: transformer?.transformer_id, + target_capability_id: transformer?.output_capability_ids?.[0], + message: 'Produce a 30-second voiceover.', + idempotency_key: 'tenant-creative-gov-build-denied', + }) as { result?: { structuredContent?: { errors?: Array<{ + code?: string; + details?: { findings?: Array<{ category_id?: string }> }; + }> } } }; + const error = denied.result?.structuredContent?.errors?.[0]; + expect(error?.code).toBe('PERMISSION_DENIED'); + expect(error?.details?.findings?.[0]?.category_id).toBe('governance_context'); + } finally { + await close(); + } + }, 30000); + + it('inherits paid transformer governance when refining a retained build variant', async () => { + const { baseUrl, close } = await bootServer(); + try { + const url = `${baseUrl}/creative-builder/mcp`; + await initializeTenant(url); + const account = { + brand: { domain: 'tenant-creative-refine-gov.example' }, + operator: 'pinnacle-agency.example', + }; + await callTenantTool(url, 2, 'sync_accounts', { + accounts: [{ ...account, billing: 'operator', payment_terms: 'net_30' }], + idempotency_key: 'tenant-creative-refine-sync-accounts', + }); + const transformers = await callTenantTool(url, 3, 'list_transformers', { + account, + include_pricing: true, + }) as { result?: { structuredContent?: { transformers?: Array<{ + transformer_id?: string; + output_capability_ids?: string[]; + }> } } }; + const transformer = transformers.result?.structuredContent?.transformers?.[0]; + const parent = await callTenantTool(url, 4, 'build_creative', { + account, + mode: 'execute', + transformer_id: transformer?.transformer_id, + target_capability_id: transformer?.output_capability_ids?.[0], + message: 'Produce the original 30-second voiceover.', + idempotency_key: 'tenant-creative-refine-parent-build', + }) as { result?: { structuredContent?: { build_variant_id?: string } } }; + const buildVariantId = parent.result?.structuredContent?.build_variant_id; + expect(buildVariantId).toBeDefined(); + + await callTenantTool(url, 5, 'sync_governance', { + accounts: [{ + account, + governance_agents: [{ + url: 'https://governance.tenant-creative-refine-gov.example/mcp', + authentication: { + schemes: ['Bearer'], + credentials: 'gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + }], + }], + idempotency_key: 'tenant-creative-refine-sync-governance', + }); + + const denied = await callTenantTool(url, 6, 'build_creative', { + account, + mode: 'execute', + refine_from_build_variant_id: buildVariantId, + message: 'Make the delivery warmer.', + idempotency_key: 'tenant-creative-refine-denied', + }) as { result?: { structuredContent?: { errors?: Array<{ code?: string }> } } }; + expect(denied.result?.structuredContent?.errors?.[0]?.code).toBe('PERMISSION_DENIED'); + } finally { + await close(); + } + }, 30000); + it('advertises sales vendor-metric optimization capabilities', async () => { const { baseUrl, close } = await bootServer(); try { @@ -1453,6 +1786,13 @@ describe('tenant routing smoke', () => { expect(creative).not.toHaveProperty('supports_refinement'); expect(creative).not.toHaveProperty('refinable_retention_seconds'); expect(creative).not.toHaveProperty('multiplicity'); + + const builderUrl = `${baseUrl}/creative-builder/mcp`; + await initializeTenant(builderUrl); + const builderCapabilities = await callTenantTool(builderUrl, 3, 'get_adcp_capabilities', {}) as { + result?: { structuredContent?: { specialisms?: string[] } }; + }; + expect(builderCapabilities.result?.structuredContent?.specialisms).not.toContain('creative-transformers'); } finally { await close(); } diff --git a/server/src/training-agent/tenants/tool-catalog.ts b/server/src/training-agent/tenants/tool-catalog.ts index cc25572939..5890c1954a 100644 --- a/server/src/training-agent/tenants/tool-catalog.ts +++ b/server/src/training-agent/tenants/tool-catalog.ts @@ -67,10 +67,10 @@ export const TOOL_CATALOG: Readonly> = { preview_creative: ['sales', 'creative', 'creative-builder'], get_creative_delivery: ['creative'], - // sync_governance rides customTools on both /sales (every media_buy_seller - // specialism registers a buyer governance agent before spend moves) and - // /signals (signal-marketplace governance-denial pattern). - sync_governance: ['sales', 'signals'], + // sync_governance is exposed by each service role that advertises a + // task-scoped governance-enforcement claim. /governance is the provider + // role and intentionally remains separate. + sync_governance: ['sales', 'signals', 'creative', 'creative-builder', 'brand'], get_signals: ['signals'], activate_signal: ['signals'], @@ -135,8 +135,8 @@ export function toolsForTenant( // 3.0-compat exclusions. The split product-discovery tools are introduced // in 3.2, while validate_input / list_transformers are gated off on every // tenant that serves them. sync_governance is a 3.1+ account task gated - // off /sales under 3.0 (the released 3.0.x sales scenarios skip it), but - // /signals keeps it across versions. + // off all current service roles under 3.0 except /signals, whose released + // compatibility storyboards already rely on it. if ( tool === 'search_brands' || tool === 'list_products' @@ -145,7 +145,7 @@ export function toolsForTenant( || tool === 'decline_proposals' ) return false; if (tool === 'validate_input' || tool === 'list_transformers') return false; - if (tool === 'sync_governance' && tenantId === 'sales') return false; + if (tool === 'sync_governance' && tenantId !== 'signals') return false; return true; }) .sort(); diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 26993e8cb3..daee76cb2a 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -403,6 +403,15 @@ export interface SessionState { * subsequent refine_from_build_variant_id request can inherit the parent * leaf's format target rather than falling back to the audio_vo default. */ buildVariantTargets: Map; + /** Billing and governance metadata inherited by refinements. A refinement + * omits transformer_id by schema, so the parent variant is the only trusted + * source for deciding whether the next render is a governed paid action. */ + buildVariantGovernance: Map; /** Data set by comply_test_controller. Persisted so scenarios survive the * serialize/deserialize round trip that every request does, even in the * single-request case with the InMemoryStateStore. */ diff --git a/server/src/training-agent/v6-brand-platform.ts b/server/src/training-agent/v6-brand-platform.ts index 72302d8e1c..1285c0d88c 100644 --- a/server/src/training-agent/v6-brand-platform.ts +++ b/server/src/training-agent/v6-brand-platform.ts @@ -79,7 +79,12 @@ function translateV5Result(result: unknown): T { const trainingBrandAccounts: AccountStore = { resolution: 'explicit', - resolve: async (ref, _ctx) => { + resolve: async (ref, ctx) => { + const principal = ctx?.authInfo?.clientId; + const authInfo = { + kind: 'api_key' as const, + ...(principal && { principal }), + }; if (ref == null) { return { id: 'public_sandbox', @@ -88,7 +93,7 @@ const trainingBrandAccounts: AccountStore = { mode: 'sandbox', ctx_metadata: {}, sandbox: true, - authInfo: { kind: 'public' }, + authInfo: { kind: 'public' as const, ...(principal && { principal }) }, }; } const brandDomain = @@ -107,7 +112,7 @@ const trainingBrandAccounts: AccountStore = { ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), ctx_metadata: { brand_domain: brandDomain }, sandbox: true, - authInfo: { kind: 'api_key' }, + authInfo, }; }, upsert: syncAccountsUpsert, diff --git a/server/src/training-agent/v6-creative-builder-platform.ts b/server/src/training-agent/v6-creative-builder-platform.ts index facfb5b9d1..c11ce16e40 100644 --- a/server/src/training-agent/v6-creative-builder-platform.ts +++ b/server/src/training-agent/v6-creative-builder-platform.ts @@ -84,7 +84,12 @@ function translateV5Result(result: unknown): T { const trainingBuilderAccounts: AccountStore = { resolution: 'explicit', - resolve: async (ref, _ctx) => { + resolve: async (ref, ctx) => { + const principal = ctx?.authInfo?.clientId; + const authInfo = { + kind: 'api_key' as const, + ...(principal && { principal }), + }; if (ref == null) { return { id: 'public_sandbox', @@ -93,7 +98,7 @@ const trainingBuilderAccounts: AccountStore = { mode: 'sandbox', ctx_metadata: {}, sandbox: true, - authInfo: { kind: 'public' }, + authInfo: { kind: 'public' as const, ...(principal && { principal }) }, }; } const brandDomain = @@ -112,7 +117,7 @@ const trainingBuilderAccounts: AccountStore = { ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), ctx_metadata: { brand_domain: brandDomain }, sandbox: true, - authInfo: { kind: 'api_key' }, + authInfo, }; }, upsert: syncAccountsUpsert, @@ -134,7 +139,9 @@ export class TrainingCreativeBuilderPlatform get capabilities() { return { - specialisms: ['creative-template', 'creative-generative'] as const, + specialisms: this.storyboardCompat?.version === '3.0' + ? ['creative-template', 'creative-generative'] as const + : ['creative-template', 'creative-generative', 'creative-transformers'] as const, creative_agents: [], channels: [] as const, pricingModels: ['cpm', 'cpa'] as const, diff --git a/server/src/training-agent/v6-creative-platform.ts b/server/src/training-agent/v6-creative-platform.ts index d4cc2bbe2c..0030e8f330 100644 --- a/server/src/training-agent/v6-creative-platform.ts +++ b/server/src/training-agent/v6-creative-platform.ts @@ -87,7 +87,12 @@ function translateV5Result(result: unknown): T { const trainingCreativeAccounts: AccountStore = { resolution: 'explicit', - resolve: async (ref, _ctx) => { + resolve: async (ref, ctx) => { + const principal = ctx?.authInfo?.clientId; + const authInfo = { + kind: 'api_key' as const, + ...(principal && { principal }), + }; if (ref == null) { return { id: 'public_sandbox', @@ -96,7 +101,7 @@ const trainingCreativeAccounts: AccountStore = { mode: 'sandbox', ctx_metadata: {}, sandbox: true, - authInfo: { kind: 'public' }, + authInfo: { kind: 'public' as const, ...(principal && { principal }) }, }; } const brandDomain = @@ -115,7 +120,7 @@ const trainingCreativeAccounts: AccountStore = { ...('operator' in ref && typeof ref.operator === 'string' && { operator: ref.operator }), ctx_metadata: { brand_domain: brandDomain }, sandbox: true, - authInfo: { kind: 'api_key' }, + authInfo, }; }, upsert: syncAccountsUpsert, diff --git a/server/tests/integration/training-agent-legacy-mcp.test.ts b/server/tests/integration/training-agent-legacy-mcp.test.ts index e3328e1d42..6aad275123 100644 --- a/server/tests/integration/training-agent-legacy-mcp.test.ts +++ b/server/tests/integration/training-agent-legacy-mcp.test.ts @@ -363,6 +363,98 @@ describe('Tenant routes via host-based dispatch (no /api/training-agent prefix)' const activatedBody = activated.body.result?.structuredContent; expect(activatedBody?.adcp_error, JSON.stringify(activated.body)).toBeUndefined(); expect(activatedBody?.deployments?.[0]?.is_live).toBe(true); + + const rightsCatalog = await call('brand', 'get_rights', { + buyer: { domain: brand.domain }, + query: 'Sofia Reyes commercial likeness rights', + uses: ['commercial', 'likeness'], + }); + const rightsOffering = rightsCatalog.body.result?.structuredContent?.rights?.find( + (offering: { pricing_options?: Array<{ currency?: string }> }) => + offering.pricing_options?.some(option => option.currency === 'USD'), + ); + expect(rightsOffering?.rights_id, JSON.stringify(rightsCatalog.body)).toEqual(expect.any(String)); + const rightsPricing = rightsOffering?.pricing_options?.find( + (option: { currency?: string }) => option.currency === 'USD', + ); + const rightsPayload = { + idempotency_key: `rights-${randomUUID()}`, + account, + rights_id: rightsOffering.rights_id, + pricing_option_id: rightsPricing.pricing_option_id, + buyer: { domain: brand.domain }, + campaign: { + description: 'Approved fitness campaign featuring Sofia Reyes', + uses: ['likeness', 'commercial'], + countries: ['US'], + estimated_impressions: 1_000_000, + start_date: '2027-06-01', + end_date: '2027-07-01', + }, + revocation_webhook: { + url: `https://${brand.domain}/webhooks/rights-revocation`, + authentication: { schemes: ['Bearer'], credentials: 'rights-revocation-secret-xxxxxxxxxxxx' }, + }, + }; + const rightsIntent = await call('governance', 'check_governance', { + idempotency_key: `check-${randomUUID()}`, + brand, + plan_id: planId, + caller, + target_agent: `${getCanonicalBase()}/brand`, + purchase_type: 'rights_license', + proposed_commitment: { amount: 10_000, currency: 'USD' }, + tool: 'acquire_rights', + payload: rightsPayload, + }); + const rightsContext = rightsIntent.body.result?.structuredContent?.governance_context; + expect(rightsContext, JSON.stringify(rightsIntent.body)).toEqual(expect.any(String)); + const acquired = await call('brand', 'acquire_rights', { + ...rightsPayload, + governance_context: rightsContext, + }); + expect(acquired.body.result?.structuredContent?.rights_status, JSON.stringify(acquired.body)).toBe('acquired'); + + const transformers = await call('creative-builder', 'list_transformers', { + account, + include_pricing: true, + }); + const transformer = transformers.body.result?.structuredContent?.transformers?.[0]; + const transformerPricing = transformer?.pricing_options?.[0]; + expect(transformer?.transformer_id, JSON.stringify(transformers.body)).toEqual(expect.any(String)); + expect(transformerPricing?.unit_price).toBeGreaterThan(0); + const creativePayload = { + idempotency_key: `creative-${randomUUID()}`, + account, + mode: 'execute', + transformer_id: transformer.transformer_id, + target_capability_id: transformer.output_capability_ids[0], + message: 'Produce an approved 30-second campaign voiceover.', + }; + const creativeIntent = await call('governance', 'check_governance', { + idempotency_key: `check-${randomUUID()}`, + brand, + plan_id: planId, + caller, + target_agent: `${getCanonicalBase()}/creative-builder`, + purchase_type: 'creative_services', + proposed_commitment: { + amount: 10_000, + currency: 'USD', + }, + tool: 'build_creative', + payload: creativePayload, + }); + const creativeContext = creativeIntent.body.result?.structuredContent?.governance_context; + expect(creativeContext, JSON.stringify(creativeIntent.body)).toEqual(expect.any(String)); + const built = await call('creative-builder', 'build_creative', { + ...creativePayload, + governance_context: creativeContext, + }); + const builtBody = built.body.result?.structuredContent; + expect(builtBody?.errors, JSON.stringify(built.body)).toBeUndefined(); + expect(builtBody?.creative_manifest).toBeDefined(); + expect(builtBody?.build_variant_id).toEqual(expect.any(String)); }); it('returns signing headers on tenant OPTIONS preflight', async () => { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 2c5094c60d..ef2a002ab6 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -2196,6 +2196,10 @@ describe('creative transformers handler', () => { it('rejects plural transformer targets outside the transformer output set', async () => { const result = await handleBuildCreative({ + account: { + brand: { domain: 'transformer-target-validation.example' }, + operator: 'pinnacle-agency.example', + }, transformer_id: 'audiostack_voiceover', target_capability_ids: ['training_image_generation'], max_variants: 2, @@ -12227,7 +12231,6 @@ describe('get_adcp_capabilities handler', () => { expect((result.adcp as Record).governance_enforcement).toEqual({ tasks: [ { task: 'create_media_buy', modes: ['signed_context'] }, - { task: 'update_media_buy', modes: ['signed_context'] }, ], }); expect(result.experimental_features).toContain('governance.campaign'); diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml index 0ef4c6eef7..5db3fc2920 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_approved.yaml @@ -2,7 +2,7 @@ id: media_buy_seller/governance_approved version: "1.0.0" title: "Seller commits a buy only after governance approval" category: media_buy_seller -summary: "Verifies the approved intent and binary execution checks, durable media-buy commit, and auditable governance outcome." +summary: "Verifies signed intent authorization, durable media-buy commit, and auditable governance outcome." track: media_buy requires: - multi_agent @@ -15,12 +15,14 @@ required_tools: - check_governance - create_media_buy - get_media_buys - - get_plan_audit_logs - report_plan_outcome requires_capability: - path: media_buy.governance_aware - equals: true + path: adcp.governance_enforcement.tasks + contains: + task: create_media_buy + modes: + - signed_context narrative: | This multi-agent test sets up a permissive governance plan, registers the @@ -28,12 +30,10 @@ narrative: | `create_media_buy` request at intent time. Only an approved intent supplies the `governance_context` sent to the seller. - During `create_media_buy`, the seller presents that authorization and its - planned delivery to the registered governance agent. Execution is binary: - only `approved` permits the seller to commit the buy. The scenario proves the - commit through `get_media_buys`, reads the governance audit log to observe the - seller's execution check, and reports the completed outcome. It never treats - a seller-to-buyer `governance_context` echo as evidence of consultation. + During `create_media_buy`, the seller verifies the signed authorization before + committing. The scenario proves the commit through `get_media_buys` and + reports the completed outcome. It never treats a seller-to-buyer + `governance_context` echo as evidence of authorization. context: governance_agent_url: "https://test-agent.adcontextprotocol.org" @@ -54,8 +54,8 @@ caller: prerequisites: description: | A multi-agent runner with `sales` and `governance` agents. The governance - agent supports plan synchronization, intent and execution checks, audit - reads, and outcome reporting. The sales agent supports governance + agent supports plan synchronization, intent checks, and outcome reporting. + The sales agent supports governance registration, media-buy creation, and media-buy readback. test_kit: "test-kits/acme-outdoor.yaml" controller_seeding: true @@ -286,6 +286,43 @@ phases: path: "expires_at" description: "Authorization has a bounded lifetime" + - id: create_media_buy_tampered + title: "Reject a payload widened after authorization" + agent: sales + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: "Reject the schema-valid request because its budget no longer matches the signed payload binding." + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + total_budget: + amount: 25001 + currency: "USD" + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + packages: + - product_id: "$context.product_1_id" + budget: 15001 + pricing_option_id: "$context.pricing_option_1_id" + - product_id: "$context.product_2_id" + budget: 10000 + pricing_option_id: "$context.pricing_option_2_id" + idempotency_key: "$generate:uuid_v4#governance_approved_create_media_buy_tampered" + governance_context: "$context.governance_context" + validations: + - check: error_code + value: "PERMISSION_DENIED" + description: "The signed payload and monetary ceiling cannot authorize a widened request" + - id: create_media_buy title: "Commit the governance-approved buy" agent: sales @@ -296,9 +333,8 @@ phases: comply_scenario: create_media_buy stateful: true expected: | - The seller verifies the approved intent and obtains an `approved` - execution verdict before committing. The response need not echo the - authorization context. + The seller verifies the approved signed intent before committing. The + response need not echo the authorization context. sample_request: brand: domain: "acmeoutdoor.example" @@ -328,15 +364,13 @@ phases: description: "Response matches create-media-buy-response.json schema" - check: field_present path: "media_buy_id" - description: "Seller identifies the buy committed after execution approval" + description: "Seller identifies the buy committed after signed authorization" - id: committed_state_and_evidence - title: "Prove the commit and governance execution check" + title: "Prove the authorized commit" narrative: | - The buyer reads authoritative seller state, then reads the governance - audit trail. Together these checks prove both sides of the contract: a - buy was durably committed and the seller performed the required binary - execution check before that commit. + The buyer reads authoritative seller state to prove the signed intent was + accepted and the authorized buy was durably committed. steps: - id: get_media_buys_readback title: "Read back the committed media buy" @@ -369,41 +403,6 @@ phases: value: "USD" description: "Readback preserves the authorized currency" - - id: get_plan_audit_logs_execution - title: "Observe the seller's execution approval" - agent: governance - task: get_plan_audit_logs - schema_ref: "governance/get-plan-audit-logs-request.json" - response_schema_ref: "governance/get-plan-audit-logs-response.json" - doc_ref: "/governance/campaign/tasks/get_plan_audit_logs" - stateful: false - expected: | - The audit trail contains the buyer's intent approval followed by the - seller's approved execution check for create_media_buy. - sample_request: - plan_ids: - - "$context.plan_id" - include_entries: true - validations: - - check: response_schema - description: "Response matches get-plan-audit-logs-response.json schema" - - check: field_value - path: "plans[0].entries[1].type" - value: "check" - description: "The seller interaction is recorded as a governance check" - - check: field_value - path: "plans[0].entries[1].tool" - value: "create_media_buy" - description: "The observed execution check governs create_media_buy" - - check: field_value - path: "plans[0].entries[1].check_type" - value: "execution" - description: "The seller performed a binary execution check" - - check: field_value - path: "plans[0].entries[1].verdict" - value: "approved" - description: "Only an approved execution verdict preceded commit" - - id: outcome_reporting title: "Close the governance accountability loop" steps: diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml index 9cfda9f7e6..bf8db149e4 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml @@ -19,8 +19,12 @@ required_tools: - report_plan_outcome requires_capability: - path: media_buy.governance_aware - equals: true + path: adcp.governance_enforcement.tasks + contains: + task: create_media_buy + modes: + - signed_context + - online_execution_check narrative: | This multi-agent test sets up a governance plan whose CTV reporting policy diff --git a/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml b/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml index de867bdccc..c669aaff3f 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/governance_denied.yaml @@ -12,14 +12,15 @@ required_tools: - get_products - create_media_buy -# Capability gate: this scenario asserts the seller produces GOVERNANCE_DENIED -# after consulting a registered governance agent, so it runs only for sellers -# that declare media_buy.governance_aware: true. Sellers without outbound -# governance consultation grade not_applicable rather than false-failing on a -# denial they have no mechanism to produce. +# Capability gate: run only for sellers claiming signed-context enforcement of +# create_media_buy. Non-claiming sellers grade not_applicable. requires_capability: - path: media_buy.governance_aware - equals: true + path: adcp.governance_enforcement.tasks + contains: + task: create_media_buy + modes: + - signed_context + - online_execution_check narrative: | This is a multi-agent test. The test harness sets up a governance plan on a governance diff --git a/static/compliance/source/specialisms/brand-rights/index.yaml b/static/compliance/source/specialisms/brand-rights/index.yaml index 8db7eddbdf..238990ddb3 100644 --- a/static/compliance/source/specialisms/brand-rights/index.yaml +++ b/static/compliance/source/specialisms/brand-rights/index.yaml @@ -8,6 +8,7 @@ track: core required_tools: - get_brand_identity requires_scenarios: + - brand_rights/governance_approved - brand_rights/governance_denied - brand_rights/update_rights_lifecycle diff --git a/static/compliance/source/specialisms/brand-rights/scenarios/governance_approved.yaml b/static/compliance/source/specialisms/brand-rights/scenarios/governance_approved.yaml new file mode 100644 index 0000000000..ff01bde22e --- /dev/null +++ b/static/compliance/source/specialisms/brand-rights/scenarios/governance_approved.yaml @@ -0,0 +1,245 @@ +id: brand_rights/governance_approved +version: "1.0.0" +title: "Brand agent accepts a signed rights authorization" +category: brand_rights +summary: "Verifies that a valid task- and payload-bound governance token permits a paid rights grant and that the grant persists." +track: brand +requires: [multi_agent] +default_agent: brand +required_tools: + - sync_accounts + - sync_governance + - get_rights + - sync_plans + - check_governance + - acquire_rights + - update_rights + +requires_capability: + path: adcp.governance_enforcement.tasks + contains: + task: acquire_rights + modes: [signed_context] + +narrative: | + The runner discovers a priced rights offering, creates a permissive plan in + the offering currency, and obtains signed intent authorization for the exact + acquire_rights payload. The brand agent must accept that token, issue the + grant, and retain it for a later lifecycle update. This positive path prevents + an always-rejecting service from satisfying the enforcement claim. + +context: + governance_agent_url: "https://test-agent.adcontextprotocol.org" + brand_agent_url: "https://test-agent.adcontextprotocol.org/brand" + +agent: + interaction_model: brand_rights_holder + capabilities: [rights_licensing, governance_aware] +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" +prerequisites: + description: "A multi-agent runner with brand and governance agents. Override brand_agent_url with the exact brand endpoint under test." + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: discovery_and_service_setup + title: "Set up the rights service and discover a paid grant" + steps: + - id: sync_accounts + agent: brand + title: "Establish the buyer account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + sample_request: + accounts: + - brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + idempotency_key: "$generate:uuid_v4#rights_governance_approved_sync_accounts" + validations: + - check: response_schema + + - id: sync_governance + agent: brand + title: "Register the governance provider" + task: sync_governance + schema_ref: "account/sync-governance-request.json" + response_schema_ref: "account/sync-governance-response.json" + doc_ref: "/accounts/tasks/sync_governance" + stateful: true + sample_request: + accounts: + - account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + governance_agents: + - url: "$context.governance_agent_url" + authentication: + schemes: ["Bearer"] + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + idempotency_key: "$generate:uuid_v4#rights_governance_approved_sync_governance" + validations: + - check: response_schema + + - id: get_rights_catalog + agent: brand + title: "Discover a priced rights offering" + task: get_rights + schema_ref: "brand/get-rights-request.json" + response_schema_ref: "brand/get-rights-response.json" + doc_ref: "/brand-protocol/tasks/get_rights" + sample_request: + buyer: { domain: "pinnacle-agency.example" } + query: "Sofia Reyes commercial likeness rights" + uses: ["commercial", "likeness"] + context_outputs: + - { name: rights_id, path: "rights[0].rights_id" } + - { name: pricing_option_id, path: "rights[0].pricing_options[0].pricing_option_id" } + - { name: pricing_currency, path: "rights[0].pricing_options[0].currency" } + validations: + - check: response_schema + - check: field_present + path: "rights[0].pricing_options[0].pricing_option_id" + - check: field_present + path: "rights[0].pricing_options[0].currency" + + - id: authorization + title: "Authorize the exact acquisition" + steps: + - id: sync_plans + agent: governance + title: "Create a permissive rights plan" + task: sync_plans + schema_ref: "governance/sync-plans-request.json" + response_schema_ref: "governance/sync-plans-response.json" + doc_ref: "/governance/campaign/tasks/sync_plans" + stateful: true + sample_request: + idempotency_key: "$generate:uuid_v4#rights_governance_approved_sync_plans" + plans: + - plan_id: "comply-rights-approved-plan" + brand: { domain: "acmeoutdoor.example" } + objectives: "Acquire commercial likeness rights" + budget: + total: 1000000000 + currency: "$context.pricing_currency" + reallocation_threshold: 1000000000 + flight: + start: "2020-01-01T00:00:00Z" + end: "2099-06-30T23:59:59Z" + countries: ["US"] + context_outputs: + - { name: plan_id, path: "plans[0].plan_id" } + validations: + - check: response_schema + + - id: check_governance_intent + agent: governance + title: "Approve the exact rights payload" + task: check_governance + schema_ref: "governance/check-governance-request.json" + response_schema_ref: "governance/check-governance-response.json" + doc_ref: "/governance/campaign/tasks/check_governance" + stateful: true + sample_request: + plan_id: "$context.plan_id" + caller: "https://pinnacle-agency.example" + target_agent: "$context.brand_agent_url" + tool: "acquire_rights" + purchase_type: "rights_license" + proposed_commitment: { amount: 1000000000, currency: "$context.pricing_currency" } + payload: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + rights_id: "$context.rights_id" + pricing_option_id: "$context.pricing_option_id" + buyer: { domain: "pinnacle-agency.example" } + campaign: + description: "Approved commercial likeness campaign" + uses: ["likeness", "commercial"] + countries: ["US"] + estimated_impressions: 1000000 + start_date: "2099-04-01" + end_date: "2099-06-30" + revocation_webhook: + url: "https://pinnacle-agency.example/webhooks/revocation" + authentication: + schemes: ["Bearer"] + credentials: "pinnacle-revocation-webhook-secret-token" + idempotency_key: "$generate:uuid_v4#rights_governance_approved_acquire" + context_outputs: + - { name: governance_context, path: "governance_context" } + validations: + - check: response_schema + - check: field_value + path: "verdict" + value: "approved" + - check: field_present + path: "governance_context" + + - id: authorized_grant + title: "Create and prove the durable rights grant" + steps: + - id: acquire_rights_approved + agent: brand + title: "Acquire rights with signed authorization" + task: acquire_rights + schema_ref: "brand/acquire-rights-request.json" + response_schema_ref: "brand/acquire-rights-response.json" + doc_ref: "/brand-protocol/tasks/acquire_rights" + stateful: true + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + rights_id: "$context.rights_id" + pricing_option_id: "$context.pricing_option_id" + buyer: { domain: "pinnacle-agency.example" } + campaign: + description: "Approved commercial likeness campaign" + uses: ["likeness", "commercial"] + countries: ["US"] + estimated_impressions: 1000000 + start_date: "2099-04-01" + end_date: "2099-06-30" + revocation_webhook: + url: "https://pinnacle-agency.example/webhooks/revocation" + authentication: + schemes: ["Bearer"] + credentials: "pinnacle-revocation-webhook-secret-token" + idempotency_key: "$generate:uuid_v4#rights_governance_approved_acquire" + governance_context: "$context.governance_context" + validations: + - check: response_schema + - check: field_value + path: "rights_status" + value: "acquired" + - check: field_present + path: "rights_constraint" + + - id: update_acquired_grant + agent: brand + title: "Pause the persisted grant" + task: update_rights + schema_ref: "brand/update-rights-request.json" + response_schema_ref: "brand/update-rights-response.json" + doc_ref: "/brand-protocol/tasks/update_rights" + stateful: true + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + rights_id: "$context.rights_id" + paused: true + idempotency_key: "$generate:uuid_v4#rights_governance_approved_pause" + validations: + - check: response_schema + - check: field_value + path: "paused" + value: true diff --git a/static/compliance/source/specialisms/brand-rights/scenarios/governance_denied.yaml b/static/compliance/source/specialisms/brand-rights/scenarios/governance_denied.yaml index 6dcc7ef6f7..82dcd1944e 100644 --- a/static/compliance/source/specialisms/brand-rights/scenarios/governance_denied.yaml +++ b/static/compliance/source/specialisms/brand-rights/scenarios/governance_denied.yaml @@ -1,35 +1,33 @@ id: brand_rights/governance_denied -version: "1.0.0" -title: "Brand agent rejects rights acquisition when governance denies" +version: "2.0.0" +title: "Brand agent rejects rights acquisition without governance approval" category: brand_rights -summary: "Verifies that a brand agent propagates GOVERNANCE_DENIED when the buyer's governance plan denies a rights license." +summary: "Verifies that a rights agent claiming acquire_rights governance enforcement fails closed without signed authorization and creates no grant." track: brand required_tools: + - sync_accounts - sync_governance - get_rights - acquire_rights + - update_rights -narrative: | - Acquiring rights is a spending event. The brand agent must consult the buyer's - governance agent before issuing a license and deny the acquisition when governance - returns denied. This keeps licensing authority consistent with media buy spending - authority — a buyer cannot commit to a generative campaign budget that exceeds the - plan even when the spending flows to a brand agent instead of a seller. +requires_capability: + path: adcp.governance_enforcement.tasks + contains: + task: acquire_rights + modes: + - signed_context - This scenario sets up a strict $50 governance plan, registers governance with the - brand agent via sync_governance, then attempts to acquire rights whose pricing - exceeds the plan. The brand agent must return the canonical denial shape — the - `AcquireRightsRejected` arm of the response (`rights_status: "rejected"` + `reason`) - — propagating the governance agent's findings in `reason`. Per the wire-placement - rule on `GOVERNANCE_DENIED`, when a task response defines a structured rejection arm, - the arm IS the canonical denial shape: sellers do NOT additionally emit - `GOVERNANCE_DENIED` in `errors[]` or `adcp_error`, and transport-level success - markers stay flipped to success. The schema enforces this — `AcquireRightsRejected` - declares `not: { required: [errors] }`, so dual-emission is already a schema violation. +narrative: | + An agent advertising signed-context enforcement for acquire_rights commits to + validating buyer governance authorization before creating a paid rights grant. + Non-claiming agents grade this scenario not_applicable. - By default, the governance agent is the training agent at test-agent.adcontextprotocol.org. - Override by supplying a different `governance_agent_url` in the run's initial context - (e.g., via `--context` on `adcp storyboard run` once the CLI supports it). + The runner registers a governance agent, discovers a priced rights offering, + and submits a well-formed acquisition without governance_context. The brand + agent must use the task's structured rejection arm and must not persist a grant. + A follow-up update_rights probe for the rejected rights_id proves the absence of + that side effect. Echoing request context is never governance evidence. context: governance_agent_url: "https://test-agent.adcontextprotocol.org" @@ -48,42 +46,11 @@ caller: prerequisites: description: | - A governance agent that supports sync_plans and check_governance, and a brand - agent that supports sync_governance + acquire_rights. + A governance agent URL to register and a brand agent supporting account + setup, governance registration, rights discovery, acquisition, and update. test_kit: "test-kits/acme-outdoor.yaml" phases: - - id: governance_plan_setup - title: "Set up strict governance plan" - steps: - - id: sync_plans - title: "Create strict governance plan" - task: sync_plans - schema_ref: "governance/sync-plans-request.json" - response_schema_ref: "governance/sync-plans-response.json" - doc_ref: "/governance/campaign/tasks/sync_plans" - stateful: true - expected: | - The governance agent acknowledges the plan. - sample_request: - plans: - - plan_id: "comply-rights-gov-denied" - brand: - domain: "acmeoutdoor.example" - objectives: "Restricted plan — rights licensing test" - budget: - total: 50 - currency: "USD" - reallocation_threshold: 25 - flight: - start: "2099-04-01T00:00:00Z" - end: "2099-06-30T23:59:59Z" - countries: ["US"] - idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_governance_plan_setup_sync_plans" - validations: - - check: response_schema - description: "Response matches sync-plans-response.json schema" - - id: brand_agent_setup title: "Register account and governance with the brand agent" steps: @@ -94,8 +61,7 @@ phases: response_schema_ref: "account/sync-accounts-response.json" doc_ref: "/accounts/tasks/sync_accounts" stateful: true - expected: | - Brand agent returns the account with account_id active. + expected: "Brand agent returns an active account." sample_request: accounts: - brand: @@ -103,7 +69,7 @@ phases: operator: "pinnacle-agency.example" billing: "operator" payment_terms: "net_30" - idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_brand_agent_setup_sync_accounts" + idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_sync_accounts" validations: - check: response_schema description: "Response matches sync-accounts-response.json schema" @@ -118,8 +84,7 @@ phases: response_schema_ref: "account/sync-governance-response.json" doc_ref: "/accounts/tasks/sync_governance" stateful: true - expected: | - Brand agent acknowledges governance registration. + expected: "Brand agent acknowledges governance registration." sample_request: accounts: - account: @@ -131,30 +96,27 @@ phases: authentication: schemes: ["Bearer"] credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_brand_agent_setup_sync_governance" + idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_sync_governance" validations: - check: response_schema description: "Response matches sync-governance-response.json schema" - id: rights_denied - title: "Attempt rights acquisition — governance denies" + title: "Attempt rights acquisition without governance context" steps: - id: get_rights_catalog - title: "Discover rights to license" + title: "Discover priced rights" task: get_rights schema_ref: "brand/get-rights-request.json" response_schema_ref: "brand/get-rights-response.json" doc_ref: "/brand-protocol/tasks/get_rights" stateful: false - expected: | - Return rights available for licensing, each with pricing_options. + expected: "Return at least one rights offering with a pricing option." sample_request: buyer: domain: "pinnacle-agency.example" query: "licensed commercial rights for a regional outdoor retail campaign" - uses: - - "commercial" - - "endorsement" + uses: ["commercial", "endorsement"] context_outputs: - path: "rights[0].rights_id" key: "rights_id" @@ -163,24 +125,22 @@ phases: validations: - check: response_schema description: "Response matches get-rights-response.json schema" + - check: field_present + path: "rights[0].pricing_options[0].pricing_option_id" + description: "The selected offering is a paid commitment" - id: acquire_rights_denied - title: "acquire_rights — governance denies via rejection arm" + title: "acquire_rights — missing governance approval" task: acquire_rights schema_ref: "brand/acquire-rights-request.json" response_schema_ref: "brand/acquire-rights-response.json" doc_ref: "/brand-protocol/tasks/acquire_rights" expected_arm: "rejected" + negative_path: payload_well_formed stateful: true expected: | - Brand agent returns the canonical denial shape for a task with a structured rejection arm: - AcquireRightsRejected (`rights_status: "rejected"` + `reason`, with `suggestions` optional). Per the - wire-placement rule on `GOVERNANCE_DENIED`, the spec-correct denial response carries no error - code on the wire — the rejection arm enforces `not: { required: [errors] }` at the schema layer, - so emitting `errors[]` alongside the rejection arm is already a schema violation. Transport-level - success markers MUST NOT be flipped (HTTP 200, MCP `isError: false`, A2A `succeeded`) — the task - ran successfully and produced a structured response. - + Return AcquireRightsRejected with rights_status rejected and a reason + explaining that governance approval is required. Do not create a grant. sample_request: account: brand: @@ -192,35 +152,47 @@ phases: domain: "pinnacle-agency.example" campaign: description: "Governance-denied rights acquisition probe" - uses: - - "likeness" - - "commercial" + uses: ["likeness", "commercial"] + countries: ["US"] + estimated_impressions: 1000000 start_date: "2099-04-01" end_date: "2099-06-30" revocation_webhook: url: "https://pinnacle-agency.example/webhooks/revocation" authentication: - schemes: - - "Bearer" + schemes: ["Bearer"] credentials: "pinnacle-revocation-webhook-secret-token" - idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_acquire_rights_denied" - - context: - correlation_id: "brand_rights--governance_denied--acquire" + idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_acquire" validations: - check: response_schema - description: "Response matches acquire-rights-response.json (rejection arm validates against AcquireRightsRejected; the schema's `not: { required: [errors] }` rule rejects dual-emission)" + description: "Response matches the mutually exclusive AcquireRightsRejected arm" - check: field_value path: "rights_status" value: "rejected" - description: "Discriminator on the rejection arm — rights_status must be 'rejected'" + description: "The structured rejection discriminator is present" - check: field_present path: "reason" - description: "Rejection arm carries the operator-readable reason (governance findings propagated verbatim per wire-placement guidance)" - - check: field_present - path: "context" - description: "Response echoes back the context object on the rejection arm" - - check: field_value - path: "context.correlation_id" - value: "brand_rights--governance_denied--acquire" - description: "Context correlation_id returned unchanged" + description: "The rejection explains that governance approval is required" + + - id: rejected_grant_not_persisted + title: "Rejected acquisition created no rights grant" + task: update_rights + schema_ref: "brand/update-rights-request.json" + response_schema_ref: "brand/update-rights-response.json" + doc_ref: "/brand-protocol/tasks/update_rights" + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: "Return REFERENCE_NOT_FOUND because the rejected acquisition created no grant." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + rights_id: "$context.rights_id" + paused: true + idempotency_key: "$generate:uuid_v4#brand_rights_governance_denied_no_grant" + validations: + - check: error_code + value: "REFERENCE_NOT_FOUND" + description: "No grant exists for the rejected acquisition" diff --git a/static/compliance/source/specialisms/creative-transformers/index.yaml b/static/compliance/source/specialisms/creative-transformers/index.yaml index 670096b32d..b5ef4b0581 100644 --- a/static/compliance/source/specialisms/creative-transformers/index.yaml +++ b/static/compliance/source/specialisms/creative-transformers/index.yaml @@ -7,6 +7,9 @@ summary: "Agent that exposes account-scoped transformers (voices, models, render required_tools: - list_transformers - build_creative +requires_scenarios: + - creative_transformers/governance_approved + - creative_transformers/governance_denied # Cross-step assertion (adcp#2664). status.monotonic rejects resource status # transitions observed across steps that aren't on the spec lifecycle graph. diff --git a/static/compliance/source/specialisms/creative-transformers/scenarios/governance_approved.yaml b/static/compliance/source/specialisms/creative-transformers/scenarios/governance_approved.yaml new file mode 100644 index 0000000000..4c8e2c8cce --- /dev/null +++ b/static/compliance/source/specialisms/creative-transformers/scenarios/governance_approved.yaml @@ -0,0 +1,197 @@ +id: creative_transformers/governance_approved +version: "1.0.0" +title: "Creative agent accepts a signed paid-build authorization" +category: creative_transformers +summary: "Verifies that a valid task- and payload-bound governance token permits a paid creative build and returns a retained artifact." +track: creative +requires: [multi_agent] +default_agent: creative-builder +required_tools: [sync_accounts, sync_governance, list_transformers, sync_plans, check_governance, build_creative] + +requires_capability: + path: adcp.governance_enforcement.tasks + contains: + task: build_creative + modes: [signed_context] + +narrative: | + The runner discovers a transformer whose unit price is strictly positive, + creates a permissive plan in that currency, and obtains authorization for the + exact build_creative payload. The creative agent must verify the token and + return a retained render artifact. This positive path prevents an + always-rejecting implementation from satisfying the enforcement claim. + +context: + governance_agent_url: "https://test-agent.adcontextprotocol.org" + creative_agent_url: "https://test-agent.adcontextprotocol.org/creative-builder" +agent: + interaction_model: stateless_transform + capabilities: [supports_transformers, governance_aware] +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" +prerequisites: + description: "A multi-agent runner with creative-builder and governance agents. Override creative_agent_url with the exact creative endpoint under test." + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: discovery_and_service_setup + title: "Set up the creative service and discover paid work" + steps: + - id: sync_accounts + agent: creative-builder + title: "Establish the buyer account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + sample_request: + accounts: + - brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + idempotency_key: "$generate:uuid_v4#creative_governance_approved_sync_accounts" + validations: + - check: response_schema + + - id: sync_governance + agent: creative-builder + title: "Register the governance provider" + task: sync_governance + schema_ref: "account/sync-governance-request.json" + response_schema_ref: "account/sync-governance-response.json" + doc_ref: "/accounts/tasks/sync_governance" + stateful: true + sample_request: + accounts: + - account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + governance_agents: + - url: "$context.governance_agent_url" + authentication: + schemes: ["Bearer"] + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + idempotency_key: "$generate:uuid_v4#creative_governance_approved_sync_governance" + validations: + - check: response_schema + + - id: discover_paid_transformer + agent: creative-builder + title: "Discover a positive-price transformer" + task: list_transformers + schema_ref: "creative/list-transformers-request.json" + response_schema_ref: "creative/list-transformers-response.json" + doc_ref: "/creative/task-reference/list_transformers" + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + include_pricing: true + context_outputs: + - { name: transformer_id, path: "transformers[0].transformer_id" } + - { name: target_capability_id, path: "transformers[0].output_capability_ids[0]" } + - { name: pricing_currency, path: "transformers[0].pricing_options[0].currency" } + validations: + - check: response_schema + - check: field_greater_than + path: "transformers[0].pricing_options[0].unit_price" + value: 0 + - check: field_present + path: "transformers[0].pricing_options[0].currency" + + - id: authorization + title: "Authorize the exact paid build" + steps: + - id: sync_plans + agent: governance + title: "Create a permissive creative-services plan" + task: sync_plans + schema_ref: "governance/sync-plans-request.json" + response_schema_ref: "governance/sync-plans-response.json" + doc_ref: "/governance/campaign/tasks/sync_plans" + stateful: true + sample_request: + idempotency_key: "$generate:uuid_v4#creative_governance_approved_sync_plans" + plans: + - plan_id: "comply-creative-approved-plan" + brand: { domain: "acmeoutdoor.example" } + objectives: "Produce campaign voiceover creative" + budget: + total: 1000000000 + currency: "$context.pricing_currency" + reallocation_threshold: 1000000000 + flight: + start: "2020-01-01T00:00:00Z" + end: "2099-06-30T23:59:59Z" + countries: ["US"] + context_outputs: + - { name: plan_id, path: "plans[0].plan_id" } + validations: + - check: response_schema + + - id: check_governance_intent + agent: governance + title: "Approve the exact build payload" + task: check_governance + schema_ref: "governance/check-governance-request.json" + response_schema_ref: "governance/check-governance-response.json" + doc_ref: "/governance/campaign/tasks/check_governance" + stateful: true + sample_request: + plan_id: "$context.plan_id" + caller: "https://pinnacle-agency.example" + target_agent: "$context.creative_agent_url" + tool: "build_creative" + purchase_type: "creative_services" + proposed_commitment: { amount: 1000000000, currency: "$context.pricing_currency" } + payload: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + mode: "execute" + transformer_id: "$context.transformer_id" + target_capability_id: "$context.target_capability_id" + message: "Produce a 30-second trail campaign voiceover." + idempotency_key: "$generate:uuid_v4#creative_governance_approved_build" + context_outputs: + - { name: governance_context, path: "governance_context" } + validations: + - check: response_schema + - check: field_value + path: "verdict" + value: "approved" + - check: field_present + path: "governance_context" + + - id: authorized_build + title: "Render and prove the retained artifact" + steps: + - id: build_creative_approved + agent: creative-builder + title: "Execute the authorized paid build" + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + stateful: true + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + mode: "execute" + transformer_id: "$context.transformer_id" + target_capability_id: "$context.target_capability_id" + message: "Produce a 30-second trail campaign voiceover." + idempotency_key: "$generate:uuid_v4#creative_governance_approved_build" + governance_context: "$context.governance_context" + validations: + - check: response_schema + - check: field_present + path: "creative_manifest" + description: "The authorized paid build produced a creative artifact" + - check: field_present + path: "build_variant_id" + description: "The paid artifact is retained for refinement" diff --git a/static/compliance/source/specialisms/creative-transformers/scenarios/governance_denied.yaml b/static/compliance/source/specialisms/creative-transformers/scenarios/governance_denied.yaml new file mode 100644 index 0000000000..33f24f3c9e --- /dev/null +++ b/static/compliance/source/specialisms/creative-transformers/scenarios/governance_denied.yaml @@ -0,0 +1,167 @@ +id: creative_transformers/governance_denied +version: "1.0.0" +title: "Creative agent rejects paid execution without governance approval" +category: creative_transformers +summary: "Verifies that a creative agent claiming build_creative governance enforcement rejects an unauthorized paid render before vendor execution." +track: creative +required_tools: + - sync_accounts + - sync_governance + - list_transformers + - build_creative + +requires_capability: + path: adcp.governance_enforcement.tasks + contains: + task: build_creative + modes: + - signed_context + +narrative: | + A paid creative render is a governed commitment. An agent advertising + signed-context enforcement for build_creative must reject an execute request + that incurs vendor cost when governance_context is absent. Non-claiming agents + grade this scenario not_applicable. + + The runner registers governance, explicitly discovers a transformer with a + positive rate, and requests execution without authorization. PERMISSION_DENIED + plus the absence of platform-primary outbound traffic proves fail-closed + behavior. A response echo is never governance evidence. + +context: + governance_agent_url: "https://test-agent.adcontextprotocol.org" + +agent: + interaction_model: stateless_transform + capabilities: + - supports_transformers + - governance_aware + examples: + - "Any paid creative transformer that honors governance before rendering" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + A governance agent URL and a creative transformer agent supporting account + setup, governance registration, priced transformer discovery, and builds. + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: creative_agent_setup + title: "Register account and governance with the creative agent" + steps: + - id: sync_accounts + title: "Establish account with creative agent" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + expected: "Creative agent returns an active account." + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + idempotency_key: "$generate:uuid_v4#creative_governance_denied_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json schema" + + - id: sync_governance + title: "Register governance agent with creative agent" + task: sync_governance + schema_ref: "account/sync-governance-request.json" + response_schema_ref: "account/sync-governance-response.json" + doc_ref: "/accounts/tasks/sync_governance" + stateful: true + expected: "Creative agent acknowledges governance registration." + sample_request: + accounts: + - account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + governance_agents: + - url: "$context.governance_agent_url" + authentication: + schemes: ["Bearer"] + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + idempotency_key: "$generate:uuid_v4#creative_governance_denied_sync_governance" + validations: + - check: response_schema + description: "Response matches sync-governance-response.json schema" + + - id: paid_build_denied + title: "Discover paid execution and omit governance context" + steps: + - id: discover_paid_transformer + title: "Discover a transformer with positive pricing" + task: list_transformers + schema_ref: "creative/list-transformers-request.json" + response_schema_ref: "creative/list-transformers-response.json" + doc_ref: "/creative/task-reference/list_transformers" + stateful: false + expected: "Return a transformer with an output capability and positive price." + sample_request: + include_pricing: true + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + context_outputs: + - path: "transformers[0].transformer_id" + key: "transformer_id" + - path: "transformers[0].output_capability_ids[0]" + key: "target_capability_id" + - path: "transformers[0].pricing_options[0].pricing_option_id" + key: "pricing_option_id" + validations: + - check: response_schema + description: "Response matches list-transformers-response.json schema" + - check: field_present + path: "transformers[0].pricing_options[0].pricing_option_id" + description: "The selected transformer has a paid execution option" + - check: field_present + path: "transformers[0].pricing_options[0].unit_price" + description: "The selected option exposes a numeric unit rate" + - check: field_greater_than + path: "transformers[0].pricing_options[0].unit_price" + value: 0 + description: "The selected option incurs positive vendor cost" + + - id: build_creative_denied + title: "build_creative — missing governance approval" + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: "Reject with PERMISSION_DENIED before any paid render executes." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + mode: "execute" + transformer_id: "$context.transformer_id" + target_capability_id: "$context.target_capability_id" + message: "Produce a 30-second trail campaign voiceover." + idempotency_key: "$generate:uuid_v4#creative_governance_denied_build" + validations: + - check: error_code + value: "PERMISSION_DENIED" + description: "Paid execution without signed authorization fails closed" + - check: upstream_traffic + description: "Rejected build causes no platform-primary outbound render call" + min_count: 0 + endpoint_pattern: "POST *" + purpose_filter: + - platform_primary diff --git a/static/compliance/source/specialisms/signal-marketplace/index.yaml b/static/compliance/source/specialisms/signal-marketplace/index.yaml index 121d1d2bd8..573b98c9a1 100644 --- a/static/compliance/source/specialisms/signal-marketplace/index.yaml +++ b/static/compliance/source/specialisms/signal-marketplace/index.yaml @@ -9,6 +9,7 @@ required_tools: - get_signals - activate_signal requires_scenarios: + - signal_marketplace/governance_approved - signal_marketplace/governance_denied narrative: | diff --git a/static/compliance/source/specialisms/signal-marketplace/scenarios/governance_approved.yaml b/static/compliance/source/specialisms/signal-marketplace/scenarios/governance_approved.yaml new file mode 100644 index 0000000000..d281d660e0 --- /dev/null +++ b/static/compliance/source/specialisms/signal-marketplace/scenarios/governance_approved.yaml @@ -0,0 +1,241 @@ +id: signal_marketplace/governance_approved +version: "1.0.0" +title: "Signal agent accepts a signed activation authorization" +category: signal_marketplace +summary: "Verifies that a valid task- and payload-bound governance token permits paid signal activation and that the deployment persists." +track: signals +requires: [multi_agent] +default_agent: signals +required_tools: + - sync_accounts + - sync_governance + - get_signals + - sync_plans + - check_governance + - activate_signal + +requires_capability: + path: adcp.governance_enforcement.tasks + contains: + task: activate_signal + modes: [signed_context] + +narrative: | + The runner discovers a priced signal, creates a permissive plan in the + discovered currency, and obtains signed intent authorization for the exact + activate_signal payload. The signal agent must accept that token, deploy the + signal, and expose the persisted live deployment on a later read. This + positive path prevents an always-rejecting service from satisfying the + governance-enforcement claim. + +context: + governance_agent_url: "https://test-agent.adcontextprotocol.org" + signal_agent_url: "https://test-agent.adcontextprotocol.org/signals" + +agent: + interaction_model: marketplace_catalog + capabilities: [governance_aware] +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" +prerequisites: + description: "A multi-agent runner with signal and governance agents. Override signal_agent_url with the exact signal endpoint under test." + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: discovery_and_service_setup + title: "Set up the signal service and discover a paid activation" + steps: + - id: sync_accounts + agent: signals + title: "Establish the buyer account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + sample_request: + accounts: + - brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + idempotency_key: "$generate:uuid_v4#signal_governance_approved_sync_accounts" + validations: + - check: response_schema + + - id: sync_governance + agent: signals + title: "Register the governance provider" + task: sync_governance + schema_ref: "account/sync-governance-request.json" + response_schema_ref: "account/sync-governance-response.json" + doc_ref: "/accounts/tasks/sync_governance" + stateful: true + sample_request: + accounts: + - account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + governance_agents: + - url: "$context.governance_agent_url" + authentication: + schemes: ["Bearer"] + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + idempotency_key: "$generate:uuid_v4#signal_governance_approved_sync_governance" + validations: + - check: response_schema + + - id: discover_paid_signal + agent: signals + title: "Discover a priced signal" + task: get_signals + schema_ref: "signals/get-signals-request.json" + response_schema_ref: "signals/get-signals-response.json" + doc_ref: "/signals/tasks/get_signals" + comply_scenario: signal_discovery + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + signal_spec: "a paid marketplace signal for outdoor enthusiasts with immediate activation" + fields: [pricing_options, deployments] + context_outputs: + - { name: signal_id, path: "signals[0].signal_id" } + - { name: signal_agent_segment_id, path: "signals[0].signal_agent_segment_id" } + - { name: pricing_option_id, path: "signals[0].pricing_options[0].pricing_option_id" } + - { name: pricing_currency, path: "signals[0].pricing_options[0].currency" } + validations: + - check: response_schema + - check: field_present + path: "signals[0].pricing_options[0].pricing_option_id" + - check: field_present + path: "signals[0].pricing_options[0].currency" + + - id: authorization + title: "Authorize the exact activation" + steps: + - id: sync_plans + agent: governance + title: "Create a permissive signal plan" + task: sync_plans + schema_ref: "governance/sync-plans-request.json" + response_schema_ref: "governance/sync-plans-response.json" + doc_ref: "/governance/campaign/tasks/sync_plans" + stateful: true + sample_request: + idempotency_key: "$generate:uuid_v4#signal_governance_approved_sync_plans" + plans: + - plan_id: "comply-signal-approved-plan" + brand: { domain: "acmeoutdoor.example" } + objectives: "Activate governed audience data" + budget: + total: 1000000000 + currency: "$context.pricing_currency" + reallocation_threshold: 1000000000 + flight: + start: "2020-01-01T00:00:00Z" + end: "2099-12-31T23:59:59Z" + countries: ["US"] + context_outputs: + - { name: plan_id, path: "plans[0].plan_id" } + validations: + - check: response_schema + + - id: check_governance_intent + agent: governance + title: "Approve the exact activation payload" + task: check_governance + schema_ref: "governance/check-governance-request.json" + response_schema_ref: "governance/check-governance-response.json" + doc_ref: "/governance/campaign/tasks/check_governance" + stateful: true + sample_request: + plan_id: "$context.plan_id" + caller: "https://pinnacle-agency.example" + target_agent: "$context.signal_agent_url" + tool: "activate_signal" + purchase_type: "signal_activation" + proposed_commitment: { amount: 1000000000, currency: "$context.pricing_currency" } + payload: + action: "activate" + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + signal_agent_segment_id: "$context.signal_agent_segment_id" + pricing_option_id: "$context.pricing_option_id" + destinations: + - type: "platform" + platform: "pinnacle-dsp" + account: "acmeoutdoor-governed-seat" + idempotency_key: "$generate:uuid_v4#signal_governance_approved_activate" + context_outputs: + - { name: governance_context, path: "governance_context" } + validations: + - check: response_schema + - check: field_value + path: "verdict" + value: "approved" + - check: field_present + path: "governance_context" + + - id: authorized_activation + title: "Deploy and prove durable state" + steps: + - id: activate_signal_approved + agent: signals + title: "Activate with signed authorization" + task: activate_signal + schema_ref: "signals/activate-signal-request.json" + response_schema_ref: "signals/activate-signal-response.json" + doc_ref: "/signals/tasks/activate_signal" + comply_scenario: signal_activation + stateful: true + sample_request: + action: "activate" + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + signal_agent_segment_id: "$context.signal_agent_segment_id" + pricing_option_id: "$context.pricing_option_id" + destinations: + - type: "platform" + platform: "pinnacle-dsp" + account: "acmeoutdoor-governed-seat" + idempotency_key: "$generate:uuid_v4#signal_governance_approved_activate" + governance_context: "$context.governance_context" + validations: + - check: response_schema + - check: field_value + path: "deployments[0].is_live" + value: true + - check: field_present + path: "deployments[0].activation_key" + - check: upstream_traffic + description: "Authorized activation dispatches the selected signal to the destination platform" + min_count: 1 + endpoint_pattern: "POST *" + since: discover_paid_signal + identifier_paths: ["signal_agent_segment_id"] + + - id: get_signal_deployment_readback + agent: signals + title: "Read back the persisted live deployment" + task: get_signals + schema_ref: "signals/get-signals-request.json" + response_schema_ref: "signals/get-signals-response.json" + doc_ref: "/signals/tasks/get_signals" + stateful: false + sample_request: + account: + brand: { domain: "acmeoutdoor.example" } + operator: "pinnacle-agency.example" + signal_ids: ["$context.signal_id"] + fields: [deployments] + validations: + - check: response_schema + - check: field_value + path: "signals[0].deployments[0].is_live" + value: true + - check: field_present + path: "signals[0].deployments[0].activation_key" diff --git a/static/compliance/source/universal/governance.yaml b/static/compliance/source/universal/governance.yaml new file mode 100644 index 0000000000..6e5eae6174 --- /dev/null +++ b/static/compliance/source/universal/governance.yaml @@ -0,0 +1,71 @@ +id: governance +version: "1.0.0" +title: "Cross-role governance conformance index" +category: campaign_governance +summary: "Non-executable index connecting the universal governance contract to capability-gated, role-specific proof storyboards." +track: campaign_governance + +narrative: | + Cross-role governance has one normative contract and several role-specific + proofs. Governance providers return intent verdicts (approved, conditions, + denied). Services that execute consequential tasks advertise exact entries in + adcp.governance_enforcement.tasks[] and prove that they validate signed intent + before side effects. The provider role and enforcing-service role are distinct. + + This file is a discoverability index, not a storyboard that every agent runs. + Each linked proof is selected only by its exact task capability gate. A denial + response alone—and especially an echoed governance_context—does not prove + enforcement; negative paths also assert that no governed side effect occurred. + +normative_requirements: + capability_claim: "Services claim only implemented task/mode pairs in adcp.governance_enforcement.tasks[]." + provider_verdicts: "Intent checks may return approved, conditions, or denied; execution checks are binary approved or denied." + authorization: "Only an approved signed governance_context authorizes a governed service action." + evidence: "Conformance requires role-specific side-effect evidence; response echoing is not evidence." + +roles: + governance_provider: + capability: "governance.campaign" + contract: "/docs/governance/campaign/specification" + enforcing_service: + capability: "adcp.governance_enforcement.tasks[]" + contract: "/docs/reference/migration/cross-role-governance-enforcement" + +task_slices: + - role: media_buy_service + tasks: [create_media_buy] + capability_mode: signed_context + storyboards: + - protocols/media-buy/scenarios/governance_approved.yaml + - role: signal_service + tasks: [activate_signal] + capability_mode: signed_context + storyboards: + - specialisms/signal-marketplace/scenarios/governance_approved.yaml + - specialisms/signal-marketplace/scenarios/governance_denied.yaml + - role: rights_service + tasks: [acquire_rights] + capability_mode: signed_context + storyboards: + - specialisms/brand-rights/scenarios/governance_approved.yaml + - specialisms/brand-rights/scenarios/governance_denied.yaml + - role: creative_service + tasks: [build_creative] + capability_mode: signed_context + storyboards: + - specialisms/creative-transformers/scenarios/governance_approved.yaml + - specialisms/creative-transformers/scenarios/governance_denied.yaml + +online_execution_proofs: + - role: media_buy_service + tasks: [create_media_buy] + capability_modes: [signed_context, online_execution_check] + storyboards: + - protocols/media-buy/scenarios/governance_conditions.yaml + - protocols/media-buy/scenarios/governance_denied.yaml + +references: + capability_schema: /schemas/protocol/get-adcp-capabilities-response.json + governance_response_schema: /schemas/governance/check-governance-response.json + specification: /docs/governance/campaign/specification + migration_guide: /docs/reference/migration/cross-role-governance-enforcement diff --git a/static/compliance/source/universal/runner-output-contract.yaml b/static/compliance/source/universal/runner-output-contract.yaml index 4a136c0405..970671db7b 100644 --- a/static/compliance/source/universal/runner-output-contract.yaml +++ b/static/compliance/source/universal/runner-output-contract.yaml @@ -62,6 +62,7 @@ authored_check_kinds: - a2a_submitted_artifact - a2a_context_continuity - field_less_than + - field_greater_than - field_equals_context - field_in_context_array - all_fields_in_context_array @@ -198,6 +199,7 @@ validation_result: # resource_equals_agent_url | oauth_metadata_graph | # any_of | refs_resolve | # a2a_submitted_artifact | field_less_than | + # field_greater_than | # field_equals_context | field_in_context_array | # all_fields_in_context_array | # upstream_traffic | @@ -281,6 +283,9 @@ validation_result: # value (a finite number) is # captured here; when from a # literal `value`, that literal. + # field_greater_than → literal finite-number + # threshold the field MUST be + # strictly greater than. # field_equals_context → comparand the field MUST # deep-equal. The runtime value # resolved from @@ -364,6 +369,9 @@ validation_result: # field_less_than → the observed value at `path` # (number when resolvable, null # when the field was missing). + # field_greater_than → the observed value at `path` + # (number when resolvable, null + # when the field was missing). # field_equals_context → the observed value at `path` # (any JSON type, null when the # field was missing). diff --git a/static/compliance/source/universal/storyboard-schema.yaml b/static/compliance/source/universal/storyboard-schema.yaml index 62b80293b6..3d54499273 100644 --- a/static/compliance/source/universal/storyboard-schema.yaml +++ b/static/compliance/source/universal/storyboard-schema.yaml @@ -1395,6 +1395,8 @@ # "error_code", "on_401_require_header", "resource_equals_agent_url", # "oauth_metadata_graph", # "any_of", "refs_resolve", "a2a_submitted_artifact", +# "field_greater_than" (strict finite-number comparison against +# a literal threshold), # "field_less_than" / "field_equals_context" / # "field_in_context_array" (compare a field on this # step's response against a value captured by a prior step — see @@ -1508,7 +1510,8 @@ # path: string (JSON path to the field, e.g., "formats[0].format_id") # value: any (expected value — string, number, or boolean; required when check is "field_value"; # accepted (optional) when check is "field_value_or_absent"; -# accepted (optional) on "field_less_than" as a literal comparand when +# required on "field_greater_than"; accepted (optional) on +# "field_less_than" as a literal comparand when # `context_key` is not set) # allowed_values: array (acceptable values for "field_value", "field_value_or_absent", # "field_contains", "http_status_in", "error_code", "any_of") @@ -1528,6 +1531,10 @@ # Passes when the field is absent OR present and equal to `value` / contained in # `allowed_values`; fails only when the field is present with a disallowed value. # Use for fields that the spec permits to be omitted but MUST NOT carry a wrong value +# +# field_greater_than check: +# Requires the value at `path` and the literal `value` threshold to be finite +# numbers, and passes only when the observed value is strictly greater. # when present (e.g., a `replayed` flag that MAY be omitted on a fresh path but MUST # NOT be true). Accepts both `value` (single expected value) and `allowed_values` (set # membership); at least one of the two MUST be provided. A `field_value_or_absent` check diff --git a/tests/governance-conditions-storyboard.test.cjs b/tests/governance-conditions-storyboard.test.cjs index be4d3eb3c7..3ad5f0f8b1 100644 --- a/tests/governance-conditions-storyboard.test.cjs +++ b/tests/governance-conditions-storyboard.test.cjs @@ -41,8 +41,8 @@ test('governance conditions require a later approved intent before mutation', () assert.deepEqual(doc.requires, ['multi_agent']); assert.equal(doc.default_agent, 'sales'); assert.deepEqual(doc.requires_capability, { - path: 'media_buy.governance_aware', - equals: true, + path: 'adcp.governance_enforcement.tasks', + contains: { task: 'create_media_buy', modes: ['signed_context', 'online_execution_check'] }, }); for (const tool of ['check_governance', 'create_media_buy', 'get_media_buys', 'report_plan_outcome']) { assert.ok(doc.required_tools.includes(tool), `${tool} must be declared`); @@ -144,38 +144,38 @@ test('governance conditions flow proves committed state and closes the outcome l assert.match(executionText, /`conditions`.*invalid/is); }); -test('governance approved flow grades intent, execution evidence, and durable state', () => { +test('governance approved flow grades signed intent and durable state', () => { const doc = loadStoryboard(APPROVED_STORYBOARD_PATH); const orderedSteps = (doc.phases || []).flatMap((phase) => phase.steps || []); const ids = orderedSteps.map((step) => step.id); const steps = stepsById(doc); const intent = steps.get('check_governance_intent'); + const tampered = steps.get('create_media_buy_tampered'); const create = steps.get('create_media_buy'); const readback = steps.get('get_media_buys_readback'); - const audit = steps.get('get_plan_audit_logs_execution'); const outcome = steps.get('report_plan_outcome'); assert.deepEqual(doc.requires, ['multi_agent']); assert.equal(doc.default_agent, 'sales'); assert.deepEqual(doc.requires_capability, { - path: 'media_buy.governance_aware', - equals: true, + path: 'adcp.governance_enforcement.tasks', + contains: { task: 'create_media_buy', modes: ['signed_context'] }, }); for (const tool of [ 'check_governance', 'create_media_buy', 'get_media_buys', - 'get_plan_audit_logs', 'report_plan_outcome', ]) { assert.ok(doc.required_tools.includes(tool), `${tool} must be declared`); } - assert.ok(intent && create && readback && audit && outcome); + assert.ok(intent && tampered && create && readback && outcome); + assert.ok(ids.indexOf(intent.id) < ids.indexOf(tampered.id)); + assert.ok(ids.indexOf(tampered.id) < ids.indexOf(create.id)); assert.ok(ids.indexOf(intent.id) < ids.indexOf(create.id)); assert.ok(ids.indexOf(create.id) < ids.indexOf(readback.id)); - assert.ok(ids.indexOf(readback.id) < ids.indexOf(audit.id)); - assert.ok(ids.indexOf(audit.id) < ids.indexOf(outcome.id)); + assert.ok(ids.indexOf(readback.id) < ids.indexOf(outcome.id)); assert.equal(intent.agent, 'governance'); assert.equal(intent.sample_request.tool, 'create_media_buy'); @@ -188,6 +188,10 @@ test('governance approved flow grades intent, execution evidence, and durable st delete createPayload.governance_context; assert.deepEqual(createPayload, intent.sample_request.payload); assert.equal(create.sample_request.governance_context, '$context.governance_context'); + assert.equal( + (tampered.validations || []).find(({ check }) => check === 'error_code')?.value, + 'PERMISSION_DENIED', + ); assert.equal( (create.validations || []).some(({ path: validationPath }) => validationPath === 'governance_context' || validationPath?.startsWith('conditions')), @@ -199,15 +203,6 @@ test('governance approved flow grades intent, execution evidence, and durable st validation(readback, 'field_equals_context', 'media_buys[0].media_buy_id')?.context_key, 'media_buy_id', ); - assert.equal(audit.sample_request.include_entries, true); - assert.equal( - validation(audit, 'field_value', 'plans[0].entries[1].check_type')?.value, - 'execution', - ); - assert.equal( - validation(audit, 'field_value', 'plans[0].entries[1].verdict')?.value, - 'approved', - ); assert.equal(outcome.sample_request.check_id, '$context.approved_check_id'); assert.equal(outcome.sample_request.seller_response.seller_reference, '$context.media_buy_id'); }); diff --git a/tests/governance-task-slices.test.cjs b/tests/governance-task-slices.test.cjs new file mode 100644 index 0000000000..081b9d4142 --- /dev/null +++ b/tests/governance-task-slices.test.cjs @@ -0,0 +1,179 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const ROOT = path.resolve(__dirname, '..'); +const source = (...parts) => path.join(ROOT, 'static/compliance/source', ...parts); +const load = (...parts) => yaml.load(fs.readFileSync(source(...parts), 'utf8')); +const stepsById = (doc) => new Map( + (doc.phases || []).flatMap((phase) => phase.steps || []).map((step) => [step.id, step]), +); + +test('rights governance proof is task-gated and proves no grant persisted', () => { + const doc = load('specialisms', 'brand-rights', 'scenarios', 'governance_denied.yaml'); + const steps = stepsById(doc); + const denied = steps.get('acquire_rights_denied'); + const noGrant = steps.get('rejected_grant_not_persisted'); + + assert.deepEqual(doc.requires_capability, { + path: 'adcp.governance_enforcement.tasks', + contains: { task: 'acquire_rights', modes: ['signed_context'] }, + }); + for (const tool of ['sync_accounts', 'sync_governance', 'get_rights', 'acquire_rights', 'update_rights']) { + assert.ok(doc.required_tools.includes(tool), `${tool} must be declared`); + } + assert.equal(steps.has('sync_plans'), false, 'the service proof must not create provider policy'); + assert.equal(denied.sample_request.governance_context, undefined); + assert.equal(denied.sample_request.campaign.estimated_impressions, 1000000); + assert.equal(denied.validations.some((validation) => validation.path === 'context'), false); + assert.equal( + noGrant.validations.find((validation) => validation.check === 'error_code')?.value, + 'REFERENCE_NOT_FOUND', + ); +}); + +test('creative governance proof discovers paid work and proves no render dispatch', () => { + const doc = load('specialisms', 'creative-transformers', 'scenarios', 'governance_denied.yaml'); + const steps = stepsById(doc); + const discovery = steps.get('discover_paid_transformer'); + const denied = steps.get('build_creative_denied'); + + assert.deepEqual(doc.requires_capability, { + path: 'adcp.governance_enforcement.tasks', + contains: { task: 'build_creative', modes: ['signed_context'] }, + }); + assert.equal(discovery.sample_request.include_pricing, true); + assert.equal( + discovery.context_outputs.find((capture) => capture.key === 'pricing_option_id')?.path, + 'transformers[0].pricing_options[0].pricing_option_id', + ); + assert.equal( + discovery.validations.find((validation) => validation.check === 'field_greater_than')?.value, + 0, + ); + assert.equal(denied.sample_request.mode, 'execute'); + assert.equal(denied.sample_request.governance_context, undefined); + assert.equal( + denied.validations.find((validation) => validation.check === 'error_code')?.value, + 'PERMISSION_DENIED', + ); + assert.deepEqual( + denied.validations.find((validation) => validation.check === 'upstream_traffic'), + { + check: 'upstream_traffic', + description: 'Rejected build causes no platform-primary outbound render call', + min_count: 0, + endpoint_pattern: 'POST *', + purpose_filter: ['platform_primary'], + }, + ); + assert.equal(denied.validations.some((validation) => validation.path === 'context'), false); +}); + +test('signal governance claim requires an accepted token and durable deployment proof', () => { + const doc = load('specialisms', 'signal-marketplace', 'scenarios', 'governance_approved.yaml'); + const steps = stepsById(doc); + const discovery = steps.get('discover_paid_signal'); + const intent = steps.get('check_governance_intent'); + const activation = steps.get('activate_signal_approved'); + const readback = steps.get('get_signal_deployment_readback'); + + assert.deepEqual(doc.requires, ['multi_agent']); + assert.deepEqual(doc.requires_capability, { + path: 'adcp.governance_enforcement.tasks', + contains: { task: 'activate_signal', modes: ['signed_context'] }, + }); + assert.equal( + discovery.context_outputs.find((capture) => capture.name === 'pricing_currency')?.path, + 'signals[0].pricing_options[0].currency', + ); + assert.equal(intent.sample_request.tool, 'activate_signal'); + assert.equal(intent.sample_request.proposed_commitment.currency, '$context.pricing_currency'); + assert.equal(activation.sample_request.governance_context, '$context.governance_context'); + assert.equal( + activation.validations.find((validation) => validation.path === 'deployments[0].is_live')?.value, + true, + ); + assert.equal( + readback.validations.find((validation) => validation.path === 'signals[0].deployments[0].is_live')?.value, + true, + ); +}); + +test('rights and creative governance claims require successful signed-token workflows', () => { + const rights = load('specialisms', 'brand-rights', 'scenarios', 'governance_approved.yaml'); + const rightsSteps = stepsById(rights); + assert.deepEqual(rights.requires, ['multi_agent']); + assert.equal(rightsSteps.get('check_governance_intent').sample_request.tool, 'acquire_rights'); + assert.equal( + rightsSteps.get('check_governance_intent').sample_request.proposed_commitment.currency, + '$context.pricing_currency', + ); + assert.equal( + rightsSteps.get('acquire_rights_approved').validations + .find((validation) => validation.path === 'rights_status')?.value, + 'acquired', + ); + assert.equal( + rightsSteps.get('update_acquired_grant').validations + .find((validation) => validation.path === 'paused')?.value, + true, + ); + + const creative = load('specialisms', 'creative-transformers', 'scenarios', 'governance_approved.yaml'); + const creativeSteps = stepsById(creative); + assert.deepEqual(creative.requires, ['multi_agent']); + assert.equal(creativeSteps.get('check_governance_intent').sample_request.tool, 'build_creative'); + assert.equal( + creativeSteps.get('check_governance_intent').sample_request.proposed_commitment.currency, + '$context.pricing_currency', + ); + assert.equal( + creativeSteps.get('discover_paid_transformer').validations + .find((validation) => validation.check === 'field_greater_than')?.value, + 0, + ); + assert.ok( + creativeSteps.get('build_creative_approved').validations + .some((validation) => validation.path === 'creative_manifest'), + ); +}); + +test('cross-role governance support artifact indexes every task slice without becoming universal execution', () => { + const doc = load('universal', 'governance.yaml'); + assert.equal(doc.phases, undefined); + assert.match(doc.narrative, /discoverability index, not a storyboard/); + assert.deepEqual( + Object.fromEntries(doc.task_slices.map((slice) => [slice.role, slice.tasks])), + { + media_buy_service: ['create_media_buy'], + signal_service: ['activate_signal'], + rights_service: ['acquire_rights'], + creative_service: ['build_creative'], + }, + ); + + const indexed = [ + ...doc.task_slices.flatMap((slice) => slice.storyboards), + ...doc.online_execution_proofs.flatMap((slice) => slice.storyboards), + ]; + for (const expected of [ + 'protocols/media-buy/scenarios/governance_approved.yaml', + 'protocols/media-buy/scenarios/governance_conditions.yaml', + 'protocols/media-buy/scenarios/governance_denied.yaml', + 'specialisms/signal-marketplace/scenarios/governance_denied.yaml', + 'specialisms/signal-marketplace/scenarios/governance_approved.yaml', + 'specialisms/brand-rights/scenarios/governance_denied.yaml', + 'specialisms/brand-rights/scenarios/governance_approved.yaml', + 'specialisms/creative-transformers/scenarios/governance_denied.yaml', + 'specialisms/creative-transformers/scenarios/governance_approved.yaml', + ]) { + assert.ok(indexed.includes(expected), `${expected} must be indexed`); + assert.ok(fs.existsSync(source(...expected.split('/'))), `${expected} must resolve`); + } + assert.match(doc.normative_requirements.evidence, /response echoing is not evidence/i); +}); diff --git a/tests/lint-storyboard-check-enum.test.cjs b/tests/lint-storyboard-check-enum.test.cjs index 95db9e4eb3..56254f9126 100644 --- a/tests/lint-storyboard-check-enum.test.cjs +++ b/tests/lint-storyboard-check-enum.test.cjs @@ -40,7 +40,7 @@ test('authored_check_kinds enum loads from runner-output-contract.yaml', () => { const kinds = loadKnownCheckKinds(); // Spot-check a few load-bearing entries — keep this tight so the test // doesn't have to track every authored kind. Full enum is the contract. - for (const expected of ['response_schema', 'field_present', 'field_pattern', 'field_contains', 'envelope_field_pattern', 'upstream_traffic']) { + for (const expected of ['response_schema', 'field_present', 'field_pattern', 'field_contains', 'field_greater_than', 'envelope_field_pattern', 'upstream_traffic']) { assert.ok(kinds.has(expected), `expected "${expected}" in authored_check_kinds`); } // Synthesized codes MUST NOT be in the authored enum. diff --git a/tests/lint-storyboard-sample-request-schema.test.cjs b/tests/lint-storyboard-sample-request-schema.test.cjs index 90ec22a19c..5ca9982277 100644 --- a/tests/lint-storyboard-sample-request-schema.test.cjs +++ b/tests/lint-storyboard-sample-request-schema.test.cjs @@ -275,6 +275,13 @@ test('normalizeSubstitutions produces a schema-valid digest placeholder', () => assert.match(digest, /^sha256:[A-Za-z0-9_-]{43}$/); }); +test('normalizeSubstitutions produces a schema-valid currency placeholder', () => { + assert.equal(normalizeSubstitutions('$context.pricing_currency', { + type: 'string', + pattern: '^[A-Z]{3}$', + }), 'USD'); +}); + // Object-typed substitution synthesis — the lint change landed in this PR. // A substitution that lands at an object location (plain or inside a // discriminated oneOf) must produce a shape-valid placeholder or ajv will diff --git a/tests/lint-universal-storyboard-doc-parity.test.cjs b/tests/lint-universal-storyboard-doc-parity.test.cjs index 4854a3659f..5ec9b7a853 100644 --- a/tests/lint-universal-storyboard-doc-parity.test.cjs +++ b/tests/lint-universal-storyboard-doc-parity.test.cjs @@ -101,13 +101,14 @@ test('catalog can list storyboard slugs through an imported MDX snippet', () => assert.deepEqual(errors, []); }); -test('non-graded fixtures (storyboard-schema, runner-output-contract, fictional-entities) are not required in docs', () => { +test('non-graded support artifacts are not required in docs', () => { const { sourceDir, repoRoot } = makeFixture(); writeStoryboard(sourceDir, 'capability-discovery'); // These three live in the real universal/ directory but aren't graded. writeStoryboard(sourceDir, 'storyboard-schema', { graded: false }); writeStoryboard(sourceDir, 'runner-output-contract', { graded: false }); writeStoryboard(sourceDir, 'fictional-entities', { graded: false }); + writeStoryboard(sourceDir, 'governance', { graded: false }); writeConformance(repoRoot, [['capability_discovery', 'shape']]); writeCatalog(repoRoot, [['capability-discovery', 'shape']]); diff --git a/tests/sdk-runner-capability-gates.test.cjs b/tests/sdk-runner-capability-gates.test.cjs index 18c84ed57f..0c4ac530f6 100644 --- a/tests/sdk-runner-capability-gates.test.cjs +++ b/tests/sdk-runner-capability-gates.test.cjs @@ -182,6 +182,40 @@ test('runStoryboard structurally matches object-valued governance task gates', a assert.equal(result.phases[0].steps[0].skip_reason, 'no_phases'); }); +test('online media-buy governance proofs do not apply to signed-context-only sellers', async () => { + const storyboardPath = path.join( + __dirname, + '..', + 'static', + 'compliance', + 'source', + 'protocols', + 'media-buy', + 'scenarios', + 'governance_conditions.yaml' + ); + const storyboard = YAML.parse(fs.readFileSync(storyboardPath, 'utf8')); + const baseProfile = { + tools: ['get_adcp_capabilities', ...storyboard.required_tools], + raw_capabilities: { + adcp: { + governance_enforcement: { + tasks: [{ task: 'create_media_buy', modes: ['signed_context'] }], + }, + }, + }, + }; + const nonExecutable = { ...storyboard, prerequisites: undefined, phases: [] }; + + const signedOnly = await runStoryboard('https://agent.example/mcp', nonExecutable, { + _profile: baseProfile, + agentTools: baseProfile.tools, + }); + assert.equal(signedOnly.overall_passed, true); + assert.equal(signedOnly.phases[0].phase_id, 'capability_unsupported'); + assert.equal(signedOnly.phases[0].steps[0].skip_reason, 'capability_unsupported'); +}); + test('billing gate skips per-agent phases when agent billing is not supported', () => { const storyboardPath = path.join( __dirname,