Skip to content

feat: support AdCP 3.1.0-rc.7#2167

Merged
bokelley merged 13 commits into
mainfrom
v3-1-0-rc-7-release
Jun 4, 2026
Merged

feat: support AdCP 3.1.0-rc.7#2167
bokelley merged 13 commits into
mainfrom
v3-1-0-rc-7-release

Conversation

@bokelley

@bokelley bokelley commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Sync AdCP schema/type/docs generation to 3.1.0-rc.7.
  • Register rc.7 protocol/creative tools across capabilities, request schemas, response schemas, and server handler wiring: get_task_status, list_tasks, and list_transformers.
  • Surface caller-specific account authorization metadata on both list_accounts and sync_accounts, including the TikTok-style split between advertiser account access and publisher identity/post authorization.
  • Sanitize AUTHORIZATION_REQUIRED.details so downstream authorization hints survive while accidental tokens/internal fields are stripped.
  • Add retry-policy coverage for the new rc.7 standard error codes.

Expert Review

  • Protocol review: fixed sync_accounts.accounts[].authorization projection and rc.7 tool registration gaps.
  • Security review: added allowlist projection for account authorization metadata and sanitized AUTHORIZATION_REQUIRED detail envelopes.
  • Code review: fixed request/response schema registration, creative-transformer specialism wiring, protocol capabilities drift, and checked_at preservation.

Validation

  • npm run sync-schemas
  • npm run generate-types
  • npm run generate-registry-types
  • npm run build:lib
  • npm run generate-agent-docs
  • npm run typecheck
  • NODE_ENV=test node --test-timeout=60000 --test-force-exit --test test/lib/x-entity-hydration.test.js test/lib/zod-schemas.test.js test/lib/request-builder-schema-roundtrip.test.js test/lib/capabilities-tools-drift.test.js test/lib/response-schema-validation.test.js test/lib/storyboard-completeness.test.js test/lib/storyboard-drift.test.js test/server-create-adcp-server.test.js test/server-decisioning-to-wire-account.test.js test/server-account-store-ctx-forwarding.test.js test/server-authorization-required-sanitization.test.js
  • npm test

@bokelley bokelley marked this pull request as ready for review June 4, 2026 15:59
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 4, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean rc.7 bump. Tenant isolation on the new task-status surface is fail-closed at the right seam, and AUTHORIZATION_REQUIRED detail sanitization routes the wire-strip through a named allowlist plus depth/size caps instead of inventing a new envelope.

Things I checked

  • ADCP_VERSION, package.json adcp_version, src/lib/version.ts ADCP_VERSION, and scripts/sync-version.ts COMPATIBLE_PREFIX all carry 3.1.0-rc.7. COMPATIBLE_ADCP_VERSIONS lists both 3.1.0-rc.7 and the release-precision 3.1-rc.7.
  • Changeset present (.changeset/rc-seven-task-auth.md). patch is consistent with the project's beta-cycle convention on 9.0.0-beta.* — flagged below in case the team wants to revisit.
  • Tenant boundary on get_task_status (src/lib/server/create-adcp-server.ts:5416+) is fail-closed: taskId && accountId !== undefined && taskRegistry !== undefined ? await taskRegistry.getTask(taskId) : null — no resolved accountId → null → uniform REFERENCE_NOT_FOUND. The subsequent task.accountId !== accountId only runs when accountId is defined, so the undefined !== undefined truthiness trap doesn't apply. list_tasks is similarly gated (accountId !== undefined && taskRegistry?.list !== undefined ? ... : { tasks: [] }).
  • sanitizeAdcpErrorDetails in src/lib/server/errors.ts is allowlist-projection (AUTHORIZATION_REQUIRED_DETAIL_KEYS, 18 entries), routed through pickSafeDetails with maxDepth: 4 / maxSizeBytes: 4096. The keys cover the spec-named recovery hints (missing_connections, authorization_url, reference_authorization, etc.) without inviting tokens or internal bookkeeping through. applyAdcpErrorAllowlist runs sanitization before the per-code field filter, so the sanitization is unconditional on every AUTHORIZATION_REQUIRED envelope.
  • Payload-side sanitization is wired symmetrically: wrapErrorArm (handler-returned error arms) and enrichErrorTwoLayer (framework-synthesized two-layer envelopes) both run sanitizePayloadError against sc.errors[*], and the latter calls syncContentJsonText after sanitization so the content[0].text mirror stays consistent with structuredContent.
  • creative-transformers added to schemas/registry/registry.yaml enum AND CREATIVE-domain specialisms in createAdcpServer AND the CREATIVE_ENTRIES handler-entry list. No drift.
  • New error codes in docs/llms.txt (AUTHORIZATION_REQUIRED, BUDGET_CAP_REACHED, CATALOG_LIMIT_EXCEEDED, CREATIVE_INACCESSIBLE, EVALUATOR_AGENT_NOT_ACCEPTED, FEED_FETCH_FAILED, INVALID_FEED_FORMAT, ITEM_VALIDATION_FAILED, SIGNAL_TARGETING_INCOMPATIBLE, UNPRICEABLE_OUTPUT) tracked in the registry table — buyer-retry-policy.ts retry coverage updated for the new codes per the PR summary.
  • New test files cover the two security-critical changes: test/server-authorization-required-sanitization.test.js (144 LoC, new) exercises the sanitizer; test/server-account-store-ctx-forwarding.test.js (+184) covers the account-store ctx forwarding tied to the authorization metadata projection.

Follow-ups (non-blocking — file as issues)

  1. domain vs protocol consistency between list_tasks and get_task_status. toProtocolTaskStatus reports the full mapped protocol (media-buy, creative, signals, measurement, brand, governance) via TASK_TYPE_TO_PROTOCOL[taskType]. toProtocolTaskListItem collapses everything except signals to media-buy: domain: protocol === 'signals' ? 'signals' : 'media-buy'. A sync_creatives task would render as domain: 'media-buy' in list_tasks and protocol: 'creative' in get_task_status. If the rc.7 ListTasksResponse.tasks[].domain enum is narrower than AdCPProtocol by design, this is correct and worth a code comment naming that. If not, the list view is lossy. Verify against the rc.7 list_tasks schema.

  2. Changeset type vs surface area. The diff adds three new public Agent methods (listTransformers, getTaskStatus, listTasks) and four new typed-handler keys on CreativeHandlers / the framework. On a stable release that's a minor; in 9.0.0-beta.22 the pre-release tag absorbs the precision so patch is harmless. Worth a project-level convention note (in CLAUDE.md or the changeset README) so future bumps don't accidentally drop a minor into a stable release.

  3. mcpTaskStore = taskStore ?? new InMemoryTaskStore() in createAdcpServer. Previously taskStore passed through as undefined and createTaskCapableServer owned the default. Now the outer scope always materializes an InMemoryTaskStore when none is supplied, which is also what's referenced for task lookups. Behavior change for adopters who relied on the inner default; align the JSDoc on AdcpServerConfig.taskStore to call this out.

  4. ListTasksResponse.status: 'completed' at the envelope top. Inherited from the async-response base, but list_tasks is a synchronous read — 'completed' on a sync read reads odd. If the schema makes it optional, drop it; if required, add a code comment naming the base-type contract so the next reader doesn't grep for the task lifecycle that doesn't exist.

Minor nits (non-blocking)

  1. taskMatchesFilters builds the full haystack for context_contains on every filtered task. JSON.stringify({ task_id, task_type, result, error, progress }) per call. Adopters with large task.result payloads and many tasks may notice. Not a problem at the current default pageSize: 50 ceiling, but if the registry grows, this is the cost site. File as a perf follow-up if list_tasks ever returns paginated counts on large fleets.

gh api rate budget was exhausted partway through, so I did not run the subagent stack (code-reviewer, ad-tech-protocol-expert, security-reviewer) — reviewed from the diff and the on-PR file contents directly. Approving on the strength of the fail-closed account gating, the allowlist-driven sanitization, and the symmetric payload/envelope coverage in wrapErrorArm + enrichErrorTwoLayer. Worth a second pass from the protocol-expert and security-reviewer once the API quota recovers, especially on the domain-flattening question.

Safe to merge.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups below. Clean rc.7 sync — version pin, generated types, account-authorization projection with a compile-time exhaustion guard, AUTHORIZATION_REQUIRED.details sanitizer layered before the per-code allowlist, and the new framework-owned get_task_status / list_tasks wired to the scoped registry rather than the raw MCP task store. The cross-tenant guard at src/lib/server/create-adcp-server.ts (task.accountId !== accountId) is the right shape — fail-closed on accountId === undefined.

Things I checked

  • Changeset present at .changeset/rc-seven-task-auth.md (patch) — correct for a pre-release additive schema bump in changesets prerelease mode.
  • ADCP_VERSION / package.json#adcp_version / src/lib/version.ts all move 3.1.0-rc.63.1.0-rc.7 in lockstep; COMPATIBLE_ADCP_VERSIONS extends additively, preserving every prior pin.
  • ACCOUNT_AUTHORIZATION_WIRE_KEYS (allowed_tasks, field_scopes, scope_name, read_only) exact-matches AccountAuthorizationSchema.shape via the round-trip assertion at test/server-decisioning-to-wire-account.test.js:22; MissingAccountAuthorizationWireKey = Exclude<keyof AccountAuthorization, …> plus Record<…, never> fails compile if the generated type widens. Both guards intact.
  • projectAccountAuthorization strips adopter-supplied access_token / refresh_token / internal_role_bindings before toWireAccount / toWireSyncAccountRow — confirmed by the new test cases at test/server-decisioning-to-wire-account.test.js.
  • AUTHORIZATION_REQUIRED.details sanitization runs inside applyAdcpErrorAllowlist before the per-code allowlist step, so every call site (adcpError(), wrapErrorArm, enrichErrorTwoLayer via sanitizePayloadErrors, the content[0].text JSON fallback via syncContentJsonText) filters once. Idempotent across passes — pickSafeDetails on already-filtered objects is a no-op; the equality check in sanitizePayloadErrors skips the L2 resync.
  • get_task_status / list_tasks register on the AdCP protocol namespace, distinct from MCP tasks/gettest/server-create-adcp-server.test.js:1437 proves raw MCP task-store records don't leak through.
  • Cross-tenant assertions at test/server-create-adcp-server.test.js (crossTenant) and test/server-decisioning-from-platform.test.js (acc_attacker) — both exercise the task.accountId !== accountId guard explicitly and assert REFERENCE_NOT_FOUND rather than leaking.
  • parseTaskListCursor /^(0|[1-9]\d*)$/ rejects negative/scientific/float/leading-zero cursors; INVALID_PARAMS returned before Array.slice. Solid.
  • creative-transformers specialism legitimately added to schemas/registry/registry.yaml:1629, AdCPSpecialismValues, SPECIALISM_REQUIREMENTS, the RequiredPlatformsFor conditional, and the creative-domain specialisms array — wire is consistent.
  • list_tasks.tasks[].domain: 'media-buy' | 'signals' collapse in toProtocolTaskListItem is spec-conformant — the rc.7 wire shape pins that field to the two-value literal (src/lib/types/tools.generated.ts ListTasksResponse), while get_task_status.protocol keeps the full 7-value enum. Asymmetry is upstream-shape, not SDK invention.

Follow-ups (non-blocking — file as issues)

  1. Top-level errors[i] fields are not allowlisted for AUTHORIZATION_REQUIRED. The sanitizer covers details only. An adopter handler that returns { errors: [{ code: 'AUTHORIZATION_REQUIRED', message: '…', refresh_token: 'rt_…', upstream_request_id: 'tenant-acme-…' }] } ships the top-level extras to the wire via structuredContent.errors[0] and the content[0].text fallback. Path B's projectPayloadErrorToEnvelope keeps the envelope clean, but the payload errors[] is not. Either register AUTHORIZATION_REQUIRED in ADCP_ERROR_FIELD_ALLOWLIST with the canonical PAYLOAD_ERROR_FIELDS set or filter top-level keys in applyAdcpErrorAllowlist for any recovery-detail code. Add a test in test/server-authorization-required-sanitization.test.js with a top-level refresh_token to lock the contract. The new sanitization is defense-in-depth, not a complete top-level fix — worth tightening before adopters lean on it in production.

  2. TASK_TYPE_TO_PROTOCOL mapping drift vs the SDK's capability-tools grouping. sync_event_sources, sync_audiences, sync_catalogs, log_event get 'measurement' in src/lib/server/create-adcp-server.ts:~923; src/lib/utils/capabilities.ts:237-247 and :306 group them under MEDIA_BUY_TOOLS / EVENT_TRACKING_TOOLS with ['media_buy', 'conversion_tracking'] capability buckets. The rc.7 AdCPProtocol enum legitimately includes 'measurement', so the choice is defensible — but the two source-of-truth tables disagree, and the protocol-namespacing decision deserves an upstream cross-check. Same for sync_accounts / get_account_financials'media-buy' (no 'account' value in the enum, so it's a forced choice). Document the chosen mapping next to the table, or thread the manifest's protocol tag through instead of hardcoding.

  3. creative-transformers lacks adopter prose. The wire types, registry enum, and platform binding all land cleanly, but skills/build-creative-agent/SKILL.md has no creative-transformers § and the CLAUDE.md specialism index isn't extended. Adopters who claim the specialism via get_adcp_capabilities have nothing to read. Fold the transformer story into build-creative-agent (per the RequiredPlatformsFor shape — CreativeBuilderPlatform covers both) and add the row to the CLAUDE.md table.

  4. taskRegistry.list ordering contract is backend-dependent. Postgres returns ORDER BY created_at DESC, task_id DESC (src/lib/server/decisioning/runtime/postgres-task-registry.ts:284); the in-memory impl returns Array.from(tasks.values()).filter(…) unsorted (task-registry.ts:197). The list_tasks handler re-sorts via compareProtocolTaskItems, so end users see consistent pagination — but adopters calling registry.list() directly will see different orderings in tests vs prod. Either sort the in-memory impl on the same key or document explicitly that list() returns unsorted records and callers must sort.

  5. mcpTaskStore = taskStore ?? new InMemoryTaskStore() is a silent behavior shift. Adopters who previously left taskStore unset got undefined propagated into createTaskCapableServer; now an in-memory store is always materialized, which means every server advertises MCP task support and the store grows unbounded for adopters who never intended async tasks. Worth a one-line callout in the changeset and either a taskStore: null opt-out or a gate on whether a task tool actually needs it.

Minor nits (non-blocking)

  1. pickSafeDetails(maxDepth: 4, maxSizeBytes: 4096) doubles the defaults (2 / 2048) at src/lib/server/errors.ts:217-220. Plausibly justified by the rc.7 missing_connections[].resource_ref nesting + free-form URLs/instructions, but a one-line comment naming the justification would stop the next person widening it on reflex.

  2. addCanonicalToolTypeAliases silently skips on ambiguous candidates at scripts/generate-types.ts:1407-1415. When candidates.length !== 1, the alias just doesn't emit. Log a warning on > 1 so a future schema change that introduces a second domain-qualified variant (e.g. GetTaskStatusProtocolRequest + GetTaskStatusMediaBuyRequest) doesn't quietly stop emitting the canonical name.

  3. FEED_FETCH_FAILED and INVALID_FEED_FORMAT retry policies at src/lib/utils/buyer-retry-policy.ts:336-337 are mutate-and-retry with reason: 'validation'. Feed-fetch is upstream-transport; resubmitting the same URL without mutation won't help. INVALID_FEED_FORMAT is a fixed defect, retrying won't help either. Either retry (transient backoff) for fetch or escalate for both — match the spec's recovery semantics.

  4. completed_at = task.updatedAt in toProtocolTaskStatus / toProtocolTaskListItem is a synthesis, not a registry-tracked value. Fine if the registry contract guarantees no further mutation after terminal status, but if the rc.7 spec intends completed_at to be the exact terminal-transition timestamp, consider tracking a discrete completedAt on TaskRecord rather than reusing updatedAt. Notable.

Safe to merge.

aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 4, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups noted below. Allowlist projection on AccountAuthorization, depth-and-size-capped AUTHORIZATION_REQUIRED.details sanitization, and account-scoped task-registry guards are the right shape — fail-closed at every seam, and the test at test/server-authorization-required-sanitization.test.js:139 asserts no secret string crosses to structuredContent.

Things I checked

  • Changeset / version mechanics. .changeset/rc-seven-task-auth.md is patch against 9.0.0-beta.22. package.json version is untouched (line 4); only adcp_version: "3.1.0-rc.7" (line 463) and the sync-schemas:all script flip. LIBRARY_VERSION in src/lib/version.ts:9 is unchanged. The bumps are confined to ADCP_VERSION and COMPATIBLE_ADCP_VERSIONS. Right shape.
  • Protocol coherence (rc.7). ad-tech-protocol-expert: sound. The 22-value TaskType enum is fully covered by ADCP_TASK_TYPES and TASK_TYPE_TO_PROTOCOL in src/lib/server/create-adcp-server.ts:2148-2199 — the ?? 'media-buy' fallback never fires for in-scope tasks. The domain: 'media-buy' | 'signals' collapse on ListTasksResponse.tasks[].domain is the spec's restricted enum at tools.generated.ts:24210, not a SDK truncation. protocol carries the full enum on the sibling GetTaskStatusResponse. creative-transformers is in AdCPSpecialismValues; all 10 new error codes are in ErrorCodeValues.
  • Account-scoped task reads. if (task == null || task.accountId !== accountId) in the get_task_status handler. Pre-check refuses lookup when accountId === undefined, so the undefined !== undefined === false collision path is unreachable. list_tasks returns { tasks: [] } when accountId is missing or taskRegistry.list is unwired. Both fail closed.
  • AUTHORIZATION_REQUIRED sanitization coverage. sanitizePayloadErrors runs in enrichErrorTwoLayer before Path A/B/C branching (create-adcp-server.ts:2604). wrapErrorArm sanitizes the typed errors[] arm before envelope synthesis. authorizationRequiredEscalationContext in buyer-retry-policy.ts:382 runs applyAdcpErrorAllowlist first, then extracts convenience fields — order is right.
  • pickSafeDetails caps. maxDepth: 4 covers details.missing_connections[].resource_ref.identity_id (depth-3 leaf). maxSizeBytes: 4096 is reasonable for an auth-handoff envelope and fails closed (details omitted entirely) when exceeded.
  • SQL safety. ${table} interpolation in postgres-task-registry.ts:276 is gated by the constructor's identifier check; account_id is parameterized via $1.
  • Custom transport / mock data: none. All new handlers go through server.registerTool and genericResponse.

Follow-ups (non-blocking — file as issues)

  1. taskListPageSize NaN handling. src/lib/server/create-adcp-server.ts:2342NaN passes the typeof === 'number' guard, then Math.max(NaN, 1) returns NaN, propagating through Math.min/Math.floor. Result: tasks.slice(0, NaN) yields [] and pagination.has_more is misreported. Add Number.isFinite(pagination.max_results) to the guard.
  2. ACCOUNT_AUTHORIZATION_WIRE_KEYS doesn't drive the projector. src/lib/server/decisioning/account.ts:43 defines the satisfies-checked constant; projectAccountAuthorization at line 894 hard-codes the same four fields by hand. If the spec adds a fifth AccountAuthorization field, the compile-time Exclude check fires, the dev updates the constant — but the projector keeps dropping the new field silently. Iterate the constant in the projector, or rename it to reflect documentation-only intent.
  3. Redundant InMemoryTaskStore construction. create-adcp-server.ts:3697taskStore ?? new InMemoryTaskStore() is double work; createTaskCapableServer already applies the same fallback at tasks.ts. Drop the local ?? and pass taskStore through.
  4. ADCP_ERROR_FIELD_ALLOWLIST.AUTHORIZATION_REQUIRED is unset. src/lib/server/envelope-allowlist.ts has no entry for the code. applyAdcpErrorAllowlist sanitizes details but returns the rest of the payload unchanged. The builder is safe because it accepts only known keys, but sanitizePayloadError runs on hand-rolled typed errors[] arms — a handler that returns errors: [{code: 'AUTHORIZATION_REQUIRED', tenant_id, refresh_token, details: {...}}] will get details sanitized but the top-level secrets pass through. Add the entry; add a top-level secret-key test.
  5. ISO-8601 lexical comparison on buyer-supplied bounds. create-adcp-server.ts:2206-2210localeCompare / <= on created_after/updated_before is sound for Z-normalized records but breaks if a buyer sends +02:00-offset bounds. Normalize via Date.parse before comparison.
  6. field_scopes keys forwarded verbatim. decisioning/account.ts:891-895 allowlists values (must be string[]) but not key names — an adopter who emits internal_admin_override: ['*'] ships that key on the wire. Constrain to canonical AdCP task names.

Minor nits (non-blocking)

  1. compareProtocolTaskItems tiebreak. create-adcp-server.ts:2271localeCompare is spec-permitted to return any integer; the result === 0 check is correct only by coincidence on V8. Today's behavior is fine; worth a one-line comment.

The third drift-cleanup commit message on this branch ("fix request/response schema registration, creative-transformer specialism wiring, protocol capabilities drift") is the sort of thing that argues for a registry-driven generation pass on the next rc bump — a follow-up topic, not this PR.

Approving on the strength of the sanitizer coverage at every emit point plus the protocol-expert read on rc.7 schema alignment.

import type { ResolvedAuthInfo } from './decisioning/account';
import type { TaskRegistry } from './decisioning/runtime/task-registry';
import type { TaskRecord, TaskRegistry } from './decisioning/runtime/task-registry';
import { protocolForTool } from './decisioning/runtime/protocol-for-tool';
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 4, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The rc.7 sync is mechanically right against the generated schemas, and the security work around AUTHORIZATION_REQUIRED sanitization plus ownerScope isolation is the actual deliverable hiding behind the version bump.

