---
contract_version: "2.1"
module: "soong_lab.importer"
purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)."
depends_on:
  - "soong_lab.design"      # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants
  - "soong_lab.export"      # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1)
used_by:
  - "soong_lab.web"         # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D)
  - "soong_lab.soong"       # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2)
language: "python"
complexity: "medium"
estimated_loc: 170
confidence: 0.83
assumptions:
  - "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)."
  - "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1."
  - "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced."
  - "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one."
  - "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute."
open_questions:
  - "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)."
  - "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)."
  - "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)."
  - "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O."
  - "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape."
---

## Context

Import is the SECOND 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). Where
`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this
module does the inverse: it takes a bundle's **`resume`** half and reconstructs an
editable `DesignObject` you can drop back into a session and keep tuning.

The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing
import decision):

- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys
  inside `resume`, and any `schema_version` (present or absent) are tolerated —
  import reads only the keys it knows (INV-I-2, mirroring the export bundle's
  add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added
  fields still imports.
- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`,
  `system_prompt`, tool-refs, and the psych/first_message length are re-validated
  **on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1)
  — so the exact fields export refuses to *ship* are the exact fields import
  refuses to *accept*, and import can never drift looser than export. A bad field
  is surfaced immediately (fail EARLY), not after the operator has tuned for
  another ten minutes against a design that was never valid.

**The round-trip is the load-bearing contract between the two modules** (INV-I-3):
for any exportable design `d`,

```
import_bundle(build_export_bundle(d, design_id="…")) == d
deserialize_design(serialize_design(d))              == d
```

This is what makes "export then reopen" lossless. `serialize_design`
(relocated to `soong_lab.design` in the export pass, R1) is the forward half;
`deserialize_design` here is its exact inverse.

**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define`
payload + sidecar) is a re-derivable deployment artifact — the reopen path
re-exports from the reconstructed design, regenerating `ship`. So import ignores
`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is
harmless (resume wins, ship regenerated). No cross-check in v1.

**What this contract does NOT do** (open_question D): no file read, no HTTP, no
session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool /
session-open wiring, and reading a bundle off the library JSON dir are downstream
epics. This module is the pure, side-effect-free reconstruction core those epics
build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps.

## Data flow

**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a
validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is
pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts),
the tolerant reconstruction, and the strict `validate_exportable` re-check are all
in-memory; no clock, no randomness, no file, no HTTP.

### The resume key set consumed (v1.0)

Import reconstructs from exactly the `serialize_design` output (the §6 camelCase
state — pinned in export.contract §resume, restated here so this contract is
self-contained):

```
resume = {
  "agentName": <str>,               # EXPORT-CRITICAL — presence required (INV-I-7)
  "role": <str ∈ ROLE_CHOICES>,     # EXPORT-CRITICAL — presence required; validate_role gates value
  "systemPrompt": <str>,            # EXPORT-CRITICAL — presence required; the AUTHORED block
  "ocean": {O,C,E,A,N},             # EXPORT-CRITICAL — presence required; validate_ocean gates value
  "tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable
  "composedPreview": <str>,         # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
  "dispositionPhrase": <str>,       # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
  "firstMessage": <str>,            # optional prose — length-gated only (blank OK)
  "psychProfile": <str>,            # optional prose — length-gated only (blank OK)
  "portrait": {status, styleMode, imageUrl?, jobId?},   # optional (absent → default Portrait())
  "goalsFears": {goals,fears} | null                     # optional (absent/null → None)
}
```

