O

OpalFlame Studio

Sign in

Standards

Published format standards.

These are the live, normative specifications for OpalFlame project packages. Fetch the raw markdown; it is served from the same files the toolchain validates against, so it cannot drift from the real standard.

Documents

  • Project package format v3 (.ofp3.tgz)implemented
    The normative standard for project packages: container layout, manifest, status vocabulary, params and refs, media placement, payload reconciliation and the invariants an importer enforces. This is the document to hand an external project builder.
    /standards/format-standard-v3.md (raw markdown)

  • Archive container (parity-protected storage)specification only, not implemented
    The long-term storage container that carries a package and survives media decay. Specification only -- the ofpack and ofverify tools are not implemented, so do not build against this expecting a working toolchain.
    /standards/archive-container.md (raw markdown)

Current

Project package format v3 (.ofp3.tgz)

# RFC-OPALFLAME-FORMAT-STANDARD-V3

Status: **NORMATIVE**. Authored 2026-07-27 (beacon-83eb) per
`docs/OPALFLAME-FORMAT-STANDARD/1-write-rfc-opalflame-format-standard-v3.md`.

This document is the single authority for the OpalFlame project package format.
Where an implementation and this RFC disagree, one of them is defective; resolve
it explicitly and record which moved. They must never drift apart silently.

Key words MUST, MUST NOT, SHOULD, MAY are used in the RFC 2119 sense.

Supersedes as the authority: `docs/packaging.ofproj.tgz.md`,
`docs/project_archive_protocol.md`, `docs/project_archive_protocol_a1.md`. Those
remain as historical/implementation notes; the out-of-repo pointer notes in the
latter two are VOID -- the authority is in-repo, here.

---

## 0. Baseline and grounding

The v2 baseline required by STEP ZERO was exported BEFORE any code in this
programme changed:

    source   : project 29 (pgtl-fix-iran), exported v2, 0 warnings
    size     : 49,505,248 bytes
    sha256   : 5a2e6d5212a721b1778c5fb7035d44aa39858a7d7ff6cd20d4fcd878ba910ac5
    members  : 151 -- 42 beat-audio, 56 extra/ VO takes, 1 shared/music,
               plus images/json

    on beacon : /var/www/opalflame.com/tests/fixtures/baseline-v2.ofproj.tgz
    archived  : gdrive:beacon-archive/format-standard-baseline/

**The baseline is deliberately NOT committed to git.** WO 1 was granted latitude
over this path ("path may be revised by WO 1") and exercises it here: a 48MB
binary in the repo is permanent, unshrinkable history for every future clone, to
carry a file that never changes. Identity is established by the sha256 above --
which is what the acceptance in WO 8 actually compares against -- not by the
blob's presence in version control. Any host can fetch it from gdrive and verify
it against this hash.

It is frozen. It MUST NOT be regenerated. Every downstream claim in this
programme is measured against it. WO 4 and WO 8 MUST verify the sha256 before
use and fail loudly on mismatch.

Aldaran (project 2) was exported first and REJECTED as the baseline: its media
has been archived off-disk, so it exports as a 1.8MB skeleton with a warning per
missing file. A baseline must exercise media placement, so the richest
zero-warning export was used instead.

### 0.1 Corrections to the programme's stated premises

Two claims in `0-index.md` / WO 5 were checked against the live system and are
WRONG. They are corrected here, and this RFC's text governs.

**(a) `audio_status` is a SCENE field, not a beat field.** The programme cites
`backend/models.py:505` as beat-level `audio_status`. Verified: `studio_shots`
has no `audio_status` column at all. Its columns are `audio_clip` and
`dirty_audio`. `audio_status` lives on `studio_segments` (the scene), alongside
`audio_path`. The three-way status table in `0-index.md` therefore compares two
beat fields against one scene field. This RFC specifies them at their real
levels.

**(b) Beat VO is NOT dropped on import.** An earlier diagnosis (recorded in
`ops/wo/ready/of_pgtl_row_furniture_engine.md`) claimed a package carrying 99
audio files imported with zero audio landing. That diagnosis looked at
`studio_tracks` / `studio_takes` (the take system) and found 0. Beat VO does not
live there -- it lives in `studio_shots.audio_clip`. Verified: projects 29 and 43
BOTH carry 42 of 46 beats with audio. What project 43 actually lost on import was
the **`extra/` alternate-takes stash: 56 files, all of them**. Those takes turned
out to be SUPERSEDED bad takes that had been re-recorded (Davey, 2026-07-27), so
losing them was harmless and in fact desirable -- see section 6.1, which makes it
policy at the exporter. The defect was never the discarding; it was doing it
SILENTLY, with `"warnings": []`, so that nobody could tell the difference between
this case and a real loss. The WO has been corrected.

The general defect both findings point at is the same, and is specified in
section 7: **the importer does not account for what it unpacked.**

### 0.2 Live laundering census (WO 5's question, answered early)

WO 5 asks for counts of out-of-vocabulary values in the live database, because
that is "the difference between a theoretical bug and a live one." It is live:

| field | value | rows |
|---|---|---|
| `studio_shots.status` | `ready` | 9 |
| `studio_shots.clip_status` | `ready` | 25 |
| `studio_segments.audio_status` | `ready` | 1 |

`ready` is in NO documented vocabulary. It was accepted by the `== "final"`-only
test, persisted, and is re-emitted by every export -- exactly the laundering this
programme exists to stop. 35 rows are affected. Section 4.4 specifies what
happens to them.

### 0.3 The v3 baseline fixture (WO 4)

`convert-ofproj-ofp3` run once against the section 0 v2 baseline, with no
`--repair` needed to pass `CHECK-OFP3`:

    output   : tests/fixtures/baseline-v3.ofp3.tgz
    size     : 33,582,649 bytes (v2's 49,505,248 minus 56 dropped extra/
               takes, 18,351,834 bytes -- exact)
    on beacon : /var/www/opalflame.com/tests/fixtures/baseline-v3.ofp3.tgz
    archived  : gdrive:beacon-archive/format-standard-baseline/baseline-v3.ofp3.tgz

**Not committed to git**, same rationale as the v2 baseline in section 0: a
33MB blob is permanent unshrinkable repo history to carry a file nobody edits
by hand.

**Identity caveat this fixture has that the v2 one does not:** the v2 baseline
is a raw export, byte-fixed forever, so its file sha256 alone is a stable
identity. The v3 fixture is the OUTPUT of `convert-ofproj-ofp3`, and
`manifest.json.created` is stamped fresh by `ofarchive.pack()` on every run
(documented, WO 2). Two independently-produced v3 archives from the same v2
input are therefore never byte-identical, even though their content is --
confirmed by WO 4: two conversion runs of the section 0 baseline unpacked to
identical trees except for that one field. **Do not use the raw archive
sha256 as a stability check for this fixture** -- compare unpacked content
(or the manifest's own per-member inventory sha256/bytes, which already
excludes itself) instead. This is why the fixture is archived as a concrete
file (so WO 8 has something to diff against) rather than pinned by a single
top-level hash the way v2 is.

It is frozen at the byte level as archived. If a later WO legitimately
changes the v3 output shape, this fixture is regenerated deliberately, in
that WO, with the diff reviewed -- never silently as a side effect (per WO 4's
own text).

