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).
390 lines
40 KiB
Markdown
390 lines
40 KiB
Markdown
---
|
|
contract_version: "2.1"
|
|
module: "soong_lab.importer"
|
|
purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)."
|
|
depends_on:
|
|
- "soong_lab.design" # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants
|
|
- "soong_lab.export" # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1)
|
|
used_by:
|
|
- "soong_lab.web" # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D)
|
|
- "soong_lab.soong" # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2)
|
|
language: "python"
|
|
complexity: "medium"
|
|
estimated_loc: 170
|
|
confidence: 0.83
|
|
assumptions:
|
|
- "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)."
|
|
- "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1."
|
|
- "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced."
|
|
- "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one."
|
|
- "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute."
|
|
open_questions:
|
|
- "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)."
|
|
- "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)."
|
|
- "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)."
|
|
- "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O."
|
|
- "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape."
|
|
---
|
|
|
|
## Context
|
|
|
|
Import is the SECOND half of the operator-accepted (2026-07-13) export/import/library
|
|
design — the block that expands the locked single-agent frame into a multi-pass
|
|
tuning loop (design → export → **reopen → tune** → keep a library). Where
|
|
`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this
|
|
module does the inverse: it takes a bundle's **`resume`** half and reconstructs an
|
|
editable `DesignObject` you can drop back into a session and keep tuning.
|
|
|
|
The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing
|
|
import decision):
|
|
|
|
- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys
|
|
inside `resume`, and any `schema_version` (present or absent) are tolerated —
|
|
import reads only the keys it knows (INV-I-2, mirroring the export bundle's
|
|
add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added
|
|
fields still imports.
|
|
- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`,
|
|
`system_prompt`, tool-refs, and the psych/first_message length are re-validated
|
|
**on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1)
|
|
— so the exact fields export refuses to *ship* are the exact fields import
|
|
refuses to *accept*, and import can never drift looser than export. A bad field
|
|
is surfaced immediately (fail EARLY), not after the operator has tuned for
|
|
another ten minutes against a design that was never valid.
|
|
|
|
**The round-trip is the load-bearing contract between the two modules** (INV-I-3):
|
|
for any exportable design `d`,
|
|
|
|
```
|
|
import_bundle(build_export_bundle(d, design_id="…")) == d
|
|
deserialize_design(serialize_design(d)) == d
|
|
```
|
|
|
|
This is what makes "export then reopen" lossless. `serialize_design`
|
|
(relocated to `soong_lab.design` in the export pass, R1) is the forward half;
|
|
`deserialize_design` here is its exact inverse.
|
|
|
|
**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define`
|
|
payload + sidecar) is a re-derivable deployment artifact — the reopen path
|
|
re-exports from the reconstructed design, regenerating `ship`. So import ignores
|
|
`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is
|
|
harmless (resume wins, ship regenerated). No cross-check in v1.
|
|
|
|
**What this contract does NOT do** (open_question D): no file read, no HTTP, no
|
|
session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool /
|
|
session-open wiring, and reading a bundle off the library JSON dir are downstream
|
|
epics. This module is the pure, side-effect-free reconstruction core those epics
|
|
build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps.
|
|
|
|
## Data flow
|
|
|
|
**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a
|
|
validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is
|
|
pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts),
|
|
the tolerant reconstruction, and the strict `validate_exportable` re-check are all
|
|
in-memory; no clock, no randomness, no file, no HTTP.
|
|
|
|
### The resume key set consumed (v1.0)
|
|
|
|
Import reconstructs from exactly the `serialize_design` output (the §6 camelCase
|
|
state — pinned in export.contract §resume, restated here so this contract is
|
|
self-contained):
|
|
|
|
```
|
|
resume = {
|
|
"agentName": <str>, # EXPORT-CRITICAL — presence required (INV-I-7)
|
|
"role": <str ∈ ROLE_CHOICES>, # EXPORT-CRITICAL — presence required; validate_role gates value
|
|
"systemPrompt": <str>, # EXPORT-CRITICAL — presence required; the AUTHORED block
|
|
"ocean": {O,C,E,A,N}, # EXPORT-CRITICAL — presence required; validate_ocean gates value
|
|
"tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable
|
|
"composedPreview": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
|
"dispositionPhrase": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
|
"firstMessage": <str>, # optional prose — length-gated only (blank OK)
|
|
"psychProfile": <str>, # optional prose — length-gated only (blank OK)
|
|
"portrait": {status, styleMode, imageUrl?, jobId?}, # optional (absent → default Portrait())
|
|
"goalsFears": {goals,fears} | null # optional (absent/null → None)
|
|
}
|
|
```
|
|
|
|
**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a
|
|
boundary (all three review arms flagged that the prose blurs it): the INNER
|
|
`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` →
|
|
neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the
|
|
export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So
|
|
"import defaults a missing ocean to neutral" is FALSE for the public path
|
|
(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the
|
|
never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as
|
|
contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are
|
|
**presence-required** — a missing one is a truncated / corrupt bundle and raises
|
|
`BundleImportError`, because defaulting them would either be caught inconsistently
|
|
(name/role/prompt default to values `validate_exportable` rejects) or silently
|
|
masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other
|
|
key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES —
|
|
`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an
|
|
object whose present `goals`/`fears` are lists — structural mismatches raise a clean
|
|
`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6
|
|
robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt
|
|
Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in
|
|
`deserialize_design` and slip PAST `validate_exportable`, since both are
|
|
non-export-critical — the same loss the `tools` gate was added to close). Import does
|
|
NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears
|
|
item contents are not export-critical (E4 / the UI own portrait validity); those
|
|
round-trip as-is (heid-review Gróa#6). A goalsFears item is COERCED into a Goal/Fear by
|
|
`_coerce_goal`/`_coerce_fear` (totality, INV-I-6): a dict → its {description, type?,
|
|
salience?} fields (missing/mistyped → the model default), a LEGACY bare string (a
|
|
pre-2026-07-16 design, when goals were strings) → its `description`, anything else → a
|
|
blank Goal/Fear (which `validate_exportable`'s ≥20-char/type/salience gate then rejects on
|
|
the strict re-validate — no silent loss).
|
|
|
|
## Invariants
|
|
|
|
- **INV-I-1** [hard]: The strict export-critical re-validation IS
|
|
`soong_lab.export.validate_exportable`, imported and reused verbatim — NO
|
|
re-implementation, no parallel import-side validator. Import therefore can NEVER
|
|
be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈
|
|
ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt`
|
|
(non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded,
|
|
description bounded), and the psych/first_message LENGTH are all gated by the
|
|
same code export uses. An `ExportError` from that gate is caught and re-raised
|
|
as `BundleImportError(same field, same detail)` — same field granularity,
|
|
import-shaped type.
|
|
- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors
|
|
INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any
|
|
`schema_version` value (present or absent) are tolerated — import reads only the
|
|
keys it knows and ignores the rest. A future-schema bundle that ADDED fields
|
|
still imports.
|
|
- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes
|
|
`validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an
|
|
EQUAL `DesignObject` (dataclass `==` over every field), and
|
|
`import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless
|
|
export↔import contract. (Equality is over Python structures; the JSON encode/decode
|
|
boundary is the endpoint/library epic's concern, not this module's.)
|
|
- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of
|
|
every mutable sub-structure (the ocean dict, the tools list, the goals/fears
|
|
lists) drawn from the bundle, never aliases. A later mutation of the input bundle
|
|
cannot change an already-imported design (the mirror of export's INV-E5-4). The
|
|
copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are
|
|
scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a
|
|
dict-valued goal) is rejected by `validate_exportable` before any successful import
|
|
(heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred
|
|
unless nested mutables ever become in-contract).
|
|
- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship`
|
|
(native + sidecar) is ignored (it is re-derivable; the reopen path re-exports).
|
|
No ship↔resume consistency check in v1; on any disagreement, resume is
|
|
authoritative.
|
|
- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input
|
|
Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a
|
|
string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list)
|
|
are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)`
|
|
coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never
|
|
`list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held
|
|
verbatim (never `dict("nope")`→ValueError). ALL rejection happens in
|
|
`import_bundle` (its structural gate + `validate_exportable`). Non-export-critical
|
|
fields that are missing or mistyped default to the `DesignObject` default;
|
|
export-critical VALUES are held AS-READ (no silent type-coercion) so
|
|
`validate_exportable` judges them — with ONE structural exception: `import_bundle`
|
|
pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is
|
|
safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge
|
|
(`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value
|
|
alone — "single judge" is exact for those three, not for `ocean` (heid-review
|
|
Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.)
|
|
- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical
|
|
resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing
|
|
one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails
|
|
loudly, not by silently defaulting — especially `ocean`, whose neutral default
|
|
would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid
|
|
design). This is the explicit-over-implicit choice: reject a missing critical key
|
|
rather than accept a silently-defaulted one.
|
|
- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` /
|
|
`disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume
|
|
for instant reopen-render, per export.contract). `recompute` is the reopen
|
|
lifecycle's concern (per-design-sessions), not import's. For a legit bundle these
|
|
are already self-consistent; a tampered preview is design-time-only and is
|
|
overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency
|
|
guarantee between the trusted preview and `ocean`+`system_prompt`: for a
|
|
hand-edited resume the two may diverge until the first recompute self-heals them —
|
|
round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT
|
|
"the preview matches a fresh recompute" (heid-review Gróa#8).
|
|
|
|
## Constraints
|
|
|
|
- **[correctness]** The export-critical re-validation reuses
|
|
`soong_lab.export.validate_exportable` (INV-I-1) — import declares no length
|
|
numbers, no role list, no OCEAN shape of its own. The shared field-bound
|
|
constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in
|
|
`soong_lab.export`; import imports both. Zero duplicated validation logic → zero
|
|
drift.
|
|
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is
|
|
a total function of its input Mapping.
|
|
- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError`
|
|
(open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` —
|
|
`import` is a Python keyword and unusable as a module path (open_question A).
|
|
- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` /
|
|
iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises
|
|
a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` /
|
|
`goalsFears` falls back to the default (`import_bundle`'s structural gates reject a
|
|
present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is
|
|
reachable only for a MISSING field). This keeps every rejection path flowing
|
|
through `BundleImportError` — a caller never sees a leaked builtin exception.
|
|
- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee
|
|
for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/
|
|
`system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by
|
|
(a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar
|
|
types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or`
|
|
short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`),
|
|
never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling
|
|
(`soong_lab.export` guarantees the totality): import does NOT blanket-catch
|
|
non-`ExportError` (that would mask real programming errors); the coupling is instead
|
|
PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 —
|
|
Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that
|
|
test fails.
|
|
- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate
|
|
(structure → presence → reconstruct → `validate_exportable`). `deserialize_design`
|
|
is exposed for the round-trip test + direct reuse but PERFORMS NO validation
|
|
(PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the
|
|
mirror of export's build_native_payload/build_sidecar assuming a validated design).
|
|
- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a
|
|
STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending
|
|
BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing,
|
|
`resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE
|
|
rejection from the reused `validate_exportable` names the `DesignObject` field in
|
|
snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it
|
|
reports the design's validity. Same logical field, two deliberate `.field` forms
|
|
encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching
|
|
on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on
|
|
`.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review
|
|
Hulda) — intentional, not drift.
|
|
|
|
```contract
|
|
FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject
|
|
BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3).
|
|
PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES)
|
|
POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None
|
|
POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4)
|
|
POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope"))
|
|
POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6)
|
|
STEPS:
|
|
1. [setup] agent_name = resume.get("agentName", ""); role = resume.get("role", UNSET_ROLE); system_prompt = resume.get("systemPrompt", "") # export-critical — held as-read, no coercion
|
|
2. [sequential] composed_preview = resume["composedPreview"] if it is a str else ""; disposition_phrase = resume["dispositionPhrase"] if it is a str else "" # design-time-derived, re-derivable → coerce clean
|
|
3. [sequential] first_message = resume.get("firstMessage", ""); psych_profile = resume.get("psychProfile", "") # optional prose — held as-read, length-gated by validate_exportable
|
|
4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean()) # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness)
|
|
5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else [] # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream
|
|
6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait() # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission)
|
|
7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4)
|
|
8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears)
|
|
TESTS:
|
|
roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d
|
|
roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d
|
|
copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4)
|
|
total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6
|
|
total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6
|
|
tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id
|
|
empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise
|
|
portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None
|
|
goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[])
|
|
roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4)
|
|
preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8)
|
|
unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2)
|
|
```
|
|
|
|
```contract
|
|
FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject
|
|
BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks.
|
|
PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError)
|
|
POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.<key>", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1)
|
|
POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5)
|
|
POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2)
|
|
STEPS:
|
|
1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object")
|
|
2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half") # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E)
|
|
3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing") # presence, INV-I-7
|
|
4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object") # structural — keeps deserialize's dict() safe + gives a clean field error
|
|
5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects") # structural — prevents silent tool loss
|
|
5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object") # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical)
|
|
5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list") # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical)
|
|
6. [sequential] design = deserialize_design(resume) # total; the structural gates above guarantee a plausible shape
|
|
7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type
|
|
8. [cleanup] RETURN design
|
|
TESTS:
|
|
roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3)
|
|
roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4)
|
|
roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen)
|
|
lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2)
|
|
missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume")
|
|
bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError
|
|
missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral
|
|
missing_role [adversarial]: resume without "role" → BundleImportError("resume.role")
|
|
missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName")
|
|
missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin)
|
|
non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict()
|
|
non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools")
|
|
non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2)
|
|
malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1)
|
|
hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3)
|
|
blank_name_rejected [adversarial]: resume.agentName=" " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1)
|
|
bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role
|
|
unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable
|
|
bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean
|
|
bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable
|
|
prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok
|
|
psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok
|
|
first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin)
|
|
ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5)
|
|
no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda)
|
|
error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B)
|
|
error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6)
|
|
```
|
|
|
|
## Integration points
|
|
|
|
**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports
|
|
`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single
|
|
most important structural decision in the contract: the strict export-critical
|
|
gate is authored ONCE (in export) and reused on import, so the two directions can
|
|
never diverge. Import adds no length numbers, no role membership list, no OCEAN
|
|
shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export`
|
|
gate). The dependency direction is clean: `importer → export → design`, all three
|
|
pure.
|
|
|
|
**`serialize_design` is the round-trip partner (no code change).** The forward
|
|
half already lives in `soong_lab.design` (relocated there in the export pass, R1).
|
|
This contract adds no change to it; `deserialize_design` is written to be its exact
|
|
inverse, and the round-trip tests pin the pair together. If a future field is
|
|
added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must
|
|
gain it in the same commit (the round-trip test enforces this — a field added to
|
|
serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks
|
|
the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`);
|
|
the tests exercise BOTH so a future `serialize_design` change that collapsed the two
|
|
cases is caught, not silently round-trip-broken (heid-review Regin#4).
|
|
|
|
**Export contract `used_by` reference (one-line canon fix, same commit as code).**
|
|
`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable
|
|
Python-keyword module path. On acceptance of open_question A, that line updates to
|
|
`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference
|
|
is corrected, not left as a second name for the same module.
|
|
|
|
**`POST /api/import` endpoint + web upload — NOT in this contract (open_question
|
|
D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint
|
|
`json.loads` the body → `import_bundle(bundle)` → seed a session with the
|
|
reconstructed design (and, per per-design-sessions, open a fresh WT session +
|
|
build the design-state summary). A `BundleImportError` becomes a 4xx with the
|
|
`field`/`detail` surfaced to the operator ("fail early on import"). That amends
|
|
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here
|
|
only so the seam is visible. This module does no HTTP.
|
|
|
|
**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions,
|
|
decision #2).** Reopening a design mid-conversation (vs. at session boot) may want
|
|
a Bifrost tool that swaps the session's stored DesignObject for an imported one. If
|
|
so, its handler calls `import_bundle` and replaces the store entry — the impure
|
|
boundary, keeping `soong_lab.importer` pure. Out of scope here.
|
|
|
|
## Downstream epics (NOT this contract)
|
|
|
|
- **Library read** (decision #5) — reading a stored bundle off the server-local
|
|
single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to
|
|
`import_bundle`. The minimal recent-designs picker lists what is importable.
|
|
- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` →
|
|
fresh WT session → the compact design-state SUMMARY seeded as context (also caps
|
|
the #355 accumulation). `import_bundle` is the reconstruction primitive it calls.
|
|
- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web
|
|
surface that turns an uploaded/selected bundle into a live, reopened session.
|