Processor audit 2026-07: findings and plan
Five parallel critical reviews of the stream-processor estate, run 2026-07-21
right after the agent-next flag-day refactor (#2168) and the
blockProcessorWhile(work) signature change (#2170). Scope: the framework
(packages/iterate/src/processors/) and all first-party implementations in
apps/os/src/domains/ plus browser-feed. Review dimensions: code smells,
code volume, testing harness, consistency of the simple abstractions, and
extractable patterns.
Overall verdict: the refactor held. Implementations are lean (ceremony ≈5% of
implementation lines), the two-switch shape is universal, all 17 suites use
makeProcessorHarness, replay tests exist. The remaining problems are
concentrated: one stringly-typed cross-DO error contract copy-pasted four
times, docs that still teach the pre-#2168 shape, pockets of dead code with
lying comments, and a harness that stops one level short of what four suites
need.
Each finding below has a stable ID for PR references. Status values: open,
decided (direction picked, not implemented), in-pr #NNNN, done #NNNN,
wontfix (with reason). Line numbers are as of cdc370e64.
A. Shared mechanics (the bug classes)#
A1. Idempotency-conflict tolerance is a message regex, copy-pasted 4×#
Status: in-pr #2183. Found independently by 4 of 5 reviews.
The "losing an idempotency race is success" dance ends in the byte-identical line
if (!/idempotency key .* already names a different event/.test(message)) throw error;in four processors:
apps/os/src/domains/agents/agent-processor-implementation.ts:1004-1014(#appendUnlessLostIdempotencyRace, 11 call sites in-file)apps/os/src/domains/integrations/telegram-agent-processor-implementation.ts:404-414(same method, signature drifted to rest-params)apps/os/src/domains/devices/device-processor-implementation.ts:505-515(array signature)apps/os/src/domains/email/email-agent-processor-implementation.ts:158-169(inlined in a catch)
The message it matches is minted independently in two places:
apps/os/src/domains/streams/stream-durable-object.ts:420 and the harness
packages/iterate/src/processors/testing.ts:96. Six files must agree on
wording. Reword the DO's message (or let an RPC layer prefix it) and all four
processors start rethrowing on benign settle races — blocking frames fail,
transport backs off, agents/devices wedge — while harness-backed tests stay
green because testing.ts mints its own copy. Conversely, an unrelated error
containing the phrase is silently swallowed.
Sibling strategies for the same race show it's one problem solved three ways (these are legitimate — they need read-back / observe semantics — but their message checks should share the predicate):
capability-host-processor-implementation.ts:909-979— on any append error, read backstream.getEvent({idempotencyKey})and verify a valid settlement stands.scheduler-processor-implementation.ts:212-246— observe-before-append; a key occupied by a non-matching event is a loud error.
Proposals:
- (a) Give the string one owner + a predicate.
packages/iterate/src/processors/idempotency.ts(which already exportssameIdempotentEventprecisely so prod and tests cannot drift) growsidempotencyConflictMessage(...)(used by the stream DO and the harness) andisIdempotencyConflict(error). Call sites keep their visible try/catch and their four different load-bearing comments. A typed error class is the wrong fix: the rejection crosses Workers RPC, which preserves the message but not class identity — the message IS the wire contract. - (b) Append option (
{ onIdempotencyConflict: "tolerate" }): nicer API, touches the append primitive's surface. - (c) Discriminated append result (
{committed} | {conflict}): cleanest end state, biggest change.
Recommendation: (a). Smallest surface, kills the drift class, no invented concept.
A2. Devices: race tolerance is asymmetric across writers of the same keys#
Status: in-pr #2183.
Device's at-head sweep tolerates the settle race
(device-processor-implementation.ts:178), but the two other writers of the
same notification-settled@<offset> keys — checkReceipts (:399-416)
and #sendNotification (:463-485) — use plain this.append. The alarm
lane and a zombie incarnation surface the identical benign race as a thrown
error. Benign today; exactly the drift A1's shared predicate makes visible.
Fix: apply the same tolerance at all three writers (lands naturally with A1).
B. Dead code and duplication (mechanical deletions)#
B1. Agent compaction coalescing machinery is dead, guarded by lying comments#
Status: in-pr #2178.
agent-processor-implementation.ts:137-140 and :780-821
(#pendingCompaction / #compactionWork / the microtask yield in
#queueCompaction) were built for the pre-#2153 concurrent-blocker world —
the comments still say "Per-event blocking work starts concurrently, so a
microtask boundary lets one batch coalesce several reports". Blockers are now
FIFO per event (stream-processor-runner.ts:653-671 awaits the chain before
the next event is even reduced), so a second token-usage-reported blocker
cannot register until the first — containing the entire compaction — has
settled. #pendingCompaction can never hold a superseding entry;
#compactionWork is always undefined at the next call. The actual
coalescing is done by the stream probe #laterOverThresholdReportPending
(:414-424).
Proposals: (a) delete the machinery, call #compactHistory directly from
the blocker, keep the stream-probe supersession guard, fix the comments to
name FIFO as the safety argument. (b) If cross-frame coalescing is actually
wanted (compaction is slow), make it real by moving the trigger to the
at-head pass — a design change, file separately if desired.
Recommendation: (a).
B2. Agent #configNow() re-reduces the entire stream for one in-scope field#
Status: in-pr #2178.
agent-processor-implementation.ts:893-897: doc comment claims it serves "the
rare code path (compaction) that runs outside a delivery frame and has no
args.state" — false; its sole caller chain starts inside a
blockProcessorWhile where state.config is in scope, and #compactHistory
(:845) already holds the events from #readConsumedEvents(). Compaction
fires precisely when the stream is longest, and this doubles a full paged
read + full re-reduce to fetch one config field.
Fix: thread config (or the derived deadline) through
#queueCompaction's input; delete #configNow.
B3. DeliveryContext.phase / observedHeadOffset / cursorRevision have zero readers#
Status: in-pr #2189.
Defined at stream-processor-runner.ts:171 (DeliveryPhase) and :182-197,
computed per event at :637-642 and :694-699. Grep across every
*-processor-implementation.ts and the browser client libraries: only
caughtUp is ever read (repo:221, scheduler:96, capability-host:142,
agent:447, slack-agent:348, telegram-agent:139, device:134). No importer of
DeliveryPhase outside the runner. tasks/simplify-stream-processor-contract.md
note 3 already wanted this checked before cutting — the check comes back
empty.
Fix: DeliveryContext becomes { caughtUp: boolean }. ~50-60 lines, and
three fewer honest-but-unread fields for reviewers to invent cases against.
The runner keeps its internals; this is author surface only.
B4. Keepalive fire-and-forget→promise bridge duplicated inside the framework#
Status: in-pr #2178.
StreamProcessor.#runKeepAliveBackedWork (stream-processor.ts:556-571) and
StreamProcessorRunner.#keepAliveBackedWork
(stream-processor-runner.ts:1023-1046) are near-identical copies of subtle
resolve/reject/rethrow choreography — the runner's comment admits it ("Same
fire-and-forget→promise bridge as the legacy…"). The runner's copy adds an
isBlocked boolean whose only effect is choosing a log line (:1041-1043).
Fix: one module-level awaitKeepAliveBacked(keepAliveWhile, work) (a
plain function, not a concept) used by both; the blocked-failure log stays at
the runner call site.
B5. Dead harness exports#
Status: in-pr #2178.
driveProcessor (testing.ts:253-266) and eventsOfType
(testing.ts:269-272) have zero users outside testing.ts;
driveProcessor's docstring describes the cutover suites' drive() helpers,
and the cutover is done. The streams-example-app already shows the real
minimal host (apps/streams-example-app/e2e/vitest/stream-processor-node.test.ts:70-103).
Fix: delete both.
B6. slack-agent re-implements readers that integrations/utils.ts exports#
Status: in-pr #2178.
slack-agent-processor-implementation.ts:910-924 defines private
readStringField / readRecordField / readNestedMessageStringField
shadowing readString / readRecord from integrations/utils.ts
(telegram-agent uses the real ones).
Fix: switch to the shared readers; pure deletion.
B7. capability-host: vestigial void + side-effecting wait predicate#
Status: in-pr #2178.
capability-host-processor-implementation.ts:527-531: void observedSettlement; is a no-op that reads like rejection-observation (the
.then at :514-517 already does that job) — delete the line.
#waitForScriptSettlement (:688-711) smuggles its result out of
waitUntilEvent by assigning inside the predicate, then throws an
"impossible" branch — correctness is coupled to the runner evaluating the
predicate on exactly the matching event and never again. Fix: re-read the
settled event after the wait resolves (stream.getEvent({idempotencyKey}),
the key is derivable) so the predicate stays pure. Alternative (a
waitForEvent-returning-the-event runner API) is framework growth for one
caller — skip unless a second caller appears.
B8. Duplicated slack-agent status-paint block with drifted guards#
Status: in-pr #2178.
slack-agent-processor-implementation.ts:486-499 (#paintRuntime) vs
:564-577 (#repaintPresence): both compose
(summary.activity ?? fallbackActivity) + "…", dedupe on
#paintedActivityText, call assistant.threads.setStatus, record the memo —
with drifted gating (one on hasAssistantThreadUi || generation checked
twice, the other on fresh && hasAssistantThreadUi) and drifted memo
discipline (conditional vs unconditional write). One copy can record a paint
it didn't make after an interleaving — the sticky-status incident shape.
Related: #callSlackApi (:660-668) string-fishes Slack error codes out of
Error.message ("already_reacted" etc.).
Fix: one private #paintActivityStatus(...) used by both call sites,
gating stays at the call sites where it genuinely differs; have the injected
callSlackApi dep surface Slack's error code as a property instead of
message-parsing.
B9. Contract examples volume (LOW priority)#
Status: in-pr #2180.
~1,080 lines of examples: across the 8 largest contracts (repo 266/832
lines = 32% of the file, project 226/767, agent 167/1,039, email 121/468).
Single consumer: apps/os/src/lib/event-docs.ts (the public docs site) + a
CI test parsing each example. Maintainer call (2026-07-21): delete event
examples wholesale rather than trim them.
C. Type safety#
C1. Registry type-erasure cluster (six erasure points)#
Status: open.
stream-processor-registry.ts:161 (runner: StreamProcessorRunner<any>),
:456 (processor as unknown as StreamProcessor<any, any>), :390
(new LiveState<Live>({} as Live)), :400-401 (as Live | undefined +
second {} as Live), :513, :527-529. The documented invariance argument
(:102-112) justifies one erasure point; the file has six, and nothing
compiler-checks that entry.processor and entry.runner share a contract.
{} as Live additionally publishes an empty object to subscribers if
assembleLive runs before any runner registers.
Proposals: (a) a small generic makeEntry<P>() constructor so the
pairing is checked at the single creation site; map value stays existential
(~20 lines). (b) LiveState lazy initializer / explicit unassembled sentinel
instead of {} as Live. (c) status quo with comments.
Recommendation: (a); (b) separately if the empty-Live window ever bites.
C2. browser-feed double-casts events across the UI package boundary#
Status: open.
apps/os/src/domains/streams/client-libraries/processors/browser-feed/implementation.ts:86-89
and :206-209:
event as unknown as Parameters<typeof reduceAgentUi>[1]. Asserts forever
that the mirror's StreamEvent and @iterate-com/ui's reducer event are the
same shape, with the compiler told not to check — drift compiles silently and
corrupts the rendered feed at runtime, in the browser. Also :275
localIndex as number (use typeof localIndex === "number" which narrows).
Fix: make reduceAgentUi accept the published StreamEvent type from
iterate/processors. Fallback: one module-level adapter function
centralizing the cast.
C3. defineProcessorContract(contract: unknown): any#
Status: open.
processor-contracts.ts:603, plus schema-boundary double assertions at
:428, :468, :542. The typed overload carries everything; the runtime
Object.assign result and the four attached helpers are never checked
against it — a helper whose runtime behavior diverges from the overload is
invisible to tsc.
Proposals: (a) type the implementation signature and satisfies-check
the assembled object against a runtime-shape type (partial checking). (b)
accept the sanctioned erasure point but add expectTypeOf type-tests pinning
each helper's input/output against a sample contract (cheap, catches drift).
(c) leave.
Recommendation: (b) at minimum; (a) if it stays readable.
C4. email-agent: unsound type predicate immediately contradicted#
Status: in-pr #2178.
email-agent-processor-implementation.ts:99-113: the filter asserts
attachment is StoredInboundAttachment & { size: number } while checking
only path; the mapper then distrusts its own assertion (path!,
size ?? 0). Fix: narrow honestly
((a): a is typeof a & { path: string } => typeof a.path === "string"), drop
the !, keep the ?? normalization the mapper already does.
C5. telegram-agent: four birthCertificate! assertions instead of narrowed params#
Status: in-pr #2178.
telegram-agent-processor-implementation.ts:241, 258, 275, 355 —
state.birthCertificate!.config… inside helpers whose guard lives one frame
up (:91); #repaintTypingAtHead (:301) re-checks null instead, so the
file can't decide whether helpers may assume birth. A future call path that
forgets the guard NPEs at runtime. Fix: helpers take the narrowed fields
they need (connection, chatId) instead of whole nullable state.
C6. devices: paired credential fields modelled as independent nullables#
Status: in-pr #2178.
device-processor-implementation.ts:392-397 — pushTokenSecretPath and
pushTokenSecretUpdatedOffset are always set/cleared together (reduce arms
:235-236, :258-259, :267-268) but typed independently, so joint use
needs ! and a desync compiles fine, flowing undefined into a fenced
Secret compare-and-clear. Fix: one nullable object in the state schema:
pushTokenSecret: { path: string; updatedOffset: number } | null. (Contract
change; this contract is young.)
C7. Agent raceAbort discards the abort reason#
Status: in-pr #2178.
agent-processor-implementation.ts:1103-1110 — both reject paths manufacture
new Error("aborted"), ignoring signal.reason, while the runner's own
abortReason() helper (stream-processor-runner.ts:1191-1193) preserves it.
Fix: reject(signal.reason ?? new Error("aborted")) — one line, twice.
C8. Orphaned doc comment in StreamProcessor#
Status: in-pr #2178.
stream-processor.ts:417-425: two stacked docblocks on #parseConsumedEvent
— the first (carrying the load-bearing "throwing here would wedge the cursor
forever" invariant) describes #reduceRawEvent, which now sits at :452
undocumented. Fix: move the block to its method.
D. Lane use and consistency#
D1. Three coexisting at-head retry strategies; repos keeps a deleted workaround#
Status: in-pr #2189. Wake-event consumption deleted (code half); doc rule shipped in #2188.
Fleet answers to "what retries a transiently failed at-head append":
- background attempt + keepalive revival (agent
agent-processor-implementation.ts:442-447, devices:172-190, reposrepo-processor-implementation.ts:235-256); - one outer
blockProcessorWhileholding the frame so redelivery retries (capability-host:144-150, scheduler:96-99— both with real justifications: promptness of settle appends); - repos additionally consumes
stream/woken+stream/subscriber-connectedas re-check triggers (repo-processor-contract.ts:620-627) — the workaround the simplify-streams task marked DELETE for the agent, and a hidden third recovery lane the docs never mention.
Proposals: (a) legitimize the first two lanes with a written decision
rule in docs/writing-stream-processors.md ("settle appends that must land
promptly → one outer blocked frame; everything re-derivable at leisure →
background attempt") and delete repos' wake-event consumption. (b) align all
to outer-block. (c) align all to background + make keepalive retry transient
failures.
Recommendation: (a).
Doc rule shipped in the docs PR; code half still open.
D2. slack-agent blocks a cosmetic repaint that telegram-agent backgrounds#
Status: done #2201 (merged by Jonas).
slack-agent-processor-implementation.ts:341-350 blocks the frame on
#repaintPresence — Slack setTitle/setStatus vendor calls hold the
cursor for the thread's durable message pipeline. The call-site comment
explains ordering, not why losing the paint would lose a per-event
consequence (it wouldn't: freshness-gated cosmetics).
telegram-agent-processor-implementation.ts:137-141 backgrounds the exactly
analogous typing repaint with a textbook justification. A hanging Slack API
call head-of-line-blocks transcription and sends — the failure mode
stream-processor.ts:95-98 warns about.
Proposals: (a) move to runInBackground (the memo fields already make it
latest-fact-wins); split out only title-clear-on-revival if that truly needs
at-least-once. (b) keep blocking but bound with a timeout and write an honest
justification. (c) doc note legitimizing durable-state paints blocking.
Recommendation: (a).
D3. agent-collection's reducer throws on committed facts#
Status: in-pr #2189.
agent-collection-processor-implementation.ts:68-71 and :124 — the only
reducer in the fleet that can wedge its cursor on a committed event, against
the doctrine every sibling states in-file (workspace, secrets :73-75,
capability-host :242-244, notifications :68-69) and the docs checklist.
One out-of-order or provenance-less committed copy (raw append, subscription
misconfig) wedges the project's whole agent catalog forever, on a pure
projector with no recovery story.
Fix: skip-and-log (return state, console.error), or skip-and-journal a
stream/error-occurred if operators need the trail.
D4. Obligation terminal naming split (+ scheduler hybrid)#
Status: decided direction, migrations deferred.
New shape (-settled + result union incl. cancellation): agent,
capability-host, devices, projects. Old split terminals: repos
(repos/created+create-failed, repo/github-import-completed/-failed,
github-push-completed/-failed, repo-processor-contract.ts:175,335-355,466-503).
Hybrid: scheduler/trigger-completed with an outcome union
(scheduler-processor-contract.ts:266). Wire formats are stable — no urgent
renames; renames need journal-compat thinking like the agent cutover.
Plan: fix the docs now (E1); new obligations must use -settled;
consider renaming scheduler's single event opportunistically; leave repos.
D5. Idempotency-key spelling: three dialects, one fan-in hazard#
Status: open (doc rule + targeted fix).
Dialects: (1) the framework helper this.idempotencyKey(key, event) (most
processors); (2) hand-rolled literals reimplementing the format — the whole
telegram pair (telegram-processor-implementation.ts:192,
telegram-agent:163,196,214,268,278), which also omit the @path
component the helper's docstring calls load-bearing for fan-in safety;
(3) deliberate cross-processor collision keys (agent/binding:…,
slack-agent/created:…, email-route:… — email :89,260,277, slack
:90,196,213) where collision is the point, indistinguishable from drift.
Inside the agent file the helper is used with three separators (settle/,
assistant-context@, resume/).
Plan: style-guide rule (helper with @<identity> for processor-scoped
keys; raw literal ONLY for intentional cross-processor convergence, flagged
by comment). Telegram's committed keys are declared stable wire formats
(telegram-agent:20-21) — migrate new streams only, note the fan-in caveat.
Doc rule shipped in the docs PR; code half still open.
D6. Birth-gate placement varies four ways#
Status: open (doc rule only).
Whole-hook gate (slack-agent:121, telegram-agent:91, telegram:84, scheduler:73); per-case (email:65, email-agent:59, slack:58); caughtUp-branch only — unborn agent still transcribes context and errors (agent:448, capability-host:143); explicit allow-created-through formula (projects:87-93, repos:113-114). Consequence drift: an unborn slack-agent silently drops forwarded webhooks without transcribing them; an unborn agent transcribes. Nothing records which choice is deliberate.
Plan: doc rule — "gate the whole hook unless a pre-birth event legitimately has a consequence; then gate per-case with a comment." No mechanical alignment (agent semantics look intentional).
Doc rule shipped in the docs PR; code half still open.
D7. Only the agent emits stream/error-occurred#
Status: in-pr #2188.
The style guide says failures journal stream/error-occurred next to
settlements; in code only the agent does (:562,699). Everyone else uses
settle unions, domain -failed events, console, or throw-to-hold-frame.
Runner-side emission is already on the simplify-streams outstanding list —
that's the right long-term home. Meanwhile reword the style rule to match
reality (settle unions ARE the error record outside agent-visible streams).
Doc rule shipped in the docs PR; code half still open.
D8. Scheduler: base-class background lane + synthetic UUID, both unjustified in-file#
Status: open (comments only).
scheduler-processor-implementation.ts:338-348 — #launchExecution, called
from delivery, uses this.runInBackground, whose docstring says
delivery-hook work should use the hook-args lane. Sound (the domain alarm IS
the scheduler's recovery, :49-52; the method is shared with the alarm
sweep) but the exemption isn't written at the site. Same for
crypto.randomUUID() executionId (:302) vs the "identity = stream offset"
style rule. Fix: two one-line comments naming the exemptions. A redesign
of trigger identity onto the requested event's offset is a separate task if
ever wanted.
D9. Small stragglers#
Status: open (batchable).
- browser-feed spells hook args as
Parameters<StreamProcessor<…>["reduce"]>[0](implementation.ts:109-117) instead of the exportedReduceArgs/ProcessEventArgsadded by #2154 as the one sanctioned spelling. (done in this PR) - Two clock idioms: five identical
#now()methods (agent:1027, slack-agent:677, slack:151, capability-host:991, projects:542), two inline(this.deps.now ?? Date.now)()(telegram-agent:240,300), and optional-vs-requirednowdep split with no principle. Doc rule: requirednowfor anything with expiry logic. Doc rule shipped in the docs PR; code half still open. - Expiry stamp types drift: epoch-ms (agent, capability-host, devices,
notifications) vs ISO strings (project approvals, scheduler) — visible
where
notification-processor-implementation.ts:55converts at the boundary. Pick epoch-ms for new contracts (majority). Doc rule shipped in the docs PR; code half still open. <slug>/configuredimplies three different merge semantics (agent/workspace patch-merge viamergeProcessorConfig; sandbox per-key null-unset; telegram wholesale replace). Fine per-domain; one doc sentence so the suffix stops implying the recipe. Doc rule shipped in the docs PR; code half still open..meta/examples coverage is bimodal: agent contract 105 descriptions, repos 87, projects 74 — vs email-agent 5, telegram-agent 7, slack-agent 9, notifications 2, browser-feed 1; four facet contracts have zero examples. Backfill opportunistically.repo-processor-implementation.ts:126-130— the only laneless synchronous side effect in the fleet (idempotent in-memory cache poke); one doc sentence legitimizes it. Doc rule shipped in the docs PR; code half still open.- 10 inline
error instanceof Error ? error.message : String(error)across 8 files plus two named variants — below the threshold where a shared helper beats visible code; leave.
E. Docs debt (one PR, outsized leverage)#
E1. docs/writing-stream-processors.md teaches the pre-#2168 contract#
Status: in-pr #2188. Urgent and free.
:199-201and:290-292narratellm-request-scheduledandllm-request-completed {failure: orphaned}— both deleted by #2168 (the shipped contract settles withagent/llm-request-settledand retries via reduce arithmetic).:152and the:393checklist teach "requested/started/completed" while the agreed style is one terminal-settledwith a result union (cancellation as a result kind). A new processor written from the doc today reproduces the shape #2168 removed.
Fix: sweep the dead names; import the -settled style bullets from
tasks/simplify-stream-processor-contract.md into the doc (the discoverable
home).
E2. The same-key-different-body conflict rule is implemented 5×, documented 0×#
Status: in-pr #2188.
The doc's replay story (:170-172) stops at "races collapse at the append
dedup layer" — identical bodies only. Real settle bodies carry
incarnation-dependent fields (durationMs, partial text, signed URLs), so
the raced append is a same-key-different-body conflict, and the required
responses (tolerate-as-settlement / read-back-the-winner /
observe-before-append) live only in call-site folklore. Fix: one
paragraph after the idempotency-keys section naming all three shapes and
when each applies (pairs with A1's predicate).
E3. "Deterministic body: anchor to event.createdAt, never now" — codify#
Status: in-pr #2188.
The replay-wedge rule (a now-stamped expiry turns redelivery into a
same-key conflict forever) is re-explained in near-identical comments at 5+
sites (slack-agent:266-270, telegram-agent:155-160, agent:318-324 and
:514-521, notifications:22-26, repos:151-153 for the deterministic-diff
variant). Fix: one doc paragraph under reprocessing safety; site comments
shrink to one line naming the concrete hazard.
E4. <domain>-defaults.ts creation-batch convention — write it down#
Status: in-pr #2188.
~9 files export deterministic birth batches (capabilityHostCreationEvents,
schedulerCreationEvents, agentCreationForPath, …); the load-bearing
property ("the same events an explicit create would append, so the keys
collide by design") is stated once, in a comment
(project-processor-implementation.ts:220-224). Consumers differ: slack and
email routers send the batch only on first contact; the telegram router
re-appends the whole batch on every forward and relies on key-dedupe — both
correct only because the batches are deterministic. One future now/random
in a defaults builder breaks the telegram-style consumer while leaving the
slack-style one green. Fix: style-guide bullet: fully deterministic
bodies, keys derived from (projectId, path) only, safe to re-append on every
contact, shared by create doors and birthing routers.
E5. Chat-facet transcription wire shape — convention, not code#
Status: in-pr #2188.
slack-agent/telegram-agent/email-agent share an identical refs block
(email:141-148, telegram:225-232, slack:191-197), identical YAML transcript
header, identical attachment-loss note (email:117-119, slack:315-317), and
identical dont-trigger-request policy spread. A shared "chat facet" base
class would be an invented noun sized wrong; the payload assembly is the
thing reviewers must see whole. Fix: one style-guide bullet describing
the transcription shape (one agents/context-added per source event,
role: developer, YAML headed by the literal source event type, one refs
entry, explicit in-content loss note on permanent enrichment failure); the
three files become greppable checks of each other.
E6. At-head memo repaint pattern#
Status: in-pr #2188 (one sentence).
slack-agent #unpaintedPresenceFact (:91-109,525-588) and telegram-agent
#unpaintedTypingFact (:75-79,296-308) deliberately mirror each other with
cross-referencing comments. Worth one sentence in the doc's freshness-gated
section; no code.
F. Testing harness#
F1. Recovery suites hand-roll ~120 lines of registry simulation each#
Status: done #2202 (Jonas decision: one harness mode; revival-consumption throw dropped).
Follow-up: in-pr #2215 — six contracts shed processor-revived; slack-agent kept it; wake delivery was already watermark-gated, so the selector was unchanged.
telegram-agent-recovery.test.ts:35-156 (harness) vs :158-236 (the one
test); the same fake DurableObjectState + alarm cell + boot/wake/
deliverPending/advance block reappears in email-agent-recovery.test.ts:16-200,
repo-recovery.test.ts:58-170, capability-host-recovery.test.ts:66-175,
already drifting (fixed 5 settle rounds vs fixpoint). testing.ts:5-7
declares registry harnesses out of scope by policy, while the doc checklist
tells every author to write these tests.
Decision: makeProcessorHarness has one production-shaped substrate for
every suite: real keepalive and recovery adapters, Durable Object KV-backed
progress, an alarm cell, virtual time, and MemoryStream. crash() always
means eviction; only a due alarm or append wakes the successor. The four
domain recovery suites use this harness rather than local registry
simulations. Consuming the revival fact remains optional because the runner's
eventless at-head pass guarantees the recovery turn.
F2. settle()'s fixpoint under-waits; failure output is thin#
Status: in-pr #2194.
testing.ts:424-431: settle stops on the first head-quiet round after
draining exactly two macrotasks — the "2" is load-bearing and undocumented.
Work needing more hops lands after settle returns, so suites bolt
vi.waitFor on top (the waitFor+settle pair co-occurs 18× in
scheduler-processor.test.ts alone; 35 vi.waitFor across in-memory
suites). releaseDueSleeps (:390-397, also a quadratic spread-reduce) runs
only inside advanceTime, so an already-due sleep parks until the next
advanceTime step. Failure says "did not reach a fixpoint in 50 rounds" with
no event types/offsets; a failing play() doesn't name the step.
Fix: require N (2-3) consecutive quiet rounds; release due sleeps at the
top of every round; settle failure lists the last round's appended event
types + head offset; play wraps step failures with step index/kind via
cause. Deletes most waitFor+settle pairs.
F3. h.events() is untyped → 50 payload casts across suites#
Status: in-pr #2194.
Harness returns payload: unknown; suites cast (payload as {…}) 50 times —
one executionId extraction repeated verbatim 8× in agent-processor.test.ts
(:852,945,996,1069,1095,1143,1189,1310) plus 20 [0]! bangs. The contract
already knows every schema (consumed and emitted).
Fix: make events generic over the contract's event map — a literal in
the contract returns z.output of its payload schema, foreign strings fall
back to unknown. No new method; shortens nearly every existing test.
F4. MemoryStream has no serialization boundary; runner spec double dedupes key-only#
Status: in-pr #2194.
testing.ts:102-108 stores payloads by reference — a cyclic or
symbol-carrying payload journals happily in tests and breaks at the
production JSON boundary. And the runner spec's own journal double
(stream-processor-runner.test.ts:165-171) dedupes on key only — the exact
masking bug MemoryStream's comment (:92-95) warns about — so the
framework's most authoritative suite cannot catch a runner change that
appends same-key/different-body.
Fix: (a) JSON round-trip payloads in MemoryStream.append (flag-day:
suites passing Dates/class instances will surface — that's the point); (b)
port the spec double's commit to sameIdempotentEvent.
F5. Harness hides the processor instance and registry-style reads#
Status: in-pr #2194.
createProcessor swallows the instance, so suites keep mutable cells
(const live = { scheduler: undefined as unknown as SchedulerProcessor },
scheduler-processor.test.ts:99-127) and hand-wire late-bound
reads: { snapshot, waitUntilEvent } per suite
(scheduler :107-116, capability-host :100-110,
scheduler-rpc-ownership.regression.test.ts:51-63) — wiring the harness
already owns, with a subtle late-binding requirement (crash() must keep
reads honest). Fix: expose processor() next to runner(), and
late-bound reads on HarnessProcessorDeps — both production-parity, both
delete recurring gymnastics.
F6. Three append doors, semantics distinguishable only by context#
Status: in-pr #2194.
["append", …] and h.append() settle; h.stream.append() doesn't — which
is load-bearing ("committed but undelivered") but invisible: 29 raw
h.stream.append calls sit visually identical to the settling kind, and the
scheduler suite carries a 4-line header explaining the trap (:10-13).
Also 109 of ~400 play calls are single-step where the method form is
plainer. Fix: doc convention only — tuples for the scenario spine,
methods for single actions, h.stream.append is THE committed-but-undelivered
door. No API change.
F7. Scheduler xfail trio: correct xfails, stale surroundings#
Status: blocked on RPC-disposal product work.
The three test.fails in scheduler-rpc-ownership.regression.test.ts:111-149
are product gaps (no disposal), not harness gaps. But the suite predates the
harness: it duplicates makeMemoryProgressStore verbatim (:20-34 vs
testing.ts:349-363) and a local deliver loop. When disposal ships, rewrite
the trio on makeProcessorHarness (+ F5) and delete the local plumbing.
F8. Magic advanceTime numbers encode config defaults#
Status: in-pr #2194.
["advanceTime", 10_000] silently encodes the debounce default across the
agent suite; exactly one test reads it honestly
(agent-processor.test.ts:796-809 via h.state().config.llmRequestDebounceMs).
Where the arithmetic is the point, read the config; leave the rest.
Suggested sequencing#
- This PR: the findings doc.
- Docs PR (E1-E6 + the doc rules from D1, D5, D6, D7, D9): urgent, free, stops new code copying dead shapes.
- Mechanical deletions PR (B1-B8, C4, C5, C7, C8, D9 ReduceArgs): no design decisions, all deletion or one-file fixes.
- A1+A2 PR: conflict-message owner +
isIdempotencyConflict+ the four call sites + devices asymmetry + harness minting the shared message. - Type-safety PR (C1, C2, C3, C6): registry entry constructor, browser-feed boundary, contract type-tests, devices credential object.
- Harness PRs (F2+F3+F5 together; F4 as its own flag-day; F1 after the mode-vs-substrate decision).
- Decisions needed from Jonas: D1 (bless two lanes, kill repos wake-events?), D2 (background the slack repaint?), F1 (registry mode vs exported substrate), F4 (accept the flag-day), D3's flavor (log vs journal).
Audit provenance: five subagent reviews, 2026-07-21, session 39e36840 continuation; ~1.1M review tokens over the framework + 18 processor files + all test suites.