# Product Template Authoring Guide

A **ProductTemplate** is a reusable blueprint for a print product. It defines the
components (cover, text block, inserts), the production operations (printing,
folding, binding), the dependency graph between them, and — for storefront /
Web2Print use — a `configSchema` exposing customer-facing options.

Templates follow a **draft / publish** lifecycle: every `upsertProductTemplate`
leaves the template in `DRAFT`. Only `publishProductTemplate` cuts an immutable
snapshot that can be `instantiateProductTemplate`'d into a real job.

There is no dedicated tooling for ProductTemplates — everything is driven
through the GraphQL API, and the schema is the authority on the exact
input and output shape.

The full long-form reference (every field, every validator, more worked examples)
lives in the repo at
`Sources/PrintVertical/Sources/PrintVertical/Domain/ProductTemplate/AUTHORING_GUIDE.md`.
The `x-*` extension catalogue (Set B) below is also covered in
`docs/json-schema-extensions.md`. This MCP guide is the LLM-facing condensation.

## Top-level shape

```graphql
mutation {
  upsertProductTemplate(input: {
    name: "A5 Flyer 4/4"
    sku: "FLY-A5-44"
    category: "flyers"
    tags: ["a5", "4/4"]
    components: [ /* see below */ ]
    operations: [ /* see below */ ]
    configSchema: { type: "object", properties: {} }
  }) {
    productTemplate { id status version }
    errors { path message }
  }
}
```

Then publish:

```graphql
mutation {
  publishProductTemplate(input: { id: "ptl_..." }) {
    productTemplate { id status publishedVersion }
    version
    errors { path message }
  }
}
```

## Components

Each component is a physical part of the finished product. Reference them from
operations and from `configSchema.x-applies-to` via their `localId`.

| Field | Required | Notes |
|-------|----------|-------|
| `localId` | yes | Stable, unique within the template |
| `kind` | yes | `MAIN`, `COVER`, `TEXT`, `INSERT`, `OUTSERT`, `DUST_JACKET`, `ENDPAPER`, `CONTENT`, `OTHER` |
| `name` | yes | Display name |
| `defaultQuantity` | no | Default `1` |
| `minQuantity` / `maxQuantity` / `quantityStep` | no | Storefront quantity validation |
| `totalPages` | no | Default page count (overridable via `PAGE_COUNT` config dimension) |
| `mediaSelectors` | no | Tenant-agnostic substrate constraints — see "Substrate selection" |
| `sides`, `spreadType` | no | `ONE_SIDED` / `TWO_SIDED_HEAD_TO_HEAD` / `TWO_SIDED_HEAD_TO_FOOT`; `SINGLE_PAGE` / `READER_SPREAD` / `PRINTER_SPREAD` |
| `finishedDimensions`, `colors`, `layout`, `finishing` | no | Geometry + ink + bleed + finishing defaults |

## Operations

Operations live in a top-level `operations` array (not nested under components),
so cross-component dependencies work.

| Field | Required | Notes |
|-------|----------|-------|
| `localId` | yes | Unique across all operations |
| `componentLocalId` | no | Links to a component; `null` for job-level operations |
| `kind` | yes | `PRINTING`, `OFFSET_PRINTING`, `DIGITAL_PRINTING`, `FOLDING`, `BINDING`, `SADDLE_STITCHING`, `CUTTING`, `LAMINATING`, `FOIL_STAMPING`, `EMBOSSING`, `CASE_MAKING`, `CASING_IN`, `SEWING`, … |
| `name` | yes | Display name |
| `defaultParameters` | no | JSON string validated against the kind's parameter schema (binding style, fold type, film type, …) |
| `dependsOn` | yes | Array of predecessor `localId`s — forms the operation DAG |

The validator rejects: duplicate localIds, unknown component refs, unknown
operation refs in `dependsOn`, self-references, and cycles.

## Substrate selection (`mediaSelectors`)

Templates are tenant-agnostic — never hardcode tenant-specific substrate codes.
Each component carries `mediaSelectors`, which are tenant-neutral constraints
(gsm, coatings, stock type, …). The storefront intersects them with the tenant's
substrate library; the customer picks one by `localId`; instantiation re-runs that
selector to write `desiredSubstrateId` onto the component.

```graphql
mediaSelectors: [
  {
    localId: "house_silk"
    name: "Silk text 130gsm"
    weight: { tolerance: { target: 130, tolerance: 20 } }
    coatings: [SATIN, MATTE]
    stockType: [TEXT, BOOK]
  },
  {
    localId: "uncoated"
    name: "Uncoated text 100gsm"
    weight: { range: { min: 90, max: 120 } }
    coatings: [NONE]
  }
]
```

`weight` (and `thickness`) is a `@oneOf` input: provide exactly one of `exact`,
`range`, or `tolerance`. Width / height are deliberately not selectable — parent
sheet sizes are tenant-specific.

Each selector must constrain at least one field. Empty arrays are rejected (use
`null` to mean "operator chooses"). `localId`s must be unique within the component.

> **Don't put substrate selection in `configSchema`.** It's tenant-dynamic and
> would force every tenant to fork the template. `mediaSelectors` keeps templates
> shareable.

## ConfigSchema — customer-facing options

`configSchema` is a JSON Schema (draft-07) **JSON object** on the template input. It
drives both validation of the customer's `config` at instantiation **and** the
Web2Print product-page UI.

Every property must declare two platform-specific vendor extensions:

- **`x-dimension`** — what kind of effect this option has at instantiation
- **`x-applies-to`** — array of component or operation `localId`s the option
  targets

### Dimension catalogue

| Dimension | Property type | Targets | Effect | Extra |
|-----------|---------------|---------|--------|-------|
| `FINISHED_SIZE` | `string` + `enum` | component localIds | Sets layout size on each target | `x-enum-values` **required** with `{width, height, unit}` per enum value |
| `OPTIONAL_COMPONENT` | `boolean` | component localIds | When `false`, the component (and its operations) is excluded from the job | `type` must be `"boolean"` |
| `OPTIONAL_FINISHING` | `boolean` | operation localIds | When `false`, the operation is excluded | `type` must be `"boolean"` |
| `BINDING_METHOD` | `string` + `enum` | operation localIds | Sets `bindingStyle` on the operation's `defaultParameters` | — |
| `COLOR_SPEC` | `string` + `enum` | component localIds | Sets the colour spec on the component | — |
| `PAGE_COUNT` | `integer` | component localIds | Writes to `extensions.totalPages` on each target | `minimum`, `maximum`, `multipleOf` for client-side validation |

### Vendor extensions (Set B — ProductTemplate-only)

| Extension | Required for | Description |
|-----------|--------------|-------------|
| `x-dimension` | every property | One of the seven dimension types above. |
| `x-applies-to` | every property | Non-empty array of component/operation `localId`s. Validated to exist. |
| `x-enum-labels` | `FINISHED_SIZE`, `BINDING_METHOD`, `COLOR_SPEC` | Object mapping enum value → display string. Always provide for storefront UIs. |
| `x-enum-values` | `FINISHED_SIZE` (required) | Object mapping enum value → `{ "width": {value, unit}, "height": {value, unit} }`. |

> **Different family from integration `config_schema.json`.** The
> `x-input-type` / `x-relatedResource` / `x-supportsExpression` keys documented in
> the Integration Builder Guide are **not** used here, and are silently ignored
> by the ProductTemplate validator. The two surfaces have separate validators.

### Worked example — hardcover photo book

```json
{
  "type": "object",
  "properties": {
    "size": {
      "type": "string",
      "title": "Book Size",
      "enum": ["a5", "a4", "square_200"],
      "x-dimension": "FINISHED_SIZE",
      "x-applies-to": ["text", "cover", "endpapers", "dustjacket"],
      "x-enum-labels": {
        "a5": "A5 (148 x 210 mm)",
        "a4": "A4 (210 x 297 mm)",
        "square_200": "Square (200 x 200 mm)"
      },
      "x-enum-values": {
        "a5":         { "width": { "value": 148, "unit": "MM" }, "height": { "value": 210, "unit": "MM" } },
        "a4":         { "width": { "value": 210, "unit": "MM" }, "height": { "value": 297, "unit": "MM" } },
        "square_200": { "width": { "value": 200, "unit": "MM" }, "height": { "value": 200, "unit": "MM" } }
      }
    },
    "dustJacket": {
      "type": "boolean",
      "title": "Include Dust Jacket",
      "default": true,
      "x-dimension": "OPTIONAL_COMPONENT",
      "x-applies-to": ["dustjacket"]
    },
    "lamination": {
      "type": "boolean",
      "title": "Laminate Dust Jacket",
      "default": true,
      "x-dimension": "OPTIONAL_FINISHING",
      "x-applies-to": ["lam_dj"]
    },
    "pages": {
      "type": "integer",
      "title": "Number of Pages",
      "minimum": 8,
      "maximum": 400,
      "multipleOf": 4,
      "x-dimension": "PAGE_COUNT",
      "x-applies-to": ["text"]
    }
  }
}
```