**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a
boundary (all three review arms flagged that the prose blurs it): the INNER
`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` →
neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the
export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So
"import defaults a missing ocean to neutral" is FALSE for the public path
(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the
never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as
contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are
**presence-required** — a missing one is a truncated / corrupt bundle and raises
`BundleImportError`, because defaulting them would either be caught inconsistently
(name/role/prompt default to values `validate_exportable` rejects) or silently
masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other
key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES —
`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an
object whose present `goals`/`fears` are lists — structural mismatches raise a clean
`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6
robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt
Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in
`deserialize_design` and slip PAST `validate_exportable`, since both are
non-export-critical — the same loss the `tools` gate was added to close). Import does
NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears
item contents are not export-critical (E4 / the UI own portrait validity); those
round-trip as-is (heid-review Gróa#6). A goalsFears item is COERCED into a Goal/Fear by
`_coerce_goal`/`_coerce_fear` (totality, INV-I-6): a dict → its {description, type?,
salience?} fields (missing/mistyped → the model default), a LEGACY bare string (a
pre-2026-07-16 design, when goals were strings) → its `description`, anything else → a
blank Goal/Fear (which `validate_exportable`'s ≥20-char/type/salience gate then rejects on
the strict re-validate — no silent loss).

## Invariants

- **INV-I-1** [hard]: The strict export-critical re-validation IS
  `soong_lab.export.validate_exportable`, imported and reused verbatim — NO
  re-implementation, no parallel import-side validator. Import therefore can NEVER
  be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈
  ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt`
  (non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded,
  description bounded), and the psych/first_message LENGTH are all gated by the
  same code export uses. An `ExportError` from that gate is caught and re-raised
  as `BundleImportError(same field, same detail)` — same field granularity,
  import-shaped type.
- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors
  INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any
  `schema_version` value (present or absent) are tolerated — import reads only the
  keys it knows and ignores the rest. A future-schema bundle that ADDED fields
  still imports.
- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes
  `validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an
  EQUAL `DesignObject` (dataclass `==` over every field), and
  `import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless
  export↔import contract. (Equality is over Python structures; the JSON encode/decode
  boundary is the endpoint/library epic's concern, not this module's.)
- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of
  every mutable sub-structure (the ocean dict, the tools list, the goals/fears
  lists) drawn from the bundle, never aliases. A later mutation of the input bundle
  cannot change an already-imported design (the mirror of export's INV-E5-4). The
  copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are
  scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a
  dict-valued goal) is rejected by `validate_exportable` before any successful import
  (heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred
  unless nested mutables ever become in-contract).
- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship`
  (native + sidecar) is ignored (it is re-derivable; the reopen path re-exports).
  No ship↔resume consistency check in v1; on any disagreement, resume is
  authoritative.
- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input
  Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a
  string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list)
  are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)`
  coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never
  `list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held
  verbatim (never `dict("nope")`→ValueError). ALL rejection happens in
  `import_bundle` (its structural gate + `validate_exportable`). Non-export-critical
  fields that are missing or mistyped default to the `DesignObject` default;
  export-critical VALUES are held AS-READ (no silent type-coercion) so
  `validate_exportable` judges them — with ONE structural exception: `import_bundle`
  pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is
  safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge
  (`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value
  alone — "single judge" is exact for those three, not for `ocean` (heid-review
  Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.)
- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical
  resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing
  one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails
  loudly, not by silently defaulting — especially `ocean`, whose neutral default
  would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid
  design). This is the explicit-over-implicit choice: reject a missing critical key
  rather than accept a silently-defaulted one.
- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` /
  `disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume
  for instant reopen-render, per export.contract). `recompute` is the reopen
  lifecycle's concern (per-design-sessions), not import's. For a legit bundle these
  are already self-consistent; a tampered preview is design-time-only and is
  overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency
  guarantee between the trusted preview and `ocean`+`system_prompt`: for a
  hand-edited resume the two may diverge until the first recompute self-heals them —
  round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT
  "the preview matches a fresh recompute" (heid-review Gróa#8).

## Constraints

- **[correctness]** The export-critical re-validation reuses
  `soong_lab.export.validate_exportable` (INV-I-1) — import declares no length
  numbers, no role list, no OCEAN shape of its own. The shared field-bound
  constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in
  `soong_lab.export`; import imports both. Zero duplicated validation logic → zero
  drift.
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is
  a total function of its input Mapping.
- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError`
  (open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` —
  `import` is a Python keyword and unusable as a module path (open_question A).
- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` /
  iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises
  a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` /
  `goalsFears` falls back to the default (`import_bundle`'s structural gates reject a
  present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is
  reachable only for a MISSING field). This keeps every rejection path flowing
  through `BundleImportError` — a caller never sees a leaked builtin exception.
- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee
  for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/
  `system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by
  (a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar
  types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or`
  short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`),
  never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling
  (`soong_lab.export` guarantees the totality): import does NOT blanket-catch
  non-`ExportError` (that would mask real programming errors); the coupling is instead
  PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 —
  Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that
  test fails.
- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate
  (structure → presence → reconstruct → `validate_exportable`). `deserialize_design`
  is exposed for the round-trip test + direct reuse but PERFORMS NO validation
  (PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the
  mirror of export's build_native_payload/build_sidecar assuming a validated design).
- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a
  STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending
  BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing,
  `resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE
  rejection from the reused `validate_exportable` names the `DesignObject` field in
  snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it
  reports the design's validity. Same logical field, two deliberate `.field` forms
  encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching
  on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on
  `.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review
  Hulda) — intentional, not drift.

```contract
FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject
BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3).
PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES)
POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None
POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4)
POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope"))
POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6)
STEPS:
  1. [setup] agent_name = resume.get("agentName", "");  role = resume.get("role", UNSET_ROLE);  system_prompt = resume.get("systemPrompt", "")   # export-critical — held as-read, no coercion
  2. [sequential] composed_preview = resume["composedPreview"] if it is a str else "";  disposition_phrase = resume["dispositionPhrase"] if it is a str else ""   # design-time-derived, re-derivable → coerce clean
  3. [sequential] first_message = resume.get("firstMessage", "");  psych_profile = resume.get("psychProfile", "")   # optional prose — held as-read, length-gated by validate_exportable
  4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean())   # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness)
  5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else []   # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream
  6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait()   # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission)
  7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None   # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4)
  8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears)
TESTS:
  roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d
  roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d
  copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4)
  total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6
  total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6
  tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id
  empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise
  portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None
  goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[])
  roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4)
  preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8)
  unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2)
```

```contract
FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject
BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks.
PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError)
POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.<key>", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1)
POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5)
POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2)
STEPS:
  1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object")
  2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half")   # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E)
  3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing")   # presence, INV-I-7
  4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object")   # structural — keeps deserialize's dict() safe + gives a clean field error
  5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects")   # structural — prevents silent tool loss
  5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object")   # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical)
  5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list")   # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical)
  6. [sequential] design = deserialize_design(resume)   # total; the structural gates above guarantee a plausible shape
  7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc   # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type
  8. [cleanup] RETURN design
TESTS:
  roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3)
  roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4)
  roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen)
  lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2)
  missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume")
  bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError
  missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral
  missing_role [adversarial]: resume without "role" → BundleImportError("resume.role")
  missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName")
  missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin)
  non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict()
  non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools")
  non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2)
  malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1)
  hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3)
  blank_name_rejected [adversarial]: resume.agentName="   " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1)
  bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role
  unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable
  bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean
  bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable
  prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok
  psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok
  first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin)
  ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5)
  no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda)
  error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B)
  error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6)
```

## Integration points

**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports
`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single
most important structural decision in the contract: the strict export-critical
gate is authored ONCE (in export) and reused on import, so the two directions can
never diverge. Import adds no length numbers, no role membership list, no OCEAN
shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export`
gate). The dependency direction is clean: `importer → export → design`, all three
pure.

**`serialize_design` is the round-trip partner (no code change).** The forward
half already lives in `soong_lab.design` (relocated there in the export pass, R1).
This contract adds no change to it; `deserialize_design` is written to be its exact
inverse, and the round-trip tests pin the pair together. If a future field is
added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must
gain it in the same commit (the round-trip test enforces this — a field added to
serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks
the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`);
the tests exercise BOTH so a future `serialize_design` change that collapsed the two
cases is caught, not silently round-trip-broken (heid-review Regin#4).

**Export contract `used_by` reference (one-line canon fix, same commit as code).**
`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable
Python-keyword module path. On acceptance of open_question A, that line updates to
`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference
is corrected, not left as a second name for the same module.

**`POST /api/import` endpoint + web upload — NOT in this contract (open_question
D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint
`json.loads` the body → `import_bundle(bundle)` → seed a session with the
reconstructed design (and, per per-design-sessions, open a fresh WT session +
build the design-state summary). A `BundleImportError` becomes a 4xx with the
`field`/`detail` surfaced to the operator ("fail early on import"). That amends
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here
only so the seam is visible. This module does no HTTP.

**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions,
decision #2).** Reopening a design mid-conversation (vs. at session boot) may want
a Bifrost tool that swaps the session's stored DesignObject for an imported one. If
so, its handler calls `import_bundle` and replaces the store entry — the impure
boundary, keeping `soong_lab.importer` pure. Out of scope here.

## Downstream epics (NOT this contract)

- **Library read** (decision #5) — reading a stored bundle off the server-local
  single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to
  `import_bundle`. The minimal recent-designs picker lists what is importable.
- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` →
  fresh WT session → the compact design-state SUMMARY seeded as context (also caps
  the #355 accumulation). `import_bundle` is the reconstruction primitive it calls.
- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web
  surface that turns an uploaded/selected bundle into a live, reopened session.
