---
contract_version: "2.1"
module: "soong_lab.export"
purpose: "Assemble a versioned export BUNDLE from a DesignObject — the native agents.define payload (Frame Invariant 1, emitted unchanged) + the soong-lab sidecar (portrait ref · Bifrost tool manifest · first_message) + the resume half (the full editable design state), under a versioned schema tolerant of unknown future metadata. Pure + deterministic: no I/O, no persistence, no network (library persistence + import are separate downstream epics)."
depends_on:
  - "soong_lab.design"      # validate_ocean + ROLE_CHOICES/validate_role (the role enum canon) + the DesignObject model + serialize_design (relocated here — see Integration points R1)
used_by:
  - "soong_lab.bifrost"     # the export design-tool handler (_make_export) builds the bundle for the session's design
  - "soong_lab.web"         # the /api/export endpoint + the browser 'Export Asset' modal render the bundle
  - "soong_lab.importer"     # FUTURE (import epic) — round-trips the resume half back into a DesignObject
language: "python"
complexity: "medium"
estimated_loc: 200
confidence: 0.82
assumptions:
  - "The DesignObject handed to export is already mutated to its final state by E3 (the Soong convo loop). Export READS it; it never mutates the design (INV-E5-4)."
  - "design_id is CALLER-SUPPLIED (a param), not generated here. Its generation + lifecycle (the durable library key, ≠ Worldtree session_id) is the per-design-sessions epic; export only needs the value to stamp the bundle. This keeps E5-export self-contained + forward-compatible with per-design-sessions landing before OR after it (agent-discretion, see open_question C)."
  - "exported_at is CALLER-SUPPLIED (a param, default None). Pure builders can't read the clock; the caller (tool handler / endpoint) stamps the timestamp so build_export_bundle stays deterministic + testable (byte-identical output for identical inputs)."
  - "role is a FIRST-CLASS design field (operator ruling 2026-07-13), set by the E3a set_role tool from a CURATED 4-value enum ROLE_CHOICES = {assistant, thoughtful-assistant, character, thoughtful-character} — mirroring the D2 curated-style-modes pattern (a fixed semantic set, NOT the target deployment's arbitrary model-role registry). Export EMITS design.role into ship.native.role, so the native payload is directly valid (Frame Invariant 1 now holds literally — no 'modulo role' caveat; only tools still bind separately at session-create). The 4 enum values are canonical soong-lab labels that map to WT model-role slugs. open_question B RESOLVED (2026-07-14, worldtree-dev via ratatoskr-dev): the 4 labels ARE WT slugs by EXACT name (1:1, no remap) — assistant/thoughtful-assistant under the `foundational` grant (gen / gen-reasoning seats), character/thoughtful-character under the `character` grant (char-rp / char-rp-reasoning seats). So ship.native.role is directly define-valid; the only deploy concern is that the CONSUMER's key must hold the matching grant (define 403s otherwise — same model.use requirement as Soong's own 'agent_architect', ADR-0012). Not a contract blocker."
  - "psych_profile exports to the NATIVE persona layer at persona.psychological_profile. RESOLVED: the vendored canonical spec (docs/psych-profile-authoring-spec.md §4) states the wire shape is LOCKED (b53) — a single prose str field, Tier-3 ValidatedPersona.psychological_profile, nesting under the existing Any-typed persona field (no schema change). Corroborated by worldtree-codex (vor-cross) + brokkr-smithy-dev (althing 01KXD34ZTF…). The open worldtree-dev thread (01KXD1PZR7…) closes as a formality."
