--- 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": "", "exported_at": , "ship": { "native": { # a valid agents.define payload (Frame Invariant 1) "agent_name": , "role": , "system_prompt": , "persona": { "ocean": {O,C,E,A,N}, # each a real number in [-1,1] (validate_ocean parity) "psychological_profile": # 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": , "salience": <0..1>, "description": =20 chars>}], "fears": [{"id": "fear-N", "salience": <0..1>, "description": =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": , # only when portrait.status == "ready"; E4 owns generation "tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create) "first_message": # the D3 opening turn (issue #347 seed) } }, "resume": { } } ``` **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: is ."` 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": , "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=, exported_at=)` 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).