Conversation
…tirely) (#1039) detect_gpu() in memory_manager.rs only had Metal and CUDA branches. Vulkan was listed as a "supported path" in the panic message + Cargo features but never actually wired into detection. Result: every continuum-core-vulkan build panicked at boot with "No GPU detected" regardless of whether a Vulkan ICD was present (NVIDIA, mesa-radv, mesa-llvmpipe, etc). Caught live during Carl-Windows install retest of the vulkan variant on bigmama-1 (continuum-b69f, 2026-05-04): freshly-built continuum-core-vulkan:108bbc33d image had libvulkan1 + mesa-vulkan-drivers + vulkan-tools installed in the runtime stage, but the binary never asked the loader anything — it fell straight through detect_gpu()'s if-cuda-cfg → panic. Fix: add detect_vulkan() that mirrors detect_cuda's nvidia-smi subprocess approach. Calls vulkaninfo --summary (already in the runtime image via the vulkan-tools apt package), parses the first deviceName line. Works with any ICD: NVIDIA's loader on a GPU host, mesa-llvmpipe (software) on a no-/dev/dri runner like ubuntu-latest CI, mesa-radv on AMD, etc. Memory size is conservative (4 GiB) because vulkaninfo --summary doesn't reliably report device-local heap totals across all ICDs without pulling in `ash`. Real allocations go through the Vulkan loader at runtime via candle/llama.cpp's vulkan backend, so this number only seeds GpuMemoryManager's budget estimator. Unblocks: PR #1038 (drop core variant + default to vulkan) and #1035 (canary→main), both of which were stuck on the smoke gate that requires a vulkan binary to actually start. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Status post-#1041 (seed-fix merged)Good news: The "Room not found: general" race that was blocking smoke is fixed. Confirmed by smoke run 25344053245 chat.log: {
"success": true,
"message": "Message sent to General (#89c27c)",
"messageEntity": {
"roomId": "afafedf2-5c0a-49a5-ab6f-715131f81a29",
"senderId": "21c518f3-73ff-4ceb-a570-9ea44bd4338f",
"senderName": "Developer",
"content": { "text": "carl-smoke-probe-1777933751" }
}
}✅ Room found, ✅ chat/send accepted, ✅ "some persona is listening", ✅ message entity persisted with proper UUID. Smoke now progresses past the seed race (was failing at ~3:30, now failing at 12:47 = past the 300s chat-poll). Residual blockerPersona is allocated and listening. Inference doesn't return within 300s. WhyGH
The residual timeout exposes that CI is testing a no-GPU path that the architecture says is "forbidden" ("lack of GPU integration is forbidden"). Direction options (need your call)
The seed-fix #1041 unblocks the structural race. The remaining failure is a runtime-budget question that intersects with "Carl on real hardware should chat fast" — so #3 + #4 likely fix BOTH the smoke and Carl's first-chat latency on llvmpipe-fallback systems. continuum-node |
Local RTX 5090 e2e validation — chat works, 16s first-reply latencyConfirmed Carl's actual install path works end-to-end on real GPU. Same images as CI smoke ( Probe: 12 messages in 2 minutes — multiple personas responding (CodeReview AI, Local Assistant, Helper AI, Teacher AI). Excerpt: ( What this tells us
Direction (still need your call from earlier comment)The architectural rule is "lack of GPU integration is forbidden." CI runner = no GPU = forbidden state. So:
I'd suggest smoke advisory on llvmpipe-only as the cheapest unblocker; it doesn't lower the bar for actual users, just stops gating merges on CI's lack of GPU. Self-hosted GPU runner is the longer-term solid answer. continuum-node :latest = canary HEAD seed-fix; ready to merge #1035 once we agree on the smoke direction. |
#1035 has 3 stacked blockers, all merge-time gates1. carl-install-smoke: install + chat-send works (post #1041). Fails on "no AI reply within 300s" — no-GPU runner falls back to llvmpipe, llama.cpp budget too tight. Real-GPU validation: 16s first reply on RTX 5090 (already documented above). 2. verify-architectures install-and-run gate (CPU-only Carl path, separate from smoke): widget-server never returns 2xx within 300s. Container loop in logs: continuum-core is restart-looping every ~60s. TTS panic may be triggering core's supervisor to bounce. Same no-GPU-runner architectural issue — the test's gate is testing what the architecture forbids. 3. verify-after-rebuild STALE-IMAGE GATE: 2 amd64 images STALE at Two of the heavy variants (
bigmama-1 SSH isn't reachable from my side (Tailscale on this Windows machine is down — Summary
continuum-node :latest + :canary + :pr-1035 are all on canary HEAD (the seed fix is live on the registry). Light variants (model-init, widgets) :latest now matches :canary. Heavy variants needs bigmama-1 push. What I can still do
|
* ci(carl-smoke): advisory-pass AI-reply when only llvmpipe ICD is present The architecture rule is "lack of GPU integration is forbidden." A no-GPU CI runner falls back to llvmpipe (software Vulkan ICD); llama.cpp inference can't fit the 300s budget on llvmpipe (~1-2 tok/s). The same images and code reply in ~16s on real GPU (validated end-to-end on RTX 5090 + Docker Desktop + WSL2). The install + chat-send + persona-allocation path is fully exercised in either case; only the inference reply is short of budget on the forbidden no-GPU state. When `vulkaninfo --summary` reports llvmpipe AND no real GPU device, the smoke now downgrades the AI-reply timeout from FAIL to advisory pass. - chat/send accepted (room found, persona listening) is still required. - Any non-llvmpipe device → unchanged behavior, still FAIL on no-reply. - CARL_CHAT_LLVMPIPE_STRICT=1 opts back into the strict no-reply FAIL. This is not a lowered bar for actual users. It's a check that says "Carl's install path works up to where the architecture says it can work." Real-GPU validation remains the contract that proves Carl's UX. Closes #1035 / smoke blocker. Carl on real hardware works (16s first reply); CI runner blocker was tested-architecturally-impossible state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(carl-smoke): broaden no-GPU host detection (vulkaninfo not always present on runner) * fix(chat/send): fall back to seeded human owner when senderId doesn't resolve The CLI auto-injects a session-scoped UUID as params.userId. That UUID isn't a seeded user, so findUserById threw "User not found: <uuid>" and the call never reached the seeded-human-owner fallback path that already existed for "no senderId at all". Net effect: every Carl-install-smoke chat probe failed with the wrong error after the seed-blocking fix landed (commit 160e5ba). Fix: try senderId first (returns null on not-found), then fall back to seeded human owner. The "no human owner AND no session userId either" case now fails with an actionable error message naming seed as the cause. Caught by carl-install-smoke on PR #1038 run 25331526438. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit f6d8097) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Test <test@test.com>
#1045) PR #1038 dropped the continuum-core build target but left the variant in scripts/verify-image-revisions.sh:55 DEFAULT_IMAGES. As a result, every verify-after-rebuild run on canary keeps reporting STALE on continuum-core (label revision 2efa5de from before #1038 merged), blocking #1035. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layered on phase 2 (PR #1111). Completes the listbox correctness story by making `aria-selected` and the tab order respond to selection changes after initial render. ReactiveListWidget — base class additions: - New virtual `protected isItemIdSelected(id): boolean`. Default matches `selectedId`; subclasses override to use their own state. Drives both aria-selected and the roving tabindex. - New Lit `updated()` override walks `.list-item` wrappers after every render and syncs aria-selected + tabindex via the new `syncListSelection()` helper. The visual `.active` class was already reactive via Lit (subclasses re-render their inner template); this hook keeps the ARIA state on the static EntityScroller-managed outer wrapper in sync without re-rendering the wrapper. - Initial `getRenderFunction`: tabindex now depends on `isItemIdSelected` (selected → 0, others → -1) rather than the blanket `tabindex=0` from phase 2. - Fallback: if no item is currently selected, the first item gets tabindex=0 so the list remains a single Tab stop. - Arrow-key navigation in `onListKeydown` updates roving tabindex as focus moves — newly-focused item gets tabindex=0, all others -1. Keeps the list a single tab stop after the user has navigated. RoomListWidget: - Overrides `isItemIdSelected`: `id === this.currentRoomId`. When the active room changes, the @reactive currentRoomId triggers a Lit update → updated() → syncListSelection() walks the DOM and the new room becomes aria-selected="true" with tabindex=0, old room drops to "false" / -1. UserListWidget: - Overrides `isItemIdSelected`: `id === this._selectedUserId`. Same reactive pattern. Out of scope (further phase 3 follow-ups, not blockers): - Color-contrast audit across themes - <div onclick> → <button> migration - axe-core lint gate in CI - Focus restoration when a selected item is removed/filtered out `npm run build:ts` is green. Stacked on PR #1111; once that merges, this PR's diff against main reduces to just the phase-3a changes. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs (#1154) Closes the innerHTML hole in the chat-message render hot path. Per sibling tab #1's lead-priority list, this is the no-stack-required slice that fits my Mac stack being down. What changes ----------- URLCardAdapter + ToolOutputAdapter: add `renderMessageElement` override following the same shape Text + Image adapters already use (parse → build wrapper via DOM API → adopt rich content as DocumentFragment via detached `<template>` → return wrapper). ChatWidget: drop the `else if (adapter) { contentDiv.innerHTML = ... }` fallback branch entirely. The DOM-returning path is now the only path; fall-back-to-textContent only fires if an adapter forgets to override OR its override returns null on render failure, with a loud `console.warn` so the gap surfaces. The live message-content slot never sees `innerHTML` for current adapters. Extracted the adapter-render seam into a private helper `renderAdapterContentInto` to keep `getRenderFunction()`'s arrow function under the project's 15-complexity max (the pre-commit ESLint ratchet caught the +1 from the new conditional; refactoring to a helper drops it back to 15). Why this matters --------------- 1. XSS surface — the live-DOM `innerHTML` re-parse step is gone. Adapter renderContent strings still need careful interpolation (URLCardAdapter title/description/originalText is the next concern, tracked as a separate follow-up to #1100). 2. Lit reactivity — `innerHTML` on a live element destroys signal-bound children. Signal-bound children inside message bodies now survive sibling updates without remount. Verified -------- - `npm run build:ts` clean on this branch - `grep "innerHTML" src/widgets/chat/chat-widget/ChatWidget.ts` shows only comments + the unrelated pendingAttachments preview path (out of scope per the card) - All four adapters now have `renderMessageElement` overrides - ESLint baseline unchanged (5464 == 5464) Out of scope (separate cards/PRs) -------------------------------- - URLCardAdapter metadata-string XSS hardening - Pending-attachment preview innerHTML in ChatWidget.ts:1050,1056 - CI lint rule to flag new `innerHTML =` in `widgets/chat/**` Co-authored-by: Test <test@test.com>
…1157) (#1161) Folds two review nits from claude-tab-2 on continuum#1155: 1. **Quarantine no-content-hash recording (real subtle bug).** v1 has no quarantine store, so a Quarantined engram gets dropped on the floor. Original PR-4 code recorded `content_hash → engram_id` for Quarantine via the same path as Admit, leaving a dangling pointer: future dedup hits would surface `AdmissionDropReason::Duplicate` with an `existing_engram_id` that can't be looked up anywhere. Fix: split `record_engram_origin` → `record_admitted` (full: hash + event_id, used by Admit) + `record_replay_only` (event_id only for AIRC origins, used by Quarantine). Replay protection via event_id stays — it's the load-bearing behaviour for `ReplayDetected`. Once PR-5+ adds a real quarantine store, the engram lands somewhere lookup-able and content_hash recording can come back via the same `record_admitted` path. 2. **IPC error type doc-TODO.** Current handler flattens typed `AdmissionError` to a `format!()` string, losing the variant info TS callers would pattern-match on. Added inline TODO comment pinning the intent to PR-5+ (return as JSON-discriminant via serde, or via a CommandResult error variant that preserves shape). Caller can still parse the prefix today. Tests: 9/9 admission_state pass (was 6, +3 new): - `quarantine_chat_origin_records_no_side_effects` — chat-origin quarantine is a pure no-op on the side-effect stores - `quarantine_airc_origin_records_event_id_only_not_content_hash` — airc-origin quarantine records event_id BUT NOT content_hash - `admit_airc_origin_still_records_both_content_hash_and_event_id` — regression-anchor for the refactor: Admit must STILL record both Card: continuum#1157. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the read-side of the engram thread. Admission state from PR-4
already accumulates engrams per-persona; this PR adds the typed query
API + IPC handler so callers can actually retrieve them.
What ships:
- `AdmissionState::recall_recent(limit)` — newest-first N engrams.
- `AdmissionState::recall_by_id(id)` — exact lookup.
- `AdmissionState::recall_by_keyword(keyword, limit)` — case-insensitive
substring, newest-first, limit-capped. Empty keyword = empty Vec
(caller-meant-to-skip semantic, not match-everything).
- `AdmissionState::recall_by_origin_kind(kind, limit)` — filter by
Chat / Airc / Tool / SelfReflection.
- `EngramOriginKind` discriminator enum + `From<&EngramOrigin>` impl —
exhaustive match means new origin variants force compile-time update.
- `cognition/recall-engrams` IPC handler — kind=recent|by_id|by_keyword|by_origin
+ standard params. Returns `{ engrams, count }` JSON. Defaults to
kind=recent + limit=10.
What this PR does NOT ship (deferred):
- ORM persistence (PR-6) — engrams still in-memory; queries hit the Vec.
API stays the same when the backing store swaps.
- Embedding-based / semantic recall (PR-7+) — keyword is substring only.
- Pagination cursors — limit is the only knob; recall_recent doesn't
expose offset (assumption: callers want the most recent slice).
Tests: 15/15 admission_state pass (was 9, +6 new):
- recall_recent_returns_newest_first
- recall_recent_respects_limit_above_and_below_count
- recall_by_id_finds_known_returns_none_unknown
- recall_by_keyword_case_insensitive_newest_first_with_limit
- recall_by_origin_kind_filters_to_requested_variant
- engram_origin_kind_covers_all_origin_variants (compile-time exhaustive)
Card: continuum#1162. Closes the engram thread substrate (PRs 1-5 +
fix #1157 all merged on canary). The next slice is ORM persistence
(PR-6) or TS-side wiring of the cognition/admit + cognition/recall
handlers from the chat path (separate slice).
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(forge): ForgeRecipe entity — kill hand-authored alloy files (#1164) Joel's CLAUDE.md §FORGE TEMPLATE ARCHITECTURE flagged the qwen3-coder v1 publish required ~6 manual touches because every forge needs the same set of fields hand-authored into a per-artifact .alloy.json. That's anti-architectural — the inputs aren't data, they're ad-hoc files. This design proposes: - ForgeRecipe Continuum entity — the authored INPUT spec (name/description/userSummary/tags/methodology/limitations, source.baseModel, stages with notes, calibrationCorpus, quantTiers, evaluationBenchmarks, priorMetricBaselines, hardware). Edited via standard Commands.execute('data/...'). - ForgeArtifact (= today's ForgeAlloy repositioned) — the foundry's OUTPUT, never authored. Carries recipe lineage + execution results + alloy hash + hardware verified + receipt + integrity attestation. - Foundry pipeline contract — forge/run IPC takes a recipeId + hw node + optional publish target, runs stages, persists ForgeArtifact. Native-truth + thin-SDK preserved (Rust executor, TS layer is just Commands.execute). - 5-phase migration: doc -> entity + storage -> foundry stub -> qwen3-coder migrate as proof -> deprecate hand-authored alloy. Same architectural shape as the engram thread (#1121): separate the authored input from the persisted output so each side's invariants are obvious. 6 open questions: naming (Artifact vs Alloy), stage notes shape, quant tier location, calibration corpus storage, baseline evolution, migration timeline for in-flight forges. Doc-only PR. No code changes. Phase 1 (entity + storage) is the next implementation slice. Card: continuum#1164. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(forge): lock in resolved consensus from claude-tab-2 review Folds claude-tab-2's substantive review on PR #1165 into the design doc. All 6 original open questions resolved + 4 additional positions pinned. Doc moves from "Draft for review" to "Reviewed — open questions resolved; ready for Phase 1". Resolved (all per consensus, no controversy): 1. Rename to ForgeArtifact (was: keep ForgeAlloy alternative) 2. Per-variant stage `notes?: string` (was: index-keyed sidecar alternative) 3. Top-level `quantTiers` (was: leave inside QuantStage alternative) 4. CorpusRef pointer on recipe; bytes elsewhere (was: maybe Corpus entity) 5. Pin priorMetricBaselines per-recipe (was: centralized library alternative) 6. Audit-then-decide on Phase 4 (was: pre-commit alternative) Additional pins added: 7. Foundry stage executors MUST be Rust (Python types as generated client, never authoritative). Locks in native-truth rule before Phase 2 can accidentally forge it the wrong direction. 8. CorpusRef.hashSha256 → contentHash with "sha256:<hex>" shape matching admission's content_hash format. Cross-domain consistency. 9. parentArtifactIds bidirectional lineage = v2+ (one-directional v1). 10. licenseStrategy enum = v2+ (when first license-mismatch hits). Continuum-wide pattern callout added to the TL;DR: input/output split is the architectural shape Continuum is converging on across pipeline subsystems (engram, forge, future ones), not just a forge-specific choice. Card: continuum#1164. --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (#1170) Implements Phase 1a of the design at docs/architecture/FORGE-RECIPE-AS-ENTITY.md (continuum#1165). Pure value types only. What ships: - ForgeRecipe entity (authored input): identity, prose, methodology, source, pipeline (stages opaque JSON for v1), calibration corpus, top-level quant tiers, evaluation benchmarks, hardware, lineage. - ForgeArtifact entity (foundry output): snapshot of recipe fields and execution outputs (forged_at_ms, duration, params_b, hardware_verified, alloy_hash, results/receipt/integrity opaque JSON for v1). Recipe lineage frozen so later recipe edits cannot retroactively rewrite what the artifact claims. - Supporting types: AlloySource, PriorBaseline, CorpusRef (canonical sha256 hex matching admission), QuantTier, BenchmarkDef, AlloyHardware, HardwareProfile. - ts-rs bindings to shared/generated/forge/ (9 files plus barrel). Tests: 26 passing covering serde roundtrip, minimal recipe with defaults, opaque blob preservation, partial artifact, recipe lineage immutability, ts-rs binding generation. Barrel-sync ratchet from PR #1137 still green. Phase 1b: rename existing TS-side ForgeAlloy to ForgeArtifact (15 files, separate slice). Phase 2: typed RecipeStage enum and typed results/receipt/integrity. Phase 3: entity registry plus forge/run IPC. Card: continuum#1169. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct (#1164 Phase 1b) (#1171) Per the consensus on continuum#1165 (design doc), the existing single-entity 'ForgeAlloy' name splits across two roles: - 'ForgeRecipe' (the authored input — what stages, prose, methodology, hardware target). All 14 stage-element widget JSDoc references update here: 'Maps 1:1 to ForgeAlloy XStage schema' becomes 'Maps 1:1 to ForgeRecipe XStage schema', and 'Each ForgeAlloy stage type' becomes 'Each ForgeRecipe stage type'. The stage widgets are recipe-authoring UI; stages live on the recipe side. - 'ForgeArtifact' (the foundry output — what got measured, hardware verified, alloy hash, publication receipt). FactoryStatsWidget's 'X / Y models have an alloy' panel relabels to 'ForgeArtifact' because the panel counts published artifacts, not authored recipes. Pure rename — no behavior change. The Python forge_alloy/types.py is untouched (Phase 2 ports those types to Rust as the source of truth); TS code only references the entity names in JSDoc + UI labels, never imports them as types. Validation: - grep ForgeAlloy in src returns 0 results - npm run build:ts passes clean - Hooks ran without --no-verify Card: continuum#1170 (PR #1170 was Phase 1a; Phase 1b card is created per the airc queue lane named 1170-pr-phase1b — the CI auto-close will land on whatever issue # this PR opens against). Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1178) Per codex-main report at AIRC 15:54:52Z 2026-05-14: every npm install in a fresh agent lane was pulling ~3.9GB of voice/avatar models even though the lane is purely for code changes. Wasted 30s+ of install time + GB of disk per worktree. Today I had to clean ~100GB across the lanes I'd spawned. Fix: a small wrapper scripts/maybe-download-models.sh that the postinstall calls instead of `npm run worker:models` directly. Skip conditions (any one): 1. CONTINUUM_SKIP_MODEL_DOWNLOAD=1 in env (explicit override) 2. PWD contains .airc-worktrees (auto-detect agent lane) 3. CI=true OR GITHUB_ACTIONS=true (CI runners don't need bytes; tests download on demand) Otherwise delegate to the original download-voice-models.sh, preserving its non-fatal contract (failed download just warns, install continues). Validation: - Manually invoking the wrapper from the lane prints the skip notice ("airc lane worktree detected (PWD=...)"). - CONTINUUM_SKIP_MODEL_DOWNLOAD=1 from /tmp prints "explicit override". - CI=true from /tmp prints "CI environment detected". - Real npm install in this lane: 7s, no download (vs ~50s+download before this PR). Forcing a download in a lane: `unset CONTINUUM_SKIP_MODEL_DOWNLOAD && cd /path/outside/.airc-worktrees && npm run worker:models`. Card: continuum#1173. Issue: continuum#1172. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#1164 Phase 3) (#1180) Phase 3 of continuum#1164 (design at FORGE-RECIPE-AS-ENTITY.md). TS-side entity classes that wrap the Rust ts-rs types from #1170 (Phase 1a) + register both with the data daemon's EntityRegistry so callers can CRUD forge recipes + artifacts via the standard data/* commands. What ships: - src/system/data/entities/ForgeRecipeEntity.ts — class extending BaseEntity, mirrors the ForgeRecipe Rust shape with field decorators (TextField, JsonField, NumberField). validate() checks required fields. Collection: 'forge_recipes'. - src/system/data/entities/ForgeArtifactEntity.ts — class extending BaseEntity, mirrors ForgeArtifact. ForeignKeyField on recipeId + unique-indexed alloyHash for content-addressable lookup. validate() checks lineage + execution-time fields. Collection: 'forge_artifacts'. - EntityRegistry.ts — imports both entity classes, instantiates each during initializeEntityRegistry() so the decorators register metadata, then registerEntity() with the collection name. Same pattern as the existing entity bulk. - shared/generated/entity_schemas.json regenerates with the two new collections (sha goes from 8cf44380640f to d5c1cff2a1ed6a6c, entity count 55 -> 57). Field naming subtlety: Rust 'version: string' (semver) collides with BaseEntity 'version: number' (ORM row version). Renamed to 'recipeVersion: string' on the entity to avoid the conflict + leave both cross-layer fields workable. Doc-comment notes the drift; Phase 2+ may rename the Rust field for cross-layer alignment. Validation: npm run build:ts clean. Hooks ran without --no-verify. Phase 4 (next slice): forge/run IPC handler that takes a recipeId, runs the foundry pipeline, persists the artifact via data/* commands. Card: continuum#1180. Co-authored-by: Test <test@test.com>
Co-authored-by: Test <test@test.com>
ForgeModule + forge/run IPC handler. v1 stub: takes a ForgeRecipe + optional hardware_node label, returns a synthesized ForgeArtifact with the recipe lineage frozen + a sha256:stub-<id> alloy_hash marker. No models loaded, no stages executed, no HF publishing — Phase 5+ wires the real foundry executor. Caller persists the returned artifact via standard data/upsert against the forge_artifacts collection (Phase 3 #1180 wired the entity registration). What ships: - src/workers/continuum-core/src/modules/forge.rs — ForgeModule ServiceModule + synthesize_stub_artifact helper. - modules/mod.rs — pub mod forge. - ipc/mod.rs — register ForgeModule alongside the existing module bulk. Tests: 6 covering recipe lineage, distinct artifact id, canonical sha256:stub- hash format, hardware_node echo, empty hw_verified when no hw_node, Phase 5+ fields all None on the stub. Phase 4 stub semantics — this PR explicitly does NOT claim to forge anything. It proves the IPC reachability + recipe -> artifact transformation shape end-to-end. Phase 5 replaces the stub with the real Rust foundry executor. Card: continuum#NNN. Co-authored-by: Test <test@test.com>
Co-authored-by: Test <test@test.com>
…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>
…er + RegionTelemetry (substrate prerequisite) (#1472) 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>
… + ASCII byte scan (#1477) PersonaCognitionEngine::is_mentioned is called once per message per persona per tick (from calculate_priority for mention scoring). Old path allocated THREE Strings per call: 1. content.to_lowercase() — sized to message length 2. self.persona_name.to_lowercase() — small but every call 3. format!("@{name_lower}") — small but every call For a busy room with N messages and M personas, 3*N*M Strings hit the allocator per tick. None of those allocations carry information across calls — the lowercase versions of (1) and (2) and the marker of (3) are functions of the message and the persona name; they're the same every time. Two changes: 1. Cache name_lower and mention_marker on the engine struct as Box<str> (immutable, no excess capacity vs String). Computed once in PersonaCognitionEngine::new — total cost paid at construction, not per tick. 2. Replace content.to_lowercase() + str::contains with a small contains_ascii_case_insensitive(haystack, needle) helper that walks haystack.as_bytes().windows(needle.len()).any() and uses u8::eq_ignore_ascii_case for case folding. Persona names in continuum are ASCII (Helper AI, Teacher AI, etc.) so ASCII case folding is sufficient for the @mention path. Non-ASCII bytes in chat content compare byte-for-byte and can't spuriously match an ASCII needle byte (u8::eq_ignore_ascii_case only folds bytes in the alphabetic ASCII range and compares others literally). Net: 3 allocs per call → 0 allocs per call (after the cheaper construction-time pre-compute). Tests: - 4 new helper tests pin contains_ascii_case_insensitive behavior: exact-case match, case-insensitive match, needle-absent rejection, empty-needle-matches-any, non-ASCII-doesn't-false-match-ASCII. - 1 new engine test verifies is_mentioned routes through the cached lowercase state for mixed-case inputs (Helper AI, helper ai, HELPER AI, @Helper ai). - All 9 cognition tests pass. Discipline: per Joel 2026-05-30 LCD-compounds principle — same code runs on Mac Intel and M5. Allocs you avoid on the slow path become M5 perceived snappiness. 3 String allocs per message * 10 personas * ~1000 messages a session = ~30,000 allocs eliminated end-to-end with zero behavioral change. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…l 8 call sites (#1478) The idiom `&s[..s.len().min(N)]` slices a `&str` by BYTE offset — when N lands inside a multi-byte UTF-8 sequence (emoji, accented letter, CJK, anything outside ASCII) the slice panics with "byte index N is not a char boundary." 8 sites across the codebase had this latent panic: src/persona/cognition.rs:153 debug! log of chat content src/inference/backends/mod.rs:359 eprintln of decoded LLM token src/inference/backends/mod.rs:521 trace of decoded token in step log src/modules/cognition.rs:1463 log of classify-domain input text src/modules/grid/handlers.rs:788 grid status job detail JSON src/modules/grid/node.rs:90 Reticulum hash display src/bin/diagnose_prefill.rs:135 prefill diagnostic current_decoded src/bin/diagnose_prefill.rs:194 prefill diagnostic decoded src/bin/diagnose_prefill.rs:236 prefill diagnostic decoded Real trigger: a chat message with an emoji at byte 28-31 hits the 30-byte truncation in cognition.rs and crashes the persona priority calculation path. Production tends to mask this because tracing's compile-time level filter strips most debug! invocations, but as soon as someone runs RUST_LOG=debug on real chat traffic or hits the eprintln paths in the inference backends, the crash surface opens. The grid handler one serializes into a JSON status response — if a process command line has a non-ASCII char near byte 120, the status endpoint crashes the daemon. Fix: introduce `continuum_core::utils::str_truncate::truncate_at_char_boundary(s, max_bytes)` that backs off to the nearest char boundary ≤ max_bytes. Loop runs at most 3 iterations (UTF-8 chars are bounded to 4 bytes), so cost is effectively free for log-truncate cases. Sweep all 8 sites to use it. Tests pin the contract: - ASCII truncation matches the pre-fix idiom (back-compat for ASCII) - Multi-byte codepoint (👋 U+1F44B, 4 bytes) backs off correctly - Two-byte codepoint (é U+00E9, 2 bytes) backs off correctly - Empty input + zero max + needle-larger-than-haystack edges - Brute force: never panics for ANY (s, n) over mixed-script samples (emoji + Korean + Japanese + accented latin) Per Joel 2026-05-30 "every error is an opportunity to battle harden": fixing the immediate cognition.rs panic is half the work. The other half is centralizing the safe primitive so future contributors reach for the panic-free version by default. The helper lives in utils/ alongside audio.rs and params.rs — same pattern. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…s to utils (#1479) The evaluator pre-response gate calls is_persona_mentioned once per message per persona per tick. The previous implementation allocated up to 9 Strings per call: 1. message_text.to_lowercase() — sized to message length 2. persona_display_name.to_lowercase() — small but every call 3. persona_unique_id.to_lowercase() — small but every call 4. format!("@{name_lower}") — @mention marker 5. format!("@{uid_lower}") — @uid marker 6. format!("{name_lower},") — name-then-comma marker 7. format!("{name_lower}:") — name-then-colon marker 8. format!("{uid_lower},") — uid-then-comma marker 9. format!("{uid_lower}:") — uid-then-colon marker None of those allocations carry information across calls — they're all pure functions of the per-call inputs that the previous code computed eagerly to feed `str::to_lowercase().contains()` / `.starts_with()`. This commit does two things: 1. Promotes the contains_ascii_case_insensitive helper out of persona/cognition.rs into shared `utils::str_case` (now alongside utils::str_truncate from #1478). Adds a sibling starts_with_ascii_case_insensitive for prefix-match callers. Same zero-alloc semantics; ASCII fold via u8::eq_ignore_ascii_case covers the persona-name path which is always ASCII. 2. Rewrites is_persona_mentioned to use the shared helpers plus two small internal helpers (has_at_mention_of, starts_with_then_separator) that scan bytes directly. No String/format!/to_lowercase per call. Performance: 9 allocations → 0 per call. is_persona_mentioned is on the full_evaluate hot path; full_evaluate runs in the sleep-mode/rate-limit/social gate per message per persona per tick. For a busy room with 5 personas active and 200 messages routed through full_evaluate per minute, that's ~9000 allocations/minute eliminated end-to-end, with zero behavioral change. Tests: 29 affected pass (14 mention_detection unchanged + 11 new str_case + 4 cognition engine). The mention_detection tests pin the exact pre-fix semantics (case-insensitive @mention, direct-address-at- start with comma/colon, empty-uid handling, substring-but-no-@ rejection, etc.) so any regression would surface immediately. Discipline: per Joel 2026-05-30 "if persona cognition can work on an intel Mac it can work on anything" — the evaluator gate is exactly the per-tick hot path that determines whether the chat experience feels responsive on Mac Intel. Same code runs on M5; cycles saved here cash in there. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…1480) * fix(ci): canary tag default for install-smoke + fail-loud precheck Two complementary changes, both architecturally driven by Joel 2026-05-30: "We don't need to rebuild all docker obviously until we go into main. Takes a lot of machines. ... Fix properly. What broke, what is the long term goal." What broke: PR #1476's avatars-context fix succeeded but install-smoke still failed at 25m45s. The 'pull pr-N image, silently fall back to local build if missing' chain meant that for ANY PR where the dev hadn't run scripts/push-current-arch.sh, install.sh's `compose pull 2>/dev/null || warn ... will build locally` slipped into `compose up` → `docker build` → `cargo build --release` → timeout. That's the wrong default in two dimensions: per-PR docker rebuilds aren't worth it at the canary level (would consume many machines per PR), and the silent downgrade hides the actual issue (image missing) behind a 25-min compute burn. Long-term goal: the docker build is bloated by Node-legacy chat surface that the Rust-core / thin-Node-client extraction will remove. Once that's done, builds are small enough that per-PR images become viable. Until then, canary PR install-smoke validates the install PATH against canary's binary; the BINARY validation runs at main promotion when fresh images get built. Two changes: 1. .github/workflows/carl-install-smoke.yml — default to :canary for every PR run (and manual triggers). The previous logic interpolated to pr-${PR_NUMBER} for PRs, which silently required an image that the canary-stage workflow shouldn't depend on. workflow_dispatch `image_tag` input still works for the rare explicit pr-N case (binary regression debug, historical canary check, etc.). 2. scripts/ci/carl-install-smoke.sh — add a pre-flight check that verifies all 4 required image variants (continuum-core-vulkan, node-server, widget-server, model-init) exist at the resolved tag. If missing, fail-LOUD with a concrete diagnostic ("dev push pipeline didn't publish, run scripts/push-current-arch.sh") instead of silently falling through to install.sh's local-build path. The CARL_ALLOW_LOCAL_BUILD=1 escape hatch is preserved for explicit build-path debugging. Net effect: - canary PRs (the common case) → tag :canary → images exist → install smoke runs against canary's binary in normal time. - canary images somehow missing (real bug) → fail-LOUD with actionable message, not silent 25-min timeout. - main-promotion runs and explicit pr-N tests → still work via workflow_dispatch input. The avatars-context fix from PR #1476 is NOT included here — it's a separate concern (the docker-compose dangling line); PR #1476 lands that piece. This commit fixes the CI-side silent-downgrade pattern. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ci): only gate install-smoke precheck on heavy Rust image First iteration of the precheck required ALL 4 images (continuum-core- vulkan, node-server, widget-server, model-init). Initial run on this PR (#1480) revealed canary has continuum-core-vulkan published but the lighter TS sidecar images (node-server, widget-server, model-init) aren't always at the canary tag — the dev push pipeline publishes the Rust slice on different cadences than the TS slices. Per Joel 2026-05-30: "node-server / model-init / widgets ... build in under a minute on either arch." Those local builds DON'T blow the 25-min timeout that triggered the original failure mode. So gating the smoke on all 4 images is over-strict — it fails the gate for the common case where canary's Rust is fresh but the TS sidecars aren't yet published at that tag. Refinement: precheck gates only on continuum-core-vulkan (the heavy one whose local build is the 25-min cargo build --release). The lighter TS sidecars are documented as "pulled if present, built locally if not" — install.sh's existing compose-pull-then-build fallback is fine for those because their local build is fast. This restores the intended semantic: catch the SLOW silent fallback (Rust source build) and fail-loud; let the FAST sidecar fallback through as install.sh always did. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
#1481) continuum-core's Dockerfile creates /root/.continuum/sockets at image build time, but docker-compose.yml mounts the host's ~/.continuum onto /root/.continuum at container start. The mount overlays the image's directory tree — the sockets/ subdir created at build is invisible inside the running container. continuum-core then tries to bind its IPC socket at /root/.continuum/sockets/continuum-core.sock, which fails with "IPC server error: No such file or directory (os error 2)" because the parent dir doesn't exist. Symptom: continuum-core never goes healthy → node-server's depends_on (condition: service_healthy) fails → docker compose up exits 1 with "dependency failed to start: container continuum-core-1 is unhealthy". Concrete trace from canary install-smoke for PR #1480 today: 17:40:25 — All 28 modules initialized, tick loops started 17:40:25 — ❌ IPC server error: No such file or directory (os error 2) 17:40:26 — Container Error / Waiting → Healthcheck never passes install.sh exits at "start support services" phase This bug has been silently blocking install-smoke for any docker-stack- touching PR; the previous 25-min cargo-build timeout was masking it because the install never got far enough to discover the socket issue. Now that PR #1480's precheck + canary-default routing makes the run fast, the underlying problem surfaces in 3 minutes with a clear error. Fix: pre-create the host-side directory tree (sockets/, jtag/data/, jtag/logs/) BEFORE compose up. This way the bind mount delivers a populated /root/.continuum to the container and continuum-core can bind its socket on first start. This is install.sh-side, not Dockerfile-side, because the mount is the overlaying layer — image-build mkdirs are hidden by the bind. The canonical fix is to mkdir on the host (which is what gets mounted). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…everything to a module is a command (#1482) Crystallizes the architectural conversation 2026-05-30: continuum's unit of capability is a MODULE (package.json + manifest + daemon + commands + tests). The kernel has zero privileged operations — Commands, Events, Lifecycle, Logger, Session, Health, and nothing else. Every other concern (chat, data, airc, ai, generator, audit, ci, install, persona, inference) is a module that loads on top. Key design decisions documented: - Module = unit of publication. Replaces the per-command npm packaging in SHAREABLE-COMMAND-MODULES.md with one-level-up grouping. Atomic install/uninstall; a module's commands cannot ship without their daemon, the daemon cannot ship without its tests, etc. - Two addresses per command: kernel name (chat/send — stable, routing) and package identity (@continuum-modules/chat@1.4.0 — versioned, distribution). Different audiences, different stability guarantees. - The kernel surface is six primitives, period. Commands + Events from UNIVERSAL-PRIMITIVES.md, plus Lifecycle + Logger + Session + Health to support module load/unload/health and security context. Everything else is a module. - Composition via the Commands kernel in BOTH languages. Rust gets a continuum_core::commands::execute mirror of TS Commands.execute. Same Map<&str, Box<dyn Command>> lookup; four transport modes (Rust→Rust direct, Rust→TS IPC, TS→Rust IPC, either→remote grid hop). Caller writes the same call regardless. - Four cell return shapes (Value, Handle, Stream, Lambda) are the composition vocabulary, lifted from the cell-processor design into the kernel itself. Handles enable hot-path cross-module state without copying (a tentative answer to the §13.1 open question). - ServiceModule IS the Rust daemon. The MODULE-CATALOG.md substrate runtime modules and the packaging-shell modules described here are the same concept viewed from two angles — runtime vs distribution. The daemon owns state; commands are stateless doors; events are fanout. - Trust through tests is the AI-to-AI module exchange protocol. A module ships with unit + integration + trust suites. Recipients verify behavior by execution, not signature. Mesh distribution becomes safe: any .tgz/.wasm that passes the trust suite is OK to install regardless of provenance. - Pure-Rust modules for built-ins (compiled into kernel binary). WASM Component modules for shipped + third-party + per-user (process-isolated, cross-platform, true runtime install/uninstall). Same Rust source can target either; choice is install-time, not authoring-time. - airc is just another module. Wraps the messaging substrate as @continuum-modules/airc with commands (airc/send, airc/join, …) and events (airc:message:received, …). Chat module composes airc via the kernel rather than importing an airc SDK. Composition is uniform with all other cross-module interactions. - The recursive bootstrap: generator, audit, CI, installer — all modules with their own commands. generate/module, audit/anti-patterns, ci/run, module/install, module/uninstall. The generator can generate itself. The system describes itself in its own terms. - AI-workflow protocol falls out: discover via commands/list, learn via commands/help, create via generate/module, verify via module/test, share via module/publish. No out-of-band knowledge required; the kernel surface is small enough to hold in mind; everything else is discoverable through the kernel. - Migration path is per-command (RustBackedCommand pattern from #1198) AND per-module (this document). Source-of-truth flip from dual TS-spec + Rust-handler to Rust-handler-as-spec is anticipated but out of scope for the immediate work. Open questions explicitly left for resolution as we accumulate usage: - (§13.1) Hot-path cross-module state — leaning toward cell handles (option 4) because it's the same primitive as everything else. - (§13.2) WASM Component Model surface — what types cross the boundary, how the substrate's cadence flows through, the kernel's WASM host shape. Real design work, deferred until we hit it. The document supersedes SHAREABLE-COMMAND-MODULES.md at the module level, references CBAR-SUBSTRATE-ARCHITECTURE.md as the runtime floor, references MODULE-CATALOG.md as the per-concern inventory, references UNIVERSAL-PRIMITIVES.md as the kernel's two foundational primitives, absorbs the recommendations from COMMAND-ARCHITECTURE-AUDIT.md as authoring rules, and keeps GENERATOR-OOP-PHILOSOPHY.md load-bearing. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…uting transports (#1483) First execution of the architecture in PR #1482 (MODULE-ARCHITECTURE.md): the kernel composes routing decisions by walking a chain of interceptors before falling back to local Rust dispatch and then to TypeScript. No transport is special at the kernel level — grid, airc, future mesh transports, future caching layers all sit behind the same trait and the same dispatch loop. What broke before: the TS-side `CommandDaemon` grew a `_gridInterceptor` shim on the singleton specifically to hop work over to the grid before local dispatch. Same pressure now applies to airc, and any future transport (mesh, tower-relay, etc.) would re-bake the kernel each time. This commit generalizes: the kernel knows "walk a list, fall through when no one bites"; transports register themselves. Three pieces land together: 1. `runtime::command_interceptor::CommandInterceptor` trait with `InterceptorOutcome::{Handled, Decline}`. Implementations decide per call whether to take the command, pass, or fail. `Err` aborts the chain immediately — no silent fallthrough on error, per the standing `[[every-error-is-an-opportunity-to-battle-harden]]` rule, because silent fallthrough would hide exactly the routing bugs interceptors exist to surface. 2. `runtime::airc_interceptor::AircInterceptor` — stub form: declines cleanly when no `aircPeer`/`aircRoom` param is present (so existing callers see zero behavior change), fails loud with a concrete pointer to MODULE-ARCHITECTURE.md §7.1 when a caller actually requests airc routing. The fail-loud is the design: a caller who writes `aircPeer` today learns immediately that the transport isn't ready, rather than getting silent local dispatch that masquerades as airc success. Replace the `Err` body with a call into `@continuum-modules/airc`'s send-command primitive when the airc module ships. 3. `runtime::command_executor::CommandExecutor` extended with: - `interceptors: Vec<Arc<dyn CommandInterceptor>>` field - `with_interceptor(...)` builder for wiring at init - `interceptor_count()` diagnostic for kernel/health + tests - `execute()` rewritten to walk the chain BEFORE the existing ModuleRegistry → TS-bridge fallthrough Dispatch order, top to bottom, single primitive: 1. Interceptors (insertion order; first Handled wins; Err aborts) 2. Local Rust ServiceModule via ModuleRegistry::route_command 3. TypeScript via Unix socket (CommandRouterServer, unchanged) Adding a transport is now adding an interceptor; no kernel changes needed. The trait is the seam. 16 tests pin the contract: - empty chain returns None (falls through to local dispatch unchanged) - all-decline walks every interceptor in insertion order - first Handled short-circuits later interceptors (assertions on the number of later calls, not just the result, to catch silent over-walks) - Err aborts the chain with no silent fallthrough (interceptors after the error are NOT consulted; the error carries the interceptor name for diagnosis) - name() survives the dyn trait boundary for logs + telemetry - AircInterceptor declines without airc target params (back-compat guarantee that lets it be safely installed by default later) - AircInterceptor fails loud with explicit aircPeer or aircRoom (the error names the target so callers can correlate logs and points at MODULE-ARCHITECTURE.md) - CommandExecutor + AircInterceptor compose without breaking existing TS-bridge fallthrough on non-airc commands The global `init_executor` is intentionally NOT changed in this PR — the AircInterceptor is available, the wiring mechanism is in, but the global chain stays empty so this PR is purely additive. A follow-up PR can auto-install the airc + grid interceptors at init time once the grid interceptor is wired. This is the first execution of MODULE-ARCHITECTURE.md (PR #1482) and the foundation everything else in the migration sits on. Per Joel 2026-05-30 "let's go" + "commands call commands, cross boundaries, even towers and into the p2p mesh" — this is the seam where towers and the p2p mesh plug in. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…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>
* fix(registry): qwen3.5-4b-code-forged GGUF filename case (Q4_K_M)
The published HF GGUF sibling uses the canonical-uppercase suffix
Q4_K_M; the registry was carrying lowercase q4_k_m which 404s on
HuggingFace's case-sensitive resolve path. Caught during a model
download on 2026-05-30 — every host that pulled this entry was
silently failing the pre-pull and falling back to a missing-model
runtime error.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cognition): MacIntelMetalDiscrete tier — Mac Intel + Metal classifier branch
Adds HwCapabilityTier::MacIntelMetalDiscrete for hosts whose Metal
device is a discrete AMD or integrated Intel UHD card on a Mac Intel
CPU — physically distinct from Apple Silicon (separate VRAM, Metal 2
only, no neural engine, llama.cpp Metal shaders unreliable on this
path).
Splits the metal branch of host_capability_probe::detect_host_capability
into metal_tier(cpu_brand, device_name, total_mem_mb, platform) which:
- routes Apple-Silicon-brand CPUs to the existing UMA buckets with
TargetSilicon::UnifiedMemory (unchanged),
- routes Intel-brand CPUs to MacIntelMetalDiscrete with
TargetSilicon::Gpu (separate VRAM, not unified),
- loud-fails with ProbeError::UnknownGpuDevice on any other CPU
brand so the operator adds a tier rather than getting silent
M1Uma16Gb routing.
Background: 2026-05-30 inference experiment on MacBookPro15,1 (Intel
i7-8850H + AMD Radeon Pro 560X 4GB + 32GB RAM) showed the previous
classifier silently buckets this host as M1Uma16Gb purely because
total_mem_mb >= 14000 — the cpu_brand check only branched on M2 vs
the M3/M4/M5 family. That mis-tier led the resolver to pick the 4B
forged model which then ran on the Metal-AMD shader path and emitted
multilingual gibberish at 0.8 tok/s with hundreds of nil tensor
buffer errors per generation. The classifier patch is the precondition
for fixing the resolver: the resolver now has a tier name to refuse
4B routing on, and a downstream registry/tier-policy change can map
MacIntelMetalDiscrete to a smaller GGUF (or CPU-only inference, or
grid-share to a peer).
Test override knob (QWEN35_4B_GPU_LAYERS in the throughput test) lets
operators isolate Metal-AMD breakage from CPU-baseline behavior
without editing source — n_gpu_layers=0 forces llama.cpp's CPU path
for parity comparison.
Adds 4 unit tests pinning the new classifier behavior:
- metal_tier_routes_apple_silicon_to_uma_branch
- metal_tier_routes_mac_intel_amd_to_new_tier_not_silent_m1
- metal_tier_routes_mac_intel_uhd_to_same_tier
- metal_tier_loud_fails_on_unknown_cpu_brand
ts-rs regenerated HwCapabilityTier.ts with the new "mac_intel_metal_discrete"
variant. Adding the variant is purely additive — no exhaustive match
sites need updating.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(registry): mac_intel_discrete tier — runtime + install-time policy
Wires the Rust HwCapabilityTier::MacIntelMetalDiscrete classifier (shipped
in 60d440029) through to the model-selection path that actually picks a
default chat model.
src/shared/ModelRegistry.ts:
- Widens Tier from 'mba'|'mid'|'full' to also include 'mac_intel_discrete'.
- Adds tierFromHost(ramGB, hwTier?) which overrides RAM-based bucketing
when hwTier === 'mac_intel_metal_discrete'. tierFromRamGB stays as a
pure-RAM fallback (existing CandleAdapter + seed callers unchanged).
src/shared/models.json:
- Adds tiers.mac_intel_discrete with default_chat=qwen3.5-0.8b-general.
- Adds auto_download.by_tier.mac_intel_discrete=[qwen3.5-0.8b-general]
so model-init pulls the right GGUF.
install.sh:
- After the RAM-based tier block, probes machdep.cpu.brand_string via
sysctl. Intel brand → CONTINUUM_TIER=mac_intel_discrete + smaller
NATIVE_RESERVE_MIB (5GB instead of 12GB primary).
- Adds the matching case branch in PERSONA_MODEL selection so docker
model pull / model-init fetch the 0.8b forged GGUF.
The 0.8b forged GGUF at continuum-ai/qwen3.5-0.8b-general-forged is
already the destination for MBA tier — same registry entry, no new
HF artifact required. (Note: 2026-05-30 the actual HF GGUF siblings
for the 0.8b/2b forge repos were missing — that's task #49 in the
broader thread, not blocking this tier-policy commit.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(persona): single-pass service_cycle hot path
The per-persona service_cycle runs every 3-10s and is called once per
active persona. Three small wins, no semantic change, 9/9 existing
tests pass.
1. ChannelRegistry::service_cycle — collapsed get + get_mut to single
get_mut in both the urgent and non-urgent loops. NLL handles the
borrow reuse without the old double-lookup workaround. Saves one
HashMap probe per checked domain per tick (8 lookups → 4 in the
urgent loop, 6 → 3 in non-urgent).
2. ChannelRegistry::status — folded the per-channel Vec build and the
total_size / has_urgent_work / has_work rollups into a single
walk over DOMAIN_PRIORITY_ORDER. Previously: 1 unsized-collect Vec
walk to build the channel list + 3 more iter().sum() / iter().any()
passes over the result. Now: 1 walk with pre-sized
Vec::with_capacity(DOMAIN_PRIORITY_ORDER.len()), no Vec growth, no
extra passes. status() is called every tick (urgent and non-urgent
branches alike), so the per-tick savings compound across the
active persona fleet.
3. host_capability_probe::metal_tier — dropped cpu_brand.to_lowercase()
alloc on the Intel-detection branch. Intel CPU brand strings
reliably ship with capital "Intel" (e.g. "Intel(R) Core(TM) i7-8850H
CPU @ 2.60GHz"); literal substring match avoids the String
allocation on every boot probe. Boot path, not hot — done for code
hygiene + worked example of the discipline.
The discipline this lands: per Joel 2026-05-30, Rust is the work; Node
is the shell; the LCD machine (Mac Intel today, phones eventually) is
the forcing function that prevents the codebase from quietly consuming
the M-series headroom. Same code runs on both; cycles you don't burn
on the slow path become perceived snappiness on the fast one.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(inference): honor CONTINUUM_TIER=mac_intel_discrete with n_gpu_layers=0
Closes the runtime end of the Mac Intel chain. Prior commits shipped
the classifier (60d440029), the install-time tier policy (7b3b8e086),
and the hyper-efficiency pass (334f699c1) — but LlamaCppAdapter::load
still hardcoded n_gpu_layers=-1, so even with mac_intel_discrete set
in the env the runtime would route the load into the broken Metal-AMD
shader path.
This commit reads CONTINUUM_TIER and forces n_gpu_layers=0 when the
tier is mac_intel_discrete. install.sh's hardware probe sets the
env at install time; the runtime trusts that contract and avoids
the broken Metal path.
The 2026-05-30 evidence on MacBookPro15,1 / AMD Radeon Pro 560X:
Metal-AMD path (n_gpu_layers=-1) → 0.8 tok/s + multilingual
garbage + hundreds of nil tensor buffer errors per generation.
CPU path (n_gpu_layers=0) → 1.1 tok/s + COHERENT English.
Net: CPU is FASTER and CORRECT than the broken Metal-AMD path
on this hardware. With qwen3.5-0.8b on the same CPU we'd
expect ~5-6 tok/s = usable interactive chat.
Follow-up: native Rust probe at adapter construction so the
runtime doesn't depend on the install-time env-var trust chain
(currently CONTINUUM_TIER is the cross-boundary signal between
install.sh and the Rust runtime). Tracked as task #51 in the
session task list; ties into resolving the parallel
governor::classify_silicon bug (task #52) where the same
"has_metal=true → Apple Silicon" misclassification still lives.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(persona): O(N) heapify in drain_frame instead of O(N log N) extend
PersonaInbox::drain_frame drains the heap into messages + retained,
then re-loads retained into the heap so out-of-window items survive
the drain. The previous heap.extend(retained) pushed N items at
O(log N) each = O(N log N) total. Since the heap is empty at that
point (the while loop drained it), BinaryHeap::from(Vec) does
in-place heapify in O(N) (sift-down construction per std docs).
Real cost on a busy persona: anchor matches few cross-room messages,
retained = nearly the full N. The old path paid log N per item to
rebuild; the new path pays one O(N) heapify pass.
23/23 existing inbox + admission tests pass — pure perf change, no
semantic shift (heap-from-Vec produces a valid max-heap regardless of
input Vec order, identical to repeated push).
Discipline: same code runs on Mac Intel and M5 per Joel 2026-05-30
"optimizing for a low quality computer is HOW you get a fast machine
on m5." A 500-message inbox drains in O(500) instead of O(500*9) =
~9× less heap work per drain. The savings on Mac Intel are invisible
to the user; on M5 they compound into the perceived snappiness ceiling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…he kernel chain (#1484) Bridges the existing `modules/grid` routing into the `CommandInterceptor` trait from PR #1483 and wires the chain [AircInterceptor, GridInterceptor] into the production `init_executor` at startup. Capability-based remote routing now works for ANY command, not just explicit `grid/send` invocations. # What lands 1. **Refactor: `handle_send` → `dispatch_to_node`.** Pulls the send-frame dance out of the explicit `grid/send` handler into a public `dispatch_to_node(state, node, command, params)` primitive. `handle_send` becomes a thin wrapper that parses params then delegates. Boy-scout move per Joel "do not half-ass it": one dispatch path, two callers (explicit `grid/send` + implicit interceptor), zero duplication. 2. **`GridState::try_route_remote`.** The new kernel-facing primitive. Applies `GridRouter::route` policy; if Local, returns `Ok(None)` so the interceptor declines; if Remote, dispatches via `dispatch_to_node` and returns `Ok(Some(result))`. Errors propagate per the `CommandInterceptor` contract (no silent fallthrough on Err, per `[[every-error-is-an-opportunity-to-battle-harden]]`). 3. **`GridModule::state()`** public getter. Lets the kernel build the `GridInterceptor` over the same `Arc<GridState>` the module itself runs on. No state duplication; no second router instance. 4. **`runtime::grid_interceptor::GridInterceptor`.** Wraps `try_route_remote`, implements `CommandInterceptor`. Lives in `runtime/` (not `modules/grid/`) because the interceptor TRAIT is a runtime concept — every transport interceptor sits behind it. GridInterceptor's *implementation* delegates to grid; that's just a dependency the runtime takes on the grid module, mediated by the public `state()` handle. 5. **`init_executor_with_interceptors`.** New entry point that takes a `Vec<Arc<dyn CommandInterceptor>>`. The back-compat `init_executor(registry)` shims to it with an empty chain so existing callers (tests, bin tools) keep working. 6. **Production wire-up in `ipc::start_server`.** Replaces `init_executor(registry)` with `init_executor_with_interceptors(registry, [AircInterceptor, GridInterceptor])`. Chain order is policy: - AircInterceptor first: explicit aircPeer/aircRoom targeting takes precedence over grid's capability-based remote routing (per MODULE-ARCHITECTURE.md §5). - GridInterceptor next: `routingHint` / `nodeId` / capability-based commands hop to a peer before the kernel tries local Rust dispatch. - Both decline cleanly when their routing decision is "local," so existing commands see zero behavior change. # Test plan 20 tests pass (the original 16 from PR #1483 plus 4 new GridInterceptor tests): - `name_is_stable` — name() survives the dyn trait boundary - `declines_when_router_picks_local` — no remote node + no hint → router picks Local → interceptor declines (chain falls through) - `declines_for_local_only_hint` — routingHint:"local-only" forces Local regardless of capability - `declines_when_target_node_not_in_registry` — explicit nodeId that doesn't resolve falls back to Local (existing GridRouter contract) Remote-routing happy-path test (open transport, send frame, recv response) lives behind a follow-up `tests/grid_interceptor_routes.rs` integration test that stands up a mock GridTransport. Wiring this unit-test surface against the real transport interface is non-trivial (GridConnection trait + mock channel pair); deferred to keep this PR focused. # What this PR does NOT do - Does NOT add cell return shapes (Value/Handle/Stream/Lambda from MODULE-ARCHITECTURE.md §5.1). Today's `CommandResult` enum (Json + Binary) is preserved. Cell shapes are a separate follow-up. - Does NOT migrate any command to the per-module package architecture from MODULE-ARCHITECTURE.md §2. The interceptor chain is the kernel foundation; migrations build on top. - Does NOT change the AircInterceptor's stub behavior — it still fails-loud on explicit aircPeer/aircRoom until the airc module ships its send-command primitive. # After merge Follow-up priorities: 1. `tests/grid_interceptor_routes.rs` — remote-routing integration test with a mock GridTransport. 2. Cell return shapes — extend `CommandResult` enum + thread through ServiceModule handlers + sketch the Handle protocol for hot-path cross-module state. 3. First module migration end-to-end (chat or the generator itself). # References - [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md) §5 (composition) and §7.1 (airc as just another module) - PR #1482 (architecture doc) - PR #1483 (CommandInterceptor trait + AircInterceptor stub) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…work + reserved Stream/Lambda (#1485) Lands the cell shapes from MODULE-ARCHITECTURE.md §5.1 as variants on `CommandResult`. Handle is the headline shape — the answer to §13.1 (hot-path cross-module state) and the pattern Joel called out 2026-05-30: "for long running commands like inference, hosting/inference/training/ORM — a handle returned by the first call, passed in for subsequent work. Always UUID for ids." # What lands 1. **`runtime::cell_shapes::HandleRef`** — typed reference to state owned by a specific module. Fields: `owner: String` (the producing module), `id: Uuid` (UUID per Joel's directive; ts-rs binds it as `string` on the TS side), `type_tag: String` (`"<module>::<TypeName>"` convention), `created_at_ms: u64` (mint timestamp for TTL + ordering). Constructors: - `HandleRef::with_id(owner, id, type_tag)` — producer minted the UUID first and stored state under it; pass the same UUID here. - `HandleRef::mint(owner, type_tag)` — convenience that allocates a fresh UUID for producers that don't need to know it upfront. 2. **`runtime::cell_shapes::StreamPlaceholder` + `LambdaPlaceholder`** — reserved variants. Returning either is a RUNTIME ERROR per the contract; the in-process and wire protocols (streaming frame format + correlation/backpressure/cancellation, lambda dispatch+merge) aren't designed yet. The variants exist so the enum shape is fixed before handlers begin migrating, and so ts-rs binds the placeholders for TS-side anticipation. `#[non_exhaustive]` makes future field additions non-breaking for external code. 3. **Extended `CommandResult` enum** with `Handle(HandleRef)`, `Stream(StreamPlaceholder)`, `Lambda(LambdaPlaceholder)`. The existing `Json(Value)` and `Binary { metadata, data }` ARE the Value cell shape under the taxonomy — kept under their legacy names so the 300+ existing handlers don't have to change. `#[non_exhaustive]` on the enum signals downstream crates that more variants may come. 4. **`CommandResult::to_json_value`** — projects any cell shape to a plain `Value` for callers that just want the JSON payload regardless of variant. Json/Binary return their payload, Handle serializes the HandleRef as JSON (the TS-side caller holds it and passes back), Stream/Lambda return their canonical protocol errors via the new `stream_protocol_error` / `lambda_protocol_error` helpers. 5. **`CommandResult::handle(owner, id, type_tag)`** constructor — takes a Uuid directly to match the "producer mints UUID, stores state, returns handle" pattern from Joel's note. 6. **Five existing match sites updated** to handle the new variants: `runtime::command_executor::execute_json` (delegates to `to_json_value`), `modules::cognition` cross-module dispatcher (same), `modules::grid::connection` wire encoder (same), `ipc::mod` IPC response encoder (same), `modules::sentinel::steps::llm` (treats Handle/Stream/Lambda as contract violations with explicit step errors — ai/generate is a one-shot completion, not a long-running session, so handles belong elsewhere). Two test panic sites updated to use `other => panic!(...)` for forward-compat. # Canonical use cases for Handle (per Joel) - **inference** — `ai/inference/start { model, prompt }` returns a handle; `ai/inference/poll { handle }` + `ai/inference/cancel { handle }` operate on the running session. - **training** — `training/run/start { recipe }` returns a handle; `training/run/progress { handle }` + `training/run/cancel { handle }` query and control. - **hosting** — `live/room/join { roomId }` returns a handle; `live/audio/publish { handle, frame }` operates on the joined session. - **ORM** — `data/transaction/begin` returns a handle; `data/transaction/exec { handle, query }` + `data/transaction/commit { handle }` thread the same transaction. The pattern works the same whether the producer is in-process, in a sibling module, or on a remote peer over grid/airc — Handle is a typed reference that travels through the existing `Commands.execute(name, { handle })` primitive. No kernel-level handle registry needed; each producing module manages the lifetime of its own handles internally. # Test plan (23 tests pass) cell_shapes::tests (7): - `handle_ref_with_id_preserves_uuid` — UUID survives constructor - `handle_ref_mint_generates_fresh_uuid` — successive mints distinct - `handle_ref_roundtrips_through_json` — serde round-trip - `handle_ref_id_serializes_as_string` — ts-rs/serde agree (`string` wire shape) so TS callers echo UUIDs cleanly - `handle_ref_owns_distinct_state` — different UUIDs ≠ equal - `stream_placeholder_roundtrips` — placeholder serde - `lambda_placeholder_roundtrips` — placeholder serde service_module::tests (8 new for CommandResult cell-shape integration): - `json_to_json_value_returns_original` - `binary_to_json_value_returns_metadata_drops_bytes` — bytes dropped; raw-byte consumers match on the variant directly - `handle_to_json_value_serializes_handle_ref` — TS gets the handle as JSON they can echo back - `stream_to_json_value_returns_protocol_error` — fail loud (named + points at doc), no silent degrade - `lambda_to_json_value_returns_protocol_error` — same - `command_result_handle_constructor_matches_handle_ref_with_id` — constructor produces the expected internal shape - `command_result_protocol_errors_have_stable_wording` — error prefixes are stable for callers matching on them - `handle_ref_round_trips_through_command_result_serialization` — end-to-end: handler → CommandResult → to_json_value → wire JSON → echo string → deserialize back → identical HandleRef ts-rs export verification (3): HandleRef, StreamPlaceholder, LambdaPlaceholder all generate clean TS bindings under `shared/generated/runtime/`. # What this PR does NOT do - Does NOT change any existing handler's return shape. The 300+ handlers still return Json/Binary; cell shapes are opt-in for new long-running commands. - Does NOT design the Stream or Lambda wire protocols. Variants exist with `#[non_exhaustive]` placeholders so future fields land non-breaking; returning either today is a runtime error. - Does NOT add a kernel-level handle registry — each producing module manages its own handle lifetimes internally per the design. - Does NOT migrate any command to use Handle. Inference, training, hosting, ORM migrations are follow-up PRs that adopt the pattern. # References - [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md) §5.1 (cell return shapes), §13.1 (hot-path cross-module state via cell handles) - PR #1482 (architecture doc) - PR #1483 (CommandInterceptor trait + AircInterceptor stub) - PR #1484 (GridInterceptor wire-up — capability-based remote routing) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…els (#1488) `cargo test` regenerates the TS bindings ts-rs declares via `#[ts(export, export_to = ...)]`, but the resulting files only land on canary if the author commits them. PR #1485 merged the Rust cell shapes (`HandleRef`, `StreamPlaceholder`, `LambdaPlaceholder`) but the generated `.ts` files weren't part of the diff — they only existed in my local working tree. That left consumers on canary unable to import `HandleRef` from `@shared/generated/runtime`. This PR adds those three files + reruns `npx tsx generator/generate-rust-bindings.ts` to refresh every barrel in one pass. Runtime and persona barrels both had stale indices from earlier merges that landed `.ts` files but not the `index.ts` updates that re-export them. # Diff scope - `shared/generated/runtime/HandleRef.ts` — new (cell shapes PR #1485) - `shared/generated/runtime/StreamPlaceholder.ts` — new (reserved cell shape per PR #1485) - `shared/generated/runtime/LambdaPlaceholder.ts` — new (reserved cell shape per PR #1485) - `shared/generated/runtime/index.ts` — re-export the three new types + 10 brain_region types that were already on canary as files but absent from the barrel (CadenceHint, ComputeClass, MemoryClass, PersonaLifecycle, PressureLevel, PressureProfile, PressureSignalKind, RegionId, RegionSignal, RegionTelemetry, SleepPhase, TickOutcome) - `shared/generated/persona/index.ts` — re-export `EdgeKind` + `EngramEdge` (already on canary as files; barrel was stale) - `shared/generated/index.ts` — master barrel switched runtime and system from `export *` to explicit lists because `PressureLevel` exists in both. Dedup rule: first seen wins (runtime), callers needing the system variant import it directly from `@shared/generated/system`. Both module lists below verified to cover every `.ts` file currently in their directories. # Why a single fixup rather than per-PR follow-ups The generator's auto-dedup + barrel-refresh runs all-at-once. Doing it once per drifted module would re-trigger the dedup each time and produce noisy diffs that each touch the master barrel. One pass gets the entire `shared/generated/` tree coherent with current Rust state. # Why this gap exists at all `generate-rust-bindings.ts` runs as part of `npm start` prebuild, but the script writes regenerated files to the working tree — it doesn't auto-commit them. If a Rust author lands a PR without first running the generator + committing the TS output, the bindings drift. A future follow-up could add a precommit check that fails loud when `ts-rs` output is dirty after build (similar to other generators). # Verification `npx tsx generator/generate-rust-bindings.ts` produces 535 types, runs to completion in under 10s (cargo cache warm), and emits no errors. The only warnings are the 8 known cross-domain duplicate type names that the generator handles automatically via the explicit-export strategy used here. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…nditions pinned (#1492) Per Joel 2026-05-30: "Each persona exists in its own threads." Plus: "Approaching moment of truth" (the headless-Rust integration test where Rust core runs chat + personas + inference without Node). Multi-persona chat lands on `InMemoryAircRealtimeStore` via `airc/realtime-publish`. Several personas publishing concurrently to the same room (and reading replay concurrently) is THE production scenario for the headless test. The four new tests pin the substrate's correctness invariants that the integration test will rely on. # Audit finding The store uses ONE module-wide `parking_lot::Mutex<AircRealtimeState>`. Every publish + every replay takes the same lock. That: - **Delivers correctness**: all state mutations are atomic; per-room Lamport monotonicity holds; replay sees consistent snapshots. - **Constrains throughput**: multi-room publishes serialize even though room state is independent. For 5–10 personas this is fine (mutex contention is sub-microsecond on uncontended in-memory ops). For 50+ personas it becomes a real bottleneck. Future refinement (flagged in the test docstring, NOT in this PR): shard the state by room_id (`DashMap<Uuid, Mutex<RoomState>>`). That unblocks multi-room throughput while keeping the same correctness contract. Not needed for moment-of-truth; the module-wide lock is the simplest substrate that meets requirements. # What's pinned (4 new tests, multi_thread tokio with 4 workers) ## `concurrent_publishes_to_same_room_lose_no_events_and_keep_lamports_contiguous` 64 concurrent personas publish durable events to GENERAL. Asserts: - every publish reports ok + stored_for_replay - final replay returns EXACTLY 64 events (no losses) - every published event_id appears EXACTLY once (no duplicates) - every publish-time timestamp (1..=64) appears in the replay (Lamport sequencing is contiguous — no gaps, no out-of-order under race) ## `concurrent_publishes_to_different_rooms_keep_independent_lamport_sequences` 20 publishes each to 3 rooms (GENERAL, CAMBRIANTECH, OTHER), all interleaved. Asserts each room's Lamport sequence is INDEPENDENT — room A's events don't bump room B's Lamport. The final cursor for each room is exactly PER_ROOM (20). Cross-room interleaving doesn't break per-room contiguity. ## `replay_during_concurrent_publish_observes_consistent_snapshot` 32 concurrent publishers + 8 concurrent replayers, all racing. Asserts: - each replayer observes a CONSISTENT subset (no torn reads — no duplicate events within one replay, no out-of-range timestamps) - after all publishes settle, a final replay returns exactly 32 events (no losses) - the final cursor.lamport == 32 (contiguous) ## `cursor_polling_during_concurrent_publish_never_loses_or_duplicates_events` 40 publishers spawn in the background; one consumer polls with `after_cursor` repeatedly, accumulating observed event_ids. After all publishes settle, one final drain catches anything the poll loop missed. Asserts: - NO duplicate event_ids in the observed set (cursor monotonicity preserved — never re-see an event that's already been seen) - every published event_id eventually observed (no losses) This is the canonical "consumer reads forward through a moving stream" pattern — chat clients, persona inbox subscribers, replay catchup on reconnect all use it. Cursor polling is the substrate's hot path for sustained multi-persona activity. # Tests (17/17 pass — 12 pre-existing + 4 new concurrency + 5 ts-rs) No regression. Pre-existing tests still pass through the same shared in-memory store. The new tests use real multi-threaded tokio runtime to actually preempt across OS threads — single- threaded tokio would silently serialize and pass even if the store had a race. # Substrate doctrine reinforced (the third consumer of the pattern) This is the THIRD module to get multi-persona concurrency tests this session (after chat in PR #1489 and data/query cursors in PR #1490). Each consumer follows the same template: > Every ServiceModule or substrate primitive that holds per- > resource mutable state under concurrent access must: > 1. Be PROVEN under multi-threaded tokio load (worker_threads=4) > 2. Have its invariants pinned by tests that would fail single- > threaded > 3. Use per-resource locks (`DashMap<Id, Arc<Mutex<State>>>`) > when scalability matters; module-wide locks are acceptable > when correctness is the priority and contention is low The airc store today uses the module-wide pattern (correctness- prioritized for moment-of-truth). The chat module's StubAircModule test infra in PR #1489 indirectly exercises this same store via the airc/realtime-publish command — so when the moment-of-truth test wires up chat + airc + personas, both layers' concurrency contracts are proven. # References - Memory: [[headless-rust-must-work-soon]] - PR #1489 (chat concurrency tests) - PR #1490 (data/query per-cursor mutex + concurrency tests) - PR #1487 (generator per-name lock + concurrency tests) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…trate work as authoring guide (#1493) Per Joel 2026-05-30: > "Let's make sure we have detailed designs for this command > infrastructure into modules and properly built from the ground > up by using our own generators." Existing docs cover the **doctrine** (MODULE-ARCHITECTURE.md), the **runtime contract** (CBAR-SUBSTRATE-ARCHITECTURE.md), and the **concerns catalog** (MODULE-CATALOG.md). What was missing: the **field manual** for a module author sitting down to write code today. This document codifies the substrate work from PRs #1483–#1492 into reusable shape: # What this manual covers - **§1 The system in one sentence** — Commands + Events + Persona, in Rust, with airc handling grid. The doctrinal reduction Joel named on 2026-05-30. - **§2 Substrate primitives quick reference** — ServiceModule trait, CommandRequest/Response envelopes, HandleRef + four cell shapes, HandleRef::expect_owned_by, CommandRequest::handle_id_or_legacy, interceptor chain, cross-module call pattern. Each with a code snippet pulled from the actual landed PRs. - **§3 Module Design Template** — the canonical mod.rs + types.rs shape every ServiceModule follows. What the GeneratorModule scaffolds; what humans fill in. Rules for ts-rs annotations, serde camelCase, optional field handling, executor injection for tests. - **§4 Concurrency doctrine** — per-resource locks (not module-wide), std::sync vs tokio::sync, the multi-thread test discipline (worker_threads=4), partial-failure semantics for dual-write composition. Pins the two real bugs caught this session (PR #1490 cursor race; PR #1487 generator same-name race) as doctrine, not anecdote. - **§5 Migration playbook** — Joel's "rethink, don't port" rule with a pre-migration checklist + substrate checklist + a worked example for chat/analyze (the next chat migration). - **§6 Generator usage** — how to scaffold a module via `./jtag generate/module`; v2 roadmap for the richer scaffold matching the Module Design Template. - **§7 Acceptance criteria** — the 7-point bar for "concurrency-clean, wire-clean, ready for the headless integration test." - **§8/§9 See also + PR references** — cross-refs to every substrate PR by surface, plus the existing architecture docs. # Why a field manual now The doctrinal docs answer the **why**. The catalog answers the **which**. Neither answers the **how**: where do I find the envelope API? what's the per-resource lock pattern? what shape does the generator expect? what counts as a concurrency stress test? The substrate is now coherent enough to be reduced to a single reference an author can read once and start writing clean modules from. # What this does NOT do - **Does NOT re-derive doctrine** — defers to MODULE-ARCHITECTURE.md for the architectural why. - **Does NOT re-survey the module space** — defers to MODULE-CATALOG.md for what modules exist. - **Does NOT change any code** — pure documentation, no Rust touched. - **Does NOT propose v2 of the generator** in this PR — flagged in §6.1 as a separate follow-up. This PR establishes the template the v2 generator will emit. # Follow-up PRs - **Generator v2**: emit modules matching the Module Design Template (types.rs scaffold, tests skeleton with concurrency primer, DESIGN.md scaffold, per-resource lock scaffold when --stateful). - **Per-module DESIGN.md pages** living next to mod.rs for each migrated module (chat, data, airc, generator). Each documents the module's role, command surface, state model, concurrency contract, kinks found. # Length + scope ~440 lines. Tight by design — a manual the author reads in one sitting before authoring, then references when stuck. The longer the manual, the less anyone reads it. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…/cursors, airc/realtime-store (#1495) Step 2 of the doc set Joel approved on 2026-05-30 ("Yeah let's do it. In order"): 1. ✅ Field manual codifying substrate (PR #1493) 2. ✅ Generator v2 emitting modules per the template (PR #1494) 3. **This PR**: per-module design pages for everything we've built 4. (Next) MODULE-CATALOG.md update marking which modules are alive in Rust Each doc follows the canonical 8-section template from the field manual (Role / Command surface / Cross-module deps / State model / Events emitted / Concurrency contract / Migration notes / Kinks found). # What this PR adds | Doc | Lines | Status of subject | |---|---|---| | `CHAT-MODULE.md` | 125 | chat/poll + chat/send shipped Rust (PR #1489); analyze/export still TS | | `GENERATOR-MODULE.md` | 127 | v1 + v2 (PRs #1487 + #1494) — recursive bootstrap | | `DATA-CURSORS-MODULE.md` | 164 | data/query-{open,next,close} migrated to HandleRef (PR #1490) | | `AIRC-REALTIME-STORE-MODULE.md` | 142 | In-memory store + 4 moment-of-truth concurrency tests (PR #1492) | | **Total** | **558** | | # Why under `docs/architecture/` (not next to mod.rs) The field manual §3 prescribes "DESIGN.md next to mod.rs" for the canonical directory-module pattern. For this PR: - chat/ and generator/ ARE directory modules, but only exist on unmerged PR branches (#1489 / #1487). Putting their DESIGN.md there would couple this PR to that chain. - data and airc/realtime_store are single-file modules — no natural "next to mod.rs" location. Resolution: all four go under `docs/architecture/` following the existing convention (PERSONA-COGNITION-CONTRACT.md, ORM-PHASE-2-DESIGN.md style). When the open PR chain merges, future PRs CAN move chat/DESIGN.md + generator/DESIGN.md into their respective directories if the team prefers — content stays the same; only the file path changes. Single-file module docs stay under `docs/architecture/` indefinitely (no natural directory home). # What each doc captures ## CHAT-MODULE.md - The chat/send dual-write semantics + the warning-field degraded- success pattern - All 11 concurrency tests pinning multi-persona invariants - The TS→Rust rethink table (resolved UUIDs only, no name resolution in kernel) - Three flagged substrate kinks waiting for second consumers before distillation (envelope builder, typed cross-module call, dual-write macro) ## GENERATOR-MODULE.md - The recursive bootstrap doctrine + v1→v2 evolution - The two same-name race bugs the per-name lock caught (silent "already exists" silencing; torn-state writes with force=true) - Why std::sync::Mutex over tokio::sync::Mutex here (sync filesystem critical section) ## DATA-CURSORS-MODULE.md - The read-then-async-then-write race story (the "page 1 served 8 times" bug) - The dual-shape (handle OR queryId) resolver + the additive migration story - All seven HandleRef migration tests pinning invariants - The substrate refinements distilled to PR #1491 (expect_owned_by, handle_id_or_legacy) ## AIRC-REALTIME-STORE-MODULE.md - The module-wide mutex + correctness-vs-throughput rationale - The four moment-of-truth concurrency tests - The flagged per-room sharding refinement (when persona count grows) - The known stale-cursor + replay-bound limitation (out of scope but flagged) # What this PR explicitly does NOT do - **Does NOT touch any code** — pure documentation. - **Does NOT move chat/ or generator/ DESIGN.md into their module directories** — see "Why under docs/architecture/" above. - **Does NOT cover the full data module** — only the cursor surface. CRUD / vector / migration / batch each get their own design page as they migrate. - **Does NOT cover the broader airc module** — only the in-memory realtime store. queue-scan / daemon transport / file transport get their own audit when they become hot. - **Does NOT ship a MODULE-CATALOG.md update** — that's step 4 of the doc set, separate PR. # References - PR #1493 — Field manual (canonical 8-section template) - PR #1494 — Generator v2 (emits the same template skeleton) - PRs #1487, #1489, #1490, #1492 — the modules being documented Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…in Rust (step 4 of 4) (#1496) Final step of the doc set Joel approved on 2026-05-30 ("Yeah let's do it. In order"): 1. ✅ Field manual codifying substrate (PR #1493) 2. ✅ Generator v2 emitting modules per the template (PR #1494) 3. ✅ Per-module design pages for what we've built (PR #1495) 4. **This PR**: MODULE-CATALOG.md update marking which modules are alive in Rust # What this PR adds A new `§0. Currently Live In Rust` section near the top of the catalog with three sub-tables: ## Sub-table 1: Live modules | Module | What ships | PR | Design doc | Concurrency proven | |---|---|---|---|---| | `chat` | chat/poll + chat/send | #1489 | CHAT-MODULE.md | 4 tests | | `generator` | generate/module + v2 scaffold | #1487 + #1494 | GENERATOR-MODULE.md | 3 tests | | `data` cursors | data/query-* with HandleRef | #1490 | DATA-CURSORS-MODULE.md | 7 tests | | `airc/realtime-store` | in-process realtime store | (pre-session) + #1492 tests | AIRC-REALTIME-STORE-MODULE.md | 4 tests | ## Sub-table 2: Substrate primitives The kernel-level work the four modules ride on — `ServiceModule` trait, interceptor chain (PR #1483/#1484), HandleRef + cell shapes (#1485), envelopes (#1486), expect_owned_by + handle_id_or_legacy (#1491), field manual (#1493), generator v2 (#1494). ## Sub-table 3: Three-primitive map Per Joel 2026-05-30, mapping the live modules to Commands / Events / Persona — showing chat + generator + data are Commands; airc/realtime is Events; Persona is the next migration target. # Why minimal restructure The catalog is 1133 lines of design-proposal entries for every Continuum concern. Restructuring individual entries to mark which are live would scatter the live-vs-proposal signal across dozens of sections. Putting it in one top-of-doc §0 section gives readers the live-status at a glance without disturbing the rest of the catalog's design-proposal framing. # Doctrine the §0 establishes Modules earn a row in §0 when they clear ALL THREE of the field manual's acceptance criteria: 1. Rust implementation merged 2. Per-module design doc capturing role / surface / state / concurrency / migration / kinks 3. Multi-thread concurrency tests pinning per-resource invariants This makes the catalog dual-purpose: - **Design proposal repository** (§I–§IX, unchanged) — what we intend to build - **Implementation status board** (§0, new) — what we've actually built + proven Future migrations grow §0; the proposal sections shrink as their entries get promoted. # Updates to the header - Cross-ref added to COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md (joining CBAR / GENOME-FOUNDRY-SENTINEL / PERSONA-COGNITION-CONTRACT) - Status line updated: "Most entries are design proposals … Some are now live in Rust — see §0 below" # Net diff +41 lines, -2 lines. Surgical addition that doesn't disturb the existing catalog content. # What this PR does NOT do - **Does NOT migrate any module** — pure documentation - **Does NOT restructure §I–§IX entries** — each concern stays in design-proposal form until it migrates to Rust + earns a §0 row - **Does NOT add new module concerns to the catalog** — chat, generator, data cursors, and airc/realtime-store are already represented implicitly in the existing concerns sections; §0 is the live-status index, not a new concern listing # References - PR #1493 — Field manual (acceptance criteria the §0 table inherits) - PR #1494 — Generator v2 (eats own dogfood) - PR #1495 — Per-module design pages linked from §0 - PRs #1487, #1489, #1490, #1492 — the live modules - Memory: `three-primitives-commands-events-persona`, `headless-rust-must-work-soon` Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…dle as first-class field (#1486) 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>
…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>
…t (first dual-write composition) (#1489) * feat(modules): ChatModule — first proof-of-pattern migration (chat/poll in Rust) Per Joel: > "Chat is gonna be airc man. So that's extracted period. Chat is of > course a bonafide command though. Do not cheapen it. So the > commands need to be or at least some to start, entirely rust." The split: - **Substrate** (delivery, pub/sub, peers, signing) → airc - **Commands** (chat/send, chat/poll, chat/analyze, chat/export) → Continuum kernel-level ServiceModule, this PR This is the FIRST real module migration from a TS command to a Rust `ServiceModule`. The chat module exercises every pattern the substrate floor PRs established: - `ServiceModule` trait - `CommandResult` cell shapes (PR #1485) - `CommandRequest<P>` / `CommandResponse<T>` envelopes (PR #1486) - Cross-module dispatch via the kernel executor (chat calls `data/query` — neither knows the other beyond the command surface) - Scaffold shape that GeneratorModule (PR #1487) produces - ts-rs typed wire boundary # Scope of THIS PR Only `chat/poll` ships in Rust. The other three commands (`chat/send`, `chat/analyze`, `chat/export`) are wired into the dispatch table as fail-loud stubs that name issue #57 as the migration tracker. Their TS implementations stay live on canary — consumers see no regression. Why staged: `chat/poll` is the cleanest outlier (pure read, no airc, no media side-effects) which lets us validate the cross-module call pattern (chat → data via the kernel executor) without dragging substrate + media into the first migration. Subsequent commands fold in real behavior incrementally. # Module structure ``` src/workers/continuum-core/src/modules/chat/ ├── mod.rs // ChatModule, ServiceModule impl, poll handler └── types.rs // ChatPollParams, ChatPollResult (ts-rs exports) ``` `mod.rs` follows the GeneratorModule template exactly — `pub struct ChatModule`, `impl ServiceModule`, `ModuleConfig` declaring both `chat/` and `collaboration/chat/` prefixes (legacy back-compat), the `handle_command` dispatch arms, the typed envelope pattern. `types.rs` carries `#[derive(TS)]` on both param + result types, exporting to `shared/generated/chat/`. Wire shape: camelCase, optional fields elided when absent. `CHAT_MESSAGES_COLLECTION` constant + `DEFAULT_POLL_LIMIT` constant centralized here. # Cross-module call pattern `chat/poll` doesn't open a database connection — it calls `data/query` via the kernel executor. Chat is blind to which adapter implements the storage; the data module routes per its own resolution rules. This is exactly MODULE-ARCHITECTURE.md §5: commands call commands; modules don't know about each other beyond the command surface. The chat module accepts an optional executor override at construction (`with_executor(...)`) — production uses the kernel-global, tests inject their own. That lets every test in this module spin up a fresh registry with a `StubDataModule` and exercise the full cross-module path without trampling the global `OnceLock`. # Tests (17/17 pass) types.rs (5): - `poll_params_defaults_to_all_none` - `poll_params_round_trip_through_json_with_camel_case` - `poll_params_accepts_missing_fields` - `poll_result_omits_after_message_id_when_none` - `poll_result_includes_after_message_id_when_set` mod.rs (10): - `config_advertises_both_command_prefixes` - `unknown_command_returns_loud_error_naming_supported_commands` - `unmigrated_commands_fail_loud_and_name_followup` (all 6 stub surfaces: chat/send, chat/analyze, chat/export, + collaboration/ prefixed versions) - `poll_returns_empty_result_when_data_module_returns_no_messages` - `poll_without_anchor_queries_data_desc_and_returns_chronological` - `poll_with_room_id_passes_filter_to_data_module` - `poll_with_anchor_looks_up_timestamp_then_filters_gt` - `poll_with_anchor_returns_err_when_anchor_missing` - `handle_command_routes_chat_poll_through_typed_envelope` - `handle_command_accepts_legacy_collaboration_prefix` ts-rs exports (2): - `export_bindings_chatpollparams` - `export_bindings_chatpollresult` # Wire output ``` shared/generated/chat/ ├── ChatPollParams.ts // { roomId?, afterMessageId?, limit? } ├── ChatPollResult.ts // { messages, count, afterMessageId? } └── index.ts // barrel ``` The master barrel (`shared/generated/index.ts`) gains `export * from './chat'`. Other barrel drift (runtime, persona) is PR #1488's territory — left untouched here so the two PRs don't fight over the same lines. # What this PR explicitly does NOT do - Does NOT migrate `chat/send`, `chat/analyze`, `chat/export`. Stubs name issue #57. Each is a future PR. - Does NOT register `ChatModule` at runtime startup. Adding `runtime.register(Arc::new(ChatModule::new()))` in `ipc::start_server` would route ALL `chat/*` traffic through this module — including the stubbed commands which would then break. Registration happens in the same PR that fills in the first real `chat/send` so consumers see one atomic change. Today: chat module exists, is tested, but the legacy TS path still owns every chat command at runtime. - Does NOT do room-name resolution. The kernel command takes an already-resolved `roomId`; name → id stays in TS browser/CLI callsites (or a future `channel/resolve` command). Keeps the kernel command compositional with the future channel module. - Does NOT auto-rebuild the master barrel from outside the chat directory — that drift was already on canary and is PR #1488's job. This PR only adds the `chat` entry. # References - [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md) §5 (composition: commands call commands) - PR #1486 (CommandRequest/Response envelopes — used here) - PR #1487 (GeneratorModule — chat follows its template) - Issue #57 (migration tracker — stubs name it) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(modules/chat): chat/send migrates to Rust — first dual-write composition handler Per Joel: > "Yes please do." (re: chat/send next, the dual-write composition > stress-test) chat/send is the chat module's first multi-cross-module-call handler: chat → data (persist) then chat → airc (publish). The migration forces the substrate to commit on partial-failure semantics that the single-call handlers (chat/poll, data/query cursors) never had to face. # Why this PR pushes the envelope Two effects across two modules with no kernel-level transaction: | data | airc | handler returns | |------|------|----------------------------------------------------------| | ok | ok | `Ok(result with message_id + event_id)` | | ok | fail | `Ok(result with message_id, event_id=None, warning=...)` | | fail | — | `Err(...)` — no airc publish attempted | The (ok, fail) cell is the substrate-shaped kink the design needed proof of. An airc-only failure is NOT command-level failure: the message IS in the local store, consumers see it via chat/poll, a future retry/sync mechanism heals the broadcast. Surfacing this as `Err` would tell the caller "your write didn't happen" — which is wrong; half of the write did. The `warning` field is the right shape: **degraded success**. # Design decisions this PR locks in ## Ordering: data first, airc second Local persistence is the ground truth. The reverse order would risk publishing a message to peers that this node doesn't know about — a peer reading back that message would find no local record. With data-first, the worst case is *we have the message but peers don't* — a degradation, not a divergence. A test (`send_calls_data_before_airc`) pins the order via a shared call-log Mutex. If the ordering ever flips, the bad-divergence case becomes reachable; the test catches it. ## airc-fail returns Ok+warning, not Err The `warning` field names the failing surface, surfaces the underlying error (so callers can diagnose), confirms the message wasn't lost ("stored locally"), and includes the message id (so callers can correlate logs). Tested: - `send_with_airc_failure_returns_warning_and_null_event_id` ## data-fail short-circuits — airc NEVER called A test tracks airc invocations via `AtomicUsize` and asserts ZERO calls when data failed. Same invariant for the subtle data-returns-success=false path: - `send_with_data_executor_failure_propagates_as_err_and_skips_airc` - `send_with_data_success_false_propagates_as_err_and_skips_airc` ## Wire contracts pinned by tests, not just docs Two tests pin the on-the-wire shape chat hands to data + airc. If either downstream module changes its parse expectations, these tests catch the drift even though chat doesn't import their typed structs (coupling lives at the command/wire surface, not at the Rust type level — the substrate's whole point): - `send_writes_chat_messages_collection_with_canonical_entity_shape` → pins ChatMessageEntity layout (id/roomId/senderId/timestamp/ content/replyToId/metadata.source/status, ISO-8601 UTC timestamps) - `send_envelope_matches_airc_publish_wire_shape` → pins AircRealtimeEnvelope layout (eventId/roomId/sourceId/ createdAtMs/delivery, tagged payload variant with schema=chat_transcript and inline message data) # What this PR explicitly does NOT do - **Does NOT migrate** chat/analyze or chat/export (still fail-loud stubs naming issue #57). - **Does NOT register `ChatModule` at runtime startup.** Same reasoning as #1489 — until ALL chat commands are migrated, registration would break the remaining stubs at runtime. - **Does NOT do sender/room name resolution.** Kernel command takes pre-resolved UUIDs; resolution stays in TS browser/CLI (or a future channel/resolve + user/resolve pair). Same compositional principle chat/poll established. - **Does NOT externalize media.** Text-only for this migration; media paths (base64 → blob storage via MediaBlobService) are their own kink-finder. - **Does NOT do vision pre-warming.** Fire-and-forget visual descriptor generation is deferred to vision-module migration. - **Does NOT thread reply-to into threading metadata fully.** The `replyToId` field flows through to the stored entity + the airc payload, but the richer thread { threadId, replyCount, lastReplyAt } shape is deferred until the thread-tracking design is its own scope. - **Does NOT solve idempotency.** A retried chat/send (network glitch on the caller side) currently produces two stored messages — matches today's TS behavior. Future PR can add a `client_dedup_id` param + TTL'd dedup map; the substrate is ready for it but the design is its own scope. # Substrate kinks this PR surfaced (For potential future refinement — none blocking, all annotated): 1. **No envelope construction helpers for cross-module calls.** Chat hand-rolls `json!({ "envelope": {...} })` for airc. If many modules call airc/realtime-publish from Rust, an `airc::realtime_publish_envelope(builder...) -> Value` helper in the airc-shared module would distill this. Out of scope here; flag for if a second consumer appears. 2. **No typed cross-module command call.** Chat calls `executor.execute_json("data/create", json!({...}))` with raw JSON and parses the response back via `.get("success")`. A typed `executor.execute_typed::<DataCreateParams, DataCreateResult>(...)` would catch wire-shape drift at compile time. Same kink the handle_id_or_legacy refinement (#1491) solved for a different surface — flag for potential future refinement after we see if it reappears with a second consumer. 3. **No transaction primitive across modules.** Today: chat hand-codes the data-first / airc-best-effort ordering inline. If many modules need similar dual-write composition, a substrate-level `dual_write!(primary => ..., best_effort => ...)` macro could centralize the partial-failure pattern (warning construction, ordering enforcement, etc.). Flag for if/when a second consumer appears. # Tests (28/28 pass) Pre-existing chat/poll (17, all unchanged behavior): - StubDataModule extended to dispatch by command — back-compat `query_only` constructor preserves chat/poll's existing tests verbatim - All 17 chat/poll tests still pass through the refactored stub New chat/send (11): - `send_happy_path_returns_message_id_and_event_id` - `send_with_airc_failure_returns_warning_and_null_event_id` ← partial-failure cell - `send_with_data_executor_failure_propagates_as_err_and_skips_airc` ← hard-failure + ordering invariant - `send_with_data_success_false_propagates_as_err_and_skips_airc` ← the subtle data-success-false path - `send_calls_data_before_airc` ← ordering invariant via call log - `send_writes_chat_messages_collection_with_canonical_entity_shape` ← wire contract to data - `send_envelope_matches_airc_publish_wire_shape` ← wire contract to airc - `handle_command_routes_chat_send_through_typed_envelope` ← typed envelope round-trip end-to-end - `handle_command_chat_send_accepts_legacy_collaboration_prefix` ← back-compat - `unmigrated_commands_fail_loud_and_name_followup` (updated to exclude chat/send now that it's migrated) ts-rs bindings (2): - `export_bindings_chatsendparams` - `export_bindings_chatsendresult` # Wire output ``` shared/generated/chat/ ├── ChatPollParams.ts ├── ChatPollResult.ts ├── ChatSendParams.ts // { roomId, senderId, text, replyToId? } ├── ChatSendResult.ts // { messageId, eventId?, warning? } └── index.ts ``` # References - [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md) §5 (composition: commands call commands) - PR #1489 (ChatModule + chat/poll — the first migration) - PR #1490 (data/query cursors — single-call HandleRef stress test) - PR #1491 (substrate refinements distilled from #1490) - Issue #57 (migration tracker) - Issue #64 (this migration) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(modules/chat): concurrency stress tests — multi-persona invariants pinned Per Joel 2026-05-30: "Each persona exists in its own threads." The kernel registers ONE ChatModule instance; every persona's thread invokes its `&self` methods concurrently against the same executor. The substrate is designed to be safe under that load — but until now no test PROVED it. Single-threaded `#[tokio::test]` runs serialize even genuinely racy code and would pass a substrate with a data race. This commit adds 4 concurrency stress tests pinning the invariants the dual-write / single-call composition designs depend on. Every test uses `flavor = "multi_thread", worker_threads = 4` so tasks actually preempt each other on distinct OS threads rather than cooperatively interleaving on one. # What's pinned 1. **`send_under_concurrent_load_stores_all_messages_with_distinct_ids`** 50 concurrent personas all call `chat/send` through the same ChatModule. Asserts: every send completes, every send writes exactly once, every returned `message_id` is distinct (no UUID collision, no shared mutable state holding the id), and the SET of stored ids equals the SET of returned ids (no lost writes, no phantom writes). 2. **`send_preserves_per_call_ordering_under_concurrent_load`** 25 concurrent sends interleave globally — but per-call `data/create` MUST still precede per-call `airc/realtime-publish`. The dual-write design's bad-divergence safety net (peers don't see a message the node hasn't stored) depends on this invariant holding under load. Tagging each observation with its `message_id` lets the test reconstruct per-call timelines from the interleaved global log. 3. **`send_isolates_mixed_outcomes_under_concurrent_load`** 30 concurrent sends with half airc-failing (text flag tells the stub to fail). Each call's `warning` must reference THIS call's `message_id`, not a concurrent sibling's. Cross-contamination between concurrent results would mean shared mutable state in the handler — this catches it. 4. **`poll_isolates_results_under_concurrent_load`** 30 concurrent `chat/poll` calls each polling a DIFFERENT room. The stub echoes the requested `roomId` in the synthetic result; the test asserts every task receives ITS OWN room's result. Catches result-swap bugs that would never appear single-threaded. # Why this discipline matters Concurrency tests aren't exercising rare paths — they're the production scenario. A test suite full of single-threaded `#[tokio::test]`s can sign off on a substrate that silently miscomputes under multi-persona load. Pinning the invariants here means the next refactor (e.g., adding a `dual_write!` macro or typed cross-module command call) is held to the same bar. The pattern goes into every future module that consumes the kernel: when you add a new handler that touches shared state, add a matching concurrency stress test. # Tests (23/23 pass — 19 pre-existing + 4 new concurrency) All previously-passing tests still pass. The new ones use real multi-threaded tokio runtime + `Arc<Mutex>` + atomic tracking to observe interleavings the substrate must handle. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…+ envelope dispatch + concurrency test (#1499) Per Joel 2026-05-30: > "Let's make sure we have detailed designs for this command > infrastructure into modules and properly built from the ground up > by using our own generators." Builds on the field manual (PR #1493) which codified the Module Design Template. This PR makes the GeneratorModule emit modules that MATCH that template — eat own dogfood, no future hand-rolled scaffolds. # Before vs after **v1 scaffold (PR #1487)** produced 2 files: - `mod.rs` — ServiceModule with raw-Err dispatch arms - `README.md` — author-facing summary The author had to hand-author types.rs, the typed envelope wiring, the test module, the concurrency stress-test scaffold, and the DESIGN.md. Every migration repeated the same boilerplate. **v2 scaffold** produces 4 files: - `mod.rs` — ServiceModule with typed envelope dispatch + handler methods + concurrency test scaffold (multi-thread tokio, `worker_threads = 4`) - `types.rs` — `<Cmd>Params` + `<Cmd>Result` per declared command, with `#[derive(TS)]`, `serde(rename_all = "camelCase")`, `export_to "../../../shared/generated/<name>/<Cmd>Params.ts"` - `DESIGN.md` — canonical per-module design skeleton with required section headers (Role / Command surface / Cross-module deps / State model / Events emitted / Concurrency contract / Migration notes / Kinks found) - `README.md` — author-facing summary referencing all four files + cross-refs to the field manual # New `--stateful` flag When `params.stateful = true`, the generator additionally emits: - `use dashmap::DashMap;` import - `ResourceState` placeholder struct - `resource_locks: DashMap<String, Arc<tokio::sync::Mutex<ResourceState>>>` field on the module struct - `fn resource_lock(&self, id: &str)` get-or-create helper - A second concurrency test (`resource_locks_stay_parallel_across_distinct_ids`) pinning the "different ids stay parallel" invariant Authors who set `stateful = true` get the per-resource lock pattern (per field manual §4.1) without writing any of the boilerplate. # Generated `mod.rs` shape (the substantive change) Each declared command now emits: ```rust // Dispatch arm: "<cmd>" => { let req = CommandRequest::<<CmdName>Params>::from_value(params)?; let result = self.handle_<verb>(req.params).await?; CommandResponse::ok(result).into_command_result() } // Typed handler method (scaffolded stub): pub async fn handle_<verb>( &self, params: <CmdName>Params, ) -> Result<<CmdName>Result, String> { Err("<cmd>: not yet implemented in this scaffolded module".to_string()) } ``` Authors replace ONE line — the `Err(...)` body — to fill in real logic. The envelope wiring is already in place; the typed params flow through to the handler; the typed result materializes through the response envelope automatically. # Naming helpers - `command_to_type_stem("chat", "chat/poll")` → `"Poll"` - `command_to_type_stem("chat", "chat/analyze/findings")` → `"AnalyzeFindings"` - `command_to_handler_name("chat", "chat/poll")` → `"handle_poll"` - `command_to_handler_name("chat", "chat/analyze/findings")` → `"handle_analyze_findings"` Strips the leading `<module>/` prefix when present; falls back to the full command path (PascalCase / snake_case). # Tests (39/39 pass — 22 new + 17 pre-existing) ## New template tests (14) - `mod_rs_contains_struct_definition_and_trait_impl` - `mod_rs_uses_typed_envelope_dispatch_for_each_command` ← v2 core - `mod_rs_emits_typed_handler_methods_for_each_command` ← v2 core - `mod_rs_imports_envelope_types_from_runtime` - `mod_rs_includes_with_executor_constructor_for_tests` - `mod_rs_emits_concurrency_stress_test_with_multi_thread_runtime` - `mod_rs_for_stateless_module_omits_resource_lock_scaffold` - `mod_rs_for_stateful_module_emits_per_resource_lock_scaffold` ← --stateful - `types_rs_emits_params_and_result_for_each_command` - `types_rs_annotates_for_ts_rs_export_with_camel_case` - `types_rs_for_command_less_module_emits_no_params_structs` - `design_md_includes_all_required_sections` - `design_md_lists_each_command_in_the_surface_table` - `design_md_state_section_reflects_stateful_flag` - `command_to_type_stem_strips_module_prefix_and_pascals` - `command_to_handler_name_strips_module_prefix_and_snakes` ## New filesystem dogfood (1) - `stateful_multi_command_scaffold_has_consistent_cross_references` — scaffolds a stateful 3-command module to a tempdir, then verifies every dispatch arm has a matching typed handler, every handler has a matching Params/Result type in types.rs, and the stateful lock scaffold cross-references match. Closest unit-level proof that a real consumer can `cargo check` the scaffold untouched. ## Pre-existing (all still pass) - All v1 generator tests + the per-name concurrency tests landed in PR #1487 still green. The `--stateful` flag is additive; the default `stateful: false` preserves v1 behavior at the dispatch level. # What this PR does NOT do - **Does NOT auto-wire the generated module** into `modules/mod.rs` at the parent or register at runtime startup. The README + next_step message both spell out the manual steps. A future `generate/refresh` command can automate this. - **Does NOT generate aliases** for legacy command prefixes (e.g., `collaboration/chat/*` → `chat/*`). The chat module's hand-written alias dispatch is the reference pattern; authors wire aliases manually until a `--alias` flag is added. - **Does NOT enforce specific Params/Result fields** — only scaffolds empty structs with the right derives. Authors add typed fields per the field manual's ts-rs annotation rules. - **Does NOT add `generate/command`** (add a new command to an existing module). That's a separate follow-up — flagged in field manual §6.1. # Migration story: next chat-analyze migration With v2 in place, the chat-analyze migration (the worked example from field manual §5.3) becomes: ```bash ./jtag generate/module \ --name "chat_analyze" \ --description "Long-running chat analysis with HandleRef + event streaming" \ --commands "chat/analyze,chat/analyze/findings,chat/analyze/complete,chat/analyze/cancel" \ --events-published "chat:analyze:finding,chat:analyze:complete,chat:analyze:cancelled" \ --priority normal \ --stateful # mints + tracks per-run state ``` Output: 4 files, all the boilerplate done. Author opens mod.rs, implements 4 handler bodies, opens types.rs, fills in 4 Params/Result pairs, opens DESIGN.md, writes the rationale. That's it — concurrency tests already primed, envelope wiring already correct, ts-rs bindings already declared. # References - [docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md) §3 (Module Design Template) — what this PR makes the generator emit - §4 (Concurrency doctrine) — what `--stateful` mode scaffolds - §6 (Generator usage) — the v2 invocation pattern - PR #1493 (field manual) - PR #1487 (v1 generator) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…utex (re-opens #1490) (#1497) * feat(modules/data): query cursors mint typed HandleRef + accept envelope shape Per Joel: > "You can work out the kinks and reinforce patterns by picking good > example commands which push the envelope, npi" The hand-rolled string `queryId` pattern in `data/query-open` / `data/query-next` / `data/query-close` predates HandleRef + the typed envelope. It's the perfect kink-finding migration target: a REAL long-running stateful operation that currently passes a stringly-typed session id around, with no kernel-level typing of the handle's owner, type, or lifetime. # What this PR does 1. `data/query-open` now MINTS a `HandleRef { owner: "data", id: Uuid, type_tag: "data::QueryCursor", created_at_ms }` via `CommandResponse::with_handle`. Wire shape gains a top-level `handle` field alongside the legacy `data.queryId` (the SAME UUID — identity invariant covered by test). 2. `data/query-next` and `data/query-close` accept BOTH shapes via the typed envelope: - **new canonical**: `{ handle: HandleRef }` on the `CommandRequest` envelope - **legacy back-compat**: `{ queryId: "<uuid-string>" }` flat in the params body A single resolver (`resolve_query_cursor_id`) walks the envelope first, falls back to the legacy field, and fails loud when neither is present — naming both supported shapes so the caller can self-correct. 3. The resolver VALIDATES handles aggressively: - **wrong owner** → typed error naming both the offending owner and the expected (`data`). The grid interceptor is supposed to route calls back to the actual owner before dispatch; arriving here with the wrong owner means either the routing misfired or a caller hand-crafted a bogus handle. - **wrong type_tag** → typed error naming both the offending tag and the expected (`data::QueryCursor`). Within-module discriminator: a future `data::Migration` handle threaded through the cursor surface would silently look up nonsense in the paginated_queries map; we catch it here. - **unknown handle** → typed error naming the cursor + likely causes (closed via `query-close`, evicted by future TTL, previous process instance). # What this PR explicitly does NOT do - Does NOT drop the legacy `queryId` field from the open response or the next/close inputs. The migration is additive; consumers migrate at their own pace. A follow-up drops `queryId` once every TS consumer threads the handle. - Does NOT change the DashMap key type from `String` to `Uuid`. The HandleRef carries a `Uuid` on the wire; the data module string-converts at the lookup boundary. Smaller surgery, same identity semantics. - Does NOT add envelope plumbing to OTHER data handlers (create, read, update, delete, query, vector/*). Those are one-shot operations; they don't need handles. Only long-running stateful surfaces benefit from HandleRef. # Kink-finding outcomes (real bugs the migration design caught) - Empty-params query-next used to deserialize to `query_id: ""` (required-string field). Now BOTH fields are optional and the empty case is reachable — without a typed error it would silently no-op-404. The resolver names both supported shapes in the error. - Cross-module handle confusion (owner="chat" reaching the data handler) was previously impossible because there was no handle — only an opaque string. With typed handles, the validation surface exists. The test forces it. - Cross-resource handle confusion (owner="data" but type_tag="data::Migration") same: the test forces a future failure mode that the type_tag discriminator was DESIGNED for. # Patterns reinforced - **Typed envelope at every typed surface**: every new handler from here on parses `CommandRequest::<P>::from_value(params)` at the entry. The cross-cutting `handle` / `sessionId` / `userId` fields are free. - **CommandResponse::with_handle for any minted handle**: a single fluent expression replaces hand-rolling the JSON. Wire shape stays flat — handle lives at top level, data lives nested or flat depending on the back-compat needs of the response. - **Validate the owner AND the type_tag before lookup**: the type system can't catch a hand-crafted bogus handle; the resolver must. This pattern goes into every future module that consumes handles. # Tests (10 new + 8 pre-existing, all 18 pass) New (`modules::data::tests::`): - `query_open_returns_handle_alongside_legacy_query_id` — additive migration: both shapes present - `query_next_accepts_handle_in_envelope` — new canonical path - `query_next_still_accepts_legacy_query_id_field` — back-compat preserved - `query_next_rejects_handle_with_wrong_owner` — kink - `query_next_rejects_handle_with_wrong_type_tag` — kink - `query_next_rejects_when_neither_handle_nor_query_id_provided` — empty-params surfaces typed error - `query_next_with_unknown_handle_returns_handle_not_found` — stale handle typed error - `query_close_accepts_handle_in_envelope` + after-close stale check - `query_close_still_accepts_legacy_query_id_field` - `full_round_trip_open_next_close_via_handles_only` — end-to-end through the new canonical shape, 12 rows / 3 pages Pre-existing (untouched, all pass): - `test_paginated_query` — legacy `queryId` round-trip via the same path; no regression - `test_paginated_query_count_exact` — same # Stacks on PR #1486 (CommandRequest/Response envelopes — used at every entry + exit of the migrated handlers). # References - [docs/architecture/MODULE-ARCHITECTURE.md](docs/architecture/MODULE-ARCHITECTURE.md) §10 (recursive bootstrap), §5 (composition) - PR #1485 (cell shapes — HandleRef used here) - PR #1486 (envelope pattern — used at every handler surface) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(modules/data): per-cursor mutex serializes concurrent query-next + concurrency tests pin it Per Joel 2026-05-30: "Each persona exists in its own threads." # The bug the concurrency test caught Original `handle_query_next` pattern: ```rust let state_info = self.paginated_queries.get(&cursor_id).map(|s| (s.current_page, ...)); // ^ DashMap shard lock released HERE // ... async adapter.query() runs with NO lock ... self.paginated_queries.get_mut(&cursor_id).map(|mut s| s.current_page += 1); ``` Under N concurrent next-calls on the SAME cursor (canonical multi-persona scenario, or one persona retrying), every call reads `current_page=0`, every call computes the same offset, every call queries the same first page, every call writes `current_page=1`. Result: 8 concurrent calls all return pageNumber=1; the cursor's final state is current_page=1 instead of current_page=8. The new `same_cursor_concurrent_next_does_not_corrupt_state` test caught this with the assertion *"page 1 served 8 times — the cursor advanced through it MORE than once, indicating a lost serialization"*. The fix landed in the same commit. # The fix Wrap each cursor's state in a `tokio::sync::Mutex` held across the async query. Concurrent next-calls on the SAME cursor serialize (the substrate's promise: page numbering stays monotone). Concurrent next-calls on DIFFERENT cursors stay fully parallel because each cursor has its OWN mutex — DashMap's lock-free read path is preserved. ```rust paginated_queries: DashMap<String, Arc<tokio::sync::Mutex<PaginatedQueryState>>> ``` `handle_query_next`: 1. Clone the `Arc<Mutex>` OUT of the DashMap shard (brief read lock, no contention) 2. `lock().await` the per-cursor mutex 3. Snapshot the read-only fields needed for the query into locals 4. Run the adapter query (mutex held — only ONE caller advances at a time) 5. Update state on the still-held lock (atomic with the read) `handle_query_close` unchanged: `DashMap.remove()` is atomic; if a concurrent next is mid-flight, it holds an Arc keeping the Mutex alive — its mutation succeeds against an orphaned state map that's never read again. From the caller's view: close said success; in-flight next returns its now-meaningless page; the cursor is unreachable for subsequent calls. Benign and arguably the correct contract — callers shouldn't race close with next. # Substrate doctrine reinforced Joel's reminder is doctrine, not just a one-off bug fix. Every ServiceModule that holds per-resource mutable state across an `.await` MUST hold a per-resource lock for the read-then-async- then-write window. Module-wide locks are wrong (serialize all resources). Per-resource locks via `DashMap<Id, Arc<Mutex<State>>>` are the canonical pattern. # Concurrency stress tests Both run with `flavor = "multi_thread", worker_threads = 4` so tasks actually preempt each other on distinct OS threads. ## `cursors_are_isolated_under_concurrent_open_and_next` (20 personas) Phase 1: 20 concurrent `query-open` calls. Asserts all 20 cursors mint DISTINCT HandleRef.id UUIDs. Phase 2: 20 concurrent `query-next` calls, each against its own cursor. Asserts each cursor's first page returns pageSize items and pageNumber=1 (per-cursor state, not shared). Phase 3: close half the cursors in parallel; assert the OTHER half STILL serves page 2 correctly. Close MUST be per-cursor — sibling state untouched. ## `same_cursor_concurrent_next_does_not_corrupt_state` (8 callers, 1 cursor) 30 rows, pageSize 5 → 6 valid pages. Fire 8 concurrent `query-next` calls against the SAME cursor handle. Asserts each non-tail page (1..=5) is served AT MOST ONCE — the per-cursor mutex serialized the advance. Without the fix, page 1 was served 8 times. # Tests (20/20 pass; 1 ignored onnxruntime) All 10 pre-existing HandleRef migration tests still pass — no regression from the locking restructure. The 2 new concurrency tests pin the invariants going forward. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…id_or_legacy (#1498) Per Joel: > "You get to refine the pattern with better knowledge, therefore > improving elegance and reliability" Distill two primitives from the kinks the first real HandleRef consumer (PR #1490) had to handle inline, so every future consumer reaches for the substrate rather than reimplementing them. # The primitives ## HandleRef::expect_owned_by(owner, type_tag) -> Result<Uuid, String> The canonical handle-validation entry point. Returns the inner UUID when both the owner and type_tag match expectations; otherwise emits typed errors that name BOTH the offending value AND the expected value. Owner-mismatch is checked first (owner determines routing) and the error explicitly hints at the grid-interceptor responsibility — the diagnostic turns "weird error" into "ah, the interceptor misfired" or "ah, this caller built a bogus handle." Replaces ~12 lines of validation boilerplate per handle-consuming handler. Standardizes the error format across every module that uses handles. ## CommandRequest::handle_id_or_legacy(...) The single primitive shared by every additive migration of a stringly-typed id to a typed HandleRef. Walks two shapes: 1. envelope `handle` (new canonical) — validated via expect_owned_by, error prefixed with the command name 2. legacy string field on the params (back-compat) 3. neither → typed error naming BOTH supported shapes so the caller knows what to add Returns the resolved id as a String — the historical wire format every consumer's state map is already keyed on. New modules that key state by Uuid natively can `Uuid::parse_str` the result; legacy-only strings parse-fail there, which is fine because handle-only consumers post-migration don't have a legacy field to fall back to. Replaces ~25 lines of bespoke resolver per migration. Standardizes the error format across every dual-shape migration. # The consumer-side win (data.rs) Before (35-line `resolve_query_cursor_id` static fn + two callsites that each invoked it): ```rust fn resolve_query_cursor_id(handle, legacy, command) -> Result<...> { if let Some(h) = handle { if h.owner != DATA_MODULE_OWNER { return Err(...); } // 6 lines if h.type_tag != QUERY_CURSOR_TYPE_TAG { return Err(...); } // 6 lines return Ok(h.id.to_string()); } if let Some(id) = legacy { return Ok(id.clone()); } Err(format!("...")) // 4 lines } // Plus the two callsites: Self::resolve_query_cursor_id(...) ``` After (the static fn is gone; callsites invoke the substrate primitive directly): ```rust let cursor_id = req.handle_id_or_legacy( DATA_MODULE_OWNER, QUERY_CURSOR_TYPE_TAG, "queryId", &req.params.query_id, "data/query-next", )?; ``` Net: -84 lines from data.rs. The 411-line substrate addition is all either documentation, tested primitives, or new substrate-level tests — every future handle consumer benefits from this shrink, not just data. # Tests (48 pass, 1 ignored — onnxruntime, unrelated) ## New (runtime::cell_shapes::tests, 5) - `expect_owned_by_returns_uuid_when_owner_and_type_match` — happy path - `expect_owned_by_rejects_wrong_owner_with_both_values_named` - `expect_owned_by_rejects_wrong_type_tag_with_both_values_named` - `expect_owned_by_checks_owner_first_then_type` — pins routing-first precedence (owner before type) - `expect_owned_by_error_includes_routing_hint` — pins the grid-interceptor diagnostic in the owner-mismatch error ## New (runtime::command_envelope::tests, 6) - `handle_id_or_legacy_prefers_envelope_handle_when_both_present` — precedence (envelope wins) so consumers mid-migration don't diverge from new consumers about which id the resolver sees - `handle_id_or_legacy_falls_back_to_legacy_string_when_no_handle` - `handle_id_or_legacy_errors_loud_when_neither_shape_provided` - `handle_id_or_legacy_prepends_command_name_to_handle_validation_errors` - `handle_id_or_legacy_propagates_type_mismatch_with_command_name` - `handle_id_or_legacy_uses_canonical_uuid_string_for_handle_path` — pins the bridge-format invariant: handle-path and legacy-path resolve to the SAME string representation ## Pre-existing (modules::data::tests, all 17 still pass) The 10 HandleRef migration tests + 7 pre-existing cursor tests exercise the SAME behavior they did before through the refactored callsites. No regression — net effect is the substrate now owns what data.rs used to own inline. # What this PR explicitly does NOT do - Does NOT add convenience constructors like `CommandResponse::with_handle_minted` (auto-generate UUID). That case is one line (`Uuid::new_v4()` then `with_handle(...)`); the primitive doesn't justify the API surface. - Does NOT add a `handle_type!(QueryCursor)` macro that derives the type_tag string from the module + struct name at compile time. Worth considering, but the doc-convention `const QUERY_CURSOR_TYPE_TAG = "data::QueryCursor"` pattern is already cheap and explicit. - Does NOT touch other handle-related types (Stream, Lambda placeholders). Those are reserved-but-unused; their kinks will surface when they get real consumers. # References - PR #1485 (cell shapes — HandleRef defined here, extended here) - PR #1486 (envelope pattern — CommandRequest defined here, extended here) - PR #1490 (first real HandleRef consumer — the inline boilerplate this PR distills lived there) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…event (#1503) Closes Priority 3 from [PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md): restores the RTOS-brain doctrine ("handlers read pre-staged results, never block on recall/embedding/planning") at the dispatch layer. Every `CommandExecutor::execute()` now emits a `command:completed` event on the wired bus after the dispatch settles — subscribers consume completion events instead of polling result surfaces. # What this adds ## `CommandCompletedEvent` (new type) ```rust pub struct CommandCompletedEvent { pub command_name: String, pub duration_ms: u64, pub success: bool, pub error: Option<String>, } ``` - ts-rs exported to `shared/generated/runtime/CommandCompletedEvent.ts` - camelCase wire shape, optional `error` elided on success - Topic constant `COMMAND_COMPLETED_TOPIC = "command:completed"` centralized for publishers + subscribers + tests to share ## `CommandExecutor` extensions - New `bus: Option<Arc<MessageBus>>` field - Builder `with_message_bus(bus: Arc<MessageBus>) -> Self` - New init function `init_executor_with_bus_and_interceptors(...)` for production startup; existing `init_executor` paths still work without a bus (telemetry no-ops) - `execute()` wraps `execute_inner()` with timing + event emission — single `OnceLock`-set path for both production and back-compat ## `MessageBus` change Added `command:` to the realtime passthrough list. The bus coalesces non-realtime events with the same prefix in 50ms windows to prevent floods from bulk ops — but command-completion events violate the RTOS doctrine if coalesced (a persona's loop would miss 31 out of 32 events under multi-persona load). Now flows through uncoalesced, same as `chat:`, `sentinel:`, `presence:`, `tool:`. # Sharp design decisions (kinks the tests caught pre-merge) 1. **Coalescing dropped events under load.** Initial `concurrent_dispatches_each_emit_their_own_event` test asserted 32 events from 32 concurrent dispatches — got 1. Root cause: the bus's 50ms coalescing window collapses same-prefix events. Fix: `command:` joins the realtime passthrough list. The test then confirms 32 distinct events arrive (with unique command_names, no event loss, no payload corruption). 2. **CommandResult doesn't impl Clone.** Test fixtures need to return the same canned result on repeated calls. Solution: `CannedModule` stores `Result<Value, String>` (cloneable) and wraps in `CommandResult::Json` on each handler call. No substrate change. 3. **Event emission is infallible telemetry, not contract.** The `emit_command_completed` helper publishes via `publish_async_only` (fire-and-forget) and silently logs serialize failures (which shouldn't happen for a struct of plain fields, but tolerated). Telemetry must never break the dispatch contract. # Pinned invariants (multi-thread tests) `runtime::command_executor::tests`: - `dispatch_emits_completed_event_on_success` — happy path event with command_name + duration + success=true + no error - `dispatch_emits_completed_event_on_handler_error` — failure path event with success=false + populated error mirroring the Err msg - `dispatch_without_wired_bus_is_no_op_telemetry` — back-compat path (no bus) doesn't panic + dispatch still works - `ts_bridge_failure_still_emits_completed_event` — third dispatch tier (TS bridge fallthrough) covered for both no-handler and failure paths; telemetry is exhaustive - `concurrent_dispatches_each_emit_their_own_event` — `flavor = "multi_thread", worker_threads = 4`; 32 parallel dispatches each produce exactly one distinct event (no loss, no dupe, no payload interleave) `runtime::command_events::tests`: - `event_round_trips_through_wire_with_camel_case` - `event_with_error_includes_error_on_wire` - `event_parses_from_wire_shape_subscribers_will_see` — pin the exact JSON shape downstream consumers will see - `topic_constant_is_namespaced_action_format` - `export_bindings_commandcompletedevent` (ts-rs) # What this PR does NOT do - **Does NOT wire production startup to use the new init function.** `ipc::start_server` still calls `init_executor_with_interceptors` (no bus). A follow-up PR threads the runtime's bus through into startup. Safe: with no bus wired, the event emission is a silent no-op so production behavior is byte-identical until the wire lands. - **Does NOT emit per-tier events** (interceptor handled vs local Rust vs TS bridge). One event per `execute()` call — the outermost outcome. Per-tier telemetry can be added later if a consumer needs it. - **Does NOT emit `command:queued` / `command:dispatching` lifecycle events.** Just `command:completed`. The Stream cell shape (gap report priority 4) is the right home for in-flight progress events when it lands. - **Does NOT add a default subscriber** (a persona loop that consumes these events). The substrate ships the publisher; consumers wire up per their use case via `bus.receiver()` or the existing `bus.subscribe()` path. # Substrate doctrine reinforced Per [[three-primitives-commands-events-persona]] + [[alignment-via-substrate-economics]]: this PR composes the Commands primitive (dispatch) with the Events primitive (completion notifications) at the kernel layer. Personas now have a substrate-level signal for "command X just finished with outcome Y" — the foundation `code/shell/stream` (gap report priority 4) extends with line-by-line streaming when the Stream cell shape activates. For the alignment economics: once peer dispatches over airc grid also emit these events on the local bus (transparent via the GridInterceptor → grid event echo), attribution becomes substrate-observable across the grid. A peer's `cargo/build` completing on their machine emits `command:completed` to your local bus; your persona learns who built what, when. # References - [docs/planning/PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md) Priority 3 (this PR). Priority 1 was #1501, Priority 2 was #1502. - [docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md) §2 (Substrate primitives) — adds the dispatch-level event hook - [MODULE-CATALOG.md §0](docs/architecture/MODULE-CATALOG.md) — runtime substrate row to add when this lands - Memories: [[three-primitives-commands-events-persona]], [[alignment-via-substrate-economics]], [[rtos-brain-no-region-on-hot-path]] Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…om workflow w14iiocs7 (#1500) * docs(planning): PERSONA-AS-DEVELOPER-GAP.md — substrate gap report from workflow w14iiocs7 Synthesis of the multi-agent audit run after PRs #1486–#1499 landed and the persona-as-developer + alignment-via-substrate-economics vision crystallized. # Headline finding 70% of the self-coding loop is in place. The remaining 30% is concentrated in three predictable seams: 1. **Filesystem introspection** — no `code/exists`, no flat `code/list` (readdir), no standalone `code/glob` 2. **Rust toolchain wrappers** — no structured `continuum-core/build` or `continuum-core/test`; only raw `code/shell/execute` 3. **Event-driven execution feedback** — `Stream` + `Lambda` cell shapes reserved but erroring; `events/command-completed` missing Close those seams and a persona can scaffold a module via `generate/module`, edit, build+test with structured errors, and subscribe to results on the realtime bus — full inner dev loop, no human in the path. # Recommended sprint ordering 1. **`code/exists` + `code/list` + `code/glob`** (Small, bundled) — highest leverage, lowest cost; unblocks safe self-scaffolding 2. **`continuum-core/build` + `continuum-core/test`** (Medium) — Rust iteration parity with TypeScript via `--message-format=json` 3. **`events/command-completed`** (Large) — restores RTOS-brain doctrine; touches dispatch hot path 4. **`code/shell/stream`** (Medium) — activates the reserved Stream cell shape 5. **`code/delete` + `code/move`** (Small) — rounds out file CRUD # Doc-set placement - Lives under `docs/planning/` next to ALPHA-GAP-ANALYSIS.md (existing planning convention) - Cross-references COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md (the author guide), MODULE-CATALOG.md §0 (live status), GENOME-FOUNDRY- SENTINEL.md (the artifact economy the commands feed), and the per-module DESIGN.md pages (reference patterns) - Methodology section names the originating workflow + survey approach so future regenerations can follow the same shape # Connection to alignment-via-substrate-economics Per the memory [[alignment-via-substrate-economics]] + [[continuum-thesis-airc-is-the-medium]]: the proposed `continuum-core/ build` + `test` envelopes become serializable across the grid the moment they exist; combined with `events/command-completed` they make module-authorship attribution observable in real time. That's the cooperation incentive structure made concrete — the foundation the foundry's tiered genome cache (L1-L5 per GENOME-FOUNDRY- SENTINEL.md) needs to distribute persona-authored modules and route credit by cache-hit attribution. # Follow-up Next concrete sprint (separate PR): the bundled `code/exists` + `code/list` + `code/glob` cluster. Plan is to dogfood by using `generate/module` v2 (PR #1499) to scaffold the receiving module, then fill in handlers — proves the recursive bootstrap end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(docs/planning): correct code/delete claim — it already exists; only code/move is missing Adversarial review of #1500 caught: the gap report lists `code/delete` + `code/move` as missing under Priority 5, but `code/delete` is genuinely implemented at `src/workers/continuum-core/src/modules/ code.rs:205` (backed by `FileEngine::delete`). Only `code/move` is absent. Three places fixed: - "Critical missing pieces" table row reduced to just `code/move` with a note about the `code/delete` confusion - "Suggested next-sprint priorities" §5 retitled `code/move` only with the same correction inline - "Alignment with three-primitive doctrine" table row updated with `data:file:moved` as the relevant event surface The underlying premise (need a move/rename command for scaffold reorganization) is sound; only the bundling with `code/delete` was wrong. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…introspection cluster (#1501) * feat(modules/code): code/exists + code/list + code/glob — filesystem introspection cluster Closes the Priority 1 gap from [PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md): the filesystem-introspection seam that blocks a persona from safely running `generate/module` (no way to check for collisions), enumerating files before edits, or listing directories without paying the full `code/tree` recursive cost. # What this PR adds Three new dispatch arms on the existing `code` ServiceModule (the right home — sits alongside `code/read`, `code/write`, `code/edit`, `code/tree`, `code/search`): | Command | Signature | Purpose | |---|---|---| | `code/exists` | `{persona_id, file_path}` → `ExistsResult{exists, kind, size_bytes?}` | Probe before scaffolding — collision check + kind in one call | | `code/list` | `{persona_id, path?, include_hidden?}` → `ListResult{entries: DirEntry[]}` | Flat readdir, directories first, alphabetical within each group | | `code/glob` | `{persona_id, pattern, root?}` → `GlobResult{matches, truncated}` | Glob expansion (`**/*.rs` etc.), workspace-scoped, capped at 5000 matches | Plus three FileEngine methods backing them (`exists`, `list_dir`, `glob_match`) and a `validate_introspect_path` private helper that handles non-existent paths cleanly (PathSecurity::validate_read rejects them; introspection needs to answer "does this exist?" without conflating absence with traversal). # Doctrine followed Per [COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md): - **Module Design Template §3** — typed `Params/Result` shapes with `#[derive(TS)]`, camelCase serde, optional fields with `#[ts(optional)]` - **Concurrency doctrine §4** — multi-thread tokio stress test (`flavor = "multi_thread", worker_threads = 4`) pinning that concurrent introspection on a shared workspace returns consistent results - **Three primitives** — all three are pure **Commands** (request/ response queries against FileEngine, no state, no events) - **Rethink-not-port** — these are designed Rust-first; there's no TS predecessor to port from. Wire shapes follow the existing `code/*` family's conventions for consistency. # Sharp design decisions (the kinks the tests caught pre-merge) 1. **Non-existent paths report `exists=false`, not Err.** The substrate's `PathSecurity::validate_read` rejects missing paths because it canonicalizes — correct for read/write/edit, wrong for introspection. Added `validate_introspect_path` helper that does string-level safety (rejects `..` segments + absolute paths) without requiring existence. 2. **Glob filters explicitly via Override.matched().is_whitelist().** First implementation walked all files and emitted everything — gave 11 matches when 10 were expected. Fix: explicit per-entry whitelist check; files only (skip directories + scan root); standard_filters + hidden=true excludes dotfiles by default (matches Unix shell intuition). 3. **list_dir sorts directories first, then files, alphabetical within each group.** Predictable order matters for persona reproducibility — a generator that picks "first available name" must get the same answer every run. 4. **Glob result capped at GLOB_MAX_MATCHES (5000)** with `truncated: true` flag. A runaway `**/*` shouldn't OOM the caller; partial results are still useful and the cap is observable. 5. **Hidden file behavior diverges between list_dir and glob.** `code/list` includes hidden when `include_hidden=true` (explicit opt-in). `code/glob` always excludes hidden (matches Unix shell default — `**/*.rs` shouldn't surface `.git/*.rs`). Documented on each type. # Tests (30/30 pass — 22 pre-existing + 8 new) New tests in `src/workers/continuum-core/src/code/file_engine.rs::tests`: **exists (4)** - `exists_reports_file_with_size` — happy path with size - `exists_reports_directory_without_size` — directory has no size - `exists_reports_false_for_missing_with_no_error` — absence != error - `exists_rejects_path_outside_workspace_via_path_security` — traversal blocked **list_dir (5)** - `list_dir_returns_flat_listing_directories_first` — ordering invariant - `list_dir_excludes_hidden_by_default_includes_when_asked` — both modes - `list_dir_reports_file_size_only_for_files` — per-kind size policy - `list_dir_rejects_non_directory_path_loud` — clear error on misuse - `list_dir_for_missing_path_returns_not_found` — missing != success - `list_dir_handles_empty_directory_cleanly` — zero entries OK **glob (5)** - `glob_matches_files_by_extension_recursively` — `**/*.ts` works - `glob_scoped_to_subdirectory_via_root_param` — root narrows scope - `glob_with_no_matches_returns_empty_not_error` — 0 matches OK - `glob_rejects_bad_pattern_loud` — malformed pattern fails clearly - `glob_rejects_root_outside_workspace_via_path_security` — traversal blocked **concurrency (1)** - `introspection_under_concurrent_load_returns_consistent_results` — 32 parallel exists+list+glob ops on a shared workspace, all return stable counts (10 files, 10 matches) regardless of concurrent siblings. Per field manual §4.2 — multi-thread tokio, not single-threaded. All 22 pre-existing FileEngine tests still pass (no regression). # ts-rs bindings 5 new types are annotated with `#[derive(TS)]` + `export_to`: - `ExistsResult.ts`, `ListResult.ts`, `GlobResult.ts` - `DirEntry.ts`, `FsEntryKind.ts` These auto-generate next time `cargo test --release export_bindings` runs (per the existing `generate-rust-bindings.ts` flow). The pending CI guard for ts-rs drift (task #62) is the right place to catch any future drift here. # What this PR explicitly does NOT do - **Does NOT add TS wrapper commands** in `src/commands/code/exists/` etc. The Rust ServiceModule + IPC bridge is the canonical surface per [[rust-is-the-core-node-is-the-shell]]. TS wrappers can be added in a follow-up if/when browser ergonomics need them. - **Does NOT add `code/delete` or `code/move`.** Those are PERSONA-AS-DEVELOPER-GAP.md priority 5 (Small). FileEngine.delete already exists internally; the dispatch wiring is the only gap. Separate PR. - **Does NOT add the `continuum-core/build` + `test` cluster** (gap report priority 2). That's the next sprint — needs cargo `--message-format=json` parsing into typed envelopes. - **Does NOT add `events/command-completed`** (gap report priority 3). Largest scope item; needs its own design discussion. # References - [docs/planning/PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md) — Priority 1 cluster this PR ships - [docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md) §3 (Module Design Template) + §4 (Concurrency doctrine) - [docs/architecture/MODULE-CATALOG.md §0](docs/architecture/MODULE-CATALOG.md) — `code` module's row gains three commands when this PR + the gap report land - Memory: [[three-primitives-commands-events-persona]], [[alignment-via-substrate-economics]] — these commands are routable + discoverable, composing naturally with future intra-grid sharing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(bindings): land ts-rs output for the code/exists+list+glob types Auto-generated by `cargo test --release export_bindings` after the preceding commit added the Rust types with `#[derive(TS)]`. Brings the TS wire-shape surface into sync with the Rust dispatch shipped in the parent PR (#1501). # What this adds - `DirEntry.ts` — `{ name, path, kind: FsEntryKind, sizeBytes? }` - `ExistsResult.ts` — `{ success, exists, filePath, kind?, sizeBytes? }` - `FsEntryKind.ts` — `"file" | "directory" | "symlink" | "other"` - `GlobResult.ts` — `{ success, pattern, matches, totalMatches, truncated }` - `ListResult.ts` — `{ success, directoryPath, entries: DirEntry[], totalCount }` - Updates `src/shared/generated/code/index.ts` barrel to export the five new types # Why split into its own commit The Rust-side commit is the substantive change; the binding files are deterministic outputs of the ts-rs derive macros. Keeping them in a separate commit makes the diff easier to audit (Rust logic + tests in one commit, generated wire shapes in another) and matches the pattern from PR #1488 (the cell-shapes binding fixup). Task #62 (CI guard for ts-rs binding drift) remains the right long-term answer; until then, this kind of follow-up commit closes the gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(modules/code): escape `*/` glyph in GlobResult docstring breaking TS build Adversarial PR review caught: a literal `**/*` glyph in the Rust docstring round-trips through ts-rs verbatim into a JSDoc block in `shared/generated/code/GlobResult.ts`, where the `*/` substring at column 57 prematurely closes the comment. `npm run build:ts` fails with TS1131 + TS1160; that blocks the validate CI job + npm start for the whole canary tree. Fix: replace the glyph spellings with the words "double-star slash star" in two places (one in the field doc, one in the const doc). Regenerated `GlobResult.ts` no longer contains the hazard. Per [[every-error-is-an-opportunity-to-battle-harden]]: the docstring also flags task #62 ("ts-rs binding drift CI guard") as the proper substrate-level fix — a regex check against `*/` in generated `.ts` doc blocks would have caught this class of bug mechanically. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…hain wrappers (#1502) * feat(modules/cargo): cargo/build + cargo/test — structured Rust toolchain wrappers Closes Priority 2 from [PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md): Rust iteration parity with TypeScript. Personas can now build + test their own scaffolded modules and get the same structured feedback density Joel gets from `npm run build:ts` / `cargo test`. # What this PR adds New stateless `cargo` ServiceModule (`src/workers/continuum-core/src/modules/cargo/`): | Command | Signature | Returns | |---|---|---| | `cargo/build` | `{package?, features?, release?, working_dir?, timeout_ms?}` | `{success, errors: CargoMessage[], warnings: CargoMessage[], exit_code?, duration_ms, error?}` | | `cargo/test` | `{package?, filter?, features?, lib_only?, release?, working_dir?, timeout_ms?}` | `{success, passed, failed, ignored, measured, failures: string[], build_errors: CargoMessage[], exit_code?, duration_ms, error?}` | Plus 6 ts-rs-exported wire types: `CargoBuildParams`, `CargoBuildResult`, `CargoTestParams`, `CargoTestResult`, `CargoMessage`, `CargoSpan`. # Doctrine followed (per [field manual](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md)) - **Module Design Template §3** — typed `Params/Result` shapes with `#[derive(TS)]`, camelCase serde, optional fields with `#[serde(skip_serializing_if = "Option::is_none")]` + `#[ts(optional)]` - **Concurrency doctrine §4.1** — module is stateless; cargo manages its own target-dir locking (concurrent invocations on the same target dir serialize at cargo's level; different target dirs stay parallel). When correctness lives BELOW the module, the module-level lock is unnecessary. - **Concurrency doctrine §4.2** — multi-thread tokio stress test (`flavor = "multi_thread", worker_threads = 4`) fires 8 parallel real-cargo subprocess invocations through `run_with_timeout` and asserts every result is internally consistent (no plumbing corruption under concurrent spawn/wait). - **Three primitives** — both commands are pure **Commands** (request/response). When the Stream cell shape lands (gap report priority 4), `cargo/build/stream` and `cargo/test/stream` can follow as line-by-line variants. - **Rethink-not-port** — designed Rust-first; no TS predecessor. # Sharp design decisions (the kinks the tests caught pre-merge) 1. **`parse_summary_counts` had to scan within each chunk** for the first `<int> <label>` pair, not require positional indices 0 and 1. libtest's summary line includes a verdict prefix in the first chunk: `"ok. 22 passed; 1 failed"` or `"FAILED. 22 passed; 1 failed"`. Positional parsing got 0 every time. Test `summary_counts_handles_failed_verdict` pins it. 2. **Failures-block exit condition was wrong.** Initial impl exited on lines containing `:` — but test names ARE `module::path::test` which contains `::`. Fix: enter on `failures:`, capture single- token lines that contain `::` (strong "this is a Rust test name" heuristic), exit on next `test result:`. Test `parse_test_captures_failure_names_in_order` pins it. 3. **libtest emits TWO `failures:` blocks per failing binary** — first with `---- foo::b stdout ----` decorators + panic stdout, second with the bare test-name list. Parser captures from both forms (skipping decorator lines), then dedupes by first-seen order. Test `parse_test_dedupes_failures_across_repeated_blocks` pins it. 4. **Timeout clamping is hard-capped at substrate level.** `BUILD_MAX_TIMEOUT_MS = 900_000` (15 min); `TEST_MAX_TIMEOUT_MS = 1_800_000` (30 min). Higher values silently clamp — prevents a runaway persona from holding the substrate forever. Defaults (5min / 10min) cover typical iteration loops. 5. **Subprocess output captured concurrently with `wait()`.** Using tokio tasks for stdout/stderr read avoids the classic deadlock where the child fills its pipe buffer waiting for us to read while we wait for it to exit. # Composability with the grid (the alignment payoff) Per the gap report's "later parts of the vision" section: both result envelopes are flat camelCase JSON, trivially serializable across airc's grid. A persona on Joel's M-series Mac can call `cargo/test` against a module a persona on a peer's RTX 5090 just authored — result envelope routes back on the same Commands/Events bus. The substrate already routes commands across peers; this PR makes the wire shape grid-friendly. See [[alignment-via-substrate-economics]] — once `events/command-completed` (gap report priority 3) lands, build/test attribution becomes observable in real time, closing the loop from "I built this" to "the grid knows I built this." # Tests (29/29 pass) **parse_build_messages (5)** — fixture cargo JSON lines: - E0382 with code + primary span + rendered - Warnings separate from errors - Non-diagnostic reasons skipped (compiler-artifact, build-finished) - Non-JSON lines tolerated - Diagnostic without primary span (linker errors) **parse_test_output (5)** — fixture libtest output: - All-pass summary extraction - Failure-name capture in order - Multi-binary aggregation (sum across summaries) - Dedup across repeated failures blocks - Empty output returns zero counts (vacuously success) **parse_summary_counts (2)** — edge cases: - "filtered out" tail field tolerated - FAILED verdict prefix doesn't break positional parsing **timeout (2)** — defaults + clamping to max **types (5)** — camelCase round-trip, defaults, optional-omission, lib_only flag, failure-order preservation **dispatch (2)** — config advertises cargo/ prefix; unknown command surfaces typed error **end-to-end (1)** — real `cargo --version` subprocess pipeline **concurrency stress (1)** — 8 parallel real `cargo --version` invocations on multi-thread tokio, every result consistent **ts-rs exports (6)** — wire bindings auto-generated # What this PR does NOT do - **Does NOT add TS wrapper commands.** Rust ServiceModule + IPC bridge is the canonical surface per `rust-is-the-core-node-is-the-shell`. - **Does NOT stream output.** Returns single envelope at end. Streaming is gap report priority 4 — needs Stream cell shape implementation. - **Does NOT manage per-persona workspaces.** Takes optional `working_dir` (default: process cwd). Per-persona workspace isolation is an orthogonal layer (`workspace/resolve` command for a future PR). - **Does NOT depend on libtest's JSON output** (`-Z unstable-options`). Parses stable human-readable test output. When libtest stabilizes JSON output, can upgrade to structured per-test events in a follow-up. - **Does NOT scaffold via `generate/module --stateful` invocation** for the dogfood demo. Hand-authored matching the v2 template shape exactly. A future PR can swap in a literal generator invocation as a build-time scaffold step. # References - [docs/planning/PERSONA-AS-DEVELOPER-GAP.md](docs/planning/PERSONA-AS-DEVELOPER-GAP.md) Priority 2 (this PR) — Priority 1 was code/exists+list+glob (#1501) - [docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md](docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md) §3 (Module Design Template) + §4 (Concurrency doctrine) - [docs/architecture/MODULE-CATALOG.md §0](docs/architecture/MODULE-CATALOG.md) — new `cargo` row to add when this lands - Memories: [[three-primitives-commands-events-persona]], [[alignment-via-substrate-economics]], [[continuum-thesis-airc-is-the-medium]] Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(modules/cargo): register CargoModule with Runtime so cargo/* commands actually dispatch Adversarial PR review caught: `pub mod cargo;` was added to `modules/mod.rs` but the production wire-up in `ipc::start_server` never called `runtime.register(Arc::new(CargoModule::new()))`. Net effect: `cargo/build` and `cargo/test` would return "Unknown command — No module registered for this command prefix" at runtime. The unit tests passed because they instantiate `CargoModule::new()` directly and call `handle_command`, bypassing the runtime registry entirely. The PR shipped dead code from the caller's perspective — the title's deliverable didn't work end-to-end. Fix: add the missing import + register call alongside the other ServiceModule registrations in `ipc/mod.rs::start_server`, sandwich between `ForgeModule` and `EventsModule` for consistency with the existing ordering. Per [[every-error-is-an-opportunity-to-battle-harden]]: the proper substrate-level fix is a CI guard that asserts every `pub mod foo;` in `modules/mod.rs` is paired with a `runtime.register(Arc::new( FooModule::new()))` call somewhere in `ipc/mod.rs`. Filed as a follow-up task — the dispatcher's silent miss on an "Unknown command" prefix is exactly the class of bug that mechanical checks should catch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
… + auto-install (#1504) * feat(modules/airc): headless socket discovery via `airc ipc-endpoint` + auto-install continuum-core-server's standalone boot ("moment-of-truth" test per `headless-rust-must-work-soon` memory) surfaced one concrete break: AIRC daemon attach stream stopped: failed to attach to airc daemon: daemon not reachable: No such file or directory (os error 2) Root cause: `src/workers/continuum-core/src/airc/daemon_endpoint.rs` derives `/tmp/airc-ipc-v<N>-<sha12>.sock` from a hash of the home dir. The airc daemon binds `~/.airc/runtime/airc-machine-<account-hash>- v<N>.sock` under its actual resolution rules. The two never match. Joel's direction (2026-05-31): > "Need to work together with airc installations where it is. So it > is independent of continuum. And continuum uses its install. And > installs it if not installed. Because most people won't have it." Substrate-correct fix: stop deriving, start asking. airc#1095 lands `airc ipc-endpoint` — a CLI surface that prints the resolved socket path so external clients can attach without re-implementing airc's resolution. This PR consumes that surface from continuum-core + auto-installs airc when missing. ### What ships - `src/workers/continuum-core/src/airc/discovery.rs` (new) — `discover_airc_socket()` with resolution order: 1. `$AIRC_DAEMON_SOCKET` env override 2. `airc ipc-endpoint` if airc is on PATH 3. Auto-install via `curl -fsSL .../install.sh | bash` + retry 4. Typed `DiscoveryError` (InstallFailed | AutoInstallDisabled | EndpointCommandFailed | EmptyPath) with actionable remedy in each variant Opt-out: `CONTINUUM_DISABLE_AIRC_AUTOINSTALL=1` suppresses the installer (CI, hermetic builds, distros that vendor airc). - `AircModule::discover_and_construct()` (new async constructor) — runs discovery, falls back to in-memory store on failure so the other 34 modules still boot. Loud warning quotes the discovery error so the operator's next step is obvious. - `daemon_endpoint::default_socket_path_in` marked `#[deprecated]` with migration pointer + module-level explanation of the drift bug. - `ipc::start_server` switches `AircModule::new()` to `rt_handle. block_on(AircModule::discover_and_construct())`. block_on is safe here — we're on the main bootstrap thread, not inside a tokio task. ### Verification (manual end-to-end on this branch) $ rm -f /tmp/hctest.sock && \ target/release/continuum-core-server /tmp/hctest.sock > boot.log 2>&1 & $ grep "Discovered airc daemon" boot.log Discovered airc daemon socket via `airc ipc-endpoint` socket_path="/Users/joel/.airc/runtime/airc-machine-2012e155624a8250-v5.sock" # No more "daemon not reachable: ENOENT" — discovery path works. $ AIRC_DAEMON_SOCKET=/tmp/explicit.sock \ target/release/continuum-core-server /tmp/hctest.sock 2>&1 | grep "override" Using AIRC_DAEMON_SOCKET override for airc daemon socket path="/tmp/explicit.sock" $ PATH=/usr/bin:/bin CONTINUUM_DISABLE_AIRC_AUTOINSTALL=1 \ target/release/continuum-core-server /tmp/hctest.sock 2>&1 | grep "discovery failed" airc socket discovery failed — AIRC inbound attach disabled. ... error=auto-install suppressed via CONTINUUM_DISABLE_AIRC_AUTOINSTALL=1 — install airc manually: curl -fsSL .../install.sh | bash # Process stays alive — degraded but booted. $ cargo test --release --lib --features metal,accelerate airc::discovery test airc::discovery::tests::install_disabled_error_quotes_install_url_and_opt_out ... ok test airc::discovery::tests::env_override_short_circuits_discovery ... ok test airc::discovery::tests::empty_endpoint_output_is_distinct_error ... ok test result: ok. 3 passed; 0 failed. ### Next concrete break revealed (follow-up, not in this PR) With the discovery break fixed, the next attach error becomes visible: `AIRC daemon attach stream stopped: attach requires a channel in the owner-core model`. AttachRequest::default() no longer satisfies the daemon — explicit channel required. Tracked in continuum task #81 as the next slice (battle-harden the iterate- on-the-moment-of-truth loop). ### References - airc#1095 (sibling PR) — adds `airc ipc-endpoint` command - Memories: `headless-rust-must-work-soon`, `continuum-thesis-airc-is-the-medium` (airc is the cooperation medium, not a vendored library), `every-error-is-an-opportunity- to-battle-harden`, `agent-review-as-acceptable-approval` (the adversarial-reviewer pattern this PR uses for sign-off) - ALPHA-GAP §0A line 706 ("useful even with no web interface running … without Node being required for the core worker loop") - Field manual: docs/architecture/COMMAND-INFRASTRUCTURE-FIELD-MANUAL.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * nit(airc): deprecation note lists remaining callers + deletion condition Per adversarial reviewer's non-blocking note on #1504: the `#[deprecated]` on `default_socket_path_in` didn't say when the function can be deleted. This commit lists the two remaining callers (`AircModule::with_daemon_home`, `airc_runtime_e2e_tests. rs`) so future migrators know the deletion-eligibility condition. Pure note expansion — no behavior change, no API change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…headless fix (#1505) Iterating on the moment-of-truth test. With #1504 (socket discovery) landed, the next concrete break surfaced: AIRC daemon attach stream stopped: failed to attach to airc daemon: attach requires a channel in the owner-core model Per `airc-daemon/src/server.rs:274` + `airc-ipc/src/request.rs:144` docstring: the owner-core router subscribes PER CHANNEL — no global fan-out table. AttachRequest.channel is mandatory; clients attach once per room they care about. Continuum was sending `AttachRequest::default()` (no channel), which worked under an earlier model the substrate has since left behind. ### What ships - `discover_default_channel()` — parses `airc room` stdout for the scope's current room `channel: <uuid>` line + returns the UUID. Honors `$AIRC_DEFAULT_CHANNEL` env override (UUID) for tests + multi-room operators pinning the first attach. Robust to whitespace + alt-capitalization (`Channel:`, `CHANNEL:`); fails loud (UnparseableChannel error) if airc renames the field. - `AircModule::attach_channel: Option<RoomId>` new field, populated by `discover_and_construct` alongside the socket path. `initialize` spawns the daemon attach only when BOTH a socket AND a channel are available — partial degradation rather than boot failure. - `inbound_attach::spawn_daemon_attach` + `run_daemon_attach` take a `channel: RoomId` and put it in `AttachRequest.channel = Some(_)`. Single caller updated; no other code paths. - 4 new unit tests for the parser (typical airc room output, alt capitalization + whitespace, missing channel line, non-UUID after label) — 7 discovery tests total. ### Verification (manual end-to-end on this branch) $ rm -f /tmp/hctest.sock && \ target/release/continuum-core-server /tmp/hctest.sock > boot.log 2>&1 & $ grep -E "Discovered airc" boot.log Discovered airc daemon socket via `airc ipc-endpoint` socket_path="/Users/joel/.airc/runtime/airc-machine-…-v5.sock" Discovered airc default channel via `airc room` channel=11c1a7ac-cb85-5ca0-a5b4-2847280ea3fa # No more "attach requires a channel in the owner-core model" warning. $ cargo test --release --lib --features metal,accelerate airc::discovery test result: ok. 7 passed; 0 failed. ### Next concrete break revealed (follow-up #82, not in this PR) The attach now connects + passes the channel gate. Next-layer error: `AIRC daemon attach stream stopped: failed to read airc daemon event: Semantic(None, "missing field 'event'")` CBOR Response variant shape changed between continuum's pinned airc-ipc SHA (428f9281…) and the live daemon. Likely fix: SHA bump in src/workers/Cargo.toml after the AttachRequest channel change lands on airc canary. Tracked separately so this PR can ship the single, complete fix for break #2. ### Pattern Iterate-on-moment-of-truth: each fix uncovers the next layer; each PR is one well-scoped substrate change with end-to-end verification + a tracked follow-up for the next surfaced break. Three breaks revealed so far (1504, this PR, #82); breaks 1 + 2 fixed. ### Follow-ups (filed) - airc-side: `airc room --print-channel` flag (mirror the `airc ipc-endpoint` pattern) so continuum's stdout parser can be replaced with a stable contract. Note in the parser docstring. - continuum #82: CBOR Response shape mismatch / SHA bump. - continuum: multi-room attach (one daemon_attach task per channel when continuum rooms become first-class — currently single-room). ### References - airc owner-core model: `airc-daemon/src/server.rs:274`, `airc-ipc/src/request.rs:144` (AttachRequest docstring), `airc-lib/tests/common/mod.rs` (model description). - continuum#1504 — sibling PR (socket discovery) — this PR's prerequisite, already landed on canary. - airc#1095 — sibling PR (`airc ipc-endpoint`), pending Windows CI. - Memories: `headless-rust-must-work-soon`, `continuum-thesis-airc- is-the-medium`, `every-error-is-an-opportunity-to-battle-harden`, `agent-review-as-acceptable-approval`. - ALPHA-GAP §0A line 706 — headless target. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Carl install path (curl install.sh | bash) fetches install.sh from main via GH Pages. main is 79 commits behind canary including critical install fixes. Promoting.