Accept campaign channels and promoted subject links

SI
7d ago
+260-13

Summary

Moves the ingest_campaign_brief brief shape into a dedicated lib/context/campaignBriefSchema.ts and adds the two fields this ticket needs: optional channels and optional promoted. channels takes up to ten distinct names of 1–60 characters, so X counts. promoted takes up to twenty distinct {request_id, subject_id} UUID pairs. Channel names follow the same rules as the database: inner whitespace is collapsed, case is ignored, control characters are rejected, and obvious links are rejected: scheme URLs, URI schemes (mailto:, spotify:, file:), www. hosts, and absolute, home or drive paths. The check is not a full URL or file detector: a bare example.com/release or plan.pdf passes as a name, and a test pins that documented boundary. Channel names are never fetched or linked.

The schema rejects ambiguous links and private file references before authorization and before any database call, with a 400 and an explicit message. That covers unknown keys such as promoted_url, file_path or assets, obvious link or path strings in channels, any non-pair value in promoted, non-UUID identifiers, extra keys on a promoted entry, duplicates and oversized arrays. Goal and audience stay free text.

Accepted pairs are forwarded unchanged to the existing create_context_campaign_brief_request RPC. The RPC checks each pair against requests the workspace already saved and stores it as an unverified promoted_candidate. A legacy brief without the new fields is forwarded exactly as before. There is still no workflow dispatch, and the RPC allow-list is untouched. The MCP tool description and the Context README now say that links are candidates, not ownership or rights. This covers Done-when item 1 (channels and links to promoted records) and the boundary tests in item 3.

Refs recoupable/app#2138 (child of epic recoupable/app#2116).

Merge sequencing

docs → database → api → app. Merge after recoupable/docs#368 and recoupable/database#108 (both on branch claude/ctx-2138-campaign-promoted-links). recoupable/database#108 carries 20261010213800_context_campaign_brief_promoted_links.sql. If the database has not applied that migration, this change does nothing new: the v1 function rejects channels/promoted with Invalid campaign brief, and unlinked briefs keep working. No app change is needed, and this PR does not depend on any open PR.

Conflict notes: processContextOperation.ts changes only the inline brief: object (one import and one line). contextToolOperations.ts changes only the campaign entry and README.md only the campaign bullet. callContextRpc.ts is not touched, so overlap with open api#981/#983/#985 stays trivial.

Relation to api#983 (attach_evidence): this slice adds no association table. It reuses the {request_id, subject_id} target shape so supplied materials can be attached through #983 once it is approved.

Verification

  • Ran (head 4de1247): pnpm exec vitest run lib/context lib/mcp/oauth/__tests__/contextToolOperations.test.ts → 69 test files passed, 8 skipped (live suites); 337 tests passed, 8 skipped, 0 failed. The MCP test confirms every operation schema still converts with z.toJSONSchema.
  • Ran: pnpm exec vitest run lib/context → 68 test files passed, 8 skipped; 336 tests passed, 8 skipped, 0 failed.
  • Ran: pnpm exec tsc --noEmit → 199 errors, all in untouched files (mostly stale test mocks under lib/agents, lib/credits, lib/messages, lib/chats and similar). None are in the six changed files. The final reviewer measured 202 on a copy with these files restored to origin/main, so there are no new type errors.
  • Ran: pnpm exec eslint on lib/context/campaignBriefSchema.ts lib/context/processContextOperation.ts lib/context/__tests__/campaignBriefOperation.test.ts lib/context/__tests__/campaignBriefSchema.test.ts lib/mcp/oauth/contextToolOperations.ts → clean. pnpm exec prettier --check on the same files plus lib/context/README.md → clean.
  • Red → green, commit 1: with origin/main's processContextOperation.ts and no campaignBriefSchema.ts, campaignBriefSchema.test.ts failed to load (Cannot find module '../campaignBriefSchema'). The case "forwards channels and already-saved promoted subjects to the database without dispatch" failed with ZodError … Unrecognized keys: "channels", "promoted".
  • Red → green, commit 2: "requires distinct bounded channels and promoted pairs" failed with Expected rejection: …"channels":["Short form video","short form video"] until whitespace was collapsed before comparing and control characters were rejected.
  • Red → green, commit 3: three schema cases failed. ["X"] was rejected by min(2), and mailto:team@example.com and C:\plans\brief.pdf were accepted. All pass after the channel-rule change.
  • Commit 4 changes wording only, plus the unused CampaignBrief type export. Its new test, "catches obvious links only, as documented", passes before and after: it pins the documented limit rather than new behaviour.
  • Privacy: tests use only synthetic UUIDs (00000000-0000-4000-…) and placeholders in the style of example.com and /private/artwork.png.
  • Not verified: hosted deployment (the automatic Vercel preview build is not a live check); live providers (none are called); the production database; end-to-end HTTP or MCP calls against a database with the companion migration applied; pnpm build (not run, per repository agent rules).

Review follow-ups

  • Addressed: channels accept one-character names such as X; the README and MCP wording now limit the URL/file rule to channels and promoted entries; the schema test UUIDs contain hex letters, so the upper-case duplicate check now tests case normalization.
  • Addressed (final review): the README, MCP description and schema comments said raw URLs and file paths were rejected generally, which overstated the channel check (cubic raised bare domains). They now say obvious links only, and a test pins the boundary. Removed the unused CampaignBrief type export. The README documents that a same-key retry of a saved linked brief replays after a target request is cancelled (database#108).
  • Not adopted: rejecting bare domains or relative paths in channel names. A heuristic such as rejecting any / would also reject plausible names (for example TV/radio), and it would have to change in lockstep with the database. promoted accepts only UUID pairs and channel names are never fetched, so the wording was narrowed instead.

Out of scope / known gaps

  • Supplied-material inventory and permitted use (Done-when item 2) are deferred, either to attach_evidence (api#983 / database#90) once approved or to a later slice. Sibling #2140 owns material intake.
  • Also not included: URL-based promoted links with provider verification; campaign planning (plan still reports campaign context as unimplemented); campaign_brief in compileContextBrief recipes; inspector rendering; chat UI.
  • The API checks only the shape of promoted targets and does not pre-read them. The database alone checks them against saved workspace requests. If it rejects a target, the caller sees the existing generic 409 response.
  • Channel names with a scheme-like prefix and no space, such as TikTok:Spark, are rejected on purpose as possible links.
  • The response type cast for request.input.brief still describes the submitted shape. The stored promoted entries also carry kind, assertion and ownership_verified.
  • No hosted or live verification of any kind.

🤖 Generated with Claude Code