--- 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": , # EXPORT-CRITICAL — presence required (INV-I-7) "role": , # EXPORT-CRITICAL — presence required; validate_role gates value "systemPrompt": , # 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": , # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8) "dispositionPhrase": , # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8) "firstMessage": , # optional prose — length-gated only (blank OK) "psychProfile": , # 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.", 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.