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

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

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

32 KiB
Raw Blame History

contract_version, module, purpose, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions
contract_version module purpose depends_on used_by language complexity estimated_loc confidence assumptions open_questions
2.1 soong_lab.export 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).
soong_lab.design
soong_lab.bifrost
soong_lab.web
soong_lab.importer
python medium 200 0.82
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 map to WT model-role slugs. open_question B RESOLVED (2026-07-14, worldtree-dev via ratatoskr-dev): the 4 labels ARE WT slugs by EXACT name (1:1, no remap) — assistant/thoughtful-assistant under the `foundational` grant (gen / gen-reasoning seats), character/thoughtful-character under the `character` grant (char-rp / char-rp-reasoning seats). So ship.native.role is directly define-valid; the only deploy concern is that the CONSUMER's key must hold the matching grant (define 403s otherwise — same model.use requirement as Soong's own 'agent_architect', ADR-0012). Not a contract blocker.
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.
[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 enumassistant (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, ~150300 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": "<caller-supplied durable library key, ≠ WT session_id>",
  "exported_at": <caller-supplied OPAQUE str | null — conventionally ISO-8601, NOT validated by export>,
  "ship": {
    "native": {                       # a valid agents.define payload (Frame Invariant 1)
      "agent_name": <str, non-blank, ≤128>,
      "role": <one of ROLE_CHOICES: assistant|thoughtful-assistant|character|thoughtful-character>,
      "system_prompt": <str, non-blank, ≤32768 — the AUTHORED block, INV-E2-2>,
      "persona": {
        "ocean": {O,C,E,A,N},         # each a real number in [-1,1] (validate_ocean parity)
        "psychological_profile": <str>   # persona.psychological_profile (LOCKED b53); included iff non-blank
      },
      "motivational": {                          # WT #187 OBJECTS, not strings (ratatoskr-dev bug 2026-07-16); iff goals_fears present + non-empty
        "goals": [{"id": "goal-N", "type": <maintenance|achievement|avoidance>, "salience": <0..1>, "description": <str>=20 chars>}],
        "fears": [{"id": "fear-N", "salience": <0..1>, "description": <str >=20 chars>}]
      }   # `id` synthesized at export (goal-N/fear-N, unique across both); validate_exportable gates description>=20 / type∈GOAL_TYPES / salience∈[0,1]
    },
    "sidecar": {
      "portrait": <image ref str | null>,   # only when portrait.status == "ready"; E4 owns generation
      "tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create)
      "first_message": <str>                 # the D3 opening turn (issue #347 seed)
    }
  },
  "resume": { <the §6 camelCase editable state — key set inlined below> }
}

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: <name> is <phrase>." 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).
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
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)
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": <str|None>, "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
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=<source>, exported_at=<stamp>) 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.pybuild_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).