openapi: 3.1.0
info:
  title: Plotmesh API
  version: 1.8.0
  description: "Scoped creative intelligence, campaign drafting, outcomes, Momentum planning (v1.6), Create projects (v1.7), approval-gated production and publishing requests, and workspace privacy controls. Every documented path maps to a handler in app/v1; scripts/check-api-drift.mjs enforces that. Scopes differ by area: v15:read and v15:draft cover the intelligence and outcomes paths, Create uses create:read and create:write (v15:draft does not inherit project access), publishing uses publish:read, publish:authorize and channels:read. Some paths are human-only — they need a signed-in, same-origin browser session and refuse API keys and connected agents outright; each says so. No documented path spends credits or publishes on its own."
servers:
  - url: https://plotmesh.xyz/v1
security:
  - bearerAuth: []
paths:
  /campaigns:
    get:
      operationId: listCampaigns
      summary: "List campaign drafts in the workspace"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createCampaignDraft
      summary: "Create a campaign draft; nothing is produced"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}:
    get:
      operationId: getCampaign
      summary: "Read one campaign, its variants and current step"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    patch:
      operationId: saveCampaignStepDraft
      summary: "Save one Remix Canvas step draft"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: archiveCampaign
      summary: "Archive a campaign draft"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/approve:
    post:
      operationId: approveCampaignPreflight
      summary: "Human-only approval of one reviewed production maximum. API credentials are refused; approval alone starts nothing."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/evaluate:
    get:
      operationId: getCampaignEvaluation
      summary: "Deterministic evaluation of saved observations with the published outcome policy"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: evaluateCampaign
      summary: "Deterministic evaluation of saved observations. Read-only: no provider call, no cell is closed, no recommendation is saved."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/hypotheses:
    get:
      operationId: listHypotheses
      summary: "List hypotheses saved for a campaign"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createHypothesis
      summary: "Save a draft hypothesis. Requires an idempotency key; launching cells stays with POST /experiments."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/preflight:
    post:
      operationId: createCampaignPreflight
      summary: "Calculate a bounded production maximum, or approve one with approve=true"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/produce:
    post:
      operationId: startApprovedCampaignDrafts
      summary: "Start the approved draft batch. Session-only; requires an approved preflight."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/variants/retry-history:
    get:
      operationId: getVariantRetryHistory
      summary: "Read saved segment-retry history for a campaign"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /campaigns/{campaignId}/variants/{variantId}/retry:
    get:
      operationId: getVariantRetryOptions
      summary: "Read the included revision state for one variant"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: retryVariantSegment
      summary: "Use the included bounded revision for one variant segment"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /capabilities:
    get:
      operationId: listCapabilities
      summary: "Public capability registry with status and known limits"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /connectors/youtube/search:
    post:
      operationId: searchYouTubeConnector
      summary: "Search permitted YouTube sources for the workspace"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /connectors/youtube/status:
    get:
      operationId: getYouTubeConnectorStatus
      summary: "Connector configuration and quota state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /developer/actions:
    get:
      operationId: listActionRequests
      summary: "Agent approval inbox: v1.5 action requests and v1.6 plan requests"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    patch:
      operationId: decideActionRequest
      summary: "Approve or reject one agent request. Approval records a decision and executes nothing."
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /developer/grants:
    get:
      operationId: listGrants
      summary: "List active agent grants"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /developer/grants/{grantId}:
    delete:
      operationId: revokeGrant
      summary: "Revoke an agent grant immediately"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /developer/keys:
    get:
      operationId: listApiKeys
      summary: "List API credentials and their scopes"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createApiKey
      summary: "Create an API credential; the secret is returned once"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /developer/keys/{keyId}:
    delete:
      operationId: revokeApiKey
      summary: "Revoke an API credential"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /developer/oauth/consent:
    post:
      operationId: decideOauthConsent
      summary: "Record a person's decision on an agent authorization request"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /developer/sandbox:
    get:
      operationId: getDeveloperSandbox
      summary: "Read the isolated sandbox workspace"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createDeveloperSandbox
      summary: "Create the isolated sandbox workspace"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /developer/webhooks:
    get:
      operationId: listWebhooks
      summary: "List webhook endpoints and delivery health"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createWebhook
      summary: "Register a webhook endpoint; the signing secret is returned once"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /developer/webhooks/{webhookId}:
    patch:
      operationId: updateWebhook
      summary: "Pause, resume or re-scope a webhook endpoint"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: deleteWebhook
      summary: "Remove a webhook endpoint"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /experiments:
    get:
      operationId: listExperiments
      summary: "List hypotheses and their cell coverage"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: launchExperiment
      summary: "Launch a controlled experiment over existing variants"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /experiments/{experimentId}:
    get:
      operationId: getExperiment
      summary: "Read one experiment, its locked cells and the current evidence label"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /experiments/{experimentId}/stop:
    post:
      operationId: stopExperiment
      summary: "Stop an experiment early. Observed cells complete, unobserved cells cancel, observations are kept."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /expert-reviews:
    get:
      operationId: listExpertReviews
      summary: "List expert offerings and the workspace's review requests"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createExpertReviewDraft
      summary: "Prepare a draft expert review. No capacity is held and no payment is taken."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /expert-reviews/waitlist:
    get:
      operationId: listExpertWaitlist
      summary: "Read waitlist entries and offers"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: joinExpertWaitlist
      summary: "Join the waitlist for a full review option"
      responses: { '201': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: leaveExpertWaitlist
      summary: "Leave the waitlist"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /expert-reviews/{reviewId}:
    patch:
      operationId: updateExpertReview
      summary: "Update a review request the workspace owns"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: cancelExpertReview
      summary: "Cancel a review request under the published refund policy"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /expert-reviews/{reviewId}/findings:
    get:
      operationId: getExpertFindings
      summary: "Read delivered expert findings"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: addExpertFinding
      summary: "Reviewer-only: add a finding to an assigned review"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /integration-health:
    get:
      operationId: getIntegrationHealth
      summary: "Publishing-destination health, permissions and repair actions"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /lineage:
    get:
      operationId: getLineage
      summary: "Saved lineage edges from source to outcome"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /notification-preferences:
    get:
      operationId: getNotificationPreferences
      summary: "Read alert preferences"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    put:
      operationId: replaceNotificationPreferences
      summary: "Replace alert preferences"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /outcomes:
    get:
      operationId: getWorkspaceOutcomes
      description: Requires v15:read. Manual observations only; current dashboard aggregates are limited to 500 recent observations and disclose truncation.
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: addManualOutcome
      summary: Save customer-reported results for a confirmed publication
      description: Requires a live v15:draft credential or a same-origin signed-in session. Records are always manually attributed, never promoted to authenticated provider metrics. The observation and local recommendation records commit together. Retain the same key and payload on an uncertain retry; changed input with the same key returns 409. Legacy requests without a key do not receive retry deduplication.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, pattern: '^[A-Za-z0-9._:-]{8,128}$' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManualOutcome' }
      responses:
        '200': { description: Exact saved snapshot replay; replayed is true and evidence/recommendation are null. }
        '201': { description: New report and its derived local records committed; replayed is false. }
        '400': { description: Invalid fields, metrics, publication or save-attempt key. }
        '403': { description: Missing scope, invalid browser origin or sandbox credential. }
        '409': { description: Save-attempt payload changed or the saved report is no longer available. }
        '413': { description: Request body exceeds 16 KB. }
        '503': { description: Save is unconfirmed. Retry the unchanged request with the same key. }
  /outcomes/history:
    get:
      operationId: getOutcomeHistory
      description: Requires v15:read. Freshly authorized cursor pages of saved manual/provider-labelled observations, not live YouTube results. Cursor ownership and filters are revalidated on every request.
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - { name: cursor, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: publicationId, in: query, schema: { type: string } }
        - { name: platform, in: query, schema: { type: string, enum: [youtube, tiktok, instagram] } }
        - { name: from, in: query, description: Inclusive UTC instant., schema: { type: string, format: date-time } }
        - { name: until, in: query, description: Exclusive UTC instant., schema: { type: string, format: date-time } }
      responses:
        '200': { description: Bounded observations plus page.currentCursor, page.nextCursor and page.hasMore. }
        '400': { description: Invalid or changed cursor filters. }
  /outcomes/learning:
    get:
      operationId: listLearningProposals
      summary: "Read bounded learning proposals from saved observations"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: proposeLearning
      summary: "Recompute bounded learning proposals from saved observations"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /outcomes/learning/draft:
    post:
      operationId: draftReleaseLearning
      summary: "Create a draft campaign and hypothesis from a bounded proposal"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /outcomes/performance:
    get:
      operationId: getPerformanceReport
      summary: Full-history performance totals with a bounded page of posts
      description: Requires v15:read. Counts each confirmed publication once using its latest saved observation at the report watermark. Results-updated periods filter that observation date, not views earned during a period. Totals cover all matching posts, not just the current page. Missing counters are null; totals are integer strings. Newly appended observations require refresh; withdrawn publications are excluded on every request. Does not read live YouTube data or create derived experiment scores.
      parameters:
        - $ref: '#/components/parameters/WorkspaceId'
        - { name: days, in: query, schema: { type: integer, enum: [0, 7, 30, 90], default: 30 } }
        - { name: platform, in: query, schema: { type: string, enum: ['', youtube, tiktok, instagram] } }
        - { name: sort, in: query, schema: { type: string, enum: [recent, views], default: recent } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string, maxLength: 2048 } }
      responses:
        '200': { description: basis, asOf, totalPosts, allPosts, totals with value/reported fields, bounded rows, and page.currentCursor/nextCursor/hasMore. }
        '400': { description: Invalid report filters or a cursor bound to different filters. }
        '401': { description: Current authentication is required. }
        '403': { description: Missing scope or workspace access. }
        '503': { description: The report could not be read; no partial totals are returned. }
  /outcomes/personas:
    get:
      operationId: getPersonaPerformance
      summary: "Saved results grouped by persona, using the latest observation per publication. Observational, never causal."
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /pattern-atoms/{atomId}:
    patch:
      operationId: updatePatternAtom
      summary: "Edit one saved pattern atom's label, explanation or control"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /patterns:
    get:
      operationId: listPatternAnalyses
      summary: "List owned Pattern DNA analyses with cursor paging"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /patterns/compare:
    get:
      operationId: comparePatterns
      summary: "Compare two owned patterns by category and subtype. Structural only, not a prediction."
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /patterns/{patternId}:
    get:
      operationId: getPatternAnalysis
      summary: "Read one owned Pattern DNA analysis and its atoms"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /patterns/{patternId}/shares:
    get:
      operationId: listPatternBriefShares
      summary: "List public brief links for a pattern. The link itself is never recoverable."
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createPatternBriefShare
      summary: "Human-only: publish an unguessable, revocable public brief link with no private source media"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /patterns/{patternId}/shares/{shareId}:
    delete:
      operationId: revokePatternBriefShare
      summary: "Revoke a public brief link immediately"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /product-brief:
    get:
      operationId: getProductBrief
      summary: "Read the active product brief"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    put:
      operationId: replaceProductBrief
      summary: "Replace the active product brief"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /protected-elements:
    get:
      operationId: listProtectedElements
      summary: "List protected brand elements"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createProtectedElement
      summary: "Protect a brand element from reuse"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /protected-elements/{elementId}:
    delete:
      operationId: deleteProtectedElement
      summary: "Remove a protected brand element"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /recommendations:
    get:
      operationId: listRecommendations
      summary: "List outcome-backed recommendations by status"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /recommendations/{recommendationId}:
    patch:
      operationId: decideRecommendation
      summary: "Accept or dismiss a recommendation"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /recommendations/{recommendationId}/accept:
    post:
      operationId: acceptRecommendation
      summary: "Record that a creator will use this next test. Starts nothing by itself."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /recommendations/{recommendationId}/dismiss:
    post:
      operationId: dismissRecommendation
      summary: "Dismiss a recommendation"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /references:
    get:
      operationId: listReferences
      responses: { '200': { $ref: '#/components/responses/Collection' } }
    post:
      operationId: saveReference
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /references/upload:
    post:
      operationId: createReferenceUpload
      summary: "Create a bounded upload target for an owned reference"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /references/{referenceId}:
    get:
      operationId: getReference
      summary: "Read a saved reference and its rights state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: deleteReference
      summary: "Delete a reference and purge its derived data"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /references/{referenceId}/analysis:
    get:
      operationId: getReferenceAnalysis
      summary: "Read the saved analysis for a reference"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: startReferenceAnalysis
      summary: "Start an approved, metered analysis of a saved reference"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /signals:
    get:
      operationId: listSignals
      summary: "List evidence-backed signals"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /signals/calibration:
    get:
      operationId: getSignalCalibration
      summary: "Read the workspace signal-weight calibration record"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: fitSignalCalibration
      summary: "Fit workspace signal weights from saved outcomes; stored scores never change"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /signals/{signalId}:
    get:
      operationId: getSignal
      summary: Read a signal and its evidence
      parameters:
        - $ref: '#/components/parameters/SignalId'
      responses:
        '200': { $ref: '#/components/responses/Object' }
  /signals/{signalId}/dismiss:
    post:
      operationId: dismissSignal
      summary: "Dismiss a signal from the workspace feed"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /signals/{signalId}/save:
    post:
      operationId: saveSignalReference
      summary: "Save a signal as a workspace reference"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /source-accounts:
    get:
      operationId: listSourceAccounts
      summary: "List permitted source accounts"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /strategy-coach:
    get:
      operationId: getStrategySession
      summary: "Read the saved strategy session"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: appendStrategySession
      summary: "Append one bounded strategy-coach turn"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /support/trace:
    get:
      operationId: getSupportTrace
      summary: "Trace one resource from source to job, ledger and outcome"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /transformations:
    post:
      operationId: createTransformation
      summary: "Record how a pattern is transformed for the workspace's own use"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /transformations/{transformationId}/originality:
    post:
      operationId: checkTransformationOriginality
      summary: "Run the originality checks for a transformation"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /usage/estimate:
    get:
      operationId: getUsageEstimate
      summary: "Price one rate-card operation or one campaign draft batch. Never reserves or charges."
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /usage/ledger:
    get:
      operationId: getUsageLedger
      summary: "Export append-only usage ledger rows as JSON or CSV. Provider cost is never included."
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /usage/preview:
    get:
      operationId: getUsagePreview
      summary: "Preview a plan change against purchased top-ups"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /watchlists:
    get:
      operationId: listWatchlists
      responses: { '200': { $ref: '#/components/responses/Collection' } }
    post:
      operationId: createWatchlist
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [name, query], properties: { name: { type: string }, query: { type: string }, monthlyUnitCap: { type: integer, minimum: 0 } } } } } }
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /watchlists/{watchlistId}:
    get:
      operationId: getWatchlist
      summary: "Read one watchlist and its refresh state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    patch:
      operationId: updateWatchlist
      summary: "Change a watchlist refresh cadence"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: deleteWatchlist
      summary: "Delete a watchlist and clear its schedule"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /watchlists/{watchlistId}/pause:
    post:
      operationId: pauseWatchlist
      summary: "Pause or resume a watchlist. Resuming does not schedule a refresh."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /watchlists/{watchlistId}/refresh:
    post:
      operationId: refreshWatchlist
      summary: "Run one metered watchlist refresh"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/campaign-goals:
    get:
      operationId: listCampaignGoals
      summary: "List saved campaign briefs"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createCampaignGoal
      summary: "Save a campaign brief against a confirmed context"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/campaign-goals/{goalId}:
    get:
      operationId: getCampaignGoal
      summary: "Read one campaign brief"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/channels/health:
    get:
      operationId: listChannelHealth
      summary: "Last observed health of every publishing destination"
      description: Requires channels:read; API keys may read it. Reports stored observations only. Health is written when an account is connected, when a token refresh succeeds or fails and when a publication finishes; this endpoint never probes the provider and spends nothing. A destination with no observation reports unknown, which blocks Momentum publishing until one is recorded. Returns at most 100 destinations ordered by platform, display name and id, with no pagination or filters, and is not filtered by credential environment.
      responses:
        '200':
          description: Stored health for each destination in the workspace.
          content:
            application/json:
              schema: { type: object, required: [channels], properties: { channels: { type: array, items: { $ref: '#/components/schemas/ChannelHealth' } } } }
        '400': { description: Malformed workspaceId. }
        '403': { description: Missing channels:read scope, or the credential belongs to another workspace. }
        '503': { description: Runtime unavailable. }
  /workspaces/{workspaceId}/channels/{channelId}/health:
    get:
      operationId: getChannelHealth
      summary: "Last observed health of one publishing destination"
      description: Requires channels:read; API keys may read it. Same stored observation as the list, for a single destination. No provider call and no spend.
      responses:
        '200':
          description: Stored health for that destination.
          content:
            application/json:
              schema: { type: object, required: [channel], properties: { channel: { $ref: '#/components/schemas/ChannelHealth' } } }
        '400': { description: Malformed workspaceId. }
        '404': { description: "channel_not_found: the channelId is malformed or names no destination in this workspace." }
        '503': { description: Runtime unavailable. }
  /workspaces/{workspaceId}/context-versions:
    get:
      operationId: listContextVersions
      summary: "List product context versions"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/context-versions/{contextId}:
    get:
      operationId: getContextVersion
      summary: "Read one product context version and its confirmation basis"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/context-versions/{contextId}/confirm:
    post:
      operationId: confirmContextVersion
      summary: "Human confirmation of product facts in a context version"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/creative/projects:
    get:
      operationId: listCreativeProjects
      summary: "List saved Create projects"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createCreativeProject
      summary: "Create a Create project draft from a goal"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/creative/projects/{projectId}:
    get:
      operationId: getCreativeProject
      summary: "Read a Create project or one of its revisions"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    patch:
      operationId: editCreativeProject
      summary: "Apply typed local edits to a Create project"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    delete:
      operationId: deleteCreativeProject
      summary: "Delete a Create project"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/creative/projects/{projectId}/alignment:
    get:
      operationId: getLyricAlignment
      summary: "Alignment state, confidence and review list for a project's lines"
      description: Requires create:read. Create has its own scopes; v15:draft does not inherit project access. Read-only. Times are in ticks at 240000 per second. The report's own revision counter counts alignment passes and is not the document revision.
      parameters:
        - { name: revision, in: query, required: false, schema: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }, description: Read an earlier revision instead of the current one. }
      responses:
        '200':
          description: "projectId, revision and alignment: schemaVersion, clock, transcriptSourceId, audioSourceId, totals (lines, unmatched, estimated, reviewed, userAnchored), reviewRequiredCueIds and lines[] with cueId, text, state (unmatched | estimated | reviewed | user_anchored), method, confidence, start, end, wordTiming, reviewRequired and mismatch."
          content:
            application/json:
              schema: { type: object, required: [projectId, revision, alignment], properties: { projectId: { type: string }, revision: { type: string }, alignment: { type: object, additionalProperties: true } } }
        '400': { description: Malformed projectId or revision. }
        '404': { description: "PROJECT_NOT_FOUND: no such project or revision in this workspace." }
        '503': { description: "CREATE_UNAVAILABLE." }
    post:
      operationId: alignLyrics
      summary: "Align a project's lyrics to a transcript already stored in the project"
      description: "Requires create:write. Local computation over stored project data only: no transcription, no provider call, no spend. Lines that are locked, reviewed or user_anchored are preserved unchanged and bound the windows around them; matched lines become estimated with a coverage confidence, and lines below half coverage become unmatched with no timing. Per-word timings are stored only when every display token matched and the line has at most 64 tokens, so karaoke never animates guessed boundaries. The pass appends a new revision. Audio-to-text itself is a browser-session route and is not part of this API."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [baseRevision, transcriptSourceId], properties: { baseRevision: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }, transcriptSourceId: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' } } }
      responses:
        '200':
          description: The updated project, its new revision, updatedAt and the recomputed alignment report.
          content: { application/json: { schema: { type: object, required: [project, revision, updatedAt, alignment], properties: { project: { type: object, additionalProperties: true }, revision: { type: string }, updatedAt: { type: string, format: date-time }, alignment: { type: object, additionalProperties: true } } } } }
        '400': { description: "INVALID_INPUT (body, path or Idempotency-Key) or EDIT_INVALID (the chosen source is not a document, has no word timings, belongs to another recording, or the project has no lyrics)." }
        '404': { description: "PROJECT_NOT_FOUND." }
        '409': { description: "REVISION_CONFLICT (baseRevision is not the current revision) or IDEMPOTENCY_CONFLICT (the same key was used for a different edit). Replaying the same key and edit returns the original result." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/projects/{projectId}/clip-candidates:
    get:
      operationId: listClipCandidates
      summary: "Sentence-bounded clip candidates from a stored transcript, with context-risk flags"
      description: "Requires create:read. Pure local computation over the stored transcript: no provider call and no spend. Candidates begin and end on sentence boundaries, carry neighbouring context, and flag cuts that could change meaning. The score is a boundary and duration heuristic — it is not a prediction of how a clip will perform. Overlapping and near-duplicate candidates are removed and the rest are returned in start order. Unknown query parameters are rejected."
      parameters:
        - { name: sourceId, in: query, required: true, schema: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }, description: A transcript source already in the project. }
        - { name: count, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 10, default: 3 } }
        - { name: minSeconds, in: query, required: false, schema: { type: number, minimum: 1, maximum: 600, default: 15 } }
        - { name: maxSeconds, in: query, required: false, schema: { type: number, minimum: 1, maximum: 600, default: 90 }, description: Must be at least minSeconds. }
        - { name: targetSeconds, in: query, required: false, schema: { type: number, minimum: 1, maximum: 600, default: 45 }, description: Clamped into the min/max range. }
        - { name: revision, in: query, required: false, schema: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' } }
      responses:
        '200':
          description: "projectId, revision, sourceId, a fixed review note, and candidates[] with id, title, reason, sourceId, audioSourceId, start and end in ticks, durationSeconds, text, firstWords, lastWords, flags (starts_mid_thought, contrast_at_start, caveat_follows, negation_nearby, contains_negation, conditional, ends_mid_sentence), contextBefore, contextAfter, sentences and score. An empty list is a normal result."
          content: { application/json: { schema: { type: object, required: [projectId, revision, sourceId, candidates, note], properties: { projectId: { type: string }, revision: { type: string }, sourceId: { type: string }, note: { type: string }, candidates: { type: array, items: { type: object, additionalProperties: true } } } } } }
        '400': { description: "INVALID_INPUT (missing sourceId, a value out of range, or an unknown query parameter) or SOURCE_RANGE_INVALID (the source has no timed lines, or the requested length range is impossible)." }
        '404': { description: "PROJECT_NOT_FOUND, or SOURCE_NOT_FOUND when that transcript is not in the project." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/projects/{projectId}/edit-proposals:
    post:
      operationId: proposeCreativeEdit
      summary: "Plan an edit without applying it"
      description: "Requires create:write. Ask for exactly one of a natural-language request, typed operations, a starter card or a saved recipe. The request branch uses a deterministic local rule planner, not a model, and an unrecognised request returns no operations rather than inventing one. The proposal is validated by applying it to the stored document in memory, so nothing is written to the project and a later commit cannot introduce an edit that the schema, locks or rights checks would refuse. Nothing is generated, published or spent. A proposal expires 24 hours after it is planned. There is no list endpoint for proposals."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditProposalRequest' }
      responses:
        '201':
          description: "proposal: id, projectId, baseRevision, the resolved operations, explanation, preservation (plain-language guarantees, always ending with the no-media/no-publish/no-spend line), notes, invalidates (downstream outputs this would stale), requiresNewMedia (always false), status open, committedRevision null, expiresAt and createdAt."
          content: { application/json: { schema: { type: object, required: [proposal], properties: { proposal: { type: object, additionalProperties: true } } } } }
        '400': { description: "INVALID_INPUT: schema, path or Idempotency-Key, including asking for none or more than one of request, operations, starter and recipe." }
        '403': { description: "WORKSPACE_UNAVAILABLE: the proposal could not be saved against this workspace." }
        '404': { description: "PROJECT_NOT_FOUND, RECIPE_NOT_FOUND, RECIPE_VERSION_NOT_FOUND, or RECIPE_INCOMPATIBLE for an unknown starter card." }
        '409': { description: "REVISION_CONFLICT: the project moved since baseRevision." }
        '422': { description: "NO_EDIT_PLANNED (the request planned no operations), RECIPE_INCOMPATIBLE (the starter or template cannot apply to this project) or EDIT_INVALID (the resolved operations were refused)." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/projects/{projectId}/edit-proposals/{proposalId}/commit:
    post:
      operationId: commitCreativeEdit
      summary: "Commit a planned edit against the exact revision it was planned on"
      description: "Requires create:write. Applies the proposal's stored operations and appends a revision. A commit against a moved revision is refused and the proposal is marked conflicted. Committing an already-committed proposal returns its committed revision without applying anything again."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [expectedBaseRevision], properties: { expectedBaseRevision: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' } } }
      responses:
        '200':
          description: The updated project, its new revision, updatedAt and the proposal, now committed and carrying committedRevision.
          content: { application/json: { schema: { type: object, required: [project, revision, updatedAt, proposal], properties: { project: { type: object, additionalProperties: true }, revision: { type: string }, updatedAt: { type: string, format: date-time }, proposal: { type: object, additionalProperties: true } } } } }
        '400': { description: "INVALID_INPUT, or EDIT_INVALID if applying the stored operations fails at commit time." }
        '404': { description: "PROPOSAL_NOT_FOUND or PROJECT_NOT_FOUND." }
        '409': { description: "REVISION_CONFLICT (the proposal was planned against a different revision, or the project moved), PROPOSAL_EXPIRED (past the 24-hour life of a proposal), IDEMPOTENCY_CONFLICT, or SOURCE_NOT_VERIFIED when the proposal adds a source that is not registered byte-identically." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/projects/{projectId}/evidence:
    get:
      operationId: getCreativeEvidence
      summary: "Line-to-quote source evidence and kept excerpts"
      description: Requires create:read. Read-only. Every storyboard or caption line that came from a document keeps the exact source span it quotes; a line without one is listed as unsupported rather than assumed grounded, and an edited line that no longer repeats its quote is flagged for a human check. Start and end here are character offsets into the source text, not ticks.
      parameters:
        - { name: revision, in: query, required: false, schema: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' } }
      responses:
        '200':
          description: "projectId, revision and evidence: lines[] (cueId, sceneId, text, sourceId, sourceName, start, end, page, quote, matchesQuote), claims[], unsupportedCueIds and reviewRequiredCueIds."
          content: { application/json: { schema: { type: object, required: [projectId, revision, evidence], properties: { projectId: { type: string }, revision: { type: string }, evidence: { type: object, additionalProperties: true } } } } }
        '400': { description: Malformed projectId or revision. }
        '404': { description: "PROJECT_NOT_FOUND." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/projects/{projectId}/restore:
    post:
      operationId: restoreCreativeProject
      summary: "Restore a Create project to an earlier revision"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/creative/projects/{projectId}/undo:
    get:
      operationId: getUndoState
      summary: "Whether the project can currently be undone or redone"
      description: Requires create:read. Read-only.
      responses:
        '200':
          description: canUndo and canRedo.
          content:
            application/json:
              schema: { type: object, required: [canUndo, canRedo], properties: { canUndo: { type: boolean }, canRedo: { type: boolean } } }
        '400': { description: Malformed projectId. }
        '404': { description: "PROJECT_NOT_FOUND." }
        '503': { description: "CREATE_UNAVAILABLE." }
    post:
      operationId: stepCreativeHistory
      summary: "Undo or redo a step. Each step appends a revision; history is never rewritten."
      description: "Requires create:write. Undo and redo append a new revision that restores an earlier document and records what it set aside, so nothing is deleted and a teammate's later work is never silently discarded. Earlier versions stay readable. Note that replaying an Idempotency-Key here returns the revision that key first created without comparing the rest of the request, so do not reuse a key for a different step."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [direction, expectedRevision], properties: { direction: { type: string, enum: [undo, redo] }, expectedRevision: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' } } }
      responses:
        '200':
          description: The restored project, the newly appended revision and updatedAt.
          content:
            application/json:
              schema: { type: object, required: [project, revision, updatedAt], properties: { project: { type: object, additionalProperties: true }, revision: { type: string }, updatedAt: { type: string, format: date-time } } }
        '400': { description: "INVALID_INPUT: body, path or Idempotency-Key." }
        '404': { description: "PROJECT_NOT_FOUND, including a current revision with no history record." }
        '409': { description: "REVISION_CONFLICT (expectedRevision is not the current revision), NOTHING_TO_UNDO or NOTHING_TO_REDO." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/recipes:
    get:
      operationId: listCreativeRecipes
      summary: "Saved styles and templates available to the caller, plus the starter catalog"
      description: Requires create:read. Lists up to 200 active recipes that are shared with the workspace or owned by the caller, newest first. Looks saved before recipe versioning stay visible and usable, marked legacy with a single version and no version history; a legacy entry cannot be read or versioned through the recipe paths below. Starters are the built-in catalog, each reporting whether the browser can currently finish it.
      responses:
        '200':
          description: "recipes[] (id, kind, name, visibility, ownedByViewer, latestVersion, updatedAt, legacy) and starters[] (id, name, category, promise, delivers, operators, availability ready | partial | unavailable, label, missing)."
          content:
            application/json:
              schema: { type: object, required: [recipes, starters], properties: { recipes: { type: array, items: { $ref: '#/components/schemas/RecipeSummary' } }, starters: { type: array, items: { type: object, additionalProperties: true } } } }
        '503': { description: "CREATE_UNAVAILABLE." }
    post:
      operationId: saveCreativeRecipe
      summary: "Save a style or template as version 1"
      description: "Requires create:write. A recipe definition is bounded data — palette, grade, motion, mix and, for templates, replaceable slots. It never contains source identifiers, file names, caption or title wording, fetch URLs or custom LUT files. Versions are immutable once stored. A new recipe defaults to private. A workspace keeps at most 200 recipes. Replaying an Idempotency-Key returns the recipe that key first created without comparing the rest of the request."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SaveRecipeRequest' }
      responses:
        '201':
          description: The saved recipe at version 1.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecipeDetail' }
        '400': { description: "INVALID_INPUT: definition schema, name, visibility or Idempotency-Key." }
        '409': { description: "RECIPE_LIMIT: the workspace already holds 200 saved recipes." }
        '422': { description: "RECIPE_INCOMPATIBLE: the definition needs an effect with no browser finish route." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/recipes/{recipeId}:
    get:
      operationId: getCreativeRecipe
      summary: "Read a recipe at its latest or a named version"
      description: Requires create:read. Readable when the recipe is shared with the workspace or owned by the caller; a recipe private to someone else is reported as not found rather than forbidden. The version list carries the 100 most recent versions, newest first.
      parameters:
        - { name: version, in: query, required: false, schema: { type: integer, minimum: 1 }, description: Read an immutable earlier version instead of the latest. }
      responses:
        '200':
          description: The recipe, the version read, its definition, the operators it needs, the definition hash and the version list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecipeDetail' }
        '400': { description: Malformed recipeId. }
        '404': { description: "RECIPE_NOT_FOUND (missing, archived or private to another member) or RECIPE_VERSION_NOT_FOUND." }
        '503': { description: "CREATE_UNAVAILABLE." }
    delete:
      operationId: archiveCreativeRecipe
      summary: "Archive a recipe. Projects that already applied it keep their pinned version."
      description: Requires create:write. Archives rather than erases, and only the author may do it — for anyone else, and for an already-archived recipe, the result is not found rather than forbidden. No Idempotency-Key is read. A second call reports not found.
      responses:
        '200':
          description: archived, recipeId and a note that pinned versions are untouched.
          content:
            application/json:
              schema: { type: object, required: [archived, recipeId, note], properties: { archived: { type: boolean }, recipeId: { type: string }, note: { type: string } } }
        '400': { description: Malformed recipeId. }
        '404': { description: "RECIPE_NOT_FOUND: no active recipe of yours with that id." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/creative/recipes/{recipeId}/versions:
    post:
      operationId: saveCreativeRecipeVersion
      summary: "Publish an immutable new version of a recipe"
      description: "Requires create:write. Each call inserts a new version; a stored version is never rewritten, and a project pins the exact version it applied, so publishing a new version never changes an existing project. Only the author may add a version, rename the recipe or change its visibility — private means visible only to the author, workspace means visible to every active member, who may read and apply it but must save their own copy to change it. A recipe's kind is frozen: a style cannot become a template. Omitting name or visibility leaves the current value. A recipe keeps at most 100 versions. Replaying an Idempotency-Key returns the version that key first created without comparing the rest of the request."
      parameters:
        - { $ref: '#/components/parameters/CreateIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [definition, expectedVersion], properties: { definition: { $ref: '#/components/schemas/RecipeDefinition' }, expectedVersion: { type: integer, minimum: 1, maximum: 100000, description: The version you believe is current; the new version is this plus one. }, name: { type: string, minLength: 1, maxLength: 80 }, visibility: { type: string, enum: [private, workspace] } } }
      responses:
        '201':
          description: The recipe at its new version.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecipeDetail' }
        '400': { description: "INVALID_INPUT: definition schema, expectedVersion out of range, recipeId or Idempotency-Key." }
        '403': { description: "RECIPE_FORBIDDEN: only the author can save a new version." }
        '404': { description: "RECIPE_NOT_FOUND." }
        '409': { description: "RECIPE_CONFLICT (the recipe is already at a different version — reload before saving), RECIPE_INCOMPATIBLE (the definition's kind differs from the recipe's) or RECIPE_LIMIT (100 versions)." }
        '422': { description: "RECIPE_INCOMPATIBLE: the definition needs an effect with no browser finish route." }
        '503': { description: "CREATE_UNAVAILABLE." }
  /workspaces/{workspaceId}/deletion-requests:
    get:
      operationId: listDeletionRequests
      summary: "Workspace deletion requests and the cancellation window"
      description: "Human-only: this path needs a signed-in browser session and a workspace owner. Sending an Authorization header is refused with SESSION_REQUIRED, so API keys and connected agents cannot read it. Readable while the workspace is frozen or being erased, and afterwards by the person who filed the request, who then sees only their own. Returns the 10 most recent requests."
      parameters:
        - { name: inventory, in: query, required: false, schema: { type: string, enum: ['1'] }, description: Set to 1 to include a per-table inventory with live row counts and what happens to each on deletion. Expensive, so it is opt-in, and it is omitted for a caller whose membership is already gone. }
      responses:
        '200':
          description: "deletionRequests[], gracePeriodHours (72) and, when asked for, inventory[] of table, category, rows, onDeletion and retainedBasis."
          content:
            application/json:
              schema: { type: object, required: [deletionRequests, gracePeriodHours], properties: { deletionRequests: { type: array, items: { $ref: '#/components/schemas/DeletionRequest' } }, gracePeriodHours: { type: number }, inventory: { type: array, items: { type: object, additionalProperties: true } } } }
        '401': { description: "UNAUTHORIZED: sign in to manage workspace privacy." }
        '403': { description: "SESSION_REQUIRED (a credential was presented) or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND, which also covers a malformed id and another tenant's workspace." }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
    post:
      operationId: scheduleWorkspaceDeletion
      summary: "Human-only: schedule this workspace for deletion after a 72-hour cancellation window"
      description: "Needs a signed-in workspace owner on a same-origin browser session with a sign-in from the last 15 minutes; a credentialed request is refused with SESSION_REQUIRED. The freeze is immediate: from this call the workspace stops accepting new work and signed media links stop resolving. Nothing is erased until the grace period ends, and the request can be cancelled until then. After the window a leased purge waits for accepted work to finish, revokes credentials, connections and grants, deletes rows and stored objects, then writes a receipt. Not everything is erased — billing and tax records, production-spend audit rows, sources another workspace still references, security and support records and earlier privacy receipts are retained with a stated basis, and workflow history, queue messages, provider-side logs and anything already published are outside this workflow. Your sign-in account and your other workspaces are untouched."
      parameters:
        - { $ref: '#/components/parameters/PrivacyIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [confirmation], properties: { confirmation: { type: string, minLength: 1, maxLength: 200, description: The workspace name, typed exactly; it is compared after trimming and is case-sensitive. } } }
      responses:
        '200': { description: Replay of an earlier request with the same Idempotency-Key. }
        '202':
          description: The workspace is frozen and scheduled; the message names the moment the grace period ends.
          content:
            application/json:
              schema: { type: object, required: [deletionRequest, message], properties: { deletionRequest: { $ref: '#/components/schemas/DeletionRequest' }, message: { type: string } } }
        '400': { description: "IDEMPOTENCY_KEY_REQUIRED, INVALID_REQUEST (absent, malformed, over 2 KB, or breaking the schema — a missing, non-string or over-long confirmation, or any unknown property) or CONFIRMATION_MISMATCH, which names the expected field." }
        '401': { description: "UNAUTHORIZED, or REAUTH_REQUIRED when the session's sign-in is older than 15 minutes." }
        '403': { description: "SESSION_REQUIRED, ORIGIN_REQUIRED, or OWNER_REQUIRED — the latter also when the workspace is no longer active, which is what a fresh key against an already-frozen workspace returns." }
        '404': { description: "WORKSPACE_NOT_FOUND." }
        '409': { description: "IDEMPOTENCY_CONFLICT or DELETION_ALREADY_SCHEDULED." }
        '415': { description: Content-Type is not application/json. }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
  /workspaces/{workspaceId}/deletion-requests/{requestId}:
    get:
      operationId: getDeletionRequest
      summary: "Read one deletion request, its phase and its receipt"
      description: "Human-only: a signed-in workspace owner; a credentialed request is refused with SESSION_REQUIRED. Readable after the purge as well, so the person who filed the request keeps access to their receipt once their membership is gone; such a caller may read only requests they filed."
      responses:
        '200':
          description: The deletion request.
          content:
            application/json:
              schema: { type: object, required: [deletionRequest], properties: { deletionRequest: { $ref: '#/components/schemas/DeletionRequest' } } }
        '401': { description: "UNAUTHORIZED." }
        '403': { description: "SESSION_REQUIRED or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND, or DELETION_NOT_FOUND for a malformed id or one that is not yours to read." }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
  /workspaces/{workspaceId}/deletion-requests/{requestId}/cancel:
    post:
      operationId: cancelWorkspaceDeletion
      summary: "Human-only: stop a scheduled deletion and reactivate the workspace"
      description: "Needs a signed-in workspace owner on a same-origin browser session; a credentialed request is refused with SESSION_REQUIRED. Deliberately does not require a recent sign-in — stopping a deletion must never be harder than starting one — and reads no Idempotency-Key. Send an empty JSON object. Cancelling restores the workspace to active; because nothing is erased during the grace period, there is nothing to restore beyond access. Cancelling an already-cancelled request returns the existing record. Once the grace period ends or the purge begins there is no cancel and no undo."
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, properties: {} }
      responses:
        '200':
          description: The cancelled request and a confirmation message.
          content:
            application/json:
              schema: { type: object, required: [deletionRequest, message], properties: { deletionRequest: { $ref: '#/components/schemas/DeletionRequest' }, message: { type: string } } }
        '400': { description: "INVALID_REQUEST: absent, malformed, over 2 KB, or breaking the schema — this body takes no properties, so any is refused." }
        '401': { description: "UNAUTHORIZED." }
        '403': { description: "SESSION_REQUIRED, ORIGIN_REQUIRED or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND or DELETION_NOT_FOUND." }
        '409': { description: "GRACE_PERIOD_ENDED: the window has closed, or the purge has already claimed the request." }
        '415': { description: Content-Type is not application/json. }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
  /workspaces/{workspaceId}/exports:
    get:
      operationId: listWorkspaceExports
      summary: "Recent workspace export jobs"
      description: "Human-only: a signed-in workspace owner on an active workspace; a credentialed request is refused with SESSION_REQUIRED, so API keys and connected agents cannot read it. Returns the 10 most recent jobs, newest first, with no pagination. A download link is never included here; ask for one on a single export."
      responses:
        '200':
          description: The recent export jobs.
          content:
            application/json:
              schema: { type: object, required: [exports], properties: { exports: { type: array, items: { $ref: '#/components/schemas/WorkspaceExport' } } } }
        '401': { description: "UNAUTHORIZED." }
        '403': { description: "SESSION_REQUIRED or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND, which also covers a frozen or deleted workspace." }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
    post:
      operationId: requestWorkspaceExport
      summary: "Human-only: request a workspace export"
      description: "Needs a signed-in workspace owner on a same-origin browser session with a sign-in from the last 15 minutes; a credentialed request is refused with SESSION_REQUIRED. Send an empty JSON object. This only enqueues a job — a scheduled worker builds the archive in resumable slices, so poll the single-export path. The archive is one JSON document holding the rows of every exportable table, with secret columns removed and listed in the manifest, plus an inventory of stored objects by key and size: stored media is listed, not copied. It is explicitly not a point-in-time snapshot, because rows are read in pages while the workspace stays usable. A ready archive stays downloadable for 7 days, then expires. A workspace may request 3 exports per rolling 24 hours and may have only one in flight."
      parameters:
        - { $ref: '#/components/parameters/PrivacyIdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, properties: {} }
      responses:
        '200': { description: Replay of an earlier request with the same Idempotency-Key. }
        '202':
          description: The export was queued.
          content:
            application/json:
              schema: { type: object, required: [export, message], properties: { export: { $ref: '#/components/schemas/WorkspaceExport' }, message: { type: string } } }
        '400': { description: "IDEMPOTENCY_KEY_REQUIRED or INVALID_REQUEST (absent, malformed, over 2 KB, or breaking the schema — this body takes no properties, so any is refused)." }
        '401': { description: "UNAUTHORIZED, or REAUTH_REQUIRED when the session's sign-in is older than 15 minutes." }
        '403': { description: "SESSION_REQUIRED, ORIGIN_REQUIRED or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND." }
        '409': { description: "IDEMPOTENCY_CONFLICT or EXPORT_IN_PROGRESS." }
        '415': { description: Content-Type is not application/json. }
        '429': { description: "EXPORT_LIMIT: 3 exports per workspace per rolling 24 hours. No Retry-After is sent." }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
  /workspaces/{workspaceId}/exports/{exportId}:
    get:
      operationId: getWorkspaceExport
      summary: "Read one export job, optionally minting a short-lived download link"
      description: "Human-only: a signed-in workspace owner on an active workspace; a credentialed request is refused with SESSION_REQUIRED. Reading progress needs only an owner session, but asking for the link additionally needs a sign-in from the last 15 minutes, because the link hands over workspace content. The response is always JSON: the link is a signed URL in the body, never a redirect or the file itself. Each request mints a fresh link valid for 5 minutes, or until the archive's own 7-day expiry if that is sooner, and the link stops working the moment the workspace stops being active. Following it returns the archive as a JSON attachment."
      parameters:
        - { name: download, in: query, required: false, schema: { type: string, enum: ['1'] }, description: Set to 1 to include a download link. Any other value is treated as not asking. }
      responses:
        '200':
          description: "The export job. download is present only when it was asked for, and is null when the archive is not ready, has expired, or signing is unavailable."
          content:
            application/json:
              schema: { type: object, required: [export], properties: { export: { $ref: '#/components/schemas/WorkspaceExport' } } }
        '401': { description: "UNAUTHORIZED, or REAUTH_REQUIRED when a link was asked for and the session's sign-in is older than 15 minutes." }
        '403': { description: "SESSION_REQUIRED or OWNER_REQUIRED." }
        '404': { description: "WORKSPACE_NOT_FOUND, or EXPORT_NOT_FOUND for a malformed id or one that belongs to another workspace." }
        '503': { description: "RUNTIME_UNAVAILABLE or PRIVACY_UNAVAILABLE." }
  /workspaces/{workspaceId}/momentum/cards/{cardId}/clearance:
    get:
      operationId: getCandidateClearance
      summary: "Read rights clearance state for a candidate"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: recordCandidateClearance
      summary: "Record a rights clearance decision for a candidate"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/cards/{cardId}/taste:
    get:
      operationId: explainCandidateTaste
      summary: "Explain how saved preferences relate to a candidate"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/clearance-reviews/{reviewId}:
    get:
      operationId: getClearanceReview
      summary: "Read one clearance review"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/clearance-reviews/{reviewId}/revoke:
    post:
      operationId: revokeClearanceReview
      summary: "Revoke a clearance decision"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks:
    get:
      operationId: listCandidateDecks
      summary: "List candidate decks"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: createCandidateDeck
      summary: "Queue a bounded candidate deck within its declared budget"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}:
    get:
      operationId: getCandidateDeck
      summary: "Read a deck and its generation state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}/cancel:
    post:
      operationId: cancelCandidateDeck
      summary: "Cancel a deck and release unspent reservations"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}/cards:
    get:
      operationId: listCandidateCards
      summary: "Read one bounded page of candidate cards"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}/cards/{cardId}/decisions:
    post:
      operationId: recordCandidateDecision
      summary: "Record a keep, skip, edit or defer decision"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}/cards/{cardId}/decisions/{decisionId}/undo:
    post:
      operationId: undoCandidateDecision
      summary: "Undo an editorial decision"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/decks/{deckId}/retry:
    post:
      operationId: retryCandidateDeck
      summary: "Retry a failed deck within the same declared budget"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/planning-inputs:
    get:
      operationId: getPlanningInputs
      summary: "Read candidates and usable estimates for planning"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/planning-jobs:
    get:
      operationId: listPlanningJobs
      summary: "List planning jobs"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/planning-jobs/{jobId}:
    get:
      operationId: getPlanningJob
      summary: "Read durable planning progress and missing inputs"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans:
    get:
      operationId: listMomentumPlans
      summary: "List saved editorial plans"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: draftMomentumPlan
      summary: "Queue a deterministic editorial plan"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}:
    get:
      operationId: getMomentumPlan
      summary: "Read a plan revision, constraints, estimates and review state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}/optimize:
    post:
      operationId: optimizeMomentumPlan
      summary: "Re-plan a revision under changed constraints"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}/production-grants:
    post:
      operationId: createProductionGrant
      summary: "Human-only: grant bounded production authority for an approved plan"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}/publish-grants:
    get:
      operationId: getPlanPublishing
      summary: "Publishing readiness for a plan: slot states, destination health and permissions issued"
      description: Requires publish:read; API keys may read it. Read-only; nothing is issued or published. Slots report provider_review_pending for every non-YouTube platform, and a destination that would otherwise be allowed is reported as blocked for the same reason, because only YouTube publishing has cleared provider review. Grants are limited to the 20 most recent for the current head revision. Carries no plan copy — no hooks and no theses.
      responses:
        '200':
          description: "plan (id, revision, revisionHash, reviewVersion, approved), slots[], channels[] (ChannelHealth), grants[] (PublishGrant) and maximumGrantHours (currently 24). plan.approved is true only when the head revision's latest whole-plan review is an approval by a person."
          content:
            application/json:
              schema: { type: object, required: [plan, slots, channels, grants, maximumGrantHours], properties: { plan: { type: object, additionalProperties: true }, slots: { type: array, items: { $ref: '#/components/schemas/PublishSlot' } }, channels: { type: array, items: { $ref: '#/components/schemas/ChannelHealth' } }, grants: { type: array, items: { $ref: '#/components/schemas/PublishGrant' } }, maximumGrantHours: { type: number } } }
        '400': { description: Malformed workspaceId or planId, or a stored slot that no longer parses. }
        '404': { description: "plan_not_found: no revision of that plan in this workspace and environment." }
        '503': { description: Runtime unavailable. }
    post:
      operationId: issuePublishGrant
      summary: "Human-only: authorize publishing for named slots of an approved plan revision. Issuing publishes nothing."
      description: "Requires publish:authorize AND a person. An API key that holds the scope passes authorization and is then refused 403 human_review_required; there is no agent or API-key path to issuing a permission. Needs a freshly revalidated same-origin browser session. Issuing records a bounded, revocable permission and spends nothing: the grant binds the workspace and environment, the exact approved revision hash, one platform, one mode, one connected and healthy destination, the issuing person, and per slot the production, render, render approval, approved version hash, stored object etag and variant hash. Expiry is capped at 24 hours, and in schedule mode must fall before the earliest selected posting time. Only one grant may ever exist per workspace, plan revision, platform, mode and person; after it is revoked, expires or is used up, that combination cannot be re-authorized and a new plan version must be approved. The same permission is re-checked when a publication is actually created."
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublishGrantRequest' }
      responses:
        '200': { description: Replay of an earlier request with the same Idempotency-Key; replayed is true and nothing new was issued. }
        '201':
          description: Permission issued.
          content:
            application/json:
              schema: { type: object, required: [grant, replayed], properties: { grant: { $ref: '#/components/schemas/PublishGrant' }, replayed: { type: boolean } } }
        '400': { description: "Missing or malformed Idempotency-Key, invalid body, invalid_grant_expiry (not in the future, more than 24 hours ahead, or not before the earliest scheduled slot) or unsupported_publication (the platform and mode combination is not supported)." }
        '403': { description: "human_review_required (the caller is an API key or agent) or origin_required (a session request from another origin)." }
        '404': { description: "plan_not_found." }
        '409': { description: "idempotency_conflict, provider_review_pending (tiktok or instagram), publish_grant_plan_changed (the revision moved or is not human-approved), publish_grant_duplicate, channel_unavailable, channel_down, channel_health_unknown, slot_not_found, slot_channel_mismatch, slot_not_ready, variant_drift_rejected or variant_unsupported." }
        '413': { description: Request body exceeds 256 KB. }
        '415': { description: Content-Type is not JSON. }
        '503': { description: "write_unconfirmed: the permission may have been recorded but could not be read back. Retry the unchanged request with the same key." }
  /workspaces/{workspaceId}/momentum/plans/{planId}/reviews:
    get:
      operationId: listPlanReviews
      summary: "Read plan review history"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: reviewMomentumPlan
      summary: "Comment on, approve or reject a plan revision. Agents can only comment."
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}/revisions:
    get:
      operationId: diffMomentumPlan
      summary: "Compare two immutable plan revisions"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/plans/{planId}/updates:
    get:
      operationId: streamPlanUpdates
      summary: "Server-sent plan review updates"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/production-grants/{grantId}:
    get:
      operationId: getProductionGrant
      summary: "Read a production grant and its remaining allowance"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/production-grants/{grantId}/revoke:
    post:
      operationId: revokeProductionGrant
      summary: "Revoke a production grant"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/productions:
    post:
      operationId: startProduction
      summary: "Start production for granted slots"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/productions/{productionId}:
    get:
      operationId: getProduction
      summary: "Read production progress and settlement"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/publish-grants:
    get:
      operationId: listPublishGrants
      summary: "List publishing permissions issued in the workspace"
      description: Requires publish:read; API keys may read it. Read-only. Scoped to the credential's workspace and environment, and to v1.6 publish grants only. Keyset pagination newest first.
      parameters:
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 50, default: 25 } }
        - { name: planId, in: query, required: false, schema: { type: string, pattern: '^[a-zA-Z0-9_-]{1,96}$' } }
        - { name: before, in: query, required: false, schema: { type: string, pattern: '^pbg_[a-f0-9]{32}$' }, description: Return permissions created strictly before this one. An unknown but well-formed id returns an empty page. }
      responses:
        '200':
          description: One page of permissions, newest first.
          content:
            application/json:
              schema: { type: object, required: [data, nextBefore], properties: { data: { type: array, items: { $ref: '#/components/schemas/PublishGrant' } }, nextBefore: { type: [string, 'null'] } } }
        '400': { description: Malformed workspaceId, limit, planId or before cursor. }
        '503': { description: Runtime unavailable. }
  /workspaces/{workspaceId}/momentum/publish-grants/{grantId}:
    get:
      operationId: getPublishGrant
      summary: "Read one publishing permission and what remains of it"
      description: Requires publish:read; API keys may read it. Read-only. status is derived at read time, so an active permission past its expiry reads as expired.
      responses:
        '200':
          description: The permission.
          content:
            application/json:
              schema: { type: object, required: [grant], properties: { grant: { $ref: '#/components/schemas/PublishGrant' } } }
        '400': { description: The grantId does not match the pbg_ identifier pattern. }
        '404': { description: "publish_grant_not_found: no such permission in this workspace and environment." }
        '503': { description: Runtime unavailable. }
  /workspaces/{workspaceId}/momentum/publish-grants/{grantId}/revoke:
    post:
      operationId: revokePublishGrant
      summary: "Human-only: revoke a publishing permission. Only the person who issued it can."
      description: "Requires publish:authorize AND a person, on a freshly revalidated same-origin browser session. API keys and agents are refused 403 human_review_required, and so is any other member of the workspace: the check is on the issuer's own identity. A permission that has already been used can still be revoked — that is the point, because it stops publications that have not finished. Revoking does not withdraw anything already published, and does not spend or refund. Revoking an already-revoked permission returns 200 with the existing record. This route does not read an Idempotency-Key."
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false, required: [reason], properties: { reason: { type: string, minLength: 1, maxLength: 500, description: Recorded with the revocation. } } }
      responses:
        '200':
          description: The permission as it now stands, normally revoked and carrying its revocation record.
          content:
            application/json:
              schema: { type: object, required: [grant], properties: { grant: { $ref: '#/components/schemas/PublishGrant' } } }
        '400': { description: Malformed grantId, or a missing, blank, oversized or unknown body field. }
        '403': { description: "human_review_required (not a person, or not the issuer) or origin_required." }
        '404': { description: "publish_grant_not_found." }
        '413': { description: Request body exceeds 256 KB. }
        '415': { description: Content-Type is not JSON. }
        '503': { description: Runtime unavailable. }
  /workspaces/{workspaceId}/momentum/quote:
    get:
      operationId: getProductionQuote
      summary: "Read current usable production estimates"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/momentum/schedule:
    get:
      operationId: getDistributionSchedule
      summary: "Read the saved distribution schedule"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/product-events:
    get:
      operationId: listProductEvents
      summary: "List saved product updates"
      responses: { '200': { $ref: '#/components/responses/Object' } }
    post:
      operationId: recordProductEvent
      summary: "Save a product update with declared evidence"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste:
    get:
      operationId: getTaste
      summary: "Read creative preferences and evidence state"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/learning-runs:
    post:
      operationId: createTasteLearningRun
      summary: "Propose preference changes from saved editorial evidence"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/proposals/{proposalId}/accept:
    post:
      operationId: acceptTasteProposal
      summary: "Human-only: apply a proposed preference change"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/rollback:
    post:
      operationId: rollbackTaste
      summary: "Roll preferences back to an earlier version"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/rules:
    post:
      operationId: createTasteRule
      summary: "Add a creative preference rule"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/rules/{ruleId}:
    post:
      operationId: updateTasteRule
      summary: "Update or retire a creative preference rule"
      responses: { '201': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/rules/{ruleId}/examples:
    get:
      operationId: listTasteRuleExamples
      summary: "Read saved examples behind a preference rule"
      responses: { '200': { $ref: '#/components/responses/Object' } }
  /workspaces/{workspaceId}/taste/versions:
    get:
      operationId: listTasteVersions
      summary: "List preference versions"
      responses: { '200': { $ref: '#/components/responses/Object' } }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: Plotmesh API key }
  parameters:
    WorkspaceId: { name: workspaceId, in: query, schema: { type: string } }
    SignalId: { name: signalId, in: path, required: true, schema: { type: string } }
    IdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9._:-]{8,128}$' } }
    CreateIdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 96, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }, description: "Create accepts a narrower key than the rest of the API: it must start with a letter or digit, allows only letters, digits, dot, underscore and hyphen, and is at most 96 characters." }
    PrivacyIdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 128, pattern: '^[A-Za-z0-9_.:-]{8,128}$' }, description: Scoped to the workspace; reusing a key that another person used returns 409. }
  headers:
    RequestId: { schema: { type: string }, description: Trace identifier for support and logs. }
    UsageEstimated: { schema: { type: integer }, description: Estimated usage units before settlement. }
    UsageSettled: { schema: { type: integer }, description: Settled usage units. }
    CostMax: { schema: { type: number }, description: Maximum authorized USD cost for this response. }
  schemas:
    ManualOutcome:
      type: object
      additionalProperties: false
      required: [publicationId]
      properties:
        workspaceId: { type: string, pattern: '^wrk_[A-Za-z0-9]+$' }
        publicationId: { type: string, pattern: '^pub_[A-Za-z0-9]+$' }
        observedAt: { type: string, format: date-time }
        windowHours: { type: integer, minimum: 0, maximum: 720 }
        metrics:
          type: object
          description: Report at least one observed metric or conversion. Counts must be whole numbers from 0 to 1000000000; other non-percentage values share that upper bound. Percentage rates range from 0 to 100. Omitted/blank values are unknown, not zero. Numeric strings are also accepted by the HTML-form-compatible runtime.
          properties:
            views: { type: integer, minimum: 0 }
            impressions: { type: integer, minimum: 0 }
            likes: { type: integer, minimum: 0 }
            comments: { type: integer, minimum: 0 }
            shares: { type: integer, minimum: 0 }
            saves: { type: integer, minimum: 0 }
            hold3sRate: { type: number, minimum: 0, maximum: 100 }
            averageWatchSeconds: { type: number, minimum: 0 }
            completionRate: { type: number, minimum: 0, maximum: 100 }
            clickThroughRate: { type: number, minimum: 0, maximum: 100 }
        conversions:
          type: object
          properties:
            clicks: { type: integer, minimum: 0 }
            conversions: { type: integer, minimum: 0 }
            revenue: { type: number, minimum: 0 }
        utm:
          type: object
          properties:
            source: { type: string, maxLength: 200 }
            medium: { type: string, maxLength: 200 }
            campaign: { type: string, maxLength: 200 }
            content: { type: string, maxLength: 200 }
            term: { type: string, maxLength: 200 }
        source: { type: string, enum: [customer_import, customer_authorized], description: Both accepted legacy labels are stored as customer_import; this field cannot claim provider authorization. }
    CreateApiKey:
      type: object
      required: [name, environment, scopes]
      properties:
        name: { type: string, minLength: 2, maxLength: 80 }
        environment: { enum: [sandbox, live] }
        scopes: { type: array, items: { enum: ['v15:read', 'v15:draft', 'v15:production:request', 'v15:publish:request'] } }
    ChannelHealth:
      type: object
      description: Last recorded health of one publishing destination. Written from real events only; never a live probe.
      required: [channelId, platform, displayName, connectionStatus, status, publishAllowed]
      properties:
        channelId: { type: string, pattern: '^chn_[a-zA-Z0-9]+$' }
        platform: { type: string }
        displayName: { type: string }
        connectionStatus: { type: string, description: Stored connection state; only "connected" allows publishing. }
        status: { type: string, enum: [ok, degraded, down, unknown], description: "unknown means nothing has been observed yet, which blocks publishing until something is. ok and degraded both allow it." }
        lastCheckedAt: { type: [string, 'null'], format: date-time }
        lastErrorCode: { type: [string, 'null'] }
        publishAllowed: { type: boolean, description: True only when the destination is connected and its status is ok or degraded. }
        blocker: { type: [string, 'null'], enum: [channel_connection_required, channel_down, channel_health_unknown, provider_review_pending, null] }
    PublishSlot:
      type: object
      description: One planned post and how close it is to being publishable.
      required: [slotId, platform, scheduledAt, state]
      properties:
        slotId: { type: string }
        platform: { type: string, enum: [youtube, tiktok, instagram] }
        scheduledAt: { type: string, format: date-time }
        state: { type: string, enum: [provider_review_pending, not_produced, in_production, awaiting_final_approval, ready], description: Every non-YouTube slot reports provider_review_pending, because only YouTube publishing has cleared provider review. }
        productionId: { type: [string, 'null'] }
        renderId: { type: [string, 'null'] }
        approvalId: { type: [string, 'null'] }
        assetHash: { type: [string, 'null'] }
        grantId: { type: [string, 'null'] }
    PublishGrant:
      type: object
      description: A bounded, revocable permission to publish named slots of one approved plan revision. Holding one publishes nothing.
      required: [id, planId, revisionHash, platform, mode, status, usable, maximumSlots, remainingSlots, expiresAt, slots]
      properties:
        id: { type: string, pattern: '^pbg_[a-f0-9]{32}$' }
        planId: { type: string }
        revisionHash: { type: string, pattern: '^[a-f0-9]{64}$' }
        platform: { type: string, enum: [youtube, tiktok, instagram] }
        mode: { type: string, enum: [draft, direct, schedule] }
        channelId: { type: [string, 'null'] }
        principal: { type: object, additionalProperties: true, description: Always a person. An agent can never hold a publishing permission. }
        issuerId: { type: [string, 'null'] }
        status: { type: string, enum: [active, expired, revoked, consumed], description: expired is derived when the permission is read, not stored. }
        usable: { type: boolean, description: Active, unexpired and with slots left. }
        maximumSlots: { type: integer }
        remainingSlots: { type: integer }
        expiresAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        slots: { type: array, items: { type: object, additionalProperties: true }, description: "Per slot: slotId, scheduledAt, productionId, renderId, approvalId, assetHash, variantHash and use, which stays null until a publication consumes that slot." }
        revocation: { type: [object, 'null'], additionalProperties: true, description: Null unless the permission was revoked; then the reason, who revoked it and when. }
    PublishGrantRequest:
      type: object
      additionalProperties: false
      required: [expectedHash, expectedReviewVersion, platform, mode, channelId, slotIds, expiresAt]
      properties:
        expectedHash: { type: string, pattern: '^[a-f0-9]{64}$', description: Hash of the approved head revision this permission binds to. }
        expectedReviewVersion: { type: integer, minimum: 0 }
        platform: { type: string, enum: [youtube, tiktok, instagram], description: Only youtube can succeed today; tiktok and instagram are refused with provider_review_pending. }
        mode: { type: string, enum: [draft, direct, schedule], description: YouTube accepts direct and schedule; draft is refused as unsupported. }
        channelId: { type: string, pattern: '^chn_[a-zA-Z0-9]+$', description: Must be connected and not observed down or unknown. }
        slotIds: { type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: { type: string, pattern: '^[a-zA-Z0-9_-]{1,96}$' } }
        expiresAt: { type: string, format: date-time, description: Must be in the future and at most 24 hours ahead; in schedule mode it must also fall before the earliest selected posting time. }
    WorkspaceExport:
      type: object
      description: One workspace export job.
      required: [id, workspaceId, status, records, bytes, createdAt]
      properties:
        id: { type: string, pattern: '^exp_[a-f0-9]{20}$' }
        workspaceId: { type: string }
        status: { type: string, enum: [queued, running, ready, failed, expired, cancelled] }
        records: { type: integer, description: Rows and stored objects counted so far. }
        bytes: { type: integer, description: Archive bytes so far. The archive may not exceed 512 MiB. }
        createdAt: { type: string, format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }
        expiresAt: { type: [string, 'null'], format: date-time, description: Seven days after completion. }
        errorCode: { type: [string, 'null'], description: On a queued or running job this is a retry note, not a failure. }
        receipt: { type: [object, 'null'], additionalProperties: true, description: "Present once ready. Names every table and stored prefix included, the secret columns and tables excluded with their reason, the archive size, and states plainly that this is not a point-in-time snapshot and that media files are listed but not copied." }
        download: { type: [object, 'null'], additionalProperties: true, description: "Present only when a link was asked for, and null when one cannot be minted. Carries url and expiresAt; the link lasts five minutes." }
    DeletionRequest:
      type: object
      description: One workspace deletion request and where it has got to.
      required: [id, workspaceId, status, phase, requestedAt, graceEndsAt, cancellable]
      properties:
        id: { type: string, pattern: '^wdr_[a-f0-9]{20}$' }
        workspaceId: { type: string }
        status: { type: string, enum: [scheduled, cancelled, purging, completed] }
        phase: { type: string, enum: [waiting, quiesce, revoke, d1, r2, finalize, late_sweep, done], description: A completed request may still be in late_sweep, which re-checks stored objects an hour later. }
        requestedAt: { type: string, format: date-time }
        graceEndsAt: { type: string, format: date-time, description: 72 hours after the request. Nothing is erased before this. }
        cancellable: { type: boolean, description: "Computed when read: still scheduled, and still inside the window." }
        cancelledAt: { type: [string, 'null'], format: date-time }
        purgeStartedAt: { type: [string, 'null'], format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }
        retrying: { type: [string, 'null'], description: While purging, the reason the current phase is being retried. A note, not a failure. }
        receipt: { type: [object, 'null'], additionalProperties: true, description: "Written when the purge finalizes. Records rows and objects removed per table and prefix, what was revoked, which provider revocations were confirmed, what was retained and on what basis, and what this workflow does not erase." }
    EditProposalRequest:
      type: object
      additionalProperties: false
      required: [baseRevision]
      description: "Send exactly one of request, operations, starter or recipe. Sending none, or more than one, is refused."
      properties:
        baseRevision: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$', description: The revision the edit is planned against. }
        request: { type: string, minLength: 1, maxLength: 2000, description: Planned by a deterministic local rule planner, not a model. An unrecognised request plans nothing rather than inventing an edit. }
        operations: { type: array, minItems: 1, maxItems: 100, items: { type: object, additionalProperties: true, required: [op], properties: { op: { type: string } } }, description: Typed edit operations. Each op has its own exact shape and rejects unknown fields. }
        starter: { type: string, pattern: '^[Pp][0-9]{2}$', description: A starter card id from the catalog. A starter carries no slot inputs, because it cannot be combined with recipe. }
        recipe: { type: object, additionalProperties: false, required: [recipeId], properties: { recipeId: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }, version: { type: integer, minimum: 1, maximum: 100000, description: Defaults to the recipe's latest version. }, inputs: { type: object, maxProperties: 12, additionalProperties: { type: object, additionalProperties: false, properties: { sourceId: { type: string }, text: { type: string, minLength: 1, maxLength: 280 } } }, description: "Keyed by template slot id; each value supplies either a sourceId or a text, never both." } } }
    VisualStyle:
      type: object
      additionalProperties: false
      required: [id, name, background, foreground, accent, font, fontSize, captionPosition, uppercase, letterSpacing, contrast, saturation, visualizer]
      properties:
        id: { type: string }
        name: { type: string, maxLength: 80 }
        background: { type: string, pattern: '^#[0-9A-Fa-f]{6}$' }
        foreground: { type: string, pattern: '^#[0-9A-Fa-f]{6}$' }
        accent: { type: string, pattern: '^#[0-9A-Fa-f]{6}$' }
        font: { type: string, enum: [sans, serif, mono] }
        fontSize: { type: number, minimum: 20, maximum: 160 }
        captionPosition: { type: string, enum: [top, center, bottom] }
        uppercase: { type: boolean }
        letterSpacing: { type: number, minimum: -4, maximum: 16 }
        contrast: { type: number, minimum: 0.5, maximum: 2 }
        saturation: { type: number, minimum: 0, maximum: 2 }
        visualizer: { type: string, enum: [none, bars, ring] }
    Grade:
      type: object
      additionalProperties: false
      required: [exposure, contrast, saturation, temperature, tint, lift, gamma, gain, vignette, grain, seed, lut, lutStrength]
      properties:
        exposure: { type: number, minimum: -3, maximum: 3 }
        contrast: { type: number, minimum: 0.5, maximum: 2 }
        saturation: { type: number, minimum: 0, maximum: 2 }
        temperature: { type: number, minimum: -1, maximum: 1 }
        tint: { type: number, minimum: -1, maximum: 1 }
        lift: { type: array, minItems: 3, maxItems: 3, items: { type: number, minimum: -0.25, maximum: 0.25 } }
        gamma: { type: array, minItems: 3, maxItems: 3, items: { type: number, minimum: 0.25, maximum: 4 } }
        gain: { type: array, minItems: 3, maxItems: 3, items: { type: number, minimum: 0.25, maximum: 4 } }
        vignette: { type: number, minimum: 0, maximum: 1 }
        grain: { type: number, minimum: 0, maximum: 0.25 }
        seed: { type: integer, minimum: 0, maximum: 2147483647 }
        lut: { type: [object, 'null'], additionalProperties: false, properties: { kind: { type: string, enum: [builtin, source] }, id: { type: string, enum: [film-warm, teal-shadow, silver, soft-fade, night-blue] }, sourceId: { type: string } }, description: "A saved recipe may only reference a builtin look; a source LUT is a private file and is refused." }
        lutStrength: { type: number, minimum: 0, maximum: 1 }
    ClipMotion:
      type: object
      additionalProperties: false
      required: [preset, strength, direction, easing]
      properties:
        preset: { type: string, enum: [push, pull, ken-burns, drift, parallax] }
        strength: { type: number, minimum: 0, maximum: 1 }
        direction: { type: string, enum: [center, left, right, up, down] }
        easing: { type: string, enum: [linear, ease-in-out] }
    AudioMix:
      type: object
      additionalProperties: false
      required: [ducking, loudness, build, endFadeTicks]
      properties:
        ducking: { type: [object, 'null'], additionalProperties: false, required: [depthDb, thresholdDb, attackMs, releaseMs], properties: { depthDb: { type: number, minimum: -24, maximum: 0 }, thresholdDb: { type: number, minimum: -60, maximum: -10 }, attackMs: { type: number, minimum: 1, maximum: 500 }, releaseMs: { type: number, minimum: 20, maximum: 3000 } } }
        loudness: { type: [object, 'null'], additionalProperties: false, required: [targetLufs, peakCeilingDb], properties: { targetLufs: { type: number, minimum: -31, maximum: -9 }, peakCeilingDb: { type: number, minimum: -6, maximum: 0 } } }
        build: { type: [object, 'null'], additionalProperties: false, required: [fromDb], properties: { fromDb: { type: number, minimum: -24, maximum: 0 } }, description: The music bus ramps from this level to 0 dB across the project. Existing music only. }
        endFadeTicks: { type: integer, minimum: 0, maximum: 2400000, description: Master fade-out at the end, in ticks at 240000 per second, up to ten seconds. }
    TemplateSlot:
      type: object
      additionalProperties: false
      required: [id, label, accepts, required, hint, start, end]
      properties:
        id: { type: string, pattern: '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}$' }
        label: { type: string, minLength: 1, maxLength: 80 }
        accepts: { type: array, minItems: 1, maxItems: 4, items: { type: string, enum: [image, video, audio, text] }, description: A slot that accepts text may accept nothing else. }
        required: { type: boolean }
        hint: { type: string, maxLength: 200, description: Shown when the slot is empty. }
        start: { type: integer, minimum: 0, maximum: 144000000, description: Ticks at 240000 per second. }
        end: { type: integer, minimum: 0, maximum: 144000000, description: Must be after start and within the template's duration. }
        fit: { type: string, enum: [contain, cover], default: contain }
        motion: { oneOf: [{ $ref: '#/components/schemas/ClipMotion' }, { type: 'null' }], default: null }
        audioRole: { type: [string, 'null'], enum: [voice, music, effects, null], default: null }
        text: { type: [object, 'null'], additionalProperties: false, required: [kind, position], properties: { kind: { type: string, enum: [title, lower-third, callout] }, position: { type: string, enum: [top, center, bottom] } }, default: null }
    RecipeDefinition:
      description: "A saved style or a saved template. Bounded treatment data only: it never carries source identifiers, file names, caption or title wording, fetch URLs or private LUT files."
      oneOf:
        - type: object
          additionalProperties: false
          required: [kind, name, style, grade, mix, motion]
          properties:
            kind: { type: string, enum: [style] }
            name: { type: string, minLength: 1, maxLength: 80 }
            style: { $ref: '#/components/schemas/VisualStyle' }
            grade: { oneOf: [{ $ref: '#/components/schemas/Grade' }, { type: 'null' }] }
            mix: { oneOf: [{ $ref: '#/components/schemas/AudioMix' }, { type: 'null' }] }
            motion: { oneOf: [{ $ref: '#/components/schemas/ClipMotion' }, { type: 'null' }] }
        - type: object
          additionalProperties: false
          required: [kind, name, aspect, durationTicks, outcome, style, grade, mix, scene, slots]
          properties:
            kind: { type: string, enum: [template] }
            name: { type: string, minLength: 1, maxLength: 80 }
            aspect: { type: string, enum: ['16:9', '9:16', '1:1', '4:5'] }
            durationTicks: { type: integer, minimum: 1, maximum: 144000000, description: Ticks at 240000 per second, up to ten minutes. }
            outcome: { type: string, enum: [video, scene, lyric_video, source_video, music] }
            style: { $ref: '#/components/schemas/VisualStyle' }
            grade: { oneOf: [{ $ref: '#/components/schemas/Grade' }, { type: 'null' }] }
            mix: { oneOf: [{ $ref: '#/components/schemas/AudioMix' }, { type: 'null' }] }
            scene: { type: [string, 'null'], enum: [bottle, headphones, sculpture, lamp, podium, pack, custom, chest, null] }
            slots: { type: array, maxItems: 12, items: { $ref: '#/components/schemas/TemplateSlot' }, description: Slot ids must be unique and no slot may extend beyond the template's duration. }
    SaveRecipeRequest:
      type: object
      additionalProperties: false
      required: [name, definition]
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        visibility: { type: string, enum: [private, workspace], default: private }
        definition: { $ref: '#/components/schemas/RecipeDefinition' }
    RecipeSummary:
      type: object
      required: [id, kind, name, visibility, ownedByViewer, latestVersion, updatedAt, legacy]
      properties:
        id: { type: string }
        kind: { type: string, enum: [style, template] }
        name: { type: string }
        visibility: { type: string, enum: [private, workspace], description: private is visible only to the author; workspace is visible to every active member, who may apply it but must save their own copy to change it. }
        ownedByViewer: { type: boolean }
        latestVersion: { type: integer }
        updatedAt: { type: string, format: date-time }
        legacy: { type: boolean, description: A look saved before recipe versioning. Still usable, but it has no version history and cannot be read or versioned through the recipe paths. }
    RecipeDetail:
      type: object
      required: [recipe, version, definition, operators, hash, versions]
      properties:
        recipe: { $ref: '#/components/schemas/RecipeSummary' }
        version: { type: integer, description: The version this response describes. }
        definition: { $ref: '#/components/schemas/RecipeDefinition' }
        operators: { type: array, items: { type: string }, description: The effects this recipe needs; every one must have a browser finish route. }
        hash: { type: string, description: Digest of the canonical definition. }
        versions: { type: array, items: { type: object, additionalProperties: true }, description: "The 100 most recent versions, newest first: version, createdAt and hash. A stored version is immutable." }
    Error:
      type: object
      required: [error]
      properties: { error: { type: object, required: [code, message], properties: { code: { type: string }, message: { type: string } } } }
  responses:
    Object:
      description: Successful object response
      headers: { Plotmesh-Request-Id: { $ref: '#/components/headers/RequestId' }, Plotmesh-Usage-Estimated: { $ref: '#/components/headers/UsageEstimated' }, Plotmesh-Usage-Settled: { $ref: '#/components/headers/UsageSettled' }, Plotmesh-Cost-Max-Usd: { $ref: '#/components/headers/CostMax' } }
      content: { application/json: { schema: { type: object, additionalProperties: true } } }
    Collection:
      description: Successful collection response
      headers: { Plotmesh-Request-Id: { $ref: '#/components/headers/RequestId' } }
      content: { application/json: { schema: { type: object, additionalProperties: true } } }
