@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
922 lines • 48 kB
JavaScript
// 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