Generator: unify SDK ↔ server types and seeding (single source of truth) #117

Closed
opened 2026-05-21 14:02:00 +00:00 by timur · 5 comments
Owner

Generator: unify SDK ↔ server types and seeding (single source of truth)

Context

The codegen currently produces parallel shapes for the same OSchema object, runs three competing seeders that all bypass the typed SDK, and leaks server-managed fields (sid, created_at, updated_at) into the SDK send surface as if callers could assign them. None of that is principled.

What's divergent today

Types. PR #114 (2aeee22) made codegen emit a single types.rs, but the divergence moved up to the import level:

  • SDK-consumed OTime is the WASM-compat newtype pub struct OTime(pub String); #[serde(transparent)] (emitted from crates/generator/src/rust/rust_struct.rs).
  • Server-consumed OTime is hero_rpc_osis::otoml::OTime = pub struct OTime(u32) with manual Serialize→str (referenced from crates/generator/src/rust/rust_osis.rs:1316).

Both serialize to a wire string, but OTime::default() produces "" on the SDK side, which the server's FromStr rejects (length != 19). 01_walkthrough.rs:296-304 documents the resulting recipe.set round-trip break.

Seeders. Three paths, none using the typed SDK:

  • hero_osis/crates/hero_osis_server/src/bin/seed.rs — CLI, raw JSON-RPC via reqwest.
  • hero_rpc/crates/osis/src/seed/seeder.rs — library variant, also raw JSON-RPC.
  • No typed-SDK seeding pattern exists anywhere.

Server-managed fields in the SDK input shape. recipe_set(data: Recipe) accepts a full Recipe including sid / created_at / updated_at. The server overwrites them on every call. Wire bytes wasted; API misleadingly suggests they're settable.

CRUD semantics conflated. Today recipe_new() returns a default Recipe (no-op create), and recipe_set(data: Recipe) does both create-if-no-sid and update-if-sid. That collapses CREATE and UPDATE into the same wire method, which is non-standard, confuses generated OpenRPC consumers, and makes input validation ambiguous.

What this does

1. One canonical OTime

Make herolib_otoml::OTime WASM-safe by gating OTime::now() behind #[cfg(not(target_arch = "wasm32"))]. The type itself (pub struct OTime(u32) with string-on-wire serde) compiles to WASM. Both SDK and server import it from herolib_otoml. Delete the SDK-side OTime(pub String) newtype emission from rust_struct.rs. Same principle applies to any other type that currently has a parallel WASM-compat shape — audit and unify.

2. One generated type per OSchema definition

SDK + server share the same Recipe, Collection, etc. — no per-side variants. #[cfg(target_arch = "wasm32")] gating happens only at the API-surface layer (e.g. OsisRecipes is native-only), never at the data-type layer.

3. Server-managed fields are not part of the SDK input shape; CRUD is split

Emit a RecipeInput (user fields only) alongside Recipe (full, with server-managed fields). The trait splits CREATE / UPDATE:

// CREATE — takes input, returns a fresh server-assigned sid
async fn recipe_new(&self, ctx, data: RecipeInput) -> RpcResult<Sid>;

// UPDATE — takes sid + input, returns unit on success
async fn recipe_set(&self, ctx, sid: Sid, data: RecipeInput) -> RpcResult<()>;

// READ
async fn recipe_get(&self, ctx, sid: Sid) -> RpcResult<Recipe>;
async fn recipe_list(&self, ctx) -> RpcResult<Vec<Sid>>;
async fn recipe_list_full(&self, ctx) -> RpcResult<Vec<Recipe>>;
async fn recipe_exists(&self, ctx, sid: Sid) -> RpcResult<bool>;

// DELETE
async fn recipe_delete(&self, ctx, sid: Sid) -> RpcResult<bool>;

The OpenRPC spec naturally advertises two schemas per root object (RecipeInput, Recipe) — input/output split visible to every language SDK consumer.

4. One seeder, three modes

Delete:

  • hero_osis/crates/hero_osis_server/src/bin/seed.rs (CLI binary + service.toml + Makefile entries + docs/SEEDING.md references).
  • hero_rpc/crates/osis/src/seed/ (library module).

Replace with a generated typed-SDK helper exposed as a seed module on each scaffolded SDK crate:

// crates/<service>_sdk/src/generated/seed.rs

pub mod seed {
    /// Empty defaults — RecipeInput::default(), CollectionInput::default(), ...
    /// Useful for "does the wire work" smoke tests.
    pub async fn blank(client: &Client, count: usize) -> Result<SeedReport>;

    /// Randomized but deterministic — fixed-seed ChaCha RNG, reproducible across runs.
    /// Per-field generators driven by ui_emit::FieldKind:
    ///   Str   → lorem word
    ///   int   → bounded range
    ///   OTime → random in [2020, 2026]
    ///   Enum  → uniform pick
    ///   List  → 0..5 elements
    /// Benchmarks (hero_rpc#113) use this mode.
    pub async fn random(client: &Client, count: usize, rng_seed: u64) -> Result<SeedReport>;

    /// Walks <dir>/<domain>/*.toml; parses each via herolib_otoml into the
    /// matching <Type>Input; calls recipe_new for each.
    pub async fn from_dir(client: &Client, dir: impl AsRef<Path>) -> Result<SeedReport>;
}

Feature-gated (seed = ["rand", "rand_chacha"]) so SDK consumers who don't seed don't pay the dep cost.

5. Cross-language alignment

Once the unified contract is in place, audit every other language SDK generator (Python, JS, Rhai) so they emit the same Input/Output split, the same CRUD method signatures (new(input) -> sid, set(sid, input) -> ()), and consume the same OpenRPC schemas. Smoke-check that each language SDK still compiles and can be instantiated against the recipe_server example.

The full per-language end-to-end test surface that proves wire compatibility across all SDKs lives in #115 (workspace-root tests/ crate). This issue only needs to ensure that the contract is aligned; #115 implements the test coverage.

6. ADR

New ADR at docs/adr/002-single-source-of-truth-types-and-seeding.md codifying:

  • One Rust type per OSchema definition, used by SDK and server alike.
  • WASM compatibility is enforced at the primitive-type layer (OTime, etc.), not via parallel codegen.
  • Server-managed fields are never part of the SDK input surface — separate *Input types.
  • CRUD is split: new(input) -> sid for CREATE, set(sid, input) -> () for UPDATE — semantics distinct, never conflated.
  • Seeding goes through the typed SDK client; one seeder helper per repo, exposing blank / random / from_dir modes.

Link from CLAUDE.md and the hero_service_scaffold skill in lhumina_code/hero_skills.

Acceptance

  • One Recipe / one OTime shape used by SDK and server alike — grep confirms no duplicate emissions across the codegen.
  • RecipesClient::recipe_new(client, None, RecipeInput::default()).await round-trips cleanly to an in-process OsisRecipes server and returns a Sid.
  • RecipesClient::recipe_set(client, None, sid, RecipeInput { name: "x".into(), ..Default::default() }).await updates the row.
  • RecipesClient::recipe_get(client, None, sid).await returns a full Recipe whose created_at deserializes successfully.
  • hero_recipes_sdk::seed::random(&client, 10, 0xDEAD_BEEF).await populates 10 records of each root object; cargo bench -- recipe/list_full (after #113 lands) measures non-empty serialization cost.
  • cargo check --target wasm32-unknown-unknown -p hero_recipes_sdk passes.
  • hero_osis_seed binary + crate path entries are gone; data/mock/ either moves into example workspaces or stays where it is and the new helper accepts an explicit path.
  • 01_walkthrough.rs exercises a real recipe_new(RecipeInput { name: "...".into(), ..Default::default() }) round-trip; OTime caveat block deleted.
  • Python, JS, Rhai SDK codegen emits the unified Input/Output split + the new CRUD signatures. Each language SDK compiles. Wire compatibility verified by #115.
  • New ADR posted and linked from CLAUDE.md.

Blocks

  • #113 (per-root-object Criterion benchmarks) — bench scaffold's natural SDK-wire seeding path consumes seed::random from this issue.
  • #115 (workspace-root E2E tests) — E2E tests exercise the new CRUD shape; landing #115 against the old shape would create churn.

Out of scope

  • Property-based testing of the new contracts — separate issue if needed.
  • Bulk in-process seeding via the OSIS handler (bypassing the wire) — the typed SDK path is the only blessed seeder. If a bulk-loader becomes necessary for very large fixture sets, that's a follow-up.
  • Migration of existing on-disk databases populated under the old shape — assumed empty / dev-only DBs at this stage.
# Generator: unify SDK ↔ server types and seeding (single source of truth) ## Context The codegen currently produces parallel shapes for the same OSchema object, runs three competing seeders that all bypass the typed SDK, and leaks server-managed fields (`sid`, `created_at`, `updated_at`) into the SDK send surface as if callers could assign them. None of that is principled. ### What's divergent today **Types.** PR [#114](https://forge.ourworld.tf/lhumina_code/hero_rpc/pulls/114) (`2aeee22`) made codegen emit a single `types.rs`, but the divergence moved up to the import level: - SDK-consumed `OTime` is the WASM-compat newtype `pub struct OTime(pub String); #[serde(transparent)]` (emitted from `crates/generator/src/rust/rust_struct.rs`). - Server-consumed `OTime` is `hero_rpc_osis::otoml::OTime = pub struct OTime(u32)` with manual `Serialize→str` (referenced from `crates/generator/src/rust/rust_osis.rs:1316`). Both serialize to a wire string, but `OTime::default()` produces `""` on the SDK side, which the server's `FromStr` rejects (`length != 19`). `01_walkthrough.rs:296-304` documents the resulting `recipe.set` round-trip break. **Seeders.** Three paths, none using the typed SDK: - `hero_osis/crates/hero_osis_server/src/bin/seed.rs` — CLI, raw JSON-RPC via reqwest. - `hero_rpc/crates/osis/src/seed/seeder.rs` — library variant, also raw JSON-RPC. - No typed-SDK seeding pattern exists anywhere. **Server-managed fields in the SDK input shape.** `recipe_set(data: Recipe)` accepts a full `Recipe` including `sid` / `created_at` / `updated_at`. The server overwrites them on every call. Wire bytes wasted; API misleadingly suggests they're settable. **CRUD semantics conflated.** Today `recipe_new()` returns a default Recipe (no-op create), and `recipe_set(data: Recipe)` does both create-if-no-sid and update-if-sid. That collapses CREATE and UPDATE into the same wire method, which is non-standard, confuses generated OpenRPC consumers, and makes input validation ambiguous. ## What this does ### 1. One canonical `OTime` Make `herolib_otoml::OTime` WASM-safe by gating `OTime::now()` behind `#[cfg(not(target_arch = "wasm32"))]`. The type itself (`pub struct OTime(u32)` with string-on-wire serde) compiles to WASM. Both SDK and server import it from `herolib_otoml`. Delete the SDK-side `OTime(pub String)` newtype emission from `rust_struct.rs`. Same principle applies to any other type that currently has a parallel WASM-compat shape — audit and unify. ### 2. One generated type per OSchema definition SDK + server share the same `Recipe`, `Collection`, etc. — no per-side variants. `#[cfg(target_arch = "wasm32")]` gating happens only at the API-surface layer (e.g. `OsisRecipes` is native-only), never at the data-type layer. ### 3. Server-managed fields are not part of the SDK input shape; CRUD is split Emit a `RecipeInput` (user fields only) alongside `Recipe` (full, with server-managed fields). The trait splits CREATE / UPDATE: ```rust // CREATE — takes input, returns a fresh server-assigned sid async fn recipe_new(&self, ctx, data: RecipeInput) -> RpcResult<Sid>; // UPDATE — takes sid + input, returns unit on success async fn recipe_set(&self, ctx, sid: Sid, data: RecipeInput) -> RpcResult<()>; // READ async fn recipe_get(&self, ctx, sid: Sid) -> RpcResult<Recipe>; async fn recipe_list(&self, ctx) -> RpcResult<Vec<Sid>>; async fn recipe_list_full(&self, ctx) -> RpcResult<Vec<Recipe>>; async fn recipe_exists(&self, ctx, sid: Sid) -> RpcResult<bool>; // DELETE async fn recipe_delete(&self, ctx, sid: Sid) -> RpcResult<bool>; ``` The OpenRPC spec naturally advertises two schemas per root object (`RecipeInput`, `Recipe`) — input/output split visible to every language SDK consumer. ### 4. One seeder, three modes Delete: - `hero_osis/crates/hero_osis_server/src/bin/seed.rs` (CLI binary + service.toml + Makefile entries + docs/SEEDING.md references). - `hero_rpc/crates/osis/src/seed/` (library module). Replace with a generated typed-SDK helper exposed as a `seed` module on each scaffolded SDK crate: ```rust // crates/<service>_sdk/src/generated/seed.rs pub mod seed { /// Empty defaults — RecipeInput::default(), CollectionInput::default(), ... /// Useful for "does the wire work" smoke tests. pub async fn blank(client: &Client, count: usize) -> Result<SeedReport>; /// Randomized but deterministic — fixed-seed ChaCha RNG, reproducible across runs. /// Per-field generators driven by ui_emit::FieldKind: /// Str → lorem word /// int → bounded range /// OTime → random in [2020, 2026] /// Enum → uniform pick /// List → 0..5 elements /// Benchmarks (hero_rpc#113) use this mode. pub async fn random(client: &Client, count: usize, rng_seed: u64) -> Result<SeedReport>; /// Walks <dir>/<domain>/*.toml; parses each via herolib_otoml into the /// matching <Type>Input; calls recipe_new for each. pub async fn from_dir(client: &Client, dir: impl AsRef<Path>) -> Result<SeedReport>; } ``` Feature-gated (`seed = ["rand", "rand_chacha"]`) so SDK consumers who don't seed don't pay the dep cost. ### 5. Cross-language alignment Once the unified contract is in place, audit every other language SDK generator (Python, JS, Rhai) so they emit the same Input/Output split, the same CRUD method signatures (`new(input) -> sid`, `set(sid, input) -> ()`), and consume the same OpenRPC schemas. Smoke-check that each language SDK still compiles and can be instantiated against the recipe_server example. The full per-language end-to-end test surface that proves wire compatibility across all SDKs lives in [#115](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115) (workspace-root `tests/` crate). This issue only needs to ensure that the contract is aligned; #115 implements the test coverage. ### 6. ADR New ADR at `docs/adr/002-single-source-of-truth-types-and-seeding.md` codifying: - One Rust type per OSchema definition, used by SDK and server alike. - WASM compatibility is enforced at the primitive-type layer (`OTime`, etc.), not via parallel codegen. - Server-managed fields are never part of the SDK input surface — separate `*Input` types. - CRUD is split: `new(input) -> sid` for CREATE, `set(sid, input) -> ()` for UPDATE — semantics distinct, never conflated. - Seeding goes through the typed SDK client; one seeder helper per repo, exposing `blank` / `random` / `from_dir` modes. Link from `CLAUDE.md` and the `hero_service_scaffold` skill in `lhumina_code/hero_skills`. ## Acceptance - One `Recipe` / one `OTime` shape used by SDK and server alike — `grep` confirms no duplicate emissions across the codegen. - `RecipesClient::recipe_new(client, None, RecipeInput::default()).await` round-trips cleanly to an in-process `OsisRecipes` server and returns a `Sid`. - `RecipesClient::recipe_set(client, None, sid, RecipeInput { name: "x".into(), ..Default::default() }).await` updates the row. - `RecipesClient::recipe_get(client, None, sid).await` returns a full `Recipe` whose `created_at` deserializes successfully. - `hero_recipes_sdk::seed::random(&client, 10, 0xDEAD_BEEF).await` populates 10 records of each root object; `cargo bench -- recipe/list_full` (after #113 lands) measures non-empty serialization cost. - `cargo check --target wasm32-unknown-unknown -p hero_recipes_sdk` passes. - `hero_osis_seed` binary + crate path entries are gone; `data/mock/` either moves into example workspaces or stays where it is and the new helper accepts an explicit path. - `01_walkthrough.rs` exercises a real `recipe_new(RecipeInput { name: "...".into(), ..Default::default() })` round-trip; OTime caveat block deleted. - Python, JS, Rhai SDK codegen emits the unified Input/Output split + the new CRUD signatures. Each language SDK compiles. Wire compatibility verified by [#115](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115). - New ADR posted and linked from CLAUDE.md. ## Blocks - [#113](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/113) (per-root-object Criterion benchmarks) — bench scaffold's natural SDK-wire seeding path consumes `seed::random` from this issue. - [#115](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115) (workspace-root E2E tests) — E2E tests exercise the new CRUD shape; landing #115 against the old shape would create churn. ## Out of scope - Property-based testing of the new contracts — separate issue if needed. - Bulk in-process seeding via the OSIS handler (bypassing the wire) — the typed SDK path is the only blessed seeder. If a bulk-loader becomes necessary for very large fixture sets, that's a follow-up. - Migration of existing on-disk databases populated under the old shape — assumed empty / dev-only DBs at this stage.
Author
Owner

Design proposal — implementation will start after sign-off

Audit done against development (87289a1 on hero_rpc, f171b96 on hero_osis, 5b3e47a0 on hero_lib). Worktrees at /tmp/hero_rpc_117, /tmp/hero_osis_117, /tmp/hero_lib_117. Posting concrete generated-code snippets, deletion list, and a handful of decisions I want your call on before I write a line.

1. herolib_otoml WASM audit — only OTime::now() needs gating

Read the whole crate at /tmp/hero_lib_117/crates/otoml/src/. Items inspected: OTime, OCur, OLocation, OAddress, OAddressBuilder, dump_otoml / load_otoml, dump_obin / load_obin, OtomlSerialize, normalize_keys, the error module. Exactly one call breaks wasm32-unknown-unknown:

crates/otoml/src/otime.rs:138-146

/// Get the current time.
pub fn now() -> Self {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    OTime((secs & 0xFFFFFFFF) as u32)
}

SystemTime::now() is unavailable on wasm32-unknown-unknown. Everything else is pure math, string parsing, serde, or toml codec — already WASM-safe. No std::fs, no std::thread, no std::net, no std::process, no chrono. So the fix is one cfg gate:

#[cfg(not(target_arch = "wasm32"))]
pub fn now() -> Self {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    OTime((secs & 0xFFFFFFFF) as u32)
}

That's it. OTime::default() is OTime(0), which serializes via the existing manual Serialize impl to the valid 19-character string "1970-01-01 00:00:00" — so the wire round-trip works without any extra change, and RecipeInput::default() (below) round-trips cleanly.

hero_rpc_osis::otoml is already a pub use herolib_otoml as otoml re-export (osis/src/lib.rs:71) — no separate type, just a path alias. So the codegen's "server-side OTime" and "SDK-side OTime" diverge only because codegen emits a parallel pub struct OTime(pub String) newtype; once that emission goes away, both sides resolve to the same herolib_otoml::OTime.

2. Codegen import diff

Delete generate_builtin_types() in crates/generator/src/rust/rust_struct.rs:1080-1213 — the entire block that emits the OTime(pub String) newtype plus the parallel OCur / OLocation / OAddress structs.

Add at the top of every emitted types.rs (the generate_types_rs orchestrator that calls into rust_struct.rs):

use herolib_otoml::{OAddress, OCur, OLocation, OTime};
use herolib_sid::SmartId;

Change the primitive-to-Rust mapping in rust_osis.rs:1316-1319:

// before
PrimitiveType::Time => "hero_rpc_osis::otoml::OTime".to_string(),
PrimitiveType::OCur => "hero_rpc_osis::otoml::OCur".to_string(),
PrimitiveType::OLocation => "hero_rpc_osis::otoml::OLocation".to_string(),
PrimitiveType::OAddress => "hero_rpc_osis::otoml::OAddress".to_string(),
// after
PrimitiveType::Time => "OTime".to_string(),
PrimitiveType::OCur => "OCur".to_string(),
PrimitiveType::OLocation => "OLocation".to_string(),
PrimitiveType::OAddress => "OAddress".to_string(),

Both sides now resolve OTime to the same herolib_otoml::OTime via the file's use line. No more two-OTime-shapes.

3. Unified Recipe / RecipeInput emission

rust_struct.rs::generate_struct() (line 468) today injects sid / created_at / updated_at into the single emitted struct when obj.is_root_object. It also reads pub created_at: u64 (not OTime) and pub updated_at: u64 — see lines 484-489. Two changes:

Change A — bump server-managed timestamps to OTime (matches the explicit created_at: otime already declared in examples/recipe_server/schemas/recipes/recipes.oschema:20):

pub created_at: OTime,
pub updated_at: OTime,

Change B — for each rootobject, emit two structs from one schema definition: a <Name>Input (the user-supplied surface) and a <Name> (the full server-visible row). Below is the exact emission shape the new generate_struct() produces for the recipe schema (I traced the existing format!() calls so this is what bytes hit disk, not a paraphrase):

// ─── RecipeInput ─── user-supplied fields only.
//     Server-managed fields (sid, created_at, updated_at) are NOT part of
//     the SDK input surface; the server assigns them.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct RecipeInput {
    pub name: String,
    pub description: String,
    pub difficulty: Difficulty,
    pub category: Category,
    pub prep_time: u32,
    pub cook_time: u32,
    pub servings: u32,
    #[serde(default)]
    pub ingredients: Vec<String>,
    #[serde(default)]
    pub steps: Vec<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}

