{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://docmark.md/contracts/v1/job.schema.json",
  "title": "DocMark /v1 job contract",
  "description": "Canonical, producer-owned wire schema for the /v1 job API implemented by service/web/service.py (validate_api_spec) and service/web/app.py. tests/test_v1_contract.py locks this schema to the handler (permitted keys, golden payloads both directions) and to the CONTRACT_VERSION code constant. NOTE: 'theme' is typed as a string here; membership against the live preset list is enforced server-side (presets are files under service/presets/) and is deliberately NOT enumerated here to avoid a second drift source. 1.5.0 adds POST /v1/jobs:render — a synchronous submit-and-wait convenience taking the SAME JobSubmissionRequest body (plus optional ?timeout_s=, clamped 5-120s, default 90): 200 = the artifact bytes (Content-Type by kind, X-DocMark-Job-Id header — no JSON envelope); failed = 422 with a JobStatusResponse-shaped body (state 'failed' + findings); wait budget exhausted = 202 RenderStillPendingResponse and the job KEEPS RUNNING (poll GET /v1/jobs/{id}). type video/newsletter/brochure is rejected 400 (async only). 1.6.0 (data-driven charts Phase 1, additive): adds `input_blobs` (job-scoped input FILE NAMES for the flag-gated worker-side xlsx→charts extraction; the portal's camelCase `inputBlobs` is normalized at the enqueue seam); chart `series[].values` must now be NUMBERS — a non-numeric value (e.g. the string \"$1.2M\") is rejected with a 400 naming its position (it previously plotted as a silent 0); a caller-supplied `provenance` inside a charts item is STRIPPED at submit (chart provenance is engine-attested only); done-job status may carry `provenance.charts` (the content-free extraction outcome). 1.7.0 (usage counter, additive): adds GET /v1/usage — the calling token's OWN tenant render-budget usage (UsageResponse; Bearer-header auth ONLY, uniform 401 on any auth failure, dedicated read rate-limit that never consumes submit-rate or budget slots). Counts are PER SERVING DOOR (this API process; the portal counts its own door separately) and reset on process restart — `counting_since` states when counting began. Sliding window: `used` decreases gradually as hits age out (`oldest_expires_in_s` = when it next drops by ONE); it never snaps to zero. All-null usage fields = no budget applies to this tenant (globally disabled or granted unlimited). Legacy invite-token deployments share one default tenant, so all invite tokens see one shared counter. Operator accounting: budget is charged to the TOKEN's tenant, so operator-rendered jobs in a client's brand appear in the operator's usage, not the client's. Also in 1.7.0: the breaker charges at the LAST gate before enqueue — a REJECTED submission no longer consumes a budget slot. 1.8.0 (portrait brochure, additive): adds optional `pages` (integer 1..4, brochure-only — rejected 400 on other types); brochures render as portrait-A4 documents with the page count honoured exactly. 1.9.0 (video entitlement, additive): a video-type submit from a tenant that is not video-entitled is refused 402 before any budget charge, with a body carrying code video_not_entitled plus upgrade_url and contact. 1.10.0 (credit-exhausted upgrade signal, additive): a submit that exhausts the token tenant's render budget now returns 429 (was a non-retryable 400) with a Retry-After header (derived from the budget window; omitted when unknown) and a body carrying code credit_exhausted, upgrade_url https://docmark.md/pricing, and contact hello@docmark.md; the 402 carries the same code/upgrade_url shape. Plain spec errors (unknown keys, over-length, bad token) are unchanged: no code or upgrade_url pointer. 1.12.0 (durable monthly quota, additive): a submit past the tenant's monthly plan quota is refused 429 PRE-charge with code monthly_quota_reached, a Retry-After header = seconds to the 1st of the next month 00:00 UTC, and the same upgrade_url/contact body shape as 1.10.0; GET /v1/usage additionally reports monthly_used / monthly_quota / monthly_unlimited / resets_at (the durable calendar-month view counted across BOTH doors — portal and /v1 — one number; all null/false when no monthly quota applies, and monthly_used is null when the durable ledger is unreachable rather than ever a guess). The daily window fields are unchanged and now act as a plan-scaled burst cap. Enforcement is flag-staged server-side; with the flags off the wire behavior is identical to 1.11.0. 1.13.0 (render outcome, additive): a done job's `provenance` may carry `render` — a content-free outcome (counts + enum, never slide text) reporting that the deck exceeded the 20-slide render cap and sections were DROPPED. Previously the renderer emitted this notice to stderr and every wrapper discarded it on a successful render, so a customer whose slides were dropped was never told. Absent on a normal render and best-effort (absent, never wrong, if unparseable). NOTE `provenance` is additionalProperties:false, so a consumer validating a vendored copy strictly must re-vendor to accept this key.",
  "x-contract-version": "1.13.0",
  "$defs": {
    "JobSubmissionRequest": {
      "title": "POST /v1/jobs request body",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "theme": {
          "type": "string",
          "minLength": 1,
          "description": "Preset name; membership validated server-side against service/presets/."
        },
        "tenant": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$",
          "description": "Operator-only: render another tenant's brand. Honoured only when the token's OWN tenant is in DOCMARK_OPERATOR_TENANTS; client tokens are locked to their own tenant and a mismatch is rejected. Omit to use the token's tenant."
        },
        "profile": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$",
          "description": "Optional design profile of the RESOLVED tenant (a look variant of the same brand, e.g. 'engineering'). Validated against the tenant's published profiles; an unknown profile is rejected with the available list. Note: 'brandVersion' (worker-side) pins the ROOT spec only — the profile overlay is always the worker's baked copy (its sha is logged per render)."
        },
        "topic": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000,
          "pattern": "\\S",
          "description": "Free-text deck topic. Mutually optional with 'content' (at least one required)."
        },
        "pages": {
          "type": "integer",
          "minimum": 1,
          "maximum": 4,
          "description": "Brochure page count (portrait A4; honoured EXACTLY by construction). Optional; absent = 1. Ignored by non-brochure types. Worker clamps 1..4."
        },
        "content": {
          "type": "array",
          "maxItems": 12,
          "description": "Structured sections. For type 'deck' these render VERBATIM (never re-authored by AI); when both 'topic' and 'content' are present, content wins and topic serves only as the cover-title fallback. Other types (brochure; docx without 'markdown') currently author from 'topic' and do not read 'content' — verbatim-content support there is roadmap.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "heading": {
                "type": "string",
                "maxLength": 100
              },
              "bullets": {
                "type": "array",
                "maxItems": 8,
                "items": {
                  "type": "string",
                  "maxLength": 240
                }
              }
            }
          }
        },
        "title": {
          "type": "string",
          "maxLength": 200
        },
        "subtitle": {
          "type": "string",
          "maxLength": 160
        },
        "web_search": {
          "type": "boolean"
        },
        "sheets": {
          "type": "array",
          "minItems": 1,
          "maxItems": 8,
          "description": "XLSX only: caller-authored structured chart-spec (each sheet has tables and/or native charts). Per-axis + aggregate caps enforced server-side by render_xlsx.validate_spec; not fully expanded here.",
          "items": {
            "type": "object"
          }
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 6,
          "description": "NEWSLETTER only: caller-authored update items ({heading, body}). Caps enforced server-side by render_newsletter.validate_newsletter_spec.",
          "items": {
            "type": "object"
          }
        },
        "issue": {
          "type": "string",
          "maxLength": 80,
          "description": "Newsletter only: the dated standfirst, e.g. 'Product update · June 2026'."
        },
        "intro": {
          "type": "string",
          "maxLength": 400,
          "description": "Newsletter only: intro paragraph."
        },
        "footer": {
          "type": "string",
          "maxLength": 200,
          "description": "Newsletter only: footer line."
        },
        "charts": {
          "type": "array",
          "minItems": 1,
          "maxItems": 12,
          "description": "DECK only: caller-authored chart-specs ({kind: bar|line|pie, title, categories, series:[{name, values}]}). Each becomes a branded chart slide carrying a rendered chart IMAGE (no native-chart embedded workbook / formula surface). Caps enforced server-side by render_chart.validate_charts. 1.6.0 (D6): series values MUST be numbers — a string like \"$1.2M\" or \"12%\" is rejected with a 400 naming the offending position (previously it plotted as a silent 0); parse to a number before submitting. A caller-supplied `provenance` key inside an item is STRIPPED at submit — chart provenance is engine-attested only.",
          "items": {
            "type": "object"
          }
        },
        "input_blobs": {
          "type": "array",
          "minItems": 1,
          "maxItems": 10,
          "description": "1.6.0 (additive): job-scoped input file references for the flag-gated worker-side xlsx→charts extraction (DOCMARK_EXTRACT_XLSX, default OFF). Each entry is a bare FILE NAME (or a portal BlobReference-style object with `path`); every entry is normalized to its final path segment and the WORKER constructs the storage path jobs/{job_id}/input/{name} from its own job id — a reference can never address another job's inputs. When the flag is on, a deck job with input_blobs and NO caller `charts` gets faithfully-extracted charts from the FIRST .xlsx (values verbatim, abstain over guess); the extraction outcome rides the status `provenance.charts`. The portal's camelCase `inputBlobs` is accepted and normalized at the enqueue seam.",
          "items": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1,
                "maxLength": 512
              },
              {
                "type": "object",
                "required": [
                  "path"
                ],
                "properties": {
                  "path": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 512
                  }
                }
              }
            ]
          }
        },
        "markdown": {
          "type": "string",
          "minLength": 1,
          "maxLength": 46000,
          "description": "DOCX only: caller-prepared Markdown, rendered FAITHFULLY to a branded .docx (AI OFF — the model never authors). Mutually exclusive with topic/content/web_search. Parsed server-side by md_to_docx (python-markdown -> the render_docx model). SIZE (corrected 1.11.0 to match the hosted transport, which has enforced it since 1.7.x): maxLength counts CODE POINTS, but the hosted bound is ~48 KB of the UTF-8-SERIALIZED WHOLE JOB — multi-byte characters (CJK markdown caps out near ~15,000 chars), JSON escaping, and every other field (title, subtitle, ...) count toward it, so escape-heavy or non-ASCII content can be refused with an actionable 400 below maxLength. ~46 KB is roughly 13-20 pages of dense English prose; split larger documents. Self-hosted FS-queue deployments have no transport bound and accept up to 256,000 bytes server-side (lenient-beyond-schema)."
        },
        "type": {
          "type": "string",
          "enum": [
            "deck",
            "video",
            "docx",
            "xlsx",
            "newsletter",
            "brochure"
          ],
          "description": "Artifact kind. 'deck' (default) renders a PPTX; 'video' a narrated MP4; 'docx' a branded Word report; 'xlsx' a branded charts workbook (requires 'sheets'); 'newsletter' a branded PNG image (requires 'items'); 'brochure' a multi-page branded PDF (topic/content, like a deck). xlsx/newsletter don't use 'topic'/'content'."
        },
        "language": {
          "type": "string",
          "enum": [
            "en",
            "zh"
          ],
          "description": "Narration + slide-text language for video. Default 'en'."
        },
        "voice": {
          "type": "string",
          "enum": [
            "male",
            "female"
          ],
          "description": "Narration voice gender for video. Default 'male'."
        }
      },
      "required": [
        "theme"
      ],
      "allOf": [
        {
          "if": {
            "properties": {
              "type": {
                "const": "xlsx"
              }
            },
            "required": [
              "type"
            ]
          },
          "then": {
            "required": [
              "sheets"
            ]
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "newsletter"
              }
            },
            "required": [
              "type"
            ]
          },
          "then": {
            "required": [
              "items"
            ]
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "enum": [
                  "xlsx",
                  "newsletter"
                ]
              }
            },
            "required": [
              "type"
            ]
          },
          "then": true,
          "else": {
            "anyOf": [
              {
                "required": [
                  "topic"
                ]
              },
              {
                "required": [
                  "content"
                ]
              },
              {
                "required": [
                  "markdown"
                ]
              }
            ]
          }
        }
      ]
    },
    "UsageResponse": {
      "title": "GET /v1/usage 200 response",
      "description": "The calling token's OWN tenant budget usage (1.7.0, additive). Read from the SAME limiter the COGS breaker consumes — what you see is what blocks you. `source: window` = in-memory sliding window (the Stage-2 ledger will extend with a new source value, not break). Nullability: used/limit/window_s/oldest_expires_in_s/counting_since are ALL null when no budget applies to this tenant (globally disabled or granted unlimited — nothing is counted, so used:0 would be a lie); oldest_expires_in_s is additionally null when used is 0. 1.12.0 (additive): monthly_used/monthly_quota/monthly_unlimited/resets_at — the DURABLE calendar-month plan view, counted across both doors in the PortalUsage ledger (unlike the per-door daily window fields). All null/false when no monthly quota applies to this tenant; monthly_used is null when the ledger is unreachable (never a guess).",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "tenant": {
          "type": "string",
          "minLength": 1
        },
        "used": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 0,
          "description": "Enqueued jobs counted in the current window at THIS door. Rejected submissions are never counted."
        },
        "limit": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 1,
          "description": "This tenant's budget (its Stage-1 grant override, else the global default)."
        },
        "window_s": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 1,
          "description": "Rolling window length in seconds (NOT a billing month)."
        },
        "oldest_expires_in_s": {
          "type": [
            "number",
            "null"
          ],
          "minimum": 0,
          "description": "Seconds until `used` next decreases by ONE (the oldest counted hit ages out). Sliding window: there is no full reset."
        },
        "counting_since": {
          "type": [
            "integer",
            "null"
          ],
          "description": "Epoch seconds when counting began: max(process start, now - window_s). Counts reset on process restart — a fresh deploy shows used from this moment only."
        },
        "monthly_used": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 0,
          "description": "1.12.0: renders counted in the current UTC calendar month, across BOTH doors (durable — survives restart/redeploy). Null when no monthly quota applies or the ledger is unreachable (never a guess)."
        },
        "monthly_quota": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 1,
          "description": "1.12.0: the plan's monthly render quota (grants.v2 monthlyQuota). Null when the tenant has no monthly quota (including explicitly unlimited plans)."
        },
        "monthly_unlimited": {
          "type": "boolean",
          "description": "1.12.0: true when the plan is explicitly monthly-unlimited (grants.v2 monthlyUnlimited — never inferred from a 0)."
        },
        "resets_at": {
          "type": [
            "string",
            "null"
          ],
          "description": "1.12.0: when the monthly count resets — the 1st of the next month 00:00 UTC, ISO-8601 (YYYY-MM-01T00:00:00Z). Null when no monthly quota applies."
        },
        "source": {
          "const": "window"
        },
        "contract_version": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "tenant",
        "used",
        "limit",
        "window_s",
        "oldest_expires_in_s",
        "counting_since",
        "monthly_used",
        "monthly_quota",
        "monthly_unlimited",
        "resets_at",
        "source",
        "contract_version"
      ]
    },
    "JobAcceptedResponse": {
      "title": "POST /v1/jobs 202 response",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "job_id": {
          "type": "string",
          "minLength": 1
        },
        "state": {
          "const": "pending"
        },
        "contract_version": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "job_id",
        "state",
        "contract_version"
      ]
    },
    "JobStatusResponse": {
      "title": "GET /v1/jobs/{id} response",
      "type": "object",
      "description": "Status envelope. 'additionalProperties' is permitted: failed jobs carry a 'findings' string, and future state metadata may be added without a breaking change.",
      "additionalProperties": true,
      "properties": {
        "job_id": {
          "type": "string",
          "minLength": 1
        },
        "contract_version": {
          "type": "string",
          "minLength": 1
        },
        "state": {
          "type": "string",
          "enum": [
            "pending",
            "processing",
            "done",
            "failed"
          ]
        },
        "findings": {
          "type": "string"
        },
        "provenance": {
          "type": "object",
          "additionalProperties": false,
          "description": "OPTIONAL (1.4.0, done jobs only, best-effort): which brand identity produced the artifact — allowlisted metadata only, never server paths. Absent on legacy results rendered before this contract or when the sidecar is unavailable. 'template' is the corporate template FILENAME (basename only); profile/overlay fields appear only when the job selected a design profile; template fields only when a template actually rendered.",
          "properties": {
            "tenant": {
              "type": [
                "string",
                "null"
              ],
              "description": "The tenant whose brand rendered the artifact; null when the job fell back to the baked default brand without a valid tenant."
            },
            "brand_source": {
              "type": "string",
              "enum": [
                "mount",
                "baked",
                "default"
              ],
              "description": "Where the ROOT brand-spec came from: the versioned brand store ('mount'), the worker image's baked client spec ('baked'), or the baked default brand ('default')."
            },
            "brand_version": {
              "type": [
                "string",
                "null"
              ],
              "description": "The brand-store version (e.g. 'v0003') when brand_source is 'mount'; null otherwise."
            },
            "profile": {
              "type": "string",
              "description": "The design profile the job selected (present only when one was)."
            },
            "overlay_source": {
              "type": "string",
              "enum": [
                "mount",
                "baked"
              ],
              "description": "Where the profile overlay came from (present only with 'profile')."
            },
            "overlay_sha": {
              "type": "string",
              "pattern": "^[0-9a-f]{8}$",
              "description": "First 8 hex chars of the overlay's sha256 (present only with 'profile') — matches the worker's logged overlay_sha for drift diagnosis."
            },
            "template": {
              "type": "string",
              "description": "Corporate template FILENAME (e.g. 'JLL_Template.dotx') when template-mode rendering was used; absent when rendering from scratch."
            },
            "template_source": {
              "type": "string",
              "enum": [
                "spec-dir",
                "fallback-dir"
              ],
              "description": "Whether the template resolved next to the brand-spec or from the worker's baked client dir (present only with 'template')."
            },
            "engine": {
              "type": "string",
              "description": "Render backend class name, lowercased (e.g. 'docxbackend', 'designedbackend')."
            },
            "contract_version": {
              "type": "string",
              "description": "The /v1 contract version the worker ran with when it rendered."
            },
            "charts": {
              "type": "object",
              "additionalProperties": false,
              "description": "1.6.0 (flag-gated xlsx→charts extraction, present only when the extraction slot ran): CONTENT-FREE outcome — structure and coordinates only, never cell values. `source` is the input file's basename; `ranges` are engine-regenerated 'Sheet!A1:C10' citations for the charted tables; on any non-charted outcome `reason` says why the job continued chartless. Corrected 2026-07-15: the enum now lists ALL shipping outcome codes — `rejected_unsafe_parts` / `abstained_metadata_parts` (security-scan classes, on the wire since v0.65.2 but missing here) and `discarded_invalid_spec` (extracted spec failed the render seam's validation — engine bug; deck delivered chartless).",
              "properties": {
                "outcome": {
                  "type": "string",
                  "enum": [
                    "charted",
                    "abstained",
                    "rejected_unsafe_parts",
                    "abstained_metadata_parts",
                    "discarded_invalid_spec"
                  ]
                },
                "source": {
                  "type": "string"
                },
                "extracted": {
                  "type": "integer",
                  "minimum": 0
                },
                "ranges": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "reason": {
                  "type": "string"
                }
              },
              "required": [
                "outcome"
              ]
            },
            "render": {
              "type": "object",
              "additionalProperties": false,
              "description": "1.13.0 (present only when the renderer reported a non-default outcome): CONTENT-FREE render outcome — counts and a known enum, never slide text. Today the only outcome is `truncated_to_cap`: the deck carried more sections than the 20-slide render cap, so `sections_submitted - sections_rendered` sections were DROPPED from the artifact. Absent on a normal render, and absent (never wrong) if the renderer's line is missing or unparseable — it is best-effort and never fails a job that produced a good artifact. NOTE the field exists because this notice previously reached nobody: the renderer emitted it, and every wrapper discarded child output on a successful render.",
              "properties": {
                "outcome": {
                  "type": "string",
                  "enum": [
                    "truncated_to_cap"
                  ]
                },
                "sections_submitted": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Sections the caller submitted."
                },
                "sections_rendered": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Sections that reached the artifact."
                }
              },
              "required": [
                "outcome",
                "sections_submitted",
                "sections_rendered"
              ]
            }
          }
        }
      },
      "required": [
        "job_id",
        "contract_version",
        "state"
      ]
    },
    "RenderStillPendingResponse": {
      "title": "POST /v1/jobs:render 202 response (wait budget exhausted)",
      "type": "object",
      "description": "Graceful async degradation (1.5.0): the sync wait budget ran out before the render finished. The job is NOT cancelled — it keeps running; retrieve it via the normal GET /v1/jobs/{job_id} + /result flow.",
      "additionalProperties": false,
      "properties": {
        "job_id": {
          "type": "string",
          "minLength": 1
        },
        "state": {
          "type": "string",
          "enum": [
            "pending",
            "processing"
          ]
        },
        "contract_version": {
          "type": "string",
          "minLength": 1
        },
        "note": {
          "type": "string",
          "minLength": 1,
          "description": "Agent/human hint: still rendering — poll GET /v1/jobs/{job_id}."
        }
      },
      "required": [
        "job_id",
        "state",
        "contract_version",
        "note"
      ]
    }
  }
}
