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).
32 KiB
contract_version, module, purpose, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions
| contract_version | module | purpose | depends_on | used_by | language | complexity | estimated_loc | confidence | assumptions | open_questions | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2.1 | soong_lab.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). |
|
|
python | medium | 200 | 0.82 |
|
|
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.definepayload (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-readydict— 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.nativeis a valid Worldtreeagents.definepayload 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). Anypersona.oceanexport emits passesvalidate_ocean;roleis always one of ROLE_CHOICES.persona.psychological_profile+motivationalare 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_promptis the AUTHOREDdesign.system_prompt, NEVERcomposed_preview(binds with INV-E2-2). The disposition line — the"Disposition: <name> is <phrase>."sentence that E2recomputeappends tocomposed_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 consistentjson.dumpssettings 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_exportableis the strict export-critical gate (decision #6): OCEAN (viavalidate_ocean), role (∈ ROLE_CHOICES viavalidate_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 raisesExportErrorand 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_versionat 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_profilemaps topersona.psychological_profileand NOWHERE else — it never leaks intobehavioral_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 ISvalidate_oceanand its role check ISvalidate_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 insoong_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
rolevalue; slug-grant validity is a deploy concern. - [explicit]
build_export_bundleis the PUBLIC entrypoint — it runs the validate→assemble ordering.build_native_payload/build_sidecarare exposed for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4); a direct caller that skipsvalidate_exportableowns 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): arolefield onDesignObject(default"character"); aROLE_CHOICESenum canon +validate_role, held as an in-code module constant (mirroring the OCEAN adjective canon);new_design()setsrole="character";serialize_designaddsrole.soong_lab.bifrost(bifrost_server.contract.md): a newset_role(_ctx, role)design tool (the 9th),input_schemaanenumof the 4 values; the handler setsdesign.roleafter membership validation. The behavioral CODE for this slice lands in the TDD phase after/heid-contract-review, alongsidesoong_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.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 theresumehalf. - Per-design-sessions (decision #2) — the
design_idgenerator + the fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).