@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
1,571 lines • 90.1 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { typedError } from "./error-envelope.js";
import { parseApiErrorBody } from "./api-error-body.js";
import { formatSuccess, formatStructuredList, withBillingGate, mapGapsToHints, } from "@withpica/mcp-utils";
import { notifyParties, summarizeNotifications, } from "@withpica/mcp-utils";
import { buildIntegrityPressure } from "./integrity-hints.js";
import { VERBOSE_PROPERTY, RECEIPT_NOTE } from "./write-receipt.js";
// ADR-213: 16-role enum for recording_credits. Mirror of
// RECORDING_CREDIT_ROLES in lib/services/recording-credits.ts. Writer /
// Composer / Lyricist / Arranger are composition credits on work_collaborators
// — those route via pica_credits_update, not this tool.
const RECORDING_CREDIT_ROLES = [
"MainArtist",
"FeaturedArtist",
"Remixer",
"Producer",
"Co-Producer",
"Executive Producer",
"Mixer",
"Engineer",
"Recording Engineer",
"Mastering Engineer",
"Performer",
"Vocalist",
"Instrumentalist",
"Arranger",
"Programmer",
"Other",
];
// ADR-252: credit_type enum for pica_credit_add / pica_credits_update.
// Discriminated input: composition roles (writer/composer/lyricist/arranger/
// publisher/sub-publisher) route to work_collaborators; master-attribution
// roles (producer/performer/engineer/etc.) route to recording_credits keyed to
// a recording; `owner` is master ownership and routes to recording_splits. The
// service layer routes internally via role-classification — the agent surface
// stays a single enum. (`work_credits` is dropped; no role lands there.)
const WORK_CREDIT_TYPES = [
"writer",
"composer",
"arranger",
"lyricist",
"publisher",
"sub-publisher",
"producer",
"performer",
"engineer",
"mixer",
"mastering",
"owner",
"vocalist",
"instrumentalist",
"conductor",
"programmer",
"remixer",
];
/**
* A second add for a person + role the recording already credits, carrying a
* DIFFERENT value on a field that credit already holds, is refused by the
* service with 409 RECORDING_CREDIT_EXISTS and nothing written
* (lib/services/recording-credit-natural-key.ts). Before 2026-09-26 it
* silently replaced the first credit's notes and answered success with the
* same credit_id (ops issue 6e19b02c). Surface it as its own code, naming the
* credit to edit, so an agent does not read it as a transport failure.
*/
function recordingCreditExists(err) {
const body = parseApiErrorBody(err);
if (body.code !== "RECORDING_CREDIT_EXISTS")
return null;
const details = (body.details ?? {});
const creditId = typeof details.existing_credit_id === "string"
? details.existing_credit_id
: null;
return typedError("RECORDING_CREDIT_EXISTS", body.message ??
`This person is already credited in this role on this recording${creditId ? ` (credit ${creditId})` : ""} with different details. Nothing was changed — one person holds one credit per role. Edit that credit with pica_credit_update (e.g. instrument: "drums, guitar") instead of adding a second.`, creditId ? { existing_credit_id: creditId } : undefined);
}
/**
* The columns that record a master credit's attestation. Mirrors
* RECORDING_CREDIT_ATTESTATION_FIELDS in
* lib/constants/recording-credit-attestation.ts, which
* POST /api/admin/recordings/[id]/credits and the bulk route refuse with the
* same code; this package cannot import the app.
*/
const RECORDING_ATTESTATION_FIELDS = [
"attestation_status",
"attested_at",
"attested_by",
"attestation_source_type",
"attestation_source_id",
];
/**
* Refuse a pica_recording_credits_update call that carries any attestation
* column — at the top level (single form) or on any credits[] entry (batch
* form). Presence is what counts: "pending" is refused too, so no caller is
* left believing it controls the field. Returns null when the call is clean.
*/
function refuseRecordingAttestation(args) {
const has = (o) => {
if (!o || typeof o !== "object")
return null;
for (const f of RECORDING_ATTESTATION_FIELDS) {
if (Object.hasOwn(o, f))
return f;
}
return null;
};
const guidance = "Every credit starts as pending and only the credited person can " +
"confirm it. Remove it and retry (put any source evidence in notes), " +
"then ask them to confirm with pica_collaborators_invite (recording_id) " +
"or pica_credits_send_for_attestation on the recording's song.";
const topField = has(args);
if (topField) {
return typedError("ATTESTATION_NOT_SETTABLE", `${topField} can't be set by this tool — nothing was saved. ${guidance}`, { field: topField });
}
if (Array.isArray(args.credits)) {
for (let i = 0; i < args.credits.length; i++) {
const field = has(args.credits[i]);
if (field) {
return typedError("ATTESTATION_NOT_SETTABLE", `credits[${i}] carries ${field}, which this tool can't set — ` +
`nothing was saved. ${guidance}`, { field, index: i });
}
}
}
return null;
}
export class CreditsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
// NOTE: `pica_credits_balance` / `pica_credits_history` / `pica_credits_purchase`
// were removed from the MCP surface 2026-04-22 (see docs/MCP_REFINEMENT_LOG.md
// entry B-001). withPICA bills a resident month plus per-work overage (ADR-276); surfacing a credit
// balance tool misled agents into gating actions behind a balance check.
// The underlying lib/services/credits/** infra remains intact for any internal
// telemetry or future pay-per-call work (MPP), it just isn't advertised.
//
// `pica_credits_list` and `pica_credits_update` below are work_credits
// (songwriter / producer credits on a work), not billing credits. Keep.
return [
{
definition: {
name: "pica_credits_list",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "See who's credited on a song and their splits. Use when the user " +
"asks: 'what was my role on this?', 'who's credited on this " +
"song?', 'who wrote it?', 'who owns this work?'. Lists the " +
"writers, composers, arrangers, and producers on a work and each " +
"one's percentage. → then: pica_claim_credit (add yourself), " +
"pica_credits_update (fix existing credits), pica_people_inspect " +
"(look at a credited person)",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "The work ID to get credits for",
},
},
required: ["work_id"],
},
nextSteps: [
{
tool: "pica_credits_update",
reason: "Fix or fill credit gaps when the listing surfaces missing roles or splits.",
when: "on_success",
},
{
tool: "pica_works_inspect",
reason: "Re-inspect the work to see how the credits affect completeness scoring.",
when: "on_success",
},
],
},
executor: this.listCredits.bind(this),
},
{
definition: {
name: "pica_credits_update",
scope: "write:catalog",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Set the credits on a work — replaces the existing composition set " +
"(master and owner entries are added, never replaced), and " +
"composition splits must total 100% (±0.01, so a real split sheet " +
"reading 33.34/33.34/33.33 or 33.33/33.33/33.33 is accepted AS " +
"SIGNED — never redistribute the odd hundredth yourself; entering " +
"what the sheet says is the whole job). Use when the user asks: 'set the " +
"writers', 'update the splits on this song', 'add the publisher'. " +
"Roles route by grain: COMPOSITION roles (writer, composer, " +
"lyricist, arranger, publisher, sub-publisher) are stored on " +
"work_collaborators; MASTER roles (producer, performer, engineer, " +
"mixer, mastering, vocalist, instrumentalist, conductor, " +
"programmer, remixer) are recording credits and live on " +
"recording_credits — those belong to a SPECIFIC recording, so for " +
"them prefer pica_recording_credits_update (target the recording); " +
"and 'owner' is master ownership, stored in recording_splits. To " +
"credit a publisher, add them as their own entry with role " +
"'publisher' (their publishing share is that entry's split) — " +
"there is no separate publisher field. is_primary marks the " +
"primary attribution and applies to composition credits only " +
"(recording credits have no primary flag). You can also set notes. " +
"Attestation is not settable here: every credit this saves starts " +
"as pending, and only the credited person confirms it — send it " +
"with pica_credits_send_for_attestation. Once it's saved, it's worth asking the " +
"user: send these to the collaborators to verify, or generate a " +
"split sheet for sign-off? Is anyone here on a work-for-hire or " +
"producer agreement (a producer takes points off the top of " +
"master royalties)? Each person can carry their own AI " +
"disclosure. Then confirm with pica_credits_list — invalid roles " +
"can fail quietly. Answers with a compact receipt — the credits as " +
"written (person, role, share) and what the save reported; pass " +
"verbose: true for the raw save result. " +
"→ then: pica_credits_list (verify saved), " +
"pica_works_inspect (check progress)",
workflows: ["work-required", "work-credits-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: {
work_id: {
type: "string",
description: "The work ID to set credits on",
},
credits: {
type: "array",
description: "Array of credit entries. Composition splits must total 100%; owner shares are checked separately.",
items: {
type: "object",
properties: {
person_id: {
type: "string",
description: "Person ID of the credited collaborator",
},
role: {
type: "string",
description: "Credit role. Composition (→ work_collaborators): writer, composer, arranger, lyricist, publisher, sub-publisher. Master (→ recording_credits, keyed to a recording): producer, performer, engineer, mixer, mastering, vocalist, instrumentalist, conductor, programmer, remixer. Ownership (→ recording_splits): owner. For a publisher, use role 'publisher' as its own entry.",
enum: [
"writer",
"composer",
"arranger",
"lyricist",
"publisher",
"sub-publisher",
"producer",
"performer",
"engineer",
"mixer",
"mastering",
"owner",
"vocalist",
"instrumentalist",
"conductor",
"programmer",
"remixer",
],
},
splits: {
type: "number",
description: "Percentage split (0-100). For composition credits it is the writer/publisher share on work_collaborators, and those must total 100%. For an 'owner' entry it is the master ownership share on the work's sole recording and is not part of that 100%: active owner shares may not pass 100% (RECORDING_SPLIT_TOTAL_EXCEEDED), re-sending the share already recorded changes nothing, and a different share for an owner already recorded is refused (RECORDING_SPLIT_EXISTS), never replaced.",
},
is_primary: {
type: "boolean",
description: "Mark this credit as the primary attribution for the person on this work. Composition credits only — recording credits have no primary flag and ignore it.",
},
notes: {
type: "string",
description: "Free-text notes about this credit (e.g., recording-session context, contribution specifics).",
},
},
required: ["person_id", "role", "splits"],
},
},
...VERBOSE_PROPERTY,
},
required: ["work_id", "credits"],
},
nextSteps: [
{
tool: "pica_credits_list",
reason: "Read the credits back to confirm every advertised field (is_primary, notes) actually persisted. Silent persistence drops are real — never trust the write response alone.",
when: "on_success",
},
{
tool: "pica_works_inspect",
reason: "Check the work's completeness score now that credits are in place.",
when: "on_success",
},
{
tool: "pica_split_sheet_generate",
reason: "Formalise the splits with a split sheet for collaborator sign-off.",
when: "on_success",
},
{
tool: "pica_agreement_types_create",
reason: "If any collaborators are subject to special terms (producer points, flat fee), create the matching agreement.",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "credit update", this.updateCredits.bind(this)),
},
// ADR-213 Primitive B — pica_recording_credits_update
{
definition: {
name: "pica_recording_credits_update",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Use when the user asks: 'credit the producer', 'who played on this', 'log the engineer', " +
"'add a performer credit', 'update the recording credits'. " +
"Credits who played/produced a recording — NOT ownership splits (percentage of master rights " +
"owned — that's pica_recording_splits_create) and NOT the songwriting credits on the underlying " +
"work (those go via pica_credits_update). Two forms: (1) single-credit — " +
"recording_id + credited_name + role, one call per person; (2) batch — credits[] " +
"applied across a release (release_id — every recording on its tracklist) or an " +
"explicit recording_ids list, never both. The same credit set is applied to EVERY " +
"target recording; existing (credited_name, role) rows are skipped, never " +
"overwritten, so re-running is safe. In the single form an existing " +
"(credited_name, role) credit only has EMPTY fields filled; a different value on " +
"a field it already holds is refused (RECORDING_CREDIT_EXISTS) — edit it with " +
"pica_credit_update. Set dry_run: true to preview a batch before " +
"committing. 16 roles: " +
"MainArtist, FeaturedArtist, Remixer, Producer, Co-Producer, " +
"Executive Producer, Mixer, Engineer, Recording Engineer, " +
"Mastering Engineer, Performer, Vocalist, Instrumentalist, " +
"Arranger, Programmer, Other. Look up the person first with " +
"pica_people_query; if they're not in the catalog, leave " +
"person_id empty and their name still shows via credited_name. " +
"Attestation is not settable here: every credit starts as " +
"pending and only the credited person confirms it — ask them " +
"with pica_collaborators_invite (recording_id) or " +
"pica_credits_send_for_attestation on the recording's song. " +
"After saving, confirm it stuck with pica_recordings_inspect " +
"(sections: ['recording_credits']). → then: " +
"pica_recordings_inspect (verify saved), pica_people_query " +
"(resolve more credits)",
workflows: ["recording-required", "recording-credits-required"],
vernacular_kind: "creator-entry",
previewMode: "dry_run",
inputSchema: {
type: "object",
properties: {
recording_id: {
type: "string",
description: "The recording UUID to credit",
},
person_id: {
type: "string",
description: "Optional UUID of an existing person record. Resolve via pica_people_query (or pica_people_create) first. Null is allowed when the credited entity is not yet a person record.",
},
credited_name: {
type: "string",
description: "The displayed credit name (NOT NULL on recording_credits). Use the artist/performer's professional or contributor name as it should appear on the recording.",
},
role: {
type: "string",
description: "Recording credit role from the 16-entry RECORDING_CREDIT_ROLES enum.",
enum: [...RECORDING_CREDIT_ROLES],
},
split_percentage: {
type: "number",
description: "Optional percentage share for this credit (0-100). Independent of composition-side split_percentage on work_collaborators — recording-side splits are tracked separately from songwriter splits.",
},
split_type: {
type: "string",
description: "Optional free-form split type label (e.g. 'master', 'producer_points').",
},
display_order: {
type: "number",
description: "Optional display order for sorting credits in UI lists. Defaults to 0.",
},
notes: {
type: "string",
description: "Optional free-text notes about this credit (recording-session context, role specifics).",
},
instrument: {
type: "string",
description: "Optional instrument(s) for this credit, e.g. 'drums' or 'drums, bass' (one credit per person — list multiple instruments on the same row). Stored on recording_credits.instrument and rendered in /inspect's contributors table.",
},
release_id: {
type: "string",
description: "UUID of a release — targets every recording on its tracklist. Provide exactly one of release_id / recording_ids (batch form only).",
},
recording_ids: {
type: "array",
items: { type: "string" },
description: "Explicit recording UUIDs to credit (max 100). Provide exactly one of release_id / recording_ids (batch form only).",
},
credits: {
type: "array",
description: "Batch form: the credit set applied to EVERY target recording (max 50). Each entry needs credited_name + role; person_id links an existing person record (resolve via pica_people_query first). Presence of this array switches the call to the batch path — omit for a single-credit call.",
items: {
type: "object",
properties: {
credited_name: {
type: "string",
description: "The displayed credit name — the contributor's professional name as it should appear on the recording.",
},
role: {
type: "string",
enum: [...RECORDING_CREDIT_ROLES],
description: "Recording credit role from the 16-entry RECORDING_CREDIT_ROLES enum. Songwriting roles (Writer, Composer, Lyricist) belong on the work via pica_credits_update.",
},
person_id: {
type: "string",
description: "Optional UUID of an existing person record in the catalog. Leave empty if the contributor has no person record — credited_name still shows.",
},
split_percentage: {
type: "number",
description: "Optional percentage share for this credit (0-100), applied identically on every target recording.",
},
split_type: {
type: "string",
description: "Optional free-form split type label (e.g. 'master', 'producer_points').",
},
notes: {
type: "string",
description: "Optional free-text notes attached to each created credit row.",
},
instrument: {
type: "string",
description: "Optional instrument(s) for this credit, e.g. 'bass' or 'drums, bass', applied on every target recording. Until 2026-09-11 batch mode had no instrument field and crediting an instrumentalist across N recordings took N single calls.",
},
},
required: ["credited_name", "role"],
},
},
dry_run: {
type: "boolean",
description: "Batch form only: when true, returns the per-row would_create / would_skip_existing decision set without persisting anything. Preview before committing.",
},
},
required: [],
},
nextSteps: [
{
tool: "pica_recordings_inspect",
reason: "Read recording_credits back via sections:['recording_credits'] to confirm the new row persisted.",
when: "on_success",
},
{
tool: "pica_people_query",
reason: "Resolve another person to credit, then call pica_recording_credits_update again for the next role.",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "recording credit update", this.updateRecordingCredits.bind(this)),
},
// ADR-232 — atomic credit primitives. Composable verbs over the same
// substrate as pica_credits_update / pica_recording_credits_update,
// but discrete: add one row, remove one row, no replace-semantics.
{
definition: {
name: "pica_credits_send_for_attestation",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Ask the people credited on a work to confirm their credit. Use " +
"when the user asks: 'send these credits to my collaborators to " +
"confirm', 'ask Mary to verify her songwriter credit', 'remind " +
"everyone to confirm their credits'. The lighter option when the " +
"user just wants confirmation, not a formal signed document — " +
"each person is notified (in-app, Telegram, email) and clicks " +
"through to confirm or dispute. Covers both composition credits " +
"(writers/publishers) and master credits (producers/performers on " +
"the recordings) — both are attestable. Sending only prompts them; " +
"it doesn't mark anything confirmed on its own. Pass " +
"reminder_type='reminder' to nudge people who haven't replied. " +
"For a signed, formal split sheet instead, use " +
"pica_split_sheet_generate then pica_split_sheet_send. → then: " +
"pica_credits_list (see who's confirmed), pica_works_inspect (the " +
"work's overall attestation health)",
workflows: ["work-required", "work-credits-required"],
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "The work whose pending credits should be sent.",
},
credit_ids: {
type: "array",
items: { type: "string" },
description: "Optional list of specific credit row IDs (from pica_credits_list) to filter to. Omit to send all pending credits on the work.",
},
reminder_type: {
type: "string",
enum: ["initial", "reminder"],
description: "'initial' (default) for first-time send; 'reminder' for nudging recipients who haven't responded. The notification copy + email subject differ.",
},
},
required: ["work_id"],
},
nextSteps: [
{
tool: "pica_credits_list",
reason: "After recipients respond, re-list credits to see whose attestation_status flipped from pending to attested.",
when: "on_success",
},
{
tool: "pica_works_inspect",
reason: "Check the work's overall attestation summary now that prompts are out.",
when: "on_success",
},
{
tool: "pica_split_sheet_generate",
reason: "If the user actually wants a signed formal split sheet (not just credit confirmation), generate one and call pica_split_sheet_send instead.",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "credit attestation send", this.sendForAttestation.bind(this)),
},
// Consolidation note (MCP tool-surface reduction): pica_credit_add and
// pica_credit_remove are deliberately NOT folded into pica_credits_update.
// They are distinct intents, not variants: credits_update REPLACES the
// whole credit set on a WORK (splits must total 100%); add/remove are
// atomic single-row mutations that span BOTH works and recordings
// (entity_type), leave the rest of the set untouched, and remove deletes
// by credit_id. The tool descriptions themselves position add/remove as
// the alternative to update ("adjust collaborators one at a time, rather
// than pica_credits_update which replaces the whole set at once"). Aliasing
// them would be a correctness bug and would drop the recording grain.
// False-positive fold, same class as the custody/royalties twins.
{
definition: {
name: "pica_credit_add",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Add one credit to a song or recording, leaving the others as " +
"they are. Use when the user asks: 'add Mary as additional " +
"songwriter at 25%', 'credit Bob as producer', 'add a vocalist to " +
"this recording'. Adds a single credit — pair it with " +
"pica_credit_remove to adjust collaborators one at a time, rather " +
"than pica_credits_update which replaces the whole set at once. " +
"Set entity_type: a work takes a credit_type (writer / publisher " +
"/ etc., 17 values) and share_pct; a recording takes a role (16 " +
"values) plus credited_name and share_pct. After adding, confirm " +
"it stuck with pica_works_inspect or pica_recordings_inspect " +
"(sections: ['credits']). A recording holds ONE credit per person " +
"per role: adding the same person + role again with different " +
"details is refused (RECORDING_CREDIT_EXISTS) — edit that credit " +
"with pica_credit_update instead (e.g. instrument 'drums, guitar'). " +
"→ then: pica_works_inspect (verify on a " +
"work) or pica_recordings_inspect (verify on a recording), " +
"pica_credit_remove (undo)",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
entity_type: {
type: "string",
enum: ["work", "recording"],
description: "Whether to add a credit on a work (composition / publishing side) or a recording (performance / production side).",
},
entity_id: {
type: "string",
description: "The work_id or recording_id to credit (UUID).",
},
person_id: {
type: "string",
description: "The person being credited (UUID). Resolve names via pica_people_query first; for recordings, person_id can be null when the credited entity is not yet a person record (credited_name carries the displayed name in that case).",
},
credit_type: {
type: "string",
enum: [...WORK_CREDIT_TYPES],
description: "(works only) Credit type. Defaults to 'writer'. Composition roles (writer / composer / lyricist / arranger / publisher / sub-publisher) route to work_collaborators; master roles (producer / performer / engineer / etc.) route to recording_credits keyed to the work's recording; 'owner' routes to recording_splits. A master/owner role needs the work to have a single recording — otherwise use pica_recording_credits_update against the recording directly.",
},
role: {
type: "string",
enum: [...RECORDING_CREDIT_ROLES],
description: "(recordings only) Role from the 16-value RECORDING_CREDIT_ROLES enum.",
},
credited_name: {
type: "string",
description: "(recordings only) Displayed credit name (NOT NULL on recording_credits). Required for recordings.",
},
share_pct: {
type: ["number", "null"],
description: "Percentage share (0-100). Maps to split_percentage on work_collaborators (composition) / recording_credits (master). Omit it (or send null) when unknown — it is stored as NULL (unknown), never as 0; the health plan then reports the work as incomplete rather than rescaling the others. REQUIRED for credit_type 'owner' (master ownership is recorded as given, never assumed 100).",
},
notes: {
type: "string",
description: "Optional free-text notes about this credit.",
},
},
required: ["entity_type", "entity_id", "person_id"],
},
nextSteps: [
{
tool: "pica_works_inspect",
reason: "Verify the new credit appears in sections:['credits'] (works only).",
when: "on_success",
},
{
tool: "pica_recordings_inspect",
reason: "Verify the new credit appears in sections:['recording_credits'] (recordings only).",
when: "on_success",
},
{
tool: "pica_credit_remove",
reason: "Undo this addition if the agent or user catches a mistake.",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "atomic credit add", this.creditAdd.bind(this)),
},
{
definition: {
name: "pica_credit_update",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Edit ONE existing credit in place — change a writer's share, an " +
"instrumentalist's instrument, a role, the displayed name, the " +
"notes, or WHO the credit is for (person_id) — leaving every " +
"other credit exactly as it is. Use when the user asks: 'change " +
"Mary's split to 40', 'set Charlie's instrument to bass', 'fix " +
"the spelling of this credit', 'link the Brian Lucey credit to " +
"his new person record'. This is the verb between pica_credit_add " +
"and pica_credit_remove: it never replaces the set (that is " +
"pica_credits_update) and never deletes-and-recreates, so the " +
"credit keeps its id. Its attestation state is kept too, except " +
"when person_id changes WHO it is for: a confirmed, disputed or " +
"declined credit then goes back to pending, because the newly " +
"linked person gave none of those answers (the response's " +
"person_change says so). " +
"Find the credit_id with pica_credits_list. " +
"Writer-split closure is NOT enforced on a single edit — re-split " +
"a song one writer at a time; the response's `allocation.status` " +
"(closed / open / incomplete) tells you whether the set still " +
"adds up, so you never have to guess or redistribute. " +
"→ then: pica_credits_list (verify), pica_works_inspect (progress)",
workflows: ["work-required", "work-credits-required"],
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
work_id: {
type: "string",
description: "The work the credit belongs to (UUID). The edit is scoped to this work — a credit_id from another work is refused.",
},
credit_id: {
type: "string",
description: "The credit to edit (UUID) — from pica_credits_list. Works for a composition credit (writer / publisher …) or a recording credit (producer / instrumentalist …) on this work.",
},
share_pct: {
type: ["number", "null"],
description: "New percentage share (0-100). Pass null to record the share as UNKNOWN — never as 0. Composition credits only.",
},
credit_type: {
type: "string",
enum: [...WORK_CREDIT_TYPES],
description: "New credit type / role.",
},
credited_name: {
type: "string",
description: "New displayed name for this credit.",
},
instrument: {
type: ["string", "null"],
description: "Instrument(s) for a recording credit, e.g. 'bass' or 'drums, bass'. Pass null to clear. Recording (master) credits only — ignored on a composition credit.",
},
notes: {
type: ["string", "null"],
description: "Free-text notes. Pass null to clear.",
},
person_id: {
type: "string",
description: "Change who this credit is for (UUID from pica_people_query) — link a name-only credit to a person record, or move a credit linked to the wrong person. The person must be in this organisation and not deleted; if they already hold this role on the song or recording, it is refused (CREDIT_PERSON_CONFLICT) rather than making a second credit. A confirmed, disputed or declined credit goes back to pending.",
},
confirm_name_mismatch: {
type: "boolean",
description: "Only with person_id on a name-only credit: set true after the user confirms the person is the one credited even though the names differ (e.g. a stage name). Without it a mismatch is refused (PERSON_NAME_MISMATCH).",
},
},
required: ["work_id", "credit_id"],
},
nextSteps: [
{
tool: "pica_credits_list",
reason: "Verify the edited credit and read the work's current allocation.",
when: "on_success",
},
{
tool: "pica_works_inspect",
reason: "Re-inspect the work to see how the change affects completeness.",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "atomic credit update", this.creditUpdate.bind(this)),
},
{
definition: {
name: "pica_credit_remove",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Remove one credit from a song or recording. Use when the user " +
"asks: 'remove this credit', 'take Bob off this song', 'unlist " +
"Mary as performer'. Takes off a single credit by its id, leaving " +
"the rest untouched — pair with pica_credit_add to adjust " +
"collaborators without rebuilding the whole credit set.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
entity_type: {
type: "string",
enum: ["work", "recording"],
description: "Whether the credit lives on a work or a recording.",
},
entity_id: {
type: "string",
description: "The work_id or recording_id (UUID) — used to scope the DELETE.",
},
credit_id: {
type: "string",
description: "The credit row's UUID. Get this from pica_works_inspect (sections:['credits']) or pica_recordings_inspect (sections:['recording_credits']).",
},
},
required: ["entity_type", "entity_id", "credit_id"],
},
nextSteps: [
{
tool: "pica_works_inspect",
reason: "Verify the credit is gone from sections:['credits'] (works only).",
when: "on_success",
},
{
tool: "pica_recordings_inspect",
reason: "Verify the credit is gone from sections:['recording_credits'] (recordings only).",
when: "on_success",
},
],
},
executor: withBillingGate(this.pica, "atomic credit remove", this.creditRemove.bind(this)),
},
];
}
async sendForAttestation(args) {
const workId = args.work_id;
if (!workId) {
return {
content: [{ type: "text", text: "work_id is required." }],
isError: true,
};
}
const creditIds = Array.isArray(args.credit_ids)
? args.credit_ids.filter((v) => typeof v === "string")
: undefined;
const reminderType = args.reminder_type === "reminder" ? "reminder" : "initial";
try {
const result = await this.pica.credits.sendForAttestation(workId, {
credit_ids: creditIds,
reminder_type: reminderType,
});
const rows = (result.results || []);
const name = (r) => r.person_name || r.person_id;
const of = (o) => rows.filter((r) => r.outcome === o);
const sent = of("sent");
const held = of("held");
const notPicaUsers = rows.filter((r) => r.outcome === "no_channel" && r.reason === "not_a_pica_user");
const noEmail = rows.filter((r) => r.outcome === "no_channel" && r.reason !== "not_a_pica_user");
const failed = of("failed");
const summary = [
`Asked ${sent.length + held.length}/${rows.length} credited people`,
sent.length > 0 ? `Sent to: ${sent.map(name).join(", ")}` : null,
held.length > 0
? `Email held for approval (not delivered yet): ${held.map(name).join(", ")}`
: null,
notPicaUsers.length > 0
? `Not PICA users (use pica_collaborators_invite): ${notPicaUsers.map(name).join(", ")}`
: null,
noEmail.length > 0
? `No email on file (add one to their person record): ${noEmail.map(name).join(", ")}`
: null,
failed.length > 0
? `Send failed: ${failed.map(name).join(", ")}`
: null,
]
.filter(Boolean)
.join(". ");
return formatSuccess(summary, result);
}
catch (err) {
// Nobody on the request could be reached: the route refuses with 422
// and names who and why. Not a transport failure.
const body = parseApiErrorBody(err);
if (body.code === "NO_REACHABLE_RECIPIENT") {
return typedError("NO_REACHABLE_RECIPIENT", body.message ?? "Nobody on this request could be reached.", body.details && typeof body.details === "object"
? body.details
: undefined);
}
const errorMessage = err instanceof Error ? err.message : String(err);
return typedError("CREDITS_ATTESTATION_SEND_FAILED", `Failed to send credits for attestation: ${errorMessage}`);
}
}
async listCredits(args) {
// Fetch both publishing collaborators and recording credits
const [collabSettled, creditsSettled] = await Promise.allSettled([
this.pica.credits.listCollaborators(args.work_id),
this.pica.credits.listForWork(args.work_id),
]);
const collaborators = collabSettled.status === "fulfilled"
? collabSettled.value
: { collaborators: [], count: 0 };
const recordingCredits = creditsSettled.status === "fulfilled" ? creditsSettled.value : [];
// Merge both into a single list
const collabArray = Array.isArray(collaborators)
? collaborators
: collaborators?.collaborators || [];
const creditArray = Array.isArray(recordingCredits)
? recordingCredits
: recordingCredits?.credits || [];
const allCredits = [...collabArray, ...creditArray];
return formatStructuredList(allCredits, "credit", {
work_id: args.work_id,
});
}
async updateCredits(args) {
// Split credits by grain: composition roles → /collaborators
// (work_collaborators), master/owner roles → /credits (recording_credits /
// recording_splits). ADR-252: publisher / sub-publisher are composition
// roles too — a publisher is its own work_collaborators entry, not a field
// on a writer's credit.
const PUBLISHING_ROLES = new Set([
"writer",
"composer",
"lyricist",
"arranger",
"publisher",
"sub-publisher",
]);
const isCompositionRole = (role) => PUBLISHING_ROLES.has(String(role || "writer")
.split(", ")[0]
.trim());
// Every credit needs a person_id — the input schema requires it, but
// models omit it when the user names someone not yet in the catalog
// (e.g. "add co-writer John Smith"). Without this check the missing id
// flows to the routes' NOT NULL columns as an opaque failure. Response-only
// guidance — no schema change.
const missingPersonIdx = args.credits.findIndex((c) => typeof c?.person_id !== "string" || c.person_id.trim() === "");
if (missingPersonIdx !== -1) {
return {
content: [
{
type: "text",
text: `credits[${missingPersonIdx}] has no person_id — every credit ` +
`needs a person from the catalog. A name alone can't be ` +
`credited on a work: find the person with pica_people_query, ` +
`or create them with pica_people_create, then retry with ` +
`their person_id.`,
},
],
isError: true,
};
}
// attestation_status is not caller-settable. Since 2026-09-29 the
// collaborators route rejects it (UNKNOWN_FIELD) and
// replace_work_collaborators forces every inserted row to 'pending'
// (migration 20260929190000) — a caller could otherwise fabricate
// "attested" with no attestation ever happening. This tool kept
// advertising and forwarding it, so agents sent it and the whole write
// failed (ops 00d9e736, 9a1cb927). Refuse it here, before anything is
// written, with the way to actually get a credit confirmed — never
// silently drop it, or the caller believes the credit is attested.
const attestationIdx = args.credits
.map((c) => c ?? {})
.findIndex((c) => Object.hasOwn(c, "attestation_status"));
if (attestationIdx !== -1) {
return typedError("ATTESTATION_NOT_SETTABLE", `credits[${attestationIdx}] carries attestation_status, which this ` +
`tool can't set — nothing was saved. Every credit starts as ` +
`pending and only the credited person can confirm it. Remove ` +
`attestation_status from every entry and retry (put any source ` +
`evidence in notes), then call pica_credits_send_for_attestation ` +
`so each person can confirm their own credit.`, { field: "attestation_status", index: attestationIdx });
}
// ADR-252: the 100% rule is a COMPOSITION concept (writer/publisher shares).
// Master/recording credits carry independent, unconstrained splits (a
// producer takes points; a vocalist may have 0%). Validate the total over
// the composition subset only — summing across grains would reject a valid
// mixed write (e.g. writer 50% + producer 40%).
const compositionCredits = args.credits.filter((c) => isCompositionRole(c.role));
const compositionSplitTotal = compositionCredits.reduce((sum, c) => sum + (c.splits || 0), 0);
// ⚠️ Bounds check, NOT `!== 100`. This pre-flight ran BEFORE the API call,
// so the route's ±0.01 tolerance (lib/validation/split-closure.ts,
// SPLIT_CLOSURE_EPSILON) was unreachable from the one tool ops issue
// c3671d8a came from: 33.34+33.34+33.33 === 100.01 and 33.33*3 === 99.99,
// both `!== 100`, both refused here while the description promised
// "accepted AS SIGNED" (review, 2026-09-11). This package cannot import
// lib/, so the epsilon is restated; the route remains the judge of
// record and `credits.test.ts` pins that both sheets pass this gate.
const SPLIT_CLOSURE_EPSILON = 0.01;
const closes = compositionSplitTotal >= 100 - SPLIT_CLOSURE_EPSILON &&
compositionSplitTotal <= 100 + SPLIT_CLOSURE_EPSILON;
if (compositionSplitTotal > 0 && !closes) {
return {
content: [
{
type: "text",
text: `Composition splits total ${Number.isInteger(compositionSplitTotal) ? compositionSplitTotal : compositionSplitTotal.toFixed(2)}%, must total 100% (±${SPLIT_CLOSURE_EPSILON}; a signed sheet reading 33.34/33.34/33.33 is fine — enter it as written). Recording credits are tracked separately and don't count toward this.`,
},
],
isError: true,
};
}
// ADR-252: only fields with a canonical home survive. is_primary and
// notes live on work_collaborators (composition); notes is also on
// recording_credits (master). attestation_status is refused above.
// territory / rights_type / start_date / end_date / publisher_id /
// publisher_split_percentage had no home after the work_credits drop and
// are no longer accepted — see write-constants.ts.
const allCredits = args.credits.map((c) => {
const isComposition = PUBLISHING_ROLES.has(String(c.role || "writer")
.split(", ")[0]
.trim());
const credit = {
person_id: c.person_id,
role: c.role,
splits: c.splits || 0,
};
// is_primary is composition-only (recording_credits has no such column).
// Only forward it on the composition path so it can't silently evaporate.
if (isComposition && c.is_primary !== undefined)
credit.is_primary = c.is_primary;
if (c.notes !== undefined)
credit.notes = c.notes;
return credit;
});
const publishingCredits = allCredits.filter((c) => PUBLISHING_ROLES.has(String(c.role || "writer")
.split(", ")[0]
.trim()));
const recordingCredits = allCredits.filter((c) => !PUBLISHING_ROLES.has(String(c.role || "writer")
.split(", ")[0]
.trim()));
let credits;
// Compact receipt by default (ops 61ce1dcc); verbose restores the raw
// route result.
const verbose = args.verbose === true;
// Route to correct endpoint(s)
if (publishingCredits.length > 0 && recordingCredits.length > 0) {
// Mixed — call both endpoints, use allSettled so a partial failure is reported not silently swallowed
const [pubSettled, recSettled] = await Promise.allSettled([
this.pica.credits.updateCollaborators(args.work_id, {
credits: publishingCredits,
}),
this.pica.credits.updateForWork(args.work_id, {
credits: recordingCredits,
}),
]);
const pubResult = pubSettled.status === "fulfilled" ? pubSettled.value : null;
const recResult = recSettled.status === "fulfilled" ? recSettled.value : null;
const pubError = pubSettled.status === "rejected"
? pubSettled.reason
: null;
const recError = recSettled.status === "rejected"
? recSettled.reason
: null;
if (pubError && recError) {
return typedError("CREDITS_UPDATE_FAILED", `both publishing and recording credits failed to save — publishing error: ${pubError?.message ?? pubError}, recording error: ${recError?.message ?? recError}`);
}
if (pubError) {
return typedError("PUBLISHING_CREDITS_FAILED", `recording credits updated, but publishing credits failed — ${pubError?.message ?? pubError}. retry the publishing portion.`);
}
if (recError) {
return typedError("RECORDING_CREDITS_FAILED", `publishing credits updated, but recording credits failed — ${recError?.message ?? recError}. retry the recording portion.`);
}
credits = { collaborators: pubResult, credits: recResult };
}
else if (publishingCredits.length > 0) {
credits = await this.pica.credits.updateCollaborators(args.work_id, {
credits: publishingCredits,
});
}
else {
credits = await this.pica.credits.updateForWork(args.work_id, {
credits: recordingCredits,
});
}
// Check completeness for hints about next steps
const hints = [];
try {
const completeness = await this.pica.health.getWorkCompleteness(args.work_id);
hints.push(...mapGapsToHints(completeness?.gaps || completeness?.missing || [], 3));
}
catch {
// Best-effort
}
// Notify all credited people about their credit
const parties = args.credits
.filter((c) => c.person_id)
.map((c) => ({
person_id: c.person_id,
message: `you've been credited as ${c.role || "writer"} (${c.splits || 0}%) on a work — please verify`,
work_id: args.work_id,
notification_type: "credit_added",
}));
let notifySummary = "";
if (parties.length > 0) {
const results = await notifyParties(this.pica, parties);
notifySummary = summarizeNotifications(results);
}
const msg = notifySummary
? `Credits updated. ${notifySummary}`
: "Credits updated successfully";
if (verbose)
return formatSuccess(msg, credits, hints);
// Same receipt shape as pica_works_update: the credits as written (who /
// what / share) plus the routes' own save result, which is already small
// (`count`, and any `skipped` rows the route refused — those must stay
// visible). The repeated integrity nags ops 61ce1dcc describes on the
// credit side come from pica_credit_add / pica_credit_remove, not this
// tool.
return formatSuccess(msg, {
work_id: args.work_id,
changed: {
credits: allCredits.map((c) => ({
person_id: c.person_id,
role: c.role,
splits: c.splits,
})),
},
saved: credits,
receipt: RECEIPT_NOTE,
}, hints);
}
/**
* ADR-213 Primitive B — pica_recording_credits_update
*
* Writes a single first-party recording credit to the recording_credits
* table via POST /admin/recordings/{recording_id}/credits.
*
* Validates role against RECORDING_CREDIT_ROLES (16 entries) before the
* round trip so we get a structured error rather than a generic 400 from
* the route. credited_name is required (NOT NULL on the table); the route
* also validates this but we surface it earlier.
*
* On success, includes a notification to the credited person if person_id
* is set, mirroring the work-side updateCredits notification flow.
*/
async updateRecordingCredits(args) {
// attestation_status is not caller-settable. An attestation is the
// credited person confirming their credit; until 2026-10-03 this tool
// advertised it and the single form forwarded it, so any caller could
// create a master credit already "attested". The route now refuses it
// too, and session-role writes are refused in the database (migration
// 20261003072610). Refuse here, before anything is written, with the way
// to actually get a credit confirmed — never silently drop it, or the
// caller believes the credit is attested. Same code and shape as the
// pica_credits_update refusal in open PR #2622.
const attestationRefusal = refuseRecordingAttestation(args);
if (attestationRefusal)
return attestationRefusal;
// Superset dispatch (write-merge fold, mirrors ReleasesTools.attachTrack)
// — subsumed pica_recording_credits_bulk_update (that name was removed in
// mcp-server 2.99.0). Both executors fully self-validate their own args.
if (Array.isArray(args.credits)) {
return this.bulkUpdateRecordingCredits(args);
}
const recordingId = args.recording_id;
const personId = (args.person_id ?? null);
const creditedName = (args.credited_name ?? "");
const role = args.role;
if (!recordingId) {
return {
content: [
{
type: "text",
text: "recording_id is required.",
},
],
isError: true,
};
}
if (!creditedName.trim()) {
return {
content: [
{
type: "text",
text: "credited_name is required (recording_credits.credited_name is NOT NULL).",
},
],
isError: true,
};
}
if (!role ||
!RECORDING_CREDIT_ROLES.includes(role)) {
return {
content: [
{
type: "text",
text: `Invalid role '${role}'. Must be one of: ${RECORDING_CREDIT_ROLES.join(", ")}. Songwriting credits (Writer, Composer, Lyricist, Arranger) go via pica_credits_update on the parent work.`,
},
],
isError: true,
};
}
let credit;
try {
credit = await this.pica.recordingCredits.create(recordingId, {
person_id: personId,
credited_name: creditedName.trim(),
role,
split_percentage: args.split_percentage ?? null,
split_type: args.split_type ?? null,
display_order: args.display_order ?? 0,
notes: args.notes ?? null,
instrument: args.instrument ?? null,
});
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// Which id was not found decides which tool the agent reaches for next,
// so read the route's OWN code rather than its prose.
//
// ⚠️ This branch used to test `/person_id|fk|foreign key|not found/i`
// against the whole message. `createCredit` resolves the recording
// org-scoped FIRST (`createCredit` in lib/services/recording-credits.ts)
// and throws
// `RECORDING_NOT_FOUND` — "recording not found in this organisation:
// <id>" — which contains "not found", matched here, and sent the agent
// to pica_people_query over a person that was never the problem.
// Logged on prod as ops issue 8711808a (2026-09-09); the body named
// RECORDING_NOT_FOUND and the recording's id the whole time.
//
// The single-credit path does not org-validate `person_id` at all (only
// `bulkApply` does), so a genuine bad person surfaces as an FK violation
// from the insert with no route code — hence the split below.
const existing = recordingCreditExists(err);
if (existing)
return existing;
const body = parseApiErrorBody(err);
if (body.code === "RECORDING_NOT_FOUND") {
return typedError("RECORDING_NOT_FOUND", `Failed to create recording credit — recording '${recordingId}' does not exist in this organisation. Resolve via pica_recordings_query then retry. Underlying error: ${errorMessage}`);
}
// ⚠️ Gate on the route's CODE, not on the absence of one. The first cut
// of this fix used `!body.code`, which is never true in production:
// `errorResponse` defaults `code` to "INTERNAL_ERROR"
// (lib/utils/api-response.ts), so every error body carries one and the
// branch was dead. It read as live only because the test fed it a bare
// `new Error("foreign key violation on person_id — not found")` — a
// shape the SDK cannot produce, since ApiError always prefixes the
// status and appends the response body. Caught in review, 2026-09-10.
//
// `createCredit` now validates person_id org-scoped and throws a named
// PERSON_NOT_FOUND, so there is a real code to read.
if (body.code === "PERSON_NOT_FOUND") {
return typedError("PERSON_NOT_FOUND", `Failed to create recording credit — person_id '${personId}' does not exist in this organisation. Resolve via pica_people_query then retry. Underlying error: ${errorMessage}`);
}
return typedError("RECORDING_CREDIT_CREATE_FAILED", `Failed to create recording credit: ${errorMessage}`);
}
// Notify the credited person if structured (matches work-side pattern).
let notifySummary = "";
if (personId) {
const parties = [
{
person_id: personId,
message: `you've been credited as ${role} on a recording — please verify`,
notification_type: "credit_added",
},
];
try {
const results = await notifyParties(this.pica, parties);
notifySummary = summarizeNotifications(results);
}
catch {
// Best-effort — don't fail the create
}
}
const msg = notifySummary
? `Recording credit added. ${notifySummary}`
: "Recording credit added successfully";
return formatSuccess(msg, credit);
}
/**
* ADR-264 WS4 prerequisite — the bulk path of pica_recording_credits_update
* (reached when args.credits is an array). It was also the executor of the
* standalone pica_recording_credits_bulk_update tool until mcp-server 2.99.0
* removed that name.
*
* Thin wrapper over POST /admin/recording-credits/bulk via
* RecordingCreditsResource.bulkUpdate. The route + service own validation
* (role enum, org scoping of release/recordings/people, caps, in-batch
* dedup) and return per-row created / skipped_existing results; the
* executor only pre-checks the target shape so agents get a crisp message
* without a round-trip.
*/
async bulkUpdateRecordingCredits(args) {
const hasRelease = typeof args.release_id === "string" && args.release_id.trim() !== "";
const hasRecordings = Array.isArray(args.recording_ids) && args.recording_ids.length > 0;
if (hasRelease === hasRecordings) {
return {
content: [
{
type: "text",
text: "Provide exactly one target: release_id (credits every recording on the release's tracklist) OR a non-empty recording_ids list.",
},
],
isError: true,
};
}
const credits = Array.isArray(args.credits) ? args.credits : [];
if (credits.length === 0) {
return {
content: [
{
type: "text",
text: "credits must be a non-empty array — each entry needs credited_name + role.",
},
],
isError: true,
};
}
let result;
try {
result = await this.pica.recordingCredits.bulkUpdate({
release_id: hasRelease ? args.release_id : undefined,
recording_ids: hasRecordings ? args.recording_ids : undefined,
credits: credits.map((c) => ({
credited_name: c.credited_name,
role: c.role,
person_id: (c.person_id ?? null),
split_percentage: (c.split_percentage ?? null),
split_type: (c.split_type ?? null),
notes: (c.notes ?? null),
})),
dry_run: args.dry_run === true,
});
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// The route 400s with per-problem detail on ANY invalid input and
// applies nothing — relay it verbatim so the agent can fix the call.
return typedError("BULK_RECORDING_CREDITS_FAILED", `Bulk recording credits failed — nothing was applied. ${errorMessage}`);
}
const message = result.dry_run
? `Dry run — nothing persisted. Across ${result.target_recording_count} recording(s): ${result.would_create} credit row(s) would be created, ${result.would_skip_existing} already exist and would be skipped.`
: `Bulk credits applied across ${result.target_recording_count} recording(s): ${result.created} credit row(s) created, ${result.skipped_existing} already existed and were skipped.`;
return formatSuccess(message, result);
}
/**
* ADR-232 — pica_credit_add executor.
*
* Discriminates by entity_type. Works route through CreditsResource.atomicAdd
* (POST /admin/works/[id]/credits/atomic-add → service-layer createWorkCredit
* which classifies role → work_collaborators (composition) or recording_credits
* (master) / recording_splits (owner)). Recordings
* route through RecordingCreditsResource.atomicAdd (POST /admin/recordings/[id]/credits).
* Both return AtomicCreditResult with credit_id.
*/
/**
* pica_credit_update — in-place edit of one credit (2026-09-11).
*
* Closes two ops issues with one verb. `pica_credits_update` is replace-all,
* so an agent changing one writer's share had to re-list every work and
* re-send the whole array — nine reads plus nine writes for one split, with
* a publisher row one forgotten re-list from being dropped (bff0af25). And nothing
* edited a recording credit BY ITS ID — beyond the natural-key upsert a
* change meant remove-then-re-add, losing id and attestation (17f27449). Agents ALREADY reach for this name:
* `pica_credit_update` was the TOOL_NOT_FOUND miss in ops issue 167bb804,
* six times across three orgs.
*/
async creditUpdate(args) {
const workId = typeof args.work_id === "string" ? args.work_id : "";
const creditId = typeof args.credit_id === "string" ? args.credit_id : "";
if (!workId || !creditId) {
return typedError("VALIDATION_ERROR", "work_id and credit_id are both required — find the credit_id with pica_credits_list.");
}
const patch = {};
if (args.share_pct !== undefined) {
const v = args.share_pct;
if (v !== null &&
(typeof v !== "number" || Number.isNaN(v) || v < 0 || v > 100)) {
return typedError("VALIDATION_ERROR", "share_pct must be a number between 0 and 100, or null to record the share as unknown.");
}
patch.share_pct = v;
}
for (const key of [
"credit_type",
"credited_name",
"notes",
"instrument",
"person_id",
]) {
if (args[key] !== undefined)
patch[key] = args[key];
}
if (Object.keys(patch).length === 0) {
return typedError("VALIDATION_ERROR", "nothing to update — pass at least one of share_pct, credit_type, credited_name, notes, instrument, person_id.");
}
if (args.confirm_name_mismatch === true) {
patch.confirm_name_mismatch = true;
}
try {
const result = await this.pica.credits.atomicUpdate(workId, creditId, patch);
const allocation = result?.allocation;
const hints = [];
if (allocation?.status === "open") {
hints.push({
gap: "writer_splits_open",
suggestion: `Writer shares on this work now total ${allocation.writer_sum ?? "?"}%, not 100. If you are re-splitting one writer at a time, carry on; otherwise ask the user how the remainder is shared — do not redistribute it yourself.`,
severity: "important",
});
}
else if (allocation?.status === "incomplete") {
hints.push({
gap: "writer_share_unknown",
suggestion: "At least one writer on this work has no recorded share. Ask the user rather than assuming — an unknown share is stored as null, never invented.",
severity: "important",
});
}
const personChange = result?.person_change;
if (personChange?.attestation_reset) {
// person_change.message is the server's own sentence
// (describeCreditPersonChange in lib/services/credit-person-link.ts),
// so the agent hears the same account the routes give.
const what = personChange.message ??
`This credit's ${personChange.previous_attestation_status ?? "previous"} status was cleared; it is now ${personChange.attestation_status ?? "pending"}.`;
hints.push({
gap: "credit_confirmation_reset",
suggestion: `${what} The person now linked gave no confirmation of their own. Tell the user, and offer to ask the newly linked person to confirm (pica_credits_send_for_attestation on this work).`,
severity: "important",
});
}
return formatSuccess("Credit updated", result, hints);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
const body = parseApiErrorBody(err);
// Typed refusals of a change of person (lib/services/credit-person-link.ts).
// Checked before the generic status branches: PERSON_NOT_FOUND is a
// 404 and CREDIT_PERSON_CONFLICT a 409, and each needs its own next step.
if (body.code === "PERSON_NOT_FOUND" ||
body.code === "PERSON_DELETED" ||
body.code === "PERSON_REQUIRED" ||
body.code === "PERSON_NAME_MISMATCH" ||
body.code === "CREDIT_PERSON_CONFLICT" ||
body.code === "PERSON_LINK_IS_THE_CATALOGUES") {
return typedError(body.code, body.message ?? message, body.details ?? undefined);
}
if (body.code === "INSUFFICIENT_SCOPE" || body.status === 403) {
return typedError("INSUFFICIENT_SCOPE", `credit_id '${creditId}' is not a credit on work '${workId}' in this organisation. List the work's credits with pica_credits_list to find a valid id. Underlying error: ${message}`);
}
if (body.code === "VALIDATION_ERROR" || body.status === 400) {
return typedError("VALIDATION_ERROR", body.message ?? message);
}
return typedError("CREDIT_UPDATE_FAILED", `Failed to update credit: ${message}`);
}
}
async creditAdd(args) {
const entityType = args.entity_type;
const entityId = args.entity_id;
const personId = args.person_id;
if (!entityType || (entityType !== "work" && entityType !== "recording")) {
return {
content: [
{
type: "text",
text: "entity_type must be 'work' or 'recording'.",
},
],
isError: true,
};
}
if (!entityId) {
return {
content: [
{
type: "text",
text: "entity_id is required.",
},
],
isError: true,
};
}
if (!personId && entityType === "work") {
return {
content: [
{ type: "text", text: "person_id is required for work credits." },
],
isError: true,
};
}
const sharePct = args.share_pct;
if (sharePct !== undefined &&
sharePct !== null &&
(typeof sharePct !== "number" ||
Number.isNaN(sharePct) ||
sharePct < 0 ||
sharePct > 100)) {
return {
content: [
{
type: "text",
text: "share_pct must be a number between 0 and 100, or null (unknown).",
},
],
isError: true,
};
}
try {
if (entityType === "work") {
const creditType = (args.credit_type ?? "writer");
if (!WORK_CREDIT_TYPES.includes(creditType)) {
return {
content: [
{
type: "text",
text: `Invalid credit_type '${creditType}'. Must be one of: ${WORK_CREDIT_TYPES.join(", ")}.`,
},
],
isError: true,
};
}
const result = await this.pica.credits.atomicAdd(entityId, {
person_id: personId,
// ADR-252: publisher / sub-publisher are valid composition roles the
// SDK param type predates; the atomic-add route classifies them to
// work_collaborators exactly like writer/composer (role-classification
// already treats them as publishing). creditType is validated against
// WORK_CREDIT_TYPES above, so the cast only narrows for the SDK type.
credit_type: creditType,
share_pct: sharePct,
notes: args.notes,
});
// Registration integrity floor (spec 2026-07-09, Task 9) — assess
// the credited work and attach both the rendered hints and the raw
// gap block. Best-effort: never blocks or fails the write.
const pressure = await buildIntegrityPressure(this.pica, "work", entityId);
return formatSuccess(`Credit added (credit_id ${result.credit_id})`, { ...result, integrity: pressure.integrity }, pressure.hints);
}
// entity_type === "recording"
const role = args.role;
const creditedName = args.credited_name?.trim();
if (!role ||
!RECORDING_CREDIT_ROLES.includes(role)) {
return {
content: [
{
type: "text",
text: `Invalid role '${role}'. For recordings, role must be one of: ${RECORDING_CREDIT_ROLES.join(", ")}.`,
},
],
isError: true,
};
}
if (!creditedName) {
return {
content: [
{
type: "text",
text: "credited_name is required for recordings (recording_credits.credited_name is NOT NULL).",
},
],
isError: true,
};
}
const result = await this.pica.recordingCredits.atomicAdd(entityId, {
person_id: personId ?? null,
credited_name: creditedName,
role,
split_percentage: sharePct ?? null,
notes: args.notes ?? null,
});
// Registration integrity floor (Task 9) — same pressure rendering as
// the work-credit branch above.
const pressure = await buildIntegrityPressure(this.pica, "recording", entityId);
return formatSuccess(`Recording credit added (credit_id ${result.credit_id})`, { ...result, integrity: pressure.integrity }, pressure.hints);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const existing = recordingCreditExists(err);
if (existing)
return existing;
// A 4xx carrying a machine code is the route REFUSING this call for a
// reason it names (VALIDATION_ERROR, PERSON_ORG_MISMATCH,
// RECORDING_TARGET_REQUIRED, SHARES_ARE_SUGGESTIONS, …). Relay that
// code and message instead of folding it into CREDIT_ADD_FAILED, which
// reads as a transport failure and invites a blind retry — ops
// 704708a1 was a 400 VALIDATION_ERROR reported as CREDIT_ADD_FAILED.
const refusal = parseApiErrorBody(err);
if (refusal.code &&
refusal.status !== undefined &&
refusal.status >= 400 &&
refusal.status < 500) {
// Siblings first, so a route key can never overwrite the envelope's
// own `error` / `message` (typedError writes those last).
return typedError(refusal.code, refusal.message ?? errorMessage, {
...refusal.extra,
...(refusal.details !== undefined
? { details: refusal.details }
: {}),
});
}
return typedError("CREDIT_ADD_FAILED", `Failed to add credit: ${errorMessage}`);
}
}
/**
* ADR-232 — pica_credit_remove executor. Atomic DELETE with constant-time
* existence semantics. ADR-252: work credits route directly to
* work_collaborators (composition) / recording_credits (master) via
* atomicRemove — `work_credits` is DROPPED. The recording credits endpoint
* answers 403 INSUFFICIENT_SCOPE on a 0-row delete for EVERY caller as of
* 2026-08-24; the `?atomic=1` the SDK still appends no longer selects a
* behaviour and is accepted-and-ignored for one release.
*/
async creditRemove(args) {
const entityType = args.entity_type;
const entityId = args.entity_id;
const creditId = args.credit_id;
if (!entityType || (entityType !== "work" && entityType !== "recording")) {
return {
content: [
{
type: "text",
text: "entity_type must be 'work' or 'recording'.",
},
],
isError: true,
};
}
if (!entityId) {
return {
content: [{ type: "text", text: "entity_id is required." }],
isError: true,
};
}
if (!creditId) {
return {
content: [{ type: "text", text: "credit_id is required." }],
isError: true,
};
}
try {
const result = entityType === "work"
? await this.pica.credits.atomicRemove(entityId, creditId)
: await this.pica.recordingCredits.atomicRemove(entityId, creditId);
// Registration integrity floor (spec 2026-07-09, Task 9) — a removed
// credit can reintroduce WORK_NO_WRITER / RECORDING_NO_OWNER, so
// assess the entity post-removal. Best-effort: never blocks or fails
// the delete.
const pressure = await buildIntegrityPressure(this.pica, entityType, entityId);
return formatSuccess(`Credit removed (credit_id ${creditId})`, { ...result, integrity: pressure.integrity }, pressure.hints);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// INSUFFICIENT_SCOPE bubbles up from the API as a 403 — surface the
// message to the agent with no existence leak. If we can't tell from
// the error string, fall through to a generic message.
if (/INSUFFICIENT_SCOPE|insufficient_scope|403/i.test(errorMessage)) {
return typedError("INSUFFICIENT_SCOPE", `credit_id '${creditId}' is not in scope. The credit either does not exist, belongs to a different organisation, or is not on the ${entityType} you named as entity_id — list that ${entityType}'s credits and retry with the right entity_id.`);
}
return typedError("CREDIT_REMOVE_FAILED", `Failed to remove credit: ${errorMessage}`);
}
}
}
//# sourceMappingURL=credits.js.map