Skip to content

fix(work): a claimed bench card's solve happens IN THE CARD'S ROOM + typed dispatch struct (#425 slice 1) - #2336

Merged
joelteply merged 1 commit into
canaryfrom
fix/claim-dispatches-into-the-cards-room
Aug 19, 2026
Merged

fix(work): a claimed bench card's solve happens IN THE CARD'S ROOM + typed dispatch struct (#425 slice 1)#2336
joelteply merged 1 commit into
canaryfrom
fix/claim-dispatches-into-the-cards-room

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

#425 slice 1. A claimed bench card's solve now happens in the card's own room, and the dispatch takes a typed struct instead of a positional list with two bare UUIDs.

Measured first, because the doctrine doc's premise is wrong

Every workspace-trace record carries room_id. Counting nil vs real across the 25 newest trace files:

turns
roomed 138,088
roomless 13,209 (8.7%)

Per citizen: 90e758b2 8,657 nil / 15,814 roomed — 35% of her cognition happened where no room could see it; fe4dac17 2,133/15,316; e5f4141d 1,177/50,273; a20b3ada 895/489 (more roomless than roomed).

What BENCHMARKS-ARE-ADAPTERS-NOT-A-RUNNER.md overstates: "a detached agent/solve produces no turns, so a citizen can burn 12 acts… and none of it reaches the curriculum." It DOES produce turns. build_workspace_cycle attaches a JsonlWorkspaceCaptureSink for every cycle — live turn, eval fork and solve alike (persona_workspace.rs:551-564); 1,258 trace files, 6.7 GB on disk right now. #427 is the corroboration and I had already accepted it: its whole subject was benchmark forks appending to the SAME traces file as live turns. You cannot contaminate a corpus you do not write to.

So the defect is narrower and still serious: those turns are invisible to the ROOM. No act receipts radiate (apply_act skips a nil room BY DESIGN), so no peer, no human and no ViewState ever sees the work. A citizenship defect, not a curriculum-input one — which is exactly what #425 is named for.

The room was never unknown

Boards are PER-ROOM, so a card's activity is the room whose board holds it — and the wrong-room claim retry directly above already resolved exactly that, inline. Extracted as room_holding_card: one resolver, two consumers, replacing a scan in one place and a shrug in the other.

work/claim now resolves the card's room and dispatches into it. A card no subscribed board holds gets no detached fallback — the claim stands and a work.claim.unplaceable_card probe says why, because inventing invisible work is the thing being removed.

The signature, on two calls from today

"Make sure params are well formed structs and constants, good OOP, not random parameter lists"

The old signature was (ctx, airc, Uuid, WorkCardId, Option<Uuid>) — two bare UUIDs separated only by argument POSITION, so transposing claimer and room compiled cleanly and would have dispatched a citizen's solve under a room's id. Now:

pub(crate) struct StagedSolveDispatch {
    pub claimer: crate::identity::PeerId,
    pub card: airc_work::WorkCardId,
    pub room: airc_core::RoomId,
}

Typed fields make that transposition a compile error, and the next fact this dispatch needs becomes a FIELD, not a sixth argument. ctx + airc stay separate parameters deliberately — they are ambient services, not facts about the dispatch; folding them in turns a value object into a context bag.

"If something is required, remove the option… a required param is caught at compile time not runtime"

room was Option<Uuid> and work/claim passed None — the whole defect, expressed as a type that permitted it. It is now RoomId, so the roomless dispatch is unrepresentable: a caller that cannot name the activity cannot construct the struct. The fallible part is the LOOKUP, which is where fallibility belongs, and it is handled at the boundary before the struct exists.

Adjacent smells named, not silently expanded

  • The dispatch roster still carries bare (String, Uuid) tuples (Add claude/snapshot — persistent memory across sessions #396's subject), so benchmark.rs types the id AT this boundary rather than reaching into the roster.
  • AgentSolveParams::room stays Option because agent/solve is also operator-invocable; narrowing that param is its own slice, with a comment saying so at the call site.

Validation

cargo check -p continuum-core --features metal,accelerate clean, zero errors.

Live proof owed and NOT claimed: no dispatched claim has run on this build. The falsifiable prediction is that a claim-fired solve's acts now radiate receipts into the card's room, and that the nil-room share of new traces falls from the 8.7% measured above.

Still open on #425 (this is slice 1, not the card)

  • ExperienceRecord::from_lived_turn has zero production callers tree-wide — the experience stream is fed by from_kanban_grade alone, so no lived turn (bench or chat) ever becomes an experience record. Wider than Gap analysis: 49 open issues, 20 closed #425.
  • persona::recorder::record_turn is called only from persona/response.rs (the respond() path); cognition/ never calls the recorder, so a solve writes workspace traces but no persona-respond fixture.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

…nd the dispatch takes a TYPED struct (#425)

MEASURED FIRST, because the doctrine doc's premise is wrong. Every workspace trace record
carries `room_id`; counting nil vs real across the 25 newest trace files:
**13,209 roomless turns vs 138,088 roomed (8.7%)**. Per citizen: 90e758b2 8,657 nil /
15,814 roomed — 35% of her cognition happened where no room could see it; a20b3ada 895 nil
/ 489 roomed, more roomless than roomed.

WHAT THE DOC OVERSTATES: "a detached agent/solve produces no turns, so none of it reaches
the curriculum." It DOES produce turns. `build_workspace_cycle` attaches a
`JsonlWorkspaceCaptureSink` for every cycle — live turn, eval fork and solve alike
(persona_workspace.rs:551-564); 1,258 trace files, 6.7 GB on disk. #427 is the
corroboration and I had already accepted it: its whole subject was benchmark forks writing
to the SAME traces file as live turns. You cannot contaminate a corpus you do not write to.

SO THE DEFECT IS NARROWER AND STILL SERIOUS: those turns are invisible to the ROOM. No act
receipts radiate (`apply_act` skips a nil room BY DESIGN), so no peer, no human and no
ViewState ever sees the work — a citizenship defect, not a curriculum-input one.

THE ROOM WAS NEVER UNKNOWN. Boards are PER-ROOM, so a card's activity is the room whose
board holds it — and the wrong-room claim retry directly above already resolved exactly
that, inline. Extracted as `room_holding_card`: ONE resolver, two consumers, replacing a
scan in one place and a shrug in the other. `work/claim` now resolves the card's room and
dispatches into it. A card no subscribed board holds gets NO detached fallback — the claim
stands and a `work.claim.unplaceable_card` probe says why, because inventing invisible work
is the thing being removed.

THE SIGNATURE, on Joel's two calls today.

"Make sure params are well formed structs and constants, good OOP, not random parameter
lists." The old one was `(ctx, airc, Uuid, WorkCardId, Option<Uuid>)` — two BARE UUIDs
separated only by argument POSITION, so transposing claimer and room compiled cleanly and
would have dispatched a citizen's solve under a room's id. Now `StagedSolveDispatch
{ claimer: PeerId, card: WorkCardId, room: RoomId }`: typed fields make the transposition a
compile error, and the next fact this dispatch needs is a FIELD, not a sixth argument.
`ctx` + `airc` stay separate parameters deliberately — ambient services, not facts about
the dispatch; folding them in turns a value object into a context bag.

"If something is required, remove the option… caught at compile time not runtime." `room`
was `Option<Uuid>` and `work/claim` passed `None` — the whole defect, expressed as a type
that permitted it. It is now `RoomId`, so the roomless dispatch is UNREPRESENTABLE: a
caller that cannot name the activity cannot construct the struct. The fallible part is the
LOOKUP, which is where fallibility belongs, and it is handled at the boundary before the
struct exists.

Adjacent smells named, not silently expanded: the dispatch roster still carries bare
`(String, Uuid)` tuples (#396's subject) so benchmark.rs types the id AT this boundary;
`AgentSolveParams::room` stays `Option` because `agent/solve` is also operator-invocable,
and narrowing that param is its own slice.

`cargo check -p continuum-core --features metal,accelerate` clean, zero errors.
LIVE PROOF OWED and NOT claimed: no dispatched claim has run on this build yet. The
falsifiable prediction is that a claim-fired solve's acts now radiate receipts into the
card's room, and that the nil-room share of new traces falls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply
joelteply enabled auto-merge (rebase) August 19, 2026 13:56
joelteply added a commit that referenced this pull request Aug 19, 2026
…remainder)

`p.room.unwrap_or_else(Uuid::nil)`. That was the whole defect: a nil room makes `apply_act`
skip receipt radiation entirely, so the run executes normally, lands in NO transcript, produces
no room turn, and NOTHING anywhere says so. 13,209 turns went by in that state — 8.7% of all
turns, 35% for one citizen — before anyone measured it, because an invisible run and a visible
one produce identical logs.

A roomless run is still LEGITIMATE and this does not refuse it. A bare `agent/solve` with no
activity behind it has no room to radiate into, and inventing one puts receipts on a phantom —
live-proven 2026-08-12, it stole the single-room chat projection. The defect was never that the
branch exists. It is that the branch was taken in silence.

So `RunVisibility::{InRoom(Uuid), Invisible}` replaces the `unwrap_or_else`, with `resolve` as
the ONE place the param becomes a decision, `room_id()` for the act pipeline (nil is the shape
`apply_act` already keys its skip on), and `warning()` returning the sentence ONLY for the
invisible case — a visible run stays quiet, because a warning that fires on the happy path
trains everyone to ignore it. The call site emits `agent.solve.roomless` + a tracing::warn once,
at the point the branch is taken.

An EXPLICIT `Uuid::nil()` resolves to Invisible too, pinned by its own test: a caller passing nil
means what omitting it means, and treating them differently would reopen the silent branch
through the other door.

This is the same failure shape as the two fixed an hour ago (a fetch cap whose comment promised
it would never truncate while nothing enforced it; a projection that doubled memory without
saying so): the system takes a consequential branch and declines to mention it. An absence is
only measurable if something declares it.

Pairs with slice 1 (PR #2336) — a CLAIMED bench card now solves in the card's room, so the
dispatched path is visible by construction and this covers everything else.

3 tests, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
@joelteply
joelteply merged commit b4fad49 into canary Aug 19, 2026
4 checks passed
@joelteply
joelteply deleted the fix/claim-dispatches-into-the-cards-room branch August 19, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant