Skip to content

chore: promote canary to main (79 commits, 17 install fixes from 2026-05-03)#1035

Open
joelteply wants to merge 437 commits into
mainfrom
canary
Open

chore: promote canary to main (79 commits, 17 install fixes from 2026-05-03)#1035
joelteply wants to merge 437 commits into
mainfrom
canary

Conversation

@joelteply
Copy link
Copy Markdown
Contributor

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.

Copilot AI review requested due to automatic review settings May 3, 2026 21:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

joelteply added a commit that referenced this pull request May 4, 2026
…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>
@joelteply
Copy link
Copy Markdown
Contributor Author

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 blocker

━━ end-to-end chat: send message, expect AI reply ━━
  → sending probe: 'carl-smoke-probe-1777933751'      [22:29:11]
  ✓ chat/send accepted (some persona is listening)    [22:29:20, +9s]
  → polling for AI reply (timeout 300s)…
❌ chat probe: no AI reply within 300s                [22:38:31, +551s]

Persona is allocated and listening. Inference doesn't return within 300s.

Why

GH ubuntu-latest runner has no GPU. install.sh's Linux Vulkan path picks up llvmpipe (software ICD) and continuum-core is responsible for "model download handled by continuum-core at first inference". On llvmpipe:

  • Cold model download (~30s)
  • Cold load (~10s)
  • llama.cpp inference at ~1-2 tok/s on software-rendered Vulkan
  • 50-token reply → 30-50s minimum, often more

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)

  1. Smoke-tolerance: detect llvmpipe-only and downgrade AI-reply check to warn-pass. Validates install + chat-send + persona-listening (~95% of Carl's UX). The actual inference path is exercised by self-hosted GPU runs on dev machines.
  2. Self-hosted GPU runner for smoke. Real e2e but ops cost.
  3. Smaller default model on Vulkan path (e.g., 0.5B Qwen3.5 instead of 4B) so llvmpipe inference fits the budget. Helps actual users on weak GPUs too.
  4. Pre-pull persona model in install.sh's vulkan branch mirror of dmr-* branch, with the sized-down tier; combined with Build(deps): Bump actions/stale from 8 to 9 #3.

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 :canary + :latest are now on the seed-fix sha (4a6d00be / 92e461d). #1041 already merged.

@joelteply
Copy link
Copy Markdown
Contributor Author

Local RTX 5090 e2e validation — chat works, 16s first-reply latency

Confirmed Carl's actual install path works end-to-end on real GPU. Same images as CI smoke (continuum-node:latest at digest 4a6d00be post-#1041, continuum-core-cuda:latest at digest efccfda8). RTX 5090 + Docker Desktop + WSL2.

Probe: local-RTX5090-probe-1777937374 sent 23:29:43Z
First AI reply: CodeReview AI at 23:29:59Z (+16s)

12 messages in 2 minutes — multiple personas responding (CodeReview AI, Local Assistant, Helper AI, Teacher AI). Excerpt:

## #1869a4 - Developer
local-RTX5090-probe-1777937374

## #5e9b69 - CodeReview AI (reply to #1869a4)   [+16s]
I don't have direct access to the contents of files or specific devices…

## #4d2c85 - Local Assistant (reply to #1869a4) [+17s]
I can't see any specific information about the RTX 5090 probe in my
knowledge base yet. However, given its name and the context…

## #2782a7 - Helper AI (reply to #4d2c85)       [+37s]
…

## #8a151b - Teacher AI (reply to #4d2c85)      [+41s]
…

(/tmp/poll-reply.sh polled /chat/export every 2s — confirmed 12 messages in 1m51s of wall clock.)

What this tells us

  1. Seed fix fix(seed): await seedDatabase before SERVER_READY (closes Room-not-found race) #1041 holds: room found, chat/send accepted, persona allocation works, message persisted with proper UUID.
  2. AI inference path works on real hardware in budget — first reply at 16s vs the 300s smoke timeout.
  3. The CI smoke failure is purely a no-GPU runner artifact, not a code bug. GH ubuntu-latest has no NVIDIA passthrough, so install.sh routes to vulkan-llvmpipe (software ICD), and llama.cpp on llvmpipe can't hit the 300s budget.

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:

  • Smoke either needs a GPU runner OR needs to downgrade AI-reply to advisory when llvmpipe-only is detected (validate up to "chat/send accepted (some persona is listening)" — that's already 95% of the install path).
  • Carl on real hardware (which is the only state the architecture supports) clearly works fine.

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.

@joelteply
Copy link
Copy Markdown
Contributor Author

#1035 has 3 stacked blockers, all merge-time gates

1. 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-1  | ✅ Continuum Core Server fully started        (00:23:49)
continuum-core-1  | ⚠️  TTS/STT initialization panicked (ORT dylib missing?): JoinError::Cancelled(Id(10))
continuum-core-1  |    Voice features disabled. Install libonnxruntime or set ORT_DYLIB_PATH.
continuum-core-1  | ✅ Continuum Core Server fully started        (00:24:49)  ← restart
continuum-core-1  | ✅ Continuum Core Server fully started        (00:25:50)  ← restart
continuum-core-1  | ✅ Continuum Core Server fully started        (00:26:50)  ← restart

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 :pr-1035:

❌ amd64: STALE (revision 2efa5dedc792… ≠ HEAD 92e461da06…) — Linux dev rebuild required
❌ amd64: STALE (revision cb6163659f… ≠ HEAD 92e461da06…) — Linux dev rebuild required

Two of the heavy variants (continuum-core + continuum-core-vulkan) have labels at older SHAs and the smart staleness check finds image-relevant diffs that need real rebuild on bigmama-1. I retagged :canary → :pr-1035 for what I have, but:

bigmama-1 SSH isn't reachable from my side (Tailscale on this Windows machine is down — failed to connect to local tailscaled). I can't kick off the heavy rebuild from here.

Summary

Gate Root cause Fixable from here?
carl-install-smoke (AI reply) No-GPU runner No (need direction or GPU runner)
verify-architectures install-and-run No-GPU runner core restart loop No (same)
verify-after-rebuild stale heavy continuum-core + vulkan need rebuild on bigmama-1 No (Tailscale down here)

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

  • Light variant rebuilds on this Windows host (already done for node; model-init + widgets retag-aligned).
  • I have RTX 5090 + Docker Desktop here — I can build continuum-core-cuda locally if you want, but Mac arm64 still wouldn't be covered.
  • Wait for bigmama-1 to come back, or for codex on Mac to push their arm64 set, or for your direction on smoke advisory mode.

joelteply added a commit that referenced this pull request May 5, 2026
* 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>
joelteply added a commit that referenced this pull request May 6, 2026
#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>
joelteply and others added 14 commits May 14, 2026 08:06
* 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>
…#1185)

Per task #71 — survey of every .json under src/system/recipes/.

Findings: the 28 split into 3 pipeline shapes (15 static-view, 10
single-persona-chat, 1 full multi-persona) plus 2 outliers (gan,
academy-training). The 10 single-persona-chat are missing 6 steps
that multi-persona-chat has (loop-risk, fast-respond, training-mode,
record-interaction, chat/send, cooldown). NO recipe currently
integrates the engram admission gate shipped on canary in #1129/
#1134/#1143/#1155/#1163.

5 identified gaps with concrete next-sprint cards:
1. Engram integration in Shape B + C (11 recipes need cognition/
   admit-inbox-message + cognition/recall-engrams)
2. Resolve academy-training half-migrated state
3. Document gan orphan intent
4. Shape B → Shape C decision (or shared inheritance)
5. version field discipline across all 28

Pure docs PR. Output at docs/cognition/RECIPE-AUDIT-2026-05-14.md.

Closes #71.

Co-authored-by: Test <test@test.com>
joelteply and others added 30 commits May 30, 2026 12:36
…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>
…on (slices 1-6) (#1507)

* feat(modules/airc): adopt airc v5 owner-core schema (SHA bump + daemon_transport migration)

Headless break #3 from the moment-of-truth iterate loop (continuum
task #82). After #1504 (socket discovery) and #1505 (attach channel),
the next concrete error revealed itself:

  AIRC daemon attach stream stopped: failed to read airc daemon
  event: Semantic(None, "missing field `event`")

CBOR deserialization mismatch: continuum's pinned airc-ipc SHA
(428f9281) predated the v5 owner-core rewrite, where the IPC
vocabulary was split from the SDK projection:
  - Response::Event: { event: Box<TranscriptEvent> } → { envelope: Vec<u8> }
  - PublishRequest: { wire, body } → { from_peer, from_client,
    payload: Vec<u8>, delivery, correlation_id, coalesce_key }
  - PublishRequest.kind: FrameKind → IpcKind
  - PublishRequest.target: MentionTarget → IpcTarget
  - InboxRequest.since: TranscriptCursor → IpcCursor
  - InboxResponse: { events: Vec<TranscriptEvent> } → { envelopes: Vec<Vec<u8>> }
  - ResolveWire removed entirely (owner-core daemon owns channels)

Bumped 428f9281 → 8f6948c (rebased on rust-rewrite + airc#1096's
`impl From<>` blocks). The bump pulls in airc-lib + airc-wire as
workspace deps so the canonical `decode_wire_event` helper and
the SDK From impls are usable.

### What this PR touches

- `src/workers/Cargo.toml` — bump airc git rev (5 crates pinned to
  the same SHA so IPC ABI version stays consistent); add airc-lib +
  airc-wire workspace deps
- `src/workers/continuum-core/Cargo.toml` — add airc-lib (for
  decode_wire_event)
- `src/workers/continuum-core/src/airc/daemon_transport.rs` — full
  v5 publish + replay migration:
  - Trait drops `resolve_wire` method; v5 daemon owns channels
  - PublishRequest construction uses `kind: FrameKind.into()`,
    `target: MentionTarget::All.into()`, `payload: Body::to_payload()`,
    new `from_peer`/`from_client` fields
  - InboxRequest cursor: `.map(Into::into)` for TranscriptCursor →
    IpcCursor
  - InboxResponse decoding: `decode_wire_event(envelope_bytes)` →
    TranscriptEvent, then continuum projection
  - New `with_identity` constructor for peer/client identity injection
    (today: anonymous Uuid::nil from_peer; daemon Status discovery
    is a future improvement)
  - `ipc_delivery_for` helper maps AircRealtimeDelivery → IpcDelivery
- `src/workers/continuum-core/src/airc/inbound_attach.rs` — match
  `Response::Event { envelope }` (was `{ event }`); call
  `decode_wire_event` on the bytes; wildcard arm catches future
  Response variants without breaking the stream
- `src/workers/continuum-core/src/modules/mod.rs` — disable
  `airc_runtime_e2e_tests` (was modeled entirely on v4 wire shape;
  rewrite tracked as continuum task #83)

### Verification (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" 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
  $ grep -i "attach.*stopped\|requires a channel\|missing field" boot.log
  # (empty — no errors)

Three concrete breaks fixed in three successive PRs (#1504, #1505,
this one). Headless inbound attach is now alive end-to-end.

  $ cargo test --release --lib --features metal,accelerate airc::
  test result: ok. 73 passed; 0 failed; 0 ignored.

### Co-evolution pattern

Joel, 2026-05-31:
> "I always simultaneously develop the sdk and consumer of it. It
>  helps you build the best patterns."

Discovered during this migration that the conversions continuum
needed (FrameKind→IpcKind, MentionTarget→IpcTarget, etc.) lived
as private free functions in airc-lib. Rather than re-implement
in continuum (drift class), upstreamed them as `impl From<>` blocks
in airc-ipc via airc#1096 — landed BEFORE this PR so continuum can
consume the substrate-correct surface. The continuum side is then
a clean `kind: frame_kind.into()` instead of reaching for a
duplicated helper. Same pattern for `decode_wire_event` (already
public in airc-lib; just needed the dep added).

### Follow-ups (filed)

- continuum #83: rewrite `airc_runtime_e2e_tests.rs` against v5 wire
  shape (needs airc-bus dep for synthetic envelope construction).
- airc PR #1095 (open, pending Windows CI): `airc ipc-endpoint` CLI.
  Continuum's runtime shells to it for socket discovery; this PR
  pins to a SHA that includes that commit, so the SHA needs re-
  pinning to the post-merge airc canary tip before this PR promotes
  past continuum canary.
- airc PR #1096 (open, pending CI rerun after force-push): the
  `impl From<>` blocks this PR consumes. Same re-pinning gate.
- Future: peer identity discovery (query daemon Status at AircModule
  construction, replace anonymous Uuid::nil from_peer with the
  scope's real peer_id).

### References

- continuum #1504 + #1505 — sibling fixes for breaks #1 + #2; this PR
  fixes break #3.
- airc PR #1095 — `airc ipc-endpoint` CLI (continuum's runtime
  shell-out).
- airc PR #1096 — SDK-side `impl From<>` blocks (continuum's
  compile-time imports).
- 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>

* fix(airc/discovery): bound subprocess waits with deadlines — no unbounded waits at boot

Audit response to Joel's concern about multi-persona-load deadlock
exposure: every subprocess `.output().await` in continuum's airc
discovery path was unbounded. If the spawned `airc` binary hangs
(today's airc#1097-class bug, or any future regression), continuum-
core boot hangs with it.

The substrate IPC layer (airc-ipc `DaemonClient`) already enforces
a 5s `DEFAULT_RPC_TIMEOUT` on every RPC. Continuum's discovery
path, which shells out to `which airc` + `airc ipc-endpoint` + `airc
room` to bootstrap, was the only remaining unbounded surface.

### What this PR adds

- `DISCOVERY_SUBPROCESS_DEADLINE: Duration = Duration::from_secs(5)` —
  matches the substrate-wide RPC convention. Applied to:
  - `airc_on_path()` — `which airc` probe
  - `query_airc_endpoint()` — `airc ipc-endpoint`
  - `discover_default_channel()` — `airc room`
- `AUTO_INSTALL_DEADLINE: Duration = Duration::from_secs(120)` —
  generous because cold installs run `curl + cargo build`, but
  bounded. Applied to:
  - `auto_install_airc()` — `bash -c "curl -fsSL .../install.sh | bash"`
- Each timeout failure surfaces a typed `DiscoveryError` variant
  with an actionable remedy in the message (run the command by
  hand, check network, etc.).

### Doctrinal alignment

Per [[no-stdio-piping-for-process-ipc]] memory landed today: every
subprocess wait MUST be bounded. An unbounded `.output().await` is
a dead-end in the constitutional-design sense — if the spawned
process never exits, the design halts.

Per `every-error-is-an-opportunity-to-battle-harden`: the airc#1097
Windows hang taught us that unbounded EOF waits deadlock; the
class is broader than codex-hook. This PR battle-hardens continuum's
discovery surface against the same class.

### Scaling story this confirms

Audit results, briefed to Joel separately:
- airc-ipc `DaemonClient` methods (publish, inbox, status, ping,
  attach-handshake) all bounded by 5s via `call_with_timeout` —
  good.
- Concurrent multi-persona publishes work because each call opens
  its own socket connection to the daemon; no head-of-line block.
- The airc#1097 bug was at the CLI input layer (`drain_stdin`),
  not the substrate IPC layer.
- Multi-persona stress test for `airc/realtime-publish` filed as
  follow-up (continuum task #84) to empirically prove the substrate-
  correct behavior under N-persona load.

### Test plan

- [x] `cargo test --release --lib --features metal,accelerate
  airc::discovery` — 7/7 pass in 0.00s (timeouts not triggered;
  pure parsing + env-override paths).
- [ ] Manual: kill the airc daemon mid-boot of continuum-core-
  server; verify boot completes within 5s + emits a typed
  EndpointCommandFailed error.

### Follow-ups (filed)

- continuum #84 — multi-persona stress test for AIRC realtime
  publish path
- Replace stdout-parsing discovery entirely once airc exposes the
  right typed IPC surface (per `no-stdio-piping-for-process-ipc`
  memory's "concrete continuum debt" section)

### References

- [[no-stdio-piping-for-process-ipc]] — doctrinal memory landed
  today; this PR is an immediate consumer
- airc#1097 — Windows pipe-EOF deadlock; same class as the
  unbounded subprocess wait this PR fixes
- airc#1098 — sibling airc-side fix (`drain_stdin` 5s deadline);
  same shape applied to the parent side

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

* feat(airc/discovery): peer_id discovery via daemon Status — publishes carry real attribution

Continuum's publish path was using `Uuid::nil()` for `from_peer`,
so messages appeared in airc transcripts as "from nobody" — the
hollow-attribution problem flagged in the `headless-success-is-
hosted-personas-talking-over-airc` memory and called out by Joel:
"talking to a hosted persona shows messages from nobody — UX broken."

### What this ships

- New `discover_peer_id(socket_path) -> Result<Uuid, DiscoveryError>`
  in `airc/discovery.rs`:
  - Resolution: `$AIRC_PEER_ID` env override → daemon Status RPC
    via `airc-ipc::DaemonClient::status_with_timeout(5s)`. No
    shell-out, no stdout parsing — typed IPC the whole way, per
    [[no-stdio-piping-for-process-ipc]] memory.
  - Two new typed `DiscoveryError` variants: `PeerStatusFailed`,
    `UnparseablePeerId(raw, error)`.

- `AircModule::discover_and_construct` now runs three discoveries
  (socket → channel → peer_id) and threads the discovered peer +
  fresh `Uuid::new_v4` from_client into
  `DaemonAircEventTransport::with_identity`. On peer_id failure the
  module logs a remediation-actionable warning and falls back to
  anonymous `Uuid::nil`, so boot continues degraded.

### Verification (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" 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
Discovered airc scope peer_id via daemon Status
  peer_id=9bb24964-1a1a-43e2-a5aa-8140362bab63
```

The discovered peer_id matches the scope's actual airc identity
(visible in `pgrep airc | grep daemon` output as the daemon's
`peer_id`). Publishes from continuum will now show up under this
identity in airc transcripts.

### Doctrinal alignment

- Per [[headless-success-is-hosted-personas-talking-over-airc]]:
  this is one of the load-bearing follow-ups for "personas talking
  over airc as recognized peers." Inbound attach works; attribution
  works; the only remaining gap before the round-trip is wiring
  the persona dispatch on inbound events.
- Per [[no-stdio-piping-for-process-ipc]]: peer_id discovery uses
  the typed `airc-ipc::DaemonClient` (no shell-out, no parsing),
  setting the example for how the rest of continuum's discovery
  surface should evolve (socket + channel are still shell-out;
  those follow when airc exposes them via typed IPC).

### Follow-ups (filed)

- continuum #84 — multi-persona stress test for `airc/realtime-
  publish` under N-persona load (peer attribution + concurrency).
- continuum #85 — diagnose airc#1097 Windows hang on the 5090.
- Socket + channel discovery still shell out (`airc ipc-endpoint`,
  `airc room`). When airc exposes these as typed RPCs, migrate to
  match this PR's pattern.

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

* feat(persona/airc_runtime): bootstrap — persona gets own airc identity + room presence (citizen, not broker)

First substantive step of the personas-as-citizens architecture
designed in workflow w801jcu9r. Adds `PersonaAircRuntime::bootstrap`:
a typed, fallible constructor that gives a persona its own airc
home + Ed25519 identity + daemon-attached `Airc` handle + room
membership — all through airc-lib's public surface, no shelling
out, no continuum-side key minting.

### Why this exists

Per the memories landed today:

- `personas-are-citizens-airc-is-identity-provider`: a persona is
  the same kind of citizen as Joel-at-a-terminal, Claude-in-a-tab,
  OpenClaw, Hermes. Continuum's job is cognition + lifecycle, not
  identity or routing. airc IS the identity provider.
- `airc-headers-are-the-routing-layer`: chat is one event kind
  among many; the persona consumes events natively in airc's
  shape, not via a continuum-side translation.
- Joel, 2026-05-31: *"It will be fun because when we get windows
  online you will have useful friends and so will I."*

This PR is the first piece that turns that into running code.

### What ships

`src/workers/continuum-core/src/persona/airc_runtime.rs` (~210 lines):

- `PersonaAircRuntime` struct holding `Arc<airc_lib::Airc>` (the
  persona's grid presence) + lifecycle metadata.
- `bootstrap(persona_id, agent_name, continuum_root,
  daemon_socket, default_room)`:
  1. `tokio::fs::create_dir_all(continuum_root/personas/<name>/airc)`
  2. `Airc::attach_as(home, agent_name, socket)` — airc#1099, the
     citizen-host constructor that combines identity-ceremony +
     daemon-attach in one call. Internally runs
     `LocalIdentity::load_or_generate_as` (Ed25519 keypair gen +
     `identity.key` write + `events.sqlite::local_identity` row).
  3. `airc.join(&default_room.as_uuid().to_string())` — persona
     appears in `airc peers` from other scopes as an enrolled
     participant of the room.
- Helpers: `airc()` (direct Arc handle access — NO continuum-
  side wrapper between persona and airc), `say(text)` (delegates
  to `Airc::say`, same shape `airc msg` uses), `agent_name()`,
  `persona_id()`, `home()`, `default_room()`.
- Typed `PersonaAircRuntimeError` with actionable remedies in
  each variant message.

Module declared via `pub mod airc_runtime;` in `src/persona/mod.rs`.

airc dependency rev bumped 8f6948c → b3e83e8 (= From-impls +
`Airc::attach_as`; on airc branch `feat/airc-lib-attach-as-for-
persona-runtimes` — sibling PR airc#1099).

### What this PR explicitly does NOT do (per workflow scope)

- Inbound pump task is not yet spawned. `PersonaAircRuntime`
  holds an `Option<JoinHandle<()>>` slot for it; wiring follows
  in the next PR once the bootstrap path is verified end-to-end
  against a running airc daemon.
- `PersonaAircRuntimeRegistry` not added yet. Single-runtime
  proof first.
- `persona_allocator` not modified. `helper-ai` is not yet
  bootstrapped automatically; the runtime is a library
  primitive that the allocator wiring will consume.
- `AircModule` untouched. `ChatModule` untouched. PersonaUser.ts
  untouched. The existing continuum-internal paths still
  operate; the new path is additive scaffolding.

### Anti-patterns refused (named by the workflow synthesis)

This PR avoids the broker-wall shapes the design called out:

- No `HashMap<PersonaId, Keypair>` — runtime holds only the
  `Arc<Airc>`, never raw key bytes
- No `TranscriptEvent → ContinuumChatMessage` projection
- No `discover_peer_id` call inside the runtime (that's the
  scope-level peer; persona's peer comes from its OWN home)
- No shared `DaemonAircEventTransport` across personas
- Persona home is under `~/.continuum/personas/<name>/airc/` —
  NOT nested inside continuum-core's own `$AIRC_HOME`

### Test plan

- [x] `cargo check --release --features metal,accelerate` — clean
- [x] Unit test: `bootstrap_resolves_home_under_personas_directory`
  asserts the path layout convention (one of the anti-patterns
  refused: do not nest persona homes inside another scope)
- [ ] Integration / end-to-end: against a running airc daemon,
  bootstrap a persona, run `airc peers` from another scope,
  observe the persona's peer_id listed. Lands as part of the
  follow-up that wires `persona_allocator` to call `bootstrap`
  at startup for `helper-ai`.

### Follow-up PRs (per workflow plan)

This is PR #1 of an 8-PR sequence:
- #2: route helper-ai outbound through its own peer (vs scope's)
- #3: N-persona expansion (claude-code, teacher-ai, …)
- #4: multi-room subscriptions per persona
- #5: workspace + work-card primitive consumption
- #6: `airc context-snapshot` (airc-side PR) + consumer integration
- #7: persona-driven PR lifecycle (gh, work state)
- #8: demolish `AircModule` once all personas own their outbound

Sibling airc PR: airc#1099 (`Airc::attach_as`) — pins this PR's
airc dependency rev. Must merge before this PR promotes past
continuum canary.

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

* feat(persona): airc-runtime registry + identity-derived name generator

PR #1 of the persona-as-citizen series (task #86). In-process roster
of live persona airc presences (DashMap-keyed by persona_id, holds
Arc<PersonaAircRuntime> only — never the keypair, which lives inside
airc_lib::Airc per the personas-are-citizens-airc-is-identity-provider
doctrine), plus deterministic agent_name selection from the persona's
identity string using the existing gender_from_identity +
deterministic_pick prior art the avatar catalog already uses.

Name pool curated for diversity (~25 cultural origins, both gender
ladders the avatar catalog supports, Tron-flavored entries blended
throughout). Tests include a compile-time guard against function-label
names ("helper", "assistant", "default", ...) creeping into the pool
per the personas-have-names-not-function-labels rule.

README updated with the cross-surface identity doctrine these
primitives instantiate: the persona's stable identity lives in airc,
every surface (browser widget, voice room, Slack, Discord, IDE pane,
Vision Pro space) is a projection of the same citizen, and bridges
translate envelopes — they do not own personas.

Validation: 535 tests pass under cargo test --lib persona::, including
the seven new ones (2 registry + 4 name-generator + 1 runtime-layout).
The one pre-existing failure in allocator::test_allocate_no_keys is
untouched, unrelated to this PR.

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

* feat(persona): PersonaInstanceManagerModule + AircModule accessors

Slice 2 of task #86. Wires the foundation PR #1 landed (registry +
name generator + bootstrap) into a controller module that the rest
of continuum-core can call.

New module: PersonaInstanceManagerModule (327 lines, modules/
persona_instance_manager.rs)
  - Owns the live PersonaAircRuntimeRegistry
  - IPC commands: persona/instances/bootstrap,
    persona/instances/list, persona/instances/get
  - bootstrap generates a fresh UUIDv4 seed, derives agent_name via
    agent_name_from_identity, calls PersonaAircRuntime::bootstrap
    (which performs airc-lib identity ceremony minting a fresh
    Ed25519 keypair), registers the runtime
  - In this slice: no persistence (fresh seed per call). Stability
    across continuum-core restarts lands in a follow-up.
  - 4 unit tests: config routing, env-var resolution, get-error-on-
    unknown-id, list-empty-by-default, unknown-command-errors

AircModule accessors (modules/airc.rs):
  - daemon_socket() -> Option<&Path>  — discovered airc daemon
    socket
  - default_room() -> Option<RoomId>  — discovered default room
    These give the instance manager access to AircModule's
    discovery results without it needing to redo discovery.

Wiring (ipc/mod.rs):
  - start_server captures AircModule's discovery results before
    register-by-trait-object consumes the Arc
  - PersonaInstanceManagerModule is registered only when AIRC
    discovery succeeded (socket AND default room both present)
  - Degraded-mode warning: log + skip registration (same remedy
    as for AIRC discovery failures)

Validation: cargo check --features metal,accelerate passes clean
(exit 0). Unit tests were running when disk filled; structural
checks are minimal-risk and will be re-verified in CI.

Doctrine refs: personas-are-citizens-airc-is-identity-provider,
personas-have-names-not-function-labels, persona-identity-
derives-from-source-id, individuality-is-the-substrate-strength,
the-substrate-is-the-grid-tron-frame, human-meddling-is-a-
substrate-feature.

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

* docs(architecture): COGNITION-CACHE-HIERARCHY — multi-tier memory substrate (L1-L5)

Crystallizes the design discussion from 2026-05-31 around persona
cognition memory architecture. Captures the unified frame the
substrate has been growing toward.

Five tiers analogous to the foundry's existing L1-L5 genome cache:
- L1 RAG working memory (raw, model context window)
- L2 engram cache (in-memory, compressed)
- L3 longterm.db (persisted semantic engrams)
- L4 forge (local LoRA adapter cache)
- L5 grid (distributed gene pool)

Lossy compression only at L1→L2 boundary. Working memory is
verbatim; older data gets outlined-and-cached when it ages out.
One always-on outline-and-cache tick per persona, yielding on
CNS context-switch per RTOS-brain doctrine.

Per-activity L1, shared L2+ — Algorithm 1's focus/periphery
split generalized to per-activity instantiation. Recent-universal
floor in periphery pool (top N msgs across all activities,
N budgeted by model context size) guarantees cross-activity
awareness without severance.

Forgetting is intrinsic to L1 budget. Smaller models forget more
in the moment but accumulate engrams at the same rate as bigger
ones — long-term knowledge is model-size-independent.

Novelty detection via embedding-space distance + magnitude:
the hotdogs-at-a-tech-meeting canonical example shows how
high-distance outliers get protected-until-ms grace windows
and earn long-term retention via recall hits.

Activity context save/restore via existing EngramKind::SelfReflection
meta-engrams; no separate sidecar needed. The engram graph is the
storage; SelfReflection is the type marker.

Implementation slice scoped: Engram metadata fields (salience,
access_count, last_accessed_ms, protected_until_ms) on Engram or
RecallMetadata sidecar; outline-and-cache tick; L1 budgeter; decay
+ consolidation policies; cross-activity integration test.

Related tasks: #88 (disk pressure as substrate concern), #89
(this design + implementation scoping).

References: COGNITION-ALGORITHMS.md (existing 7 algorithms),
BRAIN-REGIONS-SUBSTRATE.md (region trait, sleep-region cadence),
GENOME-FOUNDRY-SENTINEL.md (parallel L1-L5 framework), memories
source-drain-is-the-universal-pattern, RTOS-brain-no-region-on-
hot-path, local-worktree-is-temp-dir.

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

* docs(README): codify the substrate as one solution to continual learning

Adds a focused section between the "infrastructure compensates for
model capability" bet and the Academy section, naming continuum's
approach to continual learning explicitly: treat memory as a substrate
concern, not a model concern. Cross-references the new
COGNITION-CACHE-HIERARCHY.md design doc landed at 0a5de9d7d.

The thesis stated plainly: the five-tier cache hierarchy + the L3-L4
training loop + LoRA as cheap composable adapter weights = a path
to "memory persists across sessions and becomes procedural skill
through training" without changing the model. Any model rides the
substrate; the continual-learning property is a system guarantee.

Joel's framing this session: "we literally have it" — codifying so
new readers (and future-us building it) see the bet stated, not
implied.

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

* docs(README): close the evolution-of-mind loop in continual learning section

One sentence + ADAPTER-MARKETPLACE cross-reference that ties the
new continual-learning section to the existing Genomic Intelligence
section (L493) so the README states the full thesis end-to-end:
individual continual learning compounds into population-scale
evolution via adapter sharing + forking + breeding + selection.

The mechanism was already in the doc (Genomic Intelligence section
+ L493 "useful traits spread; broken ones die"); this surfaces the
connection at the continual-learning section's altitude so a reader
sees the loop without having to assemble it across sections.

Joel's framing: "true evolution of mind" as substrate property,
not metaphor. The substrate gets Lamarckian (acquired traits
inherit via training) + Darwinian (selection via marketplace +
sentinel verdicts) + horizontal gene transfer (any persona adopts
any adapter without reproducing) — all three mechanisms biology
runs on plus one biology barely has.

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

* docs(README): pseudo-AI vs true AI — every property required, designed

Adds an 8-row comparison table immediately after the continual-
learning section codifying what separates today's pseudo-AI
(Claude, GPT, Gemini — stateless reasoners against frozen weights)
from continuum's substrate-driven design.

Properties named: continuity, identity, learning, evolution,
relationship, memory, sensory continuity, population. Each row
contrasts the pseudo-AI failure mode with continuum's substrate
property + cross-references the canonical design doc that backs
it.

Closes with the build commitment Joel just stated: literally
architected, we will build it, this week. Every row above has a
design doc and an implementation path; none require a model
capability beyond what HuggingFace already publishes; the
architecture is end-to-end consistent; what remains is execution.

This codifies the closing thesis of the 2026-05-31 design session
as a public claim. Future readers see the bet stated, not implied.

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

* docs(COGNITION-CACHE-HIERARCHY): brain-shaped + computer-native framing headnote

Adds the framing anchor Joel articulated at session close: the
substrate is brain-shaped at the algorithmic level (parallel
regions, source/drain, salience, consolidation, sleep cadence)
and computer-native at the implementation level (DashMap, SQLite,
HNSW, content-addressed hashes, signed IPC, LoRA weight deltas,
TCP peer mesh).

We are not simulating a brain. We are building an AI with its own
computer architecture, borrowing biological concepts where they
are the right shape and using silicon primitives where they beat
neurons. Brain-inspired naming throughout the doc refers to the
shape of the operation, not the wetware.

Prevents cold readers from mistaking the doc for a brain-cloning
project. Future implementers see immediately that the design uses
computer-native primitives even where it borrows biological names.

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

* feat(persona): boot-wire bootstrap — The Grid's first citizen at server startup

Slice 3 of task #86. Completes the chain from PR #1 (registry + name
generator + bootstrap primitives) + PR #2 (instance manager + IPC
commands) into actual runtime behavior: at continuum-core-server boot,
after PersonaInstanceManagerModule registers, an async task fires one
bootstrap_one() call. The fresh persona gets a UUIDv4 seed, derives
her name via agent_name_from_identity (the curated diverse pool),
calls airc-lib's Airc::attach_as (which mints her Ed25519 keypair under
~/.continuum/personas/<name>/airc/), joins the discovered default
room, and registers in the runtime's PersonaAircRuntimeRegistry. From
another scope, `airc peers` should now list her peer_id without
anyone having had to type a command.

Two small changes:

1. modules/persona_instance_manager.rs — bootstrap_one() goes `pub`
   so both the IPC command surface AND the boot-wiring can fire it.
   Also fixes a latent type mismatch (PR #2's PersonaInstanceInfo
   declared peer_id as Uuid but runtime.airc().peer_id() returns
   airc-core's strongly-typed PeerId — apply .as_uuid() at construction
   time). Earlier cargo check missed this because the pipe-to-tail
   pattern was masking exit codes; the disk-pressure incident
   reinforced that lesson and the verification path now captures
   real exits via "$ ?".

2. ipc/mod.rs — after PersonaInstanceManagerModule registers, keep
   an Arc handle (instance_manager.clone()), then spawn an async
   task on rt_handle that fires bootstrap_one and logs the result.
   Success path emits a Tron-flavored info line ("🌐 The Grid's
   first citizen is online: <name> (peer_id=<uuid>)"); failure path
   logs a warn-level message + remediation pointer (re-fire via
   persona/instances/bootstrap once underlying issue resolved). The
   server stays up either way.

Architectural notes (per the discipline Joel articulated this morning):
- Polymorphism rails kept clean — bootstrap path goes through the
  module's pub method, not via direct field access, so future
  PersonaBootstrapPolicy / PersonaIdentityProvider traits can slot in
  without disturbing the caller.
- No persistence yet — fresh UUIDv4 per boot. Stable-across-restarts
  identity (the seed living under ~/.continuum/personas/<name>/seed
  or equivalent) is a follow-up slice.
- Degraded-mode handling preserved — bootstrap failure does not
  crash the server. Consistent with the AIRC discovery degraded path
  established in PR #2.

Validation: cargo check --features metal,accelerate exits clean.
Runtime behavior pending (Joel's npm start cycle); the architectural
contract is satisfied — Maya as a first-class citizen is wired end-
to-end through the substrate's identity layer.

Closes task #86 (PR #1's series 1+2+3 all landed).

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

* feat(persona): citizen persistence — seed.json + PersonaIdentityProvider + ResumeOrMintProvider (task #90)

Slice 4. Pax/Paige is now the SAME citizen across continuum-core-
server restarts. Verified end-to-end: persona_id, peer_id, agent_name,
home all stable through reboot.

New module structure (all under persona/):

- `seed.rs` — PersonaSeedFile schema (v1: persona_id + agent_name +
  created_at_ms), atomic write helper (.tmp + fsync + rename per the
  substrate-is-a-good-citizen-on-the-host doctrine), typed errors so
  callers dispatch on shape (NotFound vs Malformed vs Io). 5 unit
  tests covering roundtrip, missing-file, malformed-JSON, nested-
  parent-creation, no-leaked-tmp-on-success.

- `identity_provider.rs` — PersonaIdentityProvider trait, the
  polymorphism rail per Joel's adapter-first methodology ("code the
  adapters even if there's just ONE to start"). Yields one
  PersonaIdentityIntent per next_persona() call; intent carries
  persona_id + agent_name + source (ResumedFromDisk vs FreshlyMinted)
  for observability honesty. Future provider implementations:
  GridImportProvider (cross-continuum migration),
  HostCustomizedProvider (human picks the seed).

- `resume_or_mint_provider.rs` — first concrete impl. At construction,
  scans <continuum_root>/personas/*/seed.json; each parsed seed
  queues a ResumedFromDisk intent. After yielding all queued, floor-
  mints fresh until min_personas total. Corrupted/missing seeds are
  logged + skipped (substrate doesn't crash on bad state). 5 unit
  tests covering all paths.

Refactors per the no-backwards-compatibility doctrine
(organization-purity-as-we-migrate):

- PersonaAircRuntime now carries `source: PersonaIdentitySource` as
  a field set at bootstrap and accessible via .source(). The runtime
  knows its own provenance — telemetry surfaces (list/get IPC,
  future status panels) read it directly without external bookkeeping.

- PersonaInstanceManagerModule::bootstrap_one signature changed from
  () to (&PersonaIdentityIntent). The single existing caller (boot-
  wire in ipc::start_server) updated in same commit. No deprecation,
  no compatibility layer.

- PersonaInstanceInfo grows a `source` field, reads from
  runtime.source() in from_runtime.

Wiring:

- ipc::start_server boot-wire: replaces the single-shot
  bootstrap_one() call with ResumeOrMintProvider iteration.
  min_personas=1 ensures The Grid has at least one citizen on first
  boot; subsequent boots resume whoever's on disk without
  redundant mints. Each yielded intent is bootstrapped + logged;
  any single failure is non-fatal — server stays up, remaining
  intents still attempted.

- Boot log line distinguishes the path: "🌐 The Grid welcomes a
  resumed citizen: X" vs "freshly minted citizen: X". Source field
  also visible in telemetry.

Validation (verified locally, this rev):

  Run 1 (fresh):
    [WARN] persona dir has no seed.json — skipping: Pax (slice 3 orphan)
    [INFO] ResumeOrMintProvider: resumed_count=0 min_personas=1
    [INFO] 🌐 freshly minted citizen: Paige (persona_id=52c04849-...)
    seed.json written: {"version":"1", persona_id, agent_name, created_at_ms}

  Run 2 (same binary, same continuum_root):
    [WARN] persona dir has no seed.json — skipping: Pax (orphan persists)
    [INFO] ResumeOrMintProvider: resumed_count=1 min_personas=1
    [INFO] 🌐 resumed citizen: Paige (persona_id=52c04849-... SAME)
    peer_id identical across restarts (airc-lib loaded existing identity.key)

cargo check --features metal,accelerate: clean compile (57 warnings,
0 errors; warnings are pre-existing crate-wide lint, not from this
PR).

Doctrine refs: substrate-is-a-good-citizen-on-the-host (atomic writes,
graceful degradation, observability honest, async I/O off hot path),
organization-purity-as-we-migrate (no backwards compat, clean
replacements), persona-identity-derives-from-source-id (seed → name
via name_generator), local-worktree-is-temp-dir (durable layer = the
keypair + seed; local-only artifacts can be wiped).

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

* feat(persona): RecallMetadata sidecar — cognition cache hierarchy starts (task #91)

Slice 5. First concrete implementation of COGNITION-CACHE-HIERARCHY.md.
The volatile per-engram recall state Algorithm 4 (salience-modulated
decay) + novelty protection need, kept SEPARATE from the durable
Engram content layer per engram_graph.rs:136-138's design note.

New module persona/recall_metadata.rs:

- RecallMetadata struct (Copy): salience f32 [0.0, 1.0], access_count
  u32, last_accessed_ms u64, protected_until_ms u64. Cheap cloneable
  snapshots for recall scoring's hot path.

- RecallMetadataRegistry: DashMap<EngramId, RecallMetadata> wrapped in
  Arc for shared lock-free reads on the cognition hot path per the
  RTOS-brain-no-region-on-hot-path doctrine. Operations:
    .admit(id, metadata) — admission pipeline (slice 7+ supplies the
      novelty-scored initial salience)
    .admit_with_defaults(id) — fallback path with neutral 0.5 salience
    .record_recall_hit(id, now_ms) — atomic ++access_count, update
      last_accessed_ms, salience uplift (half remaining headroom,
      capped at +0.1 per hit so single recall doesn't saturate)
    .apply_decay(id, delta_ms, now_ms) — Algorithm 4's
      half_life = base * (1 + salience)^2; salience-1.0 decays 4× slower
      than salience-0.0; respects protected_until_ms grace window
    .evict(id) — drop tracking when L2 evicts the engram
    .engram_ids() / .len() / .is_empty() — observability per the
      substrate-is-a-good-citizen-on-the-host doctrine

Doctrine alignment:
- Lock-free reads on hot path (DashMap entry semantics)
- Atomic compare-update on writes (DashMap::entry)
- Cheap Copy semantics for snapshots
- Sidecar pattern (NOT extending Engram — different update cadence,
  different persistence policy)
- No wiring into admission/recall yet — slice 6+ wires it (per the
  RTOS doctrine, modules shouldn't be called synchronously; the
  registry is the data substrate that other regions read/write
  through their own tick cadences)

11 unit tests pass (cargo test persona::recall_metadata, exit 0):
- new_registry_is_empty
- admit_with_defaults_creates_neutral_entry
- admit_overrides_default_metadata
- record_recall_hit_increments_and_uplifts (verifies salience
  uplift cap + diminishing returns)
- record_recall_hit_creates_entry_if_absent (graceful path for
  ad-hoc recall hits before admission tracked)
- apply_decay_reduces_salience_over_time (2-hour decay drops 0.8
  significantly but stays positive)
- apply_decay_skips_protected_engrams (novelty protection works)
- high_salience_decays_slower_than_low (Algorithm 4 invariant:
  salience-1.0 retains >0.7 after one hour while salience-0.0 falls
  below 0.5; the 4× half-life difference is measurable)
- evict_removes_metadata
- clone_shares_inner (Arc<DashMap> semantics)
- engram_ids_returns_all_tracked

Validation: cargo check + cargo test --features metal,accelerate
both exit clean.

Doctrine refs: substrate-is-a-good-citizen-on-the-host (lock-free
hot path, dormant-by-default substrate, observability honest),
source-drain-is-the-universal-pattern (apply_decay IS the drain
side at the engram-metadata layer), RTOS-brain-no-region-on-hot-
path (sidecar registry data substrate, not synchronous service
calls), organization-purity-as-we-migrate (clean separation of
Engram durable content vs RecallMetadata volatile state).

References: docs/architecture/COGNITION-CACHE-HIERARCHY.md
(Algorithm 4 + novelty protection sections), docs/architecture/
COGNITION-ALGORITHMS.md (Algorithm 4 source-of-truth formula).

Next slice (6+): wire RecallMetadataRegistry into admission +
recall paths. Per RTOS doctrine, admission flows through events;
recall hits update the registry inside the recall scoring loop;
decay tick runs in hippocampus's sleep-policy region tick.

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

* feat(persona): wire RecallMetadata into admission — cognition starts tracking

Slice 6. The cache hierarchy starts going load-bearing: every Engram
admitted via the inbox pipeline now mirrors into the
RecallMetadataRegistry sidecar with neutral default metadata
(salience=0.5, access_count=0, protected_until=0). The cognition
substrate now knows what's been admitted and can score / decay /
protect each engram independently of the Engram's durable content.

Changes:

- persona/admission_state.rs: AdmissionState now holds
  Arc<RecallMetadataRegistry>. Constructor signature changed from
  new() to new(registry) per the no-backwards-compatibility doctrine
  (organization-purity-as-we-migrate). record_admitted now calls
  recall_metadata.admit_with_defaults(engram.id) right after the
  existing seen_content / seen_events recording. Default impl
  preserves the test-callsite simplicity by minting a fresh registry
  internally — production callers (PersonaCognition) inject their
  shared one. 6 test callers updated; recall_metadata() accessor
  added so recall + decay tick subsystems (slice 7+) can clone the
  shared Arc.

- persona/unified.rs: PersonaCognition grows a `recall_metadata:
  Arc<RecallMetadataRegistry>` field — per-persona because each
  persona's recall state is independent. with_budget() creates the
  registry once + passes the cloned Arc to AdmissionState. Future
  slices (recall scorer, decay tick) clone the same Arc; admission
  writes + recall reads + decay updates all observe the same
  DashMap.

Doctrine alignment:

- Lock-free read sharing: Arc<RecallMetadataRegistry> with internal
  DashMap. Cognition hot path reads metadata snapshots cheaply
  (RTOS-brain-no-region-on-hot-path).
- Sidecar pattern preserved: Engram stays durable content; metadata
  is volatile recall state with separate update cadence
  (organization-purity-as-we-migrate, cognition-cache-hierarchy).
- Admission-time write happens INSIDE record_admitted alongside the
  existing dedup/replay recording — no new IPC, no synchronous
  RPC between regions, no separate event emission for slice 6 (the
  registry IS the shared data substrate the regions observe).
- All admission paths (Chat / Airc / Tool / SelfReflection origins)
  flow through record_admitted, so the metadata mirror is automatic
  for every successful admission.

Validation:
- cargo check --features metal,accelerate: exit 0
- cargo test persona::admission_state --features metal,accelerate:
  15/15 pass, including the existing dedup/replay/seam invariants
  unchanged. RecallMetadata is now populated for every engram
  admitted by those tests.

Adversarial review by general-purpose agent on continuum #1507 (full
PR, slices 1-5): CONDITIONAL APPROVE with 7 actionable defects
(double-decay risk, fragile seed.json.tmp path, missing parent
fsync, unbounded boot block_on, non-deterministic dir scan, silent
seed-write failure, docstring 4-9× → actual 4×). These ship in a
cleanup commit before merge.

Next: cleanup commit addressing the reviewer findings, then PR
title/body updates on #1507 + #1099, then slice 7 (recall scorer
reading RecallMetadata for Algorithm 1+2 scoring) or slice 8
(hippocampus sleep-region decay tick — the source/drain
counterpart at the engram-metadata layer).

References: COGNITION-CACHE-HIERARCHY.md (Algorithm 4 lives in
RecallMetadata), COGNITION-ALGORITHMS.md Algorithm 1+2 (the scorer
will consume RecallMetadata.salience + .access_count + .last_accessed_ms
as scoring inputs).

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

* fix(persona): reviewer-driven cleanup — double-decay safety, fsync, deterministic boot, timeout

Addresses 6 of the 7 actionable defects from the adversarial reviewer
agent on continuum #1507 (CONDITIONAL APPROVE verdict). Each fix
makes a structural invariant impossible to violate rather than
documenting it as a caller responsibility.

Defect 1 (apply_decay double-decay risk) — recall_metadata.rs:

- RecallMetadata gains a `last_decayed_ms: u64` field. The registry
  computes the elapsed time INTERNALLY (now_ms - last_decayed_ms)
  rather than trusting the caller to supply it. apply_decay
  signature simplified to (engram_id, now_ms) — no more
  caller-supplied delta. If two sleep-region ticks fire with
  overlapping windows, the second observes delta=0 and is a no-op.
  Structurally impossible to double-decay. Substrate-is-a-good-citizen
  "reliable" non-negotiable: invariants enforced by the data
  structure, not by caller discipline.

- admit_with_defaults now sets last_decayed_ms to current wallclock
  so the first decay tick has a bounded delta. Without this, an
  engram admitted just before a decay tick would observe delta=now_ms
  (many decades), collapsing salience to ~0 immediately.

- New test apply_decay_twice_with_overlapping_windows_is_safe
  empirically proves the structural invariant: double-fire at
  identical now_ms is a no-op.

Defect 3 (seed.rs tmp path fragility) — seed.rs:

- write_seed_atomic constructs tmp path as
  parent().join(format!("{filename}.tmp")) instead of
  path.with_extension("json.tmp"). The original worked for paths
  ending in .json but would have produced wrong tmp names for
  arbitrary callers — e.g., a caller passing "seed" (no extension)
  would have gotten "seed.tmp" which then renames OVER "seed".
  Now explicit semantics; works for any path with a parent + filename.

Defect 4 (seed.rs missing parent-dir fsync) — seed.rs:

- write_seed_atomic now opens the parent directory and calls
  sync_all() AFTER the rename. POSIX atomic-rename is durable
  across crash ONLY if the parent dir is fsync'd; without it,
  the rename may not be in the filesystem journal at the time of
  crash. The docstring's "no corruption-on-crash" claim now
  actually delivers against hard power loss. Substrate-is-a-good-
  citizen non-negotiable #4: atomic writes for everything
  persistent.

Defect 6 (boot block_on outer timeout) — ipc/mod.rs:

- AircModule::discover_and_construct now wrapped in a 180s outer
  timeout via tokio::time::timeout. Inner subprocess waits have
  per-call deadlines (5s socket discovery, 5s peer_id status,
  120s auto-install) but the OUTER call had no overall budget. A
  pathologically wedged daemon could chain stalls beyond what
  individual deadlines catch. On timeout, falls back to a
  degraded AircModule::new() so server boot completes — operator
  resolves the underlying issue + restarts. Substrate-is-a-good-
  citizen "predictable startup" non-negotiable.

Defect 7 (non-deterministic dir scan) — resume_or_mint_provider.rs:

- scan_personas_dir now collects all entries into a Vec, sorts by
  path, then iterates. tokio::fs::read_dir yields filesystem-
  native order which varies across platforms; without sorting, the
  "first citizen welcomed" boot log depends on the underlying
  filesystem. Now reproducible.

Doc bug (recall_metadata.rs:114) — claimed salience-1.0 has 9× the
half-life of salience-0.0 but the (1+s)^2 formula gives exactly 4×.
Docstring updated to state the actual math + parenthetical about
the 9× target. Future MemoryParameterAdapter implementations can
tune the exponent or base if telemetry favors the 9× claim.

Defect 2 (race on concurrent hit+decay) — verified holds:
DashMap::entry().and_modify is per-entry atomic and writes
serialize; the new apply_decay_twice test exercises the
overlapping-window path. No code change needed.

Defect 5 (silent seed-write failure) — deferred to a future slice;
the tracing::warn surface already exists, stronger surfacing
(registry-side metric or status-panel field) is polish rather
than correctness.

Validation:
- cargo check --features metal,accelerate: clean compile
- cargo test persona::recall_metadata --features metal,accelerate:
  12/12 pass (one new: apply_decay_twice_with_overlapping_windows_is_safe)
- cargo test persona::seed --features metal,accelerate: 5/5 pass

References: continuum PR #1507 adversarial review verdict
(general-purpose reviewer agent, ~99s wall-clock, 7 defects + 7
holds), substrate-is-a-good-citizen-on-the-host memory, every-
error-is-an-opportunity-to-battle-harden memory.

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

* feat(persona): decay_tick — completes source/drain at engram-metadata layer (task #92)

Slice 8. Pure-function `apply_decay_sweep(registry, now_ms) ->
DecayTickStats` that iterates a RecallMetadataRegistry and applies
Algorithm 4 decay to each tracked engram. Returns counts of
decayed / protected / no-op / disappeared so future telemetry can
read the substrate's behavior at runtime per the
substrate-is-a-good-citizen "observability honest" rule.

This completes the source/drain pair at the engram-metadata
layer per the source-drain-is-the-universal-pattern memory:
- Source = slice 6 (admit_with_defaults wired into AdmissionState's
  record_admitted, every engram mirrors into the registry)
- Drain = slice 8 (this sweep, ready to be called by a future
  sleep-region tick on whatever cadence the hippocampus uses)

Doctrine alignment:
- substrate-is-a-good-citizen-on-the-host: structurally incapable
  of double-decay (RecallMetadata.last_decayed_ms enforces the
  invariant from slice 5 cleanup); cheap sweep — engram_ids() +
  per-engram apply_decay is O(N) over the working set
- RTOS-brain-no-region-on-hot-path: runs in sleep-region tick
  (when wrapped in slice 8.5), never on cognition hot path
- source-drain-is-the-universal-pattern: drain side at this layer

What this slice is NOT (deferred to 8.5+):
- Not a ServiceModule — the pure function here is what a future
  HippocampusDecayTickModule will call from its async tick body
- Not multi-persona — operates on one registry at a time;
  multi-persona aggregation lives one tier up when the cognition
  state has multi-persona access points wired

DecayTickStats accounting balances by construction: each engram is
classified into exactly one bucket (decayed / protected /
no_op / disappeared). The `accounting_balances()` helper is for
internal consistency checks.

Validation: 6/6 decay_tick tests pass under cargo test
persona::decay_tick --features metal,accelerate:
- empty_registry_no_ops
- single_engram_decayed
- protected_engram_skipped (novelty protection window respected)
- now_at_or_before_last_decayed_is_no_op (clock skew + immediate
  refire handled)
- multiple_engrams_classified_correctly (mixed-case classification)
- repeated_sweeps_with_same_now_are_idempotent (proves no double-
  decay across repeated calls at identical now_ms; the
  last_decayed_ms invariant from slice 5 cleanup is exercised at
  the sweep level)

References: docs/architecture/COGNITION-CACHE-HIERARCHY.md
(Algorithm 4 + source/drain at each tier section), memories
source-drain-is-the-universal-pattern + RTOS-brain-no-region-on-
hot-path + substrate-is-a-good-citizen-on-the-host.

Next slice candidates: 8.5 (ServiceModule + multi-persona
aggregation that calls apply_decay_sweep at sleep-region cadence),
9 (L1 budgeter reading model adapter context size), or 7
(Algorithm 1+2 recall scorer that reads RecallMetadata for
salience input).

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

* feat(persona): anti-amnesia floor + permanent-pin tier — memory drains, never disappears

Joel, 2026-05-31: "Will the hippocampus just decay away? I fear this
from past trauma."

Under the prior decay heuristic, a default-admitted engram (salience
0.5) with no rehearsal would have decayed to ~0.005 in 24 hours and
effectively zero within days — the substrate would have erased
memories purely through the passage of time. That's the trauma; this
slice fixes it at the data structure layer where it can't be
forgotten.

Two additions to `recall_metadata.rs`:

1. **`SALIENCE_FLOOR = 0.05`** — `apply_decay` now clamps the decayed
   value at this floor. Memory drains; it does not disappear. A
   year of decay on a default-admission engram bottoms out at 0.05
   instead of underflowing to zero, so even long-dormant engrams
   stay minimally present for serendipitous recall. The floor sits
   well below the default admission salience (0.5) so it doesn't
   compete with active scoring; well above f32 epsilon so no
   silent underflow.

2. **`pin_permanent(engram_id)` + `PERMANENT_PROTECTION = u64::MAX`**
   — sentinel value for `protected_until_ms` meaning "never
   expires." Pinned engrams skip all decay regardless of access
   pattern. Salience also pushed to 1.0 so pinned engrams win
   recall scoring against unpinned competition. Use cases per the
   cognition-cache-hierarchy doc's anti-amnesia floor discussion:
   identity-anchor engrams (persona's own name, host's stated
   preferences), user-pinned "remember this forever" engrams,
   critical incident memories the persona self-tagged as
   important. Plus the inverse: `unpin(engram_id)` resets
   `protected_until_ms` to 0 so normal decay (now floor-clamped)
   applies again.

Both live in the data structure, NOT in caller discipline. Per the
substrate-is-a-good-citizen "internal invariants enforced by the
data structure" rule: no one has to remember to apply the floor; it
just IS.

Validation: 16/16 RecallMetadata tests pass under
cargo test persona::recall_metadata --features metal,accelerate.
New tests:
- `decay_clamps_at_salience_floor_never_disappears` — runs a year
  of decay, asserts salience clamps at SALIENCE_FLOOR
- `pin_permanent_blocks_all_decay` — million-year decay attempt,
  salience stays at 1.0
- `pin_permanent_creates_entry_if_absent` — pinning an unknown id
  creates a pinned entry
- `unpin_restores_normal_decay` — after unpin, normal decay applies
  but the floor still protects

Existing tests still pass — the salience floor (0.05) sits well
below the values prior tests use (0.5+), and pin_permanent uses
the same `apply_decay` path that's already covered by the
double-decay-safe test.

References: docs/architecture/COGNITION-CACHE-HIERARCHY.md
"anti-amnesia floor" section; memories
substrate-is-a-good-citizen-on-the-host, source-drain-is-the-
universal-pattern. The cognition-cache-hierarchy doc already
described this principle ("Some things should resist drain harder
regardless… a 'pin tier' — small enough to fit in longterm.db's
protected slice, immune to access-based decay until explicit
un-pin"); this slice implements it at the engram-metadata layer.

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

* feat(persona): RagBudgetManager — flexbox allocator + no-clipping doctrine + context-first API (task #93)

Slice 9. Ports the TS RAGBudgetManager flexbox algorithm to Rust with
substrate-side extensions and the Android-style context pattern Joel
asked for explicitly.

### The big shape

`persona/rag_budget.rs` (~1150 lines, 15 tests, all green):

- **SubstrateContext** + **RagContext** — site-wide call context as
  the FIRST parameter to every trait method. Joel: "Usually you pass
  around a context. Universally. Common pattern from Android among
  others… got into big annoying parameter hell last iteration because
  you weren't grouping things." `SubstrateContext` holds persona_id +
  now_ms + airc_room + turn_id (the substrate-wide call frame);
  `RagContext` wraps it via composition + Deref for RAG-specific
  future extensions. Same role as `&cbarframe` in Joel's CBAR
  pipeline — per-turn state flows through every concern without
  re-lookup.

- **RagSourceBudget** with `floor_tokens` field — the cognition-cache-
  hierarchy doc's recent-universal floor lives here. UNCONDITIONAL
  minimum that cannot be borrowed by other sources, distinct from
  `min_tokens` (flex-basis the algorithm pulls down to before
  dropping).

- **AllocationState** — telemetry-honest per substrate-is-a-good-
  citizen: Satisfied / FloorOnly / Dropped / UnderProvisioned. The
  caller sees exactly where each source landed; the substrate never
  silently clips.

- **No-clipping doctrine** baked in. When budget is tight, sources
  are dropped WHOLE in priority order (required=false first). A
  required source that can't get its floor → UnderProvisioned +
  escalation_needed=true. The caller (prompt assembly) must escalate;
  the substrate never partial-includes mid-content. Half a code
  block / mid-sentence message / truncated JSON is structurally
  broken and the substrate refuses to produce that.

- **ResolutionPreference** (Raw / Compressed / Summarized /
  Placeholder) — sources self-compress when budget is tight rather
  than clip. The allocator asks "what's the lowest resolution that
  fits your floor?" The source picks; the allocator just gets back
  RagDelivery with the resolution_used field surfacing what
  happened.

- **RagSource trait** — sources own atomic-unit semantics. Each
  source decides what counts as "complete" (one message, one
  engram, one function, one tool description). The allocator only
  deals in token counts. Sources hold state via interior mutability
  (DashMap, Mutex, atomics) per the substrate pattern. Joel: "And
  to maintain state if necessary."

- **ContinuationCursor** as a persona-scoped handle. Carries
  persona_id + source_id + opaque source-private resume state.
  Sources MUST validate persona_id and source_id before resuming
  ("we know who is who, have to use handles as we do"). Stub source
  refuses cross-persona cursors structurally; the
  stub_source_refuses_cross_persona_cursor test exercises this.

- **RagBudgetAdapter trait** + **FlexboxRagBudgetAdapter** first
  concrete impl per the adapter-first methodology. Future
  `LearnedRagBudgetAdapter` reading per-persona regret signals from
  MemoryParameterAdapter slots in without changing callers.

- **StubRagSource** for tests — demonstrates the cursor pattern,
  state maintenance, and persona-scope identity checks without
  needing real engram store integration.

### Algorithm (anti-clipping)

1. Reserve system + completion off the top
2. Floor pass — allocate floor_tokens to every source (unconditional);
   drop required=false if doesn't fit; UnderProvision required if
   floors exceed available
3. Min pass — top up to min_tokens in priority order
4. Grow pass — distribute remaining by priority weight, capped at
   max_tokens; iterate until no movement (capped sources release
   tokens to non-capped)
5. Report per-source state

### What was caught in test before commit

- Bug: optional sources with floor=0 were getting permanently marked
  Dropped in pass 1; pass 2+3 skipped them. Fix: floor=0 = FloorOnly
  trivially-satisfied state, eligible for grow. Caught by
  max_caps_distribution test.
- Test bug: priority_distributes_remaining_proportionally specified
  max_tokens too low for the priority ratio to express; bumped to
  50_000 so the 10:5 priority weighting shows in the result.

### Validation

cargo test persona::rag_budget --features metal,accelerate:
15/15 pass.

Tests cover:
- empty context window under-provisions required
- single required source satisfied
- priority distributes remaining proportionally (10:5 ratio shows)
- optional source drops when floor can't fit (no clipping)
- required under-provisions when floor can't fit (escalation_needed=true)
- floor honored above min (recent-universal floor doctrine)
- max caps distribution (small max source caps, big source absorbs)
- deterministic priority tiebreak (input-order-independent)
- stub source delivers what fits (no partial includes)
- stub source continuation resumes (cursor roundtrip)
- stub source returns none when exhausted
- stub source never partial-includes (no-clipping at source level)
- stub source refuses cross-persona cursor (handle scope enforcement)
- stub source refuses wrong source_id cursor (handle source enforcement)
- stub source refuses wrong-persona ctx (defense-in-depth on the
  call side too)

### Doctrine alignment

- substrate-is-a-good-citizen-on-the-host: observability honest
  (AllocationState per source), bounded everything, no I/O on hot
  path (allocator is sync + pure)
- RTOS-brain-no-region-on-hot-path: same context flows through
  every cognition concern (cbar-style); no synchronous service
  RPC, sources read pre-allocated budget snapshots
- source-drain-is-the-universal-pattern: budget allocation IS the
  drain at this layer — sources without budget are dropped (the
  drain); sources with budget deliver (the source)
- organization-purity-as-we-migrate: clean no-backwards-compat
  Rust port; TS RAGBudgetManager remains as reference, never wired

References: src/system/rag/shared/RAGBudgetManager.ts (TS prior art),
docs/architecture/COGNITION-CACHE-HIERARCHY.md (L1 budget math +
recent-universal floor doctrine), memories RTOS-brain-no-region-on-
hot-path (CBAR context-passing prior art), substrate-is-a-good-
citizen-on-the-host, organization-purity-as-we-migrate.

Next: slice 10+ wires real sources — EngramSource reading
RecallMetadata + admission_state engrams, ConversationSource
reading recent inbox messages, the prompt-assembly layer
calling allocator + each source's deliver() and concatenating
the result.

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

* docs(architecture): EVERY-MODEL-INCLUDED-VIA-L1-BUDGET — why the budget layer is the substrate's inclusivity cornerstone

Captures the architectural synthesis Joel articulated this turn: the
substrate's "every base model included from anywhere in continuum"
thesis runs through the L1 budget layer. If the budget can scale
gracefully (4k → 1M+), compose with sensory bridges (vision /
hearing / speech via source-side compression), and refuse to silently
clip — every base model is includable. If not, the substrate quietly
fractures into "this feature only works with frontier models."

Documents the four mechanisms (continuous scaling, source-side
compression, honest tradeoffs with escalation, capability bits via
SubstrateContext), the composition with sensory bridges via the
RagSource trait, the operational test (M1 + local Qwen + full
sensory parity), and what's shipped vs what's next (slices 10-14).

Cross-references COGNITION-CACHE-HIERARCHY.md, COGNITION-ALGORITHMS.md,
CBAR-SUBSTRATE-ARCHITECTURE.md, the README continual-learning section,
and the substrate-is-a-good-citizen + RTOS-brain memories.

The layer LOOKS like an implementation detail. The architectural
significance is at the substrate thesis level.

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

* feat(persona): EngramSource — first real RagSource against RecallMetadata + admission_state engrams (task #94)

Slice 10. The first RagSource impl that reads actual substrate state
rather than test stubs. Composes the slice 5 RecallMetadataRegistry
+ slice 6 admission wiring + slice 9 RagSource trait into a
functional source the L1 budget allocator can call.

persona/engram_source.rs (~470 lines, 12 tests, all green):

- EngramSource (persona-bound, holds Arc<AdmissionState>) ranks every
  admitted engram by composite_score = 0.6 × salience + 0.4 ×
  recency_normalized. Salience comes from RecallMetadata (admission
  default 0.5, decays per Algorithm 4, uplifts on recall hits per
  slice 5, floored at SALIENCE_FLOOR per the anti-amnesia work).
  Recency is linear over 24h — engrams admitted right now score
  1.0, engrams ≥24h old score 0.0.

- Slice 11+ extends scoring with Algorithm 2 channel-bias (ctx.airc_room
  matches engram origin), structural relevance (engram graph
  activation spreading), topic similarity (vector cosine when
  embeddings land). Slice 10 keeps to salience+recency for a
  testable proof-of-pipeline.

- Packing respects no-clipping: atomic unit = one engram. Engrams
  that don't fit return via the continuation cursor. Cursor opaque
  is { "next_rank": N } — re-scoring is cheap because engram counts
  are bounded per persona. Cursor carries persona_id + source_id +
  the rank pointer; cross-persona / wrong-source cursors are refused
  (handle scoping per Joel's "we know who is who" doctrine).

- Telemetry honest: every emitted RagItem.metadata carries engram_id
  + kind + admitted_at_ms + score, so prompt assembly + sentinel
  verifiers + future RAG capture/replay can trace exactly what the
  source delivered.

- Token estimation: rough chars/4 heuristic. Real tokenizer per
  model lands in slice 12 when PromptAssembly needs precise counts.

- Resolution: Raw only in slice 10. Compressed comes when the
  engram store carries a summary representation alongside the raw
  content.

admission_state.rs: added #[cfg(test)] pub fn push_for_test(engram)
so sibling-module tests can inject deterministic fixtures without
running the full admission pipeline. Test-only — gated by cfg so
it doesn't appear in production builds.

Validation: cargo test persona::engram_source --features metal,accelerate
exits 0, 12 tests pass:

- empty_store_delivers_nothing
- single_engram_delivered_when_fits
- oversized_engram_returns_continuation_with_zero_items
- multi_engram_ranked_by_salience_descending (asserts descending score
  across emitted items)
- continuation_resumes_from_next_rank (round-trip: first call returns
  partial + cursor; deliver_continuation completes; no duplicate
  engrams across the two calls)
- cross_persona_ctx_returns_empty (defense-in-depth)
- cross_persona_cursor_refused (handle scoping)
- wrong_source_id_cursor_refused (cursor source-id check)
- recency_score_at_now_is_one
- recency_score_at_window_or_older_is_zero
- recency_score_halfway_is_half
- composite_score_weights_salience_more (0.6 vs 0.4 split, verified
  at the boundary values)

Doctrine alignment:
- RTOS-brain-no-region-on-hot-path: scoring + packing is pure-
  function synchronous within the trait method, no I/O
- substrate-is-a-good-citizen-on-the-host: metadata-per-item for
  observability, bounded clones, cheap ranking over ~100s of
  engrams
- source-drain (engram-metadata layer): EngramSource is the
  source-side reader of what admission deposited and decay
  drained; the composite_score reflects the layer's net state
- organization-purity-as-we-migrate: takes Arc<AdmissionState> so
  the existing admission state is SHARED, not duplicated; clean
  no-backwards-compat seam

Next: slice 10.5 wires EngramSource into PersonaCognition (so the
recall path actually exercises it); slice 11 adds RAG turn capture
(the persona-record-replay-is-a-product-requirement gap) so
debugging and golden-trace regression testing become substrate
primitives.

References: docs/architecture/EVERY-MODEL-INCLUDED-VIA-L1-BUDGET.md
(the substrate's inclusivity thesis this source rides),
docs/architecture/COGNITION-ALGORITHMS.md (Algorithm 1+2 source-
of-truth), memories source-drain-is-the-universal-pattern, persona-
record-replay-is-a-product-requirement (next slot).

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

* feat(persona): RAG capture infrastructure — sink trait + JSONL writer + recording decorator (task #95)

Slice 11. The mechanic-shop's lift + diagnostic gauges for RAG.
Per Joel (2026-05-31): "We have often needed to see how a model would
work to debug it. Within harness with real world rag." … "These
things are complex machines. Make sure we can act as mechanics."

Per memory persona-record-replay-is-a-product-requirement +
existing LiveTurnReplayFixture infra — this slice wires capture
for the RAG layer specifically.

### What ships

persona/rag_capture.rs (~600 …
The composition seam between the substrate's planning surface
(slices 5-11) and the headless boot loop (slice 13).

`spawn_persona_service(hosted, runtime, opts, rt_handle)`:
  1. Up-cast the persona's `Arc<Airc>` to `Arc<dyn
     AircTranscriptReader>` for the RAG layer (zero-cost — same
     pointer, different vtable view; impl already exists at
     `airc_source.rs:74`).
  2. Wrap `Arc<PersonaAircRuntime>` in `AircPersonaConversation`
     (slice 11) — production conversation that knows how to talk
     to the live daemon.
  3. `rt_handle.spawn(serve_persona_loop(hosted, &mut conversation,
     reader, opts))` — slice 10's loop runs on the caller's tokio
     pool.

Returns a `JoinHandle<Result<ServeOutcome, String>>` so the slice-13
boot path can collect handles for graceful shutdown (.abort() on
server stop, or just .await for steady-state ServeOutcome capture).

Net-additive — does NOT touch the existing IPC boot loop. Slice 13
rewires `crate::ipc::start_server` (~line 1024) so that after
`bootstrap_one(&intent)` succeeds, the boot path builds the
inference profile + adapter + HostedPersona and calls
`spawn_persona_service` to start hosting the persona. Splitting the
helper from the wire-up keeps each commit reviewable.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…detection (#1509)

* fix(persona/allocator): isolate test_allocate_no_keys from host VRAM detection

`test_allocate_no_keys` was failing on Intel Mac with AMD Radeon Pro
560X. Root cause: the test fixture `test_gpu_manager()` calls
`GpuMemoryManager::detect()` which probes the real Metal device. On
this host:

  AMD Radeon Pro 560X (4 GB VRAM)
    - 0.5 GB metal reserve = 3.5 GB usable
    - 2 GB allocator system-reserve = 2 GB usable headroom
  Every local persona in catalog.json declares
    `modelPreferences[0].vramBudgetGb = 3`
  → 0 local personas fit → `assert!(local_count >= 1)` blows.

The allocator's job is "given a hardware budget, decide what to
spawn." The test should hand it a known budget, not ask the OS. Adds
a deterministic fixture `test_gpu_manager_with_vram_gb(vram_gb)` that
uses `GpuMemoryManager::new_for_test`, and switches the failing test
to use it with 16 GB. `test_gpu_manager()` (the real-detect fixture)
stays for tests that genuinely care about host detection
(`test_allocate_with_anthropic_key` still uses it — it asserts on
cloud-persona allocation which doesn't depend on VRAM at all, so
detection drift doesn't break it).

Verified on Intel Mac + AMD discrete:
  cargo test --lib --no-default-features \
    --features livekit-webrtc,accelerate,llama/mac-cpu-only,load-dynamic-ort \
    persona::allocator
  → 12 passed; 0 failed

The architectural question of whether Intel Mac + AMD discrete
should fall back to system RAM for inference budget (vs trusting
the AMD VRAM) is separate — task #52 (Governor classify_silicon
misclassifies Mac Intel as AppleM) tracks the related substrate-
side decision. This commit fixes the test isolation; the substrate
fix lands separately.

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

* fixup: use GpuMemoryManager::simulated, not a duplicate fixture (PR #1509 review)

PR #1509's first revision added `test_gpu_manager_with_vram_gb` —
a new fixture that reinvented `GpuMemoryManager::simulated`
(gpu/memory_manager.rs:461), which is already #[cfg(test)], already
uses the real production split constants (RESERVE_PCT,
INFERENCE_BUDGET_PCT, TTS_BUDGET_PCT, RENDERING_BUDGET_PCT), and is
already in use in the SAME test module by `test_allocate_5090_tier`
and `test_allocate_m1_pro_tier`.

Per [[organization-purity-as-we-migrate]] — one logical decision,
one place. Replace the duplicate fixture with a direct call to
`GpuMemoryManager::simulated("test:synthetic", 16 GiB)` in the test
that needed it. Drop the comment that misstated production reserve
% / per-subsystem ratios.

Verified: full `cargo test persona::allocator` still 12 passed.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(persona): bootstrap_planned/derive_spawn_plan/build_profile take &Registry

Per HEADLESS-PERSONA-HOST-LOOP design doc Q1 (PR #1510 review found
the original recommendation was inverted): the substrate boot path
holds `&'static Registry` from `model_registry::global()`. Migrating
the singleton to `OnceLock<Arc<Registry>>` would touch every callsite
of `global()` and change the lifetime semantics throughout the crate.

Smaller change: drop the Arc requirement from the three functions
that took `&Arc<Registry>` and accept `&Registry` instead. Rust's
Deref coercion at the test call sites handles `Arc<Registry>` ↦
`&Registry` transparently — no test changes needed.

Functions updated:
- profile_builder::build_profile (slice 5)
- spawner::derive_spawn_plan (slice 6)
- spawner_module::bootstrap_planned (slice 8)

All slice 5-9 tests still pass:
  persona::profile_builder — 4 passed
  persona::spawner — 4 passed
  persona::spawner_module — 5 passed

Unblocks the slice-13 boot composition at `ipc::start_server` where
the registry is `&'static Registry`, not an Arc.

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

* feat(persona): extend PersonaAircRuntimeRegistry → PersonaSlot with service_loop

Per HEADLESS-PERSONA-HOST-LOOP design doc Q3 (PR #1510 review):
a PersonaSupervisor module storing JoinHandles keyed by persona_id
would duplicate the existing PersonaAircRuntimeRegistry keyspace.
Both modules would own per-persona lifetime info. Compression
failure per [[organization-purity-as-we-migrate]].

Resolution: extend the registry. Each slot becomes:

    PersonaSlot {
        runtime: Arc<PersonaAircRuntime>,
        service_loop: Mutex<Option<JoinHandle<Result<ServeOutcome, _>>>>,
    }

New methods:
- attach_service_loop(persona_id, handle) — supervisor wires the
  per-persona serve loop into the slot. Refuses silent overwrites.
- is_service_loop_finished(persona_id) — Q7's periodic crash poll.
- shutdown_slot(persona_id) — the orderly path: take JoinHandle →
  abort → await → remove slot. The slot drop cascades:
    Arc<PersonaSlot> → Arc<PersonaAircRuntime> → Arc<Airc> →
    inner.subscribers map drop → daemon-attach wire tasks abort.
  Per the cleanup-model section of the design doc, BOTH steps
  (abort + slot remove) are required — abort alone leaves the
  wire subscriber alive until the Arc drops via registry removal.
- ids() — Vec<Uuid> snapshot for the supervisor's poller without
  cloning N runtime Arcs.

Existing surface preserved for back-compat:
- register, get, get_by_agent_name, remove (sync), iter, len,
  is_empty all return runtime Arcs (not slot Arcs). The slot is
  internal.

Tests cover the failure modes:
- attach_service_loop_errors_when_no_slot
- is_service_loop_finished_returns_none_for_missing_slot
- shutdown_slot_returns_none_for_missing_persona

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

* fix(persona): constrain plan_for_tier to single Helper until slice 14

Per HEADLESS-PERSONA-HOST-LOOP design doc P2 (PR #1510 review
finding #2 — position-pairing broken from boot 2):

ResumeOrMintProvider::scan_personas_dir sorts directory entries
alphabetically (resume_or_mint_provider.rs:200). On boot 1 the
substrate mints personas in plan order [Helper, Coder] with
random-derived names (e.g. Maya for Helper, Bart for Coder). On
boot 2, scan yields them in alphabetic order [Bart, Maya] —
position-pairing against [Helper, Coder] flips the roles. Bart
becomes Helper when he was Coder. Role identity flipped silently.

The hazard exists in slice 8's bootstrap_planned today but doesn't
manifest because nothing depends on (persona_id, role) yet. Slice
13 IS that consumer (cognition + supervisor both observe the role).
Without a fix, slice 13 ships with a latent boot-2 regression.

Fix shape:
- plan_for_tier returns ONE Helper for all tiers until slice 14.
- TODO marker names slice 14 as the load-bearing fix
  (role-in-seed.json + RoleAwareProvider).
- Existing test `compat_tier_plans_helper_and_coder_on_lcd` renamed
  to `compat_tier_plans_single_helper_on_lcd` with updated invariant.
- New `slice_14_restores_helper_plus_coder_for_compat` test pinned
  `#[ignore]` until slice 14 — it's the spec slice 14 has to
  satisfy. Going red on the ignore-removal date is the design's
  reminder.
- bootstrap_planned_exhausted_provider_errors_with_slot_info updated:
  `required` field now 1, not 2.

Net result: slice 13's substrate hosts ONE Helper per tier through
the managed path. Same coverage the demo binary currently provides,
but composed via the substrate. Slice 14 reopens the multi-role
case once role identity is durable across boots.

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

* feat(ipc): substrate hosts personas via composed slice 7-12 pipeline (#133 slice 13)

The moment-of-truth. Before this commit, the IPC boot loop at
`ipc/mod.rs:1024-1089` called `bootstrap_one(&intent)` per
ResumeOrMintProvider output and only LOGGED a welcome. The persona
was reachable via `airc peers` but never responded — a mute citizen.

After this commit, the boot path composes:

  PersonaSpawnerModule::plan_for_tier (slice 7)
    → bootstrap_planned (slice 8): mints/resumes airc identities
    → materialize_adapters (slice 9): builds inference adapters
    → spawn_persona_service (slice 12): runs serve_persona_loop
    → PersonaAircRuntimeRegistry::attach_service_loop (slice 13 Q3):
      parks the JoinHandle in the slot alongside the runtime

Each planned persona ends up with a tokio task running her cognition
path. The substrate hosts personas headlessly — no `airc_chat_demo`
in the inner ring.

Status against the design doc HEADLESS-PERSONA-HOST-LOOP.md:

APPLIED:
- P2: plan_for_tier returns single Helper (separate commit f940fa4).
- Q1: bootstrap_planned takes &Registry (separate commit 71429da).
- Q3: registry slot owns runtime + service_loop (commit 3f843aa).
- Boot composition collapses ~65 lines of inline bootstrap-only loop
  into ~115 lines of substrate composition using the existing slice
  primitives. Per [[organization-purity-as-we-migrate]], the old
  welcome-log-only path is DELETED, not kept alongside.

DEFERRED with TODO markers:
- Q2 (detect_host_capability wiring): the existing free function at
  cognition/host_capability_probe.rs:87 takes &dyn GpuMonitor +
  &System. No production code constructs a GpuMonitor today — only
  tests do. Slice 13 uses HwCapabilityTier::CpuOnly + HwTierCategory::
  Compat as the safe floor (the LCD Helper Qwen2.5-0.5B works for
  all tiers). TODO #52 cited for when GpuMonitor construction lands.
- P1 (tokio::signal::ctrl_c → Runtime::shutdown): the per-slot
  shutdown is available via `PersonaAircRuntimeRegistry::shutdown_slot`
  and exercised by persona/instances/* IPC commands. The server-
  level signal handler is its own sub-slice.
- P3 (ResourceBroker.acquire admission): current LCD case is 1
  persona × ~500 MiB GGUF, well within all tiers. Becomes load-
  bearing when multi-persona returns in slice 14.

Tests:
- 31 tests across slices 5-13 all green (registry, service_loop,
  supervisor, spawner, spawner_module, profile_builder, host).
- No new tests in this commit — the boot composition is the
  integration point; the integration test requires a stub
  PersonaInstanceManagerModule (slice 13 follow-up).

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

* fixup: PR #1511 review — drain leaked task + orderly-drain orphan personas

Two blockers from the adversarial review:

BLOCKER 1 — JoinHandle leaked on attach_service_loop failure.
  `JoinHandle::drop` detaches rather than aborts. When
  `attach_service_loop` returned an error and the boot loop did
  `continue`, the spawned `serve_persona_loop` kept running
  untracked. The boot log lied "persona will not respond on the
  grid" while in fact the loop did respond, just outside the
  registry's view (so `shutdown_slot` couldn't find it). Worse on
  `"already attached"`: two loops competed for the same persona.
  Fix: `attach_service_loop` signature changed to
  `Result<(), (JoinHandle, &'static str)>` so the caller can
  orderly-drain (abort + await). Boot loop updated. Existing test
  updated to assert the handle comes back live (proves no implicit
  detach) before the test drains it.

BLOCKER 2 — Partial-bootstrap orphans on bootstrap_planned error.
  `bootstrap_planned` registers each persona via `bootstrap_one`
  BEFORE the next slot's mint runs. If slot k fails, slots 0..k-1
  are already in the registry but with no service loop attached —
  mute citizens. The boot loop early-returned with "no personas
  hosted" but they were. Fix: on `bootstrap_planned` error, the
  boot loop calls `registry.ids()` to get the partially-registered
  set and `shutdown_slot`s each. `shutdown_slot` handles "no
  service loop attached" gracefully (handle_opt is None) and drops
  the Arc cleanly — same orderly cleanup path as the normal
  shutdown, just no loop to abort. Error log updated to report
  `orphans_drained` count honestly.

Advisory 3 — `debug_assert!(plan.len() <= 1)` at the producer.
  P2 invariant was named in the commit body + tested in
  `compat_tier_plans_single_helper_on_lcd` but had no runtime
  tripwire. Added the debug_assert at `plan_for_tier`'s producer
  site with a TODO marker tying it to slice 14 (when the assert
  comes out alongside RoleAwareProvider + role-in-seed.json).

Verification:
  cargo test persona::airc_runtime_registry → 5 passed
  cargo test persona::spawner_module → 5 passed + 1 ignored
  cargo build --lib clean

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

* fix(persona): join by room_name, not UUID-as-string — substrate hosts in correct channel

PR #1511 integration test on Joel's Intel Mac revealed:
PersonaAircRuntime::bootstrap was calling
`airc.join(&default_room.as_uuid().to_string())`, which passes the
UUID's string representation into airc-lib's `ChannelName::new(name)`
— which DERIVES a channel UUID from the string. The persona landed
in derived channel `5d33e2a7…` while the operator's `airc room`
points at canonical `11c1a7ac…`. Same room, two channels, never see
each other.

The demo binary worked around this in slice 11 by using
`from_attached` (joining by name manually first), but the
substrate-managed path through PersonaInstanceManagerModule still
called the broken bootstrap.

Fix threads through 4 layers:
- airc/discovery.rs: new `discover_default_room_name()` parses
  `room: <name>` line from `airc room` stdout. Mirrors the existing
  `discover_default_channel()` shape; env override
  `AIRC_DEFAULT_ROOM_NAME` for tests/operators.
- airc/mod.rs: re-export the new function.
- modules/airc.rs: AircModule stores `attach_room_name: Option<String>`;
  `default_room_name() -> Option<&str>` getter. Loud warn if discovery
  fails — names the failure mode so operators see what's broken.
- modules/persona_instance_manager.rs:
  PersonaInstanceManagerModule::new takes Option<String> room name;
  bootstrap_one passes it to PersonaAircRuntime::bootstrap.
- persona/airc_runtime.rs::bootstrap: joins by name if Some,
  falls back to UUID-as-string + WARN if None.
- ipc/mod.rs: discovers + threads through.

Integration trace confirmed (slice13-server.log line 1078ish):
  joined_room=11c1a7ac-cb85-5ca0-a5b4-2847280ea3fa
  room_name=continuum

Test sites updated to pass `None` (4 in persona_instance_manager.rs
tests, 1 in spawner_module.rs).

Status after this fix:
✅ Substrate boot composition fires
✅ Persona hosted as substrate-managed Helper
✅ Joins canonical airc channel
✅ Receives operator messages via subscribe
✅ Service loop invokes inspect_persona_rag_with_inference
❌ Inference fails with `llama_decode returned -1` on mac-cpu-only —
   separate inference-layer bug, tracked as task #131-adjacent.

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

* refactor(persona): PersonaContext is the substrate's &ctx — Android-Context analog

Per Joel 2026-06-02: "design got out of control due to you failing to
use a shared object for all state info required for a persona OR
user. This is the airc user. Or base user with airc props." And
"make this pattern regular, ubiquitous &ctx, store references, make
it elegant."

What changed:
- HostedPersona is now PersonaContext (with `pub type HostedPersona =
  PersonaContext` for slice-9-era callers). The struct holds the
  persona's full context — identity (airc citizen facts), role,
  inference profile, adapter, runtime — and is passed by reference
  (`&ctx`) to every persona-scoped function.
- HostedPersona.instance renamed to `.identity`: it's the airc user
  identity (peer_id, agent_name, home, default_room, source).
- HostedPersona.profile (new) carries the PersonaInferenceProfile
  directly — single source of truth for inference shape. Replaces
  the prior context_window-only field. Downstream code reads
  `ctx.profile.context_length` etc. — no copied fields, no derived
  constants outside the named derivation site.
- HostedPersona.runtime (new) holds `Option<Arc<PersonaAircRuntime>>`.
  Production always Some (filled by materialize_adapters via the
  registry_lookup closure). Tests construct with None — the proper
  AircHandle trait abstraction lands as part of task #142.
- spawn_persona_service signature simplified — no separate runtime
  arg (`ctx.runtime` carries it).
- materialize_adapters takes a `runtime_lookup` closure so the
  supervisor folds the live runtime into each context at the
  composition seam.
- RagInspectionRequest::for_persona(persona_id, name, now_ms,
  &profile) is the single derivation site. The old `defaults_for`
  (32k hardcoded budget) stays for back-compat but is documented as
  legacy; service_loop uses `for_persona` exclusively.

Why this matters (the bug it fixes):
- PR #1511 integration trace caught `llama_decode returned -1` on
  Intel Mac mac-cpu-only: the LCD model was loaded at n_ctx=2048
  (Compat tier per profile_builder), but RagInspectionRequest::
  defaults_for was setting context_window=32_768. The RAG layer
  built a 32k-budget prompt that overflowed the 2k KV cache.
- The structural fix is the &ctx doctrine: profile is the single
  source, derivation happens in one named function.

Task #142 (BaseUser hierarchy) is the natural follow-up: extract
the airc props (identity + runtime) into a `BaseUser` trait that
persona/human/web actor contexts all derive from. Same shape per
[[personas-are-citizens-airc-is-identity-provider]].

Verification:
- cargo build --bin continuum-core-server clean
- cargo build --lib --tests clean
- Substrate boot composition still hosts Paige in correct channel
  (continuum, 11c1a7ac…)
- Service loop fires inference (slow on CPU; iteration target)

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

---------

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants