# Scheduler config — what's different

Scheduler config doesn't behave quite like DO config. You never talk to the API
directly — the CLI handles all routing — but a few things change what you see in
snapshots and how records relate to each other.

The CLI drives the Scheduler's **normalized REST API**, addressed by path:

```
/api/scheduler/{locationCode}/{dataTemplateId}/{entity}   # template-scoped
/api/scheduler/{entity}                                   # global
```

The write shapes match the **published schema**, so unlike DO's older
behaviour, `tllt schema <Entity>` *is* authoritative here too.

## Everything is organised per DataTemplate

A `DataTemplate` is the root of scheduler config (each is tied to a Location).
Global entities (`Location`, `DataTemplate`, `OptimisationProfile`) live at the
top of `scheduler/`; everything else belongs to exactly one template. Snapshots
mirror that:

```
{profile}/scheduler/
  Location/                 # global
  DataTemplate/             # global (each carries its location)
  OptimisationProfile/      # global
  Line A/                   # one folder per template, keyed by NAME
    EquipmentClass/
    Property/  PropertyValue/
    ChangeoverSet/  ChangeoverSetItem/
    Equipment/  EquipmentClassMembership/
    AvailabilityTemplate/  AvailabilityTemplateItem/
    MaterialGroup/  MaterialDefinition/  MaterialProperty/
    Operation/  Route/  Segment/  SegmentMaterial/  SegmentEquipment/
```

- Templates are keyed by **name**, not id (ids differ between environments).
- On `deploy`, the CLI resolves the template name to the target's id **and**
  location code automatically, and builds the path. If the template doesn't
  exist on the target it is created (its `location` is resolved by code), and any
  records scoped to it follow.

## The schema is authoritative — but mind the natural keys

For scheduler entities, `tllt schema <Entity>` (and the `_schema.json`
sidecar) describes exactly what you write — fields, enums, and which entity each
nested reference points at. Author records to that contract.

Records are keyed by a **stable natural key** (the snapshot filename), so
`diff`/`deploy` match them for update/delete — no more create-only. Some keys are
composite (joined with ` - `):

| Entity | Natural key |
| ------ | ----------- |
| `EquipmentClass`, `Equipment`, `MaterialGroup`, `MaterialDefinition` | `externalId` |
| `Property`, `ChangeoverSet`, `AvailabilityTemplate`, `OptimisationProfile` | `name` |
| `Location` | `code` |
| `Operation` | `operationCode` |
| `PropertyValue` | `property - value` |
| `ChangeoverSetItem` | `set - property - fromValue - toValue` |
| `EquipmentClassMembership` | `equipment - equipmentClass` |
| `Route` / `Segment` | `operationCode - routeCode[ - segmentCode]` |

## How references travel between environments

A record's references are stored by **natural key**, never by numeric id (ids
differ per environment). On deploy the CLI resolves them:

- **Resolved by the CLI** (the create endpoint needs a numeric id): `Equipment.changeoverSet`,
  `EquipmentClassMembership.{equipment,equipmentClass}`, `PropertyValue.property`,
  `ChangeoverSetItem.{changeoverSet,property,fromValue,toValue}`,
  `AvailabilityTemplateItem.availabilityTemplate`, `DataTemplate.location`,
  `MaterialDefinition.materialGroup`, `MaterialProperty.materialDefinition`,
  `SegmentEquipment.equipmentClass`. The CLI looks up the referenced record on the
  target by its key and injects the id.
- **Resolved by the API** (the body carries an inline code/externalId):
  `Route`/`Segment`/`SegmentMaterial`/`SegmentEquipment` parents (via inline
  `operationCode`/`routeCode`/`segmentCode`), and `SegmentMaterial.materialDefinition`
  (`externalId`).

> ⚠️ **Plain `POST /{entity}/` create endpoints do NOT resolve refs by `externalId`** —
> only the `/upsert` routes do. So `MaterialDefinition.materialGroup`,
> `SegmentEquipment.equipmentClass` and `MaterialProperty.materialDefinition` 500
> (`null value in column …_id`) if sent by externalId on create; the CLI resolves
> them to ids itself. (`SegmentMaterial.materialDefinition` is the exception — its
> create *does* resolve by externalId.)

Because of these dependencies, deploy applies creates/updates in **dependency
order** (parents before children) and deletes in reverse. A referenced parent
must therefore be present in the same deploy (or already on the target) — if it
isn't, the child fails with a clear "cannot resolve …" message.

## Binding changeover sets & availability to equipment (important)

These two behave very differently — a hard-won learning, don't forget it:

- **Changeover set → equipment: bound on create, via the `changeoverSet` ref.**
  In the normalized model `Equipment.changeoverSet` is a **required** (`nullable:false`)
  foreign key (`changeover_set_id`). You assign it simply by setting
  `"changeoverSet": { "name": "<set>" }` on the equipment record — the CLI resolves
  the name to the target id on deploy and it **persists**. There is **no separate
  step and no "calendarised changeover" object**: the FK on the equipment row *is*
  the assignment, and the optimiser reads it directly. Verify with
  `GET /{loc}/{dt}/equipment?expand=changeoverSet`.
  - ⛔️ This is the **opposite of the old flattened gateway path**, where the
    equipment write shape had no `changeoverSet` field, so equipment was created
    with `changeoverSet: null` and needed a separate PUT to bind. On the normalized
    REST surface that pitfall is gone — but it also means **you cannot create
    equipment without a changeover set** (create a `ChangeoverSet` first; deploy
    order handles this since ChangeoverSet sorts before Equipment).

  **…but binding the set is NOT enough for changeovers to apply.** The optimiser
  computes a changeover between two segments by comparing their **property maps**,
  and a segment's property map is built from its **SegmentMaterials →
  MaterialDefinition → MaterialProperty** (rows where `value IS NOT NULL`) — *not*
  from order properties and *not* from the material directly. So the full working
  chain is:

  ```
  Equipment ──changeoverSet──▶ ChangeoverSet ──▶ ChangeoverSetItem (property, fromValue→toValue, time)
  Segment ──▶ SegmentMaterial ──▶ MaterialDefinition ──▶ MaterialProperty (property name = value)
  ```

  For each sequenced pair the optimiser builds `{property → value}` for the from-
  and to-segment, then looks up the matching `ChangeoverSetItem` on the line's set
  (falling back to the set's `defaultTime`). **If a segment has no SegmentMaterial
  carrying the relevant MaterialProperty, its property map is empty and no
  changeover is found.** Item `fromValue`/`toValue` may be `'*'` (wildcard = any
  value), and `aggregationType` MAX/SUM combines multiple matched properties.

  > ✅ **Practical rule:** give **every** segment a SegmentMaterial carrying the
  > keying property — not just the output stage. In two-stage canning the **fill**
  > segment must also carry the SKU (or a recipe material with `Brand`), otherwise
  > filling-line changeovers never compute. The pattern is to put the produced SKU
  > on both the Fill and Pack segments.

> **SegmentDependency** (`segment-dependencies`) links two segments of a route by
> their inline codes: `{operationCode, routeCode, fromSegmentCode, toSegmentCode,
> dependencyType, dependencyFactor}`. `dependencyType` ∈ `FINISH_START`,
> `START_START`, `LOCKED_START_START`, `LOCKED_FINISH_START`. e.g. lock packing to
> start with filling (no gap): `fromSegmentCode: "Fill", toSegmentCode: "Pack",
> dependencyType: "LOCKED_START_START", dependencyFactor: "0"`.

- **Availability → equipment: NOT bindable via the normalized API.** There is no
  REST route linking an `AvailabilityTemplate` to an equipment's
  `availabilitySetId` (a `calendarised_availability_template_set`). Availability
  must be assigned through the **gateway** integration endpoints: `POST availabilities`
  (one row per day: `{name, dayOfTheWeek, startTime "HH:MM:SS", endTime}`) then
  `POST equipment-availabilities` (`{equipmentCode, availabilityCode, availabilityStart,
  availabilityEnd}`) per line. Keep the start→end horizon to ≈1 year — the
  `getEquipment` resolver materializes every productive period in the window, so a
  far-future end (e.g. 2099) explodes into tens of thousands of periods and 413s
  the schedule view.

## Known API quirks the CLI works around

These are pending a server-side fix; the CLI handles them so you don't have to:

- **Numeric-as-string.** A few number fields are emitted as strings by the API
  but validated as numbers on write (`ChangeoverSetItem.time`,
  `AvailabilityTemplateItem.startTime/endTime`, `OptimisationProfile` weights).
  The CLI stores and sends them as numbers.
- **Unscoped leaf collections.** `property-values`, `changeover-set-items`,
  `availability-template-items` and `material-properties` ignore the path scope
  and return the whole tenant. The CLI filters them to the right template by the
  parent's id (so you still see the correct per-template set).

## Caveats

- Always preview scheduler deploys with `--dry-run` first.
- Transactional records (live orders) are intentionally **not** managed.
