UNPKG

@withpica/mcp-server

Version:

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

1,571 lines • 90.1 kB
// 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