Things I checked

  • Version gate is correct. supportsProtocolTaskTools = protocolBundleKey === '3.1.0-rc.7' || protocolBundleKey === '3.1-rc.7' (src/lib/server/create-adcp-server.ts:3478) covers both full-semver and release-precision outputs of resolveBundleKey. Both forms reach this code in production. The 3.0.12-pin test pins this down.
  • ADCP_TASK_TYPES covers the rc.7 enum. All 22 values in TaskTypeSchema (schemas.generated.ts:1522) are in the Set; nothing dropped silently from list_tasks.
  • list_tasks.tasks[].domain collapse is spec-compliant, not a fidelity loss. ListTasksResponseSchema.tasks[].domain in rc.7 is literally media-buy | signals (schemas.generated.ts:4258). The protocol === 'signals' ? 'signals' : 'media-buy' projection at create-adcp-server.ts:2244 is what the spec asks for.
  • AccountAuthorization projection is exact. AccountAuthorizationSchema is exactly {allowed_tasks, field_scopes, scope_name, read_only}. The [...ACCOUNT_AUTHORIZATION_WIRE_KEYS].sort() === Object.keys(AccountAuthorizationSchema.shape).sort() test (test/server-decisioning-to-wire-account.test.js:22) is the right drift guard.
  • taskOwnerScopeForContext and taskOwnerScopeFor agree byte-for-byte. Same priority ladder (session → agent_url → http_sig → oauth → api_key → clientId → account) in both create-adcp-server.ts:2329 and from-platform.ts:2695. No cross-scope read possible today.
  • Cross-tenant isolation holds. Test does not leak tasks between credentials that resolve to the same account (create-adcp-server test:14998) confirms an api_key:buyer-2 caller can't see a api_key:buyer-1 task on the same shared account.
  • Validation order on the new tools fails closed. Credential-policy → request validation → bounds → version → account resolution runs before any registry call (create-adcp-server.ts:5786-5800). No unsanitized payload reaches taskRegistry.getTask / list.
  • pickSafeDetails recurses resource_ref with the same allowlist. The private_note strip in the sanitization test is real — filterByAllowlist drops any key not in the 19-key set at every depth ≤4.
  • 'Task failed' fallback at from-platform.ts:2950 is deliberate hardening. Prevents taskFnError.message (upstream HTTP bodies, DB error strings, stack traces) from being persisted into task error envelopes when the throw isn't an AdcpError.

Follow-ups (non-blocking — file as issues)

  1. Changeset understates the behavior changes. The prose calls out ownerScope persistence. It does more than that: (a) tasks_get registry-resolution failures now return SERVICE_UNAVAILABLE instead of warn-and-fall-through (from-platform.ts:2057-2061); (b) malformed handler-returned errors[] arms now fail closed as VALIDATION_ERROR instead of warn-and-wrap (create-adcp-server.ts:4907-4933); (c) sanitizePayloadError projects standard-code envelopes through an allowlist, dropping adopter-supplied top-level fields (create-adcp-server.ts:2677). All three are directionally right; adopters who relied on either pre-upgrade surface deserve a paragraph in the changeset.
  2. Legacy NULL owner_scope rows + shared-account pre-upgrade tenants. postgres-task-registry.ts:264 exposes pre-upgrade rows to any caller whose post-upgrade scope resolves to account:${accountId}. The changeset disclaims this. For deployments that previously multiplexed principals through a shared bearer with no clientId attribution, a backfill migration (owner_scope = 'legacy:pre-3.1', treated as non-listable) would close the window. Worth a note in docs/migration-*.md for shared-account adopters.
  3. taskOwnerScopeForContext and taskOwnerScopeFor are byte-identical twins. They agree today. Extract to a shared util in src/lib/server/decisioning/runtime/task-ownership.ts and import from both call sites — drift between these would silently flip cross-tenant isolation.
  4. projectAccountAuthorization is shallow. [...authorization.allowed_tasks] / [...fields] (account.ts:889) trust the TypeScript element type. An adopter mistakenly populating allowed_tasks: [{token: '...'}] would echo the object untouched. Filter each array to typeof item === 'string'.
  5. accountResolutionAttempted is plumbed but never destructured. Five return branches in resolveTaskQueryAccountId compute the flag; the two call sites only destructure {accountId, ownerScope, error} (create-adcp-server.ts:5799, 5874). Drop the field from the return type or wire it into dispatcher diagnostics.
  6. Verify error.details flatten against the rc.7 spec. from-platform.ts:2213 spreads {recovery, field, suggestion, retry_after} and adopter details into the top-level error.details. Inline comment claims rc.7 conformance; worth confirming against adcontextprotocol/adcp@v3.1.0-rc.7:tasks-get-response.json before GA.
  7. mcpTaskStore pre-default is a no-op. createTaskCapableServer already does options?.taskStore ?? new _InMemoryTaskStore() (src/lib/server/tasks.ts:143). The new local binding at create-adcp-server.ts:3777 is observably equivalent — fold back to taskStore.

Minor nits (non-blocking)

  1. taskTimestampMatches is half-open. create-adcp-server.ts:2314 uses strict > / < on both *_after and *_before. Spec convention is typically inclusive (>= / <=) — confirm against rc.7 list-tasks-request.json or drop a one-line comment justifying half-open.
  2. compareProtocolTaskItems silently treats unknown sort.field as created_at. create-adcp-server.ts:2350. Request-schema validation catches this when on, but defaults to 'off' in production. A logger.warn would surface drift in adopter logs.

The changeset is minor, which matches the wire impact (additive tools, new optional fields, new error codes). No breaking-shape changes on stable surfaces.

Ship it once you've extended the changeset prose to cover the three behavior changes in follow-up #1 — that paragraph is the only thing standing between this PR and a quiet upgrade for adopters who haven't been reading line diffs.

aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Jun 4, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Tight schema bump with the right shape: AdCP ownerScope lives on the application-layer registry (not the MCP transport TaskStore), the AccountAuthorization projection has a compile-time coverage check, AUTHORIZATION_REQUIRED.details runs through a key-allowlist + size/depth budget, and the dispatcher now fails closed on malformed Error arms instead of warn-and-passthrough.

Things I checked

  • Version gate. src/lib/version.ts already at 3.1.0-rc.7 with '3.1.0-rc.7' in COMPATIBLE_ADCP_VERSIONS; scripts/sync-version.ts extended; no drift against root ADCP_VERSION. Premise that version.ts was un-regenerated is wrong — sync ran.
  • Type coverage. ADCP_TASK_TYPES (create-adcp-server.ts:2156) matches the rc.7 TaskType enum 1:1. MUTATING_TASKS auto-derives from required idempotency_key fields so the three new RO tools correctly omit themselves and update_rights correctly lands.
  • AccountAuthorization projection. ACCOUNT_AUTHORIZATION_WIRE_KEYS + MissingAccountAuthorizationWireKey: Record<…, never> (src/lib/server/decisioning/account.ts:36-46) catches drift at compile time. Allowlist is exactly the four rc.7 wire fields; field_scopes per-entry Array.isArray filter drops shape-shifted adopter values.
  • Owner-scope ladder fails closed. taskBelongsToCaller returns false when ownerScope === undefined; in-memory list() returns { tasks: [] } on undefined scope; Postgres list() mirrors. Legacy ownerless rows readable only via the account:${accountId} fallback owner scope, exactly as the changeset documents.
  • Dispatcher fail-closed. create-adcp-server.ts:4901-4934 — malformed Error arms now emit a hardcoded VALIDATION_ERROR payload; the malformed result.errors is never re-serialized into the response. No leak path.
  • MCP wiring. Both new tools register with PASSTHROUGH_INPUT_SCHEMA, omit outputSchema (per the existing banner at L103-115 about the SDK client-side validator bug), and route through finalizeProtocolTaskToolResponse. Coexists with the tasks_get MCP-safe alias without collision.
  • Protocol coherence. list_tasks.tasks[].domain correctly narrows to 'media-buy' | 'signals' per the rc.7 schema's query_summary.domain_breakdown shape (governance/creative/brand collapse to media-buy is spec-mandated, not SDK choice).
  • AUTHORIZATION_REQUIRED sanitizer. 19-key allowlist + pickSafeDetails(maxDepth: 4, maxSizeBytes: 4096); reused verbatim in buyer-retry-policy.ts authorizationRequiredEscalationContext so the UX-handoff path applies the same filter.
  • No SQL/replay/idempotency regressions. Postgres list is parameterized; HTTP-sig agent_url server-derived from JWKS; no manual package.json version edit; changeset present with minor bump (correct — additive + behavior-change call-out for custom registries).

Follow-ups (non-blocking — file as issues)

  1. protocol-for-tool.ts missing update_rights and list_transformers. src/lib/server/decisioning/runtime/protocol-for-tool.ts:68-89. This PR adds search_brands: 'brand' but skips update_rights: 'brand' and list_transformers: 'creative'. Both fall through to the 'media-buy' default in protocolForTool(). update_rights is a brand-domain mutating tool added to MUTATING_TASKS and BRAND_RIGHTS_TOOLS in this PR — its lifecycle webhook payload's protocol field will read 'media-buy', silently misrouting brand-rights subscribers that filter by protocol. Two-line fix.
  2. supportsProtocolTaskTools hard-equality version gate. create-adcp-server.ts:3482protocolBundleKey === '3.1.0-rc.7' || protocolBundleKey === '3.1-rc.7'. Silently disables get_task_status / list_tasks registration on rc.8 / GA / any later pin. Replace with compareAdcpVersion(protocolBundleKey, '3.1.0-rc.7') >= 0 so future bumps inherit the surface.
  3. AUTHORIZATION_REQUIRED not in ADCP_ERROR_FIELD_ALLOWLIST. src/lib/server/envelope-allowlist.tssanitizeAdcpErrorDetails filters payload.details, but applyAdcpErrorAllowlist then has no entry for AUTHORIZATION_REQUIRED and lets every top-level field pass through. Hand-rolled adopter envelopes ({ code: 'AUTHORIZATION_REQUIRED', refresh_token: '…', tenant_id: '…' }) bypass the dispatcher's defense-in-depth re-sanitizer. Register the standard { code, message, recovery, field, suggestion, retry_after, issues, details } allowlist so the doctring's intent matches the code.
  4. tasks_get ownership skip when both resolvedAccountId and record.accountId are falsy. src/lib/server/decisioning/runtime/from-platform.ts:2148-2179 — the two boundary checks gate on truthiness; if a custom TaskRegistry ever returns accountId: '', ownership skips. Built-in registries (in-memory + Postgres NOT NULL) can't hit this, but the defense-in-depth fix is if (resolvedAccountId === undefined) return REFERENCE_NOT_FOUND regardless of the task's recorded accountId.
  5. Owner-scope clientId === 'unknown' collapse. create-adcp-server.ts:2341-2342 and from-platform.ts:2702-2706. auth.ts:511 sets authInfo.clientId = 'unknown' when neither client_id nor sub is extractable from an OAuth token. Non-empty string → derives client:unknown for every such caller. Multiple distinct buyers whose JWTs lack both fields collapse onto the same owner scope. Treat the 'unknown' sentinel as missing in the owner-scope derivation, or drop authInfo.clientId when principal === 'unknown'.
  6. Duplicated owner-scope helpers. taskOwnerScopeForContext (create-adcp-server.ts:2287-2301) and taskOwnerScopeFor (from-platform.ts:2692-2706) are byte-identical in logic and priority. Currently in sync; future edits will silently desync and the symptom is buyers 404-ing on their own tasks. Centralize into one export in task-registry.ts.
  7. Changeset undersells two behavior changes. .changeset/rc-seven-task-auth.md covers the ownerScope migration but skips: (a) tasks_get / get_task_status / list_tasks now surface SERVICE_UNAVAILABLE on buyer-agent-registry resolution failures (was: swallow + fall through), (b) dispatcher returns VALIDATION_ERROR for handler-returned Error arms with raw-string / missing-code-or-message items (was: warn + passthrough). Both are correct improvements; adopters running with raw-string error returns or relying on the silent-degrade poll path need the heads-up.
  8. include_history: true → INVALID_REQUEST. create-adcp-server.ts:3953 — spec marks history optional on the response and include_history freely settable on the request. Buyers passing it defensively to "ask politely" get a hard fail. Silent no-op (omit history) is the buyer-friendly read.
  9. context_contains haystack overreach. create-adcp-server.ts:2272-2275 — seed array includes item.task_type, item.status, item.domain, so context_contains: 'completed' matches every completed task. Spec text frames the field as identifier search; drop the non-id fields from the seed and lean on the existing (_)?(id|ids|ref|refs)$ walker.
  10. measurement protocol gating. create-adcp-server.ts:6143-6152 auto-adds 'measurement' to supported_protocols only when experimental_features includes 'measurement.core'. Worth checking whether schemas/cache/3.1.0-rc.7/enums/adcp-protocol.json actually marks the measurement member x-status: experimental. If stable, drop the experimental gate.
  11. Postgres ALTER TABLE runs every boot. postgres-task-registry.ts schema migration around L175 — ADD COLUMN IF NOT EXISTS owner_scope runs inside ensureSchema on every server start. Fine for Postgres ≥9.6; one-line comment justifying the runtime migration vs a one-shot script would prevent the next reviewer from flagging it as a smell.

