{"name":"Biloh Platform MCP Server","description":"Model Context Protocol server for the Biloh platform. Platform-admin scope: exposes tenant-management tools and the superset of tenant tools (callable with target_tenant_id).","endpoint":"https://biloh.com.au/api/mcp/mcp","transport":"http","spec":"https://modelcontextprotocol.io","scope":"platform","tenant":null,"tool_count":281,"tools":[{"name":"list_clients","description":"Returns clients for the authenticated user's tenant. Supports filtering by status, free-text search via `query` (case-insensitive substring match across legal_name, trading_name, and billing_email), and pagination via limit/offset. Excludes soft-deleted rows. Excludes test rows (is_test=true) by default — pass include_test:true to include them.\n\nUSE WHEN: Browsing or searching the client roster, building a picker UI, surfacing a count, or resolving a name to an ID before another tool call.\n\nDON'T USE WHEN: You already have the client ID — use get_client. Looking up contractors — use list_contractors. Looking up sites — use list_sites with a client_id filter.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_client","description":"Returns a single client by ID for the authenticated user's tenant. Returns the full client row including legal_name, trading_name, ABN, billing_email, status, and metadata.\n\nUSE WHEN: You already have a client ID and need the full row — opening a client detail view, denormalising for an outgoing email, or sanity-checking before a write.\n\nDON'T USE WHEN: You don't yet have the ID — use list_clients with a `query` first. You need the client's sites — use list_sites with this client_id. You need contacts — use list_site_contacts.\n\nPRECONDITIONS: `id` is a valid UUID of an existing, non-deleted client in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_client_footprint","description":"Returns a one-shot, read-only dependency rollup for a client (or contractor/site): counts + sample ids of every attached site, contract_service_line (active vs inactive), job (by status), proposal (by status), invoice (by status), and payable, plus the parent's is_test/deleted_at and a test/production child split (`test_rollup`). Counts include test and production children (footprint is a cleanup/offboarding aid, not a dashboard); `test_rollup.is_test_false_count` surfaces live production children hanging off an archived parent. `ids` are capped at 10 per type; `count` is the true total.\n\nUSE WHEN: 'What depends on this client?' before archiving/offboarding, a pre-deletion impact preview, churn analysis, or checking for orphaned live children under an archived parent.\n\nDON'T USE WHEN: You need the full child rows — use the list_* tools (list_sites, list_contract_service_lines, list_jobs, list_proposals, list_invoices, list_payables). You need to MUTATE — this is read-only.\n\nPRECONDITIONS: `entity_type` is one of client|contractor|site and `entity_id` is a UUID in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_client_portal_link","description":"Returns the client's persistent portal URL (the /portal/client/{token} link) and its underlying portal token. Mirror of get_contractor_portal_link (WP-CLIENT-PORTAL-LINK-RECOVERY, bugs e843138b + 2178dc8f). Idempotent: reuses the existing token if one is set, mints a new one (read-or-UPDATE over the clients.portal_token column) if not. The URL host is the tenant's canonical domain.\n\nUSE WHEN: A client lost their confirmation email and needs their portal link back, you need to open or verify the client portal, or you're embedding the link in a message to the operator. get_client does NOT return portal_token; this is the canonical way to obtain it.\n\nDON'T USE WHEN: You want the client's full record — use get_client. You need a proposal signing link — that's a separate token space (the proposal accept route). You want a contractor's portal — use get_contractor_portal_link.\n\nPRECONDITIONS: `client_id` is a valid UUID of an existing, non-deleted client in the caller's tenant.\n\nSIDE EFFECTS: Mints and persists clients.portal_token if it was null (idempotent — no-op when already set); emits portal_token.minted exactly once on a fresh mint. No email, no money movement, no destructive change. To EMAIL the link to the client, use the operator resend action on the client page (POST /api/clients/{id}/resend-portal-link) — it only ever sends to the client's stored email."},{"name":"resend_client_portal_link","description":"Email a client's persistent portal link (the /portal/client/{token} URL) to their stored billing email. The delivery half of lost-email portal recovery — the MCP-native mirror of the operator UI 'Resend portal link' action (POST /api/clients/[id]/resend-portal-link). Reuses the client's existing portal_token, minting one if null (idempotent, like get_client_portal_link). Open-relay guarded: only ever sends to the client's own stored billing_email, never a caller-supplied recipient (same guard pattern as send_operator_report_email). Returns {client_id, sent, provider_message_id, portal_url}.\n\nUSE WHEN: A client lost their portal/confirmation email and you need to re-deliver their portal link, or you retrieved the link with get_client_portal_link and now need to send it to the client from a chat-first/non-repo session without leaving MCP for the dashboard.\n\nDON'T USE WHEN: You only need the link string and not a send — use get_client_portal_link. You want a contractor's portal — use get_contractor_portal_link (no resend analogue yet). You want to email a proposal, invoice, or statement — those have their own propose_send_* then approve_send_* flows.\n\nPRECONDITIONS: client_id is a valid UUID of an existing, non-deleted client in the caller's tenant, and that client has a non-empty billing_email on file (otherwise a structured no_client_email error is returned and nothing is sent).\n\nSIDE EFFECTS: Sends one branded portal email to the client's stored billing_email (mocked in tests, never hits Resend). Mints and persists clients.portal_token if it was null (idempotent — no-op when already set). Writes a communication_log row (type 'portal_link_sent') plus an audit_log row, and emits client.updated (action 'portal_link_resent'). No money movement."},{"name":"list_sites","description":"Returns sites for the authenticated user's tenant. Optionally filtered by client_id. Excludes soft-deleted rows.\n\nUSE WHEN: Listing every site for a client (pass client_id), browsing all sites across the tenant, or surfacing a site picker before a job-creation call.\n\nDON'T USE WHEN: You already have the site ID — use get_site. You need site contacts — use list_site_contacts.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_site","description":"Returns a single site by ID for the authenticated user's tenant. Returns address, geo coords, notes (access / hazards / legal), associated client_id, and metadata.\n\nUSE WHEN: You already have a site ID and need the full row — opening a site detail view, denormalising for an outgoing work order, or sanity-checking before scheduling.\n\nDON'T USE WHEN: You don't yet have the ID — use list_sites (optionally filtered by client_id). You need site contacts — use list_site_contacts.\n\nPRECONDITIONS: `id` is a valid UUID of an existing, non-deleted site in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_site_contacts","description":"Returns contacts for a site (or every contact across the tenant if site_id is omitted). Canonical table is `site_contacts` — the legacy `client_contacts` table is deprecated. Each contact has name, role, email, phone, and is_primary flag.\n\nUSE WHEN: Resolving who to address an outgoing communication to before a send call, or surfacing a contact picker. Looking up the primary contact for a site.\n\nDON'T USE WHEN: Looking up the client billing email — that's on the client row (get_client.billing_email). Adding a new contact — use create_site_contact.\n\nPRECONDITIONS: If site_id is supplied, it must reference an existing site in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"lookup_abn","description":"Looks up an Australian business via the Australian Business Register (ABR). Supports two modes: (1) ABN lookup — provide `abn` to get verified details for a known ABN; (2) Name search — provide `name` (and optionally `state`) to find businesses by name, returns up to 5 matches. Works for businesses, body corporates, sole traders, trusts, and partnerships.\n\nUSE WHEN: About to create a client or contractor — verify their ABN and pre-populate legal name, trading name, entity type, and GST status from the authoritative register.\n\nDON'T USE WHEN: The entity is non-Australian or has no ABN — collect details directly via create_client. The entity already exists in the local DB and you just need the existing row — use get_client.\n\nPRECONDITIONS: Either `abn` (11 digits, spaces optional) or `name` (free text) is supplied, not both.\n\nSIDE EFFECTS: None — read-only network call to the ABR. Does not write any DB row."},{"name":"create_client","description":"Creates a new client record for the tenant. A client is a business entity that contracts the tenant for services. When billing_email is provided, a default site and billing contact are auto-created so the client's contact details flow onto invoices automatically.\n\nUSE WHEN: Onboarding a new client who will contract services. Capturing a new lead before any sites or services are wired up.\n\nDON'T USE WHEN: Adding a contractor — use create_contractor. Adding a service location — use create_site (the client must exist first). Wiring service pricing — use create_contract_service_line. Adding a contact person — use create_site_contact (contacts belong to sites, not clients).\n\nPRECONDITIONS: `legal_name` is non-empty. If `abn` is supplied, prefer lookup_abn first to verify and pre-populate trading_name + entity_type + GST status.\n\nSIDE EFFECTS: Inserts one row into `clients` with `is_test` set per arg (default false). When `sites[]` is supplied, creates those service locations (and suppresses the auto-default); otherwise when billing_email is supplied, creates a single default site named from the trading name. A billing site_contact (from billing_email) attaches to the first site. Writes audit_log row (action `create`, entity_type `client`)."},{"name":"update_client","description":"Updates an existing client's business details, billing info, payment terms, or status. Only provided fields are changed.\n\nUSE WHEN: Correcting client details, changing status (lead/active/inactive), updating billing email or payment terms.\n\nDON'T USE WHEN: Adding sites — use create_site. Adding contacts — use create_site_contact. Setting up service pricing — use create_contract_service_line. Permanently retiring a client — use archive_client.\n\nPRECONDITIONS: `id` references an existing, non-deleted client in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `clients` row. Writes audit_log row (action `update`, entity_type `client`) including the diff of changed fields."},{"name":"create_site","description":"Creates a new site (service location) linked to a client. Sites represent physical locations where services are delivered. Use `access_notes` for contractor access instructions and `legal_notes` for compliance/legal conditions specific to this site.\n\nUSE WHEN: Adding a service location for an existing client. Onboarding a multi-site client (call once per location).\n\nDON'T USE WHEN: Creating the client itself — use create_client first. Adding service pricing — that belongs on contract_service_line. Adding a contact person for the site — use create_site_contact.\n\nPRECONDITIONS: `client_id` references an existing, non-deleted client in the caller's tenant. Address fields are well-formed enough for geocoding (street + suburb + state + postcode).\n\nSIDE EFFECTS: Inserts one row into `sites` with `is_test` inherited from the client unless overridden. Writes audit_log row (action `create`, entity_type `site`)."},{"name":"create_site_contact","description":"Creates a contact person at a site. Contacts receive notifications based on their `contact_role` (owner, primary, billing, backup, emergency, technical). Role defaults auto-set notification flags; explicit flag values override defaults.\n\nPhone model: `phone` is the primary number (often a landline); `mobile` is the secondary/mobile number (storage-only, pending SMS integration).\n\nUSE WHEN: Adding a person who should be contactable at a site — for proposals, invoices, work orders, statements, or emergencies. Designating who receives a specific communication type.\n\nDON'T USE WHEN: Adding a contractor's contact details — use create_contractor or update_contractor. Updating an existing contact's role — use update_site_contact. Replacing the client's billing email — that lives on the `clients` row via update_client.\n\nPRECONDITIONS: `site_id` references an existing site in the caller's tenant. At least one of email or phone is supplied so the contact is reachable.\n\nSIDE EFFECTS: Inserts one row into `site_contacts`. If `contact_role` is supplied without explicit flag overrides, notification flags are seeded from the role-defaults table. Writes audit_log row (action `create`, entity_type `site_contact`)."},{"name":"update_site_contact","description":"Updates a site contact's details or notification preferences. Only provided fields are changed.\n\nPhone model: `phone` is the primary number (often a landline); `mobile` is the secondary/mobile number (storage-only, pending SMS integration).\n\nUSE WHEN: Changing a contact's phone/mobile/email, role, or which notifications they receive.\n\nDON'T USE WHEN: Moving a contact to a different site — archive the old contact and create a new one on the target site. Adding a new contact — use create_site_contact.\n\nPRECONDITIONS: `id` references an existing site contact in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `site_contacts` row. Writes audit_log row (action `update`, entity_type `site_contact`) including the diff of changed fields."},{"name":"update_site","description":"Updates an existing site's name, address, or notes. Only provided fields change; omitted fields are preserved.\n\nUse `access_notes` for contractor access instructions (keys, gate codes, hours) and `legal_notes` for site-specific compliance/legal conditions — both flow downstream into work orders.\n\nUSE WHEN: Renaming a site (e.g. fixing an auto-created '<Client> — Default' site to a proper handle), correcting an address, or adding/changing access or legal notes after creation.\n\nDON'T USE WHEN: Creating a site — use create_site. Editing a site contact's details — use update_site_contact. Moving a site to a different client — not supported (archive and recreate).\n\nPRECONDITIONS: `site_id` references an existing, non-deleted site in the caller's tenant. At least one updatable field (name/address/access_notes/legal_notes) is provided.\n\nSIDE EFFECTS: Updates the matching `sites` row (sets updated_by/updated_at). Writes audit_log row (action `update`, entity_type `site`) including the diff of changed fields. Emits `site.updated`."},{"name":"archive_client","description":"Soft-deletes a client (sets `deleted_at`). The client row remains in the database for audit history and FK references from contracts/invoices/jobs, but is excluded from active listings. Architect-only due to business consequences.\n\nUSE WHEN: A client relationship has permanently ended and the operator wants the client removed from active dashboards.\n\nDON'T USE WHEN: Temporarily pausing — set status to `inactive` via update_client instead. The client still has open jobs/proposals/invoices — resolve those first; archive while open is allowed but leaves orphans.\n\nPRECONDITIONS: Architect persona. `id` references an existing, non-archived client in the caller's tenant. No optimistic-concurrency control — `clients` has no `lock_version` column, so this soft-delete takes no `expected_lock_version` (unlike `archive_contractor` / `archive_proposal`, which require one).\n\nSIDE EFFECTS: Sets `clients.deleted_at = NOW()` on the matching row. Writes audit_log row (action `archive`, entity_type `client`) including the optional `reason`. Sites, contacts, contracts on this client are NOT cascaded — they remain queryable but the client appears archived."},{"name":"archive_site","description":"Soft-deletes a site (sets `deleted_at`). The site row remains in the database for audit history and FK references from jobs/contracts/invoices, but is excluded from active listings. Architect-only due to business consequences.\n\nUSE WHEN: A site is permanently no longer serviced (client closed the location, sold the premises, etc.).\n\nDON'T USE WHEN: Temporarily suspending — deactivate the contract_service_lines for the site instead. The client is leaving — use archive_client (sites are not auto-cascaded but the client's archived state hides them in roll-ups).\n\nPRECONDITIONS: Architect persona. `id` references an existing, non-archived site in the caller's tenant. No optimistic-concurrency control — `sites` has no `lock_version` column, so this soft-delete takes no `expected_lock_version` (unlike `archive_contractor` / `archive_proposal`, which require one).\n\nSIDE EFFECTS: Sets `sites.deleted_at = NOW()` on the matching row. Writes audit_log row (action `archive`, entity_type `site`) including the optional `reason`. Site contacts and contract service lines are NOT cascaded."},{"name":"list_contractors","description":"Returns contractors for the authenticated user's tenant with pagination. Excludes soft-deleted rows. Excludes test rows (is_test=true) by default — pass include_test:true to include them.\n\nUSE WHEN: Browsing or searching the contractor roster, building a picker, resolving a contractor name to an ID, or selecting candidates for a job assignment.\n\nDON'T USE WHEN: You already have the contractor ID — use get_contractor. Looking up clients — use list_clients. You want a ranked suggestion for a specific job — use suggest_contractors_for_job.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_contractor","description":"Returns a single contractor by ID for the authenticated user's tenant. Returns business details, ABN, primary contact, sma_status, availability, capabilities, and denormalised compliance snapshot.\n\nUSE WHEN: You already have a contractor ID and need the full row — opening a contractor detail view, denormalising for an outgoing work order, or checking sma_status / compliance before dispatch.\n\nDON'T USE WHEN: You don't yet have the ID — use list_contractors. You need a quote — use list_contractor_quotes. You need compliance documents — use list_contractor_compliance_documents.\n\nPRECONDITIONS: `id` is a valid UUID of an existing, non-deleted contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_contractor_portal_link","description":"Returns the contractor's persistent portal URL (the /portal/contractor/{token} link) and its underlying portal token. Mirrors the client portal_token surface. Idempotent: reuses the existing token if one is set, mints a new one (read-or-INSERT over the contractors.portal_token column) if not. The URL host is the tenant's canonical domain.\n\nUSE WHEN: You need to open or share a contractor's portal (jobs, compliance, profile) — e.g. driving the contractor portal during dogfood, embedding the link in an operator note, or verifying the portal renders. get_contractor does NOT return a portal_token; this is the canonical way to obtain it.\n\nDON'T USE WHEN: You want the contractor's full business record — use get_contractor. You need a work-order or SMA signing link — those are a separate token space (the WO/SMA accept routes), not the persistent portal token. You want a client's portal link — use get_client_portal_link (the symmetric client tool).\n\nPRECONDITIONS: `contractor_id` is a valid UUID of an existing, non-deleted contractor in the caller's tenant.\n\nSIDE EFFECTS: Mints and persists contractors.portal_token if it was null (idempotent — no-op when already set); emits portal_token.minted exactly once on a fresh mint. No email, no money movement, no destructive change."},{"name":"list_contractor_agreements","description":"Returns agreements (SMAs etc.) for a contractor with their current status (draft / sent / accepted / declined / expired).\n\nUSE WHEN: Checking what SMAs exist for a contractor before creating a new one, verifying an accepted-and-signed agreement exists before dispatch, or surfacing agreement history in a UI.\n\nDON'T USE WHEN: Sending an agreement — use the propose_send_contractor_agreement / approve_send_contractor_agreement pair. Creating a new one — use create_contractor_agreement.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_contractor_quotes","description":"Returns quotes a contractor has submitted for site/service combinations. Quotes are contractor-side reference data used when negotiating contract_service_line rates.\n\nUSE WHEN: Reviewing what a contractor has previously quoted before agreeing a client rate, comparing quotes across contractors for the same site/service.\n\nDON'T USE WHEN: Setting the agreed client-facing rate — use create_contract_service_line. Looking up the actual contracted rate on a live service line — use list_contract_service_lines.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_contractor_compliance_documents","description":"Returns compliance documents (insurance certificates, ABN verifications, workers comp, etc.) for a contractor with their verification status and expiry dates.\n\nUSE WHEN: Checking compliance status before dispatching a contractor to a job, auditing what documents are on file, or surfacing the expiry timeline for renewal reminders.\n\nDON'T USE WHEN: Recording a new document — use create_contractor_compliance_document. Marking a document verified — use verify_compliance_document (architect-only).\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_contractor","description":"Creates a new contractor (sub-contractor) for the tenant. Contractors are businesses that perform services on behalf of the tenant. Captures business_name, ABN, contact details, capabilities, and initial sma_status.\n\nUSE WHEN: Onboarding a new sub-contractor who will deliver services.\n\nDON'T USE WHEN: Creating a client — use create_client. Adding client-specific pricing — that belongs on contract_service_line. Setting up the SMA itself — use create_contractor_agreement after this call. Adding insurance/compliance records — use create_contractor_compliance_document.\n\nPRECONDITIONS: `business_name` is non-empty. If ABN is supplied, prefer lookup_abn first to verify.\n\nSIDE EFFECTS: Inserts one row into `contractors` with `is_test` per arg (default false). Writes audit_log row (action `create`, entity_type `contractor`)."},{"name":"update_contractor","description":"Updates a contractor's business details, insurance, banking, availability, or capabilities. Most fields are operator-accessible; banking fields (`bank_bsb`, `bank_account_number`, `bank_account_name`) require architect persona.\n\nUSE WHEN: Updating contractor details, insurance info, availability, or service capabilities. Operator-side updates of contact info.\n\nDON'T USE WHEN: Recording a contractor's quote for a specific site/service — use create_contractor_quote. Client-specific rates — those belong on contract_service_line. Permanently retiring a contractor — use archive_contractor.\n\nPRECONDITIONS: `id` (or its alias `contractor_id` — pass either) references an existing, non-archived contractor in the caller's tenant. Banking field writes require architect persona. Unrecognised or read-only fields (e.g. `sma_signed`) are rejected with an error naming the field and the correct path — they are never silently ignored.\n\nSIDE EFFECTS: Updates the matching `contractors` row. Writes audit_log row (action `update`, entity_type `contractor`) including the diff of changed fields."},{"name":"contractor_visible_notes","description":"Read or write the CONTRACTOR-FACING free-text note on a payable or job. This note is DISTINCT from the operator-only `notes` column — the internal `notes` is never shown to a contractor; only this note crosses to the contractor portal (a deliberate, leak-safe channel for field-worker context). USE WHEN: recording a note a contractor SHOULD see (site access, what to bring, a heads-up); reading what note is set; auditing every contractor-visible note across the tenant (operation 'bulk_read'). DON'T USE WHEN: writing operator-internal commentary — use update_job / update_contractor `notes`. PRECONDITIONS: operation 'get'/'set' require entity_type ('payable'|'job') + entity_id in the caller's tenant; 'set' requires note_text (empty string clears the note). SIDE EFFECTS: 'set' updates contractor_visible_note on the matching row (scoped to the caller's tenant). 'get'/'bulk_read' are read-only."},{"name":"archive_contractor","description":"Soft-deletes a contractor (sets `deleted_at`). The contractor row remains in the database for audit history and FK references from existing jobs/contracts, but is excluded from active listings and assignment pickers. Architect-only.\n\nUSE WHEN: A contractor relationship has permanently ended.\n\nDON'T USE WHEN: Temporarily pausing — update `sma_status` or availability via update_contractor instead. The contractor still has open jobs — reassign them first or accept they'll show with an archived contractor reference.\n\nPRECONDITIONS: Architect persona. `id` references an existing, non-archived contractor in the caller's tenant.\n\nSIDE EFFECTS: Sets `contractors.deleted_at = NOW()`. Writes audit_log row (action `archive`, entity_type `contractor`) including the optional `reason`. Compliance docs and quotes remain attached and queryable."},{"name":"create_contractor_contact","description":"Creates a contact person for a contractor. Each contact is a person with name, role, phone, email, and notification preferences.\n\nUSE WHEN: Adding a person who should be contactable for a contractor — for work orders, invoices, quotes, or general communications. Capturing multiple contacts during contractor onboarding (e.g. Director + Office Admin).\n\nDON'T USE WHEN: Updating an existing contact — use update_contractor_contact. Adding a site contact — use create_site_contact.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: Inserts one row into `contractor_contacts`. Writes audit_log row (action `create`, entity_type `contractor_contact`)."},{"name":"add_contractor_service_capability","description":"Declares that a contractor is configured/qualified to deliver a given service by adding a row to the `contractor_services` capability map. This is the row the `assign_job` compliance gate checks (lib/scheduler/compliance.ts §5) — without it, assignment rejects with `no_capability`.\n\nUSE WHEN: Onboarding a contractor for a service via MCP, or unblocking an `assign_job` call that failed with \"Contractor is not configured for this service.\" Clearing the capability gate end-to-end through MCP.\n\nDON'T USE WHEN: Recording a price the contractor quoted — use create_contractor_quote. Linking a site+service+contractor on a contract — use create_contract_service_line.\n\nPRECONDITIONS: `contractor_id` and `service_id` both reference existing rows in the caller's tenant.\n\nSIDE EFFECTS: Inserts at most one row into `contractor_services` (idempotent — returns the existing active row if the capability already exists). Writes audit_log (action `create`, entity_type `contractor_service`)."},{"name":"list_contractor_contacts","description":"Returns contacts for a contractor. Each contact is a person with name, role, phone, email, and notification preferences.\n\nUSE WHEN: Looking up who to contact at a contractor's business. Resolving which contacts receive work orders, invoices, or quotes.\n\nDON'T USE WHEN: Looking up the contractor's primary email — that's on the contractor row (get_contractor.email). Adding a new contact — use create_contractor_contact.\n\nPRECONDITIONS: If contractor_id is supplied, it must reference an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"update_contractor_contact","description":"Updates a contractor contact's details or notification preferences. Only provided fields are changed.\n\nUSE WHEN: Changing a contact's phone/email, role, or which notifications they receive.\n\nDON'T USE WHEN: Moving a contact to a different contractor — archive the old contact and create a new one. Adding a new contact — use create_contractor_contact.\n\nPRECONDITIONS: `contractor_contact_id` references an existing contractor contact in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `contractor_contacts` row. Writes audit_log row (action `update`, entity_type `contractor_contact`) including the diff of changed fields."},{"name":"create_contractor_agreement","description":"Creates a contractor agreement (SMA — Subcontractor Master Agreement) record in `draft` status. Agreements track the legal relationship between tenant and contractor. A draft must exist before propose_send_contractor_agreement can stage a send.\n\nUSE WHEN: Setting up a new SMA with a contractor, preparing to send terms for signing.\n\nDON'T USE WHEN: Recording a contractor's quote — use create_contractor_quote. Adding pricing — agreements cover terms, not rates; pricing lives on contract_service_line. Sending the agreement — use propose_send_contractor_agreement next.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: Inserts one row into `contractor_agreements` with status `draft`. Writes audit_log row (action `create`, entity_type `contractor_agreement`)."},{"name":"create_contractor_quote","description":"Creates a contractor quote — a price a contractor has offered for a specific site/service combination. Quotes are reference data used when negotiating contract_service_line rates.\n\nUSE WHEN: Recording what a contractor has quoted to service a specific site so it can be referenced later when setting the client-facing rate.\n\nDON'T USE WHEN: Setting the agreed client rate — use create_contract_service_line (quotes are internal reference, not client-facing pricing). Sending an outgoing quote to a client — that's a proposal; use create_proposal.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant. `quoted_amount_cents` is a non-negative integer (cents).\n\nSIDE EFFECTS: Inserts one row into `contractor_quotes`. Writes audit_log row (action `create`, entity_type `contractor_quote`). Money stored as integer cents."},{"name":"create_contractor_compliance_document","description":"Creates a compliance document record (insurance certificate, ABN verification, workers comp, etc.) for a contractor. Documents track the contractor's regulatory compliance status. Newly created docs start unverified.\n\nUSE WHEN: Recording a contractor's insurance policy, ABN verification, workers comp, or other compliance documents.\n\nDON'T USE WHEN: Verifying / approving a document — use verify_compliance_document (architect-only). The doc belongs to a client — compliance is contractor-scoped only.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant. `document_type` is one of the supported types. If an `expires_at` is supplied, it is a valid future or past ISO date.\n\nSIDE EFFECTS: Inserts one row into `contractor_compliance_documents` (status `unverified`). Writes audit_log row (action `create`, entity_type `contractor_compliance_document`). The contractor's denormalised compliance snapshot is recomputed."},{"name":"verify_compliance_document","description":"Marks a contractor compliance document as verified or unverified. Sets `verified_by` and `verified_at`. Architect-only — verification is a governance action that gates dispatch eligibility.\n\nUSE WHEN: An architect has reviewed a compliance document and confirms it is valid (or invalid).\n\nDON'T USE WHEN: Uploading or creating a new document — use create_contractor_compliance_document first. Recording an expiry — that's set at create time or via the document's own update path.\n\nPRECONDITIONS: Architect persona. `document_id` references an existing compliance document in the caller's tenant.\n\nSIDE EFFECTS: Updates the compliance document row (`verified`, `verified_by`, `verified_at`). Writes audit_log row (action `update`, entity_type `contractor_compliance_document`). The contractor's denormalised compliance snapshot is recomputed."},{"name":"verify_contractor_abn","description":"Verifies a contractor's stored ABN against the Australian Business Register (ABR) and sets abn_verified=true when the ABN is ACTIVE. A collected ABN is already a satisfied prerequisite; this is the authoritative verification step.\n\nUSE WHEN: Confirming a contractor's ABN against the register (operator action or compliance automation).\n\nDON'T USE WHEN: Looking up an ABN not yet stored — use lookup_abn.\n\nPRECONDITIONS: contractor exists in the caller's tenant and has a stored `abn`. The tenant has ABN Lookup configured.\n\nSIDE EFFECTS: read-only ABR call; on an ACTIVE match updates contractors.abn_verified + abn_verified_at + metadata.abr_entity_name. Writes audit_log. Inactive/not-found never sets verified=true."},{"name":"list_compliance_documents_pending_review","description":"Lists all compliance documents with status 'pending_review' for the tenant — the agent review queue. Results are oldest-first so the automation works FIFO.\n\nUSE WHEN: An operator or reviewing agent/automation wants to see which documents need review.\n\nDON'T USE WHEN: Fetching a specific document to read — use get_compliance_document_for_review.\n\nPRECONDITIONS: none beyond tenant authentication.\n\nSIDE EFFECTS: none (read-only)."},{"name":"record_compliance_review","description":"Records a reviewing agent's verdict on a compliance document. The platform NEVER calls an LLM — whoever drives the MCP client brings their own model. This tool records the extracted fields and verdict.\n\nUSE WHEN: A reviewing agent has read a compliance document (via get_compliance_document_for_review) and wants to record the extracted fields (expiry, insurer, policy number, coverage) plus a verdict.\n\nDON'T USE WHEN: Uploading a document — use upload_compliance_document_from_agent or the portal. Fetching the document — use get_compliance_document_for_review.\n\nPRECONDITIONS: document_id references an existing compliance document in the caller's tenant.\n\nSIDE EFFECTS: updates the document row (status, verified, expiry_date, issuer, etc.); on verdict=verified syncs contractor denormalised insurance fields; emits contractor.compliance_changed; writes audit_log (compliance_reviewed)."},{"name":"create_compliance_upload_link","description":"Generates a short-lived, single-use upload link for a contractor's compliance document (insurance certificate, photo, scan). Give this link to the user — they tap it on their phone, pick a photo/scan/PDF, and the browser uploads it natively. This avoids the base64-through-chat corruption that affects large files.\n\nUSE WHEN: A user wants to add an insurance certificate, photo of a cert, phone scan, or any compliance document via chat. This is the PRIMARY upload path — prefer it over upload_compliance_document_from_agent for all but the smallest files.\n\nDON'T USE WHEN: The contractor is uploading directly via the portal (that's the portal upload route). Or when the file is already in storage and you just need to create a compliance record (use create_contractor_compliance_document).\n\nPRECONDITIONS: contractor_id exists in the caller's tenant.\n\nSIDE EFFECTS: Creates a compliance_upload_tokens row (single-use, expires per tenant setting, default 30 min). The actual document upload and compliance row creation happen when the user submits the form. Writes audit_log."},{"name":"approve_send_contractor_agreement","description":"Approves a staged Subcontractor Master Agreement send and immediately dispatches the email to the contractor with a portal-link CTA. The contractor clicks the link, lands in the SMA portal, reviews the agreement, and digitally signs by typing their name.\n\n⚠️ HUMAN APPROVAL REQUIRED. This tool fires the send immediately — there is no second confirmation. Only call with `confirmed: true` after the operator has explicitly approved the send. The tool does not verify operator approval — that responsibility is on the caller.\n\nUSE WHEN: The operator has explicitly approved the send (e.g. reviewed via get_pending_operation and said \"yes, send it\"). Third and final leg of the propose → confirm → approve send chain.\n\nDON'T USE WHEN: The operator has not approved. Skip the propose / confirm steps. The pending operation is for a different artifact type — use approve_send_operation (generic dispatcher) or the artifact-specific approver. Re-sending an already-sent agreement — re-propose via propose_send_contractor_agreement first.\n\nPRECONDITIONS: A pending operation exists with `operation_type = 'send_contractor_agreement'` and `status = 'staged_for_send'` (i.e. confirm_pending_operation has run and the signing_token is minted). `confirmed: true`. Tenant `mail_provider` is configured.\n\nSIDE EFFECTS: Sends email via tenant `mail_provider` (Resend / Migadu). Transitions the agreement to `sent`. Marks the pending operation `approved`. Writes audit_log rows for both the operation approval and the agreement state change. Writes a `communication_log` row with the message_id."},{"name":"list_services","description":"Returns the service catalog for the authenticated user's tenant. Services are CLIENT-AGNOSTIC and CONTRACTOR-AGNOSTIC — they describe what work is offered, not who it's for. Excludes soft-deleted rows by default.\n\nUSE WHEN: Building a service picker, looking up a `service_id` before a create_contract_service_line call, or browsing the catalog.\n\nDON'T USE WHEN: You need per-client pricing — use list_contract_service_lines. You need the contractor's quote — use list_contractor_quotes.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_frequencies","description":"Returns the frequency catalog (scheduling patterns: weekly, fortnightly, 4-weekly, etc.) for the authenticated user's tenant. Frequencies are referenced by contract_service_lines to determine job recurrence.\n\nUSE WHEN: Building a frequency picker, looking up a `frequency_id` before a create_contract_service_line call, or browsing what scheduling patterns the tenant supports.\n\nDON'T USE WHEN: You want to see which CSLs use a specific frequency — use list_contract_service_lines with a filter.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_contract_service_lines","description":"Returns the rate schedule lines (per site/service/contractor) for the tenant. Optionally filtered by site_id, service_id, contractor_id, or client_id (resolved via the client's sites). Each row carries the client_rate_cents, contractor_rate_cents, frequency, active months, and active flag, plus denormalised human-readable labels: service_name, frequency_name, human_description, contractor_name (null when no contractor assigned), site_name, client_name, and client_id (resolved via the line's site). human_description is the active recurrence rule's authoritative cadence label (e.g. 'Every week on Thursday') and should be preferred over frequency_name — the catalog template name, which can lag a day_of_week override (bug 0d32f07b). UUID fields are retained for joins.\n\nUSE WHEN: Inspecting per-client per-site per-service pricing, auditing what contractor is assigned to a recurring service, surfacing an active-CSL count for a client.\n\nDON'T USE WHEN: You want the generic catalog of available services — use list_services. You want the contractor's quote (internal reference) — use list_contractor_quotes.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_contract_service_line","description":"Returns full details for a single contract service line, including recurrence data (rrule_string, human_description — the authoritative cadence label, preferred over frequency_name which can lag a day_of_week override per bug 0d32f07b — next N occurrences, plus the raw materialised recurrence — program_definition, rdates, exdates — for explicit_months/program cadences), live job counts by status, and the client portal guardrail counters (client_reschedule_count, client_defer_count — quarterly counters incremented by client-initiated reschedules/defers, checked against max_reschedules_per_quarter).\n\nUSE WHEN: Inspecting a specific recurring service's cadence, anchor, rate, or upcoming schedule.\n\nDON'T USE WHEN: You want a list of all CSLs — use list_contract_service_lines.\n\nPRECONDITIONS: contract_service_line_id must be a valid UUID belonging to the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_service","description":"Creates a generic, reusable service in the tenant's catalog. Services are CLIENT-AGNOSTIC and CONTRACTOR-AGNOSTIC — they describe what work is offered, not who it's for or who delivers it.\n\nUSE WHEN: Adding a new type of work the tenant offers (e.g., 'Lawn Mowing', 'Pressure Washing'). Adding a service that will be referenced by future contract_service_line rows.\n\nDON'T USE WHEN: Setting up client-specific pricing — use create_contract_service_line (this creates the catalog entry, not the pricing). Including client names, contractor names, quote/invoice IDs, or specific dollar amounts — those belong on the CSL. Site-specific conditions belong on site.legal_notes / site.access_notes via create_site or onboard_client_for_service.\n\nPRECONDITIONS: `name` is non-empty. For `client_visible: true` services, `legal_scope` is required (it renders on proposal + signed-agreement PDFs).\n\nSIDE EFFECTS: Inserts one row into `services`. Writes audit_log row (action `create`, entity_type `service`). Content validator may surface non-blocking warnings."},{"name":"update_service","description":"Updates fields on a generic catalog service. Only provided fields are changed. The same content rules as create_service apply: services are CLIENT-AGNOSTIC and CONTRACTOR-AGNOSTIC.\n\nUSE WHEN: Correcting or refining a service's name, legal_scope, operational_scope, or visibility.\n\nDON'T USE WHEN: Adjusting client-specific pricing — that belongs on contract_service_line; use update_contract_service_line. Embedding client names, contractor names, quote IDs, or dollar amounts in service fields. Retiring a service — use archive_service.\n\nPRECONDITIONS: `service_id` references an existing, non-archived service in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `services` row. Writes audit_log row (action `update`, entity_type `service`) including the diff. Content validator may surface non-blocking warnings."},{"name":"archive_service","description":"Soft-deletes a service from the catalog (sets `deleted_at`). The service row remains in the database for audit history and FK references from existing contract_service_lines / jobs, but is excluded from active listings.\n\nUSE WHEN: A service is no longer offered and should be hidden from new contract setup.\n\nDON'T USE WHEN: Temporarily pausing — set `is_active=false` via update_service instead. Active contract_service_lines reference this service — investigate the dependencies first; archiving while CSLs exist leaves them pointing at an archived service.\n\nPRECONDITIONS: Architect persona. `service_id` references an existing, non-archived service in the caller's tenant. No optimistic-concurrency control — `services` has no `lock_version` column, so this soft-delete takes no `expected_lock_version` (unlike `archive_contractor` / `archive_proposal`, which require one).\n\nSIDE EFFECTS: Sets `services.deleted_at = NOW()`. Writes audit_log row (action `archive`, entity_type `service`) including the optional `reason`."},{"name":"create_frequency","description":"Creates a new frequency in the tenant's catalog. Frequencies define scheduling patterns (e.g., weekly, fortnightly, 4-weekly) used by contract_service_lines to determine job recurrence.\n\nUSE WHEN: Adding a new scheduling pattern the tenant uses (e.g., '6-weekly', 'monthly first-Tuesday', 'every 3rd Thursday').\n\nDON'T USE WHEN: Setting a specific client's service frequency — that's set on create_contract_service_line by referencing an existing frequency_id.\n\nPRECONDITIONS: `name` is non-empty and unique within the tenant.\n\nSIDE EFFECTS: Inserts one row into `frequencies`. Writes audit_log row (action `create`, entity_type `frequency`). Content validator may surface non-blocking warnings about client-specific markers."},{"name":"update_frequency","description":"Updates an existing frequency's fields. Only provided fields are changed.\n\nUSE WHEN: Correcting a frequency name, type, interval, or skip_fifth_week flag.\n\nDON'T USE WHEN: Changing which frequency a specific contract_service_line uses — update the CSL directly via update_contract_service_line. Retiring a frequency — use archive_frequency.\n\nPRECONDITIONS: `frequency_id` references an existing, non-archived frequency in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `frequencies` row. Writes audit_log row (action `update`, entity_type `frequency`) including the diff. Changing interval/type does NOT automatically reschedule existing jobs — that's a separate operator action."},{"name":"archive_frequency","description":"Soft-deletes a frequency from the catalog (sets `deleted_at`). The frequency row remains in the database for audit history and FK references from existing CSLs, but is excluded from active queries.\n\nUSE WHEN: A scheduling pattern is no longer used.\n\nDON'T USE WHEN: Temporarily pausing — set `is_active=false` via update_frequency instead. Active CSLs reference this frequency — investigate first or accept they'll point at an archived row.\n\nPRECONDITIONS: Architect persona. `frequency_id` references an existing, non-archived frequency.\n\nSIDE EFFECTS: Sets `frequencies.deleted_at = NOW()`. Writes audit_log row (action `archive`, entity_type `frequency`) including the optional `reason`."},{"name":"create_contract_service_line","description":"Creates a contract service line — THE place for client-specific pricing. Links a site to a service with a frequency, client_rate_cents, and optional contractor_rate_cents. This is where per-client, per-site pricing lives.\n\nUSE WHEN: Setting up what a client pays for a specific service at a specific site. This is the correct tool for client-specific pricing, frequency assignment, and contractor assignment.\n\nDON'T USE WHEN: Creating the service itself — use create_service for the generic catalog entry. Confusing with contractor_quote (what the contractor quoted, not what the client pays) — use create_contractor_quote for internal reference data. The client + site + service combination doesn't yet exist — create them first.\n\nPRECONDITIONS: `site_id`, `service_id`, and `frequency_id` all reference existing rows in the caller's tenant. `client_rate_cents` is a non-negative integer (cents). If `contractor_id` + `contractor_rate_cents` are supplied, the contractor is active.\n\nSIDE EFFECTS: Inserts one row into `contract_service_lines`. Writes audit_log row (action `create`, entity_type `contract_service_line`). The spawning engine will mint future job rows from this CSL on its next run.\n\nACTIVATION GATE: job spawning is gated on the SITE having an accepted proposal (the same activation gate documented on create_recurring_service and the run_job_spawning preconditions). If the site has no accepted proposal, run_job_spawning returns 0 would_create for this line even when it is active. The returned row may carry activated_at:null on this granular create path — that null does NOT itself block spawning; the site-level accepted-proposal gate governs it.\n\nRESPONSE: alongside `contract_service_line`, the result echoes `resolved_dtstart` (the materialised recurrence rule's dtstart, ISO) and `anchor_source` (`anchor_date`|`activated_at`|`today`) so a caller sees how the recurrence anchored without a follow-up get_contract_service_line; both are null for ad-hoc frequencies (no recurrence rule). NOTE: is_active:true on this direct create path does NOT set activated_at, so with anchor_date and activated_at both null the recurrence anchor (dtstart) resolves to today."},{"name":"update_contract_service_line","description":"Updates a contract service line's rates, active status, contractor assignment, active months, service (in-place swap), or recurrence inputs (day_of_week, week_of_month, anchor_date). This is where rate changes, seasonal adjustments, service swaps, and schedule edits are made.\n\nUSE WHEN: Adjusting client_rate_cents or contractor_rate_cents, activating / deactivating a line, changing the assigned contractor, setting seasonal active_months, swapping the service (future undispatched jobs adopt the new service), or editing recurrence inputs (day/week/anchor — series-scope: re-materialises the recurrence rule and regenerates future undispatched jobs).\n\nDON'T USE WHEN: Updating the generic service catalog entry — use update_service. Moving a single job to a new date — use reschedule_job. Changing the frequency itself — use regenerate_csl_schedule.\n\nPRECONDITIONS: `id` references an existing CSL in the caller's tenant. New `client_rate_cents` / `contractor_rate_cents` (if supplied) are non-negative integers (cents). `service_id` (if supplied) must reference an active, non-deleted service in the tenant. `day_of_week` must be 0-6.\n\nSIDE EFFECTS: Updates the matching `contract_service_lines` row. Writes audit_log row (action `update`, entity_type `contract_service_line`) including the diff. Service swap: future undispatched jobs adopt the new service_id; dispatched/completed jobs retain the old service. Recurrence input changes: re-materialises the recurrence_rule and regenerates future undispatched jobs (series scope)."},{"name":"onboard_client_for_service","description":"One-step composite to set up a client's service at a site with correct pricing. Creates a contract_service_line and optionally appends special conditions to site.legal_notes. ENFORCES correct data placement: pricing flows to contract_service_line, site conditions flow to site.legal_notes, the service catalog is never touched.\n\nUSE WHEN: You have an existing client, site, and service, and need to set up the pricing and contract details in one call. The shortest path from existing entities to a billable CSL.\n\nDON'T USE WHEN: The client, site, or service doesn't exist yet — create them first with create_client, create_site, create_service. You only need the CSL without site updates — use create_contract_service_line. Adjusting an existing CSL — use update_contract_service_line.\n\nPRECONDITIONS: `client_id`, `site_id`, `service_id`, and `frequency_id` reference existing rows in the caller's tenant. The site belongs to the client. All money fields are non-negative integers in cents.\n\nSIDE EFFECTS: Inserts one row into `contract_service_lines`. If `site_special_conditions` is supplied, appends to `sites.legal_notes` (two audit_log rows: one for the CSL create, one for the site notes update). The spawning engine will mint future job rows from the new CSL on its next run."},{"name":"list_jobs","description":"Returns jobs (work orders, scheduled visits) for the tenant. Optionally filtered by status, site_id, contractor_id, or date range. Excludes test rows (is_test=true) by default — pass include_test:true to include them. For large result sets use `summary: true` (lean per-job projection + by_status rollup) or `count_only: true` (counts by status, no rows) to stay under the response size limit — recurring schedules make tenant-wide job lists large fast. Full rows include the reschedule fields (is_rescheduled, rescheduled_from, rescheduled_to, reschedule_reason) — the same ones get_job returns — so you can confirm a single-move-vs-cascade across many jobs in one call instead of N get_job calls (tool upgrade ee800883).\n\nUSE WHEN: Building a jobs dashboard, surfacing what's scheduled, exporting completion data, or filtering by contractor or client. Triaging (`how many jobs on this site?`) — use count_only first, then pull full rows for the slice you need.\n\nDON'T USE WHEN: You already have a job ID — use get_job. You want a dispatcher-board view — use get_dispatcher_view (composite). You want unassigned-only jobs — use list_unassigned_jobs (specialised).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_job","description":"Returns a single job by ID with full details including status, site, contractor, schedule, notes, completion photos, and completion data.\n\nUSE WHEN: You already have a job ID and need the full row — opening a job detail view, denormalising for an outgoing work-order email, or sanity-checking before a status change.\n\nDON'T USE WHEN: You don't yet have the ID — use list_jobs with a filter. You want the cascade of related jobs from a CSL — use list_jobs filtered by contract_service_line_id.\n\nPRECONDITIONS: `id` is a valid UUID of an existing job in the caller's tenant.\n\nNOT-FOUND: an id matching no live job (never existed, or soft-deleted — e.g. by regenerate_csl_schedule) returns a clean {error} not-found signal, distinguishable from a raw transport failure.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_job","description":"Creates a new job (work order) for a site/service on a scheduled date. Jobs represent individual instances of work to be performed. Should reference a contract_service_line where one exists. The job starts in `scheduled` status (or `unscheduled` if no date is provided).\n\nUSE WHEN: Scheduling ad-hoc work at a site, or manually inserting a job alongside the recurring schedule. Booking a one-off requested by the client.\n\nDON'T USE WHEN: Setting up a recurring service arrangement — use create_contract_service_line, then the spawning engine mints the recurrence. A proposal has just been accepted and the standard cascade should mint the jobs — that path is event-driven and already wired.\n\nPRECONDITIONS: `site_id` and `service_id` reference existing rows in the caller's tenant. `client_rate_cents` (if supplied) is a non-negative integer (cents). If `contractor_id` is supplied, contractor is active for the tenant.\n\nSIDE EFFECTS: Inserts one row into `jobs`. Emits domain event `job.created` with the full job snapshot. Writes audit_log row (action `create`, entity_type `job`). Money fields stored as integer cents."},{"name":"update_job","description":"Updates a job's status, schedule, contractor assignment, or notes. Valid statuses: scheduled, dispatched, completed, approved, invoiced, paid, on_hold, cancelled, missed, partial. General-purpose; specialised scheduler tools (assign_job, dispatch_job, approve_job, reschedule_job, cancel_job) are preferred when they apply.\n\nUSE WHEN: Doing a general administrative update (notes, internal references). Status transitions where no specialised scheduler tool is available.\n\nDON'T USE WHEN: Assigning a contractor — use assign_job (emits assign event). Dispatching — use dispatch_job. Approving completion — use approve_job. Rescheduling — use reschedule_job. Cancelling — use cancel_job. Recording contractor-side completion — use record_completion.\n\nPRECONDITIONS: `id` references an existing job in the caller's tenant. If `status` is supplied, it's a member of the valid status set; the transition is the operator's responsibility (engine does not enforce a strict lifecycle here).\n\nSIDE EFFECTS: Updates the matching `jobs` row. Writes audit_log row (action `update`, entity_type `job`) including the diff. Does NOT emit specialised domain events (assign / dispatch / approve etc.) — those come from the specialised scheduler tools.\n\nWARNING: administrative status changes emit no job.completed event — the auto-payable handler (FE-04B) will NOT fire. For a backfilled completion where a contractor payable is owed, use backfill_completed_job (event-correct composite) or call create_payable with linked_job_id."},{"name":"record_completion","description":"Records contractor completion of a job: sets `status='completed'`, `completion_submitted_at`, `completion_photos[]`, `completion_notes`. Validates min photos + notes per the tenant's contractor-portal completion requirements.\n\nUSE WHEN: An operator (or MCP-integration tier) needs to mark a job complete with the same validation the contractor portal uses. The job is in `status='dispatched'`.\n\nDON'T USE WHEN: Doing administrative status changes — use update_job. Approving completion (operator → `status='approved'`) — use approve_job. The job is a [TEST] job and you don't have file_attachment upload — use complete_job_with_test_photos (architect-only convenience).\n\nPRECONDITIONS: `job_id` references a job in the caller's tenant with `status='dispatched'`. Photo count meets the tenant's `min_completion_photos` requirement, default 0 — photo evidence is opt-in per tenant/contractor (either via `completion_photos` arg or existing photos on the job). Notes meet the `min_completion_notes_length` and `require_completion_notes` requirements. NOTE: these completion requirements are per-tenant values stored in `tenants.contractor_portal_settings` (edited via the Contractor Portal settings UI) — they are NOT in the settings registry, so `get_setting`/`list_settings` cannot read or change them (tool upgrade 68b7181d).\n\nSIDE EFFECTS: Updates the matching `jobs` row (`status='completed'`, `completion_submitted_at=NOW()`, photos / notes). Writes audit_log row (action `record_completion`, entity_type `job`)."},{"name":"record_field_triage","description":"Records a field-triage event on a dispatched job — the operator/MCP twin of the contractor portal 'Can't complete' flow. Sets the job's hold_reason from reason_code (+ optional note), auto-reschedules the occurrence (to reschedule_to, or the next available day — the day after the current date, never earlier than tomorrow), mints an 'auto_applied' schedule_change_requests row as the operator-facing record, and emits job.rescheduled + job.schedule_change_requested — i.e. exactly the state transition the portal triage produces.\n\nUSE WHEN: A contractor reports out-of-band (phone/text) that they are on site but can't complete — gate locked, no access, client closed, hazard — and you need the SAME outcome the portal triage screen produces, recorded from the operator side.\n\nDON'T USE WHEN: The contractor used the portal triage screen themselves (already recorded). You just want to move a date with no can't-complete reason — use reschedule_job. The contractor is permanently off the job — use decline_job. You want a PENDING request needing approval — use request_schedule_change (this tool AUTO-APPLIES the move).\n\nPRECONDITIONS: job_id references a job in the caller's tenant that is NOT terminal (completed/approved/invoiced/paid/cancelled). reason_code is one of client_closed | locked | no_access | hazard | other.\n\nSIDE EFFECTS: Updates the jobs row (scheduled_date, is_rescheduled, rescheduled_from/to, reschedule_reason, hold_reason). Inserts an 'auto_applied' schedule_change_requests row (submitted_by_type 'contractor' — migration 237's CHECK does not permit 'admin'; the recording channel is carried by the job.rescheduled event `origin`). Emits job.rescheduled + job.schedule_change_requested."},{"name":"complete_job_with_test_photos","description":"Architect-only convenience: synthesises N placeholder completion photos for a [TEST] job and calls record_completion. Refuses anything other than `is_test=true` jobs in `status='dispatched'`. Exists so fresh chat agents — which have no file_attachments upload tool — can drive the dispatched→completed transition end-to-end in dogfood scenarios. NEVER use on production data.\n\nUSE WHEN: A dogfood smoke prompt or test scenario needs to advance a [TEST] job past the photo gate to `status='completed'` end-to-end via MCP only.\n\nDON'T USE WHEN: Operating on real (`is_test=false`) jobs — the tool refuses with an explicit error. The job is in any status other than 'dispatched'. You want to mark a real job complete with real photos — use record_completion after uploading photos via the file_attachments route.\n\nPRECONDITIONS: Architect persona. `job_id` references a job in the caller's tenant with `is_test=true` AND `status='dispatched'`. `photo_count` > 0 (default 3; clamped to tenant `max_completion_photos`).\n\nSIDE EFFECTS: Inserts N `file_attachments` rows tagged `category='completion_photo'`, source `complete_job_with_test_photos`. Updates the job (`status='completed'`, photos, notes). Writes 2 audit_log rows (synthesis + completion)."},{"name":"list_work_orders","description":"Returns work orders for the tenant. Optionally filtered by status, job_id, contractor_id, scope, or contract_service_line_id. Status values: sent, accepted, declined, rejected_after_acceptance, expired, cancelled, withdrawn, requires_reacceptance. Each row carries its WP-WO-01 scope (single_job / job_set / ongoing_series) and `bound_job_ids` (junction bindings for job-bound scopes).\n\nUSE WHEN: Auditing what work orders are in flight, checking acceptance status across a contractor's queue, or filtering open WOs for a specific job.\n\nDON'T USE WHEN: You already have the work order ID — use get_work_order. You want the underlying jobs — use list_jobs.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_work_order","description":"Returns a single work order by ID with full details including signing token, acceptance record, and signed PDF URL. WP-WO-01 scope model: the response includes `scope` (single_job / job_set / ongoing_series) plus the binding — `bound_jobs` (junction-bound job rows) for job-bound scopes, or `contract_service_line` (the bound series) for ongoing_series.\n\nUSE WHEN: You already have the work order ID and need the full row — opening a WO detail view, denormalising for an outgoing reminder, or auditing the acceptance state.\n\nDON'T USE WHEN: You don't yet have the ID — use list_work_orders with a filter. You want the underlying job — use get_job.\n\nPRECONDITIONS: `id` is a valid UUID of an existing work order in the caller's tenant.\n\nLIFECYCLE-GATED NULL FIELDS: `work_order_snapshot`, `content_hash`, and `signed_pdf_url` are null until the contractor accepts via the portal. After acceptance they are populated atomically. A null value on a `sent` work order therefore means \"not yet accepted\", NOT \"broken\" — do not file a bug on it. The response also returns a structural `pending_acceptance` boolean (true when status is `sent` and the snapshot has not yet been captured) so you can branch on it directly instead of inferring from null + status.\n\nSIDE EFFECTS: None — read-only."},{"name":"approve_send_work_order","description":"Approves a staged work-order send and immediately dispatches the email to the contractor with a portal-link CTA. Mints the signing_token, creates the `work_orders` row, sends the branded email, and emits `work_order.sent`. The contractor clicks the link, reviews the work order, and digitally accepts via the portal.\n\nNOTE: this send does NOT transition the job — `jobs.status` remains 'scheduled' (only `jobs.work_order_sent_at` is set). Call `dispatch_job` after this send to move the job to 'dispatched' (required before `record_completion`).\n\n⚠️ HUMAN APPROVAL REQUIRED. This tool fires the send immediately — there is no second confirmation. Only call with `confirmed: true` after the operator has explicitly approved the dispatch.\n\nUSE WHEN: The operator has explicitly approved the dispatch (reviewed via get_pending_operation and said \"yes, send it\"). Second leg of the propose → approve send chain.\n\nDON'T USE WHEN: Operator has not approved. The pending operation is for a different artifact type. Recording an out-of-band acceptance — use record_work_order_acceptance.\n\nPRECONDITIONS: A pending operation exists with `operation_type = 'send_work_order'` and `status = 'proposed'`. `confirmed: true`. Tenant `mail_provider` is configured.\n\nSIDE EFFECTS: Mints signing_token. Inserts `work_orders` row (status `sent`). Sends email via tenant `mail_provider` (Resend / Migadu). Emits domain event `work_order.sent`. Marks the pending operation `approved`. Writes audit_log rows for both the operation approval and the WO send. Writes `communication_log` row with message_id. Updates `jobs.work_order_sent_at`; does NOT change `jobs.status` — the job stays 'scheduled' until dispatch_job is called."},{"name":"record_work_order_acceptance","description":"Records an out-of-band work-order acceptance (e.g. contractor accepted verbally or via email, and the operator is back-filling the system). Creates an immutable `work_order_acceptances` row and flips `work_orders.status` to `accepted`. Handles all three scopes (WP-WO-01): `single_job` / `job_set` acceptance binds the junction-bound job(s) and transitions them to `dispatched`; `ongoing_series` acceptance binds the contract service line (occurrence jobs are NOT bulk-transitioned — dispatch stays per-occurrence) and stores the forward-view snapshot.\n\nUSE WHEN: A contractor has accepted a work order through a channel other than the portal (phone, email reply, in-person). Back-filling an acceptance the system missed.\n\nDON'T USE WHEN: The contractor will accept via the portal — let them click-to-accept; the portal flow writes the same acceptance row and avoids double-recording. Recording a decline — use decline_job for a per-occurrence hand-back (it does NOT void a series acceptance).\n\nPRECONDITIONS: `work_order_id` references a `sent` work order in the caller's tenant. The actor has explicit operator approval to back-fill (the acceptance is immutable).\n\nSIDE EFFECTS: Inserts one immutable row into `work_order_acceptances`. Updates `work_orders.status='accepted'`. For job-bound scopes, updates the bound job(s) `status='dispatched'` and emits `job.dispatched` per job. Emits domain event `work_order.accepted`. Writes audit_log rows for each state change."},{"name":"cancel_work_order","description":"Cancels an in-flight work order and invalidates its acceptance link. Soft transition: the row is KEPT (status='cancelled'), the signing_token is expired so the portal accept link stops working, and `work_order.cancelled` is emitted. Once cancelled, the bound job's in-flight guard clears, so `propose_send_work_order` can stage a fresh send — this closes the dead-end where a sent WO blocked re-issuing after a contractor reassignment.\n\nUSE WHEN: A SENT work order must be withdrawn before acceptance — the contractor was reassigned, the visit was called off, or the WO was sent in error.\n\nDON'T USE WHEN: The work order has already been ACCEPTED — an accepted WO is a contractual instrument; unwinding it is a human decision (rejection / credit flow), not a cancel. The whole job is going away — use cancel_job. The contractor is declining an assigned job — use decline_job.\n\nPRECONDITIONS: `work_order_id` references a work order in the caller's tenant in status 'sent' or 'requires_reacceptance'. `reason` is non-empty.\n\nSIDE EFFECTS: Updates work_orders.status='cancelled' and expires signing_token_expires_at. Emits domain event `work_order.cancelled`. Writes audit_log row. Does NOT delete the row and does NOT change the bound job's status."},{"name":"list_invoices","description":"Returns invoices for the tenant. Filter by client_id, status, since (ISO date). Returns totals as integer cents plus lock_version, is_catch_up_invoice, and client_po_number on every row. By default hides `is_test=true` rows; pass `include_test=true` to surface them.\n\nUSE WHEN: Building an AR dashboard, filtering outstanding invoices for a client, exporting a date-bounded set, or auditing what's been issued.\n\nDON'T USE WHEN: You already have the invoice ID — use get_invoice. You want the state-change timeline — use list_invoice_state_changes.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_invoice","description":"Returns a single invoice by ID with its lines, totals (integer cents), lock_version, status, ATO fields (supplier/buyer), client_po_number, dates, is_catch_up_invoice flag, and live payment position: paid_cents (Σ non-voided payment allocations − attributed refunds), refunded_cents (refunds prorated by allocation share), credit_applied_cents (Σ applied client credits), and outstanding_cents (total − paid − credit, floored at 0) — one call answers \"how much is left to collect?\", refund-aware (bug 346589f7). Also returns the EFFECTIVE recipient identity — effective_recipient_abn, effective_recipient_name, recipient_abn_source ('header'|'client') — resolved as invoice.buyer_abn then the client's ABN, mirroring the rendered tax invoice + ATO send-gate, so a recipient-ABN compliance read needs no PDF render (tool upgrade 0d134b9a).\n\nUSE WHEN: You already have the invoice ID and need the full row + lines — opening an invoice detail view, denormalising for an outgoing reminder, or auditing line items.\n\nDON'T USE WHEN: You don't yet have the ID — use list_invoices with a filter. You want the state-change history — use list_invoice_state_changes.\n\nPRECONDITIONS: `id` is a valid UUID of an existing invoice in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_payment","description":"Returns a payment by ID with its allocations against invoices (m:n via payment_allocations). Amounts in integer cents. `payment_date` is the real-world bank/cash receipt date (per Finance Engine Principle 3). `reference` is the external payment reference stored at record time (propose_record_payment's `external_reference` param — bank transaction ID, EFT reference, cheque number).\n\nUSE WHEN: You already have the payment ID and need full row + allocations — auditing where a single deposit was applied across invoices, or reconciling a bank transfer.\n\nDON'T USE WHEN: You don't yet have the ID — use list_payments. You want allocations from the invoice side — use get_invoice which surfaces invoice-side payment refs.\n\nPRECONDITIONS: `id` is a valid UUID of an existing payment in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_payments","description":"Returns payments for the tenant, filtered by invoice_id (via allocations), client_id, since (ISO date). Amounts in integer cents. By default hides `is_test=true` rows.\n\nUSE WHEN: Building a cash-in dashboard, surfacing receipts for a client, exporting a date-bounded set, or auditing recent banking activity.\n\nDON'T USE WHEN: You already have the payment ID — use get_payment. You want contractor payables (cash-out) — use list_payables.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"import_bank_csv","description":"Ingest a CommBank NetBank CSV export into the bank-reconciliation feed (bank_transactions). The CSV is header-less with 4 columns: date (DD/MM/YYYY), signed amount (credit +, debit -), description, running balance. Ingest is IDEMPOTENT — re-importing an overlapping window adds zero duplicates, and two genuinely-distinct same-day/same-payer/same-amount credits (the Intum case) both survive.\n\nUSE WHEN: An operator has exported this week's bank CSV (the ~40-txn rolling NetBank window) and wants the deposits pulled in so the matcher can suggest invoice matches. Run weekly — skipping too long rolls old transactions off the export.\n\nDON'T USE WHEN: You want to read already-ingested rows — use list_bank_transactions. You want to mark a non-sale deposit out of the worklist — use ignore_bank_transaction.\n\nPRECONDITIONS: None beyond tenant auth. Bank lines NEVER come from Xero (CDR restriction) — this CSV path (or Basiq) is the source.\n\nSIDE EFFECTS: Inserts bank_transactions rows (money in = credit, money out = debit; both stored, the sales report consumes credits only). Idempotent via the (tenant, source, dedup_fingerprint, within_import_ordinal) unique index."},{"name":"list_bank_transactions","description":"Returns ingested bank transactions for the tenant, filtered by match_state (unmatched | suggested | matched | ignored), direction (credit | debit), and a value_date window (since/until ISO dates). Amounts are signed integer cents (positive = money in). By default hides is_test=true rows.\n\nUSE WHEN: Building the reconciliation worklist, checking what's still unmatched this period, or auditing an import batch.\n\nDON'T USE WHEN: You want to ingest a CSV — use import_bank_csv. You want client payments (already reconciled) — use list_payments.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"ignore_bank_transaction","description":"Mark a bank transaction as a non-sale (bank interest, an internal transfer, a random deposit or refund) so it leaves the reconciliation worklist. A reason is REQUIRED for the audit trail. This is the ingest-time classification of a DEPOSIT as not-a-sale — distinct from clearing a residual variance at close (that lives on the variance surface).\n\nUSE WHEN: A credit in the worklist is not a client payment and should not be matched to an invoice.\n\nDON'T USE WHEN: The deposit IS a client payment — match it instead (reconciliation). You want to delete an erroneously-imported row — that is a different operation.\n\nPRECONDITIONS: The bank_transaction must belong to this tenant.\n\nSIDE EFFECTS: Sets match_state='ignored' and records ignore_reason. Reversible by re-matching."},{"name":"get_reconciliation_suggestions","description":"Ranks each unmatched incoming bank credit against the tenant's outstanding invoices, returning suggested matches with a deterministic confidence score, the reasons (exact_amount, invoice_ref_in_description, alias_match/client_name_match, date_plausible), and a band (auto_suggest >= 0.9, needs_review >= 0.6). Learned client bank-aliases raise confidence for repeat payers. No LLM — fully auditable.\n\nUSE WHEN: Building the reconciliation worklist, or deciding which deposits to confirm against which invoices this week.\n\nDON'T USE WHEN: You want the raw deposits — use list_bank_transactions. You want to actually record a match — that is the confirm step (separate).\n\nPRECONDITIONS: Bank credits ingested via import_bank_csv; invoices in status sent/overdue/partially_paid.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_sales_gst_report","description":"The accountant's month-end artifact: the CASH-BASIS Sales & GST report for a period. Returns one row per payment RECEIVED (invoice number, issue date, paid date, client, ex-GST cents, GST cents, total cents), the period totals, and the AU financial-year GST-period label (e.g. 'Q4 (Apr–Jun 2026)'). CASH BASIS means a sale and its GST are recognised when the money LANDS (payment_date in the period), NOT when the invoice was issued — so an invoice raised last month but paid this month appears in THIS month's report. GST per receipt = round(amount_cents / 11) at the AU 10% rate. Omitting the period defaults to the CURRENT calendar month.\n\nUSE WHEN: The operator or accountant says 'give me my month-end report', 'what GST did we collect', 'the BAS figures', or 'what did we actually get paid this month'. This is the report that must reconcile to the bank.\n\nDON'T USE WHEN: You want money still OWED (unpaid/overdue invoices) — that is the finance home snapshot or list_invoices; this report is money RECEIVED only. You want to LOCK the month so the figures can't drift — use run_month_end_close.\n\nPRECONDITIONS: Payments recorded against invoices (directly, or via the bank-reconciliation confirm path). SIDE EFFECTS: None — read-only."},{"name":"get_month_end_status","description":"Returns the month-end CLOSE state for a period plus a LIVE summary. `state` is 'open' (no close exists yet), 'closed' (locked — figures frozen), or 'reopened' (was closed, now editable again). `live` carries the current received / ex-GST / GST / receipt_count / outstanding_ar / awaiting_reconciliation, computed fresh from the ledger. `close` (when present) carries the FROZEN snapshot taken at close plus closed_at and any reopen reason. Comparing live vs the frozen snapshot tells you whether the period drifted after it was closed. Omitting the period defaults to the CURRENT calendar month.\n\nUSE WHEN: Before closing (to preview what will be frozen and check nothing is still awaiting reconciliation), or to answer 'is June closed?' / 'has this month been signed off?'.\n\nDON'T USE WHEN: You want the itemised receipt rows — use get_sales_gst_report. You want to actually lock the month — use run_month_end_close.\n\nPRECONDITIONS: None beyond tenant auth. SIDE EFFECTS: None — read-only."},{"name":"run_month_end_close","description":"Closes (locks) a month-end period. Computes the cash-basis figures for the period and SNAPSHOTS them (received / ex-GST / GST / receipt_count / outstanding_ar) into the period_closes record, marking the period 'closed' and stamping who/when. This is the sign-off step: the frozen snapshot is the official figure for the period even if later activity changes the live numbers. IDEMPOTENT — running it again on the same period updates the same single record (re-snapshots), never creating duplicates. The Financial Operations Test: this changes NO invoice/payment amount or GST value and executes NO payment — it records an attestation that the month is signed off. Omitting the period defaults to the CURRENT calendar month.\n\nUSE WHEN: The operator/accountant has reconciled the month and says 'close June', 'lock the month', 'sign off month-end'. Best run after get_month_end_status shows zero deposits still awaiting reconciliation.\n\nDON'T USE WHEN: You only want to VIEW the figures — use get_sales_gst_report or get_month_end_status. The period is already closed and you need to correct it — use reopen_period first, fix, then close again.\n\nPRECONDITIONS: None beyond tenant auth (but reconcile first for a clean snapshot). SIDE EFFECTS: Upserts one period_closes row (status='closed', frozen snapshot, closed_at/by). Reversible via reopen_period."},{"name":"reopen_period","description":"Reopens a previously-closed month-end period so its figures can be corrected, then re-closed. Sets the period_closes record to 'reopened' and records a REQUIRED reason plus who/when — the reason is the audit trail for why a signed-off month was touched again. After reopening, make the correction (e.g. record a late payment, confirm a missed deposit) and call run_month_end_close again to re-freeze the snapshot. The Financial Operations Test: changes NO invoice/payment amount or GST value and executes NO payment — it flips a sign-off flag with an audited reason.\n\nUSE WHEN: A closed month needs a fix — 'reopen June, a payment came in late', 'unlock last month to add a deposit'.\n\nDON'T USE WHEN: The period is still open (nothing to reopen — just make the change). You want to view state — use get_month_end_status.\n\nPRECONDITIONS: The period must already be closed (errors otherwise). SIDE EFFECTS: Updates the period_closes row to status='reopened' with reopened_at and the reason. Re-close with run_month_end_close."},{"name":"attest_payment","description":"Record how a payment physically landed: the BANKED portion (will appear on this account's bank feed) and the UNBANKED portion (cash retained, or deposited to another account). The invariant is banked_cents + unbanked_cents == the payment's amount. Example: a $1,320 payment settled $420 by bank transfer and $900 cash → attest banked_cents 42000, unbanked_cents 90000, unbanked_method 'cash'. This lets the $420 bank line reconcile to the banked portion instead of nagging as an underpayment. It is a PROVENANCE SIDECAR — it does NOT change the payment amount, its allocation, or any GST, and executes no money movement (Financial Operations Test: PASS). Idempotent per payment (re-attesting overwrites the split).\n\nUSE WHEN: A recorded payment was part-banked / part-cash, was all cash, or was deposited to a different account — so reconciliation and the banked-vs-cash split are correct.\n\nDON'T USE WHEN: You're writing OFF an unpaid balance — that is a credit note, not an attestation. You're recording the payment itself — use create_payment / the reconciliation confirm path first, then attest it.\n\nPRECONDITIONS: The payment must already exist for this tenant. banked + unbanked must equal its amount.\n\nSIDE EFFECTS: Upserts one payment_attestations row (banked/unbanked split, method, note). Reversible by re-attesting."},{"name":"get_payment_attestation","description":"Return the provenance attestation for a payment — the banked vs unbanked split (banked_cents, unbanked_cents, unbanked_method, note) recorded by attest_payment — or null if the payment has not been attested (provenance unknown / assumed banked).\n\nUSE WHEN: Checking how a payment was split before reconciling its bank line, or confirming whether a cash/part-banked payment has been attested.\n\nDON'T USE WHEN: You want a period's banked-vs-cash totals (that is a summary, not a single payment) or the payment record itself (use get_payment).\n\nPRECONDITIONS: None beyond tenant auth. SIDE EFFECTS: None — read-only."},{"name":"unmatch_reconciliation","description":"Undo a reconciliation: reverse a deposit that was matched to an invoice. It VOIDS the payment the match created (which unwinds the payment's allocation and recomputes the invoice off 'paid'), then frees the deposit back to 'unmatched' so it can be re-reconciled. A reason is REQUIRED (audit trail); nothing is hard-deleted. The Financial Operations Test: this reverses a previously-recorded payment via the audited void gateway — it changes no GST/amount computation and moves no money.\n\nUSE WHEN: A deposit was matched to the wrong invoice, or a confirmed match needs correcting before close.\n\nDON'T USE WHEN: The deposit is not yet matched (nothing to undo — match it instead). You want to write off an invoice (that is a credit note). You want to delete the bank line entirely (that is a different operation).\n\nPRECONDITIONS: The bank transaction must be in match_state 'matched' with a linked payment.\n\nSIDE EFFECTS: Soft-voids the matched payment (reason-stamped) + its allocations, recomputes the invoice status, and sets the deposit back to 'unmatched' (matched_payment_id cleared). Re-matchable afterwards."},{"name":"get_payable","description":"Returns a contractor payable by ID with its linked invoice (id+status) for Layer-2 cash-flow visibility. Amount in integer cents. `payable_paid_date` is the real-world payment date (Finance Engine Principle 3).\n\nUSE WHEN: You already have the payable ID — auditing a single contractor disbursement, checking Layer-2 hold status before release.\n\nDON'T USE WHEN: You don't yet have the ID — use list_payables. You want client-side cash-in — use get_payment.\n\nPRECONDITIONS: `id` is a valid UUID of an existing payable in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_payables","description":"Returns contractor payables for the tenant, filtered by contractor_id / status / since. Amounts in integer cents. Default hides `is_test=true`.\n\nUSE WHEN: Building an AP / cash-out dashboard, surfacing pending payables for a contractor, exporting a date-bounded set, or auditing the Layer-2 release pipeline.\n\nDON'T USE WHEN: You already have the payable ID — use get_payable. You want client-side cash-in — use list_payments.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_credit","description":"Returns a client credit by ID. `source` distinguishes overpayment | prepayment | adjustment | refund_reversal (lock-in B2 — prepayments arrive before any invoice). Amount in integer cents.\n\nUSE WHEN: You already have the credit ID — auditing how a credit was applied, investigating an overpayment, surfacing a prepayment balance.\n\nDON'T USE WHEN: You don't yet have the ID — use list_credits with a client_id filter.\n\nPRECONDITIONS: `id` is a valid UUID of an existing client credit in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_credits","description":"Returns client credits for the tenant, filtered by client_id, source, since. Amounts in integer cents. Default hides `is_test=true`.\n\nUSE WHEN: Building a credits dashboard, surfacing outstanding credit balance for a client, or auditing where overpayments / prepayments have landed.\n\nDON'T USE WHEN: You already have the credit ID — use get_credit. You want refunds — use list_refunds.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_refund","description":"Returns a refund by ID with its linked payment summary (lock-in B8.1 linkage). `refund_date` is the real-world execution date; `refund_reason` is required non-empty. Amount in integer cents. Voided refunds ARE returned, flagged `voided:true` (deleted_at = the void timestamp), mirroring get_payment / get_invoice_full_history; a `Refund not found` error means the id is genuinely unknown, not voided.\n\nUSE WHEN: You already have the refund ID — auditing a single refund, verifying the linked payment shape, surfacing refund_reason for a customer query, or resolving a refund.recorded event from get_period_reconciliation_view.corrections / get_invoice_full_history (including voided ones).\n\nDON'T USE WHEN: You don't yet have the ID — use list_refunds. You want client credits — use get_credit (refund_reversal source).\n\nPRECONDITIONS: `id` is a valid UUID of an existing refund in the caller's tenant (voided or not).\n\nSIDE EFFECTS: None — read-only."},{"name":"list_refunds","description":"Returns refunds for the tenant, filtered by payment_id, since (refund_date). Amounts in integer cents. Default hides `is_test=true`.\n\nUSE WHEN: Building a refunds report, surfacing all refunds against a specific payment, exporting a date-bounded set, or auditing refund history.\n\nDON'T USE WHEN: You already have the refund ID — use get_refund. You want client credits arising from refund reversal — use list_credits with source filter.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_statement","description":"Returns a statement by ID with its items (invoice + payment line refs). `bundle_invoices` flag indicates whether the eventual email attaches per-invoice PDFs. Amounts in integer cents.\n\nUSE WHEN: You already have the statement ID — opening a statement detail view, denormalising for an outgoing send, verifying the item composition before issue.\n\nDON'T USE WHEN: You don't yet have the ID — use list_statements. You want the underlying invoices independently — use list_invoices filtered by client_id.\n\nPRECONDITIONS: `id` is a valid UUID of an existing statement in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_statements","description":"Returns statements for the tenant, filtered by client_id and since (period_end). Amounts in integer cents. Default hides `is_test=true`.\n\nUSE WHEN: Building a statements dashboard, surfacing prior statements for a client, or exporting a date-bounded set.\n\nDON'T USE WHEN: You already have the statement ID — use get_statement. You want the underlying invoices — use list_invoices.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_finance_settings","description":"Returns the tenant's finance configuration from two sources: (1) registry-backed settings (category=finance) with current values, defaults, and tier status; (2) tenant-level finance columns (payment terms, GST rate, currency, overdue interest, payment instructions, bank details) and business profile identity (name, trading name, ABN, address, phone, email). Use update_tenant_finance_profile to write the tenant-level columns.\n\nUSE WHEN: Auditing finance tunables, bank details, or business identity for this tenant.\n\nDON'T USE WHEN: You want settings from another category — use list_settings. You want to change a registry setting — use update_setting.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"update_tenant_finance_profile","description":"Updates tenant-level finance columns and/or business profile identity. These fields live directly on the tenants table (not the settings registry). This is the MCP counterpart to the Financial Defaults + Business Profile UI pages.\n\nUSE WHEN: Changing payment terms default, GST rate, currency, bank details, overdue interest, payment instructions, business name, trading name, ABN, address, phone, or email for this tenant; or the email SENDER address and display name (invoices_email, proposals_email, dispatch_email, reply_to_email, default_from_name) — e.g. send invoices from accounts@yourdomain.com shown as 'GWC Property Services' instead of the default noreply@.\n\nDON'T USE WHEN: Changing a registry-backed setting — use update_setting. Changing a per-client payment term — use update_client with payment_terms_scope.\n\nPRECONDITIONS: Operator persona. Updates object must contain only allowed column names.\n\nSIDE EFFECTS: Writes to the tenants row. Changes are immediately reflected in the UI and invoice resolver."},{"name":"list_invoice_state_changes","description":"Returns chronological state-transition rows from `audit_log` for invoices in this tenant (entity_type='invoice', filtered to state-transition verbs: created, send/sent, paid, partially_paid, overdue, void/voided, updated). Optionally filter by invoice_id and since (created_at). Read-only temporal query per Finance Engine Principle 4.\n\nUSE WHEN: Auditing what state transitions an invoice has been through, investigating a billing dispute, surfacing the timeline before a customer-facing reply.\n\nDON'T USE WHEN: You want the current invoice row — use get_invoice. You want a generic audit log across all entities — use get_audit_log.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_payable_release_status","description":"Returns the Layer 2 cash flow protection gate state for a contractor payable: whether it is releasable and why/why-not, plus the linked client invoice's status, PO, and paid date.\n\nUSE WHEN: An operator (or chat-first agent) needs to know whether a payable can be released right now, or wants to understand which client invoice is blocking it. Cheaper than calling propose_release_payable just to see if it would fail.\n\nDON'T USE WHEN: You actually want to release — call propose_release_payable then approve_release_payable. This tool is read-only diagnostic.\n\nPRECONDITIONS: The payable exists in the operator's tenant. The payable may or may not be linked to an invoice; the tool returns a structured gate state either way.\n\nSIDE EFFECTS: None — read-only. No audit row, no event, no state change."},{"name":"propose_release_payable","description":"Stages a contractor payable release for operator approval. Validates that the Layer 2 gate (linked client invoice must be paid) is passable BEFORE staging — if blocked, returns the specific gate code so the operator knows what to fix first.\n\nUSE WHEN: An operator wants to release a contractor payable that the system has marked 'approved' (or 'pending' with a paid linked invoice). The first step of the propose→approve money-moving flow.\n\nDON'T USE WHEN: You don't know the payable_paid_date — ask the operator. Principle 3 forbids defaulting to today. Also: don't use to inspect the gate state — call get_payable_release_status for that (cheaper, no staging side-effect).\n\nPRECONDITIONS: Payable exists, is not already released/void, and (in the success path) is linked to an invoice with status='paid'. payable_paid_date is the real-world date the contractor was actually paid (YYYY-MM-DD).\n\nSIDE EFFECTS: Creates an mcp_pending_operations row. Emits an audit_log entry tagged 'payable_release_proposed'. No payable state change yet — that fires on approve_release_payable."},{"name":"approve_release_payable","description":"Approves a staged contractor payable release and atomically transitions the payable to status='released'. Emits payable.released with idempotency key. Re-validates the Layer 2 gate (invoice still paid, payable still releasable) defensively.\n\nUSE WHEN: Operator has reviewed the staged operation from propose_release_payable and explicitly says \"release it\". The second step of the propose→approve money-moving flow.\n\nDON'T USE WHEN: The operator has not explicitly approved this release in the current session. Do not auto-approve. Do not call without confirmed=true.\n\nPRECONDITIONS: A pending operation of type 'release_payable' exists (from propose_release_payable). The linked invoice is still paid. The payable is not already released. The operator has approved.\n\nSIDE EFFECTS: payable.status → 'released'. released_at set. payable_paid_date set. payable.released event emitted (idempotent on payable_id). audit_log entry tagged 'release'. The mcp_pending_operations row is marked executed."},{"name":"mark_invoice_paid","description":"Marks a client invoice as paid WITHOUT recording an underlying payment row. Manual-override / paper-only reconciliation path. The canonical operator workflow for received money is record_payment (composite, propose+approve gated, records the payment + allocation + transitions invoice in one staged operation). Use this tool only when the payment cannot be captured as a payments row.\n\nUSE WHEN: You need to mark an invoice paid WITHOUT recording the underlying payment row — manual reconciliation, Xero-import catch-up of historical paid invoices, fixing a missed payment from before the system was in use, operator-driven override where the receipt cannot be matched to a single deposit.\n\nDON'T USE WHEN: You are recording a payment you actually received — use record_payment (propose_record_payment + approve_record_payment) instead. It creates the payment row, the allocation, transitions the invoice, AND emits invoice.paid — all in one staged operation. The composite tool also handles partial payments and overpayment-to-credit naturally. You don't know the exact real-world payment date — ask the operator (Principle 3 forbids defaulting to today). You don't have the current lock_version — call get_invoice first.\n\nPRECONDITIONS: Invoice exists, status is sent/partially_paid/overdue/disputed (NOT draft, voided, or already paid). Caller supplies expected_lock_version matching the invoice's current value. paid_at is the real-world payment date as ISO YYYY-MM-DD.\n\nSIDE EFFECTS: invoices.status='paid', invoices.paid_at=<paid_at>, lock_version+1. invoice.paid event emitted (idempotency key invoice.paid:<invoiceId>). Async handler 'mark_payables_releasable_on_invoice_paid' fires; linked payables in 'pending' transition to 'approved'. audit_log entry tagged 'paid'. No payment row is created — the audit trail will show the invoice paid with no matching payments row. For full audit-trail completeness, prefer record_payment."},{"name":"create_invoice","description":"Creates a draft invoice for a client with one or more line items. Auto-generates an invoice_number (next sequential via finance.invoicing.numbering_prefix setting), resolves the client PO via the cascade (manual override → first non-null on job_ids), and computes header totals (subtotal/gst/total) from the supplied lines. Default GST treatment per line is 'taxable' — Australian GST-inclusive: gst = round(amount_cents / 11). Emits invoice.created with PO + totals in payload (idempotency key invoice.created:<id>).\n\nUSE WHEN: An operator (or agent) needs to create a fresh draft invoice for a client. Manual invoice creation, catch-up invoice for historical work (set is_catch_up_invoice: true), or invoicing one or more completed jobs (supply job_ids to inherit the PO).\n\nDON'T USE WHEN: Recording a payment — use create_payment instead. Re-issuing a voided invoice — that needs a future reissue tool (not yet shipped). Sending an existing draft — use the propose_send_invoice → confirm_pending_operation → approve_send_invoice chain. Adding a line to an existing draft — use add_invoice_line.\n\nPRECONDITIONS: client_id references an active client in the caller's tenant. invoice_date and due_date are ISO YYYY-MM-DD (NEVER default to today — operator MUST enter the real-world invoice date per Finance Engine Principle 3; catch-up invoices use today's date entered explicitly). lines[] has ≥1 entry. Each line.amount_cents is a positive integer. If job_ids supplied, each id references a job in the caller's tenant.\n\nSIDE EFFECTS: Inserts one row into invoices (status='draft', lock_version=1, generated invoice_number) and N rows into invoice_lines. Header subtotal/gst_amount/total computed from lines. Emits invoice.created event. Wildcard log_audit handler writes an audit_log row.\n\nDUPLICATE GUARD (bug 7b5b805a): refuses with duplicate_warning when a live invoice (draft/sent/overdue) for the same client has an equal total and overlapping job/service period (same line job_ids, or same service identity with service dates within finance.invoicing.duplicate_window_days — default 14, strict). Re-call with override_duplicate_check: true to proceed intentionally.\n\nEX-GST ENTRY (bug 3645305b): when the operator quotes \"$X plus GST\", pass amount_basis: 'gst_exclusive' on that line — the gateway adds 10% GST on top and stores the canonical GST-inclusive amount (e.g. amount_cents 10000 ex-GST → line amount 11000, GST 1000). Default 'gst_inclusive' (amount_cents already includes GST). Entering a \"plus GST\" quote as-is WITHOUT this flag under-bills by 1/11th. Not combinable with gst_treatment 'mixed' or with quantity/unit_price_cents."},{"name":"create_invoice_from_proposal","description":"Composite: creates a DRAFT invoice directly from an ACCEPTED proposal. Resolves the client from the proposal, copies each live proposal line to an invoice line (proposal amounts are EX-GST by schema convention — the gateway adds 10% GST and stores canonical GST-inclusive amounts), inherits the proposal's client PO, and computes due_date from the payment-terms cascade (client terms → tenant default) unless an explicit due_date is supplied. Does NOT send — the draft goes through the normal propose_send_invoice → confirm_pending_operation → approve_send_invoice chain.\n\nUSE WHEN: An operator says \"invoice proposal #N\" / \"bill the accepted proposal for <client>\". Replaces the manual list_proposals → get_proposal → re-key-into-create_invoice chain and its GST-basis transcription risk.\n\nDON'T USE WHEN: Invoicing completed jobs not tied to a proposal — use create_invoice with job_ids. Ad-hoc or catch-up invoices — use create_invoice. Re-sending or editing an existing draft — use update_invoice / add_invoice_line / the send chain.\n\nPRECONDITIONS: proposal_id references a proposal in the caller's tenant with status='accepted'. invoice_date is ISO YYYY-MM-DD entered by the operator (Finance Engine Principle 3 — no default to today). The proposal has at least one live line with a positive fixed amount. Hourly/estimate lines (no fixed amount) cause an explicit refusal — invoice actuals via create_invoice instead.\n\nIDEMPOTENT: if a live (non-voided, non-deleted) invoice already exists for this proposal, returns it with already_existed=true and creates nothing.\n\nSIDE EFFECTS: Inserts one invoices row (status='draft', source_proposal_id set) + N invoice_lines rows via the createInvoice gateway. Emits invoice.created. Writes audit_log row. Inherits the duplicate-invoice guard (bug 7b5b805a) — pass override_duplicate_check: true only after confirming intentional repeat billing."},{"name":"update_invoice","description":"Updates the header fields of a draft invoice (subject_line, notes, client_po_number, due_date). Issued invoices are immutable — attempting to edit a sent/overdue/paid/voided invoice returns code:'immutable'. Optimistic-concurrency-gated on expected_lock_version: stale values return code:'stale_version' with the current lock_version so the caller can refresh and retry.\n\nUSE WHEN: Editing notes, subject line, due date, or PO on a draft invoice before sending. Adding the operator's narrative to the cover message. Adjusting due_date after a verbal extension agreed with the client.\n\nDON'T USE WHEN: Editing line items — use add_invoice_line / update_invoice_line / remove_invoice_line. The invoice is already sent — issued invoices are immutable; void+reissue is the correction path. You want to mark the invoice paid — use mark_invoice_paid. You want to send — use propose_send_invoice.\n\nPRECONDITIONS: invoice_id references a draft invoice in the caller's tenant. expected_lock_version matches the row's current lock_version. patch contains at least one field to update.\n\nSIDE EFFECTS: Updates the named header fields. Increments lock_version by 1. No event emission (header edits on drafts are reversible operator-internal operations; the audit_log row written by the wildcard handler — well, the wildcard handler doesn't fire here because no emitEvent — but mcpAuditLog writes an audit_log row directly. This is the only place in FE-02 where the audit narrative is written without an emitEvent path.)."},{"name":"void_invoice","description":"Voids an invoice in draft/sent/overdue/disputed status. REJECTS attempts to void a paid invoice — the correction path for paid is the refund flow (FE-05) + a credit note. Requires a non-empty void_reason and an explicit void_date (Principle 3 — no default to today). Increments lock_version. Emits invoice.voided with the reason in payload.\n\nUSE WHEN: A customer cancelled, a duplicate was sent in error, the wrong client was billed, or any other reason requiring the invoice to terminate before payment. Also used to discard a draft cleanly (preferred over soft-delete because it leaves an explicit audit narrative).\n\nDON'T USE WHEN: The invoice is paid — use the refund flow instead (FE-05; not yet shipped). The invoice is already voided — no-op anyway. You want to edit a draft — use update_invoice or add/update/remove_invoice_line. You want to reissue under a new invoice number — void this one, then create_invoice for the replacement.\n\nPRECONDITIONS: invoice_id references an invoice in the caller's tenant in status draft/sent/overdue/disputed (NOT paid or already voided). Caller supplies expected_lock_version matching the invoice's current value. void_reason is a non-empty string explaining the void. void_date is the real-world void date as ISO YYYY-MM-DD.\n\nSIDE EFFECTS: invoices.status='voided', lock_version+1. Emits invoice.voided event (idempotency key invoice.voided:<id>). audit_log row tagged 'voided'. Linked payables are NOT touched (Layer 2 invariant still holds — pending payables linked to voided invoices stay pending)."},{"name":"add_invoice_line","description":"Adds a line item to a draft invoice. Recomputes header totals (subtotal/gst_amount/total) atomically from non-deleted lines. Increments lock_version. Issued invoices are immutable — non-draft attempts return code:'immutable'.\n\nUSE WHEN: Building out a draft invoice line-by-line. Adding a cash discount line (negative amount_cents) as a header-recompute trigger (lock-in A2). Adding a catch-up line to an in-progress draft.\n\nDON'T USE WHEN: Updating an existing line — use update_invoice_line. Removing a line — use remove_invoice_line. The invoice is already sent — issued invoices are immutable. Editing a header field — use update_invoice.\n\nPRECONDITIONS: invoice_id references a draft invoice. expected_lock_version matches current value. amount_cents is a positive integer (INTEGER CENTS). description is non-empty.\n\nEX-GST ENTRY (bug 3645305b): when the operator quotes \"$X plus GST\", pass amount_basis: 'gst_exclusive' — the gateway adds 10% GST on top and stores the canonical GST-inclusive amount (amount_cents 10000 ex-GST → line amount 11000, GST 1000). Default 'gst_inclusive'. Not combinable with gst_treatment 'mixed'.\n\nSIDE EFFECTS: Inserts one row into invoice_lines. Recomputes invoices.subtotal/gst_amount/total. Increments lock_version. audit_log row tagged 'line_added'."},{"name":"update_invoice_line","description":"Updates an existing line on a draft invoice. Recomputes line GST + header totals atomically. Issued invoices are immutable.\n\nUSE WHEN: Correcting a typo in a line description, adjusting the amount of a draft line, changing GST treatment.\n\nDON'T USE WHEN: Adding a new line — use add_invoice_line. Removing a line — use remove_invoice_line. The invoice is already sent — immutable.\n\nPRECONDITIONS: invoice_id + line_id reference a draft invoice + non-deleted line in the caller's tenant. expected_lock_version matches the invoice's current value. At least one patch field is supplied.\n\nSIDE EFFECTS: Updates the named line fields. Recomputes header totals. Increments invoice lock_version."},{"name":"remove_invoice_line","description":"Soft-removes a line from a draft invoice (sets deleted_at). Recomputes header totals. Issued invoices are immutable.\n\nUSE WHEN: Deleting a line that was added in error on a draft invoice.\n\nDON'T USE WHEN: The invoice is already sent — immutable. You want to change an amount or description — use update_invoice_line.\n\nPRECONDITIONS: invoice_id + line_id reference a draft invoice + non-deleted line in the caller's tenant. expected_lock_version matches.\n\nSIDE EFFECTS: Sets invoice_lines.deleted_at on the line. Recomputes invoices.subtotal/gst_amount/total from remaining lines. Increments invoice lock_version."},{"name":"validate_invoice","description":"Runs the locale-specific tax-invoice validator against a draft or sent invoice and returns the structured result (ok + code + reason + carry-through fields). v1 ships the AU ruleset (lock-in B5 + WP-FE-06): supplier ABN required, recipient ABN required when GST > 0 AND total ≥ $1,000 (tenant-configurable threshold), GST sum sanity, all lines have explicit gst_treatment, tax-invoice-label correlate. The jurisdiction defaults to tenant setting `finance.tax.locale` ('AU' fallback). Future jurisdictions plug into the dispatch table without call-site changes.\n\nUSE WHEN: Confirming an invoice is ATO-clean before sending. Diagnosing why a third-leg `approve_send_invoice` was refused. Auditing invoice readiness across a tenant's draft queue (call once per invoice_id).\n\nDON'T USE WHEN: Mutating the invoice — this tool is read-only. Recomputing GST — use recalculate_invoice_gst. Reading the validation history — use the audit_log entries on the `invoice.ato_validation_failed` events.\n\nPRECONDITIONS: `invoice_id` references an invoice in the caller's tenant.\n\nSIDE EFFECTS: None. Read-only.\n\nDUPLICATE GUARD (bug 7b5b805a): the response includes warnings[] — entries of shape { type: 'possible_duplicate', invoice_number, invoice_id, status, match_basis } when a live invoice for the same client has an equal total and overlapping job/service period (window: finance.invoicing.duplicate_window_days, default 14)."},{"name":"recalculate_invoice_gst","description":"Recomputes per-line and header GST on a draft invoice from current `gst_treatment` flags. Bumps `lock_version`. Emits `invoice.gst_recalculated`. Lines with `gst_treatment='mixed'` PRESERVE their stored `gst_amount` (the operator-set value) — recalc only rewrites `taxable` (1/11 of inclusive amount) and `gst_free` (zero).\n\nUSE WHEN: After bulk-importing invoice lines from an external system that didn't compute GST. After a settings change to a tax rate (future locales). To re-baseline a draft invoice after manual line edits without changing the editor's view.\n\nDON'T USE WHEN: The invoice is not in draft. You just want to read the validation result — use `validate_invoice`. The invoice has `mixed`-treatment lines you haven't manually overridden (the recalc preserves existing `gst_cents` on mixed lines).\n\nPRECONDITIONS: `invoice_id` references a draft invoice in caller's tenant. `expected_lock_version` matches the current invoice value.\n\nSIDE EFFECTS: UPDATEs every non-deleted line's `gst_amount` per `gst_treatment` (except `mixed` which is preserved). UPDATEs invoice header `subtotal`/`gst_amount`/`total`. Bumps `lock_version`. Emits `invoice.gst_recalculated`. Writes audit_log row."},{"name":"approve_send_invoice","description":"Approves a staged invoice send (leg 3 of 3 in the propose → confirm → approve send chain). Runs the locale-specific ATO validator (per WP-FE-06 + lock-in B5) as a HARD GATE: on validation failure the send is refused with a structured code (`missing_supplier_abn` / `missing_recipient_abn_above_threshold` / etc.) and `invoice.ato_validation_failed` is emitted. On validation pass, the invoice transitions to `sent`, the email is dispatched, and `invoice.sent` fires.\n\nThird and final leg of the propose → confirm → approve send chain for invoices.\n\nUSE WHEN: The operator has reviewed the staged invoice send (via `get_pending_operation`) and explicitly approved. Always run after `propose_send_invoice` + `confirm_pending_operation`.\n\nDON'T USE WHEN: The operator has not approved. The operation is not `send_invoice` (use the artifact-specific approver). The operation is not in `staged` status (call `confirm_pending_operation` first).\n\nPRECONDITIONS: A pending operation exists with `operation_type='send_invoice'` and `status='staged'`. `confirmed: true`. Tenant `mail_provider` configured.\n\nSIDE EFFECTS: Runs `validateInvoiceForJurisdiction` per tenant `finance.tax.locale`. On failure: marks operation `validation_failed`, emits `invoice.ato_validation_failed`, returns the structured failure result (NOT a hard MCP error — the operator reads `ok:false` + `code`). On success: transitions invoice to `sent`, emits `invoice.sent` (idempotency-keyed), dispatches branded email via `sendBrandedEmail`, marks operation `approved_and_sent`, writes audit_log rows.\n\nCREDIT VISIBILITY (tool upgrade 0a26e90c): when the client has available credit at send time, the response includes `credits_on_account_before_send` (integer cents) and a `credit_hint`. Client credits AUTO-APPLY to the invoice asynchronously via the `invoice.sent` event handler — the send response cannot report the apply result synchronously; re-read via `get_invoice` to see whether the invoice became partially_paid/paid."},{"name":"propose_resend_invoice","description":"Stages a re-delivery of an already-sent invoice to all finance contacts (or an explicit override). The invoice must be in `sent`, `overdue`, or `partially_paid` status. Returns a pending_operation_id; the email fires on `confirm_pending_operation` (two-leg propose → confirm — no separate approve, the invoice was operator-approved at original send). Does NOT mutate the invoice (no number/total/status/lock_version change), runs no financial postings, emits no invoice.sent — it re-delivers the existing invoice. Recipients fan out to every site contact flagged receives_invoices (de-duped), falling back to the client billing_email; pass recipient_emails to override with a specific set.\n\nThe full send/resend timeline is in audit_log via `send`/`invoice_sent` + `invoice_resent` entries (each resend increments send_attempt_number).\n\nUSE WHEN: A previously sent invoice needs re-delivery — the client says they never received it, the original went to a single/wrong address, or a mail route was fixed.\n\nDON'T USE WHEN: Invoice is `draft`/`approved` — use propose_send_invoice. Invoice is `paid` (settled), `voided`, or `disputed` — resend has no meaning / resolve the dispute first.\n\nPRECONDITIONS: `invoice_id` references an invoice in the caller's tenant in `sent`/`overdue`/`partially_paid` status. At least one deliverable recipient resolves (a receives_invoices contact, the client billing_email, or an explicit recipient_emails entry). Tenant mail_provider configured.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `pending`, operation_type `resend_invoice`). NO email at this step. Writes an audit_log row recording the stage. The invoice is unchanged."},{"name":"propose_record_payment","description":"Stages a client payment recording for operator approval. Validates date discipline, amount, payment method, invoice state, and external-reference idempotency BEFORE staging. If the gate would block, returns the specific code so the operator can fix first.\n\nUSE WHEN: An operator wants to record a payment received from a client (EFT receipt, cash collection, cheque deposit, Stripe payment). The first step of the propose→approve money-moving flow. The canonical operator workflow for payments — supersedes the FE-04 mark_invoice_paid path for normal flow.\n\nDON'T USE WHEN: You don't know the exact real-world payment_date — ask the operator. Principle 3 forbids defaulting to today. Recording a refund — use the refund flow (FE-05, deferred). The invoice is in draft (never sent) — send it first via propose_send_invoice. The invoice is already paid — payment is rejected with invoice_already_paid.\n\nPRECONDITIONS: Invoice exists, status is sent/partially_paid/overdue/disputed (NOT draft, voided, or already paid). amount_cents > 0. payment_date is ISO YYYY-MM-DD (the actual real-world bank/cash receipt date — Principle 3). payment_method is in the FE-01 enum (cash/eft/stripe/cheque/other/bank_transfer/credit_card).\n\nSIDE EFFECTS: Inserts an mcp_pending_operations row (operation_type='record_payment'). Writes audit_log entry tagged 'payment_record_proposed'. No payment row created and no event emitted until approve_record_payment runs. If external_reference matches an existing payment, returns idempotent and does NOT create a new operation row.\n\nPARAM NAMING: The payment reference param is external_reference — NOT 'reference'. Passing 'reference' is rejected with a clear error (it was previously silently dropped, losing reconciliation data). The stored value is returned by get_payment as the 'reference' field."},{"name":"approve_record_payment","description":"Approves a staged payment recording and atomically inserts the payment row, allocation, transitions the invoice (to partially_paid or paid), and emits payment.recorded + (conditional) invoice.partially_paid or invoice.paid.\n\nUSE WHEN: Operator has reviewed the staged operation from propose_record_payment and explicitly says \"record it\". The second step of the propose→approve money-moving flow.\n\nDON'T USE WHEN: The operator has not explicitly approved the recording in the current session. Do not auto-approve. Do not call without confirmed=true.\n\nPRECONDITIONS: A pending operation of type 'record_payment' exists (from propose_record_payment) and has not yet been executed. The linked invoice is still payment-eligible (the gateway re-validates state defensively). The operator has approved.\n\nSIDE EFFECTS: Inserts a payments row + a payment_allocations row (capped at remaining invoice balance; excess → client_credits with source='overpayment'). Invoice transitions to partially_paid or paid based on accumulated allocations. Emits payment.recorded (idempotent on payment_id). If invoice transitions to paid, emits invoice.paid via the FE-04 markInvoicePaid gateway (same payload shape as FE-04). If transitions to partially_paid, emits invoice.partially_paid (payment-id-suffixed key). The mcp_pending_operations row is marked approved_and_sent."},{"name":"void_payment","description":"Soft-deletes a payment and rolls back the linked invoice's status. Required void_reason + void_date for audit-trail discipline. Use for typo recovery (wrong amount, wrong invoice) or bank reversal (deposit clawed back).\n\nUSE WHEN: A previously recorded payment was a mistake (wrong amount, wrong invoice) OR the underlying money movement was reversed (bank failed deposit, cheque bounced). The reconciliation path.\n\nDON'T USE WHEN: You want to refund money to the client — use the refund flow (FE-05, deferred). The payment recorded the wrong external_reference but the money was real — use update_payment when shipped (not part of FE-03). The voiding would unwind a contractor payable you've already released — operator must manually decide whether the released payable also needs reversing.\n\nPRECONDITIONS: Payment exists, is not already voided (deleted_at IS NULL). void_reason is non-empty. void_date is ISO YYYY-MM-DD (the actual date the void decision was made — Principle 3).\n\nSIDE EFFECTS: payments.deleted_at set; payment_allocations.deleted_at set. Invoice recomputed: paid → partially_paid (if other payments remain covering > 0) or sent (no remaining payments). Emits payment.voided always; emits invoice.payment_unwound when invoice transitions. audit_log entry tagged 'payment_voided'. Does NOT auto-unwind released contractor payables."},{"name":"void_payable","description":"Soft-deletes a non-released contractor payable (status → 'void', deleted_at set). Required void_reason + void_date for audit-trail discipline. The symmetric partner to void_payment for the contractor money-out side.\n\nUSE WHEN: A payable was created in error (wrong contractor, wrong amount, duplicate manual entry) and has NOT been released yet. Operator typo / one-off recovery, and per-row disposal of a mistakenly-created payable.\n\nDON'T USE WHEN: The payable is already RELEASED — reverse the release first; a released payable's contractor funds are flagged paid in the ledger and cannot be voided directly. You want to dispose of test data in bulk — use cleanup_test_data. You want to reduce the amount, not remove the payable — there is no partial void.\n\nPRECONDITIONS: Payable exists, is not already void (deleted_at IS NULL, status != 'void'), and is NOT released (status in pending/approved/blocked). void_reason is non-empty. void_date is ISO YYYY-MM-DD (the actual date the void decision was made — Principle 3).\n\nSIDE EFFECTS: payables.status → 'void'; payables.deleted_at set. Emits payable.voided (idempotent on payable_id) so the temporal/reconciliation timeline reflects the void. audit_log entry tagged 'payable_voided'. No money moves — a released payable is refused, so this never reverses a disbursement."},{"name":"propose_record_refund","description":"Stages a client refund recording for operator approval. Refund anchors to a payment (lock-in B8.1) and requires a non-empty refund_reason (B8.2). Validates date discipline, amount, method, payment state, and refundable-balance BEFORE staging. If the gate would block, returns the specific code so the operator can fix first.\n\nUSE WHEN: An operator wants to record money refunded to a client against a specific payment (typo recovery, returned goods, cancelled service). The first step of the propose→approve money-out flow. The canonical operator workflow for refunds.\n\nDON'T USE WHEN: You don't know the exact real-world refund_date — ask the operator. Principle 3 forbids defaulting to today. Voiding a payment that wasn't received — use void_payment instead. The payment is already voided — refund is rejected with payment_voided. The amount exceeds the refundable balance — refund is rejected with amount_exceeds_payment.\n\nPRECONDITIONS: Payment exists, is not voided, and has refundable balance ≥ amount_cents. amount_cents > 0. refund_date is ISO YYYY-MM-DD (the actual real-world bank/cash refund date — Principle 3). refund_method is in the enum (cash/eft/stripe/cheque/other). refund_reason is non-empty (B8.2).\n\nSIDE EFFECTS: Inserts an mcp_pending_operations row (operation_type='record_refund'). Writes audit_log entry tagged 'refund_record_proposed'. No refund row created and no event emitted until approve_record_refund runs. If external_reference matches an existing refund against this same payment, returns idempotent and does NOT create a new operation row."},{"name":"approve_record_refund","description":"Approves a staged refund recording and atomically inserts the refund row, recomputes the linked invoice's status (paid → partially_paid or sent/overdue depending on effective allocation), handles linked contractor payables (pending+approved flips back to pending; released emits payable_unwind_required signal), and emits refund.recorded + (conditional) invoice.partially_refunded or invoice.refunded + (conditional) payable_unwind_required.\n\nUSE WHEN: Operator has reviewed the staged operation from propose_record_refund and explicitly says \"record it\". The second step of the propose→approve money-out flow.\n\nDON'T USE WHEN: The operator has not explicitly approved the recording in the current session. Do not auto-approve. Do not call without confirmed=true.\n\nPRECONDITIONS: A pending operation of type 'record_refund' exists (from propose_record_refund) and has not yet been executed. The linked payment is still refundable (the gateway re-validates state defensively). The operator has approved.\n\nSIDE EFFECTS: Inserts a refunds row. Invoice transitions to partially_paid (if effective allocation > 0) or sent/overdue (if fully refunded) — invoices in 'paid' state roll back via this gateway. Linked payables in 'approved' (not yet released) flip back to 'pending'. Linked payables already 'released' are NOT touched — instead a payable_unwind_required event is emitted for operator action. Emits refund.recorded (idempotent on refund_id). The mcp_pending_operations row is marked approved_and_sent. The response carries a notification block { client_email_will_send, recipient, reason } projecting whether the async client refund-notification email will fire (INTENT — from finance.refunds.email_on_record + the client's billing_email; reason ∈ email_on_record | suppressed_by_setting | no_billing_email). It is intent, not a confirmed send (the send is async); read confirmed send/delivery via get_email_delivery_status(entity_type:\"refund\")."},{"name":"void_refund","description":"Soft-deletes a refund and rolls the linked invoice's status forward. Required void_reason + void_date for audit-trail discipline. Use for typo recovery (wrong amount, wrong refund) or refund returned to tenant (customer changed mind, refund cheque bounced).\n\nUSE WHEN: A previously recorded refund was a mistake (wrong amount, wrong payment) OR the refund money has come back to the tenant (customer returned it, bank reversed it). The reconciliation path.\n\nDON'T USE WHEN: You want to issue a new refund — use propose_record_refund. The refund was already voided — call get_refund first to check status. The invoice is paid via other payments — voiding the refund will roll it forward; that's expected.\n\nPRECONDITIONS: Refund exists, is not already voided (deleted_at IS NULL). void_reason is non-empty. void_date is ISO YYYY-MM-DD (the actual date the void decision was made — Principle 3).\n\nSIDE EFFECTS: refunds.deleted_at set. Invoice recomputed: partially_paid → paid (if accumulated allocations now cover total) or sent → partially_paid (if effective allocation > 0). Emits refund.voided always; emits invoice.refund_unwound when invoice transitions. audit_log entry tagged 'refund_voided'. Does NOT auto-re-approve previously reblocked contractor payables — operator decides via list_payables + approve_release_payable."},{"name":"create_credit_note","description":"Creates a DRAFT credit note against an existing sent invoice. Credit notes anchor to invoices (not payments — refunds do that), reduce the invoice's effective settled amount on send, and carry the originating invoice's PO + ATO fields automatically.\n\nUSE WHEN: The operator wants to reduce what a client owes on a previously-sent invoice — for billing corrections, post-invoice discounts, partial cancellations, or returned services. The first step of the create → propose-send → confirm → approve-send chain. Lines that link to an originating invoice_line inherit gst_treatment from that line (per architect lock — tax classification is structural).\n\nDON'T USE WHEN: You want to refund cash already received — use propose_record_refund + approve_record_refund (FE-05). The invoice is still in draft — credit notes are only valid against sent/partially_paid/paid/overdue invoices. The invoice is voided — credit notes cannot be issued against voided invoices.\n\nPRECONDITIONS: invoice_id is a sent/partially_paid/paid/overdue invoice in the caller's tenant. credit_note_date is ISO YYYY-MM-DD (real-world date — Principle 3). reason is non-empty. Each line amount_cents > 0. Line totals are NOT capped at invoice.total at create time — over-invoice totals are permitted here (this handler enforces no ≤-total cap) and are reconciled at send by approve_send_credit_note, which settles the invoice and mints any overflow as a client_credit. Send-time overflow basis (ratified by bug 48865c9a [C05] Option 1): overflow = max(0, credit-note total − OUTSTANDING), where OUTSTANDING = invoice total − refund-net cash payments − applied client_credits − prior sent credit_notes (floored at 0; the canonical lib/finance/outstanding.ts basis). A client_credit is minted only when the credit-note total exceeds OUTSTANDING (and finance.credit_notes.auto_create_client_credit_on_overflow is enabled); a credit note at or under OUTSTANDING settles the invoice with no overflow.\n\nSIDE EFFECTS: Inserts a row in credit_notes (status='draft') + one row per line in credit_note_lines. Snapshots supplier_name / supplier_abn / buyer_abn / client_po_number from the invoice header (B4 PO carry — Principle 7). Generates a sequenced credit_note_number using the tenant's finance.credit_notes.number_prefix setting. Emits credit_note.created. Writes audit_log via wildcard log_audit handler. No invoice adjustment happens here — that's gated behind approve_send_credit_note (three-leg send)."},{"name":"update_credit_note_line","description":"Patches a credit-note line. Only valid while the credit note is in draft. Recomputes the line's GST + the header totals automatically.\n\nUSE WHEN: Operator needs to adjust a credit-note line before sending — fix the amount, change the description, switch GST treatment.\n\nDON'T USE WHEN: The credit note is staged or sent — credit notes are immutable post-send. Use void_credit_note to undo and create a fresh one.\n\nPRECONDITIONS: credit_note_id refers to a draft credit note in the caller's tenant. line_id belongs to that credit note. expected_lock_version matches the credit note's current lock_version.\n\nSIDE EFFECTS: UPDATEs the line + recomputes header totals (subtotal/gst/total). Bumps lock_version. No event emitted (mirrors FE-02's update_invoice_line convention for amount-only patches)."},{"name":"approve_send_credit_note","description":"Approves a staged credit-note send. Runs the locale-specific ATO validator (per FE-06 reuse — same rules for credit notes as invoices) as a HARD GATE. On refusal returns a structured failure (ok:false + code) without sending. On pass: transitions credit_note to sent, adjusts the originating invoice's effective settled amount, optionally mints a client_credits row for overflow, dispatches the branded email, emits credit_note.sent + credit_note.applied_to_invoice.\n\nUSE WHEN: Operator has reviewed the staged send (via get_pending_operation) and explicitly approved. Always after propose_send_credit_note + confirm_pending_operation.\n\nDON'T USE WHEN: Operator has not approved. Operation type is not send_credit_note. Operation is not staged — confirm it first via confirm_pending_operation.\n\nPRECONDITIONS: A pending operation exists with operation_type='send_credit_note' and status='staged'. confirmed: true.\n\nSIDE EFFECTS: Runs validateCreditNoteForJurisdiction per tenant finance.tax.locale. On failure: marks operation validation_failed + emits credit_note.ato_validation_failed (audit-only) + returns structured failure. On success: transitions credit_note draft/staged → sent + emits credit_note.sent + recomputes the originating invoice (may emit credit_note.applied_to_invoice and flip invoice status) + optionally mints client_credits row for overflow + dispatches the email + marks operation approved_and_sent."},{"name":"propose_resend_credit_note","description":"Stages a re-delivery of an already-sent credit note to all receives_invoices contacts (credit notes are invoice-family) or an explicit override. The credit note must be in `sent` status. The email fires on `confirm_pending_operation` (two-leg propose → confirm). PURE re-delivery: no credit-note mutation, no status change, no re-application to the invoice (the original send already did that). Pass recipient_emails to override the fan-out.\n\nUSE WHEN: A previously sent credit note needs re-delivery (client didn't receive it / wrong address).\n\nDON'T USE WHEN: The credit note is `draft`/`staged` — use approve_send_credit_note (first-send chain, which runs the ATO gate + invoice recompute). The credit note is `voided`.\n\nPRECONDITIONS: `credit_note_id` references a `sent` credit note in the caller's tenant. At least one deliverable recipient resolves. Tenant mail_provider configured.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `pending`, operation_type `resend_credit_note`). NO email at this step. Writes an audit_log row. The credit note is unchanged."},{"name":"get_credit_note","description":"Returns a credit note by ID with its lines + linked invoice projection (status, total, effective settled).\n\nUSE WHEN: You already have the credit_note_id — auditing a credit note, surfacing the reason field, checking the linked invoice's current status.\n\nDON'T USE WHEN: You don't yet have the ID — use list_credit_notes. You want the originating invoice's full lifecycle — use get_invoice_full_history (FE-15).\n\nPRECONDITIONS: id is a valid UUID of an existing credit note in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_credit_notes","description":"Lists credit notes filtered by client / invoice / status / date range. Defaults to excluding test rows.\n\nUSE WHEN: Looking up credit notes for a client, an invoice, or a date range. Auditing pending drafts. Surfacing voided credit notes.\n\nDON'T USE WHEN: You have the specific credit_note_id — use get_credit_note. You want the full invoice lifecycle including credit notes — use get_invoice_full_history (FE-15).\n\nPRECONDITIONS: None.\n\nSIDE EFFECTS: None — read-only."},{"name":"void_credit_note","description":"Soft-voids a credit note. If the credit note was previously sent, rolls the originating invoice forward (recomputes effective settled). Emits credit_note.voided + (when sent) invoice.credit_unwound.\n\nUSE WHEN: Operator needs to undo a credit note (typo, wrong amount, replaced by a corrected credit). The recovery path for sent credit notes — credit notes are immutable after send (per FE-05b D.8); void + reissue is the workflow.\n\nDON'T USE WHEN: The credit note is in draft and you can just update its lines — use update_credit_note_line instead.\n\nPRECONDITIONS: credit_note_id refers to a non-voided credit note in the caller's tenant. void_date is ISO YYYY-MM-DD (Principle 3). void_reason is non-empty.\n\nSIDE EFFECTS: UPDATEs credit_notes.status='voided' + sets void_reason, void_date, deleted_at. Emits credit_note.voided. When the credit note had been sent: recomputes the invoice (may flip status forward; emits invoice.credit_unwound). NOTE: any overflow client_credits row minted by the original send is NOT auto-voided — operator handles it manually. NOTE: any Xero CreditNote draft already mirrored is NOT auto-deleted — operator cleans up in Xero."},{"name":"create_payable","description":"Manually creates a contractor payable row in 'pending' status. CATCH-UP and OPERATOR-OVERRIDE path — the canonical flow is the job.completed handler which auto-creates payables. The release-side gate (Layer 2 cash flow protection — payable cannot release until linked invoice is paid) still applies.\n\nUSE WHEN: A completed job pre-dates the FE-04B handler (the event fired before the handler was registered). You voided an auto-created payable and need to recreate it correctly. You need to attest a payable for a contractor outside the normal job lifecycle (one-off reconciliation). Operator backfilling a payable amount that landed at 0 because the source CSL had no contractor_rate.\n\nDON'T USE WHEN: The job will complete soon and the auto-create handler will fire — let the handler do its job. You don't have a documented operator reason (source_attestation is required so audit_log captures WHY this was created manually). You're trying to release a payable — use propose_release_payable + approve_release_payable instead.\n\nPRECONDITIONS: contractor_id exists in this tenant. amount_cents is a non-negative integer (INTEGER CENTS — never pass dollar amounts). source_attestation is a non-empty operator-supplied reason. If linked_job_id is supplied, the job must belong to the same tenant AND the same contractor (cross-attribution defence). If a non-void, non-deleted payable already exists for the linked_job_id, returns already_existed=true with the existing payable_id (Layer 1 DB dedup).\n\nSIDE EFFECTS: Inserts a payable row in status='pending' with payable_paid_date=NULL and released_at=NULL (Principle 3 — no real-world payment date at creation). Emits payable.created event (idempotency key payable.created:<payable_id>). Writes audit_log entry with action='created', metadata.source='manual', metadata.attestation=<source_attestation>. No money moves; the payable is in pending. The release-side gate will refuse to release this payable until it has a linked_invoice_id AND that invoice is status='paid'."},{"name":"backfill_completed_job","description":"Records an already-performed job when the contractor's bill arrives after the fact. ONE CALL creates the completed job + contractor payable with the attested GST-INCLUSIVE bill amount (84f7204f). Returns a suggested invoice line for billing the client — suggested_invoice_line.amount_cents is the CSL client_rate_cents (ex-GST); pass amount_basis:'gst_exclusive' to create_invoice/add_invoice_line so 10% GST is added on top (treating it as GST-inclusive under-bills the client).\n\nUSE WHEN: A contractor's bill arrives for work already done. The job was never recorded in the system. You need to catch up the job + payable + have an invoice line ready.\n\nDON'T USE WHEN: The job exists and just needs status change — use record_completion or update_job. The payable exists and needs editing — use update_payable. You're scheduling future work — use create_job.\n\nPRECONDITIONS: site_id and service_id (or contract_service_line_id) reference existing rows. contractor_id is active. contractor_invoice_amount_cents is the GST-INCLUSIVE amount from the contractor's bill (84f7204f). source_attestation is non-empty.\n\nSIDE EFFECTS: Creates a job (status=completed, completed_at=performed datetime). The job's admin_notes records the backfill provenance as '[backfill] {source_attestation} | Ref: {contractor_invoice_ref} | {notes}'; completion_notes and contractor_notes remain NULL (a backfilled job has no field-submitted completion text — to verify the documented 'notes recorded' outcome, read admin_notes, NOT completion_notes/contractor_notes). completion_photos likewise stays empty ([]) — a backfill records an after-the-fact bill, not a field completion. Creates a payable (status=pending) with the attested amount; the source_attestation is ALSO recorded on the payable's notes as '[backfill] {source_attestation} | Contractor ref: {contractor_invoice_ref}' (the free-text notes param lands only on the job's admin_notes, not on the payable). Emits job.completed event. Writes TWO audit_log rows: (1) action:'complete' (source 'mcp:backfill_completed_job') with NULL actor — the completion row is internal and intentionally unattributed; (2) action:'backfill_completed_job' carrying the calling actor. To verify operator attribution, check action:'backfill_completed_job', NOT action:'complete'. Does NOT auto-release the payable (Layer 2 gate still applies)."},{"name":"get_client_statement","description":"Returns the aggregated client statement view for a date range: opening balance, in-period invoices/payments/refunds, closing balance, outstanding amount, and per-PO subtotals. Amounts in integer cents. Pure read — does NOT persist a statement row (use propose_send_statement for that).\n\nUSE WHEN: An operator (or agent) asks 'where does this client stand right now', 'how much does Bob owe me', or 'show me last month for Caraway'. The canonical one-call surface for a statement-shaped view of a client's account over any period.\n\nDON'T USE WHEN: You want to send a statement — use propose_send_statement (which calls this internally + persists the row). You want a full audit timeline across all event types — that's get_client_audit_timeline (FE-15). You want a reconciliation view with corrections + late-arriving jobs + unhealthy flags — that's get_period_reconciliation_view (FE-15).\n\nPRECONDITIONS: `client_id` references a client in the caller's tenant. `period_start` and `period_end` are ISO YYYY-MM-DD with period_end >= period_start.\n\nSIDE EFFECTS: None — read-only. NOTE on outstandingCents: it (= closingBalanceCents) is the PERIOD CLOSING BALANCE = totalInvoicedCents minus totalPaidCents within [period_start, period_end], NOT the live AR outstanding, and can differ from get_client_portal_finance_view.outstanding_cents (the live balance owed right now = unpaid sent/overdue invoices, excluding credits/overpayments). For 'current outstanding' use get_client_portal_finance_view; for 'statement closing balance' use this tool."},{"name":"list_statement_send_history","description":"Lists statement send and void events for the tenant, sourced from audit_log. Filter by client_id and since (ISO date). Returns chronological actor + action + statement_id + metadata.\n\nUSE WHEN: Investigating when statements were last sent to a client, who sent them, whether any have been voided. The per-domain temporal query for statements per lock-in Principle 4 (audit log queryability is a first-class operational concern).\n\nDON'T USE WHEN: You want the statement bodies — use list_statements or get_statement. You want a full cross-domain audit timeline — use get_client_audit_timeline (FE-15).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"propose_send_statement","description":"Proposes sending a statement to a client for a period. Creates a draft `statements` row capturing opening/closing balances + in-period totals, then stages an `mcp_pending_operations` row. Awaits `confirm_pending_operation` (stages for operator approval) then `approve_send_statement` (sends the email).\n\nThree-leg send pattern mirrors `propose_send_invoice` / `propose_send_contractor_agreement`.\n\nUSE WHEN: An operator says 'send Bob a statement for last month' or an agent decides a statement is the right next action. First leg of the propose → confirm → approve chain for statement sends.\n\nDON'T USE WHEN: The client has `finance.statements.enabled = false`. You're just checking a balance — use get_client_statement (no row persisted, no operation staged). You want to skip operator approval — that's not supported; the send must go through approve_send_statement.\n\nPRECONDITIONS: `client_id` is in the caller's tenant. Client has a billing_email (unless `recipient_email_override` is supplied). `finance.statements.enabled` is true for the client. `period_end >= period_start`. `statement_date` is supplied (ISO YYYY-MM-DD — no default, Principle 3).\n\nSIDE EFFECTS: Inserts a draft `statements` row + N `statement_items` rows (one per in-period invoice + one per in-period payment). Emits `statement.created`. Inserts an `mcp_pending_operations` row (status='pending', operation_type='send_statement'). NO email sent at this step. Writes an audit_log row."},{"name":"propose_resend_statement","description":"Stages a re-delivery of an already-sent statement to all receives_statements contacts (or an explicit override). The statement must be in `sent` status. The email fires on `confirm_pending_operation` (two-leg propose → confirm). PURE re-delivery: no statement mutation, no status change. Recipients fan out to every receives_statements contact (de-duped), falling back to client billing_email; pass recipient_emails to override.\n\nUSE WHEN: A previously sent statement needs re-delivery (client didn't receive it / wrong address).\n\nDON'T USE WHEN: The statement is `draft` — use approve_send_statement (first-send chain). The statement is `voided`.\n\nPRECONDITIONS: `statement_id` references a `sent` statement in the caller's tenant. At least one deliverable recipient resolves. Tenant mail_provider configured.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `pending`, operation_type `resend_statement`). NO email at this step. Writes an audit_log row. The statement is unchanged."},{"name":"void_statement","description":"Soft-marks a statement voided. Operator-attested error recovery (sent the wrong period, wrong client, etc.). Required `void_reason` (non-empty) + `void_date` (ISO YYYY-MM-DD; no default, Principle 3). Increments lock_version, sets status=voided + voided_at, emits statement.voided.\n\nVoiding a sent statement does NOT unsend the email — the recipient already has the original. The void is the system-side audit record that the statement should not be used as a source of truth.\n\nUSE WHEN: A statement was sent in error and the operator wants the audit trail to reflect that. The recipient may already have received the email; the void is the record on the platform side.\n\nDON'T USE WHEN: You want to send a corrected statement — issue a new propose_send_statement with the corrected period or client; both rows persist for audit. You want to delete the row — that's not supported; void is the right verb.\n\nPRECONDITIONS: `statement_id` references a statement in the caller's tenant. Statement is not already voided. `void_reason` non-empty. `void_date` ISO YYYY-MM-DD.\n\nSIDE EFFECTS: Updates `statements.status='voided'` + `void_reason` + `void_date` + `voided_at` + lock_version+1. Emits `statement.voided`. Writes audit_log row."},{"name":"get_client_audit_timeline","description":"Returns the chronological financial event timeline for one client over a period. Reads `domain_events` filtered to invoice/payment/refund/statement/payable/proposal/client entities owned by the client. Each row exposes BOTH `occurred_at` (real-world event time) AND `recorded_at` (system row-insertion time) per Principle 3 — these can legitimately differ. `client_po_number` surfaces on every relevant row. Money in integer cents.\n\nUSE WHEN: An operator (or agent) asks 'show me everything that happened on this client's account this quarter / last month / this year', 'walk me through the financial history', or 'what's the full timeline for [TEST] Caraway Enterprises'. The canonical chronological narrative across all finance domains.\n\nDON'T USE WHEN: You want a single invoice's complete story — use `get_invoice_full_history`. You want period totals — use `get_period_reconciliation_view`. You want anomaly detection — use `find_orphaned_or_unusual_events`. You want per-domain state changes only — use the per-domain temporal tools (`list_invoice_state_changes`, `list_statement_send_history`).\n\nPRECONDITIONS: `client_id` references a client in the caller's tenant. `period_start` and `period_end` are ISO YYYY-MM-DD with period_end >= period_start; if omitted, defaults to trailing 90 days (response surfaces `period.source: 'default_90_days'`).\n\nSIDE EFFECTS: None — read-only."},{"name":"get_invoice_full_history","description":"Returns the complete story of a single invoice: header (with PO + lock_version + status + email_send_status) + state transitions from audit_log + line items + every allocated payment + every refund against those payments + email send_history from email_deliveries + linked contractor payables. Money in integer cents.\n\nUSE WHEN: An operator (or agent) is investigating a single invoice — 'pull the full history of INV-2026-000042', 'why is this invoice partially_paid', 'what happened to invoice X', 'show every action on this invoice'. The canonical 'tell-me-everything-about-this-invoice' surface.\n\nDON'T USE WHEN: You want a client-wide timeline — use `get_client_audit_timeline`. You only need state transitions — use `list_invoice_state_changes`. You want a period summary — use `get_period_reconciliation_view`.\n\nPRECONDITIONS: `invoice_id` references an invoice in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_period_reconciliation_view","description":"Returns the two-sided (AR + AP) basis-aware reconciliation view for a period. AR side: invoices broken by lifecycle state (sent/overdue/paid). AP side: payables broken by lifecycle state (pending/released/paid). NET position = AR total − AP total. Accounting basis (cash/accrual) selects the date anchor: cash = money-movement dates (payment_date / payable_paid_date); accrual = earned/incurred dates (issued_at / created_at). The basis flips BOTH sides identically. Also includes legacy fields (billed_cents, paid_cents, outstanding_cents, refunded_cents), corrections, unhealthy_flags, po_breakdown. Money in integer cents. REFUND BASIS: paid_cents (and ar.paid) are GROSS receipts here — paid_cents_is_refund_net is false — so net cash = paid_cents − refunded_cents and net_cents (AR − AP) is gross-of-refunds; subtract refunded_cents exactly ONCE for the C09 'received − refunded == net cash' figure (refunded_cents is informational disclosure, do not double-subtract).\n\nUSE WHEN: An operator (or agent) needs 'did the month close cleanly', 'what's the net position for March', 'show me AR vs AP for [TEST] Drakes IGA in March 2026'. The period close surface.\n\nDON'T USE WHEN: You want a stream of events — use `get_client_audit_timeline`. You want a single invoice — use `get_invoice_full_history`. You want platform-wide anomaly detection — use `find_orphaned_or_unusual_events`.\n\nPRECONDITIONS: `period_start` and `period_end` are ISO YYYY-MM-DD with period_end >= period_start. Optional `client_id` scopes the view to one client. Optional `basis` overrides the tenant setting.\n\nSIDE EFFECTS: None — read-only."},{"name":"find_orphaned_or_unusual_events","description":"Returns the operator-alert anomaly stream — six signal types surfaced by the temporal query layer:\n  • unallocated_payment (payment older than grace hours with no allocation),\n  • invoice_unpaid_overdue (sent/overdue invoice older than threshold days with no payment),\n  • soft_bounce_threshold_exceeded (invoice/statement with soft_bounce_count >= threshold),\n  • released_payable_unwind_required (refund created an unbalance on a previously-released contractor payable),\n  • ghost_event (declared EVENT_TYPES key with zero domain_events emissions in lookback window),\n  • void_with_unresolved_payable (voided invoice with a released payable still active).\n\nSeverity: critical / warn / info. Sorted severity DESC then occurred_at DESC. Each signal has `signal_type`, `severity`, `entity_type`, `entity_id`, `occurred_at`, `summary`, `suggested_next_action`, `related_event_ids`. Anomaly thresholds tunable per-tenant via `finance.temporal.*` settings + `notifications.soft_bounce_alert_threshold`.\n\nUSE WHEN: An operator (or agent) needs the 'something looks off' surface — 'find anything unusual across all clients in the last 30 days, show highest-priority signals first', 'what's broken on this tenant', 'are there any orphaned payments / overdue invoices / silent failures'. The agent-cognition surface for ops hygiene.\n\nDON'T USE WHEN: You're investigating a specific known invoice/client — use `get_invoice_full_history` / `get_client_audit_timeline`. You want period totals — use `get_period_reconciliation_view`.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_xero_connection_status","description":"Returns the Xero connector state for the caller's tenant: whether the integration is enabled (tenant setting), whether an active OAuth connection exists, and the most recent error if any.\n\nUSE WHEN: Diagnosing why a Xero mirror isn't happening. Verifying the connection before manually re-syncing. Surfacing connector health on a settings page.\n\nDON'T USE WHEN: You want to see specific failed mirror attempts — use list_xero_mirror_log. You want to enable / disable the integration — use update_setting on `integrations.xero.enabled`.\n\nPRECONDITIONS: None. Returns a fully-formed status object even when not connected.\n\nSIDE EFFECTS: None — read-only.\n\nOUTPUT SHAPE:\n{\n  integration_enabled: boolean,\n  tier_status: \"enabled\" | \"upgrade_required\",\n  connected: boolean,\n  xero_tenant_name?: string,\n  connected_at?: string,\n  last_refreshed_at?: string,\n  status?: \"active\" | \"disconnected\" | \"revoked\" | \"error\",\n  last_error?: string\n}"},{"name":"get_xero_authorize_url","description":"Returns a one-shot Xero OAuth authorize URL for the caller's tenant. The agent surfaces the URL to the operator as a clickable link; only a browser can complete the OAuth handshake.\n\nUSE WHEN: The operator wants to connect or reconnect their Xero org. get_xero_connection_status returned connected:false and the operator asked how to connect.\n\nDON'T USE WHEN: A connection already exists and is healthy — call get_xero_connection_status first. The integration is disabled — direct the operator to enable it via update_setting first.\n\nPRECONDITIONS: XERO_CLIENT_ID + XERO_CLIENT_SECRET + XERO_REDIRECT_URI + XERO_OAUTH_STATE_SIGNING_KEY env vars configured on the server. Missing env vars surface as a structured 503 error.\n\nSIDE EFFECTS: None — the URL is generated stateless via signed state (HMAC + 10-minute expiry). Repeated calls produce different URLs (different nonces).\n\nOUTPUT: { url: string, expires_in_seconds: number } or { error: \"not_configured\", detail: string }."},{"name":"list_xero_mirror_log","description":"Returns Xero mirror attempts (synced, failed, skipped) for the caller's tenant, ordered newest-first.\n\nUSE WHEN: Auditing recent Xero sync activity. Diagnosing why a specific invoice didn't appear in Xero (filter by status='failed'). Surfacing connector activity on an admin dashboard.\n\nDON'T USE WHEN: You want the mirror history for ONE specific entity — use get_xero_mirror_for_entity. You want the connection status — use get_xero_connection_status.\n\nPRECONDITIONS: None. Empty array when no rows.\n\nSIDE EFFECTS: None — read-only.\n\nARGS: status (optional: 'synced' | 'failed' | 'skipped' | 'pending'), source_entity_type (optional: 'invoice' | 'payable'), limit (default 50, max 200).\n\nOUTPUT: array of { id, source_event_id, source_entity_type, source_entity_id, xero_entity_type, xero_entity_id, xero_contact_id, status, attempts, last_error, http_status, synced_at, created_at }."},{"name":"get_xero_mirror_for_entity","description":"Returns the chronological Xero mirror history for a single invoice or payable, plus the current synced Xero entity ID if one exists.\n\nUSE WHEN: An operator asks why invoice X didn't appear in Xero. Auditing a single entity end-to-end. Deciding whether to manual_resync_*_to_xero.\n\nDON'T USE WHEN: You want all recent mirror attempts — use list_xero_mirror_log. You want the connector's overall health — use get_xero_connection_status.\n\nPRECONDITIONS: entity_type is 'invoice' or 'payable'; entity_id is a valid UUID for the caller's tenant.\n\nSIDE EFFECTS: None — read-only.\n\nOUTPUT:\n{\n  source_entity: { type, id },\n  mirror_history: Array<{ id, status, http_status, xero_entity_id, xero_contact_id, last_error, attempts, created_at, synced_at }>,\n  current_synced: { id, xero_entity_id, xero_contact_id, synced_at } | null\n}"},{"name":"manual_resync_invoice_to_xero","description":"Manually mirrors a biloh invoice to Xero as a draft. Recovery path for invoices that failed to mirror automatically (status='failed' in xero_mirror_log). Idempotent — by default returns 'already_synced' if a synced row exists.\n\nUSE WHEN: An invoice shows status='failed' in list_xero_mirror_log and the operator has fixed the underlying issue (contact name in Xero, ABN mismatch, etc.). The mirror handler crashed before persisting and you want to re-fire. The original event never reached the handler (rare; the auto-create path is the canonical flow).\n\nDON'T USE WHEN: The invoice has not been sent in biloh yet — let invoice.sent fire naturally. You want to delete a duplicate draft inside Xero — that's an operator task in Xero itself.\n\nPRECONDITIONS: invoice_id exists in this tenant. integrations.xero.enabled=true. An active xero_connections row exists. Tenant tier allows the Xero integration.\n\nSIDE EFFECTS: POSTs a new Xero invoice draft (Idempotency-Key: mirror:invoice:<id>:v1 — Xero deduplicates retried POSTs). Writes a xero_mirror_log row. Emits xero.invoice_mirrored on success or xero.mirror_failed on failure. With force=true: soft-deletes the prior synced log row (audit_log entry) then re-fires."},{"name":"manual_resync_payable_to_xero","description":"Manually mirrors a biloh contractor payable to Xero as a Bill draft. Recovery path for payables that failed to mirror automatically.\n\nUSE WHEN: A payable shows status='failed' in list_xero_mirror_log. Manual resync after fixing the contractor's name/ABN in Xero. The original event never reached the handler.\n\nDON'T USE WHEN: The payable has not been created in biloh yet — let payable.created fire naturally.\n\nPRECONDITIONS: payable_id exists in this tenant. integrations.xero.enabled=true. Active xero_connections row.\n\nSIDE EFFECTS: POSTs a Xero Bill draft (Idempotency-Key: mirror:payable:<id>:v1). Writes xero_mirror_log. Emits xero.payable_mirrored or xero.mirror_failed. With force=true: soft-deletes the prior synced log row."},{"name":"disconnect_xero","description":"Disconnects the tenant's Xero integration. Idempotent — calling on an already-disconnected tenant returns already_disconnected:true. Best-effort Xero-side revoke; local connection row is always flipped to 'disconnected'.\n\nUSE WHEN: The operator wants to revoke biloh's access to their Xero org. Switching to a different Xero org (disconnect, then re-authorize via get_xero_authorize_url). End-of-trial cleanup.\n\nDON'T USE WHEN: You want to PAUSE the mirror without revoking — use update_setting on integrations.xero.enabled=false instead (disable preserves the connection for later resume).\n\nPRECONDITIONS: None. Idempotent.\n\nSIDE EFFECTS: Calls DELETE /connections/{xero_tenant_id} on Xero (best-effort; failure logged but doesn't block). Updates xero_connections.status='disconnected' for the active row. Writes audit_log row. Emits integration.configured event with action='disconnected'. Does NOT delete any previously-mirrored Xero drafts (operator's call inside Xero)."},{"name":"get_stripe_connection_status","description":"Returns the Stripe Connect Express state for the caller's tenant: whether the integration is enabled (tenant setting), whether an active connection exists, and the capability flags from Stripe (charges_enabled, payouts_enabled, details_submitted).\n\nUSE WHEN: Diagnosing why a Pay Now link cannot be generated. Verifying the connection before triggering generate_pay_now_link. Surfacing connector health on a settings page. Health-check at the start of an operator session.\n\nDON'T USE WHEN: You want to inspect individual webhook events — use list_stripe_webhook_events. You want to enable / disable the integration — use update_setting on `integrations.stripe.enabled`. You want to begin onboarding — use start_stripe_connect_onboarding.\n\nPRECONDITIONS: None. Returns a fully-formed status object even when not connected.\n\nSIDE EFFECTS: None — read-only. (Makes one outbound, timeout-bounded, read-only Stripe Accounts.retrieve to live-verify the connection; mutates nothing.)\n\nOUTPUT SHAPE:\n{\n  integration_enabled: boolean,\n  platform_fee_bps: number,\n  connected: boolean,\n  status: \"pending\" | \"active\" | \"disconnected\" | \"restricted\" | \"error\" | \"unknown\" | null,\n  stripe_account_id?: string,\n  account_type?: string,\n  charges_enabled?: boolean,\n  payouts_enabled?: boolean,\n  details_submitted?: boolean,\n  last_error?: string,\n  onboarding_url?: string,\n  onboarding_url_expires_at?: string,\n  connected_at?: string,\n  last_refreshed_at?: string,\n  is_stale: boolean | null,                  // stored flags older than 24h, or never refreshed\n  last_refreshed_age_seconds: number | null, // age of last_refreshed_at in seconds\n  live_verified: boolean | null,             // true = a live Stripe read just confirmed access\n  live_probe: { ok: boolean, code?: string, error?: string, checked_at: string } | null\n}\n\nLIVE VERIFICATION (bug fa7bb6dd): when a live connection exists this tool makes one cheap, timeout-bounded Stripe Accounts.retrieve against the connected account. The probe may only DOWNGRADE a reported \"active\" — a 403 / account_invalid (revoked or rotated key, account.application.deauthorized) flips status to \"error\" with last_error set; a transient / network failure flips status to \"unknown\" — never a false green. On success live_verified=true. So a green status here is live-checked, not a stale echo. If Stripe is not configured the probe is skipped (live_verified=false, live_probe.error=\"client_not_configured\") and the stored flags + is_stale are returned unchanged."},{"name":"list_stripe_webhook_events","description":"Lists Stripe webhook events for THIS tenant's CONNECTED (Till-2 / Connect) Stripe account only, sorted newest-first — e.g. payment_intent.succeeded / checkout.session.completed for the tenant's own client-invoice payments. Filter by status, event_type, or date range.\n\nSCOPE: This tool surfaces the tenant's connected-account (Till-2 / Connect) Stripe events ONLY. It does NOT surface Till-1 platform-billing webhooks (Biloh charging the tenant for its own subscription) — those are processed on Biloh's PLATFORM Stripe account from session metadata and are not tenant-observable here. To verify a platform-subscription activation, use get_my_subscription (plan_code / status), NOT this tool.\n\nUSE WHEN: Investigating why a payment didn't transition the invoice (look for 'skipped' rows with outcomes like 'missing_metadata_or_amount'). Confirming Stripe is delivering events at all. Surfacing recent failures.\n\nDON'T USE WHEN: You want connection state — use get_stripe_connection_status. You want to retry a payment record — Stripe-side resend or operator manual recording via approve_record_payment. You want to confirm a Till-1 platform-subscription / plan checkout completed — use get_my_subscription, not this tool.\n\nPRECONDITIONS: None. Returns empty array when no rows match (an empty result for checkout.session.completed is EXPECTED — platform-billing events live on the Till-1 platform account, not this connected-account log).\n\nSIDE EFFECTS: None — read-only.\n\nINPUT:\n{\n  status?: \"received\" | \"processed\" | \"skipped\" | \"failed\",\n  event_type?: string,\n  since?: ISO date,\n  limit?: number (default 50, max 200)\n}\n\nOUTPUT: { rows: Array<{...}> } each row carrying id, stripe_event_id, stripe_event_type, status, outcome, source_entity_type, source_entity_id, last_error, http_status, created_at, processed_at."},{"name":"start_stripe_connect_onboarding","description":"Returns a Stripe Connect OAuth URL the operator opens in a browser to connect their existing Stripe account. Uses Standard accounts via OAuth — the tenant connects their own Stripe account (with their own bank details, KYC, and dashboard).\n\nUSE WHEN: Tenant wants to enable Pay Now / Stripe payments and hasn't connected yet. Tenant needs to re-connect after a disconnect.\n\nDON'T USE WHEN: Tenant is already fully connected (status='active', charges_enabled=true) — returns already_connected=true with a dashboard link. Integration is disabled — enable via update_setting first.\n\nPRECONDITIONS: STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET + STRIPE_CONNECT_REDIRECT_URI + STRIPE_CONNECT_CLIENT_ID env vars set on the deploy. Returns not_configured otherwise.\n\nSIDE EFFECTS: None — only generates a URL. The connection is created when the operator completes the OAuth flow in their browser.\n\nOUTPUT: { onboarding_url (OAuth URL), expires_at, stripe_account_id, already_connected, connection_id }\n\nIMPORTANT: The returned onboarding_url must be opened in a browser by the operator. Do NOT attempt to complete the OAuth flow via MCP — it requires browser-based authentication with Stripe."},{"name":"generate_pay_now_link","description":"Creates a Stripe Checkout Session for an unpaid invoice on the tenant's connected Stripe Express account. Returns the Stripe-hosted URL the customer opens to pay by card. Payment is recorded automatically via the Stripe webhook on payment_intent.succeeded.\n\nUSE WHEN: Operator wants to email a \"Pay Now\" link to a customer for an unpaid invoice. Building the client portal Finance tab (FE-13 consumes this same gateway). Sending a payment reminder with a click-to-pay link.\n\nDON'T USE WHEN: The invoice is still a draft (returns invoice_not_sent — send it first via propose_send_invoice). The invoice is already paid or voided (returns invoice_not_payable). The tenant hasn't completed Stripe Connect onboarding (returns not_connected — run start_stripe_connect_onboarding first). The integration is disabled (returns integration_disabled).\n\nPRECONDITIONS: integrations.stripe.enabled=true on the tenant. stripe_connections row in status='active' with charges_enabled=true. Invoice exists and is in a payable state (sent / partially_paid / overdue). Draft is NOT payable — a draft invoice is rejected with code:invoice_not_sent; paid/voided invoices are rejected with code:invoice_not_payable.\n\nSIDE EFFECTS: Creates a Checkout Session on Stripe (no money moves yet). Emits payment.checkout_link_generated for audit. The session URL expires per Stripe's default (~24 hours).\n\nINPUT: { invoice_id: UUID, success_url?: string, cancel_url?: string }\n\nOUTPUT: { ok: true, url, session_id, expires_at, stripe_account_id } OR { ok: false, code, detail }"},{"name":"disconnect_stripe","description":"Disconnects the tenant from Stripe Connect. Marks the local connection as disconnected; does NOT delete the Stripe Express account on Stripe's side (tenant retains ownership and can re-connect at any time).\n\nUSE WHEN: Tenant requests to stop accepting Stripe payments. Switching to a different Stripe account (disconnect then start_stripe_connect_onboarding to mint a new Express account). Cleaning up after a test connection.\n\nDON'T USE WHEN: You just want to temporarily disable Pay Now links — use update_setting on `integrations.stripe.enabled` instead (preserves the connection).\n\nPRECONDITIONS: None. Returns already_disconnected:true if no live connection.\n\nSIDE EFFECTS: Flips stripe_connections.status to 'disconnected'. Emits integration.configured with action='disconnected'.\n\nOUTPUT: { ok: true, disconnected: true, stripe_account_id } OR { ok: true, already_disconnected: true }"},{"name":"get_client_portal_finance_view","description":"Returns the four-panel client portal Finance tab data for the given client: outstanding cents, past invoices (with payable flag), payment history, credit balance + recent entries, and whether the credit panel should display per the per-tenant tunability setting. Read-only — does not persist anything.\n\nUSE WHEN: An operator wants to preview exactly what a specific client sees on their portal Finance tab. Debugging \"the client says they can't see invoice X\" or \"why does the credit show $0 when I just recorded one.\" Building a dashboard widget that mirrors what the client sees.\n\nDON'T USE WHEN: You want the full aggregator with opening/closing balances and per-PO breakdown — use get_client_statement (FE-07). You want every event the client touched ever — use get_client_audit_timeline (FE-15). You want to send a statement — use propose_send_statement (FE-07). NOTE on outstanding_cents: it is the LIVE balance owed right now (unpaid sent/overdue invoices, excluding credits/overpayments) and can legitimately differ from get_client_statement.outstandingCents, which is the PERIOD CLOSING BALANCE (invoiced minus paid within the statement period); an overpayment or credit makes the live balance lower than the period closing balance.\n\nPRECONDITIONS: client_id references a client in the caller's tenant. The client may be in any status (active / inactive / archived) — the portal view doesn't gate on status.\n\nSIDE EFFECTS: None — pure read.\n\nINPUT: { client_id: UUID, include_test?: boolean }\n\nOUTPUT: { ok: true, client, outstanding_cents, invoices, payments, credit: { balance_cents, recent_entries }, show_credit_panel } OR { ok: false, code, message }"},{"name":"list_client_invoices","description":"Lists invoices for a specific client, ordered by issued_at descending. Optional status filter. Default page size 50, max 200. Money fields returned as integer cents. Read-only.\n\nUSE WHEN: Operator wants the same invoice list a client would see on their portal Finance tab. Auditing a specific client's invoice history. Building a per-client report.\n\nDON'T USE WHEN: You want all invoices across all clients — use list_invoices. You want an aggregated balance — use get_client_statement (FE-07). You want temporal history of one invoice — use get_invoice_full_history (FE-15).\n\nPRECONDITIONS: client_id references a client in the caller's tenant.\n\nSIDE EFFECTS: None — pure read.\n\nINPUT: { client_id: UUID, status?: 'draft' | 'sent' | 'partially_paid' | 'paid' | 'overdue' | 'voided', limit?: integer, include_test?: boolean }\n\nOUTPUT: { ok: true, invoices: [{ id, invoice_number, issued_at, due_date, total_cents, status, client_po_number, is_payable }] } OR { ok: false, code, message }"},{"name":"list_client_payments","description":"Lists payments recorded against a specific client's invoices, ordered by payment_date descending. Money fields returned as integer cents. Read-only.\n\nUSE WHEN: Operator wants the same payment history a client would see on their portal Finance tab. Reconciling what the client thinks they've paid against what's in the ledger.\n\nDON'T USE WHEN: You want all payments across all clients — use list_payments. You want refunds — use list_refunds. You want a full audit timeline — use get_client_audit_timeline (FE-15).\n\nPRECONDITIONS: client_id references a client in the caller's tenant.\n\nSIDE EFFECTS: None — pure read.\n\nINPUT: { client_id: UUID, limit?: integer, include_test?: boolean }\n\nOUTPUT: { ok: true, payments: [{ id, payment_date, amount_cents, payment_method }] } OR { ok: false, code, message }"},{"name":"request_statement_for_client","description":"Returns the on-demand statement aggregate for a client over a date range — the same data the client sees in their portal Finance tab when they pick a period. Includes opening balance, in-period invoices/payments/refunds, closing balance, outstanding, and per-PO breakdown. Money fields as integer cents. Read-only — does NOT persist a statement row.\n\nUSE WHEN: Operator wants to preview what a client would see for a custom period in the portal. Generating an ad-hoc balance summary without sending it.\n\nDON'T USE WHEN: You want to SEND a statement to the client — use propose_send_statement → confirm → approve_send_statement (FE-07 three-leg). You want the four-panel portal view — use get_client_portal_finance_view (this WP). You want a reconciliation view with anomaly flags — use get_period_reconciliation_view (FE-15).\n\nPRECONDITIONS: client_id references a client in the caller's tenant. period_start and period_end are ISO YYYY-MM-DD with period_end >= period_start.\n\nSIDE EFFECTS: None — pure read.\n\nINPUT: { client_id: UUID, period_start: 'YYYY-MM-DD', period_end: 'YYYY-MM-DD', include_test?: boolean }\n\nOUTPUT: { ok: true, client, period, openingBalanceCents, closingBalanceCents, outstandingCents, totalInvoicedCents, totalPaidCents, totalRefundedCents, inPeriod: { invoices, payments, refunds }, poBreakdown, lastPaymentDate, lastPaymentAmountCents } OR { ok: false, code, message }"},{"name":"set_client_invoicing_cadence","description":"Sets the invoicing cadence for a specific client. Controls what happens when a job for this client is completed.\n\nAvailable modes:\n• `manual` — Do nothing automatically when a job is completed — I'll invoice manually.\n• `per_job_on_completion` — Send an invoice for that job when it's marked complete.\n• `period_final_consolidated` — Wait and send ONE invoice when the month's last scheduled job is completed.\n• `monthly_batch` — Collate everything and send at the end of the month — coming soon, not yet available.\n\nUSE WHEN: Setting up a new client's billing preferences, or changing how an existing client is invoiced.\n\nDON'T USE WHEN: Changing the tenant-wide default cadence — use update_setting with key 'finance.invoicing.cadence_default'.\n\nPRECONDITIONS: client_id references an existing client in the caller's tenant. mode is one of the three selectable values.\n\nSIDE EFFECTS: Writes the client-scoped setting via the settings service (audit-logged, event-emitting). Identical to editing via the UI."},{"name":"get_late_fee_config_for_client","description":"Returns the resolved late-fee config for a client — mode (none|interest|flat_fee), flat fee amount (cents), interest rate (bps), grace period days, recurrence (once|monthly), and the source of the mode (tenant_default vs client_override).\n\nUSE WHEN: auditing why a late fee was/wasn't applied for a client; previewing what the daily sweep will do; confirming a client's per-client mode_override before changing it.\n\nDON'T USE WHEN: you want to APPLY a late fee — use apply_late_fees_for_invoice. You want to change the config — use update_setting on the relevant key.\n\nPRECONDITIONS: client_id is a valid UUID of a client in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"preview_late_fees_for_invoice","description":"Dry-runs the late-fee application for an invoice and returns what WOULD happen — same response shape as apply_late_fees_for_invoice but with zero side effects. Useful for previewing the daily-sweep behaviour or confirming a fee amount before pulling the trigger.\n\nUSE WHEN: previewing the daily sweep's outcome for a specific invoice; calculating the next late-fee amount for an operator briefing; confirming idempotency state without mutating.\n\nDON'T USE WHEN: you want to actually apply the fee — use apply_late_fees_for_invoice (idempotent). You want the config without the per-invoice math — use get_late_fee_config_for_client.\n\nPRECONDITIONS: invoice_id is a valid UUID; as_of_date is ISO YYYY-MM-DD (operator-supplied per Principle 3).\n\nSIDE EFFECTS: NONE."},{"name":"apply_late_fees_for_invoice","description":"Applies the owed late fee to an overdue invoice per the resolved config (use get_late_fee_config_for_client to inspect). Creates a NEW draft invoice anchored to the original via late_fee_for_invoice_id. Idempotent per (invoice_id, period_number) — a second call returns already_applied_this_period.\n\nUSE WHEN: operator-driven catch-up or correction (the daily 23:30 UTC cron handles the automatic path); debugging the sweep; applying a fee for a specific as_of_date (e.g., back-dated for an audit).\n\nDON'T USE WHEN: the client should be exempt — set per-client `finance.late_fee.mode_override = 'none'` instead. The original invoice isn't overdue yet — preview first via preview_late_fees_for_invoice.\n\nPRECONDITIONS: invoice_id is a valid UUID of an OVERDUE invoice (status='overdue', is_late_fee_invoice=false); as_of_date is ISO YYYY-MM-DD operator-supplied (Principle 3 — no implicit today).\n\nSIDE EFFECTS: Creates ONE new invoice (draft status); emits invoice.created + invoice.late_fee_applied events; appends two audit_log rows (one for the create, one for the late-fee-applied). The new invoice is NOT auto-sent — operator must send via the standard three-leg flow."},{"name":"list_late_fee_invoices","description":"Returns every late-fee invoice anchored to a given original invoice, ordered by period_number ASC. Empty list = no late fees have been applied yet to that invoice.\n\nUSE WHEN: auditing late-fee history on a specific original invoice; investigating why a client is seeing accumulated fees; building a per-invoice late-fee timeline.\n\nDON'T USE WHEN: looking for the original invoice itself — use get_invoice. Looking for ALL late-fee invoices across all originals in a period — use list_invoices with a filter on is_late_fee_invoice.\n\nPRECONDITIONS: original_invoice_id is a valid UUID of an invoice in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_payment","description":"Records a payment received against an invoice. `amount_cents` is INTEGER CENTS. Payment does not auto-update invoice status — that must be done separately if the invoice is fully paid. `payment_date` is a real-world financial event date and must be explicit (no NOW() default per Finance Engine Principle 3).\n\nUSE WHEN: Recording that money has been received for an invoice (bank transfer landed, card processed, cash receipted).\n\nDON'T USE WHEN: Sending an invoice — use propose_send_invoice. Recording payment against a draft invoice — send the invoice first. Refunding — use the refund flow (separate WP).\n\nPRECONDITIONS: `invoice_id` references an invoice in the caller's tenant that is in a payment-eligible state (`sent`, `partially_paid`, or `overdue`). `amount_cents` is a positive integer. `payment_date` is an explicit ISO date (not the current date by default).\n\nSIDE EFFECTS: Inserts one row into `payments`. Writes audit_log row (action `create`, entity_type `payment`). Does NOT auto-flip the invoice to `paid` — call update_job / the dedicated reconciliation flow."},{"name":"cancel_pending_operation","description":"Cancels a pending operation in `pending` or `staged` status. After approve_send_operation has fired, the operation is `approved_and_sent` and cannot be cancelled — that's a separate withdrawal/voiding flow.\n\nWhen cancelling a staged send_proposal operation, the underlying proposal's status is automatically reverted from `staged_for_send` back to `draft`, and a stale PDF URL is cleared. When cancelling a staged send_contractor_agreement operation, the underlying agreement's status is reverted from `staged_for_send` back to `draft`, and signing_token / signing_token_expires_at are cleared.\n\nResponse fields include `operation_type`, `operation_final_status` (`cancelled` on success), `cancel_commit_succeeded`, and — only when the operation is `send_proposal` — `proposal_reverted`, `proposal_pdf_invalidated`, `proposal_status_before/after`. Only when the operation is `send_contractor_agreement`: `agreement_reverted`, `agreement_status_before/after`.\n\nUSE WHEN: The human has decided not to proceed with a proposed action.\n\nDON'T USE WHEN: The operation has already been approved/sent — it's too late to cancel; use a withdrawal flow. The operation does not exist or already cancelled.\n\nPRECONDITIONS: `operation_id` is an `mcp_pending_operations` row in the caller's tenant in `pending` or `staged_for_send` status.\n\nSIDE EFFECTS: Marks the operation as cancelled (internal storage value `rejected`). For staged send_proposal: reverts the proposal to `draft`, clears stale PDF URL. For staged send_contractor_agreement: reverts the agreement to `draft`, clears signing_token and signing_token_expires_at. Writes audit_log row with the optional `reason`. No emails sent. No domain events emitted by this action."},{"name":"list_proposals","description":"Lists proposals for the tenant, with optional filters. Returns proposal header info including status, client, site, and dates. Excludes soft-deleted rows by default.\n\nUSE WHEN: Viewing all proposals, filtering by client/status, or surfacing a proposal picker.\n\nDON'T USE WHEN: You already have the proposal ID — use get_proposal. You want full lines and acceptances — use get_proposal (single call returns them).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_proposal","description":"Gets a single proposal with full detail: header, all lines (with service/frequency names), and acceptance records.\n\nUSE WHEN: Viewing complete proposal detail, reviewing before sending, checking acceptance status.\n\nDON'T USE WHEN: Just listing proposals — use list_proposals. You only want the acceptance audit fields — use list_proposal_acceptances.\n\nPRECONDITIONS: `proposal_id` is a valid UUID of an existing proposal in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"preview_proposal_investment","description":"Preview the HONEST, frequency-aware investment summary for a proposal BEFORE sending — the SAME figures the operator view, the signed-agreement PDF, and the client accept page render (one shared calculator, so the preview matches the document, no drift).\n\nReturns: the annual total (the unambiguous anchor, CPI-scoped), a clearly-labelled NON-authoritative monthly-equivalent, per-visit billing facts, a representative 12-month schedule, and any visits/year divergence flags. Use it to confirm a non-monthly / lumpy-cadence proposal (e.g. an 8-weekly lawn, or a front-loaded program) won't read as an inflated or fictitious monthly bill.\n\nUSE WHEN: reviewing a proposal's pricing before propose_send_proposal, or after setting a line's visits_per_year override / frequency, to check it levelises honestly.\n\nDON'T USE WHEN: you want the raw lines — use get_proposal. You want to CHANGE a line's cadence/price — use update_proposal_line (then re-preview).\n\nPRECONDITIONS: proposal_id is a valid, non-archived proposal in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_proposal_acceptances","description":"Lists proposal acceptance records for audit/history. Returns core fields by default; set `include_snapshot=true` to include the full proposal_snapshot JSONB (large). Audit fields include ip_address, user_agent, content_hash, pdf_url, pdf_generated_at, confirmation_email_sent_at, and retention_class.\n\nUSE WHEN: Reviewing acceptance history, auditing who accepted what and when, verifying post-accept side-effects fired.\n\nDON'T USE WHEN: Checking a single proposal's status — use get_proposal which includes acceptances. You want generic audit log — use get_audit_log.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_proposal","description":"Creates a new proposal in `draft` status for a client at a specific site. After creation, add lines with add_proposal_line. Auto-assigns the next proposal_number for the tenant.\n\nUSE WHEN: Starting a new proposal for a client. Capturing the proposal header before pricing-line decisions.\n\nDON'T USE WHEN: A live proposal already exists for this opportunity — use get_proposal / list_proposals to check first. Mutating an existing draft — use update_proposal. Sending — use propose_send_proposal after lines are added.\n\nPRECONDITIONS: `client_id` and `site_id` reference existing rows in the caller's tenant. The site belongs to the client.\n\nSIDE EFFECTS: Inserts one row into `proposals` (status `draft`, next proposal_number assigned). Writes audit_log row (action `create`, entity_type `proposal`). NO lines created — those land via add_proposal_line."},{"name":"update_proposal","description":"Updates the header fields (intro message / notes, valid_until date) on a draft proposal. Only allowed when proposal status is `draft` — same constraint as update_proposal_line. For line item changes use update_proposal_line. For status changes use propose_send_proposal or cancel_pending_operation.\n\nUSE WHEN: Refining cover note, intro message, or expiry date on a draft proposal before sending.\n\nDON'T USE WHEN: Proposal is `staged_for_send` / `sent` / `accepted` — revert via cancel_pending_operation first. Editing line items — use update_proposal_line.\n\nPRECONDITIONS: `proposal_id` references a `draft` proposal in the caller's tenant.\n\nSIDE EFFECTS: Updates the matching `proposals` row. Writes audit_log row (action `update`, entity_type `proposal`) including the diff. NO line mutations."},{"name":"add_proposal_line","description":"Adds a line item to a draft proposal. Each line links a service (and optionally a frequency) with pricing in integer cents. Only allowed when proposal status is `draft`.\n\nUSE WHEN: Building up a proposal with service line items after create_proposal.\n\nDON'T USE WHEN: Proposal is already sent — cannot modify sent proposals (re-issue a new proposal). Editing an existing line — use update_proposal_line.\n\nPRECONDITIONS: `proposal_id` references a `draft` proposal in the caller's tenant. `service_id` references an existing service. `unit_price_ex_gst_cents` is a non-negative integer (cents).\n\nSIDE EFFECTS: Inserts one row into `proposal_lines`. Writes audit_log row (action `create`, entity_type `proposal_line`). Money stored as integer cents."},{"name":"update_proposal_line","description":"Updates a proposal line's price, description, quantity, or display order. Only allowed when the parent proposal is in `draft` status.\n\nUSE WHEN: Adjusting pricing or descriptions on a draft proposal before sending.\n\nDON'T USE WHEN: Proposal is sent/accepted — create a new proposal instead (sent proposals are immutable). Removing a line — use remove_proposal_line.\n\nPRECONDITIONS: `line_id` references a line on a `draft` proposal in the caller's tenant. New `unit_price_ex_gst_cents` (if supplied) is a non-negative integer (cents).\n\nSIDE EFFECTS: Updates the matching `proposal_lines` row. Writes audit_log row (action `update`, entity_type `proposal_line`) including the diff."},{"name":"remove_proposal_line","description":"Soft-deletes a line from a draft proposal (sets `deleted_at` on the line row). Only allowed when proposal status is `draft`.\n\nUSE WHEN: Removing a service line from a draft proposal before sending.\n\nDON'T USE WHEN: Proposal is sent/accepted — lines are immutable after send. Updating a line in-place — use update_proposal_line.\n\nPRECONDITIONS: `line_id` references a line on a `draft` proposal in the caller's tenant.\n\nSIDE EFFECTS: Sets `proposal_lines.deleted_at = NOW()`. Writes audit_log row (action `delete`, entity_type `proposal_line`)."},{"name":"propose_resend_proposal","description":"Stages a re-delivery of an already-sent proposal to the original or a corrected recipient. The proposal must be in `sent` status and have a non-expired signing_token. Returns a pending_operation_id and preview; the email fires on `confirm_pending_operation` (no separate approve step — the proposal was operator-approved at original send). Does NOT mutate the proposal (no status change, no new signing_token, no version increment) — the recipient sees the same signing URL as the original send.\n\nThe full timeline of sends/resends is captured in audit_log via `proposal_sent` + `proposal_resent` entries; query with `get_audit_log_for_entity(proposal, <id>)` to see the send history including send_attempt_number.\n\nUSE WHEN: A previously sent proposal needs to be re-delivered (recipient didn't receive it, wrong email originally, mail route change for deliverability).\n\nDON'T USE WHEN: Proposal is still in `draft` — use propose_send_proposal. Proposal is accepted/declined/voided/expired — resend has no meaning on a closed proposal. Signing token has expired — issue a new proposal version instead.\n\nPRECONDITIONS: `proposal_id` references a proposal in the caller's tenant in `sent` status with a non-expired `signing_token`. Tenant `mail_provider` is configured.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `proposed`, operation_type `resend_proposal`). NO email sent at this step. Writes audit_log row recording the stage. On `confirm_pending_operation`: dispatches email immediately, writes `proposal_resend` audit row with send_attempt_number."},{"name":"record_proposal_acceptance","description":"Records that a proposal has been accepted. Creates a `proposal_acceptances` record (immutable legal evidence) and updates the proposal status to `accepted`. For email-reply acceptance, captures the literal acceptance text as legal evidence.\n\nUSE WHEN: A client has accepted a proposal via a channel other than the portal (email reply, in person, paper, manual back-fill).\n\nDON'T USE WHEN: Proposal is not in `sent` status — must be sent first. The client clicked Accept in the portal — that flow already writes the acceptance row; do not double-record. Recording a decline — separate flow.\n\nPRECONDITIONS: `proposal_id` references a proposal in `sent` status in the caller's tenant. `accepted_by_name` is supplied. If `method='email_reply'`, `acceptance_quote` is supplied (literal text from the email).\n\nSIDE EFFECTS: Inserts immutable row into `proposal_acceptances`. Updates `proposals.status='accepted'`. Emits domain event `proposal.accepted`. Writes audit_log row. Downstream cascade (job spawning from accepted proposal) is event-driven."},{"name":"archive_proposal","description":"Soft-deletes a proposal (sets `deleted_at`). Accepted proposals are IMMUTABLE and cannot be archived — they are legal records. Automatically cascade-cancels any associated pending/staged operations.\n\nUSE WHEN: Cleaning up draft or declined proposals.\n\nDON'T USE WHEN: Proposal is `accepted` — accepted proposals must be preserved for legal compliance. Forcibly archiving an accepted [TEST] proposal — use force_archive_test_proposal (architect-only).\n\nPRECONDITIONS: `proposal_id` references a non-accepted, non-archived proposal in the caller's tenant.\n\nSIDE EFFECTS: Sets `proposals.deleted_at = NOW()`. Cascade-cancels any `pending` / `staged_for_send` operations attached to the proposal. Writes audit_log row (action `archive`, entity_type `proposal`) plus per-operation cancel rows."},{"name":"bulk_archive_proposals","description":"Soft-deletes multiple proposals in a single call. Accepted proposals are IMMUTABLE and are skipped (not archived). Cascade-cancels any associated pending/staged operations for archived proposals.\n\nMax 50 proposal IDs per call. Use `dry_run: true` first for any large batch to preview what would be archived. After a real run, verify writes by calling get_audit_log with `entity_type='proposal'` and a recent time window — do not rely solely on the success response for any bulk-write operation.\n\nUSE WHEN: Cleaning up test debris or batch-archiving drafts.\n\nDON'T USE WHEN: Archiving accepted proposals — use force_archive_test_proposal (architect-only) for test data cleanup of accepted records. Archiving a single proposal — use archive_proposal.\n\nPRECONDITIONS: `proposal_ids` is a 1–50 element array of UUIDs in the caller's tenant. `reason` is non-empty.\n\nSIDE EFFECTS: For each non-accepted proposal: sets `proposals.deleted_at = NOW()`, cascade-cancels its pending/staged ops. Writes a summary audit_log row (action `bulk_archive`, entity_type `proposal`) with archived_ids + skipped_ids. `dry_run: true` performs NO writes."},{"name":"force_archive_test_proposal","description":"Force-archives a proposal that would otherwise be blocked by the legal-immutability protection on `accepted` status. ARCHITECT-ONLY. Only use to clean up TEST/DOGFOOD proposals that completed the lifecycle. Production accepted proposals are immutable legal records and must not be archived.\n\nRequires `is_test_data: true`, and either a `[TEST]` prefix in proposal notes OR explicit reason text containing 'test', 'dogfood', or 'qa'. Audit-logs the override. Cascade-cancels any associated live operations.\n\nUSE WHEN: Cleaning up accepted test/dogfood proposals after end-to-end verification.\n\nDON'T USE WHEN: Archiving production accepted proposals — those are legal records and this tool refuses. Archiving non-accepted proposals — use archive_proposal (no override required).\n\nPRECONDITIONS: Architect persona. `proposal_id` references a proposal in the caller's tenant. `is_test_data: true`. Proposal notes contain `[TEST]` OR `reason` contains test/dogfood/qa.\n\nSIDE EFFECTS: Sets `proposals.deleted_at = NOW()` even on `accepted` status. Cascade-cancels live operations. Writes audit_log row (action `force_archive`, entity_type `proposal`) including is_test_data flag and prior_status — emphasises the override in the audit trail."},{"name":"clone_proposal_with_amendments","description":"Supersedes a sent proposal with an amended copy in one atomic call. Archives the source, creates a new draft with the same client/site, copies all lines (applying optional price/quantity/description/frequency amendments), and links source → new via `superseded_by`.\n\nUSE WHEN: Client requests pricing changes to a sent proposal. You need to issue a revised proposal without losing the audit trail of the original.\n\nDON'T USE WHEN: Proposal is still in draft — just edit it directly with update_proposal / update_proposal_line. Proposal is accepted — accepted proposals are immutable legal records.\n\nPRECONDITIONS: Source proposal must be in 'sent' status. All line_amendment proposal_line_ids must exist on the source.\n\nSIDE EFFECTS: (1) Archives source (sets deleted_at). (2) Creates new draft proposal with next proposal_number. (3) Copies all lines with amendments applied. (4) Sets source.superseded_by → new proposal id. (5) Emits proposal.archived + proposal.cloned_with_amendments events. All-or-nothing: if any step fails, ALL prior steps are rolled back.\n\nCOMPOSES: archive_proposal, create_proposal, add_proposal_line (all retained as granular tools)."},{"name":"approve_send_operation","description":"Approves a staged send operation and immediately sends the email to the recipient with a portal-link CTA. Generic dispatcher — branches on `pending_operation.operation_type` to the right artifact-specific send path (proposal / invoice / contractor_agreement / work_order). The recipient lands in the portal, reviews, and signs / accepts / pays via the configured method.\n\n⚠️ HUMAN APPROVAL REQUIRED. This tool fires the send immediately — there is no second confirmation. Only call with `confirmed: true` after the operator has explicitly approved the send. The tool does not verify operator approval — that responsibility is on the caller.\n\nUSE WHEN: The operator has explicitly approved the send (reviewed via get_pending_operation and said \"yes, send it\"). Final leg of the propose → confirm → approve chain for any send operation.\n\nDON'T USE WHEN: The operator has not approved. The operation is not in `staged` status. Cancelling — use cancel_pending_operation. For send_statement or send_credit_note, use the dedicated approve tools (approve_send_statement / approve_send_credit_note) as they require PDF rendering or ATO validation not available in this generic dispatcher.\n\nPRECONDITIONS: `operation_id` references an `mcp_pending_operations` row in `staged` status in the caller's tenant. `confirmed: true`. Tenant `mail_provider` is configured.\n\nSIDE EFFECTS: Sends email via tenant `mail_provider`. Transitions the underlying entity to its sent state (proposal `sent`, invoice `sent`, contractor_agreement `sent`, work_order `sent`). Emits the corresponding domain event (`proposal.sent`, `invoice.sent`, `contractor_agreement.sent`, `work_order.sent`). Marks the pending operation `approved_and_sent`. Writes audit_log rows for both the operation approval and the entity state change."},{"name":"get_pending_operation","description":"Read-only inspection of a staged operation. Returns the operation's current state, the email subject/body that will be sent, the attachment path, the signing_token, and the related entity details. The canonical \"preview before approve\" tool.\n\nUSE WHEN: Showing the operator what is queued before they approve the send via approve_send_operation. Inspecting a single staged operation in detail.\n\nDON'T USE WHEN: Browsing the queue — use list_pending_operations. Approving the send — use approve_send_operation. Cancelling — use cancel_pending_operation.\n\nPRECONDITIONS: `operation_id` references an existing `mcp_pending_operations` row in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_pending_operations","description":"List staged or in-flight operations for the current tenant. Default returns the most recent 20 staged operations. Filter by status (`staged`, `approved_and_sent`, `cancelled`) or entity_type.\n\nUSE WHEN: Finding operations that need approval, reviewing recent send activity, or building an operator approval queue.\n\nDON'T USE WHEN: You already have the operation ID — use get_pending_operation for the full single-row view. You want to actually approve — use approve_send_operation.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_expired_pending_operations","description":"List pending operations that lapsed past their TTL without being confirmed or approved — i.e. staged sends that silently died. Returns swept rows (status='expired') plus pending rows already past expires_at, newest first, with how long ago each expired. Default window: last 7 days.\n\nUSE WHEN: The operator asks why a send never went out, after a `pending_operation.expired` notification, or as a periodic chat-driven cleanup check before re-proposing lapsed sends.\n\nDON'T USE WHEN: Reviewing live operations awaiting approval — use list_pending_operations (status='staged'). You want to act on one — re-propose via the relevant propose_send_* tool (expired operations cannot be revived).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_email_delivery_status","description":"Returns the email delivery status for an outbound communication: pending/sent/delivered/bounced/soft_bounced/complained, the chronological Resend webhook event timeline, and (when the entity is a supported FE-09 type) the originating row's email_send_status flags. Pass EITHER message_id (Resend provider id) OR (entity_type, entity_id).\n\nUSE WHEN: You're investigating whether a specific invoice/statement/refund/payment-receipt email actually reached the recipient. Surfaces the most recent webhook event (delivered/bounced/etc.) and any bounce reason captured from Resend.\n\nDON'T USE WHEN: You want a list of recent bounces across the tenant — use list_recent_bounces. You want the email body that was sent — read communication_log directly.\n\nPRECONDITIONS: One of (message_id) or (entity_type AND entity_id). When entity_type is supplied it must be one of invoice/payment/refund/statement; other entity types return their email_deliveries audit timeline without the originating-row flags.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_recent_bounces","description":"Returns recent bounce and (optionally) complaint events for the tenant — one row per outbound email that failed. Includes recipient, bounce reason from Resend's payload, originating entity (invoice/statement/refund/payment_receipt), and the provider message id. Default time window: last 24 hours.\n\nUSE WHEN: You're triaging email-delivery health — \"what bounced today?\" — or building an operator dashboard surface for bounce queue. The canonical operator-visibility tool for FE-09.\n\nDON'T USE WHEN: You want the full delivery status of a single entity — use get_email_delivery_status. You want delivery success metrics (deliveries-per-day) — use a future analytics tool (not in scope yet).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_audit_log","description":"Returns recent audit log entries for the tenant. Supports filtering by actor, entity, action, and date range (time-window via `from_date` / `to_date` ISO params). The canonical audit timeline across the whole tenant.\n\nUSE WHEN: Browsing general history, narrowing to a specific time window, or auditing recent activity for a verification report.\n\nDON'T USE WHEN: Investigating a single entity's full timeline — prefer get_audit_log_for_entity (more focused). You want only invoice state transitions — use list_invoice_state_changes (filtered + finance-typed).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_audit_log_for_entity","description":"Returns all audit log entries for a specific entity in chronological order. The complete timeline for one entity — use for self-diagnosis of unexpected state. Pass include_domain_events:true to ALSO return the domain_events emitted for the entity (e.g. job.rescheduled, job.spawned, work_order.accepted) as { event_type, occurred_at, payload_summary } — the per-entity event read that confirms a documented event fired for a non-finance entity.\n\nUSE WHEN: Diagnosing unexpected state — e.g., 'why is this proposal archived?', 'who created this entity?', 'what actions were taken on this operation?'. Verifying after a bulk write. Confirming a documented domain event was emitted for a specific job/scheduling entity (include_domain_events:true).\n\nDON'T USE WHEN: Browsing general audit history — use get_audit_log with filters. Reading invoice state transitions — use list_invoice_state_changes. Reading a finance entity's event timeline over a period — use get_client_audit_timeline.\n\nPRECONDITIONS: `entity_type` and `entity_id` reference a known entity type and a UUID in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"assign_job","description":"Assigns a contractor to a job. Runs the compliance gate (SMA signed, insurance current, capability matches service, availability). On a hard-gate failure the assignment is blocked and the reason is surfaced. Soft warnings are returned but do not block.\n\nUSE WHEN: Manually assigning an unassigned job, or reassigning a job from one contractor to another. The dispatcher and contractor-picker call this path.\n\nDON'T USE WHEN: Spawning recurring jobs — spawned jobs are always unassigned. Bulk-assigning N jobs to one contractor — use bulk_assign_jobs. You want a ranked picker — use suggest_contractors_for_job first.\n\nPRECONDITIONS: `job_id` references an existing job in the caller's tenant. `contractor_id` references an active contractor. Compliance gate passes (SMA, insurance, capability, availability).\n\nSIDE EFFECTS: Updates `jobs.contractor_id`. If the job was in status `declined`, resets `status` to `scheduled` and clears `decline_reason` so the job can be re-dispatched (bug 00a6543c). Emits domain event `job.assigned` (or `job.reassigned` if `previous_contractor_id` is non-null). Writes audit_log row including any soft warnings.\n\nNOTE: Assigning does NOT auto-generate or send a work order — `work_order_sent_at` stays null. Next step: call propose_send_work_order (then approve_send_work_order) to create and send the WO to the contractor."},{"name":"dispatch_job","description":"Transitions an assigned job from `scheduled` to `dispatched`. Sets `work_order_sent_at = NOW()`. Emits `job.dispatched`. Generic dispatch — for the email-bearing work-order send, use the propose_send_work_order / approve_send_work_order pair.\n\nUSE WHEN: Marking an assigned job as dispatched in the absence of an email-bearing WO flow (e.g. contractor was notified out-of-band). Notifications wired to `job.dispatched` event will fire.\n\nDON'T USE WHEN: The job is already dispatched, completed, or cancelled. You want to send a branded work-order email — use propose_send_work_order. The job has no contractor — use assign_job first.\n\nPRECONDITIONS: `job_id` references an existing job in `scheduled` status with `contractor_id NOT NULL` in the caller's tenant.\n\nSIDE EFFECTS: Updates `jobs.status='dispatched'` and `work_order_sent_at=NOW()`. Emits domain event `job.dispatched`. Writes audit_log row."},{"name":"approve_job","description":"Approves a completed (or partial) job — admin sign-off step that downstream invoicing consumes. Sets `status='approved'`, `approved_at=NOW()`, `approved_by=actor`. Emits `job.approved` with the full payload (contractorId, scheduledDate, clientId, siteId, cslId, approvalNotes, client_po_number per the PO cascade).\n\nUSE WHEN: An operator has reviewed the contractor's completion submission and is ready to release the job for invoicing. The job is in `completed` or `partial` status.\n\nDON'T USE WHEN: The job has not yet been completed — use record_completion first. The job is already approved/invoiced/paid. Reverting an approval — separate flow via update_job.\n\nPRECONDITIONS: `job_id` references a job in `completed` or `partial` status in the caller's tenant. Caller has architect or operator persona.\n\nSIDE EFFECTS: Updates the job (`status='approved'`, `approved_at`, `approved_by`). Emits domain event `job.approved` with the full payload including `client_po_number` (cascade per lock-in B4). Writes audit_log row including optional `approval_notes`.\n\nNOTE ON `completed_at` (tool upgrade d7677091; updated by bug 059d251a): approve_job does NOT set `completed_at`. Since fix 059d251a the contractor-portal completion route sets BOTH `completed_at` and `completion_submitted_at`; jobs portal-completed BEFORE that fix may still have null `completed_at`. Treat `completion_submitted_at` (contractor finished) + `approved_at` (operator sign-off) as the authoritative lifecycle timestamps. `completed_at` is populated by record_completion, backfill_completed_job, update_job status='completed', and (post-059d251a) the portal completion route. A null `completed_at` on an approved job from the pre-fix era is NOT a failed approval."},{"name":"reschedule_job","description":"Moves a single job to a new date and captures a reason. Validates the new date shape and that the job is not in a terminal status (completed/approved/invoiced/paid/cancelled). Sets `is_rescheduled=true` and increments the per-CSL reschedule counter when triggered from the client side. Emits `job.rescheduled`. When `schedule_change_request_id` is supplied, atomically closes that pending request as 'approved' and links it in the audit trail. Any OTHER pending schedule_change_requests on the job are auto-closed as 'superseded' (architect-ratified 2026-06-06) so the operator queue never accumulates stale rows.\n\nUSE WHEN: A specific job needs to move. Per ADR-0009 §B2, this mutates the job row in place — it does NOT mutate the CSL's RRULE.\n\nDON'T USE WHEN: A *recurring pattern* is changing — use regenerate_csl_schedule. Bulk moves — use bulk_reschedule_jobs. Formally approving a client/contractor request — prefer approve_schedule_change_request (resolves the requested date for you). Declining a request — use decline_schedule_change_request.\n\nPRECONDITIONS: `job_id` references a job in the caller's tenant not in a terminal status. `new_date` is YYYY-MM-DD. `reason` is non-empty. If supplied, `schedule_change_request_id` references a PENDING request in the caller's tenant.\n\nSIDE EFFECTS: Updates `jobs.scheduled_date` and `is_rescheduled=true`. Increments CSL reschedule counter if `origin=client`. Closes the linked request as 'approved' and other pending requests as 'superseded', emitting `schedule_change_request.actioned` per closed row. Emits domain event `job.rescheduled`. Writes audit_log row."},{"name":"cancel_job","description":"Cancels a job and captures a reason. Terminal status transition — cancelled jobs are not respawned by the engine. Emits `job.cancelled`.\n\nUSE WHEN: A specific job will not happen at all. Site closed permanently, client requested cancellation, weather event. The CSL's RRULE is unchanged; future jobs continue to spawn normally.\n\nDON'T USE WHEN: The job is merely moving — use reschedule_job. The CSL itself is being deactivated — use update_contract_service_line. Contractor declining an assigned job — use decline_job.\n\nPRECONDITIONS: `job_id` references an existing job in the caller's tenant not already in `cancelled` or `paid` status. `reason` is non-empty.\n\nSIDE EFFECTS: Updates `jobs.status='cancelled'` and stores `cancellation_reason`. Emits domain event `job.cancelled`. Writes audit_log row."},{"name":"decline_job","description":"Contractor declines an assigned job after acceptance. Captures `decline_reason` and clears `contractor_id` so the job returns to the unassigned queue. Status transitions to `declined`. Emits `job.declined`.\n\nUSE WHEN: A contractor formally declines an already-dispatched job (illness, schedule conflict, scope mismatch found at site).\n\nDON'T USE WHEN: The contractor cannot complete *today* but will return — that is a field-triage reschedule, use reschedule_job. The job was never assigned — admins use cancel_job instead. Permanently removing the job — use cancel_job.\n\nPRECONDITIONS: `job_id` references a job in the caller's tenant with an assigned contractor (status `scheduled` or `dispatched`). `reason` is non-empty.\n\nSIDE EFFECTS: Updates `jobs.status='declined'`, clears `contractor_id`, sets `jobs.previous_contractor_id` to the contractor who declined (returned in the response job row — use it in re-dispatch flows to avoid re-assigning the contractor who just declined, and for audit without a separate get_audit_log call), stores `decline_reason`. Emits domain event `job.declined`. Writes audit_log row. The job re-enters the unassigned queue for re-dispatch with status='declined' (NOT reverted to 'scheduled') — `list_unassigned_jobs` includes it, and `assign_job` / `suggest_contractors_for_job` accept declined jobs. Note: `dispatch_job` requires status='scheduled' and will refuse a job still in 'declined'."},{"name":"request_schedule_change","description":"Contractor-side request to reschedule a job. Gated by the `scheduling.contractor_portal.can_change_schedule` registry setting (no | request_only | yes), falling back to the legacy `tenants.scheduling_settings.contractor_can_change_schedule` JSONB only when the registry key is unset. Records a pending request; admin must approve. Never moves the job directly when the setting is `no` or `request_only`.\n\nUSE WHEN: A contractor wants to propose a new date for a job but the tenant does not grant them direct schedule-change rights.\n\nDON'T USE WHEN: `contractor_can_change_schedule = 'yes'` — in that mode the contractor can move the job directly via reschedule_job. Admin moving a job — use reschedule_job. Contractor cannot do the job at all — use decline_job.\n\nPRECONDITIONS: `job_id` references a job in the caller's tenant. `requested_date` is in the future. `reason` is non-empty. Tenant config allows contractor requests.\n\nSIDE EFFECTS: Inserts a pending schedule-change request linked to the job. Emits domain event `job.schedule_change_requested`. Writes audit_log row. Job itself NOT moved."},{"name":"list_schedule_change_requests","description":"Lists schedule change requests (client portal, contractor portal, field triage, request_schedule_change) with status, requested/effective dates, and linked job context. Default filter is status='pending' — the operator's working set of requests awaiting approve/decline. The returned schedule_change_request_id is the input to approve_schedule_change_request / decline_schedule_change_request.\n\nUSE WHEN: Triaging outstanding schedule change requests, finding the request id to approve or decline, or reviewing request history (status='all').\n\nDON'T USE WHEN: You want job-level audit history — use get_audit_log_for_entity. You want to move a job — use reschedule_job or approve_schedule_change_request.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"approve_schedule_change_request","description":"Operator approves a pending schedule change request: atomically moves the job to the requested date (or `override_date`), marks the request 'approved' with actioned_at/actioned_by, links the audit trail to the request id, and emits job.rescheduled + schedule_change_request.actioned. Accepts schedule_change_request_id, or job_id when exactly ONE pending request exists for the job (multiple pending requests fail closed with the candidate list). Approval counts as a client-initiated reschedule: it increments the CSL's client_reschedule_count and is blocked by max_reschedules_per_quarter (architect-ratified 2026-06-06) — use reschedule_job with origin=admin to move the job anyway.\n\nUSE WHEN: A client or contractor submitted a reschedule request (see list_schedule_change_requests) and the operator wants to formally action it.\n\nDON'T USE WHEN: Refusing the request — use decline_schedule_change_request. Moving a job with no pending request — use reschedule_job.\n\nPRECONDITIONS: The request is in status 'pending' and linked to a job in the caller's tenant. The request has a requested_date OR `override_date` is supplied (YYYY-MM-DD).\n\nSIDE EFFECTS: Updates jobs.scheduled_date (via the shared reschedule core). Increments client_reschedule_count. Updates the request row (status/effective_date/actioned_*). Emits job.rescheduled and schedule_change_request.actioned. Writes audit_log row linking job and request."},{"name":"decline_schedule_change_request","description":"Operator declines a pending schedule change request: marks it 'declined' with the reason in actioned_note, sets actioned_at/actioned_by, and emits schedule_change_request.actioned. The job is NOT touched. Accepts schedule_change_request_id, or job_id when exactly ONE pending request exists for the job (multiple pending requests fail closed with the candidate list).\n\nUSE WHEN: A client or contractor reschedule request should be refused (see list_schedule_change_requests for the queue).\n\nDON'T USE WHEN: Approving the request — use approve_schedule_change_request. Cancelling the job itself — use cancel_job.\n\nPRECONDITIONS: The request is in status 'pending' in the caller's tenant. `reason` is non-empty (the requester deserves to know why).\n\nSIDE EFFECTS: Updates the request row (status='declined', actioned_*). Emits schedule_change_request.actioned. Writes audit_log row. Job row untouched."},{"name":"list_unassigned_jobs","description":"Returns unassigned jobs needing dispatch (`status='scheduled'` OR `status='declined'`) with `contractor_id IS NULL` — the admin dispatcher's unassigned queue. Declined jobs re-enter this queue for re-dispatch (decline_job clears contractor_id). `decline_reason` is included so the dispatcher knows why a job is back. Filterable by date range. Sorted by scheduled_date ascending. Excludes test rows (is_test=true) by default — pass include_test:true to include them.\n\nUSE WHEN: An operator (or chat-first ops agent) is triaging the unassigned queue and wants the work list.\n\nDON'T USE WHEN: You want all jobs regardless of assignment — use list_jobs. You want the full dispatcher dashboard payload — use get_dispatcher_view (composite).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_jobs_for_contractor","description":"Returns all jobs assigned to a specific contractor within a date range, optionally filtered by status. Contractor-portal-style view (no client legal scope, no client rate). Sorted by scheduled_date ascending.\n\nUSE WHEN: Building a contractor's own work-list view (the operator-side equivalent of /portal/contractor/[token]/jobs), or producing a contractor digest.\n\nDON'T USE WHEN: You want all jobs across the tenant — use list_jobs. You want a contractor's own portal schedule with aggregate stats — use get_contractor_schedule.\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_jobs_for_client","description":"Returns jobs for a specific client (across all their sites) within an optional date window. Without date_from/date_to it returns the client's jobs ordered by scheduled_date (earliest first, capped at limit); pass date_from/date_to — INCLUDING PAST DATES — to window the result, so this tool DOUBLES AS THE CLIENT JOB-HISTORY READ. A backfilled or completed PAST visit surfaces when you bound the window to its date — the 'upcoming' reading is only the default forward slice, never a hard filter, so do NOT conclude a job is 'not in client history' without querying its past date. Each row carries a denormalised service_name label alongside service_id, so status + service_name make the row self-describing. Each row also carries `contract_service_line_id` so jobs can be attributed to the specific recurring line that spawned them (disambiguates 2+ lines on the same service+site). Honours `tenants.scheduling_settings.client_can_see_contractor_details` — if false (default), the `contractor_id` field is stripped from the response.\n\nUSE WHEN: Building a client-portal-style upcoming view, letting a chat agent answer 'what's scheduled for client X next month', or — with a PAST date_from/date_to — reading a client's completed/historical job log (e.g. verifying a backfilled visit appears in client history).\n\nDON'T USE WHEN: You want the operator-level view across all clients — use list_jobs. You want a contractor's portal view — use list_jobs_for_contractor.\n\nPRECONDITIONS: `client_id` references an existing client in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_contractor_schedule","description":"Returns a contractor's own upcoming schedule: jobs by date plus aggregate stats. Excludes cancelled and terminal (completed/approved/missed) jobs. No client legal scope, no client rates.\n\nWINDOW: defaults to a forward window of today → today+30 (date_from/date_to). The contractor portal FUTURE list is UNBOUNDED-forward, so the DEFAULT call can UNDER-COUNT vs the portal when a non-terminal job is scheduled more than 30 days out — it is NOT full portal parity by default. For parity (every future non-terminal job) pass a far-future date_to (e.g. end of year). The response echoes the applied date_from/date_to and sets default_window_applied:true when no date_to was supplied, so you can tell whether the 30-day default clipped the result before concluding 'the portal shows more jobs than the schedule'.\n\nUSE WHEN: Driving the contractor's own portal view server-side, or producing a contractor-personalised summary.\n\nDON'T USE WHEN: You need an operator view across all contractors — use get_dispatcher_view. You want the raw list of jobs — use list_jobs_for_contractor (omits the aggregate stats).\n\nPRECONDITIONS: `contractor_id` references an existing contractor in the caller's tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"suggest_contractors_for_job","description":"Returns the contractors that PASS the compliance gate for this job's service (`candidates`), the contractors that FAIL it (`blocked` — each with the failure reason, including contractors with no capability registered for the service), plus the contractor preferred for the underlying CSL (if any). Excludes test rows (is_test=true) from both `candidates` and `blocked` by default — pass include_test:true to include them. MVP returns binary pass/fail with no scoring. v2 adds geographic proximity + workload + margin scoring (deferred).\n\nUSE WHEN: An operator wants to see who is eligible for a specific job before opening the picker — and why the others are not appearing.\n\nDON'T USE WHEN: You already know which contractor — go straight to assign_job. You want a roster-level browse — use list_contractors.\n\nPRECONDITIONS: `job_id` references an existing job in the caller's tenant with a `service_id`.\n\nSIDE EFFECTS: None — read-only."},{"name":"bulk_assign_jobs","description":"Assigns N jobs to one contractor in a single call. Compliance gate runs once per (contractor, service_id) — jobs that fail capability or other gates are returned in the `failed` list with their reason. All passes are committed; nothing rolls back on a partial failure.\n\nUSE WHEN: 'Assign all window cleaning jobs this week to Gladstone WC' (B5). Replaces 10+ assign_job calls. Composite per ADR-0002.\n\nDON'T USE WHEN: Assigning one job — use assign_job (granular is retained per ADR-0002 §0.2.2). Assigning different contractors to different jobs — loop assign_job per job.\n\nPRECONDITIONS: `job_ids` is a non-empty array of job UUIDs in the caller's tenant. `contractor_id` references an active contractor. Each job's service is in the contractor's capability set (per-job gate).\n\nSIDE EFFECTS: For each passing job: updates `jobs.contractor_id`, emits `job.assigned`. Declined jobs are reset to `status='scheduled'` with `decline_reason` cleared so they can be re-dispatched (bug 00a6543c). Writes one summary audit_log row with assigned_count, failed_count, and the failed array. NO rollback on partial failure.\n\nNOTE: Assigning does NOT auto-generate or send work orders — call propose_send_work_order per job (then approve_send_work_order) to create and send WOs to the contractor."},{"name":"bulk_reschedule_jobs","description":"Moves N jobs to a single new date with the same reason. Each job's lifecycle is gated independently — completed/approved/invoiced/paid/cancelled jobs are returned in the `failed` list. Emits one `job.rescheduled` event per successful move.\n\nUSE WHEN: A whole week of jobs needs to shift after a public holiday or bad weather day. Composite per ADR-0002 §0.2.2.\n\nDON'T USE WHEN: Each job needs a different new date — call reschedule_job per job. Cancelling instead of moving — use cancel_job.\n\nPRECONDITIONS: `job_ids` is a non-empty array of job UUIDs in the caller's tenant. `new_date` is in the future. `reason` is non-empty.\n\nSIDE EFFECTS: For each non-terminal job: updates `jobs.scheduled_date`, sets `is_rescheduled=true`, emits `job.rescheduled`. Writes one summary audit_log row with moved_count, failed_count, and the failed array. NO rollback on partial failure."},{"name":"get_dispatcher_view","description":"Composite — returns the canonical ScheduleView for the operator dispatcher: jobs bucketed into unassigned/today/upcoming/completed/cancelled, contractors-on-leave list, and aggregate stats (total / assigned / unassigned / completed). Each job includes lock_version for OCC mutations. Honours the same tenant scope as list_jobs and list_unassigned_jobs. For busy tenants / large date windows use `summary: true` (each bucketed job carries only {id, scheduled_date, status, site_id, site_name, contractor_id} plus stats) or `count_only: true` (per-bucket counts + stats, no rows) to stay under the response size limit.\n\nUSE WHEN: Loading the dispatcher dashboard, or driving a chat-first ops view (ADR-0002 §0.2.1). Triaging a busy tenant — use count_only/summary first, then pull the full view for the slice you need.\n\nDON'T USE WHEN: You need only one piece of the view — call the granular tool instead (list_jobs / list_unassigned_jobs).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping. Date range args are optional (defaults to today + 30 days).\n\nSIDE EFFECTS: None — read-only.\n\nRESPONSE SHAPE (default): { date_from, date_to, persona, buckets: { unassigned[], today[], upcoming[], completed[], cancelled[] }, contractors_unavailable[], stats: { total, assigned, unassigned, completed } }. Each job has: id, scheduled_date, status, contractor_id, contractor_name, site_id, site_name, site_address, service_id, service_name, client_rate_cents, contractor_rate_cents, is_rescheduled, original_date, rescheduled_from, is_recurring, lock_version. With summary:true → { summary:true, date_from, date_to, persona, buckets (lean jobs), stats }. With count_only:true → { count_only:true, date_from, date_to, persona, bucket_counts, stats }."},{"name":"regenerate_csl_schedule","description":"Permanently changes a contract service line's frequency (cadence) and/or recurrence inputs (day_of_week, week_of_month, anchor_date), then regenerates its future undispatched jobs. Two-phase: call with confirm=false for a preview of what would change, then confirm=true to apply. Alias: change_csl_schedule.\n\nUSE WHEN: The recurring pattern itself is changing — e.g. weekly → fortnightly, or the day of week is shifting. This mutates the CSL's frequency_id and/or recurrence inputs and regenerates jobs.\n\nDON'T USE WHEN: Moving a specific job to a new date — use reschedule_job. Bulk-moving jobs to the same new date — use bulk_reschedule_jobs.\n\nPRECONDITIONS: `contract_service_line_id` references an active CSL in the caller's tenant. `new_frequency_id` references a frequency in the tenant's frequencies table. `reason` is non-empty.\n\nSIDE EFFECTS (confirm=true): Updates CSL frequency_id and/or recurrence inputs. Re-materialises the recurrence_rule. Soft-deletes future undispatched recurring jobs. Creates new jobs on the new cadence. Emits schedule.changed event. Writes audit_log row. Dispatched/completed/assigned jobs are preserved.\n\nHORIZON: confirm materialises the new cadence across a forward window of at least ~460 days (REGEN_MIN_HORIZON_DAYS in lib/scheduling/regenerate-jobs.ts) — deliberately wider than the nightly spawner's (run_job_spawning) ~60-day look-ahead, so low-frequency cadences (quarterly, biannual, annual) still produce replacement jobs. Consequence: regenerating a high-frequency CSL (e.g. weekly) creates the full ~460-day set in one call (≈60+ weekly jobs), not ~60 days' worth — so jobs_created is materially larger than the nightly spawner would produce for the same CSL. Use the confirm=false preview, which lists every date in jobs_to_create, to see the exact count before applying."},{"name":"create_recurring_service","description":"Set up a recurring service for a client at a site — anything from a simple fixed cadence to a multi-phase PROGRAM with intervals that change over the year. Creates a contract service line + a materialised recurrence rule, spawns the look-ahead jobs, and emits job.spawned / schedule.changed so the rest of the chain (dispatch → work orders → invoicing) flows automatically. Two-phase: confirm=false PREVIEWS the exact dates (no writes); confirm=true creates.\n\nTHREE WAYS TO EXPRESS THE SCHEDULE — precedence: program > explicit_dates > frequency cadence:\n  1. FIXED CADENCE (default): pass frequency_id (+ day_of_week / week_of_month / anchor_date). Handles 'every 2nd Tuesday', 'quarterly on the 1st Wednesday', 'fortnightly'.\n  2. MULTI-PHASE PROGRAM (the `program` arg): for cadences a single rule CANNOT express — an interval that CHANGES after N visits. THIS is how you solve the hard real-world schedules operators struggle with: a lawn-care course of '2 visits a month apart, then every 8 weeks' (a front-loaded onboarding ramp), seasonal horticulture, pest-control programs, pool servicing. A program is an ordered list of `segments`, each {gap_unit, gap_value, count}; each segment's visits chain off the previous segment's last visit. repeat:'annual' locks a predictable yearly cycle; mark an onboarding ramp segment once:true so it runs only in year 1.\n  3. EXPLICIT DATES (the `explicit_dates` arg): a literal ISO-date list for a fully irregular / externally-dictated schedule, or to bolt extra one-off visits onto a cadence (RFC 5545 RDATE).\n\nHOW THIS SETS UP FUTURE EVENTS (important): the program / dates are persisted on the recurrence rule, so the nightly spawner keeps materialising future occurrences as its window advances — every spawn emits job.spawned and the line emits schedule.changed. You declare the cadence ONCE here and the platform fires the events that drive dispatch + invoicing from then on. Never hand-create the recurring jobs yourself.\n\nUSE WHEN: setting up ANY new recurring service — and ESPECIALLY reach for `program` whenever the real cadence is front-loaded / seasonal / irregular and a plain frequency would misrepresent it.\n\nDON'T USE WHEN: modifying an existing recurring service — use update_contract_service_line or regenerate_csl_schedule. A one-off job — use create_job. Creating the site / service / frequency — create those first (frequency_id is still required as the line's service label even when a program drives the dates).\n\nPRECONDITIONS: client_id, site_id, service_id, frequency_id reference existing tenant rows. client_rate_cents is a non-negative INTEGER (cents, never dollars). Fixed periodic cadence (quarterly/biannual/annual) needs day_of_week+week_of_month or anchor_date. A `program` MUST satisfy: annual_visits === the sum of segment counts that are NOT once:true (the predictable-income guarantee — a mismatch is rejected with code program_count_mismatch), and every segment needs gap_unit (day|week|month), gap_value>0, count>0. `program` and `explicit_dates` require a pro / enterprise / custom / mcp-integration tier (basic = fixed cadences only; otherwise returns code upgrade_required with zero writes).\n\nSIDE EFFECTS (confirm=true): inserts a contract_service_line; materialises a recurrence_rule (carrying program_definition / rdates when supplied); spawns today-or-future look-ahead jobs IF the site has an accepted proposal (activation gate — otherwise the line is created live and jobs spawn once a proposal is accepted); emits job.spawned per job + schedule.changed; writes audit_log. The response echoes the full first-cycle dates (preview) and jobs:[{id,scheduled_date}] (confirm) so no follow-up list_jobs call is needed. jobs_spawned can legitimately be 0 when the first occurrence is beyond the look-ahead window — the CSL is still live and the nightly spawner will catch it.\n\nEXAMPLES — preview a program (confirm:false): { client_id, site_id, service_id, frequency_id, client_rate_cents: 22000, program: { annual_visits: 6, repeat: 'annual', anchor_date: '2026-05-06', segments: [ { gap_unit: 'month', gap_value: 1, count: 2 }, { gap_unit: 'week', gap_value: 8, count: 4 } ] } } → returns the 6 dates 2026-05-06, 06-06, 08-01, 09-26, 11-21, 2027-01-16. Onboarding-once variant: segments: [ { gap_unit:'month', gap_value:1, count:2, once:true }, { gap_unit:'week', gap_value:8, count:6 } ] (ramp runs year 1 only; 6 steady visits/year thereafter)."},{"name":"run_job_spawning","description":"On-demand preview and execution of the recurring-job spawning engine. Two-phase: call with dry_run=true (default) to preview what jobs would be created, then dry_run=false with the returned confirm_token to create them.\n\nUSE WHEN: You want to see what jobs the nightly spawner would create, or trigger a spawn immediately instead of waiting for the cron. Scopes to a single CSL (via contract_service_line_id) or all active CSLs in the tenant.\n\nDON'T USE WHEN: Changing a CSL's frequency — use regenerate_csl_schedule. Creating a one-off ad-hoc job — use create_job.\n\nNOTE: this tool only handles job CREATION (look-ahead window). Missed-job detection — the transition of past-due scheduled jobs to 'missed' status — is a separate nightly cron process and CANNOT be triggered on demand via any MCP tool. To verify detection is working, check audit_log events with action='missed' on past-due jobs via get_audit_log_for_entity; do not expect past-due jobs in this tool's would_create output.\n\nPRECONDITIONS: Tenant must have at least one active CSL with an accepted proposal on its site.\n\nSIDE EFFECTS (dry_run=false): Creates job records. Emits job.spawned events. Writes audit_log row. Idempotent — jobs that already exist are correctly skipped via the uq_jobs_csl_date_live unique index and listed in the skipped_existing response field. A populated skipped_existing list is healthy idempotency behaviour (the guard working as designed), not an error."},{"name":"report_bug_to_biloh","description":"Report, file, submit, log, raise, or flag a bug, defect, or issue with the Biloh platform — the CANONICAL WRITE TOOL for filing a bug report. When your intent is 'report a bug' / 'file a bug', call THIS tool: list_my_reported_issues is only the read-back of what you already filed, and list_platform_bugs is the platform-admin triage view; neither files anything. Bugs are stored at the platform level (cross-tenant, visible to platform admins). Downstream automation may pick up the bug and ship a fix; the calling agent is not notified of resolution from within this tool. INCLUDE A `suggested_fix` ONLY IF YOU'RE CONFIDENT — it's a hypothesis for the receiving agent, not a directive.\n\nUSE WHEN: You encounter unexpected platform behaviour — a tool returning the wrong shape, a UI route 404'ing, a stale schema, an unexpected validation error, an inconsistency between MCP output and database state, or any platform-level defect.\n\nDON'T USE WHEN: Tenant-specific data issues (bad client name, missing site) — those are not bugs, just operator work. Requesting a NEW tool — use request_tool_to_biloh instead.\n\nPRECONDITIONS: `summary` and `description` are populated. `surface` is one of the allowed enum values.\n\nSIDE EFFECTS: Inserts a row into the platform `bugs` table (cross-tenant). Writes audit_log row in the calling tenant. No email or notification fired — downstream automation polls and triages."},{"name":"request_tool_to_biloh","description":"Request, propose, ask for, or submit a NEW tool to the Biloh platform — the canonical write tool for filing a tool request (list_my_reported_issues is the read-back of what you already filed; it does not file anything). Tool requests are stored at the platform level (cross-tenant). Downstream automation may design and ship the new tool; the calling agent is not notified of completion from within this tool. BE PRECISE about `proposed_name`, `tool_class`, and `workflow_context` — the receiving agent uses these to decide whether to build.\n\nUSE WHEN: You wished you had a tool that didn't exist — a composite that would have replaced six granular calls, or a granular tool that's genuinely missing.\n\nDON'T USE WHEN: Reporting buggy behaviour on an existing tool — use report_bug_to_biloh. The tool already exists under a different name — call `mcp_session_diagnostic` first to see the full server tool list.\n\nPRECONDITIONS: `proposed_name` is populated. `tool_class` is `composite` or `granular`. `workflow_context` explains what the agent was trying to do.\n\nSIDE EFFECTS: Inserts a row into the platform `tool_requests` table (cross-tenant). Writes audit_log row in the calling tenant. No email or notification fired — downstream automation polls and triages."},{"name":"upgrade_tool_to_biloh","description":"Suggest, propose, file, submit, or log an upgrade or improvement to an EXISTING Biloh tool that works correctly but could communicate or structure itself better — the canonical write tool for filing a tool-upgrade suggestion. Upgrades are stored at the platform level (cross-tenant). Downstream automation may pick up the upgrade and ship it; the calling agent is not notified of resolution from within this tool.\n\nUSE WHEN: A tool functions correctly but its description under-warns about a failure mode, its response omits a field you had to infer, it gives no hint about what to call next, it's mis-catalogued or should compose with another tool, its preview computes against the wrong basis, or it soft-fails silently. The third signal channel: not \"broken\" (use report_bug_to_biloh), not \"missing\" (use request_tool_to_biloh) — \"works but could be sharper.\"\n\nDON'T USE WHEN: The tool returns the wrong result or errors unexpectedly — that's a bug, use report_bug_to_biloh. The capability doesn't exist at all — use request_tool_to_biloh. A tenant-data issue — that's operator work, not a platform signal.\n\nPRECONDITIONS: `tool_name`, `improvement_type`, `current_behavior`, `desired_behavior`, `justification` populated. `improvement_type` is one of the 7 enum values.\n\nSIDE EFFECTS: Inserts a row into the platform `platform_tool_upgrades` table (cross-tenant). Writes audit_log row in the calling tenant. No email or notification fired — downstream automation polls and triages."},{"name":"list_my_reported_issues","description":"Read back the bugs, tool requests, and tool upgrades THIS tenant has filed via report_bug_to_biloh / request_tool_to_biloh / upgrade_tool_to_biloh, with their current triage status. Tenant-scoped — returns only your own reports, never the cross-tenant platform queue.\n\nUSE WHEN: A self-healing smoke/dogfood loop needs to check whether a bug it filed has been triaged/resolved before re-running a blocked scenario; an operator wants the status (new / claimed / in_progress / resolved / wont_fix) and resolution commit of something they reported. For dedup/blocked-recheck scans at scale, pass summary:true (drops the large resolution_notes field) and/or limit so the response stays inline instead of overflowing the token limit.\n\nDON'T USE WHEN: You need the cross-tenant platform backlog (that is platform-admin only — list_platform_bugs / list_platform_tool_requests / list_platform_tool_upgrades). Filing a new report — use report_bug_to_biloh / request_tool_to_biloh / upgrade_tool_to_biloh.\n\nPRECONDITIONS: None. Optional filters: `type` (bug|tool_request|tool_upgrade), `status`, `id`. Optional projection/pagination: `summary` (lean — omit resolution_notes), `limit`, `offset` (applied newest-first; response carries total + has_more).\n\nSIDE EFFECTS: None — read-only."},{"name":"send_operator_report_email","description":"Send a report email from the platform to the CONFIGURED operator address. The recipient is read ONLY from the tenant setting `notifications.operator_report_email` — you cannot pass an arbitrary recipient (open-relay guard). The sender is pinned to the Resend-verified gwcpropertyservices.com.au domain.\n\nUSE WHEN: A non-repo agent (smoke runner, scheduled task) needs to email the operator a run/status report and has no host-shell access; delivering a digest or alert to the operator inbox.\n\nDON'T USE WHEN: Emailing a client or contractor — those are tenant comms with their own staged propose_send_* → approve_send_* flow. Sending to an arbitrary address — not supported by design.\n\nPRECONDITIONS: `subject` and `body_text` are populated. The `notifications.operator_report_email` setting is non-empty (otherwise a structured error is returned and nothing is sent).\n\nSIDE EFFECTS: Sends one email via the platform-verified sender (mocked in tests). Writes an audit_log row (entity_type='operator_report_email'). Does NOT write communication_log (that is for tenant-facing comms)."},{"name":"unenroll_user_mfa","description":"Removes a user's TOTP two-factor authentication factors so they can re-enrol after losing their authenticator device. The target user must belong to YOUR tenant (team member or portal user).\n\nUSE WHEN: A team member or portal user lost their authenticator and is locked out of the 2FA challenge at sign-in.\n\nDON'T USE WHEN: The user simply wants to switch devices and can still sign in — they can remove and re-add 2FA themselves from their security settings. Resetting a PASSWORD — use the forgot-password flow.\n\nPRECONDITIONS: `user_email` resolves to an auth user with membership in the caller's tenant.\n\nSIDE EFFECTS: Deletes the user's TOTP factors via the auth admin API — their NEXT sign-in is password-only until they re-enrol. Writes audit_log (action `mfa_unenrolled`)."},{"name":"cleanup_test_data","description":"Soft-deletes rows flagged `is_test=true` that are not `is_test_preserved=true` and older than `older_than_days`. Returns per-entity-type counts and the first 10 affected IDs. Pass `dry_run: true` to preview without mutating. Architect-only — same handler invoked by the nightly cleanup_test_data scheduled task.\n\nUSE WHEN: Cleaning up stale [TEST]/[SMOKE]/[DEMO] data after a verification run, or auditing what the nightly cleanup will remove. Always start with `dry_run: true` to preview.\n\nDON'T USE WHEN: Removing production data — use the normal archive_* tools (`is_test=false` rows are untouched). The data is canonical dogfood (e.g. [TEST] Claude Dispatch) — those are `is_test_preserved=true` and this tool skips them by design.\n\nPRECONDITIONS: Architect persona. `older_than_days` ≥ 0 (default 7). `entity_types` either omitted/['all'] or a known subset.\n\nSIDE EFFECTS: With `dry_run: false`: soft-deletes (sets `deleted_at = NOW()`) on every matching row across every eligible entity table. Writes one audit_log row per affected entity batch. With `dry_run: true`: no writes, only counts."},{"name":"list_test_entities","description":"Returns every row marked `is_test=true` across the platform's entity tables, including the `is_test_preserved` flag and current `deleted_at` status. Architect-only.\n\nUSE WHEN: Auditing what test data exists before running cleanup_test_data, or verifying that the dogfood fixtures (e.g. [TEST] Claude Dispatch) are still preserved.\n\nDON'T USE WHEN: You want to deletion-preview — use cleanup_test_data with `dry_run: true` (it filters by age). You want production data — use the normal list_* tools.\n\nPRECONDITIONS: Architect persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"mark_entity_as_test","description":"Sets `is_test=true` on existing rows in the named entity table. Pass a single `entity_id` OR a bulk `entity_ids` array (up to 500 ids in one call — flagging test data is inherently bulk: one test client spawns dozens of CSLs and hundreds of jobs). Pass `preserved: true` to also set `is_test_preserved=true` so the nightly cleanup never removes them. Architect-only.\n\nUSE WHEN: Retroactively flagging entities that should have been marked as test data at creation time — use `entity_ids` to flag a whole batch (e.g. every spawned job of a test CSL) in one call. Promoting a dogfood fixture to `is_test_preserved=true`.\n\nDON'T USE WHEN: Creating a new test entity — pass `is_test: true` on the create_* call (source-level lock enforces this). Demoting a test row back to production — use unmark_entity_as_test.\n\nPRECONDITIONS: Architect persona. `entity_type` is one of the markable entity tables. Exactly one of `entity_id` / `entity_ids` is supplied. Ids reference existing rows in the caller's tenant.\n\nSIDE EFFECTS: Updates the row(s) to `is_test=true` (and `is_test_preserved=true` if specified). Writes audit_log capturing the flag change (one row per call; bulk calls record per-id results in metadata). Bulk response reports `updated_count` + `not_found_ids` so partial matches are visible. For OCC-versioned entity types (contractors, jobs, proposals) the response row also includes the post-update `lock_version`, so a flag-then-archive sequence needs no mid-sequence re-read; the other entity types have no `lock_version` column and omit it (tool upgrade da5de41e)."},{"name":"unmark_entity_as_test","description":"Sets `is_test=false` (and clears `is_test_preserved`) on an existing row in the named entity table. Promotes a test fixture to production data. Architect-only.\n\nUSE WHEN: Recovering a row that was wrongly flagged as test data and should appear in real dashboards.\n\nDON'T USE WHEN: Flagging a row AS test — use mark_entity_as_test. Cleaning up — use cleanup_test_data.\n\nPRECONDITIONS: Architect persona. `entity_type` is one of the markable entity tables. `entity_id` references an existing row in the caller's tenant currently `is_test=true`.\n\nSIDE EFFECTS: Updates the row to `is_test=false` and `is_test_preserved=false`. Writes audit_log row capturing the flag change. The row now appears in regular dashboards."},{"name":"offboard_entity_cascade","description":"Archive (deleted_at) and/or flag-as-test (is_test=true) a parent entity AND every dependent record it owns, in one auditable operation. For a client: cascades across its sites, contract_service_lines, future-undispatched jobs (cancelled), and draft/sent (optionally accepted) proposals. For a contractor: its CSLs + assigned/unassigned future jobs. `mode`: 'archive' | 'flag_test' | 'both'. Returns per-type counts + sample ids under `affected`, plus `blocked_production_children` and `skipped_preserved`.\n\nSAFETY: invoices are NEVER mutated (financial records — use void_invoice) and are reported count-only; is_test=false children of a TEST parent are NEVER touched (listed under blocked_production_children); is_test_preserved children are skipped; a production parent (is_test=false) requires confirm:true. Eligible CSLs are deactivated before their jobs are cancelled so the spawner cannot re-create them. `dry_run` defaults TRUE — call once to preview, then dry_run:false to execute.\n\nUSE WHEN: Removing a test/smoke client (and the orphaned CSLs/jobs/proposals that keep feeding the dashboard) in one call; targeted, auditable offboarding of a parent and its graph.\n\nDON'T USE WHEN: You only want a preview — use get_client_footprint (read-only). You need to void a specific invoice/payment — use the void_* finance tools. You want the nightly test sweep — that's cleanup_test_data.\n\nPRECONDITIONS: Architect persona. `entity_type` is client|contractor|site, `entity_id` a UUID in the caller's tenant, `mode` one of archive|flag_test|both.\n\nSIDE EFFECTS (dry_run:false only): sets deleted_at / is_test / is_active=false / status='cancelled' on eligible children per mode. Writes one audit_log row per mutated entity type (action offboard_cascade.*). Never mutates invoices."},{"name":"list_settings","description":"Lists platform settings registered for this tenant with current values, default values, tier status, and metadata. Filterable by category, scope, and full-text search across keywords + descriptions.\n\nUSE WHEN: Surveying available knobs, finding a setting by what it does (e.g. search 'xero' or 'late fee'), or auditing what is/isn't on default. Settings the tenant's tier cannot edit are returned with `tier_status='upgrade_required'` — visible, not hidden.\n\nDON'T USE WHEN: You already have the setting key — use get_setting. You want only finance settings — use get_finance_settings (convenience wrapper).\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_setting","description":"Reads one platform setting's current value + metadata for this tenant. Returns the registered definition, the default value, the `previous_value` (if any), `tier_status` (enabled or upgrade_required), and the last-change authorship.\n\nUSE WHEN: Inspecting a single setting's state before deciding to change it, or surfacing 'last changed by X on Y' in a UI.\n\nDON'T USE WHEN: You don't know the key — use list_settings with a search filter. You want the change history — use list_setting_history.\n\nPRECONDITIONS: `key` is a valid registered setting key. `scope_id` required for scope ∈ {client, contractor, job}.\n\nSIDE EFFECTS: None — read-only."},{"name":"update_setting","description":"Updates one platform setting's value for this tenant. Writes audit_log + emits `settings.updated` event. Reversible via revert_setting (architect persona). Supports optimistic-concurrency via `lock_version`.\n\nUSE WHEN: Changing a tunable knob (e.g. 'integrations.xero.feed_mode' from 'detailed' to 'consolidated'). Provide a free-form `reason` so the audit row carries the rationale.\n\nDON'T USE WHEN: Natural-language settings change without lock_version — use configure_setting (composite front-of-house). The setting is tier-gated and the tenant lacks the tier — the tool returns `upgrade_required`; surface an Upgrade CTA instead.\n\nPRECONDITIONS: `key` is a valid registered setting. The new value matches the registered data type. Tenant tier permits editing this key. If `lock_version` is supplied, it must match the current row version.\n\nSIDE EFFECTS: Updates `tenant_settings` row (insert if first write). Writes audit_log row (action `setting.changed`). Emits `settings.updated` domain event with the diff. Stores `previous_value` to enable one-click revert."},{"name":"revert_setting","description":"Architect-only. Reverts one platform setting to its `previous_value` (the value before the most recent change). The revert is itself audit-logged (`action='setting.reverted'`) AND itself reversible — calling revert_setting twice in a row returns to the original state.\n\nUSE WHEN: Undoing a setting change that broke something downstream (e.g. flipping Xero feed mode back after a sync issue). The audit trail captures both the original change and the revert for forensic queries.\n\nIf the setting was set only once (no `previous_value`), revert restores the UNSET state — the tenant override row is deleted and the registry default applies again.\n\nDON'T USE WHEN: The setting has never been changed at all (no tenant_settings row) — the tool returns `nothing_to_revert`. You're an operator persona — operators cannot revert; surface an architect-approval flow.\n\nPRECONDITIONS: Architect persona. `key` references a setting that has been changed at least once for this scope.\n\nSIDE EFFECTS: Swaps `value` and `previous_value` on the tenant_settings row — or deletes the row entirely when `previous_value` is null (restoring the registry default). Writes audit_log row (action `setting.reverted`). Emits `settings.updated` domain event with the diff."},{"name":"list_setting_history","description":"Returns the chronological audit history for one platform setting: every `setting.changed` and `setting.reverted` event with actor, timestamp, before/after values, and reason. Limit defaults to 20, max 100.\n\nUSE WHEN: Reconstructing 'who changed this and why', diagnosing a tenant's misconfiguration, or feeding the Recently Changed view on the settings UI.\n\nDON'T USE WHEN: You want the current value — use get_setting. You want history for a different entity — use get_audit_log_for_entity.\n\nPRECONDITIONS: `key` is a valid registered setting key. `scope_id` required for scope ∈ {client, contractor, job}.\n\nSIDE EFFECTS: None — read-only."},{"name":"configure_setting","description":"Composite (front-of-house). Set a platform setting to a new value. Audit-logged + reversible via revert_setting. Use this as the default for natural-language settings changes; use update_setting directly when you need to pass `lock_version` for optimistic-concurrency control.\n\nUSE WHEN: 'change the Xero feed mode to consolidated', 'enable the daily digest', or any intent-named setting change. The chat agent reaches for this first.\n\nDON'T USE WHEN: You need optimistic-concurrency via `lock_version` — use update_setting directly. You want to revert — use revert_setting.\n\nPRECONDITIONS: `key` is a valid registered setting. The new value matches the registered data type. Tenant tier permits editing this key.\n\nSIDE EFFECTS: Same as update_setting — updates `tenant_settings`, writes audit_log (action `setting.changed`), emits `settings.updated` with diff, stores `previous_value` for revert."},{"name":"get_session_context","description":"⚡ READ ME FIRST ⚡ — Returns the full operational context for this tenant: business identity, your persona + role, key conventions, current state snapshot, and pointers to deeper reference. Call this before any other tool when you first connect. This is mandatory grounding for acting intelligently in this tenant.\n\nUSE WHEN: First call on connect for a fresh agent. Re-orienting after a long pause. Confirming who the tenant is and how they want you to speak before any other action.\n\nDON'T USE WHEN: You already called it this session and the tenant hasn't changed — the response is stable across a session. Querying current data for a specific entity — this is grounding, not entity state.\n\nPRECONDITIONS: None — tenant-authenticated MCP transport enforces caller scoping.\n\nSIDE EFFECTS: None — read-only. The writes that change what this tool returns happen via the existing update_setting path which already audits + emits settings.updated."},{"name":"search_tools","description":"Find the right MCP tool by intent — returns a ranked, persona-scoped shortlist (name + one-line summary + category) so you do not have to scan the full catalogue or guess a name.\n\nUSE WHEN: You know WHAT you want to do but not which tool does it (e.g. \"onboard a client\", \"raise an invoice\", \"find a client by name\"). The first step before any unfamiliar workflow.\n\nDON'T USE WHEN: You already know the exact tool name — call it directly. You want the FULL catalogue — use mcp_health (tool_names). You want tenant grounding — use get_session_context.\n\nPRECONDITIONS: None — runs for any authenticated MCP context.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_all_tenants","description":"Platform-admin only: list every tenant on the Biloh platform with their plan, status, and canonical domain. Default: excludes [TEST] fixtures and archived tenants.\n\nUSE WHEN: Surveying the platform tenant roster, building a billing dashboard, or finding a tenant by name across the whole platform.\n\nDON'T USE WHEN: You already have the tenant ID — use get_tenant_details. You want a single tenant's data — call the tenant-scoped tools after `mint_pat_for_tenant`.\n\nPRECONDITIONS: Platform persona (PAT with tenant_id IS NULL).\n\nSIDE EFFECTS: None — read-only."},{"name":"get_tenant_details","description":"Platform-admin only: full detail for one tenant, including its subscription, owner email, canonical domain, and high-level usage counts (clients, contractors, jobs).\n\nUSE WHEN: Inspecting a tenant's state — subscription plan, owner, usage scale — before a platform-admin action or billing decision.\n\nDON'T USE WHEN: You want the full roster — use list_all_tenants. You want tenant-scoped business data (live clients/contractors/jobs) — mint a tenant PAT and call the tenant tools.\n\nPRECONDITIONS: Platform persona. `tenant_id` references an existing tenant.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_tenant","description":"Platform-admin only: provision a new tenant. TWO-PHASE: call WITHOUT confirm_token to preview + get a token (nothing is created), then re-call with the SAME name + owner_email AND that confirm_token to provision. Inserts `tenants` + `tenant_subscriptions` rows and pre-seeds `owner_email` so the owner attaches automatically when they sign up.\n\nUSE WHEN: Onboarding a new paying tenant. Seeding a [TEST] tenant for dogfood / smoke. Bootstrapping a sales demo.\n\nDON'T USE WHEN: Adding a new user to an existing tenant — that's the auth flow / tenant_users. Adding a client of an existing tenant — that's create_client (tenant-scoped).\n\nPRECONDITIONS: Platform persona. `name` is non-empty. `owner_email` is a valid email. `plan` is in {basic, pro, enterprise, custom}. For test seeds: `is_test: true`.\n\nSIDE EFFECTS: Inserts one row into `tenants` and one into `tenant_subscriptions`. Pre-seeds `owner_email` so first sign-up auto-attaches as owner. Writes audit_log row at the platform scope. No emails sent."},{"name":"archive_tenant","description":"Platform-admin only: soft-archive a tenant. IRREVERSIBLE from the MCP surface, so it is TWO-PHASE: call it WITHOUT confirm_token to get a preview of the tenant + a confirm_token (nothing is archived), then re-call with the SAME tenant_id + reason AND that confirm_token to actually archive. Sets `archived_at` and `archived_reason`; the tenant is hidden from default lists and its `canonical_domain` stops resolving.\n\nUSE WHEN: A tenant has cancelled / churned / been removed for non-payment. Decommissioning a [TEST] tenant after a dogfood run.\n\nDON'T USE WHEN: The tenant has data the platform team still needs to read — archived tenants are excluded from default queries. Removing a tenant user — that's the auth/tenant_users flow.\n\nPRECONDITIONS: Platform persona. `tenant_id` references an existing, non-archived tenant.\n\nSIDE EFFECTS: With a valid confirm_token — updates `tenants.archived_at = NOW()` and `archived_reason`; the tenant disappears from `list_all_tenants` (unless `include_archived=true`) and its canonical_domain no longer resolves. WITHOUT confirm_token — NO mutation; returns confirmation_required + a preview + the token."},{"name":"mint_pat_for_tenant","description":"Platform-admin only: mint a Personal Access Token (PAT) for a tenant. Returns the plaintext token exactly once — store it immediately. Optional `target_user_id` binds the token to a tenant user (so it appears in that user's API Tokens UI); defaults to caller. The target user must be an active member of `tenant_users` for the given `tenant_id`.\n\nUSE WHEN: Issuing tenant-scoped credentials (Claude Desktop, ChatGPT custom GPT, Grok, server-to-server callers) on behalf of a tenant user, or provisioning a [TEST] PAT for a dogfood tenant.\n\nDON'T USE WHEN: You want a PLATFORM-scoped PAT (no tenant_id) — use mint_platform_pat. Revoking an existing token — use revoke_pat. Listing PATs — separate UI / tool.\n\nPRECONDITIONS: Platform persona. `tenant_id` references an existing tenant. `target_user_id` (if supplied) is an active member of `tenant_users` for that tenant. `expires_at` (if supplied) is in the future.\n\nSIDE EFFECTS: Inserts one row into `personal_access_tokens` with a hashed `token`. Returns the plaintext token exactly once — never retrievable again. Writes audit_log row at platform scope."},{"name":"mint_platform_pat","description":"Platform-admin only: mint a PLATFORM-scoped Personal Access Token (no `tenant_id`). Use this to issue credentials for cross-tenant platform-admin tools called via biloh.com.au. Returns the plaintext token exactly once — store it immediately. Optional `target_user_id` binds the token to a specific platform admin (must be a member of `platform_admins`); defaults to caller.\n\nUSE WHEN: Provisioning credentials for a platform-admin Cowork session, a cross-tenant dogfood worker, or a service-account-style platform script.\n\nDON'T USE WHEN: You want a tenant-scoped PAT — use mint_pat_for_tenant. Revoking — use revoke_pat.\n\nPRECONDITIONS: Platform persona. `target_user_id` (if supplied) is a member of `platform_admins`. `expires_at` (if supplied) is in the future.\n\nSIDE EFFECTS: Inserts one row into `personal_access_tokens` with `tenant_id IS NULL` and a hashed `token`. Returns the plaintext token exactly once. Writes audit_log row at platform scope."},{"name":"revoke_pat","description":"Platform-admin only: revoke any Personal Access Token by id. Unlike the tenant-user revoke flow (`/api/tokens/[id]`) which is limited to tokens owned by the calling user, this override has no ownership filter. Idempotent — re-revoking a previously revoked token is a no-op success.\n\nUSE WHEN: A token has been compromised. A tenant user has churned and their tokens need invalidating. A [TEST] PAT used in dogfood is ready for cleanup.\n\nDON'T USE WHEN: Revoking your own token — use the tenant-user `/api/tokens/[id]` route. Listing PATs — separate tool / UI.\n\nPRECONDITIONS: Platform persona. `token_id` references an existing PAT (may be already-revoked — idempotent).\n\nSIDE EFFECTS: Sets `personal_access_tokens.revoked_at = NOW()` if not already revoked. Writes audit_log row at platform scope. The token immediately stops authenticating against the MCP transport."},{"name":"get_platform_metrics","description":"Platform-admin only: high-level platform health snapshot — MRR (cents and formatted), active tenant count, 30-day churn, total active PATs. MRR = SUM of `subscription_plans.price_monthly_cents` over active subscriptions; excludes [TEST] fixtures and archived tenants.\n\nUSE WHEN: Building a platform exec dashboard, surfacing MRR and churn for a stand-up, or auditing PAT issuance scale.\n\nDON'T USE WHEN: You want tenant-by-tenant breakdown — use list_all_tenants. You want the dogfood quality score — use get_dogfood_status.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_platform_bugs","description":"Platform-admin only: list bugs reported via report_bug_to_biloh across all tenants. Filter by status (defaults to all non-resolved).\n\nUSE WHEN: Triaging the platform bug backlog, building a status-based bug dashboard, or feeding the autonomous bug-fix worker (ADR-0002 §0.2.5).\n\nDON'T USE WHEN: Reading a tenant-scoped bug log — tenants don't see bug data; platform admins do. Listing tool requests — use list_platform_tool_requests.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_platform_tool_requests","description":"Platform-admin only: list tool-build requests submitted via request_tool_to_biloh across all tenants. Filter by status (defaults to all non-resolved).\n\nUSE WHEN: Triaging the platform tool-request backlog, deciding what new tools to build next, or feeding the autonomous tool-deploy pipeline (ADR-0002 §0.2.5).\n\nDON'T USE WHEN: Listing bugs — use list_platform_bugs. Actually deploying a new tool — use deploy_new_tool.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"deploy_new_tool","description":"Platform-admin only: queue a new MCP tool for build. STUB — currently records the request in `platform_tool_requests`; the autonomous tool-deploy pipeline (Layer 1.2) will eventually pick up and ship.\n\nUSE WHEN: A platform admin has a complete tool definition (name, purpose, input/return shape, example) and wants it queued for the autonomous build pipeline.\n\nDON'T USE WHEN: Reporting a bug — use report_bug_to_biloh. Quick tool idea without a full spec — use request_tool_to_biloh.\n\nPRECONDITIONS: Platform persona. `tool_definition_md` is a populated Markdown block.\n\nSIDE EFFECTS: Inserts a row into `platform_tool_requests` (status `new`). Writes audit_log row at platform scope. Until the autonomous pipeline lands, the request waits for manual triage."},{"name":"get_dogfood_status","description":"Platform-admin only: returns the latest dogfood run summary, a 30-day score trend, the top N currently-failing checks, and regression alerts (checks that failed this run but passed last run). Per ADR-0010 §5. Designed to fit in one MCP response without round-tripping the raw evidence JSONB.\n\nUSE WHEN: 'How's dogfood looking?' from the architect's Dispatch chat. Auditing regression after a recent ship. Building a quality-score trend chart.\n\nDON'T USE WHEN: You want the raw evidence — query the `dogfood_runs` table directly. You want platform metrics like MRR — use get_platform_metrics.\n\nPRECONDITIONS: Platform persona. `top_n` ≤ 20 (default 5). `tier` (if supplied) is 1-5.\n\nSIDE EFFECTS: None — read-only."},{"name":"claim_platform_bug","description":"Platform-admin only: atomically claim a platform bug for processing. The claim has a 60-minute TTL; expired claims can be taken over. Same-claimer retries are idempotent.\n\nUSE WHEN: An agent or human is about to work on a bug from the platform backlog. Call classify_platform_work_item first to check if the item is safe to auto-fix.\n\nDON'T USE WHEN: The bug is already claimed by someone else (check list_platform_bugs first). Resolving a bug — use resolve_platform_bug.\n\nPRECONDITIONS: Bug must be in status 'new', or have an expired claim TTL, or be claimed by the same claimer (retry). Bug must not be test data (is_test=false).\n\nSIDE EFFECTS: Updates platform_bugs row (status, claimed_at, claimed_by, claim_expires_at). Writes audit_log. Emits platform_bug.claimed event."},{"name":"mark_platform_bug_in_progress","description":"Platform-admin only: transition a claimed platform bug to in_progress. Refreshes the 60-minute claim TTL.\n\nUSE WHEN: Work has started on the bug — code is being investigated or written.\n\nPRECONDITIONS: Bug must be in status 'claimed'.\n\nSIDE EFFECTS: Updates status + claim_expires_at. Writes audit_log. Emits platform_bug.in_progress event."},{"name":"resolve_platform_bug","description":"Platform-admin only: resolve a claimed/in-progress platform bug with an audit trail.\n\nUSE WHEN: The bug has been fixed (resolved), determined to be not-a-bug (wont_fix), or found to be a duplicate.\n\nPRECONDITIONS: Bug must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Updates status, resolved_at, resolved_by, resolution fields. Writes audit_log. Emits platform_bug.resolved event."},{"name":"release_platform_bug_claim","description":"Platform-admin only: release a claim on a platform bug, returning it to status 'new'. Unconditionally callable by ANY platform user — no ownership check. This ensures stale claims from crashed sessions can always be unstuck.\n\nUSE WHEN: Handing off work, abandoning a fix attempt, or unsticking a stale claim left by another agent.\n\nPRECONDITIONS: Bug must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Clears claimed_at, claimed_by, claim_expires_at, sets status='new'. Writes audit_log. Emits platform_bug.released event."},{"name":"claim_platform_tool_request","description":"Platform-admin only: atomically claim a platform tool request for processing. 60-minute TTL. Same-claimer retries are idempotent.\n\nUSE WHEN: About to design/build a requested tool.\n\nPRECONDITIONS: Request must be in status 'new', have expired TTL, or be claimed by the same claimer.\n\nSIDE EFFECTS: Updates status/claimed fields. Writes audit_log. Emits platform_tool_request.claimed event."},{"name":"mark_platform_tool_request_in_progress","description":"Platform-admin only: transition a claimed tool request to in_progress. Refreshes TTL.\n\nPRECONDITIONS: Request must be in status 'claimed'.\n\nSIDE EFFECTS: Updates status + claim_expires_at. Writes audit_log. Emits event."},{"name":"resolve_platform_tool_request","description":"Platform-admin only: resolve a claimed/in-progress tool request.\n\nPRECONDITIONS: Request must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Updates resolution fields. Writes audit_log. Emits platform_tool_request.resolved event."},{"name":"release_platform_tool_request_claim","description":"Platform-admin only: release a claim on a tool request, returning it to 'new'. Unconditionally callable — no ownership check.\n\nPRECONDITIONS: Request must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Clears claimed fields, sets status='new'. Writes audit_log. Emits event."},{"name":"classify_platform_work_item","description":"Platform-admin only: classify a platform bug or tool request for autonomous processing eligibility. Returns one of: autonomous-allowed, requires-human-review, requires-human-fix — with reasons explaining the classification.\n\nUSE WHEN: Before claiming a work item, check if it's safe to auto-fix. The generic drain-the-queue worker calls this on every item.\n\nDON'T USE WHEN: Already resolved items.\n\nPRECONDITIONS: Item must exist.\n\nSIDE EFFECTS: None — read-only."},{"name":"set_platform_work_item_classification","description":"Platform-admin override: force a specific classification on a platform_bug or platform_tool_request. The next call to classify_platform_work_item will return the forced value with reasons including 'architect_override:<reason>'. Pass force_classification=null to clear the override and restore default behaviour.\n\nUSE WHEN: A floor rule is firing for a syntactic reason (keyword in description, not in actual diff) and you want to unblock the autonomous worker.\n\nDON'T USE WHEN: The bug actually does touch a Hard NO list item (RLS, tenant data, financial records, audit_log). The override CAN force the classifier output, but the Hard NO list is enforced by RLS + write gateways + audit logging downstream, not by the classifier."},{"name":"get_decision_queue","description":"Platform-admin composite: the architect's Decision Queue — every status='new' work item (platform_bugs + platform_tool_requests + platform_tool_upgrades, is_test excluded) with live classification, the worker's latest analysis/recommendation, and any existing architect decision. Items needing a human decision sort first. Same data as /platform/decisions (chat-first parity).\n\nUSE WHEN: Triaging human-gated items — 'what needs my decision?'. Reviewing worker recommendations before approving via decide_work_item.\n\nDON'T USE WHEN: Worker-side triage — use get_worker_queue (lean, architect-approved-first). Listing one table with full bodies — use list_platform_bugs / list_platform_tool_requests / list_platform_tool_upgrades.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"decide_work_item","description":"Platform-admin composite: record an architect decision on a work item from the Decision Queue. Decisions: 'approve' (force autonomous-allowed — the worker builds it next fire, jumping the queue), 'approve_with_steer' (approve + a binding note the worker MUST fold into the implementation — note required), 'park' (force requires-human-fix — human-owned, worker annotates only), 'dismiss' (resolve as wont_fix/wont_build/wont_upgrade — soft, reversible), 'request_recommendation' (flag the item for the worker's next fire: FULL discovery + a structured RECOMMENDATION/ALTERNATIVES/RISK/FAILING-TEST-DESIGN annotation, NO build — the architect approves the recommendation afterwards; optional note focuses the investigation), 'reopen' (clear a prior approve/park decision — the card drops its force_classification and returns to 'Needs your decision' at its natural classification; use to pull back an approval or reconsider a card an agent has since added a new recommendation to). Wraps set_platform_work_item_classification + annotate_* + claim/resolve into one intent call. Reversible: clear an approval via set_platform_work_item_classification(force_classification=null).\n\nUSE WHEN: Clearing the human-decision backlog after reading get_decision_queue — the architect's one-tap consent surface.\n\nDON'T USE WHEN: Writing pure analysis without deciding — use annotate_platform_bug. Resolving an item you actually FIXED — use resolve_platform_bug with the commit sha.\n\nPRECONDITIONS: Item must be status='new' (approve/park/dismiss race-guarded — stale_state error if a worker claimed it meanwhile). Platform persona.\n\nSIDE EFFECTS: Updates force_classification columns (approve/park) or claims+resolves (dismiss). Appends architect_steer analysis entry when a note is given. Writes audit_log. Emits platform_*.classification_overridden / platform_*.resolved."},{"name":"get_worker_queue","description":"Platform-admin composite: the autonomous worker's one-call triage read. Merges status='new' items across platform_bugs + platform_tool_requests + platform_tool_upgrades into a LEAN payload (no descriptions/bodies — pull the fat row only for items you select). Each item carries: live classification + reasons, architect_approved (force_classification='autonomous-allowed' set by the architect — these sort FIRST and jump the impact×eligibility queue), steer_note (binding architect guidance from the Decision Queue), and the dedup-gate fields (last_examined_at = newest analysis timestamp, updated_at, force_classification_set_at).\n\nUSE WHEN: Worker fire start — replaces the three list_* calls + per-item classify calls for triage. Selection rule: architect-approved items first.\n\nDON'T USE WHEN: You need full item bodies — use list_platform_bugs etc. for the selected items. Architect-side triage — use get_decision_queue.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_platform_work_item","description":"Platform-admin single-item fetch: return the full fat row (description, repro_steps, suggested_fix, agent_context, last_classification_analysis, lifecycle fields) for ONE platform work item by (item_type, item_id), where item_type is bug | tool_request | tool_upgrade. The single-item complement to get_worker_queue (lean, all items) and the list_* family (fat, ALL items).\n\nUSE WHEN: After get_worker_queue triage selects items, fetch each selected item's full body in one call instead of the large list_* read.\n\nDON'T USE WHEN: You need the lean queue of all open items — use get_worker_queue. You need every open item's full body — use list_platform_bugs / list_platform_tool_requests / list_platform_tool_upgrades.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"claim_platform_tool_upgrade","description":"Platform-admin only: atomically claim a platform tool upgrade for processing. The claim has a 60-minute TTL; expired claims can be taken over. Same-claimer retries are idempotent.\n\nUSE WHEN: An agent or human is about to work on a tool upgrade from the platform backlog. Call classify_platform_work_item first to check if the item is safe to auto-fix.\n\nDON'T USE WHEN: The upgrade is already claimed by someone else (check list_platform_tool_upgrades first). Resolving — use resolve_platform_tool_upgrade.\n\nPRECONDITIONS: Upgrade must be in status 'new', or have an expired claim TTL, or be claimed by the same claimer (retry). Must not be test data (is_test=false).\n\nSIDE EFFECTS: Updates platform_tool_upgrades row (status, claimed_at, claimed_by, claim_expires_at). Writes audit_log. Emits platform_tool_upgrade.claimed event."},{"name":"mark_platform_tool_upgrade_in_progress","description":"Platform-admin only: transition a claimed platform tool upgrade to in_progress. Refreshes the 60-minute claim TTL.\n\nUSE WHEN: Work has started on the upgrade — code is being investigated or written.\n\nPRECONDITIONS: Upgrade must be in status 'claimed'.\n\nSIDE EFFECTS: Updates status + claim_expires_at. Writes audit_log. Emits platform_tool_upgrade.in_progress event."},{"name":"resolve_platform_tool_upgrade","description":"Platform-admin only: resolve a claimed/in-progress platform tool upgrade with an audit trail.\n\nUSE WHEN: The upgrade has been shipped (resolved), determined to be not-needed (wont_upgrade), or found to be a duplicate.\n\nPRECONDITIONS: Upgrade must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Updates status, resolved_at, resolved_by, resolution fields. Writes audit_log. Emits platform_tool_upgrade.resolved event."},{"name":"release_platform_tool_upgrade_claim","description":"Platform-admin only: release a claim on a platform tool upgrade, returning it to status 'new'. Unconditionally callable by ANY platform user — no ownership check. This ensures stale claims from crashed sessions can always be unstuck.\n\nUSE WHEN: Handing off work, abandoning a fix attempt, or unsticking a stale claim left by another agent.\n\nPRECONDITIONS: Upgrade must be in status 'claimed' or 'in_progress'.\n\nSIDE EFFECTS: Clears claimed_at, claimed_by, claim_expires_at, sets status='new'. Writes audit_log. Emits platform_tool_upgrade.released event."},{"name":"list_platform_tool_upgrades","description":"Platform-admin only: list tool upgrades reported via upgrade_tool_to_biloh across all tenants. Filter by status (defaults to all non-resolved).\n\nUSE WHEN: Triaging the platform tool-upgrade backlog, building a status-based dashboard, or feeding the autonomous worker.\n\nDON'T USE WHEN: Reading a tenant-scoped log — tenants don't see upgrade data; platform admins do. Listing bugs — use list_platform_bugs. Listing tool requests — use list_platform_tool_requests.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"annotate_platform_tool_upgrade","description":"Platform-admin only: append a classification analysis entry to a platform tool upgrade without claiming or changing its status. Use this to persist analysis on requires-human-fix items that the worker cannot touch.\n\nUSE WHEN: A worker has classified a tool upgrade as requires-human-fix and needs to record its analysis for future drain sessions.\n\nDON'T USE WHEN: You want to change the upgrade's status — use the lifecycle tools instead.\n\nPRECONDITIONS: Upgrade must exist.\n\nSIDE EFFECTS: Appends to last_classification_analysis JSONB. Writes audit_log. Does NOT change status, claimed_at, claimed_by, or claim_expires_at."},{"name":"annotate_platform_bug","description":"Platform-admin only: append a classification analysis entry to a platform bug without claiming or changing its status. Use this to persist analysis on requires-human-fix items that the worker cannot touch.\n\nUSE WHEN: A worker has classified a bug as requires-human-fix and needs to record its analysis for future drain sessions.\n\nDON'T USE WHEN: You want to change the bug's status — use the lifecycle tools instead.\n\nPRECONDITIONS: Bug must exist.\n\nSIDE EFFECTS: Appends to last_classification_analysis JSONB. Writes audit_log. Emits platform_bug.annotated event. Does NOT change status, claimed_at, claimed_by, or claim_expires_at."},{"name":"annotate_platform_tool_request","description":"Platform-admin only: append a classification analysis entry to a tool request without claiming or changing its status.\n\nUSE WHEN: A worker has classified a tool request as requires-human-fix or requires-human-review and needs to record its analysis.\n\nPRECONDITIONS: Tool request must exist.\n\nSIDE EFFECTS: Appends to last_classification_analysis JSONB. Writes audit_log. Emits platform_tool_request.annotated event. Does NOT change status."},{"name":"record_fire_metrics","description":"Platform-admin only: record a self-grading metric row for a single automation worker fire. One row per fire in worker_fire_metrics. Foundation for tuning floors, dedup windows, and recipes by evidence.\n\nUSE WHEN: At the end of every automation worker fire, before sending the email report.\n\nDON'T USE WHEN: Mid-fire or for non-automation sessions.\n\nPRECONDITIONS: Worker must have completed its queue drain.\n\nSIDE EFFECTS: Inserts worker_fire_metrics row, writes audit_log, emits worker_fire.metric_recorded event."},{"name":"list_marketing_content","description":"Lists the tenant's marketing-site content for one kind (category | industry | case_study | page | help_article | testimonial | site_config). Returns published rows by default; pass include_unpublished:true to see drafts.\n\nUSE WHEN: Reviewing what's on the public website — industries, case studies, categories, pages, testimonials, or the site config (hero, colors, nav, structured copy).\n\nDON'T USE WHEN: You want the operational services/pricing catalog (use list_services) or the quote-request inbox.\n\nSIDE EFFECTS: None (read-only)."},{"name":"upsert_marketing_content","description":"Creates or updates one marketing-site content row (kinds: category | industry | case_study | page | help_article | testimonial | site_config). Insert vs update is resolved by payload.id, else by the kind's slug within the tenant. site_config always updates the tenant's single row; its `content` object is MERGED key-by-key (existing keys survive).\n\nUSE WHEN: Editing website copy, adding an industry page or case study, adjusting category labels/order, updating the hero or nav from chat.\n\nDON'T USE WHEN: Changing operational service pricing (update_service) or uploading photos (create_marketing_upload_link).\n\nPRECONDITIONS: payload fields must belong to the kind's allowlist — unknown fields (incl. tenant_id/id smuggling) are rejected.\n\nSIDE EFFECTS: Writes the row; audit_log entry 'marketing_content_upserted'. Public site picks the change up within ~5 minutes (ISR)."},{"name":"set_marketing_content_published","description":"Publishes or unpublishes one marketing-site content row (kinds with a published flag: category | industry | case_study | page | testimonial).\n\nUSE WHEN: Taking a draft live, or pulling content off the public site without deleting it.\n\nSIDE EFFECTS: Toggles published; audit_log entry 'marketing_content_publish_toggled'. Public site updates within ~5 minutes (ISR)."},{"name":"create_marketing_upload_link","description":"Generates a short-lived, SINGLE-USE browser upload link for a website photo. Give the link to the user — they tap it on their phone, pick the photo, add a caption, and it lands in the website media library. Then use list_marketing_assets + attach_marketing_asset to place it on the site.\n\nUSE WHEN: The user has a photo (job photo, before/after, hero shot, logo) they want on the website. This is the ONLY correct path for photos — never accept image bytes through chat.\n\nPHOTO GUIDANCE for the user: landscape orientation, at least 1600px wide; the site optimises sizes automatically.\n\nPRECONDITIONS: none. SIDE EFFECTS: inserts a marketing_upload_tokens row (expires per tenant setting marketing.upload.link_ttl_minutes, default 30 min); audit_log."},{"name":"list_marketing_assets","description":"Lists the tenant's website photo library (tenant_media_assets), newest first. Each item carries public_url (what attach_marketing_asset places on the site), kind, alt_text, and upload time.\n\nUSE WHEN: Checking whether a photo arrived after an upload link was used, or choosing which photo to place on a page.\n\nSIDE EFFECTS: None (read-only)."},{"name":"attach_marketing_asset","description":"Places a library photo onto the website by writing its public URL onto a marketing content row.\n\nTargets: case_study_hero | case_study_before | case_study_after | industry_hero | service_image — pass target {kind, id} where id is the content row UUID (find it via list_marketing_content / list_marketing_assets). For the site hero or logo slots use upsert_marketing_content (site_config) with the asset's public_url instead.\n\nUSE WHEN: A photo has been uploaded and the user wants it on a case study (hero/before/after), an industry page, or a service card.\n\nSIDE EFFECTS: Updates the target row's image column; audit_log. Public site updates within ~5 minutes (ISR)."},{"name":"get_branding_kit","description":"Returns the tenant's branding kit: every asset slot (logo_full, logo_white, logo_icon, favicon, email_header, pdf_header, social_banner, letterhead) with its status, required format/dimensions, and public URL where uploaded — plus brand colors, fonts, and completion percentage. This kit feeds invoices/PDFs, the app icon, the style guide, and the public website.\n\nUSE WHEN: Checking what branding a tenant has, what's missing before white-labelling, or which URL a logo serves from.\n\nSIDE EFFECTS: None — read-only."},{"name":"create_branding_upload_link","description":"Generates a short-lived, SINGLE-USE browser upload link that lands a file directly in a branding-kit SLOT (replacing the current asset at its stable path). Because every surface reads the kit, one upload updates invoices, the app icon, the style guide, and the website together.\n\nUSE WHEN: The user wants to add or replace a logo/icon/header in their branding kit from their phone — including first-time white-label setup.\n\nDON'T USE WHEN: The photo is website content (case studies, heroes) — use create_marketing_upload_link. \n\nPRECONDITIONS: the slot exists in the tenant's kit (see get_branding_kit).\n\nSIDE EFFECTS: inserts a marketing_upload_tokens row with branding_slot set (TTL per marketing.upload.link_ttl_minutes); the upload itself updates branding_assets + storage; audit_log."},{"name":"get_my_subscription","description":"Returns the current tenant's subscription status, plan, trial countdown, billing details, and the active plan's catalog price — price_monthly_cents, price_annual_cents, currency (AUD), tax_inclusive (true per ADR-0013), read verbatim from the plan catalog so a tenant can verify displayed price == charged price. Tenant-scoped — each tenant sees only their own subscription. It also returns the cancel lifecycle — cancel_at_period_end (true once a cancel-at-period-end is scheduled; the subscription stays active until current_period_end) and canceled_at (the cancellation timestamp, null when not cancelling) — so a tenant agent can read back a pending cancellation via MCP, not only via the Stripe portal.\n\nUSE WHEN: Checking subscription status, trial days remaining, plan details, the cancel-at-period-end state, or the charged price of the current plan.\n\nPRECONDITIONS: Operator persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_my_entitlements","description":"Returns the calling tenant's EFFECTIVE feature entitlements resolved from its active subscription plan: the per-plan feature flags (custom_branding, api_access, priority_support, white_label, ai_enabled) and numeric limits (max_users, max_clients, max_sites, feature_usage_limit) read VERBATIM from the subscription_plans catalog row for the tenant's current plan_code, plus plan is_active and the tenant's feature-gate tier (tenants.subscription_tier). The read-side complement to get_my_subscription that makes 'displayed == entitled' verifiable from the tenant/MCP surface. Tenant-scoped — each tenant sees only its own entitlements.\n\nUSE WHEN: Checking what features/limits the current plan grants, or verifying a tier change took effect (displayed == entitled).\n\nPRECONDITIONS: Operator persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"list_tenant_subscriptions","description":"Platform-admin only: list all tenant subscriptions across the platform with plan, status, and Stripe IDs.\n\nUSE WHEN: Auditing subscription health, MRR breakdown, or identifying churned/expired tenants.\n\nPRECONDITIONS: Platform persona.\n\nSIDE EFFECTS: None — read-only."},{"name":"get_tenant_subscription","description":"Platform-admin only: get subscription details for a specific tenant by tenant_id.\n\nUSE WHEN: Investigating a specific tenant's billing state.\n\nPRECONDITIONS: Platform persona. tenant_id required.\n\nSIDE EFFECTS: None — read-only."},{"name":"extend_trial","description":"Platform-admin only: extend a tenant's trial period by a specified number of days. Records the reason in the audit log.\n\nUSE WHEN: A tenant requests more time to evaluate, or during onboarding support.\n\nPRECONDITIONS: Platform persona. tenant_id, days, reason required. Tenant must be in trialing status.\n\nSIDE EFFECTS: Updates trial_ends_at. Writes audit_log row with reason. Emits platform.trial_extended."},{"name":"comp_subscription","description":"Platform-admin only: grant a complimentary subscription to a tenant (no Stripe object). Records the reason for audit.\n\nUSE WHEN: Strategic partner agreements, investor demos, or support escalations.\n\nPRECONDITIONS: Platform persona. tenant_id, plan_code, reason required.\n\nSIDE EFFECTS: Updates subscription to active with the given plan. Emits platform.subscription_comped."},{"name":"set_tenant_plan_price_override","description":"Test-gated, tenant-scoped subscription-plan price override for autonomous, fully-reversible price-change testing inside a dogfood tenant. Sets (or clears with price_cents:null) a GST-inclusive override keyed on (this tenant, plan_code, billing_interval) that changes ONLY this tenant's Till-1 Checkout charge — the shared subscription_plans catalog and all other tenants are untouched.\n\nUSE WHEN: Verifying plan-price-change propagation (catalog price -> Stripe Checkout total) end to end inside a designated test tenant.\n\nDON'T USE WHEN: Changing real catalog pricing — use sync_subscription_plans_to_stripe. On a live (non-test) tenant — the tool hard-rejects.\n\nPRECONDITIONS: is_test=true AND the calling tenant is a designated dogfood/test tenant (tenants.is_test=true). plan_code in {basic, pro, enterprise}. price_cents is null (clear) or a non-negative integer (GST-inclusive cents).\n\nSIDE EFFECTS: Writes the `finance.billing.plan_price_override` tenant setting (audit_log + settings.updated event + one-click revert). Does NOT touch the /pricing display surface (separate displayed-vs-charged concern)."},{"name":"set_tenant_feature_flag_override","description":"Test-gated, tenant-scoped entitlement feature-flag override for autonomous, fully-reversible entitlement-gating testing inside a dogfood tenant. Sets (value:true/false) or clears (value:null → fall back to the plan default) a per-tenant override of ONE entitlement flag (custom_branding, api_access, priority_support, white_label, ai_enabled) that overlays the shared subscription_plans catalog ONLY for THIS tenant. get_my_entitlements reflects the overlay, so 'displayed == entitled' is verifiable from the MCP surface without platform-admin browser auth and without touching the shared catalog or any other tenant.\n\nUSE WHEN: Verifying a feature-flag/entitlement change propagates (plan default -> get_my_entitlements -> gated UI) end to end inside a designated test tenant (smoke scenario G04). The WRITE complement to get_my_entitlements's READ.\n\nDON'T USE WHEN: Changing real catalog entitlements — edit the plan in /platform/plans. On a live (non-test) tenant — the tool hard-rejects with refused_non_test_tenant.\n\nPRECONDITIONS: The calling tenant is a designated dogfood/test tenant (is_test=true OR is_test_preserved=true). flag in {custom_branding, api_access, priority_support, white_label, ai_enabled}. value is a boolean (set) or null (clear). confirm must be true to apply; confirm:false previews.\n\nSIDE EFFECTS: When confirm:true — writes the `entitlements.feature_flag_override` tenant setting (audit_log + settings.updated event + one-click revert). confirm:false (dry-run) writes nothing. Never mutates subscription_plans; production tenants are byte-identical (the read overlay + write path are both test-gated)."},{"name":"reset_test_subscription","description":"Architect/test-gated: reset the CALLING tenant's Till-1 (tenant→Biloh) subscription to the 'no active paid subscription' baseline (status=active, plan_code=basic, stripe_subscription_id=null) so the Group-G subscribe/checkout/decline flows can be run repeatedly. The plan-chooser re-opens because its gate keys on stripe_subscription_id being null.\\n\\nTEST-GATED like cleanup_test_data / mark_entity_as_test: REFUSES unless the calling tenant is a test/dogfood tenant (is_test=true OR is_test_preserved=true) — this protects real tenants (e.g. GWC, both flags false). Claude Dispatch is is_test=false but is_test_preserved=true, so the OR predicate is required for it to qualify. Idempotent: a tenant already at baseline returns changed:false and writes nothing. Local-only v1: does NOT cancel the test-mode Stripe subscription (left as a no-op); it clears local state only, which is what the UI gate reads.\\n\\nUSE WHEN: A smoke/dogfood runner needs to return a test tenant (e.g. Claude Dispatch) to the unsubscribed baseline to re-exercise Group-G checkout. DON'T USE WHEN: On a real/production tenant (refused by design). Changing a real subscription — use comp_subscription or the Stripe billing portal.\\n\\nPRECONDITIONS: Architect persona. The calling tenant is a test/dogfood tenant. `confirm` must be true to mutate; confirm:false is a dry-run preview.\\n\\nSIDE EFFECTS: When confirm:true and not already baseline — updates the tenant's tenant_subscriptions row(s) to baseline, syncs tenants.subscription_tier='basic', writes one audit_log row (action 'reset_test_subscription'). Dry-run and already-baseline no-op write nothing."},{"name":"create_billing_portal_session","description":"Creates a Stripe Billing Portal session for the current tenant. Returns a URL to redirect the operator to manage their subscription (update payment method, switch plan, cancel). Billing-exempt — works for any subscription status.\n\nUSE WHEN: Operator wants to manage their billing, update payment method, or change plan.\n\nPRECONDITIONS: Operator persona. Tenant must have a stripe_customer_id.\n\nSIDE EFFECTS: Creates a Stripe Billing Portal session (ephemeral, expires in ~24h)."},{"name":"sync_subscription_plans_to_stripe","description":"Platform-admin only: sync Biloh subscription plans to Stripe Products + Prices. Idempotent — safe to run repeatedly. Creates Stripe Products (keyed by metadata.biloh_plan_code) and recurring Prices (keyed by lookup_key) for basic, pro, and enterprise plans. Skips the custom plan ($0, no self-serve checkout). Detects price-amount changes and archives the old Stripe Price before creating a replacement.\n\nUSE WHEN: Initial Stripe catalog setup, after changing plan prices in subscription_plans, or to verify/recover Stripe ↔ DB sync state.\n\nPRECONDITIONS: Platform persona. STRIPE_SECRET_KEY must be set in the environment. No arguments required.\n\nSIDE EFFECTS: Creates/updates Stripe Products and Prices. Writes stripe_product_id, stripe_price_id_monthly, stripe_price_id_annual back to subscription_plans rows.\n\nMODE-AWARE: Reads STRIPE_SECRET_KEY from environment — syncs to sandbox in test mode, live at go-live, with no code change."},{"name":"get_compliance_document_for_review","description":"Returns a compliance document's metadata, readable content (text and/or images delivered in-band), and a short-lived signed URL (15 min). The reviewing agent can read the document directly from this tool's response — no URL fetch required.\n\nUSE WHEN: A reviewing agent or operator needs to read and extract information from an uploaded compliance document (insurance certificate, workers comp, etc.).\n\nDON'T USE WHEN: Just listing which docs need review — use list_compliance_documents_pending_review.\n\nPRECONDITIONS: document_id references an existing compliance document in the caller's tenant.\n\nSIDE EFFECTS: writes audit_log (compliance_document_review_opened). No mutation to the document itself."},{"name":"upload_compliance_document_from_agent","description":"Uploads a compliance document (insurance certificate, etc.) on behalf of a contractor via the agent chat. Accepts the file as base64, validates integrity, stores it in Supabase Storage, creates a pending_review compliance document row, and syncs contractor denormalised fields. Mirrors the portal upload route exactly.\n\nIMPORTANT: For anything but tiny files (< 8 KB), prefer create_compliance_upload_link instead — it generates a browser-native upload link that avoids base64 corruption in the chat channel. This tool is kept for small files and automation.\n\nUSE WHEN: A user provides a very small insurance PDF or certificate in the chat (< 8 KB) and asks you to upload it for a contractor.\n\nDON'T USE WHEN: The file is larger than a few KB — use create_compliance_upload_link instead. The contractor is uploading directly via the portal — that's the portal upload route.\n\nPRECONDITIONS: contractor_id exists in the caller's tenant. File must be <= 10 MB. Content type must be pdf/jpeg/png.\n\nSIDE EFFECTS: uploads to contractor-docs storage bucket; inserts contractor_compliance_documents row (pending_review); syncs contractors.insurance_document_url for insurance types; emits contractor.compliance_changed; writes audit_log."},{"name":"propose_send_contractor_agreement","description":"Stages a Subcontractor Master Agreement (SMA) send. Validates the agreement is in draft status and the contractor has a deliverable email. Renders a draft preview PDF and returns a 1-hour signed preview URL (`pdf_url`) so the operator can review the document before approving. Creates an `mcp_pending_operations` row that must be confirmed via `confirm_pending_operation` (which mints the signing_token) before the operator approves the actual send via `approve_send_contractor_agreement`.\n\nThe sent email includes a portal-link CTA (Review Agreement button) for click-to-sign acceptance — do not instruct the recipient to reply with acceptance text, the signing flow is handled by the portal.\n\nUSE WHEN: A draft SMA has been created (via create_contractor_agreement) and is ready to send to the contractor for signing. First leg of the propose → confirm → approve send chain.\n\nDON'T USE WHEN: The agreement is already sent or accepted. The contractor has no email and no recipient override is provided. Bypassing the staged-send pattern — the propose/approve pair is the only sanctioned send path.\n\nPRECONDITIONS: `agreement_id` references a draft `contractor_agreements` row in the caller's tenant. Either the contractor has a primary email OR `recipient_email` is supplied as an override.\n\nSIDE EFFECTS: Renders the draft SMA preview PDF to storage (sma-preview.pdf — non-blocking; staging proceeds if the render fails). Inserts one `mcp_pending_operations` row (status `proposed`, operation_type `send_contractor_agreement`). NO email is sent at this step. Writes audit_log row recording the stage. The agreement itself is unchanged until `confirm_pending_operation` runs."},{"name":"propose_send_proposal","description":"Stages a proposal for sending. Validates proposal is in `draft` status and has at least one line. Renders the proposal PDF and returns a preview URL. Creates an `mcp_pending_operations` row that must be confirmed via `confirm_pending_operation` and then approved via `approve_send_operation` before the email actually fires. The sent email includes a portal-link CTA (Review Proposal button) for click-to-accept signing — do not instruct the recipient to reply with acceptance text, the signing flow is handled by the portal.\n\nThe response includes a `warnings` array. Each warning identifies a non-blocking issue (e.g., a line with no description, or description duplicating the service name). Warnings do not prevent the send — they surface concerns the operator should consider before approving. An empty warnings array means no issues detected.\n\nUSE WHEN: Ready to send a completed proposal to a client. First leg of the propose → confirm → approve send chain for proposals.\n\nDON'T USE WHEN: Proposal has no lines — add them via add_proposal_line first. Proposal is already sent/accepted. Re-sending an already-sent proposal — use propose_resend_proposal.\n\nPRECONDITIONS: `proposal_id` references a `draft` proposal with at least one non-deleted line. `recipient_email` is supplied. Tenant `mail_provider` is configured. Tenant branding (`getTenantBranding`) must succeed for PDF rendering.\n\nSIDE EFFECTS: Renders proposal PDF to Supabase storage. Inserts one `mcp_pending_operations` row (status `proposed`, operation_type `send_proposal`). NO email sent at this step. Writes audit_log row recording the stage. The proposal itself is unchanged until `confirm_pending_operation` runs."},{"name":"propose_send_work_order","description":"Stages a work-order send. Accepts EXACTLY ONE of `job_id` (single job), `job_ids` (a set of jobs for the same contractor, max 100), or `contract_service_line_id` (an ongoing recurring series) — the work-order scope (single_job / job_set / ongoing_series) is inferred from which identifier you supply. Validates the assigned contractor has a signed Subcontractor Master Agreement; for `job_ids` the validation is ALL-OR-NOTHING (WP-WO-03): every member is checked against the full compliance gate (SMA + insurance + capability + availability), the payment gate, and live work-order conflicts — any single failure rejects the WHOLE batch with a per-job `failures` array and creates ZERO rows. A successful job_set stage returns `earnings_total_cents` (Σ per-job contractor rate, ex-GST — a forecast, not a payment commitment). Creates an `mcp_pending_operations` row that must be approved via `approve_send_work_order` before the work order is actually dispatched. The sent email includes a portal-link CTA (Review Work Order button) — the contractor accepts via the portal.\n\nNEXT STEP: call `approve_send_work_order(operation_id, confirmed: true)` DIRECTLY after this tool. Do NOT call `confirm_pending_operation` — send_work_order is a two-step chain (propose → approve), not the three-step chain (propose → confirm → approve) used by proposals/invoices/statements. `confirm_pending_operation` will return 'Unknown operation type: send_work_order' if called.\n\nUSE WHEN: Ready to dispatch work to a contractor — one job, a batch of jobs, or an ongoing recurring service line. First leg of the two-step propose → approve send chain.\n\nDON'T USE WHEN: The job/series has no assigned contractor — use assign_job / update_contract_service_line first. Contractor has not signed SMA — chase the agreement via propose_send_contractor_agreement. A WO is already in flight (status `sent`) or already `accepted` for the same binding. Bypassing the staged-send pattern.\n\nPRECONDITIONS: Exactly one of `job_id` | `job_ids` | `contract_service_line_id`. The bound job(s)/series belong to the caller's tenant with an assigned contractor whose `sma_signed = true` (job_ids must all share ONE contractor — a work order is addressed to one contractor). No existing `sent` or `accepted` work_order for the same binding.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `proposed`, operation_type `send_work_order`). NO email sent at this step. Writes audit_log row recording the stage. Nothing else changes until `approve_send_work_order` runs."},{"name":"propose_send_credit_note","description":"Stages a credit-note send for operator approval. First leg of the three-leg send chain (propose → confirm → approve). Resolves the recipient email from the linked client's billing_email if not supplied. The ATO validation gate runs in approve_send_credit_note, NOT here.\n\nUSE WHEN: Operator wants to send a draft credit note to the client. Always the first MCP call in the send chain.\n\nDON'T USE WHEN: The credit note is already sent — use void_credit_note + create a fresh one. The credit note is in draft but lines are still being edited — finish edits via update_credit_note_line first.\n\nPRECONDITIONS: credit_note_id is a draft credit note in the caller's tenant with ≥ 1 line and total > 0.\n\nSIDE EFFECTS: Inserts an mcp_pending_operations row (operation_type='send_credit_note'). Writes audit_log entry 'credit_note_send_proposed'. No email sent and no invoice adjustment until approve_send_credit_note runs."},{"name":"approve_send_statement","description":"Approves a staged statement send and dispatches the email to the client immediately. The statement transitions from draft → sent, message_id + recipient_email + sent_at are persisted, statement.sent is emitted.\n\n⚠️ HUMAN APPROVAL REQUIRED. This tool fires the send immediately — there is no second confirmation. Only call with `confirmed: true` after the operator has explicitly approved.\n\nUSE WHEN: The operator has reviewed via get_pending_operation and said 'yes, send it'. Third and final leg of the propose → confirm → approve send chain for statements.\n\nDON'T USE WHEN: Operator has not approved. The operation is for a different artifact type — use approve_send_operation (proposals) or approve_send_contractor_agreement. The statement is already sent — re-propose via propose_send_statement if you need a new send.\n\nPRECONDITIONS: A pending operation exists with operation_type='send_statement' and status='staged' (confirm_pending_operation has run). `confirmed: true`. Tenant mail_provider configured.\n\nSIDE EFFECTS: Sends email via tenant mail_provider (Resend / Migadu). Transitions statement to `sent`. Marks pending operation `approved_and_sent`. Writes audit_log rows for operation approval + statement send. communication_log row written by sendBrandedEmail. Emits statement.sent."},{"name":"propose_send_invoice","description":"Proposes sending an invoice to the client. Creates an `mcp_pending_operations` row — the send is staged (stored status value 'pending') and MUST be confirmed via `confirm_pending_operation`, then approved via `approve_send_invoice`, before the invoice is actually sent. This three-leg pattern prevents accidental sends. Expiry is tenant-tunable via finance.sends.pending_operation_ttl_minutes (default 60, clamp 5-240).\n\nUSE WHEN: Ready to send a draft invoice to the client for payment. First leg of the propose → confirm send chain for invoices.\n\nDON'T USE WHEN: The invoice is not in `draft` status. Recording payments — use create_payment. Bypassing the staged-send pattern.\n\nPRECONDITIONS: `invoice_id` references an invoice in the caller's tenant in `draft` status. The client has a deliverable billing_email (or `recipient_email` override is supplied). Tenant `mail_provider` is configured.\n\nSIDE EFFECTS: Inserts one `mcp_pending_operations` row (status `proposed`, operation_type `send_invoice`). NO email sent at this step. Writes audit_log row recording the stage. The invoice is unchanged until `confirm_pending_operation` runs.\n\nDUPLICATE GUARD (bug 7b5b805a): refuses to stage when a live invoice (draft/sent/overdue) for the same client has an equal total and overlapping job/service period (window: finance.invoicing.duplicate_window_days, default 14). Re-call with override_duplicate_check: true to proceed intentionally."},{"name":"confirm_pending_operation","description":"Confirms a previously staged operation (leg 2 of 3 in the propose → confirm → approve send chain). Behaviour depends on operation_type:\n• send_proposal: mints the signing_token and transitions to staged_for_send. Does NOT send — requires approve_send_operation as a separate operator-approval gate.\n• send_contractor_agreement: mints the signing_token and transitions the agreement to staged_for_send. Does NOT send — requires approve_send_contractor_agreement as a separate operator-approval gate.\n• send_invoice: stages the invoice send for operator approval. Does NOT send — requires approve_send_invoice as the third leg (per WP-FE-06; v1 ran an immediate send here, now three-leg with ATO validation gate).\n• send_statement: stages the statement send for operator approval. Does NOT send — requires approve_send_statement as the third leg (per WP-FE-07; the statements row was created draft by propose_send_statement).\n• send_credit_note: stages the credit-note send for operator approval. Does NOT send — requires approve_send_credit_note as the third leg (per WP-FE-05b; the ATO/locale validation gate + invoice recompute run at approve).\n• resend_proposal: re-delivers the email immediately using the existing signing_token (no separate approve step — the proposal was operator-approved at original send time). Writes a proposal_resent audit_log entry with send_attempt_number.\n• resend_invoice: re-delivers an already-sent invoice IMMEDIATELY on confirm — TWO-leg propose → confirm, NO separate approve (the invoice was operator-approved at its original send). Fans out to the receives_invoices contacts staged on the operation. PURE re-delivery: no invoice mutation (status/number/total/lock_version unchanged), no postings, no invoice.sent event; stamps the invoice message_id + email_send_status and writes an invoice_resent audit_log entry with send_attempt_number. Staged by propose_resend_invoice — do NOT call approve_send_invoice / approve_send_operation for this type.\n• resend_statement: re-delivers an already-sent statement IMMEDIATELY on confirm (re-renders the statement PDF). PURE re-delivery: no statement mutation. Staged by propose_resend_statement; do NOT call approve_send_statement. Writes a statement_resent audit_log entry with send_attempt_number.\n• resend_credit_note: re-delivers an already-sent credit note IMMEDIATELY on confirm (re-attaches the stored PDF). PURE re-delivery: no re-application to the invoice, no postings. Staged by propose_resend_credit_note; do NOT call approve_send_credit_note. Writes a credit_note_resent audit_log entry with send_attempt_number.\n\nValidates ownership, expiry, and status before transitioning. Only the original proposer can confirm.\n\nUSE WHEN: The human has reviewed the proposed action summary and wants to proceed (middle leg of the propose → confirm → approve chain for sends; final/firing leg for resend_proposal / resend_invoice / resend_statement / resend_credit_note — these re-delivery types fire the email immediately on confirm with NO approve leg).\n\nDON'T USE WHEN: Cancelling an operation — use cancel_pending_operation. The operation has expired — re-propose. Bypassing the staged pattern with an out-of-band send.\n\nPRECONDITIONS: `operation_id` is a `proposed` row in `mcp_pending_operations` in the caller's tenant. Operation not expired. Caller is the original proposer (operator persona).\n\nSIDE EFFECTS: Varies by operation_type. send_proposal / send_contractor_agreement / send_invoice / send_statement / send_credit_note: transitions operation to `staged`, no email. resend_proposal / resend_invoice / resend_statement / resend_credit_note: re-deliver the email IMMEDIATELY (two-leg — no approve leg), write a `<type>_resent` audit row with send_attempt_number, and never mutate the document (no status/number change, no postings, no *.sent event); resend_invoice + resend_statement also stamp the entity message_id + email_send_status. Every variant writes audit_log rows."},{"name":"render_artifact_pdf","description":"Renders a tenant artefact (proposal, signed agreement, work order, ongoing-series work order, invoice, quote, statement, receipt) as a PDF, stores it in Supabase storage scoped to the tenant, and returns a 1-hour signed download URL. Idempotent — re-call to refresh after data changes. Updates the entity row's `pdf_url` / `signed_pdf_url` field if applicable.\n\nUSE WHEN: The agent needs to preview, attach, or download a PDF for an entity (e.g. before a send, or to share a link with the operator).\n\nDON'T USE WHEN: The entity doesn't exist or the template isn't supported (currently `proposal`, `signed_agreement`, `work_order`, `work_order_series`, `invoice`, `quote`, `statement`, and `credit_note` are implemented; only `receipt` is reserved for a future phase). Sending the artefact — use the propose_send_* tool which renders + stages in one call.\n\nPRECONDITIONS: `entity_id` references an existing entity in the caller's tenant. `template` is one of the supported types. For `work_order`, the work order must have status `accepted`. For `work_order_series`, the work order must have scope `ongoing_series` (an `accepted` one renders the signed acceptance block from the snapshot's legal-record projection; a `sent` one renders the pre-acceptance forecast). Tenant branding (`getTenantBranding`) must succeed.\n\nSIDE EFFECTS: Renders PDF and uploads to Supabase storage under the tenant scope. Updates the entity's `pdf_url` / `signed_pdf_url` if applicable. Returns a 1-hour signed URL. Writes audit_log row capturing the render."}],"categories":{"clients":26,"contractors":23,"contracts":12,"jobs":16,"work_orders":6,"proposals":17,"invoices":28,"operations":7,"audit":2,"platform":5,"platform_admin":40},"auth":{"method":"bearer","token_type":"personal_access_token","issued_via":"biloh.com.au platform admin via mint_pat_for_tenant or /platform/tokens"},"contact":"hello@biloh.com.au","documentation":"https://biloh.com.au/mcp","marketing":"https://biloh.com.au"}