## Lifecycle and versioning

- `upsertProductTemplate` always leaves the template `DRAFT` and bumps `version`
  (yes, even on repeated draft saves — `publishedVersion` is the only number
  that matters for reproducibility).
- `publishProductTemplate` snapshots the current state as an immutable
  `ProductTemplateVersion` and flips status to `PUBLISHED`.
- `instantiateProductTemplate` always uses the **latest published** version. There
  is currently no way to pin instantiation to a historical version.
- Use `listProductTemplateVersions(templateId)` and
  `getProductTemplateVersion(templateId, version)` to inspect history.

## Instantiation

```graphql
mutation {
  instantiateProductTemplate(input: {
    templateId: "ptl_..."
    jobName: "Hardcover Order"
    quantityOverride: 500
    config: { size: "a4", dustJacket: false, pages: 64 }
    selectorChoices: [
      { componentLocalId: "text", selectorLocalId: "house_silk" }
    ]
  }) {
    job { id name }
    components { id name kind quantity }
    operationCount
    templateVersion
    errors { path message }
  }
}
```

- `config` is a `JSON` scalar — pass it as an inline object literal, **not** a
  string. It's validated against the published `configSchema`.
- `selectorChoices` resolves substrate per component. A single tenant-library
  match writes `desiredSubstrateId` onto `component.extensions`; zero or multiple
  matches leave it unset for the operator/workflow to resolve.
- The mutation creates `Component`, `Operation`, and `OperationEdge` records on
  the job and stamps `source_template_id` + `source_template_version` on the job.

## ConfigSchema validation reference

The validator rejects (in addition to standard JSON Schema invariants):

| Path | Condition |
|------|-----------|
| `configSchema` | Not a JSON object |
| `configSchema.type` | Not `"object"` |
| `configSchema.properties.<prop>.x-dimension` | Missing or invalid value |
| `configSchema.properties.<prop>.x-applies-to` | Missing, empty, or references unknown localId |
| `configSchema.properties.<prop>.type` | Not `"boolean"` for `OPTIONAL_COMPONENT` / `OPTIONAL_FINISHING` |
| `configSchema.properties.<prop>.x-enum-values` | Missing for `FINISHED_SIZE` |
| `configSchema.properties.<prop>.x-enum-values.<val>` | Missing `width` or `height` |
| `components[i].mediaSelectors` | Empty array (use `null` instead) |
| `components[i].mediaSelectors` | Duplicate selector `localId`s |
| `components[i].mediaSelectors[j]` | No constraints set — would match every substrate |
| `components[i].mediaSelectors[j].weight` | Range with `min > max` |

## Permissions

| Permission | Required for |
|------------|--------------|
| `productTemplate:read` | `getProductTemplate`, `listProductTemplateVersions`, `getProductTemplateVersion` |
| `productTemplate:list` | `listProductTemplates` |
| `productTemplate:write` | `upsertProductTemplate`, `publishProductTemplate` |
| `productTemplate:delete` | `deleteProductTemplate` |
| `productTemplate:execute` | `instantiateProductTemplate` |

## Discovery

- Filter `listProductTemplates` by `sku` (exact), `category` (exact),
  `isActive`, `status` (`"DRAFT"` / `"PUBLISHED"`).
- Templates carry free-form `tags` (string array) and a single `category` for
  organisation. Use `isActive: false` to hide a template without deleting it.
- The GraphQL schema is the source of truth for the input shape — this guide
  condenses, and the schema is always current where a guide can lag.