Files
ratatoskr/docs/vendor/soong-lab-bundle/importer.contract.md
T
vh 80c8d58b83 chore(canonicals): sync soong-lab export + importer contracts
Refresh vendored soong-lab-bundle canonical copies against upstream and
re-pin hashes in .corviduo-canonicals.toml.

- export: open_question B resolved — the 4 role labels map 1:1 to WT
  model-role slugs by exact name (assistant/thoughtful-assistant under
  the `foundational` grant, character/thoughtful-character under
  `character`), so ship.native.role is directly define-valid. Also,
  motivational goals/fears are now structured objects (WT #187) with
  id/type/salience/description and validate_exportable gates
  (description >=20, type in GOAL_TYPES, salience in [0,1]).
- importer: adds _coerce_goal/_coerce_fear totality path (INV-I-6) with
  legacy bare-string back-compat and strict re-validate (no silent loss).

No ratatoskr code impact: the motivational Tier-3 layer is schema-deferred
(Phase 2.0 baseline-only) and no goals/fears string-consumers exist. No
version bump (vendored-canonical docs sync, skip-the-bump per SemVer).
2026-07-18 02:22:58 -07:00

40 KiB

contract_version, module, purpose, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions
contract_version module purpose depends_on used_by language complexity estimated_loc confidence assumptions open_questions
2.1 soong_lab.importer 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).
soong_lab.design
soong_lab.export
soong_lab.web
soong_lab.soong
python medium 170 0.83
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.
[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.importimport 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.
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)
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 (Nonenull, 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.