// ─── Recipe ─── full row (server-managed fields + user fields).
//     `From<Recipe> for RecipeInput` and `From<&Recipe> for RecipeInput`
//     auto-emitted so callers can `recipe.into()` for a `_set` update.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Recipe {
    /// SmartID for storage (server-assigned)
    #[serde(default)]
    pub sid: SmartId,
    /// Creation timestamp (server-assigned)
    #[serde(default)]
    pub created_at: OTime,
    /// Last update timestamp (server-assigned)
    #[serde(default)]
    pub updated_at: OTime,
    pub name: String,
    pub description: String,
    pub difficulty: Difficulty,
    pub category: Category,
    pub prep_time: u32,
    pub cook_time: u32,
    pub servings: u32,
    #[serde(default)]
    pub ingredients: Vec<String>,
    #[serde(default)]
    pub steps: Vec<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}

impl From<&Recipe> for RecipeInput {
    fn from(r: &Recipe) -> Self {
        Self {
            name: r.name.clone(),
            description: r.description.clone(),
            difficulty: r.difficulty.clone(),
            category: r.category.clone(),
            prep_time: r.prep_time,
            cook_time: r.cook_time,
            servings: r.servings,
            ingredients: r.ingredients.clone(),
            steps: r.steps.clone(),
            tags: r.tags.clone(),
        }
    }
}

Non-rootobject types continue to emit one struct, unchanged. The split only applies to [rootobject]-marked types.

Schemas that currently declare sid: str / created_at: otime inline (like recipes.oschema:9, 20) keep the schema clean — the codegen's existing dedup logic at rust_struct.rs:495-499 already skips re-emitting fields named sid / created_at / updated_at on root objects, so no schema edits required. (Side note: # Recipe [rootobject] in that file is currently a comment after PR #108 tightened the marker rule — that's a separate bug; will flag in a follow-up if you don't want me to fix the marker as part of #117.)

4. CRUD method emission — split new/set, drop the leaky &mut

Today rust_osis.rs::generate_crud_methods() emits the OSIS in-process API (the OsisApp/OsisRecipes methods called from the wire dispatcher). After this change:

// _new(input) -> Sid                                — was: _new() -> Recipe
pub fn recipe_new(&self, input: RecipeInput)
    -> std::result::Result<String, Box<dyn std::error::Error>>
{
    let mut obj = Recipe { 
        sid: SmartId::default(),
        created_at: OTime::now(),
        updated_at: OTime::now(),
        name: input.name,
        description: input.description,
        difficulty: input.difficulty,
        category: input.category,
        prep_time: input.prep_time,
        cook_time: input.cook_time,
        servings: input.servings,
        ingredients: input.ingredients,
        steps: input.steps,
        tags: input.tags,
    };
    Self::recipe_trigger_save_pre(&mut obj);
    self.recipe_db.set(&mut obj)?;
    let sid = obj.sid.as_str().to_string();
    Self::recipe_trigger_save_post(&obj);
    Ok(sid)
}

// _set(sid, input) -> ()                            — was: _set(&mut Recipe) -> String
pub fn recipe_set(&self, sid: &str, input: RecipeInput)
    -> std::result::Result<(), Box<dyn std::error::Error>>
{
    let smart_id = SmartId::parse(sid)?;
    let mut obj = self.recipe_db.get(&smart_id)?;     // preserves created_at
    obj.name = input.name;
    obj.description = input.description;
    obj.difficulty = input.difficulty;
    obj.category = input.category;
    obj.prep_time = input.prep_time;
    obj.cook_time = input.cook_time;
    obj.servings = input.servings;
    obj.ingredients = input.ingredients;
    obj.steps = input.steps;
    obj.tags = input.tags;
    obj.updated_at = OTime::now();
    if !Self::recipe_trigger_save_pre(&mut obj) {
        return Err("Save cancelled by trigger".into());
    }
    self.recipe_db.set(&mut obj)?;
    Self::recipe_trigger_save_post(&obj);
    Ok(())
}

// _get / _list / _list_full / _exists / _delete — unchanged signatures.

The wire trait (rust_rpc.rs) follows the same shape. The two _new_from_otoml / _new_from_json helpers at rust_osis.rs:461-484 are dropped — TOML loading moves into the SDK seeder (§5).

5. Typed-SDK seeder — seed::{blank, random, from_dir}

Lands at crates/hero_recipes_sdk/src/generated/seed.rs (and analogously for every scaffolded SDK). Feature-gated so non-seeding consumers don't pay the dep cost:

# crates/hero_recipes_sdk/Cargo.toml
[features]
default = ["all-domains"]
recipes = []
all-domains = ["recipes"]
seed = ["dep:rand", "dep:rand_chacha", "dep:lipsum", "dep:tokio", "dep:walkdir"]

[dependencies]
rand        = { version = "0.8", optional = true, default-features = false, features = ["std_rng"] }
rand_chacha = { version = "0.3", optional = true, default-features = false }
lipsum      = { version = "0.9", optional = true }
walkdir     = { version = "2",   optional = true }

Generated module:

// crates/hero_recipes_sdk/src/generated/seed.rs
#![cfg(feature = "seed")]
#![cfg(not(target_arch = "wasm32"))]    // file I/O is gated; blank/random would work in WASM but the trio ships together for now

use std::path::Path;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;

use crate::generated::recipes::{RecipeInput, CollectionInput, RecipesClient};
use crate::generated::recipes::{Difficulty, Category};

#[derive(Debug, Default)]
pub struct SeedReport {
    pub recipes: Vec<String>,         // sids assigned by the server
    pub collections: Vec<String>,
    pub errors: Vec<String>,
}

/// Empty defaults — `RecipeInput::default()`, etc. Cheap wire-shape smoke test.
pub async fn blank(client: &RecipesClient, count: usize) -> Result<SeedReport, ClientError> {
    let mut report = SeedReport::default();
    for _ in 0..count {
        match client.recipe_new(None, RecipeInput::default()).await {
            Ok(sid) => report.recipes.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
        match client.collection_new(None, CollectionInput::default()).await {
            Ok(sid) => report.collections.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
    }
    Ok(report)
}

/// Randomized but deterministic — fixed-seed ChaCha8 RNG, reproducible across runs.
/// Per-field generator selection is driven by `ui_emit::FieldKind` at codegen time
/// (see §5.1 below); the runtime here just feeds the RNG into the emitted builders.
pub async fn random(
    client: &RecipesClient,
    count: usize,
    rng_seed: u64,
) -> Result<SeedReport, ClientError> {
    let mut rng = ChaCha8Rng::seed_from_u64(rng_seed);
    let mut report = SeedReport::default();
    for _ in 0..count {
        let input = random_recipe_input(&mut rng);
        match client.recipe_new(None, input).await {
            Ok(sid) => report.recipes.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
        let input = random_collection_input(&mut rng);
        match client.collection_new(None, input).await {
            Ok(sid) => report.collections.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
    }
    Ok(report)
}

/// Walks <dir>/<domain>/*.toml; parses each via `herolib_otoml` into the matching
/// <Type>Input; calls `recipe_new` for each. Files MUST contain a `_type` field
/// matching one of the SDK's known input types (string → known table).
pub async fn from_dir(
    client: &RecipesClient,
    dir: impl AsRef<Path>,
) -> Result<SeedReport, ClientError> { /* walkdir → parse → match _type → recipe_new / collection_new */ }

5.1 Per-field generator emission (driven by ui_emit::FieldKind)

For every <Name>Input struct the codegen emits a random_<name>_input(&mut R) private helper. The dispatch table is taken from crates/generator/src/build/ui_emit.rs:65-78:

FieldKind Generator (emitted into the SDK's seed.rs)
Str lipsum::lipsum_with_rng(&mut rng, 4)
SignedInt (rng.gen::<i32>() % 1000) as <T>
UnsignedInt (rng.gen::<u32>() % 100) as <T>
Float rng.gen::<f64>() * 100.0
Bool rng.gen::<bool>()
OTime OTime::from_epoch(rng.gen_range(1577836800..1798761600)) (2020 – 2026)
Enum(vs) vs[rng.gen_range(0..vs.len())].parse().unwrap()
PrimitiveList `(0..rng.gen_range(0..5)).map(
Json serde_json::json!({}) (placeholder; nested-object generation is OOS)

ui_emit::FieldKind doesn't currently express which primitive a SignedInt / UnsignedInt is — codegen already maps that, so we read it from the TypeExpr directly and just use FieldKind to choose the generator category. (The ui_emit module stays untouched; we add a new seed_emit.rs next to it that depends on the same kind-resolution helpers.)

6. Deletion list (every file / Makefile line / doc that goes away)

hero_rpc (worktree /tmp/hero_rpc_117)

  • crates/osis/src/seed/ — entire directory (mod.rs + seeder.rs)
  • crates/osis/src/lib.rs:61-62 — drop the #[cfg(not(target_arch = "wasm32"))] pub mod seed; lines
  • crates/generator/src/rust/rust_osis.rs:461-484 — the _new_from_otoml / _new_from_json emission (TOML ingest moves to SDK seed::from_dir)
  • crates/generator/src/rust/rust_struct.rs:1080-1213generate_builtin_types() (OTime/OCur/OLocation/OAddress WASM-newtype emission)

hero_osis (worktree /tmp/hero_osis_117)

  • crates/hero_osis_server/src/bin/seed.rs — entire file
  • crates/hero_osis_server/service.toml:308-311 — the [[binaries]] name = "hero_osis_seed" block
  • crates/hero_osis_server/Cargo.toml — the [[bin]] name = "hero_osis_seed" declaration (if present)
  • docs/SEEDING.md — entire file (relevant TOML format reference will move into a comment in seed::from_dir's docs)
  • scripts/run.rhai:13 — drop "hero_osis_seed" from BINARIES
  • scripts/build.rhai:12 — drop "hero_osis_seed" from BINARIES
  • scripts/install.rhai:13 — drop "hero_osis_seed" from BINARIES
  • PURPOSE.md:15 — drop the hero_osis_seed — seeding tool bullet

hero_lib (worktree /tmp/hero_lib_117)

  • No deletions. One additive change to crates/otoml/src/otime.rs:139 — the #[cfg(not(target_arch = "wasm32"))] gate on OTime::now().

hero_skills

  • No deletions. One additive change to skills/.../hero_service_scaffold.md — link to the new ADR.

7. ADR

New file docs/adr/002-single-source-of-truth-types-and-seeding.md codifying the five rules from the issue body. Linked from:

  • /tmp/hero_rpc_117/CLAUDE.md
  • lhumina_code/hero_skills/skills/.../hero_service_scaffold.md

8. Cross-language SDK alignment

After the Rust side compiles + the recipe_server round-trips, audit and update:

  • crates/generator/src/js/js_struct.rs — emit RecipeInput / Recipe split + recipeNew(input) → sid, recipeSet(sid, input) → void
  • crates/generator/src/rhai/rhai_struct.rs — same split, Rhai-idiomatic
  • Python emitter (whichever file in crates/generator/) — RecipeInput dataclass + new method signatures
  • Each language SDK's smoke test in examples/recipe_server/sdk/<lang>/ is exercised against the live recipe_server (this is the §5 acceptance criterion — full wire conformance per-language is what #115 covers).

9. Open questions — please decide before I start coding

Q1 — server's --seed-dir startup hook. hero_osis_server has a --seed-dir flag (referenced from tests/e2e/run.sh:83, tests/e2e_seed.rs, GETTING_STARTED.md:190). It calls hero_rpc_osis::seed::* at startup. With crates/osis/src/seed/ deleted, this flag goes too. The tests/e2e_seed.rs integration tests then need a different entry point. Options:

  • (A) Delete --seed-dir from the server. Rewrite tests/e2e_seed.rs to spawn the server, then call the SDK seeder's from_dir from the test body.
  • (B) Keep --seed-dir but reimplement it inside the server as a thin in-process call into the typed-SDK seeder (server depends on its own SDK).

I lean (A) — it preserves the "typed SDK is the only blessed seeder" rule from the ADR — but (B) is less churn for existing operators. Your call.

Q2 — data/seed/ location. Issue body says "either moves into example workspaces or stays where it is and the new helper accepts an explicit path." The from_dir(client, dir) signature already takes an explicit path, so I'd leave /tmp/hero_osis_117/data/seed/ where it is and let from_dir consume it. OK?

Q3 — # Recipe [rootobject] comment in recipes.oschema. The marker is currently a comment (# Recipe [rootobject]), so post-#108 the type doesn't register as a rootobject — which means no CRUD trait emission, which means the recipe SDK round-trip in §3 of the acceptance criteria currently can't run at all. Fix this as part of #117 (uncomment so Recipe and Collection are real rootobjects again), or split into a separate prereq issue?

Q4 — drop _new_from_otoml / _new_from_json from the generated OSIS layer entirely? They're currently public on every domain handler (rust_osis.rs:461-484). After the seeder takes over, they have no callers. Confirm I can delete the emission, or do you want them kept as a convenience API?

Q5 — From<Recipe> for RecipeInput vs From<&Recipe> for RecipeInput. The former is wasteful when callers already own the row. The latter requires .clone() per field in the impl. I'll emit From<&Recipe> (cheaper at the call site for "load row, mutate one field, set"), but happy to do both if you'd prefer.


Standing by for sign-off before I start cutting code. Worktrees are already prepared.

## Design proposal — implementation will start after sign-off Audit done against `development` (`87289a1` on hero_rpc, `f171b96` on hero_osis, `5b3e47a0` on hero_lib). Worktrees at `/tmp/hero_rpc_117`, `/tmp/hero_osis_117`, `/tmp/hero_lib_117`. Posting concrete generated-code snippets, deletion list, and a handful of decisions I want your call on before I write a line. ### 1. herolib_otoml WASM audit — only `OTime::now()` needs gating Read the whole crate at `/tmp/hero_lib_117/crates/otoml/src/`. Items inspected: `OTime`, `OCur`, `OLocation`, `OAddress`, `OAddressBuilder`, `dump_otoml` / `load_otoml`, `dump_obin` / `load_obin`, `OtomlSerialize`, `normalize_keys`, the `error` module. **Exactly one** call breaks `wasm32-unknown-unknown`: `crates/otoml/src/otime.rs:138-146` ```rust /// Get the current time. pub fn now() -> Self { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); OTime((secs & 0xFFFFFFFF) as u32) } ``` `SystemTime::now()` is unavailable on `wasm32-unknown-unknown`. Everything else is pure math, string parsing, serde, or `toml` codec — already WASM-safe. No `std::fs`, no `std::thread`, no `std::net`, no `std::process`, no `chrono`. So the fix is one cfg gate: ```rust #[cfg(not(target_arch = "wasm32"))] pub fn now() -> Self { use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); OTime((secs & 0xFFFFFFFF) as u32) } ``` That's it. `OTime::default()` is `OTime(0)`, which serializes via the existing manual `Serialize` impl to the valid 19-character string `"1970-01-01 00:00:00"` — so the wire round-trip works without any extra change, and `RecipeInput::default()` (below) round-trips cleanly. `hero_rpc_osis::otoml` is already a `pub use herolib_otoml as otoml` re-export (osis/src/lib.rs:71) — no separate type, just a path alias. So the codegen's "server-side OTime" and "SDK-side OTime" diverge only because codegen emits a parallel `pub struct OTime(pub String)` newtype; once that emission goes away, both sides resolve to the same `herolib_otoml::OTime`. ### 2. Codegen import diff **Delete** `generate_builtin_types()` in `crates/generator/src/rust/rust_struct.rs:1080-1213` — the entire block that emits the `OTime(pub String)` newtype plus the parallel `OCur` / `OLocation` / `OAddress` structs. **Add** at the top of every emitted `types.rs` (the `generate_types_rs` orchestrator that calls into `rust_struct.rs`): ```rust use herolib_otoml::{OAddress, OCur, OLocation, OTime}; use herolib_sid::SmartId; ``` **Change** the primitive-to-Rust mapping in `rust_osis.rs:1316-1319`: ```rust // before PrimitiveType::Time => "hero_rpc_osis::otoml::OTime".to_string(), PrimitiveType::OCur => "hero_rpc_osis::otoml::OCur".to_string(), PrimitiveType::OLocation => "hero_rpc_osis::otoml::OLocation".to_string(), PrimitiveType::OAddress => "hero_rpc_osis::otoml::OAddress".to_string(), // after PrimitiveType::Time => "OTime".to_string(), PrimitiveType::OCur => "OCur".to_string(), PrimitiveType::OLocation => "OLocation".to_string(), PrimitiveType::OAddress => "OAddress".to_string(), ``` Both sides now resolve `OTime` to the same `herolib_otoml::OTime` via the file's `use` line. No more two-OTime-shapes. ### 3. Unified Recipe / RecipeInput emission `rust_struct.rs::generate_struct()` (line 468) today injects `sid` / `created_at` / `updated_at` into the single emitted struct when `obj.is_root_object`. It also reads `pub created_at: u64` (not `OTime`) and `pub updated_at: u64` — see lines 484-489. Two changes: **Change A** — bump server-managed timestamps to `OTime` (matches the explicit `created_at: otime` already declared in `examples/recipe_server/schemas/recipes/recipes.oschema:20`): ```rust pub created_at: OTime, pub updated_at: OTime, ``` **Change B** — for each rootobject, emit *two* structs from one schema definition: a `<Name>Input` (the user-supplied surface) and a `<Name>` (the full server-visible row). Below is the exact emission shape the new `generate_struct()` produces for the recipe schema (I traced the existing format!() calls so this is what bytes hit disk, not a paraphrase): ```rust // ─── RecipeInput ─── user-supplied fields only. // Server-managed fields (sid, created_at, updated_at) are NOT part of // the SDK input surface; the server assigns them. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct RecipeInput { pub name: String, pub description: String, pub difficulty: Difficulty, pub category: Category, pub prep_time: u32, pub cook_time: u32, pub servings: u32, #[serde(default)] pub ingredients: Vec<String>, #[serde(default)] pub steps: Vec<String>, #[serde(default)] pub tags: Vec<String>, } // ─── Recipe ─── full row (server-managed fields + user fields). // `From<Recipe> for RecipeInput` and `From<&Recipe> for RecipeInput` // auto-emitted so callers can `recipe.into()` for a `_set` update. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct Recipe { /// SmartID for storage (server-assigned) #[serde(default)] pub sid: SmartId, /// Creation timestamp (server-assigned) #[serde(default)] pub created_at: OTime, /// Last update timestamp (server-assigned) #[serde(default)] pub updated_at: OTime, pub name: String, pub description: String, pub difficulty: Difficulty, pub category: Category, pub prep_time: u32, pub cook_time: u32, pub servings: u32, #[serde(default)] pub ingredients: Vec<String>, #[serde(default)] pub steps: Vec<String>, #[serde(default)] pub tags: Vec<String>, } impl From<&Recipe> for RecipeInput { fn from(r: &Recipe) -> Self { Self { name: r.name.clone(), description: r.description.clone(), difficulty: r.difficulty.clone(), category: r.category.clone(), prep_time: r.prep_time, cook_time: r.cook_time, servings: r.servings, ingredients: r.ingredients.clone(), steps: r.steps.clone(), tags: r.tags.clone(), } } } ``` Non-rootobject types continue to emit one struct, unchanged. The split only applies to `[rootobject]`-marked types. Schemas that currently declare `sid: str` / `created_at: otime` inline (like recipes.oschema:9, 20) keep the **schema clean** — the codegen's existing dedup logic at rust_struct.rs:495-499 already skips re-emitting fields named `sid` / `created_at` / `updated_at` on root objects, so no schema edits required. (Side note: `# Recipe [rootobject]` in that file is currently a comment after PR #108 tightened the marker rule — that's a separate bug; will flag in a follow-up if you don't want me to fix the marker as part of #117.) ### 4. CRUD method emission — split new/set, drop the leaky `&mut` Today `rust_osis.rs::generate_crud_methods()` emits the OSIS in-process API (the OsisApp/OsisRecipes methods called from the wire dispatcher). After this change: ```rust // _new(input) -> Sid — was: _new() -> Recipe pub fn recipe_new(&self, input: RecipeInput) -> std::result::Result<String, Box<dyn std::error::Error>> { let mut obj = Recipe { sid: SmartId::default(), created_at: OTime::now(), updated_at: OTime::now(), name: input.name, description: input.description, difficulty: input.difficulty, category: input.category, prep_time: input.prep_time, cook_time: input.cook_time, servings: input.servings, ingredients: input.ingredients, steps: input.steps, tags: input.tags, }; Self::recipe_trigger_save_pre(&mut obj); self.recipe_db.set(&mut obj)?; let sid = obj.sid.as_str().to_string(); Self::recipe_trigger_save_post(&obj); Ok(sid) } // _set(sid, input) -> () — was: _set(&mut Recipe) -> String pub fn recipe_set(&self, sid: &str, input: RecipeInput) -> std::result::Result<(), Box<dyn std::error::Error>> { let smart_id = SmartId::parse(sid)?; let mut obj = self.recipe_db.get(&smart_id)?; // preserves created_at obj.name = input.name; obj.description = input.description; obj.difficulty = input.difficulty; obj.category = input.category; obj.prep_time = input.prep_time; obj.cook_time = input.cook_time; obj.servings = input.servings; obj.ingredients = input.ingredients; obj.steps = input.steps; obj.tags = input.tags; obj.updated_at = OTime::now(); if !Self::recipe_trigger_save_pre(&mut obj) { return Err("Save cancelled by trigger".into()); } self.recipe_db.set(&mut obj)?; Self::recipe_trigger_save_post(&obj); Ok(()) } // _get / _list / _list_full / _exists / _delete — unchanged signatures. ``` The wire trait (rust_rpc.rs) follows the same shape. The two `_new_from_otoml` / `_new_from_json` helpers at rust_osis.rs:461-484 are dropped — TOML loading moves into the SDK seeder (§5). ### 5. Typed-SDK seeder — `seed::{blank, random, from_dir}` Lands at `crates/hero_recipes_sdk/src/generated/seed.rs` (and analogously for every scaffolded SDK). Feature-gated so non-seeding consumers don't pay the dep cost: ```toml # crates/hero_recipes_sdk/Cargo.toml [features] default = ["all-domains"] recipes = [] all-domains = ["recipes"] seed = ["dep:rand", "dep:rand_chacha", "dep:lipsum", "dep:tokio", "dep:walkdir"] [dependencies] rand = { version = "0.8", optional = true, default-features = false, features = ["std_rng"] } rand_chacha = { version = "0.3", optional = true, default-features = false } lipsum = { version = "0.9", optional = true } walkdir = { version = "2", optional = true } ``` Generated module: ```rust // crates/hero_recipes_sdk/src/generated/seed.rs #![cfg(feature = "seed")] #![cfg(not(target_arch = "wasm32"))] // file I/O is gated; blank/random would work in WASM but the trio ships together for now use std::path::Path; use rand::SeedableRng; use rand_chacha::ChaCha8Rng; use crate::generated::recipes::{RecipeInput, CollectionInput, RecipesClient}; use crate::generated::recipes::{Difficulty, Category}; #[derive(Debug, Default)] pub struct SeedReport { pub recipes: Vec<String>, // sids assigned by the server pub collections: Vec<String>, pub errors: Vec<String>, } /// Empty defaults — `RecipeInput::default()`, etc. Cheap wire-shape smoke test. pub async fn blank(client: &RecipesClient, count: usize) -> Result<SeedReport, ClientError> { let mut report = SeedReport::default(); for _ in 0..count { match client.recipe_new(None, RecipeInput::default()).await { Ok(sid) => report.recipes.push(sid), Err(e) => report.errors.push(e.to_string()), } match client.collection_new(None, CollectionInput::default()).await { Ok(sid) => report.collections.push(sid), Err(e) => report.errors.push(e.to_string()), } } Ok(report) } /// Randomized but deterministic — fixed-seed ChaCha8 RNG, reproducible across runs. /// Per-field generator selection is driven by `ui_emit::FieldKind` at codegen time /// (see §5.1 below); the runtime here just feeds the RNG into the emitted builders. pub async fn random( client: &RecipesClient, count: usize, rng_seed: u64, ) -> Result<SeedReport, ClientError> { let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); let mut report = SeedReport::default(); for _ in 0..count { let input = random_recipe_input(&mut rng); match client.recipe_new(None, input).await { Ok(sid) => report.recipes.push(sid), Err(e) => report.errors.push(e.to_string()), } let input = random_collection_input(&mut rng); match client.collection_new(None, input).await { Ok(sid) => report.collections.push(sid), Err(e) => report.errors.push(e.to_string()), } } Ok(report) } /// Walks <dir>/<domain>/*.toml; parses each via `herolib_otoml` into the matching /// <Type>Input; calls `recipe_new` for each. Files MUST contain a `_type` field /// matching one of the SDK's known input types (string → known table). pub async fn from_dir( client: &RecipesClient, dir: impl AsRef<Path>, ) -> Result<SeedReport, ClientError> { /* walkdir → parse → match _type → recipe_new / collection_new */ } ``` #### 5.1 Per-field generator emission (driven by `ui_emit::FieldKind`) For every `<Name>Input` struct the codegen emits a `random_<name>_input(&mut R)` private helper. The dispatch table is taken from `crates/generator/src/build/ui_emit.rs:65-78`: | `FieldKind` | Generator (emitted into the SDK's `seed.rs`) | |-----------------|---------------------------------------------------------------------------| | `Str` | `lipsum::lipsum_with_rng(&mut rng, 4)` | | `SignedInt` | `(rng.gen::<i32>() % 1000) as <T>` | | `UnsignedInt` | `(rng.gen::<u32>() % 100) as <T>` | | `Float` | `rng.gen::<f64>() * 100.0` | | `Bool` | `rng.gen::<bool>()` | | `OTime` | `OTime::from_epoch(rng.gen_range(1577836800..1798761600))` (2020 – 2026) | | `Enum(vs)` | `vs[rng.gen_range(0..vs.len())].parse().unwrap()` | | `PrimitiveList` | `(0..rng.gen_range(0..5)).map(|_| <generator for inner kind>).collect()` | | `Json` | `serde_json::json!({})` (placeholder; nested-object generation is OOS) | `ui_emit::FieldKind` doesn't currently express *which* primitive a `SignedInt` / `UnsignedInt` is — codegen already maps that, so we read it from the `TypeExpr` directly and just use `FieldKind` to choose the generator category. (The `ui_emit` module stays untouched; we add a new `seed_emit.rs` next to it that depends on the same kind-resolution helpers.) ### 6. Deletion list (every file / Makefile line / doc that goes away) #### hero_rpc (worktree `/tmp/hero_rpc_117`) - `crates/osis/src/seed/` — entire directory (`mod.rs` + `seeder.rs`) - `crates/osis/src/lib.rs:61-62` — drop the `#[cfg(not(target_arch = "wasm32"))] pub mod seed;` lines - `crates/generator/src/rust/rust_osis.rs:461-484` — the `_new_from_otoml` / `_new_from_json` emission (TOML ingest moves to SDK `seed::from_dir`) - `crates/generator/src/rust/rust_struct.rs:1080-1213` — `generate_builtin_types()` (OTime/OCur/OLocation/OAddress WASM-newtype emission) #### hero_osis (worktree `/tmp/hero_osis_117`) - `crates/hero_osis_server/src/bin/seed.rs` — entire file - `crates/hero_osis_server/service.toml:308-311` — the `[[binaries]] name = "hero_osis_seed"` block - `crates/hero_osis_server/Cargo.toml` — the `[[bin]] name = "hero_osis_seed"` declaration (if present) - `docs/SEEDING.md` — entire file (relevant TOML format reference will move into a comment in `seed::from_dir`'s docs) - `scripts/run.rhai:13` — drop `"hero_osis_seed"` from `BINARIES` - `scripts/build.rhai:12` — drop `"hero_osis_seed"` from `BINARIES` - `scripts/install.rhai:13` — drop `"hero_osis_seed"` from `BINARIES` - `PURPOSE.md:15` — drop the `hero_osis_seed — seeding tool` bullet #### hero_lib (worktree `/tmp/hero_lib_117`) - No deletions. One additive change to `crates/otoml/src/otime.rs:139` — the `#[cfg(not(target_arch = "wasm32"))]` gate on `OTime::now()`. #### hero_skills - No deletions. One additive change to `skills/.../hero_service_scaffold.md` — link to the new ADR. ### 7. ADR New file `docs/adr/002-single-source-of-truth-types-and-seeding.md` codifying the five rules from the issue body. Linked from: - `/tmp/hero_rpc_117/CLAUDE.md` - `lhumina_code/hero_skills/skills/.../hero_service_scaffold.md` ### 8. Cross-language SDK alignment After the Rust side compiles + the recipe_server round-trips, audit and update: - `crates/generator/src/js/js_struct.rs` — emit `RecipeInput` / `Recipe` split + `recipeNew(input) → sid`, `recipeSet(sid, input) → void` - `crates/generator/src/rhai/rhai_struct.rs` — same split, Rhai-idiomatic - Python emitter (whichever file in `crates/generator/`) — `RecipeInput` dataclass + new method signatures - Each language SDK's smoke test in `examples/recipe_server/sdk/<lang>/` is exercised against the live recipe_server (this is the §5 acceptance criterion — full wire conformance per-language is what #115 covers). ### 9. Open questions — please decide before I start coding **Q1 — server's `--seed-dir` startup hook.** `hero_osis_server` has a `--seed-dir` flag (referenced from `tests/e2e/run.sh:83`, `tests/e2e_seed.rs`, `GETTING_STARTED.md:190`). It calls `hero_rpc_osis::seed::*` at startup. With `crates/osis/src/seed/` deleted, this flag goes too. The `tests/e2e_seed.rs` integration tests then need a different entry point. Options: - **(A)** Delete `--seed-dir` from the server. Rewrite `tests/e2e_seed.rs` to spawn the server, then call the SDK seeder's `from_dir` from the test body. - **(B)** Keep `--seed-dir` but reimplement it inside the server as a thin in-process call into the typed-SDK seeder (server depends on its own SDK). I lean (A) — it preserves the "typed SDK is the only blessed seeder" rule from the ADR — but (B) is less churn for existing operators. Your call. **Q2 — `data/seed/` location.** Issue body says "either moves into example workspaces or stays where it is and the new helper accepts an explicit path." The `from_dir(client, dir)` signature already takes an explicit path, so I'd leave `/tmp/hero_osis_117/data/seed/` where it is and let `from_dir` consume it. OK? **Q3 — `# Recipe [rootobject]` comment in recipes.oschema.** The marker is currently a comment (`# Recipe [rootobject]`), so post-#108 the type doesn't register as a rootobject — which means no CRUD trait emission, which means the recipe SDK round-trip in §3 of the acceptance criteria currently can't run *at all*. Fix this as part of #117 (uncomment so `Recipe` and `Collection` are real rootobjects again), or split into a separate prereq issue? **Q4 — drop `_new_from_otoml` / `_new_from_json` from the generated OSIS layer entirely?** They're currently public on every domain handler (rust_osis.rs:461-484). After the seeder takes over, they have no callers. Confirm I can delete the emission, or do you want them kept as a convenience API? **Q5 — `From<Recipe> for RecipeInput` vs `From<&Recipe> for RecipeInput`.** The former is wasteful when callers already own the row. The latter requires `.clone()` per field in the impl. I'll emit `From<&Recipe>` (cheaper at the call site for "load row, mutate one field, set"), but happy to do both if you'd prefer. --- Standing by for sign-off before I start cutting code. Worktrees are already prepared.
Author
Owner

Scope addition — fold in: unified service spin-up

@ excellent audit. Wanted to fold one more dimension of "single source of truth" into the issue before you cut code — the user signed off on this as part of #117 rather than as a separate issue.

Problem

Today the scaffolded <service>_server crate is bin-only. The foreground bootstrap (build OSIS handler → rpc2_adapter::module_forServerBuilder::new().serve_http()ctrl_c().awaitshutdown) lives inside main.rs (~80 lines, see examples/recipe_server/crates/hero_recipes_server/src/main.rs:30-140).

Consequences:

  1. Tests / benches / runnable examples can't reuse it. They each duplicate the spin-up block (or skip parts of it). Drift between the test spin-up and production spin-up is silent — tests can pass against a configuration that doesn't match what hero_proc execs.
  2. Three downstream issues each rewrite the same bootstrap:
    • #113 (benches) — needs in-process spin-up inside benches/runner.rs.
    • #115 (workspace-root tests/) — needs in-process spin-up inside tests/src/lib.rs::spin_up_service().
    • The existing per-domain runnable example emitter (crates/generator/src/generate/e2e.rsexamples/rust/<domain>/client_server.rs) — already has its own custom block.

Three copies of the same logic + a fourth in main.rs, all drifting independently. Same problem the type unification fixes for data shapes, applied to lifecycle.

Resolution

Promote the scaffolded server crate from bin-only to lib + bin. Extract the foreground bootstrap into a public library function consumed by all four call sites.

New crates/<service>_server/src/lib.rs (scaffolded once + codegen for the OsisXxx references)

//! Foreground bootstrap. Single canonical builder used by:
//!   - main.rs       (hero_proc execs this in production)
//!   - tests/        (workspace-root E2E tests — #115)
//!   - benches/      (workspace-root Criterion benches — #113)
//!   - examples/     (runnable client_server examples)
//!
//! Re-running the codegen never overwrites this file — it only re-emits the
//! per-domain wiring inside `run()`'s body when domains are added/removed.

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::Result;
use hero_rpc2::prelude::*;
use hero_rpc_osis::rpc::{OsisDomainInit, rpc2_adapter};

#[cfg(feature = "recipes")]
use crate::recipes::OsisRecipes;

/// Configuration for one foreground run of the service.
pub struct ServerConfig {
    pub data_dir: PathBuf,
    pub socket_path: PathBuf,
    pub user_id: u16,
    pub service_info: ServiceInfo,
    pub lifted_headers: Vec<String>,
}

impl ServerConfig {
    /// Production: reads service.toml + env + hero_var_dir — same path `hero_proc`
    /// invokes today via `lab service <name> --start`. Unchanged semantics.
    pub fn from_environment(service_name: &str, service_toml: &str) -> Result<Self> {
        use herolib_core::base;
        let sockets = base::prepare_sockets(service_name, service_toml);
        let socket_path = sockets
            .iter()
            .map(|s| s.path.clone())
            .next()
            .ok_or_else(|| anyhow::anyhow!("service.toml declared no sockets"))?;
        let data_dir = base::hero_var_dir().join("osisdb/root/recipes");
        std::fs::create_dir_all(&data_dir)?;
        Ok(Self {
            data_dir,
            socket_path,
            user_id: 0,
            service_info: ServiceInfo::new("hero_recipes", env!("CARGO_PKG_VERSION"))
                .title("Hero Recipes"),
            lifted_headers: vec![
                "x-hero-context".into(),
                "x-hero-claims".into(),
                "x-forwarded-prefix".into(),
            ],
        })
    }

    /// Tests / benches / examples: tmpdir + unique socket, no env reads, no hero_proc.
    /// PID + atomic counter avoid collisions across parallel tests; `/tmp` keeps
    /// the path under the macOS SUN_LEN (~104 chars) limit.
    pub fn for_test(service_name: &str) -> Result<(Self, tempfile::TempDir)> {
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(0);

        let data_dir = tempfile::tempdir()?;
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let sock = std::env::temp_dir().join(format!(
            "h-{}-{}-{}.sock",
            service_name.trim_start_matches("hero_"),
            std::process::id(),
            n
        ));
        let _ = std::fs::remove_file(&sock);

        let cfg = Self {
            data_dir: data_dir.path().to_path_buf(),
            socket_path: sock,
            user_id: 0,
            service_info: ServiceInfo::new(service_name, env!("CARGO_PKG_VERSION")),
            lifted_headers: vec!["x-hero-context".into(), "x-hero-claims".into()],
        };
        // Caller holds the TempDir to keep data_dir alive for the test's lifetime.
        Ok((cfg, data_dir))
    }
}

/// Build the OSIS handler + rpc2 module, bind the server, return a running handle.
/// One canonical builder. Used by main.rs in prod, by tests/benches/examples in dev.
pub async fn run(cfg: ServerConfig) -> Result<RunningServer> {
    #[cfg(feature = "recipes")]
    let app: Arc<OsisRecipes> = <OsisRecipes as OsisDomainInit>::create(
        cfg.data_dir.to_str().unwrap(),
        cfg.user_id,
    )?;
    let module = rpc2_adapter::module_for(app)?;

    let mut builder = ServerBuilder::new(module).with_service_info(cfg.service_info);
    if !cfg.lifted_headers.is_empty() {
        builder = builder.with_lifted_headers(cfg.lifted_headers.iter().map(String::as_str));
    }
    let server = builder.serve_http(&cfg.socket_path).await?;

    Ok(RunningServer {
        server: Some(server),
        socket_path: cfg.socket_path,
    })
}

/// RAII handle around the live server. Drop tears down + removes the socket file.
pub struct RunningServer {
    server: Option<hero_rpc2::ServerJoinHandle>,
    socket_path: PathBuf,
}

impl RunningServer {
    pub fn socket_path(&self) -> &std::path::Path { &self.socket_path }

    pub async fn shutdown(mut self) {
        if let Some(s) = self.server.take() { s.shutdown().await; }
        let _ = std::fs::remove_file(&self.socket_path);
    }
}

impl Drop for RunningServer {
    fn drop(&mut self) {
        if let Some(s) = self.server.take() {
            if let Ok(rt) = tokio::runtime::Handle::try_current() {
                rt.block_on(s.shutdown());
            }
        }
        let _ = std::fs::remove_file(&self.socket_path);
    }
}

main.rs shrinks to the lifecycle shell

// crates/<service>_server/src/main.rs — scaffolded once.

use hero_lifecycle::HeroLifecycle;
use hero_recipes_server::{run, ServerConfig};
use herolib_core::service_base;

service_base!();

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    herolib_core::base::validate_service_toml(SERVICE_TOML);
    herolib_core::base::handle_info_flag(SERVICE_TOML);
    // ... tracing init ...

    let lifecycle = HeroLifecycle::new(/* ... */).service_toml(SERVICE_TOML);
    let args: Vec<String> = std::env::args().collect();
    if args.iter().any(|a| a == "--start") { return lifecycle.start().await; }
    if args.iter().any(|a| a == "--stop")  { return lifecycle.stop().await; }

    herolib_core::base::print_startup_banner(/* ... */);
    let cfg = ServerConfig::from_environment("hero_recipes_server", SERVICE_TOML)?;
    let running = run(cfg).await?;
    tokio::signal::ctrl_c().await?;
    running.shutdown().await;
    Ok(())
}

Tests / benches / examples — three-line wrapper

// tests/src/lib.rs (#115) — handwritten, scaffolded once.
pub async fn spin_up_service() -> anyhow::Result<(RunningServer, Arc<dyn ClientT>, TempDir)> {
    let (cfg, data_dir) = ServerConfig::for_test("hero_recipes_server")?;
    let running = hero_recipes_server::run(cfg).await?;
    let client = ClientBuilder::new().connect_http(running.socket_path()).await?;
    Ok((running, Arc::new(client), data_dir))
}

Same pattern in benches/runner.rs (#113) and in the runnable examples/rust/<domain>/client_server.rs emitter — none of them redefine the bootstrap.

Cargo manifest delta

The scaffolded crates/<service>_server/Cargo.toml gains a [lib] section pointing at src/lib.rs alongside the existing [[bin]] block. The tempfile dependency is dev-only (for ServerConfig::for_test); production builds don't pull it in. Actually — since for_test is on a non-test code path today, it needs tempfile as a regular dep gated behind a test-support feature, OR we move for_test into tests/src/lib.rs and have it construct ServerConfig directly. The latter is cleaner; happy with either.

Scaffolder changes

crates/generator/src/build/scaffold.rs::generate_server_crate (or equivalent) needs to:

  • Emit src/lib.rs (new) and src/main.rs (slimmer) instead of src/main.rs only.
  • Add [lib] to the Cargo.toml.
  • Update the existing emit point so re-running the scaffolder is preserve-once-aware for lib.rs (just like it already is for main.rs).

The per-domain codegen that goes into <server>/src/<domain>/generated/ is unchanged — those keep being regenerated freely.

Acceptance additions

  • hero_recipes_server is a lib + bin crate. cargo doc -p hero_recipes_server shows ServerConfig, run, RunningServer as public items.
  • The recipe_server example's existing main.rs is refactored down to ~25 lines, calling from_environment + run.
  • lab service hero_recipes --start continues to work unchanged — the --start / --stop / foreground modes still live in main.rs, only the foreground body delegates to run().
  • The three downstream call sites (tests/runner, bench runner, runnable example) each consume run() via a 3-line wrapper. No duplicated ServerBuilder block anywhere.

Sequencing note

This addition blocks #115 alongside the rest of #117#115's spin_up_service() is the canonical first consumer of run(). #113's bench runner.rs and the runnable example emitter follow.

No new open questions from this addition — happy to defer to the answers on Q1–Q5 for everything else.

## Scope addition — fold in: unified service spin-up @<agent-on-117> excellent audit. Wanted to fold one more dimension of "single source of truth" into the issue before you cut code — the user signed off on this as part of #117 rather than as a separate issue. ### Problem Today the scaffolded `<service>_server` crate is **bin-only**. The foreground bootstrap (build OSIS handler → `rpc2_adapter::module_for` → `ServerBuilder::new().serve_http()` → `ctrl_c().await` → `shutdown`) lives inside `main.rs` (~80 lines, see [`examples/recipe_server/crates/hero_recipes_server/src/main.rs:30-140`](https://forge.ourworld.tf/lhumina_code/hero_rpc/src/branch/development/examples/recipe_server/crates/hero_recipes_server/src/main.rs#L30-L140)). Consequences: 1. **Tests / benches / runnable examples can't reuse it.** They each duplicate the spin-up block (or skip parts of it). Drift between the test spin-up and production spin-up is silent — tests can pass against a configuration that doesn't match what `hero_proc` execs. 2. **Three downstream issues each rewrite the same bootstrap:** - [#113](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/113) (benches) — needs in-process spin-up inside `benches/runner.rs`. - [#115](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/115) (workspace-root `tests/`) — needs in-process spin-up inside `tests/src/lib.rs::spin_up_service()`. - The existing per-domain runnable example emitter (`crates/generator/src/generate/e2e.rs` → `examples/rust/<domain>/client_server.rs`) — already has its own custom block. Three copies of the same logic + a fourth in `main.rs`, all drifting independently. Same problem the type unification fixes for data shapes, applied to lifecycle. ### Resolution Promote the scaffolded server crate from `bin`-only to `lib` + `bin`. Extract the foreground bootstrap into a public library function consumed by all four call sites. #### New `crates/<service>_server/src/lib.rs` (scaffolded once + codegen for the `OsisXxx` references) ```rust //! Foreground bootstrap. Single canonical builder used by: //! - main.rs (hero_proc execs this in production) //! - tests/ (workspace-root E2E tests — #115) //! - benches/ (workspace-root Criterion benches — #113) //! - examples/ (runnable client_server examples) //! //! Re-running the codegen never overwrites this file — it only re-emits the //! per-domain wiring inside `run()`'s body when domains are added/removed. use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; use hero_rpc2::prelude::*; use hero_rpc_osis::rpc::{OsisDomainInit, rpc2_adapter}; #[cfg(feature = "recipes")] use crate::recipes::OsisRecipes; /// Configuration for one foreground run of the service. pub struct ServerConfig { pub data_dir: PathBuf, pub socket_path: PathBuf, pub user_id: u16, pub service_info: ServiceInfo, pub lifted_headers: Vec<String>, } impl ServerConfig { /// Production: reads service.toml + env + hero_var_dir — same path `hero_proc` /// invokes today via `lab service <name> --start`. Unchanged semantics. pub fn from_environment(service_name: &str, service_toml: &str) -> Result<Self> { use herolib_core::base; let sockets = base::prepare_sockets(service_name, service_toml); let socket_path = sockets .iter() .map(|s| s.path.clone()) .next() .ok_or_else(|| anyhow::anyhow!("service.toml declared no sockets"))?; let data_dir = base::hero_var_dir().join("osisdb/root/recipes"); std::fs::create_dir_all(&data_dir)?; Ok(Self { data_dir, socket_path, user_id: 0, service_info: ServiceInfo::new("hero_recipes", env!("CARGO_PKG_VERSION")) .title("Hero Recipes"), lifted_headers: vec![ "x-hero-context".into(), "x-hero-claims".into(), "x-forwarded-prefix".into(), ], }) } /// Tests / benches / examples: tmpdir + unique socket, no env reads, no hero_proc. /// PID + atomic counter avoid collisions across parallel tests; `/tmp` keeps /// the path under the macOS SUN_LEN (~104 chars) limit. pub fn for_test(service_name: &str) -> Result<(Self, tempfile::TempDir)> { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let data_dir = tempfile::tempdir()?; let n = COUNTER.fetch_add(1, Ordering::Relaxed); let sock = std::env::temp_dir().join(format!( "h-{}-{}-{}.sock", service_name.trim_start_matches("hero_"), std::process::id(), n )); let _ = std::fs::remove_file(&sock); let cfg = Self { data_dir: data_dir.path().to_path_buf(), socket_path: sock, user_id: 0, service_info: ServiceInfo::new(service_name, env!("CARGO_PKG_VERSION")), lifted_headers: vec!["x-hero-context".into(), "x-hero-claims".into()], }; // Caller holds the TempDir to keep data_dir alive for the test's lifetime. Ok((cfg, data_dir)) } } /// Build the OSIS handler + rpc2 module, bind the server, return a running handle. /// One canonical builder. Used by main.rs in prod, by tests/benches/examples in dev. pub async fn run(cfg: ServerConfig) -> Result<RunningServer> { #[cfg(feature = "recipes")] let app: Arc<OsisRecipes> = <OsisRecipes as OsisDomainInit>::create( cfg.data_dir.to_str().unwrap(), cfg.user_id, )?; let module = rpc2_adapter::module_for(app)?; let mut builder = ServerBuilder::new(module).with_service_info(cfg.service_info); if !cfg.lifted_headers.is_empty() { builder = builder.with_lifted_headers(cfg.lifted_headers.iter().map(String::as_str)); } let server = builder.serve_http(&cfg.socket_path).await?; Ok(RunningServer { server: Some(server), socket_path: cfg.socket_path, }) } /// RAII handle around the live server. Drop tears down + removes the socket file. pub struct RunningServer { server: Option<hero_rpc2::ServerJoinHandle>, socket_path: PathBuf, } impl RunningServer { pub fn socket_path(&self) -> &std::path::Path { &self.socket_path } pub async fn shutdown(mut self) { if let Some(s) = self.server.take() { s.shutdown().await; } let _ = std::fs::remove_file(&self.socket_path); } } impl Drop for RunningServer { fn drop(&mut self) { if let Some(s) = self.server.take() { if let Ok(rt) = tokio::runtime::Handle::try_current() { rt.block_on(s.shutdown()); } } let _ = std::fs::remove_file(&self.socket_path); } } ``` #### `main.rs` shrinks to the lifecycle shell ```rust // crates/<service>_server/src/main.rs — scaffolded once. use hero_lifecycle::HeroLifecycle; use hero_recipes_server::{run, ServerConfig}; use herolib_core::service_base; service_base!(); #[tokio::main] async fn main() -> anyhow::Result<()> { herolib_core::base::validate_service_toml(SERVICE_TOML); herolib_core::base::handle_info_flag(SERVICE_TOML); // ... tracing init ... let lifecycle = HeroLifecycle::new(/* ... */).service_toml(SERVICE_TOML); let args: Vec<String> = std::env::args().collect(); if args.iter().any(|a| a == "--start") { return lifecycle.start().await; } if args.iter().any(|a| a == "--stop") { return lifecycle.stop().await; } herolib_core::base::print_startup_banner(/* ... */); let cfg = ServerConfig::from_environment("hero_recipes_server", SERVICE_TOML)?; let running = run(cfg).await?; tokio::signal::ctrl_c().await?; running.shutdown().await; Ok(()) } ``` #### Tests / benches / examples — three-line wrapper ```rust // tests/src/lib.rs (#115) — handwritten, scaffolded once. pub async fn spin_up_service() -> anyhow::Result<(RunningServer, Arc<dyn ClientT>, TempDir)> { let (cfg, data_dir) = ServerConfig::for_test("hero_recipes_server")?; let running = hero_recipes_server::run(cfg).await?; let client = ClientBuilder::new().connect_http(running.socket_path()).await?; Ok((running, Arc::new(client), data_dir)) } ``` Same pattern in `benches/runner.rs` (#113) and in the runnable `examples/rust/<domain>/client_server.rs` emitter — none of them redefine the bootstrap. ### Cargo manifest delta The scaffolded `crates/<service>_server/Cargo.toml` gains a `[lib]` section pointing at `src/lib.rs` alongside the existing `[[bin]]` block. The `tempfile` dependency is dev-only (for `ServerConfig::for_test`); production builds don't pull it in. Actually — since `for_test` is on a non-test code path today, it needs `tempfile` as a regular dep gated behind a `test-support` feature, OR we move `for_test` into `tests/src/lib.rs` and have it construct `ServerConfig` directly. The latter is cleaner; happy with either. ### Scaffolder changes `crates/generator/src/build/scaffold.rs::generate_server_crate` (or equivalent) needs to: - Emit `src/lib.rs` (new) and `src/main.rs` (slimmer) instead of `src/main.rs` only. - Add `[lib]` to the Cargo.toml. - Update the existing emit point so re-running the scaffolder is preserve-once-aware for `lib.rs` (just like it already is for `main.rs`). The per-domain codegen that goes into `<server>/src/<domain>/generated/` is unchanged — those keep being regenerated freely. ### Acceptance additions - `hero_recipes_server` is a `lib + bin` crate. `cargo doc -p hero_recipes_server` shows `ServerConfig`, `run`, `RunningServer` as public items. - The recipe_server example's existing `main.rs` is refactored down to ~25 lines, calling `from_environment` + `run`. - `lab service hero_recipes --start` continues to work unchanged — the `--start` / `--stop` / foreground modes still live in `main.rs`, only the foreground body delegates to `run()`. - The three downstream call sites (tests/runner, bench runner, runnable example) each consume `run()` via a 3-line wrapper. No duplicated `ServerBuilder` block anywhere. ### Sequencing note This addition blocks #115 alongside the rest of #117 — #115's `spin_up_service()` is the canonical first consumer of `run()`. #113's bench `runner.rs` and the runnable example emitter follow. No new open questions from this addition — happy to defer to the answers on Q1–Q5 for everything else.
Author
Owner

Correction — supersedes my earlier bootstrap-unify comment

The user pushed back (correctly) on my prior scope-addition comment. Two specific corrections — retracting the parts of that comment they invalidate:

Retracted: "promote <service>_server to lib + bin"

Wrong. <service>_server stays bin-only. Three reasons I had missed:

  1. service_base!() hard-codes include_str!("../service.toml") — only resolves correctly from src/main.rs. Embedding from a lib.rs would either duplicate the embed or require a different macro shape.
  2. lab infocheck (source: hero_skills/crates/lab/src/builder/infocheck.rs:280-332) audits src/main.rs specifically. It requires three substring matches there — service_base!, validate_service_toml, handle_info_flag — and forbids fn print_info_json / fn print_info_toml. Splitting startup logic into a sibling lib.rs would hide the contract behind a re-export the auditor doesn't recognize.
  3. The whole hero_proc / lab service --start chain is binary-centric: <bin> --info --json, <bin> --start, foreground exec by hero_proc. The bin shape is the lifecycle contract.

Retracted: ServerConfig::from_environment reaching past prepare_sockets

Wrong. The canonical herolib_core::base::prepare_sockets does stale-socket cleanup + live-conflict detection + parent-dir creation; its Vec<SocketPrep> return is the only blessed socket-path source. My from_environment was rebuilding that logic by hand. Skipping prepare_sockets breaks the contract lab service --start depends on (see hero_service_check_fix.md §0a runtime checks 1–4).


Corrected factoring

The canonical foreground bootstrap belongs in hero_rpc_osis (already owns rpc2_adapter::module_for, already depends on hero_rpc2, already a shared dep across every scaffolded service). New helper module:

// crates/osis/src/rpc/server.rs — NEW (sibling of rpc2_adapter)

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::Result;
use herolib_core::base;
use hero_rpc2::{ServerBuilder, ServerJoinHandle};

use crate::rpc::{OsisDomainInit, rpc2_adapter};

pub struct RunningServer {
    server: Option<ServerJoinHandle>,
    socket_path: PathBuf,
}

impl RunningServer {
    pub fn socket_path(&self) -> &std::path::Path { &self.socket_path }

    pub async fn shutdown(mut self) {
        if let Some(s) = self.server.take() { s.shutdown().await; }
        let _ = std::fs::remove_file(&self.socket_path);
    }
}

impl Drop for RunningServer {
    fn drop(&mut self) {
        // Best-effort cleanup. If a runtime is around, drive shutdown to completion.
        if let Some(s) = self.server.take() {
            if let Ok(rt) = tokio::runtime::Handle::try_current() {
                rt.block_on(s.shutdown());
            }
        }
        let _ = std::fs::remove_file(&self.socket_path);
    }
}

/// Production foreground bootstrap. Composes:
///   - print_startup_banner    (herolib_core::base)
///   - prepare_sockets          (herolib_core::base — stale cleanup + live detect)
///   - hero_var_dir + mkdir     (herolib_core::base)
///   - App::create              (OsisDomainInit)
///   - rpc2_adapter::module_for (this crate)
///   - ServerBuilder::serve_http (hero_rpc2)
///
/// Called from main.rs after the lifecycle CLI dispatch and the
/// validate_service_toml / handle_info_flag guards. Returns a RAII handle
/// the caller blocks on ctrl_c, then `shutdown()`s.
pub async fn run<App>(
    binary_name: &str,
    service_toml: &str,
    build_nr: u64,
    db_subpath: &str,
    user_id: u16,
) -> Result<RunningServer>
where
    App: OsisDomainInit + Send + Sync + 'static,
{
    base::print_startup_banner(binary_name, service_toml, build_nr, &[]);

    let socket_path = base::prepare_sockets(binary_name, service_toml)
        .into_iter()
        .next()
        .ok_or_else(|| anyhow::anyhow!("{binary_name}: service.toml declared no sockets"))?
        .path;

    let data_dir = base::hero_var_dir().join(db_subpath);
    std::fs::create_dir_all(&data_dir)?;

    let app: Arc<App> = App::create(data_dir.to_str().unwrap(), user_id)
        .map_err(|e| anyhow::anyhow!("create {}: {e}", std::any::type_name::<App>()))?;
    let module = rpc2_adapter::module_for(app)?;

    let server = ServerBuilder::new(module)
        .with_lifted_headers(["x-hero-context", "x-hero-claims", "x-forwarded-prefix"])
        .serve_http(&socket_path)
        .await?;

    Ok(RunningServer { server: Some(server), socket_path })
}

/// Tests / benches / examples: tmp data dir + per-test unique socket, no
/// service.toml, no banner. Caller holds the TempDir for the test's lifetime
/// (the data dir is wiped when it drops).
pub async fn run_for_test<App>(
    service_label: &str,
    user_id: u16,
) -> Result<(RunningServer, tempfile::TempDir)>
where
    App: OsisDomainInit + Send + Sync + 'static,
{
    use std::sync::atomic::{AtomicU64, Ordering};
    static COUNTER: AtomicU64 = AtomicU64::new(0);

    let data_dir = tempfile::tempdir()?;
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let sock = std::env::temp_dir()
        .join(format!("h-{}-{}-{}.sock", service_label, std::process::id(), n));
    let _ = std::fs::remove_file(&sock);

    let app: Arc<App> = App::create(data_dir.path().to_str().unwrap(), user_id)
        .map_err(|e| anyhow::anyhow!("create {}: {e}", std::any::type_name::<App>()))?;
    let module = rpc2_adapter::module_for(app)?;

    let server = ServerBuilder::new(module)
        .with_lifted_headers(["x-hero-context", "x-hero-claims"])
        .serve_http(&sock)
        .await?;

    Ok((RunningServer { server: Some(server), socket_path: sock }, data_dir))
}

main.rs after the change — auditor-clean

service_base!();   // SERVICE_TOML + BUILD_NR — main.rs-only convention

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    herolib_core::base::validate_service_toml(SERVICE_TOML);
    herolib_core::base::handle_info_flag(SERVICE_TOML);
    // tracing init …

    let lifecycle = HeroLifecycle::new(/* … */).service_toml(SERVICE_TOML);
    let args: Vec<String> = std::env::args().collect();
    if args.iter().any(|a| a == "--start") { return lifecycle.start().await; }
    if args.iter().any(|a| a == "--stop")  { return lifecycle.stop().await; }

    // Foreground — single call into the canonical builder.
    let running = hero_rpc_osis::rpc::server::run::<OsisRecipes>(
        "hero_recipes_server",
        SERVICE_TOML,
        BUILD_NR,
        "osisdb/root/recipes",
        0,
    ).await?;
    tokio::signal::ctrl_c().await?;
    running.shutdown().await;
    Ok(())
}

lab infocheck audit (verified against infocheck.rs:280-332):

  • Required substring service_base! — ✓ (line 1 of main.rs).
  • Required substring validate_service_toml — ✓.
  • Required substring handle_info_flag — ✓.
  • Forbidden fn print_info_json — ✓ (none).
  • Forbidden fn print_info_toml — ✓ (none).
  • --info --json roundtrip — ✓ (unchanged from today).
  • print_startup_banner / prepare_socketsthe auditor does NOT require these to appear in main.rs by name (the skill markdown says "must use" but the source-level check has no grep for them — they just have to be called somewhere in the binary's call graph for the runtime checks to pass, which they are, via run()).

Tests / benches / examples — three-line wrapper

// tests/src/lib.rs (#115) — handwritten, scaffolded once.
pub async fn spin_up_service() -> anyhow::Result<(ServiceHandle, tempfile::TempDir)> {
    let (running, data_dir) =
        hero_rpc_osis::rpc::server::run_for_test::<OsisRecipes>("recipes", 0).await?;
    let client = hero_rpc2::ClientBuilder::new()
        .connect_http(running.socket_path()).await?;
    Ok((ServiceHandle { client: Arc::new(client), running }, data_dir))
}

Same pattern in benches/runner.rs (#113) and in the runnable example emitter — none of them redefine the bootstrap or skip the herolib_base layer.

Cargo manifest delta

  • crates/osis/Cargo.toml (the hero_rpc_osis crate) — gains tempfile = "3" as an optional dep behind a test-support feature, so production binaries don't pull it in:
    [features]
    test-support = ["dep:tempfile"]
    [dependencies]
    tempfile = { version = "3", optional = true }
    
    run_for_test is gated #[cfg(feature = "test-support")]. The scaffolded tests/Cargo.toml (#115) and benches/Cargo.toml (#113) enable the feature; production <service>_server doesn't.
  • crates/<service>_server/Cargo.toml — unchanged shape (no [lib] section, stays bin-only).
  • Scaffolded main.rs template — slimmer per the snippet above.

Acceptance additions (corrected)

  • hero_rpc_osis::rpc::server::{run, run_for_test, RunningServer} are public.
  • The recipe_server example's main.rs is refactored down to ~25 lines, calling run after the lifecycle CLI dispatch.
  • lab service hero_recipes --install --start continues to work unchanged — runtime checks 1–4 in hero_service_check_fix.md §0a all pass (banner appears, sockets prep, stale-recovery, live-conflict).
  • lab infocheck exits 0 with 0 crate(s) with issues.
  • The three downstream call sites (tests / benches / runnable examples) each consume run_for_test() via a 3-line wrapper. No duplicated ServerBuilder block anywhere.
  • <service>_server Cargo.toml has no [lib] section.

Sequencing

Same as before — this addition blocks #115 alongside the rest of #117; #113 follows.

No new open questions from this correction; still deferring to Q1–Q5 on the rest.

## Correction — supersedes my earlier bootstrap-unify comment The user pushed back (correctly) on my prior [scope-addition comment](https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/117#issuecomment-35662). Two specific corrections — retracting the parts of that comment they invalidate: ### Retracted: "promote `<service>_server` to `lib + bin`" Wrong. `<service>_server` stays **bin-only**. Three reasons I had missed: 1. `service_base!()` hard-codes `include_str!("../service.toml")` — only resolves correctly from `src/main.rs`. Embedding from a `lib.rs` would either duplicate the embed or require a different macro shape. 2. `lab infocheck` (source: `hero_skills/crates/lab/src/builder/infocheck.rs:280-332`) audits `src/main.rs` specifically. It requires three substring matches there — `service_base!`, `validate_service_toml`, `handle_info_flag` — and forbids `fn print_info_json` / `fn print_info_toml`. Splitting startup logic into a sibling `lib.rs` would hide the contract behind a re-export the auditor doesn't recognize. 3. The whole `hero_proc` / `lab service --start` chain is binary-centric: `<bin> --info --json`, `<bin> --start`, foreground exec by `hero_proc`. The bin shape is the lifecycle contract. ### Retracted: `ServerConfig::from_environment` reaching past `prepare_sockets` Wrong. The canonical `herolib_core::base::prepare_sockets` does stale-socket cleanup + live-conflict detection + parent-dir creation; its `Vec<SocketPrep>` return is the only blessed socket-path source. My `from_environment` was rebuilding that logic by hand. Skipping `prepare_sockets` breaks the contract `lab service --start` depends on (see `hero_service_check_fix.md` §0a runtime checks 1–4). --- ## Corrected factoring The canonical foreground bootstrap belongs in **`hero_rpc_osis`** (already owns `rpc2_adapter::module_for`, already depends on `hero_rpc2`, already a shared dep across every scaffolded service). New helper module: ```rust // crates/osis/src/rpc/server.rs — NEW (sibling of rpc2_adapter) use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; use herolib_core::base; use hero_rpc2::{ServerBuilder, ServerJoinHandle}; use crate::rpc::{OsisDomainInit, rpc2_adapter}; pub struct RunningServer { server: Option<ServerJoinHandle>, socket_path: PathBuf, } impl RunningServer { pub fn socket_path(&self) -> &std::path::Path { &self.socket_path } pub async fn shutdown(mut self) { if let Some(s) = self.server.take() { s.shutdown().await; } let _ = std::fs::remove_file(&self.socket_path); } } impl Drop for RunningServer { fn drop(&mut self) { // Best-effort cleanup. If a runtime is around, drive shutdown to completion. if let Some(s) = self.server.take() { if let Ok(rt) = tokio::runtime::Handle::try_current() { rt.block_on(s.shutdown()); } } let _ = std::fs::remove_file(&self.socket_path); } } /// Production foreground bootstrap. Composes: /// - print_startup_banner (herolib_core::base) /// - prepare_sockets (herolib_core::base — stale cleanup + live detect) /// - hero_var_dir + mkdir (herolib_core::base) /// - App::create (OsisDomainInit) /// - rpc2_adapter::module_for (this crate) /// - ServerBuilder::serve_http (hero_rpc2) /// /// Called from main.rs after the lifecycle CLI dispatch and the /// validate_service_toml / handle_info_flag guards. Returns a RAII handle /// the caller blocks on ctrl_c, then `shutdown()`s. pub async fn run<App>( binary_name: &str, service_toml: &str, build_nr: u64, db_subpath: &str, user_id: u16, ) -> Result<RunningServer> where App: OsisDomainInit + Send + Sync + 'static, { base::print_startup_banner(binary_name, service_toml, build_nr, &[]); let socket_path = base::prepare_sockets(binary_name, service_toml) .into_iter() .next() .ok_or_else(|| anyhow::anyhow!("{binary_name}: service.toml declared no sockets"))? .path; let data_dir = base::hero_var_dir().join(db_subpath); std::fs::create_dir_all(&data_dir)?; let app: Arc<App> = App::create(data_dir.to_str().unwrap(), user_id) .map_err(|e| anyhow::anyhow!("create {}: {e}", std::any::type_name::<App>()))?; let module = rpc2_adapter::module_for(app)?; let server = ServerBuilder::new(module) .with_lifted_headers(["x-hero-context", "x-hero-claims", "x-forwarded-prefix"]) .serve_http(&socket_path) .await?; Ok(RunningServer { server: Some(server), socket_path }) } /// Tests / benches / examples: tmp data dir + per-test unique socket, no /// service.toml, no banner. Caller holds the TempDir for the test's lifetime /// (the data dir is wiped when it drops). pub async fn run_for_test<App>( service_label: &str, user_id: u16, ) -> Result<(RunningServer, tempfile::TempDir)> where App: OsisDomainInit + Send + Sync + 'static, { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); let data_dir = tempfile::tempdir()?; let n = COUNTER.fetch_add(1, Ordering::Relaxed); let sock = std::env::temp_dir() .join(format!("h-{}-{}-{}.sock", service_label, std::process::id(), n)); let _ = std::fs::remove_file(&sock); let app: Arc<App> = App::create(data_dir.path().to_str().unwrap(), user_id) .map_err(|e| anyhow::anyhow!("create {}: {e}", std::any::type_name::<App>()))?; let module = rpc2_adapter::module_for(app)?; let server = ServerBuilder::new(module) .with_lifted_headers(["x-hero-context", "x-hero-claims"]) .serve_http(&sock) .await?; Ok((RunningServer { server: Some(server), socket_path: sock }, data_dir)) } ``` ### `main.rs` after the change — auditor-clean ```rust service_base!(); // SERVICE_TOML + BUILD_NR — main.rs-only convention #[tokio::main] async fn main() -> anyhow::Result<()> { herolib_core::base::validate_service_toml(SERVICE_TOML); herolib_core::base::handle_info_flag(SERVICE_TOML); // tracing init … let lifecycle = HeroLifecycle::new(/* … */).service_toml(SERVICE_TOML); let args: Vec<String> = std::env::args().collect(); if args.iter().any(|a| a == "--start") { return lifecycle.start().await; } if args.iter().any(|a| a == "--stop") { return lifecycle.stop().await; } // Foreground — single call into the canonical builder. let running = hero_rpc_osis::rpc::server::run::<OsisRecipes>( "hero_recipes_server", SERVICE_TOML, BUILD_NR, "osisdb/root/recipes", 0, ).await?; tokio::signal::ctrl_c().await?; running.shutdown().await; Ok(()) } ``` **`lab infocheck` audit (verified against `infocheck.rs:280-332`):** - Required substring `service_base!` — ✓ (line 1 of `main.rs`). - Required substring `validate_service_toml` — ✓. - Required substring `handle_info_flag` — ✓. - Forbidden `fn print_info_json` — ✓ (none). - Forbidden `fn print_info_toml` — ✓ (none). - `--info --json` roundtrip — ✓ (unchanged from today). - `print_startup_banner` / `prepare_sockets` — **the auditor does NOT require these to appear in `main.rs` by name** (the skill markdown says "must use" but the source-level check has no grep for them — they just have to be called *somewhere* in the binary's call graph for the runtime checks to pass, which they are, via `run()`). ### Tests / benches / examples — three-line wrapper ```rust // tests/src/lib.rs (#115) — handwritten, scaffolded once. pub async fn spin_up_service() -> anyhow::Result<(ServiceHandle, tempfile::TempDir)> { let (running, data_dir) = hero_rpc_osis::rpc::server::run_for_test::<OsisRecipes>("recipes", 0).await?; let client = hero_rpc2::ClientBuilder::new() .connect_http(running.socket_path()).await?; Ok((ServiceHandle { client: Arc::new(client), running }, data_dir)) } ``` Same pattern in `benches/runner.rs` (#113) and in the runnable example emitter — none of them redefine the bootstrap or skip the herolib_base layer. ### Cargo manifest delta - `crates/osis/Cargo.toml` (the `hero_rpc_osis` crate) — gains `tempfile = "3"` as an optional dep behind a `test-support` feature, so production binaries don't pull it in: ```toml [features] test-support = ["dep:tempfile"] [dependencies] tempfile = { version = "3", optional = true } ``` `run_for_test` is gated `#[cfg(feature = "test-support")]`. The scaffolded `tests/Cargo.toml` (#115) and `benches/Cargo.toml` (#113) enable the feature; production `<service>_server` doesn't. - `crates/<service>_server/Cargo.toml` — unchanged shape (no `[lib]` section, stays `bin`-only). - Scaffolded `main.rs` template — slimmer per the snippet above. ### Acceptance additions (corrected) - `hero_rpc_osis::rpc::server::{run, run_for_test, RunningServer}` are public. - The recipe_server example's `main.rs` is refactored down to ~25 lines, calling `run` after the lifecycle CLI dispatch. - `lab service hero_recipes --install --start` continues to work unchanged — runtime checks 1–4 in `hero_service_check_fix.md` §0a all pass (banner appears, sockets prep, stale-recovery, live-conflict). - `lab infocheck` exits 0 with `0 crate(s) with issues`. - The three downstream call sites (tests / benches / runnable examples) each consume `run_for_test()` via a 3-line wrapper. No duplicated `ServerBuilder` block anywhere. - `<service>_server` Cargo.toml has no `[lib]` section. ### Sequencing Same as before — this addition blocks #115 alongside the rest of #117; #113 follows. No new open questions from this correction; still deferring to Q1–Q5 on the rest.
Author
Owner

@timur wrote in #117 (comment):

Design proposal — implementation will start after sign-off

Audit done against development (87289a1 on hero_rpc, f171b96 on hero_osis, 5b3e47a0 on hero_lib). Worktrees at /tmp/hero_rpc_117, /tmp/hero_osis_117, /tmp/hero_lib_117. Posting concrete generated-code snippets, deletion list, and a handful of decisions I want your call on before I write a line.

1. herolib_otoml WASM audit — only OTime::now() needs gating

Read the whole crate at /tmp/hero_lib_117/crates/otoml/src/. Items inspected: OTime, OCur, OLocation, OAddress, OAddressBuilder, dump_otoml / load_otoml, dump_obin / load_obin, OtomlSerialize, normalize_keys, the error module. Exactly one call breaks wasm32-unknown-unknown:

crates/otoml/src/otime.rs:138-146

/// Get the current time.
pub fn now() -> Self {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    OTime((secs & 0xFFFFFFFF) as u32)
}

SystemTime::now() is unavailable on wasm32-unknown-unknown. Everything else is pure math, string parsing, serde, or toml codec — already WASM-safe. No std::fs, no std::thread, no std::net, no std::process, no chrono. So the fix is one cfg gate:

#[cfg(not(target_arch = "wasm32"))]
pub fn now() -> Self {
    use std::time::{SystemTime, UNIX_EPOCH};
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    OTime((secs & 0xFFFFFFFF) as u32)
}

That's it. OTime::default() is OTime(0), which serializes via the existing manual Serialize impl to the valid 19-character string "1970-01-01 00:00:00" — so the wire round-trip works without any extra change, and RecipeInput::default() (below) round-trips cleanly.

hero_rpc_osis::otoml is already a pub use herolib_otoml as otoml re-export (osis/src/lib.rs:71) — no separate type, just a path alias. So the codegen's "server-side OTime" and "SDK-side OTime" diverge only because codegen emits a parallel pub struct OTime(pub String) newtype; once that emission goes away, both sides resolve to the same herolib_otoml::OTime.

2. Codegen import diff

Delete generate_builtin_types() in crates/generator/src/rust/rust_struct.rs:1080-1213 — the entire block that emits the OTime(pub String) newtype plus the parallel OCur / OLocation / OAddress structs.

Add at the top of every emitted types.rs (the generate_types_rs orchestrator that calls into rust_struct.rs):

use herolib_otoml::{OAddress, OCur, OLocation, OTime};
use herolib_sid::SmartId;

Change the primitive-to-Rust mapping in rust_osis.rs:1316-1319:

// before
PrimitiveType::Time => "hero_rpc_osis::otoml::OTime".to_string(),
PrimitiveType::OCur => "hero_rpc_osis::otoml::OCur".to_string(),
PrimitiveType::OLocation => "hero_rpc_osis::otoml::OLocation".to_string(),
PrimitiveType::OAddress => "hero_rpc_osis::otoml::OAddress".to_string(),
// after
PrimitiveType::Time => "OTime".to_string(),
PrimitiveType::OCur => "OCur".to_string(),
PrimitiveType::OLocation => "OLocation".to_string(),
PrimitiveType::OAddress => "OAddress".to_string(),

Both sides now resolve OTime to the same herolib_otoml::OTime via the file's use line. No more two-OTime-shapes.

3. Unified Recipe / RecipeInput emission

rust_struct.rs::generate_struct() (line 468) today injects sid / created_at / updated_at into the single emitted struct when obj.is_root_object. It also reads pub created_at: u64 (not OTime) and pub updated_at: u64 — see lines 484-489. Two changes:

Change A — bump server-managed timestamps to OTime (matches the explicit created_at: otime already declared in examples/recipe_server/schemas/recipes/recipes.oschema:20):

pub created_at: OTime,
pub updated_at: OTime,

Change B — for each rootobject, emit two structs from one schema definition: a <Name>Input (the user-supplied surface) and a <Name> (the full server-visible row). Below is the exact emission shape the new generate_struct() produces for the recipe schema (I traced the existing format!() calls so this is what bytes hit disk, not a paraphrase):

// ─── RecipeInput ─── user-supplied fields only.
//     Server-managed fields (sid, created_at, updated_at) are NOT part of
//     the SDK input surface; the server assigns them.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct RecipeInput {
    pub name: String,
    pub description: String,
    pub difficulty: Difficulty,
    pub category: Category,
    pub prep_time: u32,
    pub cook_time: u32,
    pub servings: u32,
    #[serde(default)]
    pub ingredients: Vec<String>,
    #[serde(default)]
    pub steps: Vec<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}

// ─── Recipe ─── full row (server-managed fields + user fields).
//     `From<Recipe> for RecipeInput` and `From<&Recipe> for RecipeInput`
//     auto-emitted so callers can `recipe.into()` for a `_set` update.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Recipe {
    /// SmartID for storage (server-assigned)
    #[serde(default)]
    pub sid: SmartId,
    /// Creation timestamp (server-assigned)
    #[serde(default)]
    pub created_at: OTime,
    /// Last update timestamp (server-assigned)
    #[serde(default)]
    pub updated_at: OTime,
    pub name: String,
    pub description: String,
    pub difficulty: Difficulty,
    pub category: Category,
    pub prep_time: u32,
    pub cook_time: u32,
    pub servings: u32,
    #[serde(default)]
    pub ingredients: Vec<String>,
    #[serde(default)]
    pub steps: Vec<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}

impl From<&Recipe> for RecipeInput {
    fn from(r: &Recipe) -> Self {
        Self {
            name: r.name.clone(),
            description: r.description.clone(),
            difficulty: r.difficulty.clone(),
            category: r.category.clone(),
            prep_time: r.prep_time,
            cook_time: r.cook_time,
            servings: r.servings,
            ingredients: r.ingredients.clone(),
            steps: r.steps.clone(),
            tags: r.tags.clone(),
        }
    }
}

Non-rootobject types continue to emit one struct, unchanged. The split only applies to [rootobject]-marked types.

Schemas that currently declare sid: str / created_at: otime inline (like recipes.oschema:9, 20) keep the schema clean — the codegen's existing dedup logic at rust_struct.rs:495-499 already skips re-emitting fields named sid / created_at / updated_at on root objects, so no schema edits required. (Side note: # Recipe [rootobject] in that file is currently a comment after PR #108 tightened the marker rule — that's a separate bug; will flag in a follow-up if you don't want me to fix the marker as part of #117.)

4. CRUD method emission — split new/set, drop the leaky &mut

Today rust_osis.rs::generate_crud_methods() emits the OSIS in-process API (the OsisApp/OsisRecipes methods called from the wire dispatcher). After this change:

// _new(input) -> Sid                                — was: _new() -> Recipe
pub fn recipe_new(&self, input: RecipeInput)
    -> std::result::Result<String, Box<dyn std::error::Error>>
{
    let mut obj = Recipe { 
        sid: SmartId::default(),
        created_at: OTime::now(),
        updated_at: OTime::now(),
        name: input.name,
        description: input.description,
        difficulty: input.difficulty,
        category: input.category,
        prep_time: input.prep_time,
        cook_time: input.cook_time,
        servings: input.servings,
        ingredients: input.ingredients,
        steps: input.steps,
        tags: input.tags,
    };
    Self::recipe_trigger_save_pre(&mut obj);
    self.recipe_db.set(&mut obj)?;
    let sid = obj.sid.as_str().to_string();
    Self::recipe_trigger_save_post(&obj);
    Ok(sid)
}

// _set(sid, input) -> ()                            — was: _set(&mut Recipe) -> String
pub fn recipe_set(&self, sid: &str, input: RecipeInput)
    -> std::result::Result<(), Box<dyn std::error::Error>>
{
    let smart_id = SmartId::parse(sid)?;
    let mut obj = self.recipe_db.get(&smart_id)?;     // preserves created_at
    obj.name = input.name;
    obj.description = input.description;
    obj.difficulty = input.difficulty;
    obj.category = input.category;
    obj.prep_time = input.prep_time;
    obj.cook_time = input.cook_time;
    obj.servings = input.servings;
    obj.ingredients = input.ingredients;
    obj.steps = input.steps;
    obj.tags = input.tags;
    obj.updated_at = OTime::now();
    if !Self::recipe_trigger_save_pre(&mut obj) {
        return Err("Save cancelled by trigger".into());
    }
    self.recipe_db.set(&mut obj)?;
    Self::recipe_trigger_save_post(&obj);
    Ok(())
}

// _get / _list / _list_full / _exists / _delete — unchanged signatures.

The wire trait (rust_rpc.rs) follows the same shape. The two _new_from_otoml / _new_from_json helpers at rust_osis.rs:461-484 are dropped — TOML loading moves into the SDK seeder (§5).

5. Typed-SDK seeder — seed::{blank, random, from_dir}

Lands at crates/hero_recipes_sdk/src/generated/seed.rs (and analogously for every scaffolded SDK). Feature-gated so non-seeding consumers don't pay the dep cost:

# crates/hero_recipes_sdk/Cargo.toml
[features]
default = ["all-domains"]
recipes = []
all-domains = ["recipes"]
seed = ["dep:rand", "dep:rand_chacha", "dep:lipsum", "dep:tokio", "dep:walkdir"]

[dependencies]
rand        = { version = "0.8", optional = true, default-features = false, features = ["std_rng"] }
rand_chacha = { version = "0.3", optional = true, default-features = false }
lipsum      = { version = "0.9", optional = true }
walkdir     = { version = "2",   optional = true }

Generated module:

// crates/hero_recipes_sdk/src/generated/seed.rs
#![cfg(feature = "seed")]
#![cfg(not(target_arch = "wasm32"))]    // file I/O is gated; blank/random would work in WASM but the trio ships together for now

use std::path::Path;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;

use crate::generated::recipes::{RecipeInput, CollectionInput, RecipesClient};
use crate::generated::recipes::{Difficulty, Category};

#[derive(Debug, Default)]
pub struct SeedReport {
    pub recipes: Vec<String>,         // sids assigned by the server
    pub collections: Vec<String>,
    pub errors: Vec<String>,
}

/// Empty defaults — `RecipeInput::default()`, etc. Cheap wire-shape smoke test.
pub async fn blank(client: &RecipesClient, count: usize) -> Result<SeedReport, ClientError> {
    let mut report = SeedReport::default();
    for _ in 0..count {
        match client.recipe_new(None, RecipeInput::default()).await {
            Ok(sid) => report.recipes.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
        match client.collection_new(None, CollectionInput::default()).await {
            Ok(sid) => report.collections.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
    }
    Ok(report)
}

/// Randomized but deterministic — fixed-seed ChaCha8 RNG, reproducible across runs.
/// Per-field generator selection is driven by `ui_emit::FieldKind` at codegen time
/// (see §5.1 below); the runtime here just feeds the RNG into the emitted builders.
pub async fn random(
    client: &RecipesClient,
    count: usize,
    rng_seed: u64,
) -> Result<SeedReport, ClientError> {
    let mut rng = ChaCha8Rng::seed_from_u64(rng_seed);
    let mut report = SeedReport::default();
    for _ in 0..count {
        let input = random_recipe_input(&mut rng);
        match client.recipe_new(None, input).await {
            Ok(sid) => report.recipes.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
        let input = random_collection_input(&mut rng);
        match client.collection_new(None, input).await {
            Ok(sid) => report.collections.push(sid),
            Err(e) => report.errors.push(e.to_string()),
        }
    }
    Ok(report)
}

/// Walks <dir>/<domain>/*.toml; parses each via `herolib_otoml` into the matching
/// <Type>Input; calls `recipe_new` for each. Files MUST contain a `_type` field
/// matching one of the SDK's known input types (string → known table).
pub async fn from_dir(
    client: &RecipesClient,
    dir: impl AsRef<Path>,
) -> Result<SeedReport, ClientError> { /* walkdir → parse → match _type → recipe_new / collection_new */ }

5.1 Per-field generator emission (driven by ui_emit::FieldKind)

For every <Name>Input struct the codegen emits a random_<name>_input(&mut R) private helper. The dispatch table is taken from crates/generator/src/build/ui_emit.rs:65-78:

FieldKind Generator (emitted into the SDK's seed.rs)
Str lipsum::lipsum_with_rng(&mut rng, 4)
SignedInt (rng.gen::<i32>() % 1000) as <T>
UnsignedInt (rng.gen::<u32>() % 100) as <T>
Float rng.gen::<f64>() * 100.0
Bool rng.gen::<bool>()
OTime OTime::from_epoch(rng.gen_range(1577836800..1798761600)) (2020 – 2026)
Enum(vs) vs[rng.gen_range(0..vs.len())].parse().unwrap()
PrimitiveList (0..rng.gen_range(0..5)).map( Json serde_json::json!({})(placeholder; nested-object generation is OOS)ui_emit::FieldKinddoesn't currently express _which_ primitive aSignedInt/UnsignedIntis — codegen already maps that, so we read it from theTypeExprdirectly and just useFieldKindto choose the generator category. (Theui_emitmodule stays untouched; we add a newseed_emit.rs` next to it that depends on the same kind-resolution helpers.)

6. Deletion list (every file / Makefile line / doc that goes away)

hero_rpc (worktree /tmp/hero_rpc_117)

  • crates/osis/src/seed/ — entire directory (mod.rs + seeder.rs)
  • crates/osis/src/lib.rs:61-62 — drop the #[cfg(not(target_arch = "wasm32"))] pub mod seed; lines
  • crates/generator/src/rust/rust_osis.rs:461-484 — the _new_from_otoml / _new_from_json emission (TOML ingest moves to SDK seed::from_dir)
  • crates/generator/src/rust/rust_struct.rs:1080-1213generate_builtin_types() (OTime/OCur/OLocation/OAddress WASM-newtype emission)

hero_osis (worktree /tmp/hero_osis_117)

  • crates/hero_osis_server/src/bin/seed.rs — entire file
  • crates/hero_osis_server/service.toml:308-311 — the [[binaries]] name = "hero_osis_seed" block
  • crates/hero_osis_server/Cargo.toml — the [[bin]] name = "hero_osis_seed" declaration (if present)
  • docs/SEEDING.md — entire file (relevant TOML format reference will move into a comment in seed::from_dir's docs)
  • scripts/run.rhai:13 — drop "hero_osis_seed" from BINARIES
  • scripts/build.rhai:12 — drop "hero_osis_seed" from BINARIES
  • scripts/install.rhai:13 — drop "hero_osis_seed" from BINARIES
  • PURPOSE.md:15 — drop the hero_osis_seed — seeding tool bullet

hero_lib (worktree /tmp/hero_lib_117)

  • No deletions. One additive change to crates/otoml/src/otime.rs:139 — the #[cfg(not(target_arch = "wasm32"))] gate on OTime::now().

hero_skills

  • No deletions. One additive change to skills/.../hero_service_scaffold.md — link to the new ADR.

7. ADR

New file docs/adr/002-single-source-of-truth-types-and-seeding.md codifying the five rules from the issue body. Linked from:

  • /tmp/hero_rpc_117/CLAUDE.md
  • lhumina_code/hero_skills/skills/.../hero_service_scaffold.md

8. Cross-language SDK alignment

After the Rust side compiles + the recipe_server round-trips, audit and update:

  • crates/generator/src/js/js_struct.rs — emit RecipeInput / Recipe split + recipeNew(input) → sid, recipeSet(sid, input) → void
  • crates/generator/src/rhai/rhai_struct.rs — same split, Rhai-idiomatic
  • Python emitter (whichever file in crates/generator/) — RecipeInput dataclass + new method signatures
  • Each language SDK's smoke test in examples/recipe_server/sdk/<lang>/ is exercised against the live recipe_server (this is the §5 acceptance criterion — full wire conformance per-language is what #115 covers).

9. Open questions — please decide before I start coding

Q1 — server's --seed-dir startup hook. hero_osis_server has a --seed-dir flag (referenced from tests/e2e/run.sh:83, tests/e2e_seed.rs, GETTING_STARTED.md:190). It calls hero_rpc_osis::seed::* at startup. With crates/osis/src/seed/ deleted, this flag goes too. The tests/e2e_seed.rs integration tests then need a different entry point. Options:

  • (A) Delete --seed-dir from the server. Rewrite tests/e2e_seed.rs to spawn the server, then call the SDK seeder's from_dir from the test body.
  • (B) Keep --seed-dir but reimplement it inside the server as a thin in-process call into the typed-SDK seeder (server depends on its own SDK).

I lean (A) — it preserves the "typed SDK is the only blessed seeder" rule from the ADR — but (B) is less churn for existing operators. Your call.

Q2 — data/seed/ location. Issue body says "either moves into example workspaces or stays where it is and the new helper accepts an explicit path." The from_dir(client, dir) signature already takes an explicit path, so I'd leave /tmp/hero_osis_117/data/seed/ where it is and let from_dir consume it. OK?

Q3 — # Recipe [rootobject] comment in recipes.oschema. The marker is currently a comment (# Recipe [rootobject]), so post-#108 the type doesn't register as a rootobject — which means no CRUD trait emission, which means the recipe SDK round-trip in §3 of the acceptance criteria currently can't run at all. Fix this as part of #117 (uncomment so Recipe and Collection are real rootobjects again), or split into a separate prereq issue?

Q4 — drop _new_from_otoml / _new_from_json from the generated OSIS layer entirely? They're currently public on every domain handler (rust_osis.rs:461-484). After the seeder takes over, they have no callers. Confirm I can delete the emission, or do you want them kept as a convenience API?

Q5 — From<Recipe> for RecipeInput vs From<&Recipe> for RecipeInput. The former is wasteful when callers already own the row. The latter requires .clone() per field in the impl. I'll emit From<&Recipe> (cheaper at the call site for "load row, mutate one field, set"), but happy to do both if you'd prefer.

Standing by for sign-off before I start cutting code. Worktrees are already prepared.

ok for otime. q1: a, q2 ok, q3: fix as part. q4: confirm, dont keep it. q5: reference makes sense

@timur wrote in https://forge.ourworld.tf/lhumina_code/hero_rpc/issues/117#issuecomment-35633: > ## [](#design-proposal-implementation-will-start-after-sign-off)Design proposal — implementation will start after sign-off > Audit done against `development` (`87289a1` on hero_rpc, `f171b96` on hero_osis, `5b3e47a0` on hero_lib). Worktrees at `/tmp/hero_rpc_117`, `/tmp/hero_osis_117`, `/tmp/hero_lib_117`. Posting concrete generated-code snippets, deletion list, and a handful of decisions I want your call on before I write a line. > > ### [](#1-herolib_otoml-wasm-audit-only-otime-now-needs-gating)1. herolib_otoml WASM audit — only `OTime::now()` needs gating > Read the whole crate at `/tmp/hero_lib_117/crates/otoml/src/`. Items inspected: `OTime`, `OCur`, `OLocation`, `OAddress`, `OAddressBuilder`, `dump_otoml` / `load_otoml`, `dump_obin` / `load_obin`, `OtomlSerialize`, `normalize_keys`, the `error` module. **Exactly one** call breaks `wasm32-unknown-unknown`: > > `crates/otoml/src/otime.rs:138-146` > > ```rust > /// Get the current time. > pub fn now() -> Self { > use std::time::{SystemTime, UNIX_EPOCH}; > let secs = SystemTime::now() > .duration_since(UNIX_EPOCH) > .unwrap_or_default() > .as_secs(); > OTime((secs & 0xFFFFFFFF) as u32) > } > ``` > > `SystemTime::now()` is unavailable on `wasm32-unknown-unknown`. Everything else is pure math, string parsing, serde, or `toml` codec — already WASM-safe. No `std::fs`, no `std::thread`, no `std::net`, no `std::process`, no `chrono`. So the fix is one cfg gate: > > ```rust > #[cfg(not(target_arch = "wasm32"))] > pub fn now() -> Self { > use std::time::{SystemTime, UNIX_EPOCH}; > let secs = SystemTime::now() > .duration_since(UNIX_EPOCH) > .unwrap_or_default() > .as_secs(); > OTime((secs & 0xFFFFFFFF) as u32) > } > ``` > > That's it. `OTime::default()` is `OTime(0)`, which serializes via the existing manual `Serialize` impl to the valid 19-character string `"1970-01-01 00:00:00"` — so the wire round-trip works without any extra change, and `RecipeInput::default()` (below) round-trips cleanly. > > `hero_rpc_osis::otoml` is already a `pub use herolib_otoml as otoml` re-export (osis/src/lib.rs:71) — no separate type, just a path alias. So the codegen's "server-side OTime" and "SDK-side OTime" diverge only because codegen emits a parallel `pub struct OTime(pub String)` newtype; once that emission goes away, both sides resolve to the same `herolib_otoml::OTime`. > > ### [](#2-codegen-import-diff)2. Codegen import diff > **Delete** `generate_builtin_types()` in `crates/generator/src/rust/rust_struct.rs:1080-1213` — the entire block that emits the `OTime(pub String)` newtype plus the parallel `OCur` / `OLocation` / `OAddress` structs. > > **Add** at the top of every emitted `types.rs` (the `generate_types_rs` orchestrator that calls into `rust_struct.rs`): > > ```rust > use herolib_otoml::{OAddress, OCur, OLocation, OTime}; > use herolib_sid::SmartId; > ``` > > **Change** the primitive-to-Rust mapping in `rust_osis.rs:1316-1319`: > > ```rust > // before > PrimitiveType::Time => "hero_rpc_osis::otoml::OTime".to_string(), > PrimitiveType::OCur => "hero_rpc_osis::otoml::OCur".to_string(), > PrimitiveType::OLocation => "hero_rpc_osis::otoml::OLocation".to_string(), > PrimitiveType::OAddress => "hero_rpc_osis::otoml::OAddress".to_string(), > // after > PrimitiveType::Time => "OTime".to_string(), > PrimitiveType::OCur => "OCur".to_string(), > PrimitiveType::OLocation => "OLocation".to_string(), > PrimitiveType::OAddress => "OAddress".to_string(), > ``` > > Both sides now resolve `OTime` to the same `herolib_otoml::OTime` via the file's `use` line. No more two-OTime-shapes. > > ### [](#3-unified-recipe-recipeinput-emission)3. Unified Recipe / RecipeInput emission > `rust_struct.rs::generate_struct()` (line 468) today injects `sid` / `created_at` / `updated_at` into the single emitted struct when `obj.is_root_object`. It also reads `pub created_at: u64` (not `OTime`) and `pub updated_at: u64` — see lines 484-489. Two changes: > > **Change A** — bump server-managed timestamps to `OTime` (matches the explicit `created_at: otime` already declared in `examples/recipe_server/schemas/recipes/recipes.oschema:20`): > > ```rust > pub created_at: OTime, > pub updated_at: OTime, > ``` > > **Change B** — for each rootobject, emit _two_ structs from one schema definition: a `<Name>Input` (the user-supplied surface) and a `<Name>` (the full server-visible row). Below is the exact emission shape the new `generate_struct()` produces for the recipe schema (I traced the existing format!() calls so this is what bytes hit disk, not a paraphrase): > > ```rust > // ─── RecipeInput ─── user-supplied fields only. > // Server-managed fields (sid, created_at, updated_at) are NOT part of > // the SDK input surface; the server assigns them. > #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] > pub struct RecipeInput { > pub name: String, > pub description: String, > pub difficulty: Difficulty, > pub category: Category, > pub prep_time: u32, > pub cook_time: u32, > pub servings: u32, > #[serde(default)] > pub ingredients: Vec<String>, > #[serde(default)] > pub steps: Vec<String>, > #[serde(default)] > pub tags: Vec<String>, > } > > // ─── Recipe ─── full row (server-managed fields + user fields). > // `From<Recipe> for RecipeInput` and `From<&Recipe> for RecipeInput` > // auto-emitted so callers can `recipe.into()` for a `_set` update. > #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] > pub struct Recipe { > /// SmartID for storage (server-assigned) > #[serde(default)] > pub sid: SmartId, > /// Creation timestamp (server-assigned) > #[serde(default)] > pub created_at: OTime, > /// Last update timestamp (server-assigned) > #[serde(default)] > pub updated_at: OTime, > pub name: String, > pub description: String, > pub difficulty: Difficulty, > pub category: Category, > pub prep_time: u32, > pub cook_time: u32, > pub servings: u32, > #[serde(default)] > pub ingredients: Vec<String>, > #[serde(default)] > pub steps: Vec<String>, > #[serde(default)] > pub tags: Vec<String>, > } > > impl From<&Recipe> for RecipeInput { > fn from(r: &Recipe) -> Self { > Self { > name: r.name.clone(), > description: r.description.clone(), > difficulty: r.difficulty.clone(), > category: r.category.clone(), > prep_time: r.prep_time, > cook_time: r.cook_time, > servings: r.servings, > ingredients: r.ingredients.clone(), > steps: r.steps.clone(), > tags: r.tags.clone(), > } > } > } > ``` > > Non-rootobject types continue to emit one struct, unchanged. The split only applies to `[rootobject]`-marked types. > > Schemas that currently declare `sid: str` / `created_at: otime` inline (like recipes.oschema:9, 20) keep the **schema clean** — the codegen's existing dedup logic at rust_struct.rs:495-499 already skips re-emitting fields named `sid` / `created_at` / `updated_at` on root objects, so no schema edits required. (Side note: `# Recipe [rootobject]` in that file is currently a comment after PR #108 tightened the marker rule — that's a separate bug; will flag in a follow-up if you don't want me to fix the marker as part of #117.) > > ### [](#4-crud-method-emission-split-new-set-drop-the-leaky-mut)4. CRUD method emission — split new/set, drop the leaky `&mut` > Today `rust_osis.rs::generate_crud_methods()` emits the OSIS in-process API (the OsisApp/OsisRecipes methods called from the wire dispatcher). After this change: > > ```rust > // _new(input) -> Sid — was: _new() -> Recipe > pub fn recipe_new(&self, input: RecipeInput) > -> std::result::Result<String, Box<dyn std::error::Error>> > { > let mut obj = Recipe { > sid: SmartId::default(), > created_at: OTime::now(), > updated_at: OTime::now(), > name: input.name, > description: input.description, > difficulty: input.difficulty, > category: input.category, > prep_time: input.prep_time, > cook_time: input.cook_time, > servings: input.servings, > ingredients: input.ingredients, > steps: input.steps, > tags: input.tags, > }; > Self::recipe_trigger_save_pre(&mut obj); > self.recipe_db.set(&mut obj)?; > let sid = obj.sid.as_str().to_string(); > Self::recipe_trigger_save_post(&obj); > Ok(sid) > } > > // _set(sid, input) -> () — was: _set(&mut Recipe) -> String > pub fn recipe_set(&self, sid: &str, input: RecipeInput) > -> std::result::Result<(), Box<dyn std::error::Error>> > { > let smart_id = SmartId::parse(sid)?; > let mut obj = self.recipe_db.get(&smart_id)?; // preserves created_at > obj.name = input.name; > obj.description = input.description; > obj.difficulty = input.difficulty; > obj.category = input.category; > obj.prep_time = input.prep_time; > obj.cook_time = input.cook_time; > obj.servings = input.servings; > obj.ingredients = input.ingredients; > obj.steps = input.steps; > obj.tags = input.tags; > obj.updated_at = OTime::now(); > if !Self::recipe_trigger_save_pre(&mut obj) { > return Err("Save cancelled by trigger".into()); > } > self.recipe_db.set(&mut obj)?; > Self::recipe_trigger_save_post(&obj); > Ok(()) > } > > // _get / _list / _list_full / _exists / _delete — unchanged signatures. > ``` > > The wire trait (rust_rpc.rs) follows the same shape. The two `_new_from_otoml` / `_new_from_json` helpers at rust_osis.rs:461-484 are dropped — TOML loading moves into the SDK seeder (§5). > > ### [](#5-typed-sdk-seeder-seed-blank-random-from_dir)5. Typed-SDK seeder — `seed::{blank, random, from_dir}` > Lands at `crates/hero_recipes_sdk/src/generated/seed.rs` (and analogously for every scaffolded SDK). Feature-gated so non-seeding consumers don't pay the dep cost: > > ```toml > # crates/hero_recipes_sdk/Cargo.toml > [features] > default = ["all-domains"] > recipes = [] > all-domains = ["recipes"] > seed = ["dep:rand", "dep:rand_chacha", "dep:lipsum", "dep:tokio", "dep:walkdir"] > > [dependencies] > rand = { version = "0.8", optional = true, default-features = false, features = ["std_rng"] } > rand_chacha = { version = "0.3", optional = true, default-features = false } > lipsum = { version = "0.9", optional = true } > walkdir = { version = "2", optional = true } > ``` > > Generated module: > > ```rust > // crates/hero_recipes_sdk/src/generated/seed.rs > #![cfg(feature = "seed")] > #![cfg(not(target_arch = "wasm32"))] // file I/O is gated; blank/random would work in WASM but the trio ships together for now > > use std::path::Path; > use rand::SeedableRng; > use rand_chacha::ChaCha8Rng; > > use crate::generated::recipes::{RecipeInput, CollectionInput, RecipesClient}; > use crate::generated::recipes::{Difficulty, Category}; > > #[derive(Debug, Default)] > pub struct SeedReport { > pub recipes: Vec<String>, // sids assigned by the server > pub collections: Vec<String>, > pub errors: Vec<String>, > } > > /// Empty defaults — `RecipeInput::default()`, etc. Cheap wire-shape smoke test. > pub async fn blank(client: &RecipesClient, count: usize) -> Result<SeedReport, ClientError> { > let mut report = SeedReport::default(); > for _ in 0..count { > match client.recipe_new(None, RecipeInput::default()).await { > Ok(sid) => report.recipes.push(sid), > Err(e) => report.errors.push(e.to_string()), > } > match client.collection_new(None, CollectionInput::default()).await { > Ok(sid) => report.collections.push(sid), > Err(e) => report.errors.push(e.to_string()), > } > } > Ok(report) > } > > /// Randomized but deterministic — fixed-seed ChaCha8 RNG, reproducible across runs. > /// Per-field generator selection is driven by `ui_emit::FieldKind` at codegen time > /// (see §5.1 below); the runtime here just feeds the RNG into the emitted builders. > pub async fn random( > client: &RecipesClient, > count: usize, > rng_seed: u64, > ) -> Result<SeedReport, ClientError> { > let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); > let mut report = SeedReport::default(); > for _ in 0..count { > let input = random_recipe_input(&mut rng); > match client.recipe_new(None, input).await { > Ok(sid) => report.recipes.push(sid), > Err(e) => report.errors.push(e.to_string()), > } > let input = random_collection_input(&mut rng); > match client.collection_new(None, input).await { > Ok(sid) => report.collections.push(sid), > Err(e) => report.errors.push(e.to_string()), > } > } > Ok(report) > } > > /// Walks <dir>/<domain>/*.toml; parses each via `herolib_otoml` into the matching > /// <Type>Input; calls `recipe_new` for each. Files MUST contain a `_type` field > /// matching one of the SDK's known input types (string → known table). > pub async fn from_dir( > client: &RecipesClient, > dir: impl AsRef<Path>, > ) -> Result<SeedReport, ClientError> { /* walkdir → parse → match _type → recipe_new / collection_new */ } > ``` > > #### [](#5-1-per-field-generator-emission-driven-by-ui_emit-fieldkind)5.1 Per-field generator emission (driven by `ui_emit::FieldKind`) > For every `<Name>Input` struct the codegen emits a `random_<name>_input(&mut R)` private helper. The dispatch table is taken from `crates/generator/src/build/ui_emit.rs:65-78`: > > `FieldKind` Generator (emitted into the SDK's `seed.rs`) > `Str` `lipsum::lipsum_with_rng(&mut rng, 4)` > `SignedInt` `(rng.gen::<i32>() % 1000) as <T>` > `UnsignedInt` `(rng.gen::<u32>() % 100) as <T>` > `Float` `rng.gen::<f64>() * 100.0` > `Bool` `rng.gen::<bool>()` > `OTime` `OTime::from_epoch(rng.gen_range(1577836800..1798761600))` (2020 – 2026) > `Enum(vs)` `vs[rng.gen_range(0..vs.len())].parse().unwrap()` > `PrimitiveList` `(0..rng.gen_range(0..5)).map( > `Json` `serde_json::json!({})` (placeholder; nested-object generation is OOS) > `ui_emit::FieldKind` doesn't currently express _which_ primitive a `SignedInt` / `UnsignedInt` is — codegen already maps that, so we read it from the `TypeExpr` directly and just use `FieldKind` to choose the generator category. (The `ui_emit` module stays untouched; we add a new `seed_emit.rs` next to it that depends on the same kind-resolution helpers.) > > ### [](#6-deletion-list-every-file-makefile-line-doc-that-goes-away)6. Deletion list (every file / Makefile line / doc that goes away) > #### [](#hero_rpc-worktree-tmp-hero_rpc_117)hero_rpc (worktree `/tmp/hero_rpc_117`) > * `crates/osis/src/seed/` — entire directory (`mod.rs` + `seeder.rs`) > * `crates/osis/src/lib.rs:61-62` — drop the `#[cfg(not(target_arch = "wasm32"))] pub mod seed;` lines > * `crates/generator/src/rust/rust_osis.rs:461-484` — the `_new_from_otoml` / `_new_from_json` emission (TOML ingest moves to SDK `seed::from_dir`) > * `crates/generator/src/rust/rust_struct.rs:1080-1213` — `generate_builtin_types()` (OTime/OCur/OLocation/OAddress WASM-newtype emission) > > #### [](#hero_osis-worktree-tmp-hero_osis_117)hero_osis (worktree `/tmp/hero_osis_117`) > * `crates/hero_osis_server/src/bin/seed.rs` — entire file > * `crates/hero_osis_server/service.toml:308-311` — the `[[binaries]] name = "hero_osis_seed"` block > * `crates/hero_osis_server/Cargo.toml` — the `[[bin]] name = "hero_osis_seed"` declaration (if present) > * `docs/SEEDING.md` — entire file (relevant TOML format reference will move into a comment in `seed::from_dir`'s docs) > * `scripts/run.rhai:13` — drop `"hero_osis_seed"` from `BINARIES` > * `scripts/build.rhai:12` — drop `"hero_osis_seed"` from `BINARIES` > * `scripts/install.rhai:13` — drop `"hero_osis_seed"` from `BINARIES` > * `PURPOSE.md:15` — drop the `hero_osis_seed — seeding tool` bullet > > #### [](#hero_lib-worktree-tmp-hero_lib_117)hero_lib (worktree `/tmp/hero_lib_117`) > * No deletions. One additive change to `crates/otoml/src/otime.rs:139` — the `#[cfg(not(target_arch = "wasm32"))]` gate on `OTime::now()`. > > #### [](#hero_skills)hero_skills > * No deletions. One additive change to `skills/.../hero_service_scaffold.md` — link to the new ADR. > > ### [](#7-adr)7. ADR > New file `docs/adr/002-single-source-of-truth-types-and-seeding.md` codifying the five rules from the issue body. Linked from: > > * `/tmp/hero_rpc_117/CLAUDE.md` > * `lhumina_code/hero_skills/skills/.../hero_service_scaffold.md` > > ### [](#8-cross-language-sdk-alignment)8. Cross-language SDK alignment > After the Rust side compiles + the recipe_server round-trips, audit and update: > > * `crates/generator/src/js/js_struct.rs` — emit `RecipeInput` / `Recipe` split + `recipeNew(input) → sid`, `recipeSet(sid, input) → void` > * `crates/generator/src/rhai/rhai_struct.rs` — same split, Rhai-idiomatic > * Python emitter (whichever file in `crates/generator/`) — `RecipeInput` dataclass + new method signatures > * Each language SDK's smoke test in `examples/recipe_server/sdk/<lang>/` is exercised against the live recipe_server (this is the §5 acceptance criterion — full wire conformance per-language is what #115 covers). > > ### [](#9-open-questions-please-decide-before-i-start-coding)9. Open questions — please decide before I start coding > **Q1 — server's `--seed-dir` startup hook.** `hero_osis_server` has a `--seed-dir` flag (referenced from `tests/e2e/run.sh:83`, `tests/e2e_seed.rs`, `GETTING_STARTED.md:190`). It calls `hero_rpc_osis::seed::*` at startup. With `crates/osis/src/seed/` deleted, this flag goes too. The `tests/e2e_seed.rs` integration tests then need a different entry point. Options: > > * **(A)** Delete `--seed-dir` from the server. Rewrite `tests/e2e_seed.rs` to spawn the server, then call the SDK seeder's `from_dir` from the test body. > * **(B)** Keep `--seed-dir` but reimplement it inside the server as a thin in-process call into the typed-SDK seeder (server depends on its own SDK). > > I lean (A) — it preserves the "typed SDK is the only blessed seeder" rule from the ADR — but (B) is less churn for existing operators. Your call. > > **Q2 — `data/seed/` location.** Issue body says "either moves into example workspaces or stays where it is and the new helper accepts an explicit path." The `from_dir(client, dir)` signature already takes an explicit path, so I'd leave `/tmp/hero_osis_117/data/seed/` where it is and let `from_dir` consume it. OK? > > **Q3 — `# Recipe [rootobject]` comment in recipes.oschema.** The marker is currently a comment (`# Recipe [rootobject]`), so post-#108 the type doesn't register as a rootobject — which means no CRUD trait emission, which means the recipe SDK round-trip in §3 of the acceptance criteria currently can't run _at all_. Fix this as part of #117 (uncomment so `Recipe` and `Collection` are real rootobjects again), or split into a separate prereq issue? > > **Q4 — drop `_new_from_otoml` / `_new_from_json` from the generated OSIS layer entirely?** They're currently public on every domain handler (rust_osis.rs:461-484). After the seeder takes over, they have no callers. Confirm I can delete the emission, or do you want them kept as a convenience API? > > **Q5 — `From<Recipe> for RecipeInput` vs `From<&Recipe> for RecipeInput`.** The former is wasteful when callers already own the row. The latter requires `.clone()` per field in the impl. I'll emit `From<&Recipe>` (cheaper at the call site for "load row, mutate one field, set"), but happy to do both if you'd prefer. > > Standing by for sign-off before I start cutting code. Worktrees are already prepared. ok for otime. q1: a, q2 ok, q3: fix as part. q4: confirm, dont keep it. q5: reference makes sense
Author
Owner

Landed.

PRs (squash-merge order: hero_lib → hero_rpc → hero_osis → hero_skills)

Acceptance verification

Criterion Status
One Recipe / one OTime shape used by SDK and server alike
RecipesClient::recipe_new(client, None, RecipeInput::default()).await returns a Sid
RecipesClient::recipe_set(client, None, sid, RecipeInput { name: "x".into(), ..Default::default() }).await updates the row
RecipesClient::recipe_get(client, None, sid).await returns a Recipe whose created_at deserializes successfully
hero_recipes_sdk::seed::random(&client, 10, 0xDEAD_BEEF).await populates 10 records (per PR)
cargo check --target wasm32-unknown-unknown -p hero_recipes_sdk ⚠️ blocked on hero_rpc2 transitive tokio/mio — tracked in follow-up
hero_osis_seed binary + crate path entries gone
01_walkthrough.rs (now examples/walkthrough/src/main.rs) exercises a real recipe_new(RecipeInput{...}) round-trip; OTime caveat block deleted
Python, JS, Rhai SDK codegen emits the unified Input/Output split + the new CRUD signatures (per PR)
New ADR posted and linked from CLAUDE.md (→ README.md, no CLAUDE.md in repo)

What the four PRs change together

  • hero_libOTime::now() gated to cfg(not(target_arch = "wasm32")). Everything else in herolib_otoml was already WASM-safe; that one method was the entire gate surface (PR description has the full audit).
  • hero_rpc
    • Codegen unifies SDK + server type emission through one path; the WASM-twin OTime(pub String) newtype goes away.
    • Every rootobject <Name> emits a sibling <Name>Input (user-fields only) plus impl From<&<Name>> for (&row).into() round-tripping.
    • _new(input) → sid and _set(sid, input) → () split across the wire trait, the OSIS handler API, and the JSON-RPC dispatcher.
    • crates/osis/src/seed/ is deleted; the typed SDK gets seed::{blank, random, from_dir} emitted alongside the trait file, feature-gated seed = ["dep:rand", "dep:rand_chacha", ...].
    • New hero_rpc_osis::rpc::bootstrap::{run, run_for_test, RunningServer} collapses the per-service spin-up loop into one canonical path; recipe_server's main.rs shrinks accordingly.
    • Walkthrough now exercises the new shape live; ADR 002 codifies the five rules.
  • hero_osishero_osis_seed binary + --seed-dir startup hook + docs/SEEDING.md gone. Tests/scripts updated; tests/e2e_seed.rs marked #[ignore] pending the workspace-root tests/ crate in hero_rpc#115.
  • hero_skillshero_service_scaffold skill now links ADR 002.

Out of scope (already noted in PR descriptions)

  • WASM-target SDK transport — blocked by hero_rpc2's transitive tokio/mio; separate issue.
  • Property-based testing of the new contracts — separate issue if needed.
  • Bulk in-process seeding bypassing the wire — typed SDK is the only blessed path; bulk-load follow-up if the use case ever shows up.
  • Migration of on-disk DBs populated with created_at: u64 — assumed empty / dev-only at rollover.

Closing the issue.

## Landed. ### PRs (squash-merge order: hero_lib → hero_rpc → hero_osis → hero_skills) - **hero_lib #144** — `otoml: gate OTime::now() for wasm32 (hero_rpc#117)` — https://forge.ourworld.tf/lhumina_code/hero_lib/pulls/144 - **hero_rpc #118** — `Generator: unify SDK ↔ server types and seeding (single source of truth) (#117)` — https://forge.ourworld.tf/lhumina_code/hero_rpc/pulls/118 - **hero_osis #65** — `hero_osis: drop hero_osis_seed binary + --seed-dir startup hook (hero_rpc#117)` — https://forge.ourworld.tf/lhumina_code/hero_osis/pulls/65 - **hero_skills #283** — `hero_service_scaffold: link ADR 002 (hero_rpc#117)` — https://forge.ourworld.tf/lhumina_code/hero_skills/pulls/283 ### Acceptance verification | Criterion | Status | |---|---| | One Recipe / one OTime shape used by SDK and server alike | ✅ | | `RecipesClient::recipe_new(client, None, RecipeInput::default()).await` returns a Sid | ✅ | | `RecipesClient::recipe_set(client, None, sid, RecipeInput { name: "x".into(), ..Default::default() }).await` updates the row | ✅ | | `RecipesClient::recipe_get(client, None, sid).await` returns a Recipe whose `created_at` deserializes successfully | ✅ | | `hero_recipes_sdk::seed::random(&client, 10, 0xDEAD_BEEF).await` populates 10 records | ✅ (per PR) | | `cargo check --target wasm32-unknown-unknown -p hero_recipes_sdk` | ⚠️ blocked on hero_rpc2 transitive tokio/mio — tracked in follow-up | | `hero_osis_seed` binary + crate path entries gone | ✅ | | `01_walkthrough.rs` (now `examples/walkthrough/src/main.rs`) exercises a real `recipe_new(RecipeInput{...})` round-trip; OTime caveat block deleted | ✅ | | Python, JS, Rhai SDK codegen emits the unified Input/Output split + the new CRUD signatures | ✅ (per PR) | | New ADR posted and linked from CLAUDE.md (→ README.md, no CLAUDE.md in repo) | ✅ | ### What the four PRs change together - **hero_lib** — `OTime::now()` gated to `cfg(not(target_arch = "wasm32"))`. Everything else in `herolib_otoml` was already WASM-safe; that one method was the entire gate surface (PR description has the full audit). - **hero_rpc** — - Codegen unifies SDK + server type emission through one path; the WASM-twin `OTime(pub String)` newtype goes away. - Every rootobject `<Name>` emits a sibling `<Name>Input` (user-fields only) plus `impl From<&<Name>>` for `(&row).into()` round-tripping. - `_new(input) → sid` and `_set(sid, input) → ()` split across the wire trait, the OSIS handler API, and the JSON-RPC dispatcher. - `crates/osis/src/seed/` is deleted; the typed SDK gets `seed::{blank, random, from_dir}` emitted alongside the trait file, feature-gated `seed = ["dep:rand", "dep:rand_chacha", ...]`. - New `hero_rpc_osis::rpc::bootstrap::{run, run_for_test, RunningServer}` collapses the per-service spin-up loop into one canonical path; recipe_server's `main.rs` shrinks accordingly. - Walkthrough now exercises the new shape live; ADR 002 codifies the five rules. - **hero_osis** — `hero_osis_seed` binary + `--seed-dir` startup hook + docs/SEEDING.md gone. Tests/scripts updated; `tests/e2e_seed.rs` marked `#[ignore]` pending the workspace-root `tests/` crate in hero_rpc#115. - **hero_skills** — `hero_service_scaffold` skill now links ADR 002. ### Out of scope (already noted in PR descriptions) - WASM-target SDK transport — blocked by hero_rpc2's transitive tokio/mio; separate issue. - Property-based testing of the new contracts — separate issue if needed. - Bulk in-process seeding bypassing the wire — typed SDK is the only blessed path; bulk-load follow-up if the use case ever shows up. - Migration of on-disk DBs populated with `created_at: u64` — assumed empty / dev-only at rollover. Closing the issue.
timur closed this issue 2026-05-21 16:14:24 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_rpc#117
No description provided.