UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

922 lines • 48 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * Recordings Tools — MCP tools for managing recordings. * * ADR-149 Phase 5b: Consolidated to query+inspect pattern. * - pica_recordings_query replaces pica_recordings_list + pica_recordings_by_work * - pica_recordings_inspect replaces pica_recordings_get (with sections support) * * ADR-174 Phase 2 item 3 reconciliation: the write surface covers only * real columns on `public.recordings`. Phantom fields previously * accepted via `additionalProperties: true` are gone; agents that * tried to set them silently dropped data on the way to Postgres. * * Two pre-flight surprises documented inline below: * * 1. `title` is a legacy alias for `recording_title`. The HTTP route * translates before the service sees the payload (POST since the * route was written, PATCH only since ADR-174). The tool keeps * `title` as the input field name for caller ergonomics; the * description marks the rename explicitly. * * 2. Duration lives in `duration_ms` on recordings (works carry * `duration_seconds`). Agents conflating the two get a 400 with * a redirect from the route allow-list. * * Add-candidates surfaced per the audit at * docs/audits/2026-04-14-mcp-db-reconciliation.md §3: * duration_ms, preview_url, album_art_url, spotify_url, * spotify_track_uri, spotify_track_id, youtube_video_id, youtube_url, * apple_music_url, deezer_track_id. * * ADR-282: per-society fields (ppl_registered, ppl_recording_id, * mlc_recording_id, mlc_song_code) have been replaced by the generic * society_registrations array so no single society is privileged. * * Most of these are populated by the enrichment cascade on streaming * import; exposing them lets agents seed or override that data * deliberately rather than discovering — surprised — that the field * they thought they set was silently absent. * * `isrc_prefix` is intentionally NOT in this set — column is GENERATED * ALWAYS in `public.recordings` (left(replace(isrc,'-',''),5)). Caught * by ADR-198 Phase 1 contract — every write attempt 500'd with PG * 428C9 "cannot insert non-DEFAULT value into generated column". */ import { EntityNotFoundError, ApiError, currentCatalogue, } from "@withpica/mcp-sdk"; import { formatProposedChange, readChangeOutcome, } from "./relationship-change.js"; import { formatSuccess, formatDeletion, formatStructuredList, pushSectionError, } from "@withpica/mcp-utils"; import { buildRecordingAttributionHints } from "./recording-attribution-hints.js"; import { buildIntegrityPressure } from "./integrity-hints.js"; import { MEASURED_AUDIO_BASIS, toMeasuredAudio } from "./measured-audio.js"; /** * Canonical write-schema for `recordings`. Every property must * correspond to a real column on `public.recordings` (or be the * documented `title` legacy alias) — verified against * `lib/db/database.types.ts`. The HTTP route at * `app/api/admin/recordings/route.ts` carries the same allow-list * (plus the `recording_type` legacy alias for non-MCP callers); this * list is the narrower MCP-agent contract. * * `version_type` is enum-restricted by the * `recordings_version_type_check` CHECK constraint (ADR-166 Phase 1). * Reflecting the enum here means agents see invalid values as schema * errors at the tool surface, not as 500s round-tripped from Postgres. */ /** * The `recordings_version_type_check` CHECK vocabulary (ADR-166 Phase 1). * * Exported and referenced rather than re-typed, so the create/update schema * and the `pica_recordings_query` filter cannot drift apart — the filter * carried no enum at all until A10 (2026-08-24), so an agent had no way to * know `live` was rejected and `live_performance` was not. * Pinned by src/__tests__/tools/filter-param-enums.test.ts. */ export const RECORDING_VERSION_TYPES = [ "demo", "master", "alternate_master", "music_video", "lyric_video", "live_performance", "acoustic", "remix", "cover", "alternate", "instrumental", "backing_track", "edit", ]; const RECORDING_WRITE_PROPERTIES = { // Core (legacy `title` alias — translated to `recording_title` by the // HTTP route; included here so agents don't have to know the rename). title: { type: "string", description: "Recording title. Legacy alias — the HTTP route translates this to the canonical `recording_title` column. Either field is accepted; pass `title` for ergonomics.", }, recording_title: { type: "string", description: "Recording title (canonical column name).", }, artist_name: { type: "string", description: "Artist name for this recording (free-text display label)", }, notes: { type: "string", description: "Free-text master-side notes/annotation on the recording (parity with works' notes). Use to document master-grain context — e.g. why a master split is intentionally unset/pending — without fabricating a recording_splits row. Pass null to clear.", }, primary_artist_id: { type: "string", description: "UUID of the primary artist (a people.id in your catalog). Use this to " + "REPOINT a recording from a wrong/placeholder artist record to the correct " + "person — artist_name stays the display label. Must be a person in your org " + "(validated; a bad id is rejected, not silently applied). Pass null to clear. " + "Resolve the person first with pica_people_query / pica_resolve_person.", }, version_type: { type: "string", description: "Recording version type — the canonical way to express a variant (instrumental, acoustic, live, backing_track, edit, remix, etc.) of a work. Defaults to 'master' on create. A variant should be a recording under the parent work with the right version_type, NOT a separate work. Enforced by DB CHECK constraint.", enum: [...RECORDING_VERSION_TYPES], }, work_id: { type: "string", description: "ID of the work this recording is of (FK to works). A bad UUID returns the error code RECORDING_FK_VIOLATION — call pica_works_query first to resolve a title to an ID.", }, ownership_percentage: { type: "number", description: "Ownership percentage (0–100, validated server-side)", }, labels: { type: "string", description: "Record label(s) as freetext. Legacy column — the structured home is `global_recording_labels` (not yet exposed via MCP).", }, // Identifiers isrc: { type: "string", description: "International Standard Recording Code (e.g. GBUM71804829). Triggers global_recordings linking on create/update.", }, isrc_source: { type: "string", description: "Where the ISRC came from (e.g. 'spotify', 'mlc', 'manual', 'ppl', 'apple_music', 'youtube_music', 'soundcloud', 'disco').", }, musicbrainz_recording_id: { type: "string", description: "MusicBrainz recording UUID", }, // ADR-282: replaces per-society columns (ppl_registered, ppl_recording_id, // mlc_recording_id, mlc_song_code) with a generic array so no single society // is privileged by a dedicated field. society_registrations: { type: "array", description: "Society registrations for this recording, e.g. [{ society_code: 'PPL', registered: true, external_id: 'MLC-123' }]. " + "society_code identifies the collecting society (PRS, PPL, MCPS, MLC, GEMA, ASCAP, …); " + "no society is privileged by a dedicated field.", items: { type: "object", properties: { society_code: { type: "string", description: "collecting society code, e.g. PRS, PPL, MLC, GEMA", }, registered: { type: "boolean", description: "whether registered with this society", }, external_id: { type: "string", description: "the society's identifier for this entity (optional)", }, }, required: ["society_code", "registered"], }, }, // Master metadata (ADR-165 Phase 2 — populated by enrichment but // settable from MCP for manual seeding/overrides). duration_ms: { type: "number", description: "Duration in milliseconds. Note: `works` carries duration_seconds; `recordings` carries duration_ms.", }, preview_url: { type: "string", description: "30-second preview URL (Spotify or equivalent)", }, album_art_url: { type: "string", description: "Denormalised album artwork URL. Source of truth is releases.artwork_url; duplicated here for catalogue-card hot paths.", }, spotify_url: { type: "string", description: "Per-track Spotify URL (e.g. https://open.spotify.com/track/...). Legacy input NAME — " + "the server extracts the track id and stores ONLY the canonical spotify_track_id.", }, spotify_track_uri: { type: "string", description: "spotify:track:{id} URI for the master. Legacy input NAME — the server extracts the " + "track id and stores ONLY the canonical spotify_track_id.", }, spotify_track_id: { type: "string", description: "Canonical Spotify track id (bare id; used for dedup + cascade matching). " + "The SINGLE stored Spotify column — preferred over spotify_track_uri/spotify_url; " + "when only the uri/url is supplied the server derives this id automatically.", }, youtube_video_id: { type: "string", description: "YouTube video id. Non-master version types (music_video, lyric_video, etc.) get their own video id.", }, youtube_url: { type: "string", description: "YouTube watch URL", }, apple_music_url: { type: "string", description: "Apple Music track URL", }, deezer_track_id: { type: "string", description: "Deezer track id", }, // Recording-session metadata. Engineer is a credit (pica_recording_credits_update, // role Engineer / Recording Engineer / Mastering Engineer / Mixer); live is a // version (version_type='live_performance'). These two are recording-intrinsic. recorded_at: { type: "string", description: "Date the master was recorded, as an ISO date (YYYY-MM-DD). Distinct from the session's project date — set this when the recording's own date is known.", }, studio_name: { type: "string", description: "Name of the studio or room the master was recorded in (e.g. 'Abbey Road Studio 2'). For a structured session with location, attach the recording to a pica_projects_* recording_session instead/as well.", }, // Declared jsonb extension points ai_disclosure: { type: "object", description: "AI disclosure object (UK AI Copyright compliance). Fields: composition, performance, production, mastering ('none'|'assisted'|'generated'), tools_used (string[]), disclosure_date, disclosed_by.", }, provenance_attestation: { type: "object", description: "Human creation proof. Fields: human_created (bool), attested_by, attested_at, attestation_method, supporting_evidence, ip_address, signature_hash, certificate_id.", }, training_rights: { type: "object", description: "B2B ML licensing compliance object. Fields: available_for_training (bool), training_terms, minimum_fee, excluded_uses, permitted_uses, geographic_restrictions, requires_attribution, requires_notification, license_duration, updated_at, updated_by.", }, }; export class RecordingsTools { pica; constructor(pica) { this.pica = pica; } /** * Get all recordings tools */ getTools() { return [ { definition: { name: "pica_recordings_query", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Find recordings — list all, search by query, or filter by work. " + "Each row includes streaming_activity_score (0–100) and streaming_activity_components; sort desc to surface highest-traction recordings first. " + "Each row also includes missing_field_count (0–3: how many of isrc, master_ownership, primary_artist are absent); sort missing_field_count:desc to surface the most-incomplete recordings. " + "Date + presence filters (recorded_from/to, created_from/to, has_isrc) apply when browsing without a free-text query; a text query uses the dedicated search path and does not combine with these filters in one call. " + "→ then: pica_recordings_inspect (details for a specific recording)", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search by title, ISRC, or artist name", }, work_id: { type: "string", description: "Filter recordings by work ID (replaces pica_recordings_by_work)", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Max results (default: 50)", }, sort: { type: "string", enum: [ "streaming_activity:asc", "streaming_activity:desc", "missing_field_count:asc", "missing_field_count:desc", ], description: "Sort order. streaming_activity:desc surfaces highest-traction recordings first; missing_field_count:desc surfaces the most-incomplete recordings first (gap-finding); :asc reverses each. Omit for default created_at desc.", }, streaming_activity_gt: { type: "number", description: "Filter to recordings whose streaming_activity_score is strictly greater than this value (0–100).", }, streaming_activity_lt: { type: "number", description: "Filter to recordings whose streaming_activity_score is strictly less than this value (0–100).", }, missing_field_count_gt: { type: "number", description: "Filter to recordings whose missing_field_count is strictly greater than this value (0–3: isrc, master_ownership, primary_artist).", }, missing_field_count_lt: { type: "number", description: "Filter to recordings whose missing_field_count is strictly less than this value (0–3).", }, has_isrc: { type: "boolean", description: "Filter to recordings that have (true) or lack (false) an ISRC. 'recordings missing an ISRC' → has_isrc=false.", }, version_type: { type: "string", enum: [...RECORDING_VERSION_TYPES], description: "Filter to recordings of this version type. Same values as pica_recordings_create's version_type.", }, offset: { type: "number", description: "Pagination offset (default: 0).", }, recorded_from: { type: "string", description: "Only recordings recorded on/after this ISO date (YYYY-MM-DD). For last year use recorded_from=2025-01-01 & recorded_to=2025-12-31.", }, recorded_to: { type: "string", description: "Only recordings recorded on/before this ISO date (YYYY-MM-DD).", }, created_from: { type: "string", description: "Only recordings added on/after this ISO date.", }, created_to: { type: "string", description: "Only recordings added on/before this ISO date.", }, }, }, }, executor: this.queryRecordings.bind(this), }, { definition: { name: "pica_recordings_inspect", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "Get details for a specific recording — find the id with pica_recordings_query. " + "Omit sections for everything. " + "Sections: basic (recording row + ISRC prefix intel), credits (work-side credits via parent work), recording_credits (first-party operator-authored production credits on this recording), audio (audio files via parent work, each with a `measured` block: tempo_bpm, key, mode, energy, loudness_lufs, duration_seconds), production_assets, releases. " + "Round-trip a pica_recording_credits_update write via sections:['recording_credits']. " + "→ then: pica_audio_query (audio files), pica_credits_update (fix work-side credits), pica_recording_credits_update (add a recording-side credit)", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", // id/recording_id/isrc are interchangeable entry points — see // pica_works_inspect (feedback 6d516171). isrc resolves to the // recording server-side (in-app assistant roadblock 2026-07-05: the // model kept calling inspect with `{isrc:"..."}` and hitting // WRAPPED_ARGS_MISSING). requiredOneOf lets pica_execute still // hard-reject empty/stripped args. requiredOneOf: [["id", "recording_id", "isrc"]], inputSchema: { type: "object", properties: { id: { type: "string", description: "Recording ID. Also accepted as recording_id (alias for read-after-write flows from write tools that use recording_id).", }, recording_id: { type: "string", description: "Recording ID (alias for id — accepted because the write tools use recording_id).", }, isrc: { type: "string", description: "ISRC of the recording — resolved to the recording for you (e.g. GBUM71804829). Use when you have the code but not the id.", }, sections: { type: "array", items: { type: "string", enum: [ "basic", "credits", "recording_credits", "audio", "production_assets", "releases", "field_provenance", ], }, description: 'Which data sections to include. Omit for all sections. Use ["basic"] for lightweight lookup. Use ["recording_credits"] to round-trip a pica_recording_credits_update write.', }, }, required: [], }, }, executor: this.inspectRecording.bind(this), }, { definition: { name: "pica_recordings_create", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Create a new recording. IMPORTANT: call pica_recordings_query with work_id first to check for existing recordings on the same work — duplicate masters cause ISRC conflicts. " + "Album metadata (album_name, album_release_date, upc, track_number) lives on pica_releases_create/update, not here. " + "Label-as-org belongs on the release (pica_releases_create/update's label_organization_id via pica_labels_query); the legacy `labels` freetext column is kept for backwards compat. " + "ISWCs identify works, not recordings — set them via pica_works_create/update. " + "Duration is in milliseconds (`duration_ms`); the `duration_seconds` works convention is rejected with a 400. " + "After several mutations, surface a pica_dashboard_briefing to the user. " + "→ before: pica_recordings_query (check duplicates) → then: pica_audio_presigned_upload (upload audio), pica_audio_analyze (extract features)", workflows: ["recording-required"], vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { ...RECORDING_WRITE_PROPERTIES, create_twin: { type: "boolean", description: "default true — creating a work also creates its linked master recording (and vice versa); pass false to create one grain only", }, }, required: ["title", "artist_name", "version_type"], }, }, executor: this.createRecording.bind(this), }, { definition: { name: "pica_recordings_update", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Update an existing recording. Only real `recordings` columns are accepted. " + "Album metadata (album_name, upc, track_number) belongs on pica_releases_create/update; ISWC belongs on pica_works_create/update; duration is in `duration_ms` (not `duration_seconds`). " + "Bad keys return a 400 with a redirect message naming the correct tool. " + "→ then: pica_recordings_inspect (verify changes persisted)", workflows: ["recording-required"], vernacular_kind: "specialist", vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Recording ID (required)", }, ...RECORDING_WRITE_PROPERTIES, }, required: ["id"], }, }, executor: this.updateRecording.bind(this), }, { definition: { name: "pica_recordings_delete", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "destructive", description: "Delete a recording. Its credits and splits are removed with it. " + "If the recording is a track on a release, carries royalty payments, " + "or is used in a sync placement, the delete is REFUSED and the error " + "lists exactly what to clear first (recordings cannot be archived). " + "Remove those dependents, then retry. To correct a wrong recording " + "rather than remove it, use pica_recordings_update.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Recording ID", }, }, required: ["id"], }, }, executor: this.deleteRecording.bind(this), }, ]; } /** * Query recordings — list all, search by text, or filter by work */ async queryRecordings(args) { const limit = args.limit || 50; const workId = args.work_id; const query = args.query; const sort = args.sort; const streaming_activity_gt = args.streaming_activity_gt; const streaming_activity_lt = args.streaming_activity_lt; const missing_field_count_gt = args.missing_field_count_gt; const missing_field_count_lt = args.missing_field_count_lt; const has_isrc = args.has_isrc; const version_type = args.version_type; const offset = args.offset; const recorded_from = args.recorded_from; const recorded_to = args.recorded_to; const created_from = args.created_from; const created_to = args.created_to; // Filter by work_id — delegate to getByWork if (workId) { const recordings = await this.pica.recordings.getByWork(workId); return formatStructuredList(recordings, "recording", { work_id: workId, total: recordings.length, }); } // Always the paginated search, filtered or not: it carries the route's // real total and has_more. The old default (recordings.list) reported the // page length as the total and could not page (row-cap T3). const result = await this.pica.recordings.search({ query, limit, offset, sort, streaming_activity_gt, streaming_activity_lt, missing_field_count_gt, missing_field_count_lt, has_isrc, version_type, recorded_from, recorded_to, created_from, created_to, }); return formatStructuredList(result.data, "recording", { ...(query && { query }), total: result.total, hasMore: result.hasMore, // Zero-result guidance from the search route (e.g. "N recordings // have a null primary artist — try ISRC or title"). Dropping this // was ops_issue 689a8e9a: a silent empty read as "no recordings // exist" and nearly caused duplicate creation. ...(result.recovery_hint && { recovery_hint: result.recovery_hint }), }); } /** * Inspect a recording — get details with optional section filtering */ /** * Resolve an ISRC to a recording id. recordings.search filters by ISRC * client-side, so we take the first EXACT (case-insensitive, dash-stripped) * match rather than a substring hit. Throws EntityNotFoundError (→ NOT_FOUND) * when nothing matches, so inspect surfaces a hard not-found instead of a * confusing empty payload (ADR-198). */ async resolveRecordingIdByIsrc(isrc) { const norm = (v) => v.replace(/-/g, "").toUpperCase(); const target = norm(isrc); // Query with the NORMALIZED value: recordings.isrc is stored dash-free and // recordings.search does a raw substring match with no dash-stripping, so a // dashed input (e.g. "UK-JUX-17-00002") would miss the stored "UKJUX1700002" // and false-NOT_FOUND. We still exact-match `norm(r.isrc) === target` below. const { data } = await this.pica.recordings.search({ query: target, limit: 50, }); const match = data.find((r) => r.isrc && norm(r.isrc) === target); if (!match) { throw new EntityNotFoundError(`No recording found with ISRC ${isrc}. Use pica_search_all to locate it, or pica_recordings_query to browse.`); } return match.id; } /** * Resolve the primary recording for a section that runs WITHOUT "basic" * (e.g. sections: ["credits"]). A 404 (EntityNotFoundError) or 400 * (invalid id) on this fetch is the same hard-error class as the * basic-section fetch — the id the caller asked about cannot be * resolved — so it propagates (2026-09-01 sibling sweep of the * works_inspect 2026-07-18 fail-quiet fix; previously it degraded into * credits_error/audio_error inside a success envelope). Any other * failure is handed to onSoftError and the section records it exactly * as before — only the primary fetch gets the hard treatment, never * the section's own calls. */ async primaryForSection(result, id, onSoftError) { if (result.recording) return result.recording; try { return (await this.pica.recordings.get(id)); } catch (error) { if (error instanceof EntityNotFoundError) throw error; if (error instanceof ApiError && error.status === 400) throw error; onSoftError(error); return null; } } async inspectRecording(args) { // Accept recording_id as an alias for id — write tools use recording_id, // so agents doing read-after-write naturally pass recording_id here. Without // this alias args.id would be undefined → recordings.get(undefined) would // fail or return unexpected results (ADR-198 silent-empty footgun). let id = (args.id ?? args.recording_id); // Accept isrc as an entry point — resolve it to the recording id. Throws // EntityNotFoundError (→ NOT_FOUND) when no recording carries that ISRC, the // same hard signal recordings.get raises for a missing id. if (!id && typeof args.isrc === "string" && args.isrc.trim()) { id = await this.resolveRecordingIdByIsrc(args.isrc.trim()); } if (!id) { return { content: [ { type: "text", text: "id (or recording_id or isrc) is required." }, ], isError: true, }; } const sections = args.sections; const allSections = !sections || sections.length === 0; const result = {}; // ADR-224 — see works.ts for the doctrine. recordings_inspect's // pre-conversion pattern was the sibling-field shape (`result.X = []` // + `result.X_error = msg`), the medium-severity variant of the four // ADR-224 found. Flag-off mode preserves the sibling-field shape; // flag-on drops the sibling fields and routes failures through // errors[]. Primary-entity EntityNotFoundError propagation (PR #110, // 2026-04-26) is preserved unchanged in both modes — that's a hard // error signal that doesn't go through the section-error path. const useStructured = process.env.ADR_224_STRUCTURED_ERRORS === "1"; const errors = []; const TOOL_NAME = "pica_recordings_inspect"; // Basic section: recording details + ISRC prefix intel if (allSections || sections?.includes("basic")) { try { const recording = await this.pica.recordings.get(id); result.recording = recording; // Enrich with ISRC prefix intelligence if ISRC is present if (recording.isrc) { const prefixIntel = await this.pica.recordings.prefixLookup(recording.isrc); if (prefixIntel) { result.prefix_intel = prefixIntel; } } } catch (error) { // 404 on the primary entity is a hard error — propagate so the // agent sees an explicit not-found signal instead of a 200 with // a partial-empty payload (ADR-198 verification 2026-04-26 — // pica_recordings_inspect was hallucinating into the void on // missing route handlers / missing recordings). This propagation // predates and is independent of ADR-224 — kept unchanged. if (error instanceof EntityNotFoundError) { throw error; } // A 400 on the PRIMARY entity fetch is an invalid recording id // (upstream VALIDATION_ERROR — e.g. a non-UUID id), not a degraded // section. Fail-softing it produced a success envelope with the 400 // buried in recording_error — no isError, no recovery hint (the // works_inspect 2026-07-18 fix named this sibling as open follow-up). // Propagate like the 404 above so formatError surfaces a proper // error envelope + the pica_recordings_inspect recovery hint. if (error instanceof ApiError && error.status === 400) { throw error; } if (!useStructured) { result.recording_error = error instanceof Error ? error.message : "Failed to fetch recording"; } pushSectionError(errors, TOOL_NAME, "basic", error, id); } } // Credits section: work credits (via recording's parent work) if (allSections || sections?.includes("credits")) { // We need the recording to get its work_id; hard errors on that // primary fetch propagate via primaryForSection. const recording = await this.primaryForSection(result, id, (error) => { if (!useStructured) { result.credits_error = error instanceof Error ? error.message : "Failed to fetch credits"; } else { result.credits = null; } pushSectionError(errors, TOOL_NAME, "credits", error, id); }); if (recording) { try { const workId = recording.work_id; if (workId) { const credits = await this.pica.credits.listForWork(workId); result.credits = credits; } else { result.credits = []; result.credits_note = "No parent work linked — no credits available"; } } catch (error) { if (!useStructured) { result.credits_error = error instanceof Error ? error.message : "Failed to fetch credits"; } else { result.credits = null; } pushSectionError(errors, TOOL_NAME, "credits", error, id); } } } // ADR-213 Primitive A.1 — additive recording_credits section. // Distinct from the `credits` section above (which reads work-side // credits via the parent work). This reads first-party operator- // authored production credits directly from the recording_credits // table. Closes the round-trip path for pica_recording_credits_update // per ADR-198. Trade-secret separation: this never touches the // enrichment-cascade-fed `credits` table (per ip-protection.md). if (allSections || sections?.includes("recording_credits")) { try { result.recording_credits = await this.pica.recordingCredits.list(id); } catch (error) { if (!useStructured) { result.recording_credits = []; result.recording_credits_error = error instanceof Error ? error.message : "Failed to fetch recording_credits"; } else { result.recording_credits = null; } pushSectionError(errors, TOOL_NAME, "recording_credits", error, id); } } // Audio section: audio files linked to the parent work if (allSections || sections?.includes("audio")) { const recording = await this.primaryForSection(result, id, (error) => { if (!useStructured) { result.audio_error = error instanceof Error ? error.message : "Failed to fetch audio files"; } else { result.audio_files = null; } pushSectionError(errors, TOOL_NAME, "audio", error, id); }); if (recording) { try { const workId = recording.work_id; if (workId) { const audioFiles = await this.pica.audioFiles.list({ work_id: workId, }); // Each file's measured facts, labelled with units and method. result.audio_files = Array.isArray(audioFiles) ? audioFiles.map((f) => ({ ...f, measured: toMeasuredAudio(f), })) : audioFiles; result.measured_basis = MEASURED_AUDIO_BASIS; } else { result.audio_files = []; result.audio_note = "No parent work linked — no audio files available"; } } catch (error) { if (!useStructured) { result.audio_error = error instanceof Error ? error.message : "Failed to fetch audio files"; } else { result.audio_files = null; } pushSectionError(errors, TOOL_NAME, "audio", error, id); } } } // ADR-156 op #3 Session B Task 5 — surface linked physical assets // so the inspect tool is discoverable from the recording side. if (allSections || sections?.includes("production_assets")) { try { result.production_assets = await this.pica.recordings.listProductionAssets(id); } catch (error) { if (!useStructured) { result.production_assets = []; result.production_assets_error = error instanceof Error ? error.message : "Failed to fetch production assets"; } else { result.production_assets = null; } pushSectionError(errors, TOOL_NAME, "production_assets", error, id); } } // ADR-173 Decision 2 — every release this recording appears on, // with its track position. Answers "which albums is this master // on?" in one call. if (allSections || sections?.includes("releases")) { try { result.releases = await this.pica.recordings.listReleases(id); } catch (error) { if (!useStructured) { result.releases = []; result.releases_error = error instanceof Error ? error.message : "Failed to fetch releases"; } else { result.releases = null; } pushSectionError(errors, TOOL_NAME, "releases", error, id); } } // ADR-277 WS2 — per-field provenance: each non-null field's stakes tier + // attestation / corroboration flags, derived server-side from the // recording's own field_sources. Sequential like the sections above; a // derive failure degrades the section, never the whole inspect call. if (allSections || sections?.includes("field_provenance")) { try { result.field_provenance = await this.pica.health.fieldProvenanceRecording(id); } catch (error) { if (!useStructured) { result.field_provenance = null; result.field_provenance_error = error instanceof Error ? error.message : "Failed to derive field provenance"; } else { result.field_provenance = null; } pushSectionError(errors, TOOL_NAME, "field_provenance", error, id); } } const enriched = useStructured ? { ...result, errors } : result; return formatSuccess("Recording details", enriched); } /** * Create recording. * * `version_type` defaults to 'master' (the DB-level default and the * canonical ADR-166 value). Pre-ADR-174 the executor defaulted to * 'original', which is not a valid version_type — every call without * an explicit type tripped the recording-type CHECK constraint and * surfaced as a 500 from the service. */ async createRecording(args) { if (!args.version_type) args.version_type = "master"; const recording = await this.pica.recordings.create(args); // ADR-213 Primitive A — append attribution hints so every new recording // prompts the ownership / credits / master-claim conversation by default. // Mirror of buildCustodyHints (works.ts:723) for the recordings side. const hints = buildRecordingAttributionHints(recording); // Registration integrity floor (spec 2026-07-09, Task 9) — assess the // just-created recording and attach both the rendered hints and the raw // gap block. Best-effort: never blocks or fails the create. const pressure = await buildIntegrityPressure(this.pica, "recording", recording.id); hints.push(...pressure.hints); // Spreadsheet-moment auto-twin (2026-07-10) — the SDK merges `twin`/ // `twin_error` onto the create() response (see RecordingsResource.create). // Surface a completion hint when a twin work was actually minted so the // agent knows a second grain exists and how to opt out next time. if (recording.twin) { hints.push({ gap: "twin_work_created", suggestion: `also created the linked work ${recording.twin.id} — pass create_twin: false to skip`, severity: "nice_to_have", }); } return formatSuccess("Recording created successfully", { ...recording, integrity: pressure.integrity }, hints); } /** * Update recording */ async updateRecording(args) { const { id, ...updates } = args; const outcome = readChangeOutcome(await this.pica.recordings.update(id, updates)); if (outcome.kind === "proposed") return formatProposedChange("recording", id, Object.keys(updates), outcome); const recording = outcome.record; // In a client's catalogue the integrity reads are not opened through the // relationship (lib/auth/catalogue-routes.ts): answer the record alone. if (currentCatalogue() !== undefined) return formatSuccess("Recording updated successfully", recording); // Registration integrity floor (Task 9) — same pressure rendering as // createRecording. const pressure = await buildIntegrityPressure(this.pica, "recording", recording.id); return formatSuccess("Recording updated successfully", { ...recording, integrity: pressure.integrity }, pressure.hints); } /** * Delete recording */ async deleteRecording(args) { const outcome = await this.pica.recordings.delete(args.id); return formatDeletion("recording", args.id, outcome); } } export { RECORDING_WRITE_PROPERTIES }; //# sourceMappingURL=recordings.js.map