open_questions:
  - "[B — deploy grounding, not a blocker] The 4 ROLE_CHOICES values (assistant / thoughtful-assistant / character / thoughtful-character) must be registered + grantable model-role slugs on the TARGET Worldtree (like Soong's own agent_architect role). Confirm with worldtree-dev/infra that these exact slugs exist on the deploy target before shipping; a missing slug fails the designed agent's session-create, not export. Export emits whatever role the design holds; validity of the slug on a given deployment is a deploy concern."
  - "[C — agent-discretion, notable] design_id as a caller-supplied param (drafted) vs E5-export generating it. Drafted as an input so E5-export doesn't force per-design-sessions to land first. If the operator re-sequences the epics so per-design-sessions lands first, no change needed here (the param source just moves)."
  - "[D — scope] E5-export = the PURE builders + validators + bundle schema (this contract). The /api/export endpoint + replacing the web/api.js exportBundle shim = a thin web-surface follow-up (amends web_surface.contract.md), NOT this contract. The Bifrost export-tool wiring IS in scope (Integration points) because the tool already exists as a stub. The set_role tool + DesignObject.role field are a companion prerequisite slice (Integration points) whose contract updates land in THIS pass (design_object + bifrost_server)."
  - "schema_version starts at '1.0'. The version bump policy on future bundle-shape changes (add-only vs breaking) is deferred to when the second version actually exists — v1 only needs the field present + readers to tolerate unknown metadata (INV-E5-6)."
---

## Context

E5-export is the FOUNDATION half of the operator-accepted (2026-07-13)
export/import/library design — the block that expands the locked single-agent
frame into a multi-pass tuning loop (design → export → reopen → tune → keep a
library). This contract owns exactly ONE thing: turning a finished
`DesignObject` into a **versioned export bundle**. Persistence (the library JSON
dir), the recent-designs picker, and import round-tripping are separate
downstream epics; export is pure and deterministic so those epics — and the
tests — can build on a stable, side-effect-free core.