Minor nits (non-blocking)

  1. Dead accountResolutionAttempted field. create-adcp-server.ts resolveTaskQueryAccountId returns it; neither get_task_status nor list_tasks destructures it. Either wire it into unauthenticated-vs-anonymous diagnostics or drop it.
  2. PROTOCOL_TASK_TYPE_TO_PROTOCOL typed Partial<Record<TaskType, ...>>. create-adcp-server.ts:2181. A future TaskType addition silently maps to the ?? 'media-buy' fallback. Tighten to total Record<TaskType, WireAdcpProtocol> so the next rc fails the build instead.
  3. sanitizePayloadError overwrites seller-supplied recovery. create-adcp-server.ts:2680 — for standard codes the function forces recovery = STANDARD_ERROR_CODES[code].recovery. Adopters who legitimately mark a standard-code error as recovery: 'transient' will see it rewritten. Skip the rewrite when the seller's value is a valid ErrorRecovery member.
  4. extra.authInfo.credential in-place hoist. create-adcp-server.ts:4049-4051 and from-platform.ts:2014-2016. The shared-session sticky-credential mutation is intentional (idempotent guard, sticks for downstream tools in the same session), but it reads as a clone-bug at first glance. One-line comment at both sites prevents a drive-by "should be a clone" PR.

Safe to merge.

@aao-ipr-bot

