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).
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). |
|
|
python | medium | 170 | 0.83 |
|
|
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 anyschema_version(present or absent) are tolerated — import reads only the keys it knows (INV-I-2, mirroring the export bundle's add-only-friendlyINV-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 REUSINGsoong_lab.export.validate_exportableverbatim (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. AnExportErrorfrom that gate is caught and re-raised asBundleImportError(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 anyschema_versionvalue (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
DesignObjectdthat passesvalidate_exportable,deserialize_design(serialize_design(d))reconstructs an EQUALDesignObject(dataclass==over every field), andimport_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
DesignObjectholds 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 byvalidate_exportablebefore 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
resumehalf 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_designis TOTAL — it never raises on any input Mapping. Hostile shapes (a stringocean, an inttools, a listportrait, a stringgoalsFears, or a dictgoalsFearswhosegoals/fearsis a non-list) are coerced/defaulted, not crashed — in particular EVERYlist(...)/dict(...)coercion is type-GUARDED first: a non-listgoalsbecomes[](neverlist(7)→TypeError norlist("ab")→["a","b"]), a non-dictoceanis held verbatim (neverdict("nope")→ValueError). ALL rejection happens inimport_bundle(its structural gate +validate_exportable). Non-export-critical fields that are missing or mistyped default to theDesignObjectdefault; export-critical VALUES are held AS-READ (no silent type-coercion) sovalidate_exportablejudges them — with ONE structural exception:import_bundlepre-checks thatoceanis a dict (sodeserialize_design'sdict()copy is safe), sooceanhas a structural judge (import_bundle) AND a value judge (validate_ocean), whileagent_name/role/system_promptare judged by value alone — "single judge" is exact for those three, not forocean(heid-review Gróa#2/#4, Hulda, Regin#3). (Mirrorsrecompute's hostile-input tolerance in derive.py.) - INV-I-7 [hard]: PRESENCE —
import_bundlerequires the export-critical resume keysagentName,role,systemPrompt,oceanto be PRESENT; a missing one raisesBundleImportError(f"resume.{key}", …)(a truncated bundle fails loudly, not by silently defaulting — especiallyocean, whose neutral default would mask trait loss).toolsabsent →[](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).recomputeis 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 nextset_ocean/edit_prompt. Import makes NO consistency guarantee between the trusted preview andocean+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_CHOICESlive insoong_lab.design; the strict gate lives insoong_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]
BundleImportErrordoes NOT shadow the builtinImportError(open_question B). The module issoong_lab.importer, NOTsoong_lab.import—importis a Python keyword and unusable as a module path (open_question A). - [robustness]
deserialize_designguards types BEFORE anydict()/ iteration: a non-dictoceanis held as-read (neverdict("nope"), which raises a rawValueError); a non-listtoolsyields[]; a non-dictportrait/goalsFearsfalls 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 throughBundleImportError— 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 aNone) is provided JOINTLY by (a) holding them as-read + (b)validate_exportablebeing TOTAL over hostile scalar types — every checkisinstance-guards BEFORE any.strip()/len(), and theorshort-circuits, so a hostile scalar yields a cleanExportError(→BundleImportError), never a rawTypeError/AttributeError. This is an EXPLICIT cross-module coupling (soong_lab.exportguarantees 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 throughimport_bundle(heid-bug-hunt 3/3 — Gróa#3/Hulda#2/Regin#1). Ifvalidate_exportableever did an unguarded string op, that test fails. - [explicit]
import_bundleis the PUBLIC entrypoint that runs the full gate (structure → presence → reconstruct →validate_exportable).deserialize_designis exposed for the round-trip test + direct reuse but PERFORMS NO validation (PRE-I-1) — a direct caller that skipsimport_bundleowns 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_bundlenames the offending BUNDLE key in camelCase with aresume.prefix (resume.agentNamemissing,resume.oceannot-an-object) — it reports the bundle's JSON shape. A VALUE rejection from the reusedvalidate_exportablenames theDesignObjectfield in snake_case with no prefix (agent_nameblank,persona.oceanout of range) — it reports the design's validity. Same logical field, two deliberate.fieldforms encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching onerr.fieldMUST handle both, anderr.detaildisambiguates. Tests assert on.field(pinned);.detailwording 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 (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 toimport_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_bundleis 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.