Link campaign briefs to saved promoted subjects

SI
7d ago
+394-0

Summary

Extends create_context_campaign_brief_request so a campaign brief can name channels and the already-saved workspace subjects it promotes. This is Done-when item 1: channels and links to promoted records.

Channels. Names are normalized and limited to ten, each 1–60 characters, so X is accepted. They must be distinct ignoring case. Control characters are rejected, and so are obvious links: scheme URLs, URI schemes (mailto:, spotify:, file:), www. hosts, and absolute, home or drive paths. This is not a full URL or file detector: a bare example.com/release or plan.pdf passes as a name. Channel names are never fetched or linked.

Promoted links. Each promoted entry is a {request_id, subject_id} pair. It must point to an artist, recording, release or catalog subject listed in the output of a completed or partial Context request owned by the same workspace. It is stored as {request_id, subject_id, kind, assertion: 'promoted_candidate', ownership_verified: false}. Links are checked only against records Recoup has already saved; nothing fetches a URL. Nothing infers ownership or rights, and no site is generated (item 3).

Rejections. These raise Invalid campaign brief: raw URL, file path and asset keys; non-UUID ids; duplicates; oversized arrays; and a missing brief, name, goal or link id. Those last checks are now null-safe with IS DISTINCT FROM, which fixes the case where a brief without name was saved with a null name. Unknown, cross-workspace, cancelled or wrong-kind targets raise Invalid promoted subject. In every case nothing is written.

Compatibility. Briefs without the new keys, or with empty arrays, keep the campaign-brief-v1 fingerprint, so requests saved by the previous function replay unchanged under their key. Linked briefs use campaign-brief-v2. A retry of an already-saved linked brief under the same key reuses the stored link kinds when the stored request is completed, partial or cancelled, so it returns the stored request even if a target request was cancelled after the first save. The fingerprint check still rejects a changed brief, and a new key re-checks every target. Output gaps are now explicit and separate: Promoted subject ownership and rights not verified, Supplied assets not inventoried, and No promoted subjects linked when nothing is linked. list_context_campaign_request_target is unchanged. It still matches because the request input and the result snapshot share the same normalized brief.

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

Merge sequencing

docs → database → api → app. Merge after recoupable/docs#368 and before recoupable/api#1020 (both on branch claude/ctx-2138-campaign-promoted-links). This PR does not depend on any open PR.

The migration only create or replaces the function and re-issues its revoke/grant. It does not touch the kind/resource constraints that 20260925130000_context_supporting_text_entry.sql last rewrote, so ordering among already-applied files is safe.

Relation to open database#90 / api#983 (attach_evidence relevance-only associations): this slice creates no second association table and no context_evidence_* objects. Promoted candidates live inside the brief's own normalized input/result JSON and reuse the same {request_id, subject_id} target shape, so materials can be attached through #90 once it is approved.

Sibling tickets #2139 and #2141 may add subject kinds. The allowed promoted kinds are hard-coded to artist/recording/release/catalog; extend that list later rather than loosening it now.

Verification

  • Ran (head 87872f8): export PATH="/opt/homebrew/opt/postgresql@17/bin:$PATH" LC_ALL=C && python3 -m unittest discover -s tests -p 'test_campaign_brief_links.py' → Ran 3 tests … OK: the two new tests plus the inherited release-cases lifecycle test.
  • Ran: python3 -m unittest discover -s tests -p 'test_release_cases.py' → Ran 1 test … OK, and python3 -m unittest discover -s tests -p 'test_company_assessment.py' → Ran 3 tests … OK. Both unchanged.
  • Ran: grep -c 'test_campaign_brief_links.py' .github/workflows/release-cases.yml → 1. The GitHub Actions release-cases jobs on PostgreSQL 15 and 16, which run this suite, passed on the previous head a19e83f and again on head 87872f8.
  • Red → green, commit 1: with the fixture and runner in place but no 20261010213800_… migration, test_campaign_brief_channels_and_promoted_candidate_links failed with ERROR: Invalid campaign brief, while the compatibility test passed.
  • Red → green, commit 2: the new fixture cases first failed against the previous migration. ["X"] was rejected; mailto:team@example.com was accepted (TEST_FAILURE: malformed brief accepted); and a brief without name was accepted and saved. All pass after the fix.
  • Red → green, commit 3: the new completed-target and retry cases first failed against the previous migration with ERROR: Invalid promoted subject, raised by the same-key retry after the target request was cancelled. The completed-target save itself already passed, so that path was correct but untested. All 3 tests pass after the fix.
  • Harness notes: the runner applies the catalog, songwriter, company, campaign-brief and supporting-text Context migrations on top of the release-cases fixture. The catalog entry migration references the legacy catalogs / account_catalogs tables, so the runner creates stand-ins with only their shape. Locally the postmaster needed LC_ALL=C; otherwise it exits with "postmaster became multithreaded during startup". CI's Ubuntu runner has a valid locale.
  • Privacy: fixtures use only synthetic accounts, gen_random_uuid(), example.com and repeat('C',22) placeholder resource ids.
  • Not verified: production or any hosted database (this work applied the migration only to disposable local PostgreSQL; the repository's automatic Supabase Preview integration builds a preview branch on push and is not production verification); PostgreSQL 15/16 locally (CI covers them); live API or MCP callers; replay of production data; hosted deployment; live providers.

Review follow-ups

  • Addressed: null-safe required-field and link-id checks; URI-scheme and drive-path channel rejection.
  • Addressed (final review): a linked brief's retry no longer re-checks targets that were verified at first save, so a cancelled target no longer turns a same-key retry into a 409; a completed target request is now covered by the fixture; the channel comments and this body state that only obvious links are rejected.
  • 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 API. promoted accepts only UUID pairs and channel names are never fetched, so the wording was narrowed instead.
  • Not adopted: splitting the function to keep the migration under 100 lines. It keeps the single-function shape of the merged 20260925120000_context_campaign_brief_entry.sql it replaces, so the two read side by side.

Out of scope / known gaps

  • Supplied artwork/audio/video/copy/document inventory with source and permitted use, and the missing-material report (Done-when item 2), are deferred, either to attach_evidence (database#90 / api#983) once approved or to a later slice. Sibling #2140 owns material intake.
  • Also not included: URL-based promoted links with provider verification; a campaign planning module; campaign_brief in the brief compiler recipes; inspector rendering of promoted candidates; chat UI.
  • Channel names with a scheme-like prefix and no space, such as TikTok:Spark, are rejected on purpose as possible links.
  • No hosted deployment or live trial.

🤖 Generated with Claude Code