**Amendment (WO 8, of_wo8_compare_export_convert):** `manifest.json` also
carries producer-identity fields that are, by construction, never expected to
agree between two different producers of the same content -- `generator`
names which tool wrote the archive (`convert_ofproj_ofp3` for the CLI
converter, `studio` for a live website export); `converted_from` is stamped
only by the CLI converter (the input `.ofproj.tgz` filename) and is absent
from a studio export, which never converts anything; `source_project` is
stamped only by a studio export (the live project's slug) and is absent from
the converter's output, which has no studio project behind it. These three
fields plus `created` are the manifest's complete set of producer-provenance
fields (`backend/ofarchive.py`'s `pack()`, the sole manifest author, docs
this set as `{schema_version, kind, slug, name, created, generator,
inventory, **extra}` -- `converted_from`/`source_project` arrive via
`extra`). None of the four describe archive CONTENT and none are covered by
the inventory's own sha256/bytes. WO 8's acceptance run confirmed this is the
*entire* diff between `convert-ofproj-ofp3`'s CONVERTED output and the
studio's EXPORTED output of the same imported project -- every other byte of
`manifest.json`, and all of `project.json`/`beats.json`/`scenes.json`, were
identical. Comparisons under this RFC (WO 8's acceptance chain, and any
future one) MUST exclude this field set when judging manifest equivalence;
a difference confined to it is JUSTIFIED, not a defect.

---

## 1. Identity and versioning

| thing | v2 (current) | v3 (this RFC) |
|---|---|---|
| extension | `.ofproj.tgz` | `.ofp3.tgz` |
| `manifest.kind` | `opalflame-project` | `opalflame-project` |
| `PROJECT_SCHEMA_VERSION` | 2 | **3** |

A v3 importer MUST accept `schema_version: 3` only. It MUST refuse 1 and 2 with
error `E_SCHEMA_UNSUPPORTED`, naming the version and directing the operator to
`convert-ofproj-ofp3`. Accepting v2 directly inside the v3 importer is forbidden
-- that is the try/except fallback this programme exists to eliminate. v2 support
is a pipeline (convert -> check -> import), with an auditable intermediate
artifact.

---

## 2. Container

A gzipped tar. Every member path MUST be relative, MUST NOT contain `..`, MUST
NOT be absolute, and MUST NOT be a symlink or device node. A violating member is
`E_MEMBER_UNSAFE` and the package is refused whole -- not skipped.

`manifest.json` MUST exist and carry:

    kind            "opalflame-project"
    schema_version  3
    inventory       [ {path, sha256, bytes}, ... ]

The inventory MUST list every non-directory member except `manifest.json`
itself. On import each listed member MUST be verified: a mismatch of sha256 or
bytes is `E_INVENTORY_MISMATCH`. A member present in the tar but absent from the
inventory is `E_INVENTORY_UNLISTED`. Both refuse the package.

---

## 3. Files

| file | required | holds |
|---|---|---|
| `manifest.json` | yes | identity, inventory |
| `project.json` | yes | project settings, cast, music, furniture |
| `beats.json` | yes | the beat list |
| `scenes.json` | yes | scene list (incl. scene `audio_status`) |
| `script.md` | no | fallback narration when `beats.json` carries none |
| `beats/<beat_key>/` | no | per-beat media |
| `shared/` | no | media referenced by more than one beat |
| `extra/` | no | keep-everything stashes (`prev_images`). NOT VO takes -- see 6.1 |
| `attachments.json` | no | the attachment index -- see 3.1 |
| `attachments/` | no | project ATTACHMENTS: chain-of-title + paperwork. RESTORED on import -- see 3.1 |

---

### 3.1 `attachments/` -- files kept with the project

Added by of_project_attachments. Davey, 2026-08-01: *"accept file attachments
that import export without loss so i can attach files to projects that persist,
that is all."*

An attachment is any file the project owns -- a contract, a promo cut, a script,
a nomination letter. **The format records no taxonomy and makes no judgement
about what a file is for.** It promises only that the file travels and arrives
unchanged.

**`attachments/` is not `extra/`.** They are one letter apart and mean opposite
things:

| | `extra/` | `attachments/` |
|---|---|---|
| purpose | keep-everything stash | files the project owns |
| on import | retained in the archive stash, **not** restored (sec 6.1) | **restored** as real project attachments |
| may be dropped? | yes, by design | no -- an export that drops one is lossy |

**`attachments.json`** (root, optional) is a JSON list, one object per
attachment:

    path          attachments/<file>   (normative: inventoried like any member)
    label         display name
    filename      original upload filename
    content_type  declared MIME type
    bytes         size
    sha256        integrity across the round trip
    note          OPTIONAL free text

Every attachment is a normal inventoried, sha256+byte-verified member (sec 7):
the slot changes what import DOES with a member, never the container rules. An
attachment whose bytes do not match its recorded sha256 is REFUSED on import --
"without loss" has to mean bit-identical or it means nothing.

**Templates carry no attachments.** A template is a furniture skeleton for a new
episode, and one episode's files do not belong to another.

**Import reports the count.** Silence is the defect (sec 6.1); a silent
placement is as bad as a silent drop.

---

## 4. The status vocabulary (the motivating defect)

### 4.1 THE ASYMMETRY CALL

v2 spells the zero value three different ways for the same concept:

    image_status : empty | draft | final      (zero = "empty")
    clip_status  : none  | draft | final      (zero = "none")
    audio_status : none  | draft | final      (zero = "none", scene-level)

**DECISION: v3 NORMALIZES. The vocabulary is `none | draft | final` for all
three. `empty` is not a v3 value.**

Reasoning, recorded so it is not relitigated:

1. **`none` is already the majority spelling** -- two of the three fields use it.
   Normalizing toward the minority would churn more data for no gain.
2. **The fields describe a SLOT, not a beat.** `none` reads correctly as "this
   slot holds no media." `empty` reads as a statement about the beat itself,
   which is a different (and already separately represented) idea. The
   asymmetry is not just cosmetic; it invites the reader to think image status
   means something categorically different from clip status. It does not.
3. **The migration is total and mechanical.** Every v2 `empty` becomes v3
   `none`. There is no case requiring judgment, no information is lost, and the
   mapping is reversible. This is the cheapest possible normalization, and the
   cost only rises the longer it is deferred.
4. **v3 is a new extension.** Nothing existing breaks. v2 packages keep their
   spelling forever and are translated at the boundary by a named tool.

Carrying the wart forward would mean codifying, permanently and deliberately, a
distinction with no meaning -- and every future reader would have to be taught it
is meaningless. WO 2 (`convert-ofproj-ofp3`) owns the migration.

### 4.2 Field placement (normative)

    beats.json[].image_status   none | draft | final
    beats.json[].clip_status    none | draft | final
    scenes.json[].audio_status  none | draft | final

`audio_status` is a SCENE field. It MUST be emitted in `scenes.json` in v3. Not
exporting it (the v2 behavior) is a hole that loses state on round trip: the
value survives in the database, is never written to the package, and is
re-defaulted to `none` on the next import. Closing it costs one field.

Beat audio itself is a beat concern (`audio_clip` -> `beats/<key>/audio/...`) and
is governed by section 6 (media placement), not by a beat-level status field.
v3 does NOT add a beat-level `audio_status`; the media's presence in the
inventory is the authority.

### 4.3 Slot routing (normative)

For a beat carrying an image:

| `image_status` | routing |
|---|---|
| `final` | the media populates `image_final`; `image_draft` MUST be null |
| `draft` | the media populates `image_draft`; `image_final` MUST be null |
| `none` | **conflict** -- see below |

If `image_status` is `none` but an image member is present, that is
`E_STATUS_MEDIA_CONFLICT`. The importer MUST refuse. It MUST NOT guess which the
operator meant, and MUST NOT quietly promote the status to match the media. The
message MUST name the beat key, the field, the value, and the media path.

The same rules apply to `clip_status` / `clip_path`.

The v2 behavior of defaulting `clip_status` to `final` whenever a clip is present
is REMOVED. The package states the status; the importer does not infer it.

### 4.4 Out-of-vocabulary values

An importer MUST validate the value against the closed vocabulary BEFORE any
routing decision, and MUST refuse an illegal value with `E_STATUS_VOCAB`, naming
beat key, field and value. It MUST NOT coerce, MUST NOT fall through to a draft
slot, and MUST NOT persist the value.

This applies uniformly. The v2 special case where `kind` alone was validated and
coerced while the status fields were not is REMOVED: one rule governs the whole
function. `kind` is likewise a closed vocabulary and an illegal value is
`E_KIND_VOCAB` -- refused, not coerced to `normal`.

**The 35 live `ready` rows.** `convert-ofproj-ofp3` MUST NOT invent a mapping for
`ready` -- there is no certain translation, and guessing is what created the
problem. The converter emits the field as-is and records it in its report as
unmappable. `CHECK-OFP3` then repairs it, deriving the value from the archive's
actual disk contents (media present and referenced as final -> `final`; present
as draft -> `draft`; absent -> `none`). Repair happens only in the operator-run
tool, never at the import boundary. A converted package that still contains
`ready` MUST fail CHECK-OFP3 until repaired, and MUST be refused by the importer.

A live-database migration for those 35 rows is required and is WO 6's
responsibility; the same derive-from-media rule applies.

---

## 5. `params` and refs

`params` is passed through VERBATIM (INV-13) except for these documented
exceptions, which are the complete list:

- path-bearing keys are rebased to the destination (`bed`, `onscreen_shape[].png`);
- `onscreen_text` has two shapes -- a bare string (pure copy) and a dict (copy
  plus geometry `x/y/size/color/stroke/start/end/fade`). On TEMPLATE build the
  string form is dropped and the dict form survives with `text` blanked, because
  placement and styling are the show's format while copy is the episode's.

**Unknown keys are preserved, not dropped.** An importer MUST NOT discard a
`params` key it does not recognize; forward compatibility depends on this, and
silent key loss is the same defect class as silent media loss. An importer that
cannot rebase a path-bearing key it DOES recognize MUST refuse
(`E_PARAM_PATH_UNRESOLVABLE`), not pass a stale path through.

`refs` is authoritative over `params.ref_images`, which is overwritten on
import. The cap is 8; more than 8 is `E_REFS_CAP`.

---

## 6. Media placement

Beat-owned media lives under `beats/<beat_key>/` (`img/`, `audio/`, `clip/`).
Media shared by more than one beat lives under `shared/`.

Path fields in `beats.json` and `project.json` hold ARCHIVE-RELATIVE paths. An
absolute path, or a path escaping the archive root, is `E_PATH_ESCAPE`.

A beat carrying no media is legal: all its status fields are `none` and it has no
`beats/<key>/` directory.

### 6.1 Only the LIVE take ships (Davey, 2026-07-27)

**An exporter MUST NOT pack superseded VO takes.** For each beat, exactly the
audio file currently in use is packed. Takes that were re-recorded and replaced
are dead weight: they are never restored, never referenced, and never wanted.

    Davey, 2026-07-27: "only the actual voice file in use is needed, all the bad
    ones I did over can go away at export ... we will never need the old, it's
    wasted space."

This REPLACES an earlier rule in this RFC which required a full import to place
every `extra/` member. That rule was written on the assumption that the 56
alternate takes lost by a v2 import were valuable; they were superseded bad
takes, and discarding them was the desired outcome. The v2 behaviour was wrong
only in being SILENT, not in dropping them -- and the correct place to fix it is
the exporter, which should never have packed them.

Consequences:

- `extra/` no longer carries VO takes. It remains legal for genuinely
  non-reproducible keep-everything content (e.g. `prev_images`), and anything
  it does carry is subject to section 7 like any other member.
- Packages get materially smaller. Measured on the frozen v2 baseline: 151
  members / 52.8MB of payload, of which **56 members / 18.4MB are superseded
  takes -- 35% of the archive**, carried for nothing. Every export, transfer,
  gdrive upload and cold-storage copy has been paying that third.
- The exporter MUST record in its report how many superseded takes it omitted,
  so the omission is visible and auditable rather than silent. Silence is the
  defect; a counted, named exclusion is not.
- `convert-ofproj-ofp3` (WO 2) MUST drop superseded takes when upgrading a v2
  package, reporting the count. A v3 package containing superseded VO takes
  fails CHECK-OFP3.

Determining WHICH take is live is the exporter's job and MUST be explicit: the
take referenced by the beat's `audio_clip` is live; every other audio file
associated with that beat is superseded. If the live take cannot be determined
for a beat that has audio, the exporter MUST refuse (`E_TAKE_AMBIGUOUS`) rather
than guess or pack everything defensively.

---

## 7. Payload reconciliation (NEW IN V3)

This section exists because v2's failure mode was not misreading a field -- it
was accepting media and never placing it, while reporting success.

An importer MUST reconcile what it placed against the manifest inventory. For
every inventory member, exactly one of the following MUST hold at the end of
import:

1. it was placed at a destination path, recorded in the import report; or
2. it was deliberately excluded by a rule NAMED in the report (e.g. template
   build excluding `extra/`); or
3. import refused.

An unplaced, unexcluded member is `E_PAYLOAD_UNPLACED`. The report MUST list
placed, excluded (with rule) and unplaced counts. **An import report MUST NOT be
clean when members went nowhere.** `"warnings": []` alongside 56 discarded files
is the specific outcome this section forbids.

The inventory already carries `{path, sha256, bytes}`, so this check requires no
new bookkeeping -- only that the importer be held to it.

---

## 8. Invariants

Carried forward with their numbers intact:

- **INV-3** -- media stays relative-pathed.
- **INV-8** -- niche drives publish routing; read through `effective_niche`.
- **INV-9** -- export is read-only on the project and every source file.
- **INV-13** -- `params` verbatim except section 5's exceptions.
- **n reassigned 1..N on import** -- beat `n` is dense and 1-based after import;
  `beat_key` is the stable cross-package identity.

New in v3:

- **INV-14** -- every inventory member is placed, named-excluded, or the import
  refuses (section 7).
- **INV-15** -- no value outside a closed vocabulary is ever persisted
  (section 4.4).

---

## 9. Error taxonomy

Stable identifiers. WO 3 (CHECK-OFP3) and WO 6 (importer) both consume this
list; neither may invent codes.

| code | raised when |
|---|---|
| `E_SCHEMA_UNSUPPORTED` | `schema_version` is not 3 |
| `E_MEMBER_UNSAFE` | absolute path, `..`, symlink, device node |
| `E_INVENTORY_MISMATCH` | sha256/bytes disagree with the member |
| `E_INVENTORY_UNLISTED` | member in tar, absent from inventory |
| `E_STATUS_VOCAB` | status value outside the closed vocabulary |
| `E_KIND_VOCAB` | `kind` outside the closed vocabulary |
| `E_STATUS_MEDIA_CONFLICT` | status `none` but media present, or status names a slot with no media |
| `E_PATH_ESCAPE` | path field absolute or escaping the archive root |
| `E_PARAM_PATH_UNRESOLVABLE` | recognized path key cannot be rebased |
| `E_REFS_CAP` | more than 8 refs |
| `E_PAYLOAD_UNPLACED` | inventory member neither placed nor named-excluded |
| `E_TAKE_AMBIGUOUS` | live VO take for a beat cannot be determined (6.1) |
| `E_MANIFEST_MISSING` | no `manifest.json`, or `kind` wrong |

Every message MUST name the offending beat key (or file), the field, and the
value. "Invalid package" alone is not a conforming message.

---

## 10. Doctrine

Restated because it governs every implementation decision above:

**There is no automatic fallback at the import boundary.** An out-of-spec package
is REFUSED. An operator runs CHECK-OFP3 once, deliberately, and the conformed
package is what gets imported and kept. Nothing this tooling cannot map with
certainty is guessed -- it is a refusal with a specific message.

Repair belongs to CHECK-OFP3. Translation belongs to convert-ofproj-ofp3.
The importer's only jobs are to verify and to place, or to refuse.

---

## 11. AMENDMENT 1 (2026-07-30) -- translation is BIDIRECTIONAL

**Status: normative. Corrects an omission, not a change of direction.**

Davey, 2026-07-30: "convert_ofproj_ofp3.py ... should be back and forth
converter between the two and should fix format errors in v2 when converting
from v2 to v3 and back". On being shown that no document said so: "my intentions
were not correctly recorded so we need to fix that."

### 11.1 What went wrong (recorded so it is not repeated)

`convert-ofproj-ofp3` was ALWAYS intended to translate in BOTH directions. WO 2
captured it as a one-way "upgrade filter"; its spec doc, this RFC and the
implementation each deferred to the layer above and inherited the omission. Four
documents agreed with each other and all four were wrong, because none was
re-checked against the instruction that produced them.

The tool as built (`bin/convert_ofproj_ofp3.py`, v2 -> v3 only) is CORRECT
against its written spec and is not at fault. The spec was incomplete.

### 11.2 Normative requirement

Translation between v2 `.ofproj.tgz` and v3 `.ofp3.tgz` MUST be available in
both directions:

    convert-ofproj-ofp3 IN.ofproj.tgz OUT.ofp3.tgz     # v2 -> v3 (built)
    convert-ofproj-ofp3 IN.ofp3.tgz   OUT.ofproj.tgz   # v3 -> v2 (TO BUILD)

Direction MUST be inferred from the input package's `schema_version`, never from
the filename, and MUST be reported before any work is done.

Both directions MUST repair what they can prove and REFUSE what they cannot, per
sections 4.4 and 9. Repair is not one-directional: a v2 package that is
malformed on the way DOWN is corrected on the same terms as on the way up.

### 11.3 Extra keys are PRESERVED, never dropped and never an error

Davey, 2026-07-30: "there may be some things that v3 supports that v2 does not,
in that case extra keys should be kept and passed not dropped or errored."

This SUPERSEDES an earlier draft of this section which specified the downgrade as
lossy-with-enumerated-drops. Preservation makes the round trip lossless by
construction rather than merely well-documented, and is the requirement.

**Rule (normative, BOTH directions).** A field the target schema has no place
for is NOT dropped, NOT refused, and NOT guessed at. It is CARRIED.

- Every unrepresentable key is written verbatim into an extension carrier in the
  output package, preserving its path and value exactly.
- The carrier is `extra.json` at the package root, keyed by the owning object's
  path, e.g. `{"scenes/3": {"audio_status": "ready"}}`.
- On translation in either direction, an existing `extra.json` is READ FIRST and
  its contents re-merged into any field the target schema DOES represent, then
  whatever still has no home is re-carried. A key therefore survives any number
  of round trips.
- `extra.json` is advisory payload, not schema. A v2 consumer that knows nothing
  about it MUST ignore it (must-ignore semantics) -- its presence never makes a
  v2 package non-conforming.
- An unknown key is NEVER an `E_STATUS_VOCAB` refusal. Refusal is reserved for a
  value in a KNOWN field that is outside that field's closed vocabulary (4.4);
  an unknown FIELD is carried, not judged.

`scenes.json.audio_status` is the worked example: v2 has no such field
(sec 0.1(a)), so on downgrade it moves to `extra.json` and on the next upgrade it
returns to `scenes.json` unchanged.

The LIVE VO take (6.1) is never extension payload -- it is real media and must be
placed properly in both directions, or `E_TAKE_AMBIGUOUS`.

### 11.4 Round-trip invariant

With 11.3 in force the round trip is LOSSLESS in both directions:

    v2 -> v3 -> v2   byte-identical to a conformed copy of the original
    v3 -> v2 -> v3   byte-identical to a conformed copy of the original

excluding ONLY the documented `manifest.json` `created` timestamp. There is no
"fields v2 cannot represent" exemption, because there are no such fields any
more -- they ride in `extra.json`.

Any other difference is a defect in one of the two directions and MUST be
reported by CHECK-OFP3 rather than tolerated.

Implementation is tracked as programme WO 9.