<!-- BEGIN ctl-shared-conventions v1 -->
<!--
  This block is the SHARED conventions core, single-sourced in
  @norskvideo/ctl-dev-kit (conventions/CLAUDE.core.md). It is copied verbatim
  into each product repo's CLAUDE.md between these fenced markers. A drift-check
  gate is planned (Workstream I) to fail CI if a copy diverges from the dev-kit's
  version — until it lands, do NOT hand-edit a copy: edit the dev-kit source and
  re-sync. The per-product tail (this product's build, iterate loop, gotchas)
  lives OUTSIDE the markers and is yours to own.

  It is copied (not `@import`ed) on purpose: CLAUDE.md's value is being read
  verbatim by a human on GitHub with no tooling, and an @import shows a human a
  dead path.
-->

## Conventions (shared across all Norsk ctl product repos — do not edit in place)

- **RED -> GREEN -> REFACTOR. No fix before its failing test.** Write the test,
  run it, watch it fail against the old code, THEN write the fix. This applies
  _most_ when the fix feels too obvious to bother — that's precisely when the
  skip happens. If code somehow got written first: stash it, prove the test RED,
  pop, prove GREEN.
- **No `Co-Authored-By` tags on commits.** Imperative subject. Short body, only
  if the why isn't obvious from the diff. Each commit logically scoped — don't
  dump a phase into one commit.
- **No emojis** in code, commits, or docs unless explicitly asked.
- **Minimal comments.** Names explain _what_; comments explain _why_ —
  non-obvious constraints, surprising invariants, workarounds for upstream bugs.
  Don't restate the code.
- **Result over throws** for predictable failures. `Result<T, E>` + `ok()`/
  `err()`/`tryResult()` from `@norskvideo/ctl-foundation`. Throws are reserved
  for genuine exceptions.
- **ESLint / biome rules are non-negotiable** — adhere, never ignore. Keep the
  whole project clean (`biome check .` exits 0), not just your own diff.
- **Test temp dirs that get bind-mounted into a container must come from the
  repo-local temp helper**, never `os.tmpdir()`/`/tmp`. On OrbStack, `/tmp` bind
  mounts don't forward host->guest inotify events, so file watchers never fire —
  a failure that looks exactly like a code bug. Unit tests that don't bind-mount
  may keep using `os.tmpdir()`.
- **Address another container by its unique `<id>-<service>-1`, never a bare
  service name, unless the caller is on the instance's own network only.** A
  bare name (`studio`, `media`) resolves on every network the caller is on, and
  on `norsk-net` it matches every instance's container: a call silently lands on
  another instance. `media` sits on `<id>_default` only (Docker Desktop UDP
  replies break on a dual-homed container), so never add `norsk-net` to it or to
  a sidecar. Full rules: norsk-ctl `docs/product-template-format.md`,
  "Networking: how services address each other".
- **Don't pipe test runs to `tail`** — you lose the failure context. Write output
  to a temp file, then tail _that_ file for the results.
- **Evolve the ctl<->product contract additively.** An older ctl must launch a
  newer product release: never repurpose or remove a required launch-interface
  field. When a product genuinely cannot launch on an older ctl it declares a
  named capability in its manifest's `requires: [...]`; ctl refuses only if it
  lacks that capability. Reach for a capability as rarely as possible — it is the
  escape hatch, not the default.
- **Reloading a rebuilt image into a running daemon is `product reload` — a plain
  rebuild never reaches launched instances.** The daemon launches from a STORED
  template snapshot fetched once at `product add`. `product reload` pulls the
  image, re-runs the control plane and re-renders every product-default template
  no instance is using (an in-use one is reported skipped: delete or relaunch the
  instance). `product pull` doesn't re-render. On a ctl that predates this
  (reload refreshed only the manifest) the loop after `build:image` is:
  `instance delete <id> --purge` -> `template remove <t>` -> `product remove <p>`
  (plus `docker rm -f norsk-product-<p>` if the control-plane container lingers,
  else its host port stays allocated) -> `product add --image <img> --license-file
  <lic>` -> `instance launch-template`. Each product ships this as
  `deployment/iterate.sh` (`bun run iterate`); model new ones on it. Two gotchas it
  encodes: (1) the **built image name must satisfy the license's imageRef** — the
  licensed repo (e.g. `norskvideo/norsk-<x>-product`), not a throwaway `:dev` name;
  `product add` 400s on a mismatch, so build UNDER that repo. `licenseTemplate.json`
  records that entry (this product at the image this repo builds) and is what a
  license gets minted from; CI's licence-template step fails if it is missing or
  names another image, so change its `imageRef` whenever `PUBLISH_IMAGE` or the
  build script's `IMAGE_TAG` default changes. (2) the **license must
  entitle the product**: the template's Studio service sets
  `NORSK_PRODUCT_NAME`, which Studio declares at the studio<->media licence
  handshake (the scaffold sets it). A template without it falls back to
  `norsk-studio`, and the licence must then entitle that too. Needs a running daemon
  (`norsk-ctl serve`) + `norsk-ctl` on PATH (the nix shell) + `NORSK_LICENSE_FILE`.
  Anything the daemon can't see for you — a stored template, a rebuilt image — is
  stale until this loop re-runs; don't debug a ghost.
- **The shared CI workflows are single-sourced too.** `.github/workflows/bump.yml`
  and the rest are copied verbatim from `@norskvideo/ctl-dev-kit` (`conventions/`),
  same as this core block and `nix/ctl-shell.nix`; `check:drift` fails CI if a copy
  diverges. Only the `product:` key is per-repo. Never hand-edit the copy — edit
  the dev-kit source and re-sync.
- **CI runs the norsk-ctl this repo pins** (`flake.nix` `ctlVersion`), never S3's
  moving `latest`; only a `ctl-candidate` run tests a channel (`rc`). One nightly
  bump, `bump.yml`, moves the dev-kit, the ctl-* and moq-* packages, and the
  engine/Studio/norsk-ctl pins together, and never pushes to main: it opens one PR
  on `bump/nightly`, and `bot-prs` merges it once checks, smoke, integration and
  the guides gate (build-image) are green on it. A red bump PR is the alarm for a
  bad upstream change, and names what moved; main stays green. To land the rest
  without a bad component: `gh workflow run bump.yml -f components=<the others>`
  (`dev-kit`, `ctl-packages`, `pins`).
- **Flaky checks stay out of gates, never out of sight.** The guides gate runs
  the deterministic tiers; the engine tier runs beside it and reports (summary,
  warning, cell detail) without gating until norsk-ctl task 57 is fixed. A
  known-flaky test goes in `.github/quarantine.json` with an owner, a date and
  an exit condition: `checks` leaves it out of the gating run, then runs it on
  its own and reports it. Fix it and take it out; don't let an entry age.
- **A config-driven workflow needs a parity-checked invariant contract.** If this
  product emits its Studio graph dynamically from config, its load-bearing rules
  are documented as stable-ID statements (`INV-<AREA>-<NNN>`) in an
  `INVARIANTS.md`, and every ID is cited verbatim by an executable rule that
  guards it. The parity guard — `invariantParityFromDisk` from
  `@norskvideo/ctl-dev-kit/testing/invariants` (template + copy-paste test in
  `testing/INVARIANTS.template.md`) — fails CI on any drift: a documented ID
  with no citing rule, or a rule citing an ID the doc doesn't define. A config
  change is never done until the invariant is in BOTH the doc and its rule
  (same ID, added together); a runtime-only invariant the emitted graph can't
  show is declared in the guard's `untestableIds`, still parity-checked. Where
  the doc lives and whether rules sit in one sweep suite
  (`norsk-ctl-product-playout`, `-commentary`) or beside the code they guard
  (`-probe`, `-turnkey-funke-pegasus`) is the repo's choice — the contract is
  the IDs and the guard, not the layout. A **customer-specific** workflow (a
  reconciled `input.json` under `customers/<slug>/`) carries its **own**
  `customers/<slug>/INVARIANTS.md`, scoped to that customer, seeded from
  `testing/INVARIANTS.customer.template.md` and guarded by a parity test whose
  `testDirs` is scoped to that customer's tests; assert against the composed
  graph so resolved defaults (segment durations, resolutions, output count) are
  guarded, not just literal config.
- **Invariants are a developer-managed contract — never edit them autonomously.**
  An `INVARIANTS.md` (product or customer) records what a human, or a paying
  customer, has decided must not regress. Claude / automated agents MUST NOT add,
  edit, remove, or renumber invariant rows — or weaken the tests that guard them
  — on their own initiative; those changes come only through deliberate
  interaction with a developer. If a code change appears to conflict with an
  invariant, STOP and surface it to a developer rather than "fixing" the
  invariant to make a test pass. This is precisely what protects against
  accidental regressions to the things that matter most.
- **Know who reads it — name the persona before writing docs or a
  persona-facing surface.** Every Norsk ctl product writes for the same four
  reader personas — Evaluator, Builder, Integrator, Administrator (SRE) —
  defined in `@norskvideo/ctl-dev-kit/conventions/personas.md`, with this
  product's own audiences mapped onto them in its `docs/personas.md`. Before
  writing or restructuring docs, a configure/dashboard surface, onboarding
  material, or any copy a persona will read, name that reader and honour the
  ownership handoff (Evaluator/Builder -> product repo, Administrator -> ctl,
  Integrator -> both). Runtime actors (the roles people play in the deployed
  product) are NOT doc-reader personas, and neither are norsk-ctl's access
  roles, whose names collide with two of these by coincidence: whoever stands
  the deployment up and runs it reads as the Builder; guests who are invited in receive
  onboarding kit, they don't read the manual.

<!-- END ctl-shared-conventions v1 -->
