From 39050c333fcfa2223aae6d07b4e8ab15716ecf50 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 14 Jul 2026 10:17:22 -0700 Subject: [PATCH] chore(canonicals): vendor + pin soong-lab bundle contracts (export + importer @ f434016) --- .corviduo-canonicals.toml | 18 + .../soong-lab-bundle/export.contract.md | 369 +++++++++++++++++ .../soong-lab-bundle/importer.contract.md | 384 ++++++++++++++++++ 3 files changed, 771 insertions(+) create mode 100644 docs/vendor/soong-lab-bundle/export.contract.md create mode 100644 docs/vendor/soong-lab-bundle/importer.contract.md diff --git a/.corviduo-canonicals.toml b/.corviduo-canonicals.toml index 242a673..06db492 100644 --- a/.corviduo-canonicals.toml +++ b/.corviduo-canonicals.toml @@ -186,3 +186,21 @@ consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-parameters.m pinned_sha256_16 = "17157c82771aeeee" pinned_at = "2026-07-13T00:00:00+00:00" tolerate_drift = true # parameter distillation; authoring-spec governs on conflict + +[[pins]] +id = "soong-lab-export-contract-v1" +canonical_source = "soong-lab" +canonical_path = "docs/contracts/export.contract.md" +consumer_path = "docs/vendor/soong-lab-bundle/export.contract.md" +pinned_sha256_16 = "bbd8fcf0cc7bc535" +pinned_at = "2026-07-14T17:15:44+00:00" +tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change + +[[pins]] +id = "soong-lab-importer-contract-v1" +canonical_source = "soong-lab" +canonical_path = "docs/contracts/importer.contract.md" +consumer_path = "docs/vendor/soong-lab-bundle/importer.contract.md" +pinned_sha256_16 = "777b1764c8eb2cb7" +pinned_at = "2026-07-14T17:15:44+00:00" +tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change diff --git a/docs/vendor/soong-lab-bundle/export.contract.md b/docs/vendor/soong-lab-bundle/export.contract.md new file mode 100644 index 0000000..edab8b2 --- /dev/null +++ b/docs/vendor/soong-lab-bundle/export.contract.md @@ -0,0 +1,369 @@ +--- +contract_version: "2.1" +module: "soong_lab.export" +purpose: "Assemble a versioned export BUNDLE from a DesignObject — the native agents.define payload (Frame Invariant 1, emitted unchanged) + the soong-lab sidecar (portrait ref · Bifrost tool manifest · first_message) + the resume half (the full editable design state), under a versioned schema tolerant of unknown future metadata. Pure + deterministic: no I/O, no persistence, no network (library persistence + import are separate downstream epics)." +depends_on: + - "soong_lab.design" # validate_ocean + ROLE_CHOICES/validate_role (the role enum canon) + the DesignObject model + serialize_design (relocated here — see Integration points R1) +used_by: + - "soong_lab.bifrost" # the export design-tool handler (_make_export) builds the bundle for the session's design + - "soong_lab.web" # the /api/export endpoint + the browser 'Export Asset' modal render the bundle + - "soong_lab.importer" # FUTURE (import epic) — round-trips the resume half back into a DesignObject +language: "python" +complexity: "medium" +estimated_loc: 200 +confidence: 0.82 +assumptions: + - "The DesignObject handed to export is already mutated to its final state by E3 (the Soong convo loop). Export READS it; it never mutates the design (INV-E5-4)." + - "design_id is CALLER-SUPPLIED (a param), not generated here. Its generation + lifecycle (the durable library key, ≠ Worldtree session_id) is the per-design-sessions epic; export only needs the value to stamp the bundle. This keeps E5-export self-contained + forward-compatible with per-design-sessions landing before OR after it (agent-discretion, see open_question C)." + - "exported_at is CALLER-SUPPLIED (a param, default None). Pure builders can't read the clock; the caller (tool handler / endpoint) stamps the timestamp so build_export_bundle stays deterministic + testable (byte-identical output for identical inputs)." + - "role is a FIRST-CLASS design field (operator ruling 2026-07-13), set by the E3a set_role tool from a CURATED 4-value enum ROLE_CHOICES = {assistant, thoughtful-assistant, character, thoughtful-character} — mirroring the D2 curated-style-modes pattern (a fixed semantic set, NOT the target deployment's arbitrary model-role registry). Export EMITS design.role into ship.native.role, so the native payload is directly valid (Frame Invariant 1 now holds literally — no 'modulo role' caveat; only tools still bind separately at session-create). The 4 enum values are canonical soong-lab labels that MUST be registered/granted model-roles on the target Worldtree at deploy (same grant requirement as Soong's own 'agent_architect' role, ADR-0012) — a deploy-time grounding item, not a contract blocker (open_question B)." + - "psych_profile exports to the NATIVE persona layer at persona.psychological_profile. RESOLVED: the vendored canonical spec (docs/psych-profile-authoring-spec.md §4) states the wire shape is LOCKED (b53) — a single prose str field, Tier-3 ValidatedPersona.psychological_profile, nesting under the existing Any-typed persona field (no schema change). Corroborated by worldtree-codex (vor-cross) + brokkr-smithy-dev (althing 01KXD34ZTF…). The open worldtree-dev thread (01KXD1PZR7…) closes as a formality." +open_questions: + - "[B — deploy grounding, not a blocker] The 4 ROLE_CHOICES values (assistant / thoughtful-assistant / character / thoughtful-character) must be registered + grantable model-role slugs on the TARGET Worldtree (like Soong's own agent_architect role). Confirm with worldtree-dev/infra that these exact slugs exist on the deploy target before shipping; a missing slug fails the designed agent's session-create, not export. Export emits whatever role the design holds; validity of the slug on a given deployment is a deploy concern." + - "[C — agent-discretion, notable] design_id as a caller-supplied param (drafted) vs E5-export generating it. Drafted as an input so E5-export doesn't force per-design-sessions to land first. If the operator re-sequences the epics so per-design-sessions lands first, no change needed here (the param source just moves)." + - "[D — scope] E5-export = the PURE builders + validators + bundle schema (this contract). The /api/export endpoint + replacing the web/api.js exportBundle shim = a thin web-surface follow-up (amends web_surface.contract.md), NOT this contract. The Bifrost export-tool wiring IS in scope (Integration points) because the tool already exists as a stub. The set_role tool + DesignObject.role field are a companion prerequisite slice (Integration points) whose contract updates land in THIS pass (design_object + bifrost_server)." + - "schema_version starts at '1.0'. The version bump policy on future bundle-shape changes (add-only vs breaking) is deferred to when the second version actually exists — v1 only needs the field present + readers to tolerate unknown metadata (INV-E5-6)." +--- + +## Context + +E5-export is the FOUNDATION 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). This contract owns exactly ONE thing: turning a finished +`DesignObject` into a **versioned export bundle**. Persistence (the library JSON +dir), the recent-designs picker, and import round-tripping are separate +downstream epics; export is pure and deterministic so those epics — and the +tests — can build on a stable, side-effect-free core. + +**The bundle is ONE artifact with two halves** (settled decision #4): + +- **ship** — what you hand to a deployment: the native `agents.define` payload + (Frame Invariant 1, emitted unchanged) + the soong-lab **sidecar** (persona + portrait ref, the Bifrost tool manifest, the D3 first_message). +- **resume** — what you reopen to keep tuning: the full editable design state + (the §6 DesignObject serialization), so a future import reconstructs the + DesignObject exactly. + +Plus a stable **`design_id`** (the durable library key, ≠ Worldtree +`session_id`) and a **`schema_version`**, both at the top level. + +**Frame Invariant 1 is preserved — and now holds literally.** `ship.native` is a +valid Worldtree Tier-3 `agents.define` payload assembled from `agent_name` + the +designed agent's **`role`** (the model-role, resolved below) + the AUTHORED +`system_prompt` (INV-E2-2 — never `composed_preview`) + `persona.ocean` +(Worldtree renders affect at runtime) + `persona.psychological_profile` (the +native home, LOCKED b53 per the vendored spec §4) + `motivational` (from +goals_fears). The image and tools are NOT in the native schema — they ride the +sidecar (tools bind via Bifrost at session-create, exactly as grounded). + +**The `role` resolution (operator ruling 2026-07-13).** The blast-radius pass +caught that `agents.define` requires `role` (a model-role slug, ADR-0012) but the +design had no source for it. Resolution: **role is a first-class design field**, +set by a new E3a **`set_role`** tool from a **curated 4-value enum** — +`assistant` (general LLM), `thoughtful-assistant` (CoT general), +`character` (RP/writing-tuned), `thoughtful-character` (CoT RP). This mirrors the +D2 curated-style-modes decision: a fixed semantic set the operator picks from, +NOT a coupling to any one deployment's arbitrary role registry. Export emits +`design.role`, so the native payload is directly POST-valid (modulo the tool +binding every consumer already supplies at session-create). The one deploy-time +caveat: the 4 slugs must be granted on the target Worldtree (open_question B). + +**The psych field is RESOLVED (no longer quarantined).** Vendored spec §4 locks +`persona.psychological_profile` (prose `str`, ~150–300 words, read every turn), +nesting under the `Any`-typed persona layer. Export maps `design.psych_profile` +there and NOWHERE else — spec §4's hard constraint is that the self-report lens +reads ONLY this field (leaking psych prose into `behavioral_notes`/`system_prompt` +causes the "executive-assistant" failure). + +## Data flow + +**In:** a `DesignObject` (final, from E3) + a caller-supplied `design_id` (str) ++ an optional caller-supplied `exported_at` (str | None). **Out:** a plain +JSON-ready `dict` — the versioned bundle. **On disk / network:** NONE. Export is +pure: the OCEAN parity gate (`validate_ocean`), the role-enum gate +(`validate_role`), and the export-critical validators are in-memory; timestamps + +ids come in as params; no clock, no randomness, no file, no HTTP. (Library +persistence writes the returned dict to the JSON dir — that is the library epic, +not this module.) + +### Export bundle schema (v1.0) + +``` +{ + "schema_version": "1.0", # ALWAYS EXPORT_SCHEMA_VERSION — not a caller param + "design_id": "", + "exported_at": , + "ship": { + "native": { # a valid agents.define payload (Frame Invariant 1) + "agent_name": , + "role": , + "system_prompt": , + "persona": { + "ocean": {O,C,E,A,N}, # each a real number in [-1,1] (validate_ocean parity) + "psychological_profile": # persona.psychological_profile (LOCKED b53); included iff non-blank + }, + "motivational": {"goals": [...], "fears": [...]} # included iff goals_fears present + non-empty + }, + "sidecar": { + "portrait": , # only when portrait.status == "ready"; E4 owns generation + "tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create) + "first_message": # the D3 opening turn (issue #347 seed) + } + }, + "resume": { } +} +``` + +**The `resume` key set (inlined — heid-review fold Gróa #9).** The resume half IS +`serialize_design(design)` (relocated to `soong_lab.design`, R1), but its key set is +pinned HERE so this contract is self-contained and an implementer knows the exact +round-trip surface without reading the external, being-relocated function: + +``` +resume = { + "agentName", "role", "systemPrompt", "composedPreview", "firstMessage", + "ocean" {O,C,E,A,N}, "dispositionPhrase", "psychProfile", + "tools" [{id,name,description}], "portrait" {status, styleMode, imageUrl?, jobId?}, + "goalsFears" {goals,fears} | null +} +``` + +Import reconstructs a DesignObject from exactly these keys. `role` (new, R1) MUST be +present so a reopened design carries its model-role. (`composedPreview` + +`dispositionPhrase` are design-time-derived and re-derivable, but they ride the resume +so a reopen renders instantly before the first recompute.) + +**Divergences from the imported web mock (settled here, they were UI-comp +shortcuts):** + +| Field | Mock (web/*.js) | Real export (this contract) | +|---|---|---| +| native shape | `{name, tier, system_prompt, personality:{model,values}}` | real `agents.define` (`agent_name`/`role`/`persona.ocean`/`motivational`) | +| role | absent | `design.role` ∈ ROLE_CHOICES | +| system_prompt | `composedPreview` (mockApi) | authored `system_prompt` (INV-E2-2) | +| psychProfile | omitted ("open backend decision") | `persona.psychological_profile` (LOCKED b53) | +| bundle identity | none | `design_id` + `schema_version` | +| resume half | none | full `serialize_design` state | + +## Invariants + +- **INV-E5-1** [hard]: `ship.native` is a valid Worldtree `agents.define` payload + MODULO the tool binding — it carries every required field (`agent_name`, + `role`, `system_prompt`) + `persona.ocean`, and OMITS only the tools (they bind + via Bifrost at session-create, as they already do). Any `persona.ocean` export + emits passes `validate_ocean`; `role` is always one of ROLE_CHOICES. + `persona.psychological_profile` + `motivational` are OPTIONAL native fields + (grounded) — omitting them when blank/empty keeps the payload fully valid, not + merely "valid enough" (heid-review fold, Gróa #1). +- **INV-E5-2** [hard]: The exported `system_prompt` is the AUTHORED + `design.system_prompt`, NEVER `composed_preview` (binds with INV-E2-2). The + **disposition line** — the `"Disposition: is ."` sentence that + E2 `recompute` appends to `composed_preview` (design_object.contract.md POST-E2-5) + — is design-time-only and never ships. +- **INV-E5-3** [hard]: Export is pure + deterministic — identical + `(design, design_id, exported_at)` inputs yield a byte-identical serialized + bundle. No clock, no randomness, no I/O. The determinism is WITHIN the module: + the returned dict has a fixed key insertion order (schema_version, design_id, + exported_at, ship, resume; native + sidecar likewise), so any consistent + `json.dumps` settings produce byte-identical output — the invariant does NOT + claim cross-implementation byte-identity (heid-review fold, Regin #6). +- **INV-E5-4** [hard]: Export NEVER mutates the input `DesignObject` (read-only); + the bundle holds copies, not aliases, of every mutable sub-structure (ocean + dict, tool list, goals/fears lists) so a later design mutation can't change an + already-built bundle. +- **INV-E5-5** [hard]: `validate_exportable` is the strict export-critical gate + (decision #6): OCEAN (via `validate_ocean`), role (∈ ROLE_CHOICES via + `validate_role`), agent_name (non-blank, ≤128), system_prompt (non-blank, + ≤32768), tool-refs (id/name non-blank + bounded). A design that fails ANY of + these raises `ExportError` and NO bundle is produced — a built bundle is always + well-formed enough to round-trip on import. +- **INV-E5-6** [hard]: The bundle carries `schema_version` at the top level, and + readers (import, future) MUST tolerate unknown extra keys (lenient on unknown + metadata, decision #6) — the schema is add-only-friendly. +- **INV-E5-7** [hard]: `psych_profile` maps to `persona.psychological_profile` + and NOWHERE else — it never leaks into `behavioral_notes`, `system_prompt`, or + any other native field (vendored spec §4 hard constraint — the lens reads only + this dedicated field). + +## Constraints + +- **[correctness]** `validate_exportable`'s OCEAN check IS `validate_ocean` and + its role check IS `validate_role` (both E2) — no re-implementation, no drift. + The LENGTH bounds (name, prompt, tool id/name/desc, psych_profile, first_message) + MUST equal the E3a tool-schema caps — now shared constants in `soong_lab.design` + (`AGENT_NAME_MAX`, `SYSTEM_PROMPT_MAX`, `PSYCH_PROFILE_MAX`, `FIRST_MESSAGE_MAX`, + `TOOL_*_MAX`), imported by BOTH bifrost/tools.py and export — so a design's field + LENGTHS never drift. Import the shared constants; do not re-declare the numbers. + (Export is stricter only on whitespace-blankness of the required fields — the one + intentional one-way difference from the tools' minLength:1.) +- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Every + time-varying value (`design_id`, `exported_at`) is a param. +- **[explicit]** The one deploy-time caveat (the 4 role slugs must be granted on + the target WT) is documented in THIS contract (open_question B) + the library / + README when it lands — NOT promised as a bundle/sidecar field (heid-review fold: + the bundle is machine-consumed; a human deploy-note is not bundle data). The + bundle carries the `role` value; slug-grant validity is a deploy concern. +- **[explicit]** `build_export_bundle` is the PUBLIC entrypoint — it runs the + validate→assemble ordering. `build_native_payload` / `build_sidecar` are exposed + for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4); + a direct caller that skips `validate_exportable` owns that gate (heid-review fold, + Hulda #5). + +```contract +FN validate_exportable(design: DesignObject) -> None +BRIEF: The strict export-critical gate (settled decision #6) — refuse to build a bundle from a design that would fail on re-import or at the designed agent's define/session-create. Checks OCEAN (validate_ocean), role (validate_role), agent_name, system_prompt, every tool-ref, and the psych_profile/first_message LENGTH — against the SAME length caps the E3a tools enforce (shared constants). NO-DRIFT is one-directional: export's LENGTH bounds equal the tool caps, but export is deliberately STRICTER on whitespace — a whitespace-only required field (name/prompt/tool id/name) passes the tools' minLength:1 yet is rejected here (a " " name must not ship). Raises ExportError with the offending field; never mutates the design. +PRE: [PRE-E5-1 hard] design is a DesignObject +POST: [POST-E5-1 exception] raises ExportError(field, detail) unless ALL hold: design.ocean passes validate_ocean; design.role passes validate_role (∈ ROLE_CHOICES); agent_name is a non-blank str of len ≤ _AGENT_NAME_MAX; system_prompt is a non-blank str of len ≤ _SYSTEM_PROMPT_MAX; every tool has non-blank str id (≤_TOOL_ID_MAX) + non-blank str name (≤_TOOL_NAME_MAX) + str description (≤_TOOL_DESC_MAX); psych_profile is a str of len ≤ _PSYCH_PROFILE_MAX (blank OK); first_message is a str of len ≤ _FIRST_MESSAGE_MAX (blank OK). The id/name-required vs description/psych/first_message-may-be-blank asymmetry is INTENTIONAL — description defaults to "" via attach_tool; psych_profile/first_message are optional prose so only their LENGTH is bounded, not blankness (heid-review Gróa #8 + correctness-finder folds) +POST: [POST-E5-2 state_change] design is unchanged — no mutation (INV-E5-4) +STEPS: + 1. [setup, flexibility=prescriptive] TRY validate_ocean(design.ocean) — on OceanError, RAISE ExportError("persona.ocean", str(exc)) (reuse E2, no re-impl) + 2. [sequential, flexibility=prescriptive] TRY validate_role(design.role) — on RoleError, RAISE ExportError("role", str(exc)) (reuse E2 role canon) + 3. [branch] IF agent_name is not a non-blank str OR len > _AGENT_NAME_MAX: RAISE ExportError("agent_name", ...) + 4. [branch] IF system_prompt is not a non-blank str OR len > _SYSTEM_PROMPT_MAX: RAISE ExportError("system_prompt", ...) # the AUTHORED block, INV-E5-2 + 5. [loop] FOR EACH tool in design.tools: IF id/name blank or over max, or description non-str/over max: RAISE ExportError(f"tools[{i}]", ...) + 6. [branch] IF psych_profile is non-str OR len > _PSYCH_PROFILE_MAX: RAISE ExportError("psych_profile", ...) # length only — blank OK (optional prose) + 7. [branch] IF first_message is non-str OR len > _FIRST_MESSAGE_MAX: RAISE ExportError("first_message", ...) # length only — blank OK + 8. [cleanup] RETURN None +TESTS: + minimal_ok [happy,tracer]: agent_name+system_prompt set, role="character", neutral OCEAN, no tools → no raise + blank_name [adversarial]: agent_name="" → ExportError("agent_name") + blank_prompt [adversarial]: system_prompt=" " → ExportError("system_prompt") + prompt_too_long [boundary]: system_prompt of len _SYSTEM_PROMPT_MAX+1 → ExportError; len _SYSTEM_PROMPT_MAX → ok + bad_ocean [adversarial]: ocean missing a key → ExportError("persona.ocean") (via validate_ocean) + bad_role [adversarial]: role="wizard" (not in ROLE_CHOICES) → ExportError("role") (via validate_role) + blank_role [adversarial]: role="" → ExportError("role") + bad_tool_ref [adversarial]: a tool with id="" → ExportError("tools[0]") + no_mutation [property]: a rejected design is byte-identical before/after the raise (INV-E5-4) + psych_profile_length [boundary]: psych_profile="" → ok; len _PSYCH_PROFILE_MAX+1 → ExportError("psych_profile") + first_message_length [boundary]: first_message len _FIRST_MESSAGE_MAX+1 → ExportError("first_message"); blank → ok + whitespace_name_rejected [adversarial]: agent_name=" " → ExportError("agent_name") — deliberately stricter than the tool's minLength:1 (a whitespace-only name must not ship) + length_bounds_parity [property]: any (name, prompt, tool, psych, first_message) LENGTH the E3a tool schema accepts is ≤ export's caps (shared constants); export is stricter ONLY on whitespace-blankness of required fields, never looser on length +``` + +```contract +FN build_native_payload(design: DesignObject) -> dict[str, Any] +BRIEF: Map a DesignObject to a valid native agents.define payload (Frame Invariant 1). Emits agent_name + role + the AUTHORED system_prompt + persona{ocean, psychological_profile?} + motivational?. Copies mutable sub-structures (INV-E5-4). Assumes validate_exportable already passed (called by build_export_bundle). +PRE: [PRE-E5-2 hard] design passed validate_exportable (OCEAN valid, role valid, name/prompt present) — build_export_bundle enforces this ordering +POST: [POST-E5-3 return_value] result has agent_name == design.agent_name, role == design.role (∈ ROLE_CHOICES), and system_prompt == design.system_prompt (the AUTHORED block, INV-E5-2), and result["persona"]["ocean"] == a COPY of design.ocean +POST: [POST-E5-4 return_value] result["role"] == design.role — the designed agent's model-role (one of the 4 ROLE_CHOICES); a valid agents.define required field +POST: [POST-E5-5 return_value] persona.psychological_profile == design.psych_profile when psych_profile is non-blank, else the key is absent; it appears under persona and NOWHERE else (INV-E5-7) +POST: [POST-E5-6 return_value] motivational == {"goals": copy, "fears": copy} when design.goals_fears is present AND at least one list is non-empty; else the key is absent (never an empty motivational block) +STEPS: + 1. [setup] payload = {"agent_name": design.agent_name, "role": design.role, "system_prompt": design.system_prompt} # role emitted; system_prompt is the authored block (INV-E5-2) + 2. [sequential] persona = {"ocean": dict(design.ocean)} # COPY, not alias (INV-E5-4) + 3. [branch] IF design.psych_profile is a non-blank str: persona["psychological_profile"] = design.psych_profile # LOCKED b53 field; ONLY here (INV-E5-7) + 4. [sequential] payload["persona"] = persona + 5. [branch] IF design.goals_fears is not None AND (goals or fears non-empty): payload["motivational"] = {"goals": list(gf.goals), "fears": list(gf.fears)} + 6. [cleanup] RETURN payload # tools NOT here — they ride the sidecar / bind via Bifrost at session-create +TESTS: + authored_prompt [happy,tracer]: system_prompt authored + composed_preview differs → payload.system_prompt == authored, NOT composed_preview (INV-E5-2) + role_emitted [happy]: role="thoughtful-character" → payload.role == "thoughtful-character" (POST-E5-4) + ocean_copied [property]: mutate design.ocean after build → payload's ocean unchanged (INV-E5-4) + psych_present [happy]: psych_profile set → persona.psychological_profile == it; it is the ONLY field carrying it (INV-E5-7) + psych_absent [boundary]: psych_profile="" → no psychological_profile key + motivational_present [happy]: goals_fears with goals=["x"] → motivational.goals == ["x"] + motivational_absent [boundary]: goals_fears None → no motivational key; goals_fears with both lists empty → no motivational key + no_tools_no_image [trace]: payload has no "tools" and no image field (they ride the sidecar / bind separately) +``` + +```contract +FN build_sidecar(design: DesignObject) -> dict[str, Any] +BRIEF: Assemble the soong-lab sidecar — the three artifacts the native schema has no home for: the persona portrait ref, the Bifrost tool manifest, and the D3 first_message. Copies the tool list (INV-E5-4). +PRE: [PRE-E5-4 hard] design is a DesignObject (its portrait/tools/first_message fields are read as-is; no validation here — validate_exportable is the gate, called by build_export_bundle before this) +POST: [POST-E5-7 return_value] result == {"portrait": , "tools": [{"id","name","description"} per tool, copied], "first_message": design.first_message}; portrait == design.portrait.image_url IFF design.portrait.status == "ready", else None (a "ready" status with a None image_url therefore yields None — no crash; any non-"ready" status → None — heid-review fold Gróa #4) +STEPS: + 1. [setup] portrait = design.portrait.image_url if design.portrait.status == "ready" else None + 2. [sequential] tools = [t.to_dict() for t in design.tools] # ToolRef.to_dict() — the shared {id,name,description} projection (dedups with serialize_design); it MUST emit exactly id/name/description, so if to_dict ever grows keys the sidecar spec must be revisited (heid-code-review fold) + 3. [cleanup] RETURN {"portrait": portrait, "tools": tools, "first_message": design.first_message} +TESTS: + ready_portrait [happy]: portrait.status="ready", image_url set → sidecar.portrait == the url + unready_portrait [boundary]: portrait.status="generating" (url set) → sidecar.portrait is None (only ready ships) + none_portrait [boundary]: portrait.status="none" → sidecar.portrait is None + tools_manifest [happy,tracer]: two tools → sidecar.tools has both {id,name,description} + tools_copied [property]: mutate design.tools after build → sidecar.tools unchanged (INV-E5-4) + first_message [happy]: first_message set → sidecar.first_message == it +``` + +```contract +FN build_export_bundle(design: DesignObject, *, design_id: str, exported_at: str | None = None) -> dict[str, Any] +BRIEF: The top-level export entrypoint — validate (strict, INV-E5-5), then assemble the versioned bundle: {schema_version, design_id, exported_at, ship:{native, sidecar}, resume}. Pure + deterministic (INV-E5-3); the caller supplies design_id + exported_at (no clock here). The resume half reuses serialize_design (the §6 state) so import round-trips. schema_version is NOT a caller param (heid-review fold) — it is ALWAYS EXPORT_SCHEMA_VERSION, so a bundle's version is never caller-forgeable; a future migration bumps the module constant. exported_at is an OPAQUE caller-supplied string (conventionally ISO-8601) — export does NOT parse or validate it (purity; the caller owns timestamp correctness). +PRE: [PRE-E5-3 hard] design_id is a non-blank str (the durable library key) — a blank id RAISES ExportError("design_id", ...) (a bundle with no library key is unusable) +POST: [POST-E5-8 exception] IF the design fails validate_exportable, the ExportError propagates and NO bundle is returned (INV-E5-5) — validation is BEFORE assembly +POST: [POST-E5-9 return_value] returns {schema_version: EXPORT_SCHEMA_VERSION (always), design_id, exported_at, ship:{native: build_native_payload(design), sidecar: build_sidecar(design)}, resume: serialize_design(design)}; exported_at is the param verbatim (None → JSON null), unvalidated +POST: [POST-E5-10 return_value] deterministic — identical (design, design_id, exported_at) → byte-identical json.dumps(result) given fixed dumps settings; the returned dict has a FIXED key insertion order (schema_version, design_id, exported_at, ship, resume), so a caller's json.dumps is stable (INV-E5-3); design unchanged (INV-E5-4) +STEPS: + 1. [setup, flexibility=prescriptive] IF design_id is not a non-blank str: RAISE ExportError("design_id", "a non-blank design_id is required") + 2. [sequential] CALL validate_exportable(design) # strict gate BEFORE assembly (INV-E5-5) — raises propagate + 3. [sequential] native = build_native_payload(design); sidecar = build_sidecar(design); resume = serialize_design(design) + 4. [cleanup] RETURN {"schema_version": EXPORT_SCHEMA_VERSION, "design_id": design_id, "exported_at": exported_at, "ship": {"native": native, "sidecar": sidecar}, "resume": resume} +TESTS: + full_bundle [happy,tracer]: a complete design + design_id="d-1" → bundle has schema_version, design_id=="d-1", ship.native.agent_name, ship.native.role, ship.sidecar.first_message, resume.systemPrompt + blank_design_id [adversarial]: design_id="" → ExportError("design_id") before any assembly + invalid_design_no_bundle [adversarial]: a design with blank agent_name → ExportError propagates, no dict returned (POST-E5-8) + deterministic [property]: build twice with the same (design, design_id, exported_at) → byte-identical json.dumps (INV-E5-3) + exported_at_passthrough [trace]: exported_at="2026-07-13T00:00:00Z" → bundle.exported_at == it verbatim; None → null; a non-ISO "banana" is passed through unvalidated + schema_version_not_a_param [trace]: build_export_bundle(..., schema_version="banana") raises TypeError — schema_version is fixed, never caller-supplied (heid-review fold) + resume_roundtrips [property]: resume half == serialize_design(design) — every editable field present for import (incl. role) + no_mutation [property]: design byte-identical before/after build (INV-E5-4) + schema_version_present [trace]: bundle.schema_version == EXPORT_SCHEMA_VERSION (INV-E5-6) +``` + +## Integration points + +**R1 — relocate `serialize_design` out of `web.py` (agent-discretion refactor, +no public-surface change).** The resume half reuses the §6 DesignObject +serialization, but `serialize_design` currently lives in `soong_lab.web` +(Starlette-coupled). Importing `web.py` into `export` would drag Starlette + +the orchestrator into a pure module. Fix: **move `serialize_design` to +`soong_lab.design`** (it is a pure `DesignObject → dict` mapping with no web +dependency — it belongs with the model; add `role` to its output), and update the +two consumers to import it from there. Blast radius (confirmed via grep): +`web.py` (define → import; 3 call-sites unchanged), `tests/test_web.py:23` +(import path), and the new `export` consumer. Behavior-identical; +`web_surface.contract.md` gets a one-line note. No-backwards-compat: the old +location is deleted, all refs updated in the same commit. + +**Companion prerequisite slice — the `role` field + `set_role` tool (contracts +updated in THIS pass).** Export emits `design.role`, so the field + its tool must +exist. This slice (governed by the sibling contracts, amended alongside this one): +- `soong_lab.design` (design_object.contract.md): a `role` field on + `DesignObject` (default `"character"`); a `ROLE_CHOICES` enum canon + + `validate_role`, held as an in-code module constant (mirroring the OCEAN + adjective canon); `new_design()` sets `role="character"`; `serialize_design` + adds `role`. +- `soong_lab.bifrost` (bifrost_server.contract.md): a new `set_role(_ctx, role)` + design tool (the 9th), `input_schema` an `enum` of the 4 values; the handler + sets `design.role` after membership validation. +The behavioral CODE for this slice lands in the TDD phase after +`/heid-contract-review`, alongside `soong_lab.export`. + +**Bifrost export tool (`_make_export` in bifrost/tools.py) — in scope.** Replace +the deferred stub with: get the session's design from the store, then +`build_export_bundle(design, design_id=, exported_at=)` and +return the bundle (or a compact confirmation carrying it). The `design_id` +source is the per-design-sessions seam (open_question C) — until it lands, the +tool may pass the session_id as a provisional design_id (a documented +placeholder, NOT a silent default). The tool handler is the impure boundary that +stamps `exported_at` (clock) and supplies `design_id`, keeping +`soong_lab.export` pure. + +**`/api/export` endpoint + web/api.js shim — NOT in this contract (open_question +D).** The browser 'Export Asset' button calls `api.export()`, today a +client-side shim assembling a NON-native mock bundle. The real path is a thin +`GET /api/export` on `web.py` → `build_export_bundle(orchestrator.get_design(), +…)` → JSON → the modal's native/sidecar panes render it. That amends +`web_surface.contract.md`; it is a follow-up slice in the same epic, specified +here only so the seam is visible. + +## Downstream epics (NOT this contract) + +- **Library persistence** (decision #5) — writing the returned bundle to the + server-local single-user JSON dir on corviduo-dev, keyed by `design_id`; the + minimal recent-designs picker. +- **Import** (decision #6) — reading a bundle: lenient on unknown metadata + (INV-E5-6), STRICT re-validation of the export-critical fields (the import-side + mirror of `validate_exportable`), reconstructing a DesignObject from the + `resume` half. +- **Per-design-sessions** (decision #2) — the `design_id` generator + the + fresh-WT-session-per-open lifecycle (also caps the #355 accumulation). diff --git a/docs/vendor/soong-lab-bundle/importer.contract.md b/docs/vendor/soong-lab-bundle/importer.contract.md new file mode 100644 index 0000000..52d7ac5 --- /dev/null +++ b/docs/vendor/soong-lab-bundle/importer.contract.md @@ -0,0 +1,384 @@ +--- +contract_version: "2.1" +module: "soong_lab.importer" +purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)." +depends_on: + - "soong_lab.design" # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants + - "soong_lab.export" # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1) +used_by: + - "soong_lab.web" # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D) + - "soong_lab.soong" # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2) +language: "python" +complexity: "medium" +estimated_loc: 170 +confidence: 0.83 +assumptions: + - "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)." + - "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1." + - "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced." + - "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one." + - "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute." +open_questions: + - "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)." + - "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)." + - "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)." + - "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O." + - "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape." +--- + +## Context + +Import is the SECOND half of the operator-accepted (2026-07-13) export/import/library +design — the block that expands the locked single-agent frame into a multi-pass +tuning loop (design → export → **reopen → tune** → keep a library). Where +`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this +module does the inverse: it takes a bundle's **`resume`** half and reconstructs an +editable `DesignObject` you can drop back into a session and keep tuning. + +The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing +import decision): + +- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys + inside `resume`, and any `schema_version` (present or absent) are tolerated — + import reads only the keys it knows (INV-I-2, mirroring the export bundle's + add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added + fields still imports. +- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`, + `system_prompt`, tool-refs, and the psych/first_message length are re-validated + **on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1) + — so the exact fields export refuses to *ship* are the exact fields import + refuses to *accept*, and import can never drift looser than export. A bad field + is surfaced immediately (fail EARLY), not after the operator has tuned for + another ten minutes against a design that was never valid. + +**The round-trip is the load-bearing contract between the two modules** (INV-I-3): +for any exportable design `d`, + +``` +import_bundle(build_export_bundle(d, design_id="…")) == d +deserialize_design(serialize_design(d)) == d +``` + +This is what makes "export then reopen" lossless. `serialize_design` +(relocated to `soong_lab.design` in the export pass, R1) is the forward half; +`deserialize_design` here is its exact inverse. + +**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define` +payload + sidecar) is a re-derivable deployment artifact — the reopen path +re-exports from the reconstructed design, regenerating `ship`. So import ignores +`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is +harmless (resume wins, ship regenerated). No cross-check in v1. + +**What this contract does NOT do** (open_question D): no file read, no HTTP, no +session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool / +session-open wiring, and reading a bundle off the library JSON dir are downstream +epics. This module is the pure, side-effect-free reconstruction core those epics +build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps. + +## Data flow + +**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a +validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is +pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts), +the tolerant reconstruction, and the strict `validate_exportable` re-check are all +in-memory; no clock, no randomness, no file, no HTTP. + +### The resume key set consumed (v1.0) + +Import reconstructs from exactly the `serialize_design` output (the §6 camelCase +state — pinned in export.contract §resume, restated here so this contract is +self-contained): + +``` +resume = { + "agentName": , # EXPORT-CRITICAL — presence required (INV-I-7) + "role": , # EXPORT-CRITICAL — presence required; validate_role gates value + "systemPrompt": , # EXPORT-CRITICAL — presence required; the AUTHORED block + "ocean": {O,C,E,A,N}, # EXPORT-CRITICAL — presence required; validate_ocean gates value + "tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable + "composedPreview": , # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8) + "dispositionPhrase": , # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8) + "firstMessage": , # optional prose — length-gated only (blank OK) + "psychProfile": , # optional prose — length-gated only (blank OK) + "portrait": {status, styleMode, imageUrl?, jobId?}, # optional (absent → default Portrait()) + "goalsFears": {goals,fears} | null # optional (absent/null → None) +} +``` + +**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a +boundary (all three review arms flagged that the prose blurs it): the INNER +`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` → +neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the +export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So +"import defaults a missing ocean to neutral" is FALSE for the public path +(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the +never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as +contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are +**presence-required** — a missing one is a truncated / corrupt bundle and raises +`BundleImportError`, because defaulting them would either be caught inconsistently +(name/role/prompt default to values `validate_exportable` rejects) or silently +masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other +key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES — +`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an +object whose present `goals`/`fears` are lists — structural mismatches raise a clean +`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6 +robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt +Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in +`deserialize_design` and slip PAST `validate_exportable`, since both are +non-export-critical — the same loss the `tools` gate was added to close). Import does +NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears +item contents are not export-critical (E4 / the UI own portrait validity); those +round-trip as-is (heid-review Gróa#6). + +## Invariants + +- **INV-I-1** [hard]: The strict export-critical re-validation IS + `soong_lab.export.validate_exportable`, imported and reused verbatim — NO + re-implementation, no parallel import-side validator. Import therefore can NEVER + be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈ + ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt` + (non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded, + description bounded), and the psych/first_message LENGTH are all gated by the + same code export uses. An `ExportError` from that gate is caught and re-raised + as `BundleImportError(same field, same detail)` — same field granularity, + import-shaped type. +- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors + INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any + `schema_version` value (present or absent) are tolerated — import reads only the + keys it knows and ignores the rest. A future-schema bundle that ADDED fields + still imports. +- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes + `validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an + EQUAL `DesignObject` (dataclass `==` over every field), and + `import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless + export↔import contract. (Equality is over Python structures; the JSON encode/decode + boundary is the endpoint/library epic's concern, not this module's.) +- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of + every mutable sub-structure (the ocean dict, the tools list, the goals/fears + lists) drawn from the bundle, never aliases. A later mutation of the input bundle + cannot change an already-imported design (the mirror of export's INV-E5-4). The + copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are + scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a + dict-valued goal) is rejected by `validate_exportable` before any successful import + (heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred + unless nested mutables ever become in-contract). +- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship` + (native + sidecar) is ignored (it is re-derivable; the reopen path re-exports). + No ship↔resume consistency check in v1; on any disagreement, resume is + authoritative. +- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input + Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a + string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list) + are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)` + coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never + `list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held + verbatim (never `dict("nope")`→ValueError). ALL rejection happens in + `import_bundle` (its structural gate + `validate_exportable`). Non-export-critical + fields that are missing or mistyped default to the `DesignObject` default; + export-critical VALUES are held AS-READ (no silent type-coercion) so + `validate_exportable` judges them — with ONE structural exception: `import_bundle` + pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is + safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge + (`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value + alone — "single judge" is exact for those three, not for `ocean` (heid-review + Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.) +- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical + resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing + one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails + loudly, not by silently defaulting — especially `ocean`, whose neutral default + would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid + design). This is the explicit-over-implicit choice: reject a missing critical key + rather than accept a silently-defaulted one. +- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` / + `disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume + for instant reopen-render, per export.contract). `recompute` is the reopen + lifecycle's concern (per-design-sessions), not import's. For a legit bundle these + are already self-consistent; a tampered preview is design-time-only and is + overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency + guarantee between the trusted preview and `ocean`+`system_prompt`: for a + hand-edited resume the two may diverge until the first recompute self-heals them — + round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT + "the preview matches a fresh recompute" (heid-review Gróa#8). + +## Constraints + +- **[correctness]** The export-critical re-validation reuses + `soong_lab.export.validate_exportable` (INV-I-1) — import declares no length + numbers, no role list, no OCEAN shape of its own. The shared field-bound + constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in + `soong_lab.export`; import imports both. Zero duplicated validation logic → zero + drift. +- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is + a total function of its input Mapping. +- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError` + (open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` — + `import` is a Python keyword and unusable as a module path (open_question A). +- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` / + iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises + a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` / + `goalsFears` falls back to the default (`import_bundle`'s structural gates reject a + present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is + reachable only for a MISSING field). This keeps every rejection path flowing + through `BundleImportError` — a caller never sees a leaked builtin exception. +- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee + for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/ + `system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by + (a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar + types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or` + short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`), + never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling + (`soong_lab.export` guarantees the totality): import does NOT blanket-catch + non-`ExportError` (that would mask real programming errors); the coupling is instead + PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 — + Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that + test fails. +- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate + (structure → presence → reconstruct → `validate_exportable`). `deserialize_design` + is exposed for the round-trip test + direct reuse but PERFORMS NO validation + (PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the + mirror of export's build_native_payload/build_sidecar assuming a validated design). +- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a + STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending + BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing, + `resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE + rejection from the reused `validate_exportable` names the `DesignObject` field in + snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it + reports the design's validity. Same logical field, two deliberate `.field` forms + encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching + on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on + `.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review + Hulda) — intentional, not drift. + +```contract +FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject +BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3). +PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES) +POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None +POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4) +POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope")) +POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6) +STEPS: + 1. [setup] agent_name = resume.get("agentName", ""); role = resume.get("role", UNSET_ROLE); system_prompt = resume.get("systemPrompt", "") # export-critical — held as-read, no coercion + 2. [sequential] composed_preview = resume["composedPreview"] if it is a str else ""; disposition_phrase = resume["dispositionPhrase"] if it is a str else "" # design-time-derived, re-derivable → coerce clean + 3. [sequential] first_message = resume.get("firstMessage", ""); psych_profile = resume.get("psychProfile", "") # optional prose — held as-read, length-gated by validate_exportable + 4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean()) # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness) + 5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else [] # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream + 6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait() # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission) + 7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4) + 8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears) +TESTS: + roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d + roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d + copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4) + total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6 + total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6 + tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id + empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise + portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None + goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[]) + roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4) + preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8) + unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2) +``` + +```contract +FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject +BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks. +PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError) +POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1) +POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5) +POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2) +STEPS: + 1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object") + 2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half") # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E) + 3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing") # presence, INV-I-7 + 4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object") # structural — keeps deserialize's dict() safe + gives a clean field error + 5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects") # structural — prevents silent tool loss + 5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object") # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical) + 5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list") # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical) + 6. [sequential] design = deserialize_design(resume) # total; the structural gates above guarantee a plausible shape + 7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type + 8. [cleanup] RETURN design +TESTS: + roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3) + roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4) + roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen) + lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2) + missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume") + bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError + missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral + missing_role [adversarial]: resume without "role" → BundleImportError("resume.role") + missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName") + missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin) + non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict() + non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools") + non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2) + malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1) + hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3) + blank_name_rejected [adversarial]: resume.agentName=" " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1) + bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role + unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable + bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean + bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable + prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok + psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok + first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin) + ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5) + no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda) + error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B) + error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6) +``` + +## Integration points + +**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports +`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single +most important structural decision in the contract: the strict export-critical +gate is authored ONCE (in export) and reused on import, so the two directions can +never diverge. Import adds no length numbers, no role membership list, no OCEAN +shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export` +gate). The dependency direction is clean: `importer → export → design`, all three +pure. + +**`serialize_design` is the round-trip partner (no code change).** The forward +half already lives in `soong_lab.design` (relocated there in the export pass, R1). +This contract adds no change to it; `deserialize_design` is written to be its exact +inverse, and the round-trip tests pin the pair together. If a future field is +added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must +gain it in the same commit (the round-trip test enforces this — a field added to +serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks +the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`); +the tests exercise BOTH so a future `serialize_design` change that collapsed the two +cases is caught, not silently round-trip-broken (heid-review Regin#4). + +**Export contract `used_by` reference (one-line canon fix, same commit as code).** +`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable +Python-keyword module path. On acceptance of open_question A, that line updates to +`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference +is corrected, not left as a second name for the same module. + +**`POST /api/import` endpoint + web upload — NOT in this contract (open_question +D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint +`json.loads` the body → `import_bundle(bundle)` → seed a session with the +reconstructed design (and, per per-design-sessions, open a fresh WT session + +build the design-state summary). A `BundleImportError` becomes a 4xx with the +`field`/`detail` surfaced to the operator ("fail early on import"). That amends +`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here +only so the seam is visible. This module does no HTTP. + +**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions, +decision #2).** Reopening a design mid-conversation (vs. at session boot) may want +a Bifrost tool that swaps the session's stored DesignObject for an imported one. If +so, its handler calls `import_bundle` and replaces the store entry — the impure +boundary, keeping `soong_lab.importer` pure. Out of scope here. + +## Downstream epics (NOT this contract) + +- **Library read** (decision #5) — reading a stored bundle off the server-local + single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to + `import_bundle`. The minimal recent-designs picker lists what is importable. +- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` → + fresh WT session → the compact design-state SUMMARY seeded as context (also caps + the #355 accumulation). `import_bundle` is the reconstruction primitive it calls. +- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web + surface that turns an uploaded/selected bundle into a live, reopened session.