aao-ipr-bot Bot commented Jun 4, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley bokelley merged commit a33f57c into main Jun 4, 2026
31 checks passed
@bokelley bokelley deleted the v3-1-0-rc-7-release branch June 4, 2026 19:26

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Real hardening — owner-scoped task polling, AUTHORIZATION_REQUIRED detail allowlist, fail-closed on malformed errors[] arms — backed by tests that actually exercise cross-tenant isolation (test/server-create-adcp-server.test.js:14843-14900 runs two distinct api_key: buyers against the same registry and asserts the second buyer gets REFERENCE_NOT_FOUND on the first's task). Follow-ups below; none block.

Triage: source-code PR → ran code-reviewer + ad-tech-protocol-expert + security-reviewer in parallel. No expert returned a High / Critical / unsound verdict.

Things I checked

  • Largest-file rule: read src/lib/server/create-adcp-server.ts (+895/-16) and src/lib/server/decisioning/runtime/from-platform.ts (+186/-27) end-to-end. taskBelongsToCaller (create-adcp-server.ts:2316-2321) enforces both accountId match AND ownerScope match, with the documented legacy-row fallback (record.ownerScope === undefined → expectedOwnerScope === 'account:${accountId}'). Same logic in from-platform.ts:2162-2179 for tasks_get. Read/write halves are byte-identical — see follow-ups.
  • Changeset audit: .changeset/rc-seven-task-auth.md exists, typed minor. SDK is on 9.0.0-beta.22 so prerelease semver applies. Narrative is honest about the three behavior reversals (registry-resolution → SERVICE_UNAVAILABLE, malformed errors[] → VALIDATION_ERROR, allowlist projection of standard-code envelopes). On a GA train I'd push for major; on a beta train with this much disclosure, fine.
  • Protocol coherence: ad-tech-protocol-expert returned sound-with-caveats. rc.7 tool wiring matches generated PROTOCOL_TOOLS and WIRE_SPEC_FIELDS. AccountAuthorization projection allowlist (allowed_tasks, field_scopes, scope_name, read_only) matches the rc.7 schema. Nine new standard codes (BUDGET_CAP_REACHED, CREATIVE_INACCESSIBLE, EVALUATOR_AGENT_NOT_ACCEPTED, UNPRICEABLE_OUTPUT, SIGNAL_TARGETING_INCOMPATIBLE, FEED_FETCH_FAILED, INVALID_FEED_FORMAT, ITEM_VALIDATION_FAILED, CATALOG_LIMIT_EXCEEDED) land in enums.generated.ts with consistent recovery classifications.
  • Security: security-reviewer returned Medium/Low only — no High. Real cross-tenant isolation tests at test/server-create-adcp-server.test.js:14843-14900. AUTHORIZATION_REQUIRED sanitization test (test/server-authorization-required-sanitization.test.js) asserts access_token, refresh_token, tenant_id, private_note are dropped at every nesting level, with a final doesNotMatch(/secret_should_not_cross_wire|internal_tenant_1/) belt-and-suspenders check.
  • Witness invariant: toProtocolTaskStatus / toProtocolTaskListItem / projectAccountAuthorization faithfully map registry/adapter state without fabricated upstream fields. The synthesized completed_at: task.updatedAt and the synthesized error shape from statusMessage are framework-owned fields, not buyer-supplied — acceptable, but see follow-up on TASK_STATUS_MESSAGE.
  • Test-plan honesty: every checklist item in the PR body's Validation section is marked done. No unchecked manual-verify path for the primary user-facing change.
  • Postgres migration: ADD COLUMN IF NOT EXISTS owner_scope TEXT is metadata-only (no rewrite). Follow-up below on the non-concurrent index.

Follow-ups (non-blocking — file as issues)

  • Sanitize details on more than just AUTHORIZATION_REQUIRED. errors.ts:217 gates the sanitizer on code === 'AUTHORIZATION_REQUIRED'. code-reviewer and security-reviewer both flagged this. Adopters who put credential-shaped data in a SERVICE_UNAVAILABLE / UPSTREAM_REJECTED / custom-code details block — including the dispatchHitl path that persists task.error for replay through get_task_status — get verbatim passthrough. Extend sanitizeAdcpErrorDetails to apply a conservative default budget (depth + size + redact-pattern-scrub on message) to all standard codes.
  • Custom-code details passes through unfiltered. sanitizePayloadError in create-adcp-server.ts:2680-2700 returns the input unchanged for codes that are neither in STANDARD_ERROR_CODES nor ADCP_ERROR_FIELD_ALLOWLIST. A handler returning {code:'VENDOR_X_FAIL', details:{access_token:'…'}} round-trips the secret. Default to the same minimal {code, message, recovery, field, suggestion, retry_after} projection for unrecognized codes.
  • Two byte-identical implementations of taskOwnerScopeFor*. from-platform.ts:2685 and create-adcp-server.ts:2329 are the write and read halves of the same security primitive. Drift between them silently breaks polls. Extract to a single helper, import in both places, add a unit test that asserts equivalence across http_sig / oauth / api_key / clientId / no-credential matrix.
  • ownerScope collision on empty credential subfields. Neither helper checks that credential.agent_url / credential.client_id / credential.key_id is a non-empty string. An adopter authenticator that produces {kind:'http_sig', agent_url:''} collapses every caller to http_sig:. Reuse isVerifiedHttpSigPayload-style guards (already present at src/lib/server/decisioning/buyer-agent.ts:377).
  • TASK_STATUS_MESSAGE is an invented code. create-adcp-server.ts:2231 synthesizes error.code: 'TASK_STATUS_MESSAGE' for non-failed tasks carrying a statusMessage. ad-tech-protocol-expert couldn't verify this in the rc.7 StandardErrorCode union. If it isn't there, strict response validation will catch it (and downgrade to VALIDATION_ERROR), but loose mode ships a non-spec code on the wire. Either drop the field when task.error is absent, or land the code in upstream error-codes.json first.
  • Non-CONCURRENT CREATE INDEX on populated task tables. postgres-task-registry.ts:184-186 runs CREATE INDEX IF NOT EXISTS idx_${table}_owner_account without CONCURRENTLY. Holds ACCESS EXCLUSIVE on the table for the duration of the build — minutes on a multi-tenant SaaS with millions of historical task rows. Existing indexes in the same migration follow this same convention, so this PR isn't introducing the pattern, but it's worth a getDecisioningTaskRegistryMigration JSDoc warning and an opt-in CONCURRENTLY helper for adopters with large task tables.
  • Legacy ownerless rows readable through account:${accountId} fallback. code-reviewer flagged this as a Major, security-reviewer characterized it as pre-PR baseline (no isolation existed before). Both are right. The narrative in the changeset already covers it. Worth a follow-up to consider denying the fallback when ownerless rows exist in the registry — fail-closed beats fail-open.
  • Version gate === '3.1.0-rc.7' || '3.1-rc.7' is intentionally narrow. Correct for an RC, but at 3.1.0 GA the new task tools will silently disappear from the wire until the gate widens. File the follow-up now so it's not forgotten.

Minor nits (non-blocking)

  1. 'Task failed' is unhelpful for ops. from-platform.ts:2956 flattens non-AdcpError throws to the bare string. Log err.message at the same call site (opts.logger.error) so adopters can correlate the persisted task error against ops logs even though the buyer never sees the raw message.
  2. list_tasks registration asymmetry. create-adcp-server.ts:5894 requires taskRegistry.list !== undefined; get_task_status doesn't. Buyer calling list_tasks against a custom registry that omits list gets MCP "method not found" instead of an adcpError('NOT_SUPPORTED', …). Inconsistent with how the rest of the framework signals capability gaps.

Safe to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant