Skip to content

feat(continuum-core/runtime): L0-3a.0 — BrainRegion trait + ReadyBuffer + RegionTelemetry (substrate prerequisite)#1471

Merged
joelteply merged 1 commit into
canaryfrom
71923a08/feat-continuum-core-memory-l0-3a-hippoca
May 30, 2026
Merged

feat(continuum-core/runtime): L0-3a.0 — BrainRegion trait + ReadyBuffer + RegionTelemetry (substrate prerequisite)#1471
joelteply merged 1 commit into
canaryfrom
71923a08/feat-continuum-core-memory-l0-3a-hippoca

Conversation

@joelteply
Copy link
Copy Markdown
Contributor

Card: 71923a08-b3de-448a-98ef-fe7cc3e817c0

First sub-slice of L0-3a. Pure typed surface from BRAIN-REGIONS-SUBSTRATE.md (merged via #1470). No region implementations, no algorithms, no governor integration — those land in L0-3a.1+ slices.

Three new modules in continuum-core/src/runtime/

Module Purpose
brain_region.rs BrainRegion async trait + supporting types (RegionId, PressureProfile, TickOutcome, CadenceHint, RegionSignal, RegionContext, RegionError, plus MemoryClass/ComputeClass/PressureSignalKind/PersonaLifecycle/SleepPhase/PressureLevel enums)
ready_buffer.rs ReadyBuffer trait + DashMapReadyBuffer default impl (Arc-shared, sharded concurrent, wait-free reads, TTL eviction)
region_telemetry.rs RegionTelemetry struct + consumption_fraction() / had_buffer_misses() helpers

11 ts-rs bindings emitted to shared/generated/runtime/.

Doctrine carried in (from #1470 addendum)

No region of cognition runs on the hot path. Each region is its own RTOS task with its own tick. The handler dispatches and reads pre-staged results. The handler never blocks on recall, embedding, planning, or admission — those are continuously produced by their owning regions, in parallel, governed by SubstrateGovernor.

The trait shape enforces this:

  • BrainRegion::tick returns TickOutcome telemetry — the governor reads it to tune budget
  • ReadyBuffer::peek is synchronous + MUST NOT await — handlers can't block on it
  • Empty buffer is documented as a signal, not a block — handlers degrade gracefully

Tests (74 pass, 0 fail in runtime::)

  • 6 BrainRegion tests (trait impl, default on_signal no-op, RegionId static-construction + Display, RegionContext global vs per-persona, TickOutcome::idle)
  • 9 ReadyBuffer tests (publish+peek, missing key, overwrite, evict_stale removes-old-keeps-fresh + zero-clears-all, len/is_empty, Arc-clone-shares-storage, dyn-trait, with_capacity)
  • 5 RegionTelemetry tests (consumption_fraction full / zero-pub / partial, had_buffer_misses)
  • 11 ts-rs export_bindings_* auto-tests for all generated TS types
  • All 50 pre-existing runtime:: tests still pass

Boy-scout disclosure

cargo fmt -p continuum-core (run to ensure my new files were fmt-clean) picked up pre-existing fmt drift in ~30 unrelated files across the crate — all line-width / attribute-formatting changes on #[ts(export, export_to = ...)] etc. Semantically empty, follows the project's rustfmt config. Including the fix in this PR per boy-scout rule. If you'd rather see those broken out, I can revert them from this commit and ship a separate fmt-only PR.

What is NOT in this card

  • No region implementations — HippocampusModule, MotorCortexModule, AttentionModule, SleepPolicyModule all land in later slices
  • No algorithms — algorithms 1-7 from COGNITION-ALGORITHMS.md land in subsequent cards
  • No SubstrateGovernor integration — yield-learning loop is L0-4c
  • No derive macro / scaffold generator — those land when ≥3 regions exist to motivate the abstraction (outlier-validation strategy per CLAUDE.md)

Predecessors merged

Next slices

L0-3a.1 HippocampusModule skeleton → L0-3a.2 Engram/EngramGraph types → L0-3a.3 algorithm 4 (salience decay) → L0-3a.4 algorithm 2 (channel-as-bias) → L0-3a.5 algorithm 3 (activation spreading) → L0-3a.6 algorithm 1 (two-pool budget) → L0-3a.7 algorithm 5 (predictor + ready-buffer publish, the alive-feeling slice) → L0-3a.8 holdout fixture suite → L0-3a.9 TS Hippocampus.ts deletion.

🤖 Generated with Claude Code

…er + RegionTelemetry (substrate prerequisite)

Card: 71923a08-b3de-448a-98ef-fe7cc3e817c0

First sub-slice of L0-3a. Pure typed surface from BRAIN-REGIONS-SUBSTRATE.md
(merged via #1470). No region implementations, no algorithms, no governor
integration. Those land in L0-3a.1+ slices.

## New modules in continuum-core/src/runtime/

### brain_region.rs

The cognitive-cycle trait every region implements:

- BrainRegion (async trait, dyn-compatible)
  - id() -> RegionId
  - pressure_profile() -> PressureProfile
  - async tick(ctx: &RegionContext) -> TickOutcome
  - async on_signal(signal: RegionSignal) -> Result<(), RegionError>  // default no-op
- RegionId (Cow<'static, str> newtype, const constructor for static IDs)
- PressureProfile { memory_class, compute_class, responds_to }
- MemoryClass: Light | Moderate | Heavy | VramSensitive
- ComputeClass: Bookkeeping | Cpu | CpuVectorized | InferenceLight | InferenceHeavy
- PressureSignalKind (kind-only mirror of governor::PressureSignal for static decl)
- TickOutcome { published, consumed_since_last, pressure_observed, cadence_hint }
- TickOutcome::idle() convenience constructor
- CadenceHint: Faster | Hold | Slower | Sleep (region requests; governor decides)
- RegionSignal: PersonaLifecycle | SleepTransition | SystemPressureChanged
- PersonaLifecycle: Created | Destroyed
- SleepPhase: Active | Idle | Sleep
- PressureLevel: Nominal | Moderate | High | Critical
- RegionContext { tick_number, persona_scope }  // global vs per-persona
- RegionError (thiserror): SignalRejected | NotReady | Internal

### ready_buffer.rs

The publish/peek surface every region uses to hand off pre-staged results:

- ReadyBuffer trait
  - peek(&self, key: &Key) -> Option<Value>  // synchronous, MUST NOT block
  - publish(&self, key: Key, value: Value)   // atomic replace
  - evict_stale(&self, max_age: Duration) -> usize
  - len() / is_empty()
- DashMapReadyBuffer<K, V> default implementation
  - Arc-shared DashMap inner — cheap Clone hands out additional handles
  - Sharded concurrent access; wait-free reads in the common case
  - TimestampedEntry tracks published_at for evict_stale

Semantic rules enforced in the doc + the trait:
- Reads MUST NOT block / MUST NOT await
- Staleness acceptable — empty buffer is signal, not block
- Per-region buffers, not global

### region_telemetry.rs

The per-tick telemetry shape:

- RegionTelemetry { region_id, persona_id, tick_started_at, tick_duration,
                    published, consumed_since_last, buffer_misses_since_last,
                    pressure_observed }
- consumption_fraction() -> Option<f32>  // None when published == 0
- had_buffer_misses() -> bool

Feeds the substrate governor's yield-learning loop (algorithm 7, lands L0-4c)
and the operator surface (./jtag region/stats, region/yield).

## ts-rs bindings (11 emitted to shared/generated/runtime/)

CadenceHint, ComputeClass, MemoryClass, PersonaLifecycle, PressureLevel,
PressureProfile, PressureSignalKind, RegionId, RegionSignal,
RegionTelemetry, SleepPhase, TickOutcome.

Generated and validated by the ts-rs export_bindings_* tests.

## Tests

23 new unit tests across the three modules. All pass.

- brain_region: 6 tests (trait impl, default on_signal noop, RegionId
  construction + Display, RegionContext global vs per-persona, TickOutcome::idle)
- ready_buffer: 9 tests (publish+peek roundtrip, missing key, overwrite,
  evict_stale removes old + keeps fresh, evict ZERO clears everything,
  len/is_empty, clone shares Arc inner, dyn trait usage, with_capacity)
- region_telemetry: 5 tests (consumption_fraction with publishes / zero /
  full, had_buffer_misses true / false)

Plus ts-rs auto-generated export_bindings_* tests for all 11 types.

Total: 74 tests pass in runtime::, 0 fail.

## Boy-scout

cargo fmt applied across the package picked up some unrelated drift in
governor/types.rs (line-width formatting on ts(export...) attributes).
Including the fix.

## What is NOT in this card

- No region implementations (HippocampusModule, MotorCortexModule,
  AttentionModule all land in later slices)
- No algorithms (1-7 from COGNITION-ALGORITHMS.md land in subsequent cards)
- No SubstrateGovernor integration (yield-learning loop is L0-4c)
- No derive macro / scaffold generator (lands when ≥3 regions exist to
  motivate the abstraction — per outlier-validation in CLAUDE.md)

## Predecessors merged

- #1469 (L0-2-CUTOVER-INVESTIGATION + RTOS-brain doctrine) — 2026-05-29
- #1470 (BRAIN-REGIONS-SUBSTRATE + COGNITION-ALGORITHMS docs) — 2026-05-29

## Next slices

L0-3a.1 HippocampusModule skeleton, L0-3a.2 Engram + EngramGraph types,
L0-3a.3 Algorithm 4 (salience decay), L0-3a.4 Algorithm 2 (channel-as-bias),
L0-3a.5 Algorithm 3 (activation spreading), L0-3a.6 Algorithm 1 (two-pool
budget), L0-3a.7 Algorithm 5 (predictor + ready-buffer publish), L0-3a.8
holdout fixture suite, L0-3a.9 TS Hippocampus.ts deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@joelteply joelteply force-pushed the 71923a08/feat-continuum-core-memory-l0-3a-hippoca branch from 4de80bf to 0bad2c3 Compare May 30, 2026 03:02
@joelteply joelteply merged commit 2366354 into canary May 30, 2026
4 checks passed
@joelteply joelteply deleted the 71923a08/feat-continuum-core-memory-l0-3a-hippoca branch May 30, 2026 03:43
joelteply added a commit that referenced this pull request May 30, 2026
…rainRegion + ServiceModule, empty tick) (#1473)

* feat(continuum-core/runtime): L0-3a.0 — BrainRegion trait + ReadyBuffer + RegionTelemetry (substrate prerequisite)

Card: 71923a08-b3de-448a-98ef-fe7cc3e817c0

First sub-slice of L0-3a. Pure typed surface from BRAIN-REGIONS-SUBSTRATE.md
(merged via #1470). No region implementations, no algorithms, no governor
integration. Those land in L0-3a.1+ slices.

## New modules in continuum-core/src/runtime/

### brain_region.rs

The cognitive-cycle trait every region implements:

- BrainRegion (async trait, dyn-compatible)
  - id() -> RegionId
  - pressure_profile() -> PressureProfile
  - async tick(ctx: &RegionContext) -> TickOutcome
  - async on_signal(signal: RegionSignal) -> Result<(), RegionError>  // default no-op
- RegionId (Cow<'static, str> newtype, const constructor for static IDs)
- PressureProfile { memory_class, compute_class, responds_to }
- MemoryClass: Light | Moderate | Heavy | VramSensitive
- ComputeClass: Bookkeeping | Cpu | CpuVectorized | InferenceLight | InferenceHeavy
- PressureSignalKind (kind-only mirror of governor::PressureSignal for static decl)
- TickOutcome { published, consumed_since_last, pressure_observed, cadence_hint }
- TickOutcome::idle() convenience constructor
- CadenceHint: Faster | Hold | Slower | Sleep (region requests; governor decides)
- RegionSignal: PersonaLifecycle | SleepTransition | SystemPressureChanged
- PersonaLifecycle: Created | Destroyed
- SleepPhase: Active | Idle | Sleep
- PressureLevel: Nominal | Moderate | High | Critical
- RegionContext { tick_number, persona_scope }  // global vs per-persona
- RegionError (thiserror): SignalRejected | NotReady | Internal

### ready_buffer.rs

The publish/peek surface every region uses to hand off pre-staged results:

- ReadyBuffer trait
  - peek(&self, key: &Key) -> Option<Value>  // synchronous, MUST NOT block
  - publish(&self, key: Key, value: Value)   // atomic replace
  - evict_stale(&self, max_age: Duration) -> usize
  - len() / is_empty()
- DashMapReadyBuffer<K, V> default implementation
  - Arc-shared DashMap inner — cheap Clone hands out additional handles
  - Sharded concurrent access; wait-free reads in the common case
  - TimestampedEntry tracks published_at for evict_stale

Semantic rules enforced in the doc + the trait:
- Reads MUST NOT block / MUST NOT await
- Staleness acceptable — empty buffer is signal, not block
- Per-region buffers, not global

### region_telemetry.rs

The per-tick telemetry shape:

- RegionTelemetry { region_id, persona_id, tick_started_at, tick_duration,
                    published, consumed_since_last, buffer_misses_since_last,
                    pressure_observed }
- consumption_fraction() -> Option<f32>  // None when published == 0
- had_buffer_misses() -> bool

Feeds the substrate governor's yield-learning loop (algorithm 7, lands L0-4c)
and the operator surface (./jtag region/stats, region/yield).

## ts-rs bindings (11 emitted to shared/generated/runtime/)

CadenceHint, ComputeClass, MemoryClass, PersonaLifecycle, PressureLevel,
PressureProfile, PressureSignalKind, RegionId, RegionSignal,
RegionTelemetry, SleepPhase, TickOutcome.

Generated and validated by the ts-rs export_bindings_* tests.

## Tests

23 new unit tests across the three modules. All pass.

- brain_region: 6 tests (trait impl, default on_signal noop, RegionId
  construction + Display, RegionContext global vs per-persona, TickOutcome::idle)
- ready_buffer: 9 tests (publish+peek roundtrip, missing key, overwrite,
  evict_stale removes old + keeps fresh, evict ZERO clears everything,
  len/is_empty, clone shares Arc inner, dyn trait usage, with_capacity)
- region_telemetry: 5 tests (consumption_fraction with publishes / zero /
  full, had_buffer_misses true / false)

Plus ts-rs auto-generated export_bindings_* tests for all 11 types.

Total: 74 tests pass in runtime::, 0 fail.

## Boy-scout

cargo fmt applied across the package picked up some unrelated drift in
governor/types.rs (line-width formatting on ts(export...) attributes).
Including the fix.

## What is NOT in this card

- No region implementations (HippocampusModule, MotorCortexModule,
  AttentionModule all land in later slices)
- No algorithms (1-7 from COGNITION-ALGORITHMS.md land in subsequent cards)
- No SubstrateGovernor integration (yield-learning loop is L0-4c)
- No derive macro / scaffold generator (lands when ≥3 regions exist to
  motivate the abstraction — per outlier-validation in CLAUDE.md)

## Predecessors merged

- #1469 (L0-2-CUTOVER-INVESTIGATION + RTOS-brain doctrine) — 2026-05-29
- #1470 (BRAIN-REGIONS-SUBSTRATE + COGNITION-ALGORITHMS docs) — 2026-05-29

## Next slices

L0-3a.1 HippocampusModule skeleton, L0-3a.2 Engram + EngramGraph types,
L0-3a.3 Algorithm 4 (salience decay), L0-3a.4 Algorithm 2 (channel-as-bias),
L0-3a.5 Algorithm 3 (activation spreading), L0-3a.6 Algorithm 1 (two-pool
budget), L0-3a.7 Algorithm 5 (predictor + ready-buffer publish), L0-3a.8
holdout fixture suite, L0-3a.9 TS Hippocampus.ts deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(continuum-core/modules): L0-3a.1 — HippocampusModule skeleton (BrainRegion + ServiceModule, empty tick)

Card: f8c51b26-9ddd-4107-97da-3237fc18ab4b

Second sub-slice of L0-3a. Skeleton only — no algorithms, no command
migration. Algorithms 1-5 from COGNITION-ALGORITHMS.md land in L0-3a.2
through L0-3a.7. Command surface migration (memory/* from MemoryModule)
is L0-3a.1b.

## HippocampusModule

- Implements ServiceModule with EMPTY command_prefixes + event_subscriptions
  (MemoryModule continues to handle memory/* commands until L0-3a.1b)
- Implements BrainRegion (from #1471 trait machinery) with:
  - id = "hippocampus" (static)
  - pressure_profile: { MemoryClass::Heavy, ComputeClass::CpuVectorized,
    responds_to: [SystemMemHigh, InferenceQueueDepth] }
  - tick: idle — bumps internal monotonic counter, returns TickOutcome::idle()
  - on_signal: default no-op (L0-4d wires SleepTransition reaction)
- Owns a DashMapReadyBuffer<EngramPrefetchKey, EngramPrefetch> exposed via
  engram_prefetch() — Arc-shared so motor cortex / attention can peek
  without going through the trait object
- Shares MemoryState with MemoryModule via Arc — when L0-3a.1b absorbs
  command handling, migration is structurally trivial

## EngramPrefetch / EngramPrefetchKey

Placeholder ready-buffer value type. Carries produced_at_tick so handlers
can detect stale buffers without timestamp comparison. Real shape (engram
set + scoring metadata + genome blend hint) lands L0-3a.2 with the actual
Engram types.

Key shape: (persona_id, channel_id) tuple. Per-region buffer doctrine — one
prefetch per persona-per-channel.

## Outlier-validation hedge (docstring)

The BrainRegion trait in #1471 has only one implementation candidate today.
Module docstring explicitly checks the trait surface against two other
plausible regions to prevent it ossifying around hippocampus:

- Motor cortex (L0-4a): continuous candidate-utterance ranking. Differs in
  latency sensitivity. CadenceHint::Faster + per-key freshness semantics fit.
- Attention (L0-4b): salience-map maintenance. Differs in publish-target
  (writes to shared PersonaCognition.salience, not own ready-buffer).
  TickOutcome.published counts either target without trait change.

Both alternative shapes fit the same trait without forcing. Trait surface
proven for 3 distinct region behaviors before any of them ship.

## Tests (7 pass, 0 fail)

- region_id_is_stable_static_string
- pressure_profile_declares_memory_heavy_compute_vectorized
- idle_tick_returns_idle_outcome_and_bumps_counter
- engram_prefetch_buffer_roundtrip
- engram_prefetch_handle_is_shared_via_arc (verifies Arc-shared semantics)
- service_module_handle_command_errors_for_unrouted_commands
- service_module_config_has_empty_cmd_and_event_surfaces

## Scope: 2 files

Modified: src/workers/continuum-core/src/modules/mod.rs (pub mod hippocampus)
Added:    src/workers/continuum-core/src/modules/hippocampus.rs (379 lines)

Fmt-drift in unrelated files was split off into a companion PR following
the same pattern as #1472, keeping this review focused.

## Predecessors

- #1471 (L0-3a.0 trait machinery) — merged to canary
- #1470 (BRAIN-REGIONS-SUBSTRATE + COGNITION-ALGORITHMS docs) — merged
- #1469 (L0-2-CUTOVER-INVESTIGATION + RTOS-brain doctrine) — merged

## Next slices

L0-3a.2 Engram + EngramGraph types → L0-3a.3 algorithm 4 (salience decay)
→ L0-3a.4 algorithm 2 (channel-as-bias) → L0-3a.5 algorithm 3 (activation
spreading) → L0-3a.6 algorithm 1 (two-pool budget) → L0-3a.7 algorithm 5
(predictor + ready-buffer publish — the alive-feeling slice) → L0-3a.8
holdout fixture suite → L0-3a.9 TS Hippocampus.ts deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 30, 2026
…dgeKind (algorithm 3 substrate) (#1474)

Card: 8459bfa6-b40c-4c22-8f25-0963a7987c17

Sidecar substrate for algorithm 3 (activation spreading, COGNITION-ALGORITHMS.md §3). Pure storage layer — traversal logic lands in L0-3a.5. Does NOT modify the existing persona::engram admission membrane.

## What ships

### persona/engram_graph.rs (new, 376 lines)

- EdgeKind enum — SharedEntity | SharedTopic | CitedIn | RecallCoOccurrence | ConversationalReply | TaskOutcome
- EngramEdge { target: Uuid, kind: EdgeKind, weight: f32 } — algorithm-3 traversal payload
- EngramGraph — DashMap<Uuid, Vec<EngramEdge>> sharded for concurrent writes
  - new() / with_capacity(n) / default()
  - add_edge(from, to, kind, weight)
  - neighbors(id) — outbound edges, O(1) amortized, insertion order preserved
  - in_degree(id) — inbound count, O(N) scan (cold path — algorithm 4 centrality)
  - edge_count() — telemetry
  - evict_engram(id) — removes outbound + inbound, idempotent
  - is_empty()

### ts-rs bindings

shared/generated/persona/EdgeKind.ts
shared/generated/persona/EngramEdge.ts

## Sidecar pattern

Intentionally separate from persona::engram (the admission membrane):
- engram.rs ships provenance, trust, content refs — WHERE engrams come from
- engram_graph.rs ships connectivity — HOW engrams connect

Keeping them separate means admission consumers don't grow algorithm-3 dependencies, and algorithm-3 consumers don't grow admission dependencies. Clean concern boundaries.

## Tests (16 pass, 0 fail)

- new_engram_graph_is_empty
- add_edge_increments_count
- neighbors_returns_added_edges_in_insertion_order
- neighbors_of_unknown_source_is_empty
- weights_preserved_through_neighbors
- in_degree_counts_inbound_edges_across_sources
- in_degree_counts_repeated_edges_from_same_source
- evict_engram_removes_outbound_edges
- evict_engram_removes_inbound_edges_from_other_engrams
- evict_engram_is_idempotent
- concurrent_add_edge_from_threads_is_safe (8 threads × 100 edges, all targeting same id, in_degree=800)
- default_constructor_matches_new
- with_capacity_constructor_works
- edge_kind_round_trips_through_serde
- export_bindings_edgekind (ts-rs auto)
- export_bindings_engramedge (ts-rs auto)

## What is NOT in this card

- spread_activation function (L0-3a.5, algorithm 3 — reads this graph)
- EdgeKind weights tuned by algorithm 7 (L0-4c yield-learning)
- RecallMetadata sidecar (L0-3a.2b — salience, last_touched, access_count, embedding)
- EngramRef shape (L0-3a.2b)
- Engram admission membrane modifications (no changes to persona::engram)

## Predecessors

- #1473 (L0-3a.1 HippocampusModule skeleton) — merged
- #1471 (L0-3a.0 trait machinery) — merged
- #1470 (cognition algorithms doc) — merged

## Flywheel test

Third PR (after #1471, #1473) through the auto-merger flywheel that peer's #1091/#1092/#1093 enabled. Local fmt was scoped to ONLY my file (no widespread cargo fmt -p sweep), so no companion fmt-drift PR needed this time.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 30, 2026
The `avatars: ./src/models/avatars` additional_context was added in
9b1f6ca (April 2026) when the plan was to bake CC0 avatar VRMs
into the continuum-core image. That plan never landed end-to-end —
docker/continuum-core.Dockerfile lines 131-143 document the rollback:
src/models is gitignored, the dir doesn't exist in CI checkouts,
and the Dockerfile uses `RUN mkdir -p /app/avatars` as a placeholder
instead of COPYing from the avatars context.

The compose-side context declaration was left behind, dangling. No
Dockerfile uses `--from=avatars` (verified by grep), so the declaration
referenced nothing in build instructions. But docker compose validates
that ALL additional_contexts resolve at build time — a missing local
context dir fails the whole build with "stat /tmp/carl-smoke-NNNN/src/
models/avatars: no such file or directory".

That's the exact failure mode currently blocking carl-install-smoke
on PR #1475 (Mac Intel hardware tier) — any PR that touches install.sh
triggers carl-install-smoke, which has been silently broken by this
dangling context since the rollback. Other PRs (e.g. #1471, #1473,
#1474) didn't touch install.sh so the check never ran on them; the
break was invisible until now.

Removing the line restores the carl-install-smoke happy path while
keeping the Dockerfile's empty-dir placeholder intact. Restore the
build context when the avatar-provisioning story lands (LFS, model-init
download, or curl from a CC0 URL in CI before docker build) per the
gap noted in docs/infrastructure/PR891-E2E-VALIDATION.md.

Inline comment preserves the context-of-removal in the file so a
future contributor doesn't re-add the dangling line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 30, 2026
…se (#1476)

The `avatars: ./src/models/avatars` additional_context was added in
9b1f6ca (April 2026) when the plan was to bake CC0 avatar VRMs
into the continuum-core image. That plan never landed end-to-end —
docker/continuum-core.Dockerfile lines 131-143 document the rollback:
src/models is gitignored, the dir doesn't exist in CI checkouts,
and the Dockerfile uses `RUN mkdir -p /app/avatars` as a placeholder
instead of COPYing from the avatars context.

The compose-side context declaration was left behind, dangling. No
Dockerfile uses `--from=avatars` (verified by grep), so the declaration
referenced nothing in build instructions. But docker compose validates
that ALL additional_contexts resolve at build time — a missing local
context dir fails the whole build with "stat /tmp/carl-smoke-NNNN/src/
models/avatars: no such file or directory".

That's the exact failure mode currently blocking carl-install-smoke
on PR #1475 (Mac Intel hardware tier) — any PR that touches install.sh
triggers carl-install-smoke, which has been silently broken by this
dangling context since the rollback. Other PRs (e.g. #1471, #1473,
#1474) didn't touch install.sh so the check never ran on them; the
break was invisible until now.

Removing the line restores the carl-install-smoke happy path while
keeping the Dockerfile's empty-dir placeholder intact. Restore the
build context when the avatar-provisioning story lands (LFS, model-init
download, or curl from a CC0 URL in CI before docker build) per the
gap noted in docs/infrastructure/PR891-E2E-VALIDATION.md.

Inline comment preserves the context-of-removal in the file so a
future contributor doesn't re-add the dangling line.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 31, 2026
…w module scaffolds

Per Joel 2026-05-30: "we developed a generator so we could manufacture
these patterns for new commands modules etc, which itself was a
command. Meta."

The recursive bootstrap from MODULE-ARCHITECTURE.md §10 lands. The
generator IS a module. The things it creates are modules. Every
operation it performs is a command. The system describes itself in
its own terms.

# What this does

`Commands.execute("generate/module", { ... })` scaffolds a compilable
ServiceModule package under
`src/workers/continuum-core/src/modules/<name>/`:

- `mod.rs` — `pub struct <Name>Module {}` with `ServiceModule`
  implemented, the `ModuleConfig` declaring the spec's commands +
  events, and `handle_command` returning typed "not yet implemented"
  errors for each declared command (so the scaffold compiles + the
  author fills in real handlers afterwards).
- `README.md` — author-facing doc capturing the same contract +
  spelling out the manual wire-up step (add `pub mod <name>;` to
  the parent `modules/mod.rs`, register `Arc::new(<Name>Module::new())`
  at runtime startup).

The generated module follows every pattern this session codified:

- `ServiceModule` trait from PR #1471 (the substrate floor)
- `CommandResult` cell shapes from PR #1485
- `CommandRequest<P>` / `CommandResponse<T>` envelopes from PR #1486
  (the generator itself uses these — typed envelope in, typed
  envelope out)
- The architecture from MODULE-ARCHITECTURE.md (PR #1482)

# Why this is the meta move

Every architectural pattern we codified degrades fast if every new
module's author has to re-derive them from the docs. The generator
is the boy-scout amplifier: write the patterns once into the
templates, run `Commands.execute("generate/module", ...)`, get a
module skeleton that already follows them. Subsequent migrations
become "fill in the handler bodies" rather than "re-derive the
shape."

The generator can eventually generate itself (the recursion closes).
This PR ships the v1; future PRs add `generate/command` (add a new
command to an existing module) and `generate/refresh` (re-scan the
modules tree and refresh manifests).

# Implementation surface

Three files under `modules/generator/`:

- **`types.rs`** — `GenerateModuleParams` (name, description, commands,
  events_subscribed, events_published, priority, force) +
  `GenerateModuleResult` (module_path, files_created, next_step) +
  `PrioritySpec` wire enum + `validate_module_name`. All
  serde-friendly, no leak of internal types onto the wire.

- **`templates.rs`** — pure render functions: `mod_rs_template`,
  `readme_template`, and helpers. No I/O lives here; the caller does
  the writes. Keeps the templates testable in isolation and the I/O
  paths easy to swap (e.g., future dry-run mode).

- **`mod.rs`** — `GeneratorModule` (the `ServiceModule` impl) +
  `generate_module_inner` (the actual filesystem work). `handle_command`
  parses a `CommandRequest<GenerateModuleParams>` and materializes a
  `CommandResponse<GenerateModuleResult>` — uses the exact envelope
  pattern PR #1486 introduced, eating its own dogfood.

The module is wired into `modules/mod.rs` as `pub mod generator;` —
the same step the generator instructs callers to perform for the
modules IT scaffolds.

# Tests (21/21 pass)

types.rs (5):
- `validate_accepts_canonical_names` — chat, ai_provider,
  ai-provider, _internal, a1
- `validate_rejects_empty_or_invalid` — empty, capitalized,
  leading-digit, has-space, with-slash
- `priority_spec_round_trips_through_json` — all 4 variants
- `priority_spec_default_is_normal`
- `priority_spec_as_variant_str_matches_rust_enum`

templates.rs (7):
- `mod_rs_contains_struct_definition_and_trait_impl`
- `mod_rs_lists_each_declared_command_in_prefix_and_dispatch`
- `mod_rs_includes_module_name_prefix_in_command_prefixes`
- `mod_rs_subscribes_to_declared_events`
- `mod_rs_documents_published_events_in_module_docstring`
- `mod_rs_for_command_less_module_still_compiles_shape`
- `readme_lists_declared_contract`
- `readme_handles_empty_lists_gracefully`

mod.rs (8):
- `struct_name_handles_hyphens_underscores_and_simple_names`
- `config_advertises_generate_prefix`
- `generate_module_creates_dir_and_files` — full filesystem round-trip
  in a tempdir, asserts struct name + declared commands + ServiceModule
  appear in the generated mod.rs
- `generate_module_refuses_existing_dir_without_force` — fail-loud,
  error names the conflict AND the escape hatch
- `generate_module_overwrites_with_force` — and the second
  generation's description appears in the file
- `generate_module_rejects_invalid_names` — empty / space / slash /
  parent-escape / leading-digit
- `handle_command_returns_typed_envelope` — end-to-end through the
  ServiceModule trait + CommandRequest envelope + CommandResponse
  envelope + the JSON round-trip
- `handle_command_rejects_unknown_command_loud` — error names the bad
  command + what's supported

# What this PR explicitly does NOT do

- Does NOT auto-wire the generated module into the parent
  `modules/mod.rs`. The generator emits the exact line the caller
  needs to add — explicit human step keeps the registration audit
  obvious. A future `generate/refresh` command can do this
  automatically.
- Does NOT generate package.json / manifest.json. The architecture
  doc anticipates these, but the on-disk module structure in
  continuum-core today is "everything compiles into one binary," so
  per-module manifests are a future migration (WASM-component
  modules will need them per MODULE-ARCHITECTURE.md §9).
- Does NOT register `GeneratorModule` at runtime startup. The module
  is reachable via direct construction in tests; production wire-up
  happens in `ipc::start_server` once the typical "register Arc::new"
  pattern is followed (the generator's README spells this out for
  EVERY module it creates, including itself).
- Does NOT implement `generate/command` (add a command to an
  existing module) or `generate/refresh` (re-scan + refresh
  manifests). Both are natural follow-ups; this PR ships the v1.

# References

- [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md)
  §10 (recursive bootstrap), §2 (what a module is)
- PR #1486 (CommandRequest/Response envelopes — used here)
- PR #1485 (cell shapes — used here)
- PR #1483 / #1484 (interceptor chain — orthogonal but composable)
- PR #1482 (architecture doc)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 31, 2026
…w module scaffolds

Per Joel 2026-05-30: "we developed a generator so we could manufacture
these patterns for new commands modules etc, which itself was a
command. Meta."

The recursive bootstrap from MODULE-ARCHITECTURE.md §10 lands. The
generator IS a module. The things it creates are modules. Every
operation it performs is a command. The system describes itself in
its own terms.

# What this does

`Commands.execute("generate/module", { ... })` scaffolds a compilable
ServiceModule package under
`src/workers/continuum-core/src/modules/<name>/`:

- `mod.rs` — `pub struct <Name>Module {}` with `ServiceModule`
  implemented, the `ModuleConfig` declaring the spec's commands +
  events, and `handle_command` returning typed "not yet implemented"
  errors for each declared command (so the scaffold compiles + the
  author fills in real handlers afterwards).
- `README.md` — author-facing doc capturing the same contract +
  spelling out the manual wire-up step (add `pub mod <name>;` to
  the parent `modules/mod.rs`, register `Arc::new(<Name>Module::new())`
  at runtime startup).

The generated module follows every pattern this session codified:

- `ServiceModule` trait from PR #1471 (the substrate floor)
- `CommandResult` cell shapes from PR #1485
- `CommandRequest<P>` / `CommandResponse<T>` envelopes from PR #1486
  (the generator itself uses these — typed envelope in, typed
  envelope out)
- The architecture from MODULE-ARCHITECTURE.md (PR #1482)

# Why this is the meta move

Every architectural pattern we codified degrades fast if every new
module's author has to re-derive them from the docs. The generator
is the boy-scout amplifier: write the patterns once into the
templates, run `Commands.execute("generate/module", ...)`, get a
module skeleton that already follows them. Subsequent migrations
become "fill in the handler bodies" rather than "re-derive the
shape."

The generator can eventually generate itself (the recursion closes).
This PR ships the v1; future PRs add `generate/command` (add a new
command to an existing module) and `generate/refresh` (re-scan the
modules tree and refresh manifests).

# Implementation surface

Three files under `modules/generator/`:

- **`types.rs`** — `GenerateModuleParams` (name, description, commands,
  events_subscribed, events_published, priority, force) +
  `GenerateModuleResult` (module_path, files_created, next_step) +
  `PrioritySpec` wire enum + `validate_module_name`. All
  serde-friendly, no leak of internal types onto the wire.

- **`templates.rs`** — pure render functions: `mod_rs_template`,
  `readme_template`, and helpers. No I/O lives here; the caller does
  the writes. Keeps the templates testable in isolation and the I/O
  paths easy to swap (e.g., future dry-run mode).

- **`mod.rs`** — `GeneratorModule` (the `ServiceModule` impl) +
  `generate_module_inner` (the actual filesystem work). `handle_command`
  parses a `CommandRequest<GenerateModuleParams>` and materializes a
  `CommandResponse<GenerateModuleResult>` — uses the exact envelope
  pattern PR #1486 introduced, eating its own dogfood.

The module is wired into `modules/mod.rs` as `pub mod generator;` —
the same step the generator instructs callers to perform for the
modules IT scaffolds.

# Tests (21/21 pass)

types.rs (5):
- `validate_accepts_canonical_names` — chat, ai_provider,
  ai-provider, _internal, a1
- `validate_rejects_empty_or_invalid` — empty, capitalized,
  leading-digit, has-space, with-slash
- `priority_spec_round_trips_through_json` — all 4 variants
- `priority_spec_default_is_normal`
- `priority_spec_as_variant_str_matches_rust_enum`

templates.rs (7):
- `mod_rs_contains_struct_definition_and_trait_impl`
- `mod_rs_lists_each_declared_command_in_prefix_and_dispatch`
- `mod_rs_includes_module_name_prefix_in_command_prefixes`
- `mod_rs_subscribes_to_declared_events`
- `mod_rs_documents_published_events_in_module_docstring`
- `mod_rs_for_command_less_module_still_compiles_shape`
- `readme_lists_declared_contract`
- `readme_handles_empty_lists_gracefully`

mod.rs (8):
- `struct_name_handles_hyphens_underscores_and_simple_names`
- `config_advertises_generate_prefix`
- `generate_module_creates_dir_and_files` — full filesystem round-trip
  in a tempdir, asserts struct name + declared commands + ServiceModule
  appear in the generated mod.rs
- `generate_module_refuses_existing_dir_without_force` — fail-loud,
  error names the conflict AND the escape hatch
- `generate_module_overwrites_with_force` — and the second
  generation's description appears in the file
- `generate_module_rejects_invalid_names` — empty / space / slash /
  parent-escape / leading-digit
- `handle_command_returns_typed_envelope` — end-to-end through the
  ServiceModule trait + CommandRequest envelope + CommandResponse
  envelope + the JSON round-trip
- `handle_command_rejects_unknown_command_loud` — error names the bad
  command + what's supported

# What this PR explicitly does NOT do

- Does NOT auto-wire the generated module into the parent
  `modules/mod.rs`. The generator emits the exact line the caller
  needs to add — explicit human step keeps the registration audit
  obvious. A future `generate/refresh` command can do this
  automatically.
- Does NOT generate package.json / manifest.json. The architecture
  doc anticipates these, but the on-disk module structure in
  continuum-core today is "everything compiles into one binary," so
  per-module manifests are a future migration (WASM-component
  modules will need them per MODULE-ARCHITECTURE.md §9).
- Does NOT register `GeneratorModule` at runtime startup. The module
  is reachable via direct construction in tests; production wire-up
  happens in `ipc::start_server` once the typical "register Arc::new"
  pattern is followed (the generator's README spells this out for
  EVERY module it creates, including itself).
- Does NOT implement `generate/command` (add a command to an
  existing module) or `generate/refresh` (re-scan + refresh
  manifests). Both are natural follow-ups; this PR ships the v1.

# References

- [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md)
  §10 (recursive bootstrap), §2 (what a module is)
- PR #1486 (CommandRequest/Response envelopes — used here)
- PR #1485 (cell shapes — used here)
- PR #1483 / #1484 (interceptor chain — orthogonal but composable)
- PR #1482 (architecture doc)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
joelteply added a commit that referenced this pull request May 31, 2026
…w module scaffolds (#1487)

* feat(runtime): CommandRequest<P> / CommandResponse<T> envelopes — handle as first-class field

Per Joel 2026-05-30: "Some things are used so much should just be part
of command result and params, handle for example. Find the patterns and
simplify. The better the pattern, the easier to use the command or to
reduce code size. I love OOP though."

Today's `ServiceModule::handle_command(command, params: Value) ->
Result<CommandResult, String>` shovels everything through raw JSON;
handlers re-parse the cross-cutting bits (handle, sessionId, userId,
success, error) themselves and rebuild the same envelope at every
return point. This commit gives the pattern names and a typed API so
new handlers stop hand-rolling the envelope every time.

# What lands

**`runtime::command_envelope::CommandRequest<P>`** — typed envelope
around an inbound command. Flattens the command-specific params `P`
with the cross-cutting fields every command can carry:
- `handle: Option<HandleRef>` — a handle from a previous call.
  Present when this command operates on existing state owned by
  another command (e.g., `inference/poll` carries the handle minted
  by `inference/start`).
- `session_id: Option<Uuid>` — calling session.
- `user_id: Option<Uuid>` — calling user.

Construction: `CommandRequest::<P>::from_value(value)?` at handler
entry. Test/programmatic construction via the builder methods
(`new(params)`, `.with_handle(...)`, `.with_session(...)`,
`.with_user(...)`). Wire shape stays flat — `#[serde(flatten)]` on
the params field — so existing TS-side callers don't see a shape
change.

**`runtime::command_envelope::CommandResponse<T>`** — typed envelope
around an outbound result. Same flatten pattern. Cross-cutting
fields:
- `success: bool` — operation-level success.
- `data: T` — command-specific payload, flattened into JSON.
- `handle: Option<HandleRef>` — a handle MINTED by this command for
  the caller's follow-up. The "first call returns a handle" pattern
  Joel called out for inference / training / hosting / ORM lives here.
- `error: Option<String>` — operation-level error, set when
  success == false.

Builder-style API: `CommandResponse::ok(data)` for happy path; chain
`.with_handle(owner, id, type_tag)` to mint a handle for follow-up;
`.with_handle_ref(handle)` to echo an existing handle. For failure,
`CommandResponse::<T>::err(message)` (requires `T: Default` so the
data field has a value; callers without a default just construct
directly).

Bridge into the existing `ServiceModule::handle_command` return: call
`.into_command_result()` — serializes the flattened envelope as
JSON, wraps as `CommandResult::Json`. One method to bridge typed
internal handler into the kernel surface.

# What this collapses (before/after)

Before — handler hand-rolls the envelope every time:
```ignore
async fn handle_inference_start(&self, params: Value) -> Result<CommandResult, String> {
    let p: InferenceStartParams = serde_json::from_value(params.clone())
        .map_err(|e| e.to_string())?;
    let session_id = params.get("sessionId").and_then(|v| v.as_str())
        .and_then(|s| Uuid::parse_str(s).ok());
    let id = Uuid::new_v4();
    self.sessions.insert(id, InferenceSession::new(p));
    Ok(CommandResult::Json(serde_json::json!({
        "success": true,
        "firstToken": first_token,
        "handle": HandleRef::with_id("ai/inference", id, "ai::InferenceSession"),
    })))
}
```

After — envelope handles the cross-cutting fields:
```ignore
async fn handle_inference_start(&self, params: Value) -> Result<CommandResult, String> {
    let req = CommandRequest::<InferenceStartParams>::from_value(params)?;
    let id = Uuid::new_v4();
    self.sessions.insert(id, InferenceSession::new(req.params));
    CommandResponse::ok(InferenceStartData { first_token })
        .with_handle("ai/inference", id, "ai::InferenceSession")
        .into_command_result()
}
```

Cross-cutting fields stop being something handlers know about. They
become free.

# Test plan (9/9 pass)

- `request_parses_flat_params_no_envelope_fields` — pure params,
  envelope fields default to None
- `request_parses_envelope_fields_flat` — handle/sessionId/userId all
  pulled from the same JSON object at top level
- `request_parse_error_carries_diagnostic` — type mismatch surfaces
  as Err with envelope identity (not panic)
- `request_builder_attaches_envelope_fields` — builder API works
- `response_ok_serializes_flat_with_success_true` — happy-path shape,
  handle/error omitted when None
- `response_with_handle_attaches_handle_at_top_level` — handle sits
  alongside flat data fields
- `response_err_serializes_with_success_false_and_message` — failure
  shape with default data preserved
- `response_into_command_result_yields_json_variant` — bridge to the
  ServiceModule return type works
- `round_trip_through_wire_preserves_envelope_fields` — end-to-end:
  handler returns response with handle → serialize → caller builds
  next request using the handle + own session/user → all envelope
  fields survive

# What this PR does NOT do

- Does NOT change `ServiceModule::handle_command` signature. The
  Value-based shape stays for the 300+ existing surface; new
  handlers opt into the typed envelope via `from_value` /
  `into_command_result`.
- Does NOT migrate any existing handler. The envelope is the
  primitive; migrations are individual follow-up PRs.
- Does NOT add a kernel-level handle registry. Each producer manages
  handle lifetimes internally per MODULE-ARCHITECTURE.md §13.1.

# References

- [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md)
  §5.1 (cell return shapes), §13.1 (hot-path cross-module state)
- PR #1485 (cell return shapes — Handle variant + HandleRef)
- PR #1484 (GridInterceptor)
- PR #1483 (CommandInterceptor trait + AircInterceptor stub)
- PR #1482 (architecture doc)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(modules): GeneratorModule — recursive bootstrap, manufactures new module scaffolds

Per Joel 2026-05-30: "we developed a generator so we could manufacture
these patterns for new commands modules etc, which itself was a
command. Meta."

The recursive bootstrap from MODULE-ARCHITECTURE.md §10 lands. The
generator IS a module. The things it creates are modules. Every
operation it performs is a command. The system describes itself in
its own terms.

# What this does

`Commands.execute("generate/module", { ... })` scaffolds a compilable
ServiceModule package under
`src/workers/continuum-core/src/modules/<name>/`:

- `mod.rs` — `pub struct <Name>Module {}` with `ServiceModule`
  implemented, the `ModuleConfig` declaring the spec's commands +
  events, and `handle_command` returning typed "not yet implemented"
  errors for each declared command (so the scaffold compiles + the
  author fills in real handlers afterwards).
- `README.md` — author-facing doc capturing the same contract +
  spelling out the manual wire-up step (add `pub mod <name>;` to
  the parent `modules/mod.rs`, register `Arc::new(<Name>Module::new())`
  at runtime startup).

The generated module follows every pattern this session codified:

- `ServiceModule` trait from PR #1471 (the substrate floor)
- `CommandResult` cell shapes from PR #1485
- `CommandRequest<P>` / `CommandResponse<T>` envelopes from PR #1486
  (the generator itself uses these — typed envelope in, typed
  envelope out)
- The architecture from MODULE-ARCHITECTURE.md (PR #1482)

# Why this is the meta move

Every architectural pattern we codified degrades fast if every new
module's author has to re-derive them from the docs. The generator
is the boy-scout amplifier: write the patterns once into the
templates, run `Commands.execute("generate/module", ...)`, get a
module skeleton that already follows them. Subsequent migrations
become "fill in the handler bodies" rather than "re-derive the
shape."

The generator can eventually generate itself (the recursion closes).
This PR ships the v1; future PRs add `generate/command` (add a new
command to an existing module) and `generate/refresh` (re-scan the
modules tree and refresh manifests).

# Implementation surface

Three files under `modules/generator/`:

- **`types.rs`** — `GenerateModuleParams` (name, description, commands,
  events_subscribed, events_published, priority, force) +
  `GenerateModuleResult` (module_path, files_created, next_step) +
  `PrioritySpec` wire enum + `validate_module_name`. All
  serde-friendly, no leak of internal types onto the wire.

- **`templates.rs`** — pure render functions: `mod_rs_template`,
  `readme_template`, and helpers. No I/O lives here; the caller does
  the writes. Keeps the templates testable in isolation and the I/O
  paths easy to swap (e.g., future dry-run mode).

- **`mod.rs`** — `GeneratorModule` (the `ServiceModule` impl) +
  `generate_module_inner` (the actual filesystem work). `handle_command`
  parses a `CommandRequest<GenerateModuleParams>` and materializes a
  `CommandResponse<GenerateModuleResult>` — uses the exact envelope
  pattern PR #1486 introduced, eating its own dogfood.

The module is wired into `modules/mod.rs` as `pub mod generator;` —
the same step the generator instructs callers to perform for the
modules IT scaffolds.

# Tests (21/21 pass)

types.rs (5):
- `validate_accepts_canonical_names` — chat, ai_provider,
  ai-provider, _internal, a1
- `validate_rejects_empty_or_invalid` — empty, capitalized,
  leading-digit, has-space, with-slash
- `priority_spec_round_trips_through_json` — all 4 variants
- `priority_spec_default_is_normal`
- `priority_spec_as_variant_str_matches_rust_enum`

templates.rs (7):
- `mod_rs_contains_struct_definition_and_trait_impl`
- `mod_rs_lists_each_declared_command_in_prefix_and_dispatch`
- `mod_rs_includes_module_name_prefix_in_command_prefixes`
- `mod_rs_subscribes_to_declared_events`
- `mod_rs_documents_published_events_in_module_docstring`
- `mod_rs_for_command_less_module_still_compiles_shape`
- `readme_lists_declared_contract`
- `readme_handles_empty_lists_gracefully`

mod.rs (8):
- `struct_name_handles_hyphens_underscores_and_simple_names`
- `config_advertises_generate_prefix`
- `generate_module_creates_dir_and_files` — full filesystem round-trip
  in a tempdir, asserts struct name + declared commands + ServiceModule
  appear in the generated mod.rs
- `generate_module_refuses_existing_dir_without_force` — fail-loud,
  error names the conflict AND the escape hatch
- `generate_module_overwrites_with_force` — and the second
  generation's description appears in the file
- `generate_module_rejects_invalid_names` — empty / space / slash /
  parent-escape / leading-digit
- `handle_command_returns_typed_envelope` — end-to-end through the
  ServiceModule trait + CommandRequest envelope + CommandResponse
  envelope + the JSON round-trip
- `handle_command_rejects_unknown_command_loud` — error names the bad
  command + what's supported

# What this PR explicitly does NOT do

- Does NOT auto-wire the generated module into the parent
  `modules/mod.rs`. The generator emits the exact line the caller
  needs to add — explicit human step keeps the registration audit
  obvious. A future `generate/refresh` command can do this
  automatically.
- Does NOT generate package.json / manifest.json. The architecture
  doc anticipates these, but the on-disk module structure in
  continuum-core today is "everything compiles into one binary," so
  per-module manifests are a future migration (WASM-component
  modules will need them per MODULE-ARCHITECTURE.md §9).
- Does NOT register `GeneratorModule` at runtime startup. The module
  is reachable via direct construction in tests; production wire-up
  happens in `ipc::start_server` once the typical "register Arc::new"
  pattern is followed (the generator's README spells this out for
  EVERY module it creates, including itself).
- Does NOT implement `generate/command` (add a command to an
  existing module) or `generate/refresh` (re-scan + refresh
  manifests). Both are natural follow-ups; this PR ships the v1.

# References

- [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md)
  §10 (recursive bootstrap), §2 (what a module is)
- PR #1486 (CommandRequest/Response envelopes — used here)
- PR #1485 (cell shapes — used here)
- PR #1483 / #1484 (interceptor chain — orthogonal but composable)
- PR #1482 (architecture doc)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(modules/generator): per-name lock serializes concurrent same-name generation + concurrency tests

Per Joel 2026-05-30: "Each persona exists in its own threads."

# Race scenarios the test caught

Original `generate_module_inner`:
```rust
if target_dir.exists() && !params.force {
    return Err("already exists");
}
std::fs::create_dir_all(&target_dir)?;
write_file(mod.rs);
write_file(README.md);
```

Concurrent same-name `generate/module` calls:

1. **Both without force**: BOTH pass the exists() check, BOTH call
   create_dir_all (idempotent → both succeed), BOTH write — and the
   friendly "already exists" error is silenced. With DIFFERENT params,
   last write wins per file → **silent torn state** (mod.rs from
   caller A + README from caller B).

2. **Both with force**: same torn-state hazard — interleaved writes
   produce inconsistent final state.

3. **Different names**: no conflict, should stay fully parallel.

# The fix

`DashMap<String, Arc<std::sync::Mutex<()>>>` keyed by module name.
The per-name mutex is acquired before the exists() check and held
through the writes — same-name concurrent calls serialize; different
names stay parallel via DashMap's per-shard locking.

`std::sync::Mutex` (not `tokio::sync::Mutex`) because the protected
critical section is purely synchronous filesystem I/O — no `.await`
inside the lock. Blocking the tokio worker for the brief mkdir + 2
writes is correct and avoids cascading the API into async. The
critical section is short and generation is rare (humans/AI
scaffolding modules, not the hot path).

Lock entries are never evicted — module names are bounded (no
unbounded stream of unique names) and each entry is ~50 bytes. If
memory ever matters, a TTL scan can be added without changing the
protocol.

# Concurrency stress tests

Every test uses `flavor = "multi_thread", worker_threads = 4` so
spawned tasks actually preempt on distinct OS threads, not
cooperatively interleave on one.

## `same_name_concurrent_generation_without_force_yields_one_winner`

8 racers, same name, no force. Asserts EXACTLY 1 winner, 7 losers,
every loser's error names both the failure mode ("already exists")
AND the escape hatch ("force"). Without the per-name lock, this test
would have shown N winners (silent corruption).

## `same_name_concurrent_generation_with_force_produces_consistent_final_state`

8 racers, same name, force=true. Each caller embeds a unique
`MARKER-NN` in its `description` (which both templates write into
their output). Asserts both files end with the SAME marker — torn
state would show different markers in mod.rs vs README.

## `different_names_concurrent_generation_runs_fully_parallel`

12 racers, all distinct names. Asserts all 12 succeed, each module's
files exist with their own content. Verifies the per-name lock map
holds 12 distinct entries (different DashMap shards → no
contention).

# Tests (24/24 pass — 21 pre-existing + 3 new concurrency)

All pre-existing tests still pass — no regression from the locking
addition. The new tests pin all three cells of the
(same-name × force-flag) matrix plus the different-names parallel
path.

# Substrate doctrine reinforced

This is the SAME pattern that landed in PR #1490 (per-cursor mutex
for data/query-next). The pattern generalizes:

> Every ServiceModule that protects per-resource mutable state
> across an `.await` (tokio::sync::Mutex) OR holds per-resource
> filesystem invariants (std::sync::Mutex) must serialize per
> resource, not module-wide. `DashMap<Id, Arc<Mutex<State>>>` is the
> canonical pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant