Link campaign briefs to saved promoted subjects
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, andpython3 -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 Actionsrelease-casesjobs on PostgreSQL 15 and 16, which run this suite, passed on the previous heada19e83fand again on head87872f8. - Red → green, commit 1: with the fixture and runner in place but no
20261010213800_…migration,test_campaign_brief_channels_and_promoted_candidate_linksfailed withERROR: 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.comwas accepted (TEST_FAILURE: malformed brief accepted); and a brief withoutnamewas 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_catalogstables, so the runner creates stand-ins with only their shape. Locally the postmaster neededLC_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.comandrepeat('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
completedtarget 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 exampleTV/radio), and it would have to change in lockstep with the API.promotedaccepts 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.sqlit 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_briefin 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