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).
373 lines
32 KiB
Markdown
373 lines
32 KiB
Markdown
---
|
||
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 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."
|
||
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": "<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).
|
||
|
||
```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": <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
|
||
```
|
||
|
||
```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=<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 the
|
||
`resume` half.
|
||
- **Per-design-sessions** (decision #2) — the `design_id` generator + the
|
||
fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).
|