**The bundle is ONE artifact with two halves** (settled decision #4):

- **ship** — what you hand to a deployment: the native `agents.define` payload
  (Frame Invariant 1, emitted unchanged) + the soong-lab **sidecar** (persona
  portrait ref, the Bifrost tool manifest, the D3 first_message).
- **resume** — what you reopen to keep tuning: the full editable design state
  (the §6 DesignObject serialization), so a future import reconstructs the
  DesignObject exactly.

Plus a stable **`design_id`** (the durable library key, ≠ Worldtree
`session_id`) and a **`schema_version`**, both at the top level.

**Frame Invariant 1 is preserved — and now holds literally.** `ship.native` is a
valid Worldtree Tier-3 `agents.define` payload assembled from `agent_name` + the
designed agent's **`role`** (the model-role, resolved below) + the AUTHORED
`system_prompt` (INV-E2-2 — never `composed_preview`) + `persona.ocean`
(Worldtree renders affect at runtime) + `persona.psychological_profile` (the
native home, LOCKED b53 per the vendored spec §4) + `motivational` (from
goals_fears). The image and tools are NOT in the native schema — they ride the
sidecar (tools bind via Bifrost at session-create, exactly as grounded).

**The `role` resolution (operator ruling 2026-07-13).** The blast-radius pass
caught that `agents.define` requires `role` (a model-role slug, ADR-0012) but the
design had no source for it. Resolution: **role is a first-class design field**,
set by a new E3a **`set_role`** tool from a **curated 4-value enum** —
`assistant` (general LLM), `thoughtful-assistant` (CoT general),
`character` (RP/writing-tuned), `thoughtful-character` (CoT RP). This mirrors the
D2 curated-style-modes decision: a fixed semantic set the operator picks from,
NOT a coupling to any one deployment's arbitrary role registry. Export emits
`design.role`, so the native payload is directly POST-valid (modulo the tool
binding every consumer already supplies at session-create). The one deploy-time
caveat: the 4 slugs must be granted on the target Worldtree (open_question B).

**The psych field is RESOLVED (no longer quarantined).** Vendored spec §4 locks
`persona.psychological_profile` (prose `str`, ~150–300 words, read every turn),
nesting under the `Any`-typed persona layer. Export maps `design.psych_profile`
there and NOWHERE else — spec §4's hard constraint is that the self-report lens
reads ONLY this field (leaking psych prose into `behavioral_notes`/`system_prompt`
causes the "executive-assistant" failure).

## Data flow

**In:** a `DesignObject` (final, from E3) + a caller-supplied `design_id` (str)
+ an optional caller-supplied `exported_at` (str | None). **Out:** a plain
JSON-ready `dict` — the versioned bundle. **On disk / network:** NONE. Export is
pure: the OCEAN parity gate (`validate_ocean`), the role-enum gate
(`validate_role`), and the export-critical validators are in-memory; timestamps +
ids come in as params; no clock, no randomness, no file, no HTTP. (Library
persistence writes the returned dict to the JSON dir — that is the library epic,
not this module.)

### Export bundle schema (v1.0)

```
{
  "schema_version": "1.0",                 # ALWAYS EXPORT_SCHEMA_VERSION — not a caller param
  "design_id": "<caller-supplied durable library key, ≠ WT session_id>",
  "exported_at": <caller-supplied OPAQUE str | null — conventionally ISO-8601, NOT validated by export>,
  "ship": {
    "native": {                       # a valid agents.define payload (Frame Invariant 1)
      "agent_name": <str, non-blank, ≤128>,
      "role": <one of ROLE_CHOICES: assistant|thoughtful-assistant|character|thoughtful-character>,
      "system_prompt": <str, non-blank, ≤32768 — the AUTHORED block, INV-E2-2>,
      "persona": {
        "ocean": {O,C,E,A,N},         # each a real number in [-1,1] (validate_ocean parity)
        "psychological_profile": <str>   # persona.psychological_profile (LOCKED b53); included iff non-blank
      },
      "motivational": {                          # WT #187 OBJECTS, not strings (ratatoskr-dev bug 2026-07-16); iff goals_fears present + non-empty
        "goals": [{"id": "goal-N", "type": <maintenance|achievement|avoidance>, "salience": <0..1>, "description": <str>=20 chars>}],
        "fears": [{"id": "fear-N", "salience": <0..1>, "description": <str >=20 chars>}]
      }   # `id` synthesized at export (goal-N/fear-N, unique across both); validate_exportable gates description>=20 / type∈GOAL_TYPES / salience∈[0,1]
    },
    "sidecar": {
      "portrait": <image ref str | null>,   # only when portrait.status == "ready"; E4 owns generation
      "tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create)
      "first_message": <str>                 # the D3 opening turn (issue #347 seed)
    }
  },
  "resume": { <the §6 camelCase editable state — key set inlined below> }
}
```

**The `resume` key set (inlined — heid-review fold Gróa #9).** The resume half IS
`serialize_design(design)` (relocated to `soong_lab.design`, R1), but its key set is
pinned HERE so this contract is self-contained and an implementer knows the exact
round-trip surface without reading the external, being-relocated function:

```
resume = {
  "agentName", "role", "systemPrompt", "composedPreview", "firstMessage",
  "ocean" {O,C,E,A,N}, "dispositionPhrase", "psychProfile",
  "tools" [{id,name,description}], "portrait" {status, styleMode, imageUrl?, jobId?},
  "goalsFears" {goals,fears} | null
}
```

Import reconstructs a DesignObject from exactly these keys. `role` (new, R1) MUST be
present so a reopened design carries its model-role. (`composedPreview` +
`dispositionPhrase` are design-time-derived and re-derivable, but they ride the resume
so a reopen renders instantly before the first recompute.)

**Divergences from the imported web mock (settled here, they were UI-comp
shortcuts):**

| Field | Mock (web/*.js) | Real export (this contract) |
|---|---|---|
| native shape | `{name, tier, system_prompt, personality:{model,values}}` | real `agents.define` (`agent_name`/`role`/`persona.ocean`/`motivational`) |
| role | absent | `design.role` ∈ ROLE_CHOICES |
| system_prompt | `composedPreview` (mockApi) | authored `system_prompt` (INV-E2-2) |
| psychProfile | omitted ("open backend decision") | `persona.psychological_profile` (LOCKED b53) |
| bundle identity | none | `design_id` + `schema_version` |
| resume half | none | full `serialize_design` state |

## Invariants

- **INV-E5-1** [hard]: `ship.native` is a valid Worldtree `agents.define` payload
  MODULO the tool binding — it carries every required field (`agent_name`,
  `role`, `system_prompt`) + `persona.ocean`, and OMITS only the tools (they bind
  via Bifrost at session-create, as they already do). Any `persona.ocean` export
  emits passes `validate_ocean`; `role` is always one of ROLE_CHOICES.
  `persona.psychological_profile` + `motivational` are OPTIONAL native fields
  (grounded) — omitting them when blank/empty keeps the payload fully valid, not
  merely "valid enough" (heid-review fold, Gróa #1).
- **INV-E5-2** [hard]: The exported `system_prompt` is the AUTHORED
  `design.system_prompt`, NEVER `composed_preview` (binds with INV-E2-2). The
  **disposition line** — the `"Disposition: <name> is <phrase>."` sentence that
  E2 `recompute` appends to `composed_preview` (design_object.contract.md POST-E2-5)
  — is design-time-only and never ships.
- **INV-E5-3** [hard]: Export is pure + deterministic — identical
  `(design, design_id, exported_at)` inputs yield a byte-identical serialized
  bundle. No clock, no randomness, no I/O. The determinism is WITHIN the module:
  the returned dict has a fixed key insertion order (schema_version, design_id,
  exported_at, ship, resume; native + sidecar likewise), so any consistent
  `json.dumps` settings produce byte-identical output — the invariant does NOT
  claim cross-implementation byte-identity (heid-review fold, Regin #6).
- **INV-E5-4** [hard]: Export NEVER mutates the input `DesignObject` (read-only);
  the bundle holds copies, not aliases, of every mutable sub-structure (ocean
  dict, tool list, goals/fears lists) so a later design mutation can't change an
  already-built bundle.
- **INV-E5-5** [hard]: `validate_exportable` is the strict export-critical gate
  (decision #6): OCEAN (via `validate_ocean`), role (∈ ROLE_CHOICES via
  `validate_role`), agent_name (non-blank, ≤128), system_prompt (non-blank,
  ≤32768), tool-refs (id/name non-blank + bounded). A design that fails ANY of
  these raises `ExportError` and NO bundle is produced — a built bundle is always
  well-formed enough to round-trip on import.
- **INV-E5-6** [hard]: The bundle carries `schema_version` at the top level, and
  readers (import, future) MUST tolerate unknown extra keys (lenient on unknown
  metadata, decision #6) — the schema is add-only-friendly.
- **INV-E5-7** [hard]: `psych_profile` maps to `persona.psychological_profile`
  and NOWHERE else — it never leaks into `behavioral_notes`, `system_prompt`, or
  any other native field (vendored spec §4 hard constraint — the lens reads only
  this dedicated field).

## Constraints

- **[correctness]** `validate_exportable`'s OCEAN check IS `validate_ocean` and
  its role check IS `validate_role` (both E2) — no re-implementation, no drift.
  The LENGTH bounds (name, prompt, tool id/name/desc, psych_profile, first_message)
  MUST equal the E3a tool-schema caps — now shared constants in `soong_lab.design`
  (`AGENT_NAME_MAX`, `SYSTEM_PROMPT_MAX`, `PSYCH_PROFILE_MAX`, `FIRST_MESSAGE_MAX`,
  `TOOL_*_MAX`), imported by BOTH bifrost/tools.py and export — so a design's field
  LENGTHS never drift. Import the shared constants; do not re-declare the numbers.
  (Export is stricter only on whitespace-blankness of the required fields — the one
  intentional one-way difference from the tools' minLength:1.)
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Every
  time-varying value (`design_id`, `exported_at`) is a param.
- **[explicit]** The one deploy-time caveat (the 4 role slugs must be granted on
  the target WT) is documented in THIS contract (open_question B) + the library /
  README when it lands — NOT promised as a bundle/sidecar field (heid-review fold:
  the bundle is machine-consumed; a human deploy-note is not bundle data). The
  bundle carries the `role` value; slug-grant validity is a deploy concern.
- **[explicit]** `build_export_bundle` is the PUBLIC entrypoint — it runs the
  validate→assemble ordering. `build_native_payload` / `build_sidecar` are exposed
  for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4);
  a direct caller that skips `validate_exportable` owns that gate (heid-review fold,
  Hulda #5).

```contract
FN validate_exportable(design: DesignObject) -> None
BRIEF: The strict export-critical gate (settled decision #6) — refuse to build a bundle from a design that would fail on re-import or at the designed agent's define/session-create. Checks OCEAN (validate_ocean), role (validate_role), agent_name, system_prompt, every tool-ref, and the psych_profile/first_message LENGTH — against the SAME length caps the E3a tools enforce (shared constants). NO-DRIFT is one-directional: export's LENGTH bounds equal the tool caps, but export is deliberately STRICTER on whitespace — a whitespace-only required field (name/prompt/tool id/name) passes the tools' minLength:1 yet is rejected here (a "   " name must not ship). Raises ExportError with the offending field; never mutates the design.
PRE: [PRE-E5-1 hard] design is a DesignObject
POST: [POST-E5-1 exception] raises ExportError(field, detail) unless ALL hold: design.ocean passes validate_ocean; design.role passes validate_role (∈ ROLE_CHOICES); agent_name is a non-blank str of len ≤ _AGENT_NAME_MAX; system_prompt is a non-blank str of len ≤ _SYSTEM_PROMPT_MAX; every tool has non-blank str id (≤_TOOL_ID_MAX) + non-blank str name (≤_TOOL_NAME_MAX) + str description (≤_TOOL_DESC_MAX); psych_profile is a str of len ≤ _PSYCH_PROFILE_MAX (blank OK); first_message is a str of len ≤ _FIRST_MESSAGE_MAX (blank OK). The id/name-required vs description/psych/first_message-may-be-blank asymmetry is INTENTIONAL — description defaults to "" via attach_tool; psych_profile/first_message are optional prose so only their LENGTH is bounded, not blankness (heid-review Gróa #8 + correctness-finder folds)
POST: [POST-E5-2 state_change] design is unchanged — no mutation (INV-E5-4)
STEPS:
  1. [setup, flexibility=prescriptive] TRY validate_ocean(design.ocean) — on OceanError, RAISE ExportError("persona.ocean", str(exc)) (reuse E2, no re-impl)
  2. [sequential, flexibility=prescriptive] TRY validate_role(design.role) — on RoleError, RAISE ExportError("role", str(exc)) (reuse E2 role canon)
  3. [branch] IF agent_name is not a non-blank str OR len > _AGENT_NAME_MAX: RAISE ExportError("agent_name", ...)
  4. [branch] IF system_prompt is not a non-blank str OR len > _SYSTEM_PROMPT_MAX: RAISE ExportError("system_prompt", ...)   # the AUTHORED block, INV-E5-2
  5. [loop] FOR EACH tool in design.tools: IF id/name blank or over max, or description non-str/over max: RAISE ExportError(f"tools[{i}]", ...)
  6. [branch] IF psych_profile is non-str OR len > _PSYCH_PROFILE_MAX: RAISE ExportError("psych_profile", ...)   # length only — blank OK (optional prose)
  7. [branch] IF first_message is non-str OR len > _FIRST_MESSAGE_MAX: RAISE ExportError("first_message", ...)   # length only — blank OK
  8. [cleanup] RETURN None
TESTS:
  minimal_ok [happy,tracer]: agent_name+system_prompt set, role="character", neutral OCEAN, no tools → no raise
  blank_name [adversarial]: agent_name="" → ExportError("agent_name")
  blank_prompt [adversarial]: system_prompt="   " → ExportError("system_prompt")
  prompt_too_long [boundary]: system_prompt of len _SYSTEM_PROMPT_MAX+1 → ExportError; len _SYSTEM_PROMPT_MAX → ok
  bad_ocean [adversarial]: ocean missing a key → ExportError("persona.ocean") (via validate_ocean)
  bad_role [adversarial]: role="wizard" (not in ROLE_CHOICES) → ExportError("role") (via validate_role)
  blank_role [adversarial]: role="" → ExportError("role")
  bad_tool_ref [adversarial]: a tool with id="" → ExportError("tools[0]")
  no_mutation [property]: a rejected design is byte-identical before/after the raise (INV-E5-4)
  psych_profile_length [boundary]: psych_profile="" → ok; len _PSYCH_PROFILE_MAX+1 → ExportError("psych_profile")
  first_message_length [boundary]: first_message len _FIRST_MESSAGE_MAX+1 → ExportError("first_message"); blank → ok
  whitespace_name_rejected [adversarial]: agent_name="   " → ExportError("agent_name") — deliberately stricter than the tool's minLength:1 (a whitespace-only name must not ship)
  length_bounds_parity [property]: any (name, prompt, tool, psych, first_message) LENGTH the E3a tool schema accepts is ≤ export's caps (shared constants); export is stricter ONLY on whitespace-blankness of required fields, never looser on length
```

```contract
FN build_native_payload(design: DesignObject) -> dict[str, Any]
BRIEF: Map a DesignObject to a valid native agents.define payload (Frame Invariant 1). Emits agent_name + role + the AUTHORED system_prompt + persona{ocean, psychological_profile?} + motivational?. Copies mutable sub-structures (INV-E5-4). Assumes validate_exportable already passed (called by build_export_bundle).
PRE: [PRE-E5-2 hard] design passed validate_exportable (OCEAN valid, role valid, name/prompt present) — build_export_bundle enforces this ordering
POST: [POST-E5-3 return_value] result has agent_name == design.agent_name, role == design.role (∈ ROLE_CHOICES), and system_prompt == design.system_prompt (the AUTHORED block, INV-E5-2), and result["persona"]["ocean"] == a COPY of design.ocean
POST: [POST-E5-4 return_value] result["role"] == design.role — the designed agent's model-role (one of the 4 ROLE_CHOICES); a valid agents.define required field
POST: [POST-E5-5 return_value] persona.psychological_profile == design.psych_profile when psych_profile is non-blank, else the key is absent; it appears under persona and NOWHERE else (INV-E5-7)
POST: [POST-E5-6 return_value] motivational == {"goals": copy, "fears": copy} when design.goals_fears is present AND at least one list is non-empty; else the key is absent (never an empty motivational block)
STEPS:
  1. [setup] payload = {"agent_name": design.agent_name, "role": design.role, "system_prompt": design.system_prompt}   # role emitted; system_prompt is the authored block (INV-E5-2)
  2. [sequential] persona = {"ocean": dict(design.ocean)}   # COPY, not alias (INV-E5-4)
  3. [branch] IF design.psych_profile is a non-blank str: persona["psychological_profile"] = design.psych_profile   # LOCKED b53 field; ONLY here (INV-E5-7)
  4. [sequential] payload["persona"] = persona
  5. [branch] IF design.goals_fears is not None AND (goals or fears non-empty): payload["motivational"] = {"goals": list(gf.goals), "fears": list(gf.fears)}
  6. [cleanup] RETURN payload   # tools NOT here — they ride the sidecar / bind via Bifrost at session-create
TESTS:
  authored_prompt [happy,tracer]: system_prompt authored + composed_preview differs → payload.system_prompt == authored, NOT composed_preview (INV-E5-2)
  role_emitted [happy]: role="thoughtful-character" → payload.role == "thoughtful-character" (POST-E5-4)
  ocean_copied [property]: mutate design.ocean after build → payload's ocean unchanged (INV-E5-4)
  psych_present [happy]: psych_profile set → persona.psychological_profile == it; it is the ONLY field carrying it (INV-E5-7)
  psych_absent [boundary]: psych_profile="" → no psychological_profile key
  motivational_present [happy]: goals_fears with goals=["x"] → motivational.goals == ["x"]
  motivational_absent [boundary]: goals_fears None → no motivational key; goals_fears with both lists empty → no motivational key
  no_tools_no_image [trace]: payload has no "tools" and no image field (they ride the sidecar / bind separately)
```

```contract
FN build_sidecar(design: DesignObject) -> dict[str, Any]
BRIEF: Assemble the soong-lab sidecar — the three artifacts the native schema has no home for: the persona portrait ref, the Bifrost tool manifest, and the D3 first_message. Copies the tool list (INV-E5-4).
PRE: [PRE-E5-4 hard] design is a DesignObject (its portrait/tools/first_message fields are read as-is; no validation here — validate_exportable is the gate, called by build_export_bundle before this)
POST: [POST-E5-7 return_value] result == {"portrait": <str|None>, "tools": [{"id","name","description"} per tool, copied], "first_message": design.first_message}; portrait == design.portrait.image_url IFF design.portrait.status == "ready", else None (a "ready" status with a None image_url therefore yields None — no crash; any non-"ready" status → None — heid-review fold Gróa #4)
STEPS:
  1. [setup] portrait = design.portrait.image_url if design.portrait.status == "ready" else None
  2. [sequential] tools = [t.to_dict() for t in design.tools]   # ToolRef.to_dict() — the shared {id,name,description} projection (dedups with serialize_design); it MUST emit exactly id/name/description, so if to_dict ever grows keys the sidecar spec must be revisited (heid-code-review fold)
  3. [cleanup] RETURN {"portrait": portrait, "tools": tools, "first_message": design.first_message}
TESTS:
  ready_portrait [happy]: portrait.status="ready", image_url set → sidecar.portrait == the url
  unready_portrait [boundary]: portrait.status="generating" (url set) → sidecar.portrait is None (only ready ships)
  none_portrait [boundary]: portrait.status="none" → sidecar.portrait is None
  tools_manifest [happy,tracer]: two tools → sidecar.tools has both {id,name,description}
  tools_copied [property]: mutate design.tools after build → sidecar.tools unchanged (INV-E5-4)
  first_message [happy]: first_message set → sidecar.first_message == it
```

```contract
FN build_export_bundle(design: DesignObject, *, design_id: str, exported_at: str | None = None) -> dict[str, Any]
BRIEF: The top-level export entrypoint — validate (strict, INV-E5-5), then assemble the versioned bundle: {schema_version, design_id, exported_at, ship:{native, sidecar}, resume}. Pure + deterministic (INV-E5-3); the caller supplies design_id + exported_at (no clock here). The resume half reuses serialize_design (the §6 state) so import round-trips. schema_version is NOT a caller param (heid-review fold) — it is ALWAYS EXPORT_SCHEMA_VERSION, so a bundle's version is never caller-forgeable; a future migration bumps the module constant. exported_at is an OPAQUE caller-supplied string (conventionally ISO-8601) — export does NOT parse or validate it (purity; the caller owns timestamp correctness).
PRE: [PRE-E5-3 hard] design_id is a non-blank str (the durable library key) — a blank id RAISES ExportError("design_id", ...) (a bundle with no library key is unusable)
POST: [POST-E5-8 exception] IF the design fails validate_exportable, the ExportError propagates and NO bundle is returned (INV-E5-5) — validation is BEFORE assembly
POST: [POST-E5-9 return_value] returns {schema_version: EXPORT_SCHEMA_VERSION (always), design_id, exported_at, ship:{native: build_native_payload(design), sidecar: build_sidecar(design)}, resume: serialize_design(design)}; exported_at is the param verbatim (None → JSON null), unvalidated
POST: [POST-E5-10 return_value] deterministic — identical (design, design_id, exported_at) → byte-identical json.dumps(result) given fixed dumps settings; the returned dict has a FIXED key insertion order (schema_version, design_id, exported_at, ship, resume), so a caller's json.dumps is stable (INV-E5-3); design unchanged (INV-E5-4)
STEPS:
  1. [setup, flexibility=prescriptive] IF design_id is not a non-blank str: RAISE ExportError("design_id", "a non-blank design_id is required")
  2. [sequential] CALL validate_exportable(design)   # strict gate BEFORE assembly (INV-E5-5) — raises propagate
  3. [sequential] native = build_native_payload(design); sidecar = build_sidecar(design); resume = serialize_design(design)
  4. [cleanup] RETURN {"schema_version": EXPORT_SCHEMA_VERSION, "design_id": design_id, "exported_at": exported_at, "ship": {"native": native, "sidecar": sidecar}, "resume": resume}
TESTS:
  full_bundle [happy,tracer]: a complete design + design_id="d-1" → bundle has schema_version, design_id=="d-1", ship.native.agent_name, ship.native.role, ship.sidecar.first_message, resume.systemPrompt
  blank_design_id [adversarial]: design_id="" → ExportError("design_id") before any assembly
  invalid_design_no_bundle [adversarial]: a design with blank agent_name → ExportError propagates, no dict returned (POST-E5-8)
  deterministic [property]: build twice with the same (design, design_id, exported_at) → byte-identical json.dumps (INV-E5-3)
  exported_at_passthrough [trace]: exported_at="2026-07-13T00:00:00Z" → bundle.exported_at == it verbatim; None → null; a non-ISO "banana" is passed through unvalidated
  schema_version_not_a_param [trace]: build_export_bundle(..., schema_version="banana") raises TypeError — schema_version is fixed, never caller-supplied (heid-review fold)
  resume_roundtrips [property]: resume half == serialize_design(design) — every editable field present for import (incl. role)
  no_mutation [property]: design byte-identical before/after build (INV-E5-4)
  schema_version_present [trace]: bundle.schema_version == EXPORT_SCHEMA_VERSION (INV-E5-6)
```

## Integration points

**R1 — relocate `serialize_design` out of `web.py` (agent-discretion refactor,
no public-surface change).** The resume half reuses the §6 DesignObject
serialization, but `serialize_design` currently lives in `soong_lab.web`
(Starlette-coupled). Importing `web.py` into `export` would drag Starlette +
the orchestrator into a pure module. Fix: **move `serialize_design` to
`soong_lab.design`** (it is a pure `DesignObject → dict` mapping with no web
dependency — it belongs with the model; add `role` to its output), and update the
two consumers to import it from there. Blast radius (confirmed via grep):
`web.py` (define → import; 3 call-sites unchanged), `tests/test_web.py:23`
(import path), and the new `export` consumer. Behavior-identical;
`web_surface.contract.md` gets a one-line note. No-backwards-compat: the old
location is deleted, all refs updated in the same commit.

**Companion prerequisite slice — the `role` field + `set_role` tool (contracts
updated in THIS pass).** Export emits `design.role`, so the field + its tool must
exist. This slice (governed by the sibling contracts, amended alongside this one):
- `soong_lab.design` (design_object.contract.md): a `role` field on
  `DesignObject` (default `"character"`); a `ROLE_CHOICES` enum canon +
  `validate_role`, held as an in-code module constant (mirroring the OCEAN
  adjective canon); `new_design()` sets `role="character"`; `serialize_design`
  adds `role`.
- `soong_lab.bifrost` (bifrost_server.contract.md): a new `set_role(_ctx, role)`
  design tool (the 9th), `input_schema` an `enum` of the 4 values; the handler
  sets `design.role` after membership validation.
The behavioral CODE for this slice lands in the TDD phase after
`/heid-contract-review`, alongside `soong_lab.export`.

**Bifrost export tool (`_make_export` in bifrost/tools.py) — in scope.** Replace
the deferred stub with: get the session's design from the store, then
`build_export_bundle(design, design_id=<source>, exported_at=<stamp>)` and
return the bundle (or a compact confirmation carrying it). The `design_id`
source is the per-design-sessions seam (open_question C) — until it lands, the
tool may pass the session_id as a provisional design_id (a documented
placeholder, NOT a silent default). The tool handler is the impure boundary that
stamps `exported_at` (clock) and supplies `design_id`, keeping
`soong_lab.export` pure.

**`/api/export` endpoint + web/api.js shim — NOT in this contract (open_question
D).** The browser 'Export Asset' button calls `api.export()`, today a
client-side shim assembling a NON-native mock bundle. The real path is a thin
`GET /api/export` on `web.py` → `build_export_bundle(orchestrator.get_design(),
…)` → JSON → the modal's native/sidecar panes render it. That amends
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified
here only so the seam is visible.

## Downstream epics (NOT this contract)

- **Library persistence** (decision #5) — writing the returned bundle to the
  server-local single-user JSON dir on corviduo-dev, keyed by `design_id`; the
  minimal recent-designs picker.
- **Import** (decision #6) — reading a bundle: lenient on unknown metadata
  (INV-E5-6), STRICT re-validation of the export-critical fields (the import-side
  mirror of `validate_exportable`), reconstructing a DesignObject from the
  `resume` half.
- **Per-design-sessions** (decision #2) — the `design_id` generator + the
  fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).
