TypeScript Migration Feasibility Verdict (M29-4)
Status: decision record for the
M29 feasibility spike. Evidence:
M29-1 (#537, OpenAPI-generated types), M29-2 (#538, apps/server scaffold), and above all
M29-3 (#539) - the port of GET /api/admin/audit_log to TypeScript with a
differential parity harness proving byte-identity
against the Python implementation.
The question this doc answers: given the measured cost of one real migration slice, how aggressively should the Python->TS migration proceed? The gate agreed up front: ~1 dev-week per slice. If a read-only slice already exceeded that, the full migration is multi-quarter and the pragmatic path is to freeze the Python core and grow only new work in TS ("center of gravity"). Under the gate, the strangler-fig keeps going.
Measured per-slice cost (M29-3, source + test + harness)
From the M29-3 PR (#543):
- Wall-clock, one agent session end to end: ~35 min - ~12 min reading the Python source
and capturing byte-level ground truth (response bodies, pydantic-core rendering,
build_list_sqloutput), ~12 min source-port plus Vitest coverage matching the pytest cases, ~8 min harness build and first run, ~3 min fixing the one diff class the harness surfaced (float notation) to 67/67 byte-identical. - One-dev-equivalent estimate: roughly 1-1.5 dev-days for a comparable hand port.
- The dominant cost was not the port itself but discovering the byte-level
serialization contract (pydantic-core vs
json.dumps, jsonb key order, microsecond text formats) and building the harness that makes parity provable. That harness andsrc/pyjson.tsare now reusable, so subsequent read-slices should cost well under half of this.
One-off foundation cost, already paid and amortized across all future slices: the
apps/server scaffold with deploy artifacts (#538) and the OpenAPI-generated TS types
(#537, a DRY win independent of any rewrite).
Parity gaps found
The full ledger lives in apps/server/parity/README.md;
nothing was silently normalized. By seam:
pydantic <-> Zod (serialization contract). The Python byte contract is pydantic-core's
Rust JSON writer, not json.dumps: floats render as 1e-6 (unpadded exponent),
0.00001 stays fixed-notation. The harness caught this on its first run (46/67); the port
now matches pydantic-core via src/pyjson.ts, with ground-truth unit tests. Zod asserts the
response shape; the byte contract needed its own renderer. Also reproduced verbatim: the
Python query's ORDER BY a.at, a.id::text tiebreak (id "9" sorts after "10" within one
timestamp) - "fixing" it would break pagination parity.
asyncpg <-> postgres-js (driver codecs). The JS driver's default parsers would truncate
microseconds (timestamptz -> JS Date) and reorder integer-like jsonb object keys
(JSON.parse), and jsonb ints beyond 2^53 would lose precision. The fix
(src/db.ts): custom codecs keep timestamptz and json/jsonb
in Postgres text form end to end, with the connection pinned to UTC, and pyjson.ts owns
rendering - matching what asyncpg + pydantic produce on the Python side.
Auth. On the admin surface there is no JWT: the credential is the DB-backed
bloom_admin_session cookie the Python API issues, validated against the same shared
tables. The full auth matrix (valid admin per role, valid non-admin -> 403, expired
session, wrong-surface session as the wrong-audience analogue -> 401, plus
auth-beats-validation precedence) is byte-identical across both runtimes. Cross-runtime
session validation is a solved problem for future slices.
Accepted residual gaps (documented with rationale, all 422-vs-422 edge cases no real
client hits): exotic ISO-8601 since/until spellings only Python's fromisoformat
accepts, NaN/Infinity literals inside filter JSON, which unknown filter key a 422
names when several are sent at once, and headers beyond content-type (uvicorn vs
Express legitimately differ).
Dual-runtime + shared-Postgres coexistence
The milestone's premise was that the real seam between TS and still-Python code is the shared Postgres, not the HTTP gateway. Findings:
- One config/secret set serves both backends.
apps/serverreads the sameBLOOM_SUPABASE_DB_URLandBLOOM_LOG_LEVELthe Python API reads; the helm chart mounts the samebloom-api-secretsSecret into the second workload. - Shared tables work unmodified. The TS endpoint reads
bloom_audit_logand the admin auth tables exactly as Python writes them - no schema change, no dual-write, no translation layer. The parity harness boots the real Python API and the TS server against one shared postgres:16 and both serve the identical corpus with no interference. - Pooler footprint is managed, not accidental. The TS pool is deliberately small
(
max: 4) so a second backend does not double the API's footprint against the shared Supabase pooler; this needs a conscious budget as more slices migrate. - Deploy topology exists and is guarded. The server ships as a second workload beside
the API (image, chart, CI) with health/readiness surfaces,
server.enabledoff by default until a gateway route points at it. Read-only slices coexist trivially; the untested (and future) frontier is slices that write pydantic-shaped blobs Python later reads back.
Verdict: keep strangling toward all-TS
The measured cost is well under the gate. ~1-1.5 one-dev-equivalent days per slice - including the harness and byte-contract discovery that only had to be paid once - against a ~1 dev-week gate, with subsequent read-slices projected at well under half the measured cost. The freeze-core-and-grow-TS fallback was conditioned on the slice blowing the gate; it did not, by a factor of ~3-5.
Recommendation:
- Continue the strangler-fig migration toward all-TS, slice by slice, in future milestones. Sequencing stays evidence-driven per the milestone's key decision: order slices by measured cost, core included - no artificial "defer the fragile core" line.
- Keep the parity discipline that made this cheap. Every migrated read endpoint gets a
differential-harness diff (real corpus, byte-identical bodies, auth matrix) before
cutover;
pyjson.ts, the text-form codecs, and the harness are the reusable kit. - Re-check the gate on the first slice of each new shape. The measurement covers one read-only vertical. The first write slice (Python reading back TS-written rows) and the first LLM/agent-adjacent slice should each re-measure against the same ~1-dev-week gate; a blown gate reopens this verdict, cheaply, rather than sinking a quarter.
- Bank horizontal wins as they appear. The OpenAPI-types work (#537) paid for itself with zero migration risk; prefer more of that (shared contracts, drift guards) alongside vertical slices.
Executing subsequent slices is future-milestone work, informed by this verdict.
M30 addendum - first write slice (2026-08-18)
Recommendation #3 above mandates re-checking the ~1-dev-week gate on the first slice of each
new shape. M30 is the first write shape: M30-1
(#549) extended the differential harness
with a mutation mode that diffs three surfaces per case (HTTP status, response body bytes,
and the persisted bloom_admin_grants + bloom_audit_log rows read back out of the shared
Postgres), and M30-2 (#550) ported the
guarded admin mutation seam (CSRF origin gate -> session auth -> permission gate -> whitelist
body -> audit wrapper) plus the users.grant_admin / users.change_role verbs, flipping the
harness to TS-target-vs-Python-oracle. Result: 22/22 mutation cases byte-identical on all
three surfaces, plus a closing trail read-back where Python serves the interleaved TS- and
Python-written audit rows byte-identically - exactly the frontier the M29 verdict left open.
Measured per-slice cost (M30-1 + M30-2)
Unlike M29-3, the M30 PR bodies carry no self-reported session clock, so the numbers below are upper bounds derived from the GitHub timeline (the commits were rebased at push, so per-commit times do not resolve finer):
- M30-1 harness extension: <= ~16 min agent wall-clock - the milestone's issues were
created 08:13 UTC and PR #549 opened 08:28. That covers the 22-case mutation matrix, the
read-back + canonical row renderers (
parity/readback.ts, reusingpyjson.tsand the text-form codecs), the validated-then-normalized treatment of DB-generated values, and the write-parity ledger. - M30-2 port: <= ~24 min agent wall-clock - its branch could only start after #549
merged at 08:37, and PR #550 opened 09:00. That covers the CSRF-gate middleware, the
admin_actionsprotocol twin with the audit wrapper and theFOR UPDATElast-superadmin lockout, the two verbs with pydantic-exact whitelist 422s, and the harness flip. - Whole write shape: <= ~40 min agent wall-clock, on par with the read slice's ~35 min
even though every mutation case diffs three surfaces instead of one - the M29 kit
(
pyjson.ts, the codecs, the boot-both-runtimes harness) amortized as predicted. - One-dev-equivalent estimate: roughly 1.5-2 dev-days for a comparable hand build of the mutation harness plus the seam port, on the same basis as the M29 estimate (the dominant cost is again contract discovery - audit-row semantics, pydantic 422 ordering, jsonb canonical order - not the port itself).
Verdict against the gate: UNDER, by a factor of ~2.5-3. The "well under half of the
read-slice cost" projection for repeat slices did not apply here - a new shape pays for its
own harness mode - and the slice still cleared the gate with room. The mutation mode and
readback.ts are now part of the reusable kit, so subsequent write slices should again
cost a fraction of this measurement.
Write-specific coexistence findings
The full ledger is in apps/server/parity/README.md
("Write-parity ledger" and "Write-slice findings ledger"); the feasibility-relevant findings:
- The audit-blob serialization contract round-trips on the write path.
jsonbcanonicalizes on write (key length, then bytewise), so the TS port insertsbefore/aftersnapshots with plainJSON.stringifyand still lands rows byte-identical to Python'sjson.dumpsinserts - nopyjson-style writer was needed on the way in. A later Python read of TS-written rows is byte-identical, proven twice: per-case row read-back diffs, and the closing trail read-back through both runtimes'GET /api/admin/audit_log. - Redaction and generated-value parity hold. The redacted snapshots (including on the
outcome=denied/errorrefusal paths) diff byte-identical;granted_byis caller-derived and diffs verbatim; the DB-generated values (audit bigserialid,at, grantgranted_at, all from Postgres) are shape-asserted then normalized in the harness - generation lives in the database, so both writers inherit identical semantics by construction. - The dual-writer concerns the adversary flagged did not materialize. No advisory locks
and no cross-runtime transaction coordination were needed: the race-sensitive invariant
(last-superadmin lockout) ports as SQL semantics -
FOR UPDATErow locks inside one transaction - so the race-free property holds between runtimes for free, because the lock lives in Postgres, not in app code. Each row still has exactly one writer per request; coexistence is interleaving at the table level, and that is what the trail read-back proves. One genuine rule did surface (via the harness's own fixtures): seeding explicit bigserial ids without advancing the sequence collides with the next live writer - harmless today, a real warning for any future backfill-style migration step. - Two new port costs unique to the write path, both caught by the row diff: Node's
dual-stack listener reports IPv4 peers as
::ffff:127.0.0.1where uvicorn reports the dotted quad (the TSclient_metatwin strips the mapping or every audit row diffs onip), and the body whitelist's 422 bytes are pydantic's, in pydantic's multi-error order.
Pooler budget note
The TS pool stays at max: 4 (src/db.ts) and now carries a
write workload whose transactions (the FOR UPDATE lockout) hold a connection longer than
a read does. Production footprint is unchanged for now - server.enabled remains off, so the
shared Supabase pooler sees no second backend yet - but the M29 stance stands and sharpens:
every migrated slice moves traffic between pools rather than adding load, yet during any
coexistence window both backends hold their full pool budgets open. Re-derive the per-backend
max against the pooler's connection ceiling before the first gateway route flips, and again
as write slices (longer-held connections) accumulate.
Recommendation: keep going, cadence unchanged
- Continue the strangler-fig into write endpoints. The first write slice measured under the gate with the same margin discipline as M29; the freeze fallback stays dormant.
- Every write slice keeps the three-surface parity bar - status, body bytes, AND persisted-row read-back with the denied/error audit semantics diffed - using the now-paid mutation mode as the kit.
- The next mandatory re-measurement point per recommendation #3 is the first LLM/agent-adjacent slice (the remaining unmeasured shape). Write slices no longer need per-slice gate checks unless one visibly blows past the measured envelope.
M31 addendum - first LLM/agent slice (2026-08-18)
Recommendation #3's next mandatory re-measurement point was the first LLM/agent-adjacent
slice. M31 is that shape: M31-1 (#557)
extended the differential harness with a fully offline LLM record/replay mode (a shared
fixture corpus as the single source of truth for both runtimes, plus a Python oracle that
replays it through the real parse_structured_response and the real OpenClawLLMProvider
with the transport stubbed at the httpx boundary), M31-2
(#558) ported the provider-neutral LLM
core (the LLMProvider interface + LLMError taxonomy, the JSON-recovery-and-validate
parseStructuredResponse, and the scriptable FakeLLMProvider) and flipped the harness's
response-parse dimension to TS-target-vs-Python-oracle, and M31-3
(#559) ported the OpenClaw raw-text
provider over an injectable GatewayTransport and flipped the request-construction
dimension the same way. Result: 48/48 LLM cases clean - 21 response-parse + 3
request-construction fixtures through the Python oracle vs pinned goldens, plus the same 24
cases through the TS port diffed against both the live Python observations and the goldens.
Everything ships dormant (server.enabled unchanged, nothing wired to a route).
Measured per-slice cost (M31-1 + M31-2 + M31-3)
Same methodology as the M30 addendum - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline (commits rebased at push):
- M31-1 harness LLM mode: <= ~20 min agent wall-clock - the milestone's issues were
created 09:52 UTC and PR #557 opened 10:12. That covers the 24-fixture shared corpus (code
fences, prose wrapping, braces-in-strings, refusals, malformed JSON, schema-mismatch and
coercion cases, unicode, a >50 KB payload), the Python oracle with
httpx.MockTransportcapture, thepnpm run parity:llmentrypoint, and offline twin test suites on both sides. - M31-2 core port: <= ~11 min agent wall-clock - its branch could only start after #557 merged at 10:21, and PR #558 opened 10:31. That covers the interface + error taxonomy, the string-literal-aware balanced-JSON scanner with its pinned quirks, the fake provider, and the harness flip for the parse dimension.
- M31-3 provider port: <= ~13 min agent wall-clock - after #558 merged at 10:39, PR
#559 opened 10:52. That covers the gateway client over the injectable transport with the
httpx-matched error mapping, the CPython-
repr()renderer (src/llm/pyrepr.ts) for the schema-instruction byte contract, and the harness flip for the request dimension. - Whole LLM shape: <= ~44 min agent wall-clock, on par with the read slice's ~35 min and the write shape's <= ~40 min - a third confirmation that a new shape's cost is dominated by its one-off harness mode plus byte-contract discovery, which the M29/M30 kit keeps amortizing.
- One-dev-equivalent estimate: roughly 1.5-2 dev-days for a comparable hand build, on the same basis as the earlier estimates: the dominant cost is again contract discovery - the CPython dict-repr embedded in the prompt, the scanner's pinned corner cases, pydantic lax-mode semantics - not the port itself.
Verdict against the gate: UNDER, by a factor of ~2.5-3 - the same margin as the write
shape. The LLM mode's corpus, oracle, runtime seam, and pyrepr.ts join the reusable kit,
so subsequent LLM-adjacent slices should cost a fraction of this measurement.
Non-determinism handling: why never diffing live completions is sound
LLM output is non-deterministic, so the byte-parity discipline cannot diff live model calls the way the read/write harness diffs live HTTP responses. The LLM mode's answer is record/replay with transport isolation, and the parity claim stays sound because the provider decomposes into exactly three stages:
- Request construction is deterministic - a pure function of
(system, messages, schema, model, max_tokens)- and is fully byte-diffed: method, path, provider-set headers, and the JSON body including themax_completion_tokensmapping and the schema-instruction system prompt with its embeddedmodel_json_schema()Python-dict repr, pinned verbatim. - Response parsing is deterministic - a pure function of the returned raw text - and is fully diffed across a corpus chosen to cover the recovery space (fences, prose, quirks, refusals, malformed payloads, schema mismatches), byte-diffing canonical parsed JSON on ok paths and asserting the exact error type + pinned message shape on failure paths.
- The live network call is the sole non-deterministic boundary, isolated behind an
injectable seam on both sides (
httpx.MockTransportin Python,GatewayTransportin TS) and documented as such in the module header and the parity README. The completion itself is an opaque payload passing between the two diffed stages: both runtimes provably send byte-identical requests to the same gateway, and provably parse any given raw text identically - so no byte-relevant behavior lives in the un-diffed region. The transport's own failure mapping (timeout, non-2xx, network error -> theLLMErrortaxonomy, naming only the status or exception type, never URLs or auth material) is covered by offline unit twins on both sides.
Request-construction + parse parity findings
The full ledger is in apps/server/parity/README.md
("LLM-mode ledger"); the feasibility-relevant findings:
- The prompt embeds a Python-dict
repr(), and that is a byte contract. The provider f-stringsmodel_json_schema()into the schema-instruction system message, so the wire bytes contain a CPython dict repr (single quotes,True/False/None,', 'separators,$defsfor nested models). The TS twin carries the schema'smodel_json_schema()shape in pydantic's key order and renders it withsrc/llm/pyrepr.ts, ground-truthed against CPython 3.12 - the LLM shape's analogue of M29'spyjson.ts. A subtle corollary the corpus caught: a docstring on a pydantic model folds into the schema asdescription, so it is part of the wire bytes too. - Parser quirks are pinned, not fixed. The balanced-JSON scanner starts at the first
{/[in the text even inside a quoted string, while string-literal awareness applies only inside the candidate. The corpus pins both the recovered shape and the quirk so the port reproduces Python's behavior exactly - same discipline as M29'sORDER BYtiebreak. - Pydantic lax-mode semantics are part of the parse contract: extra fields silently
drop and numeric strings coerce (
"count": "19"->19); both are pinned as ok fixtures, and the Zod twins reproduce them. Zod's object parsing emits keys in shape-definition order, which is what makes canonical ok bytes match pydantic'smodel_dump(mode="json")field order byte-for-byte. - A canonical-bytes intersection rule for fixtures: cross-runtime goldens must stay
where
JSON.stringifyandjson.dumps(separators=(",", ":"), ensure_ascii=False)agree- integers only (
1.0renders as1.0in Python but1in JS), no lone surrogates - with both runtimes pinning the contract in their offline suites. This is the fixture-side mirror of the M29 float-notation finding.
- integers only (
- Error-path parity is taxonomy + message shape, not full bytes: exact error type name plus a pinned message substring, because full messages may legitimately differ across runtimes (e.g. schema class-name spelling); live target-vs-oracle runs still byte-diff all canonical ok output.
Recommendation: keep going, cadence unchanged
- Continue the strangler-fig into LLM/agent code. All three measured shapes - read, write, LLM - came in under the gate by ~2.5-5x; the freeze-core fallback stays dormant.
- LLM-adjacent slices keep the record/replay discipline: a shared fixture corpus as the single source of truth, the transport as the only un-diffed boundary, and both dimensions (request bytes, parse behavior) diffed target-vs-oracle-vs-golden using the now-paid LLM mode as the kit.
- The remaining frontiers, in ascending risk, are the next mandatory re-measurement points: streaming (token/SSE delivery adds a timing-and-chunking surface the record/replay mode does not yet model), multi-turn tool-use agent loops (state threaded across model calls, where request construction becomes a function of prior parses), and the orchestrator/workflow-engine/langgraph core - the last unmeasured and highest-risk shape, where control flow itself is the contract. Per recommendation #3, the first slice of each re-measures against the same ~1-dev-week gate; further plain request/parse LLM slices need no per-slice gate check unless one visibly blows the measured envelope.
M32 addendum - first streaming (SSE) slice (2026-08-19)
Recommendation #3's next mandatory re-measurement point after M31 was streaming - the first
shape whose surface is timing and chunking, not request/response bytes. M32 is that shape:
M32-1 (#566) extended the differential
harness with a fully offline SSE/streaming mode (a 15-fixture shared corpus of scripted
publishes, idle keepalive gaps, and client disconnects, plus a Python oracle that drives the
REAL EventBus and the REAL project_events handler on a virtual-clock event loop), M32-2
(#567) ported the core (the EventBus
twin with its injectable ProjectEventStore seam, the SSE frame serializer, and the
pyJsonDumps payload writer) and flipped the frame-assembly dimension to
TS-target-vs-Python-oracle, and M32-3
(#568) ported the Express
GET /api/projects/{thread_id}/events route behind the get_current_user and _owns_thread
twins and added the end-to-end phase: both runtimes boot against one shared Postgres and
the live HTTP frame-byte streams are diffed for byte + ordering identity. Result: 30/30
offline SSE cases clean (15 Python-oracle-vs-golden plus the same 15 through the TS port
against both the live Python observations and the goldens), and the end-to-end phase clean -
14 live streams plus the legacy-link ownership case byte-identical with verified subscriber
cleanup, 4/4 auth/ownership denial cases byte-identical, and 1 documented live skip (a real
client cannot disconnect before the response exists; the fixture stays covered offline).
Everything ships dormant (server.enabled unchanged, no gateway route flipped).
Measured per-slice cost (M32-1 + M32-2 + M32-3)
Same methodology as the M30/M31 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline (issue-created -> PR-opened per slice; commits rebased at push, so per-commit times do not resolve finer):
- M32-1 harness SSE mode: <= ~32 min agent wall-clock - the milestone's issues were
created 16:47 UTC and PR #566 opened 17:19. That covers the 15-fixture corpus, the
VirtualClockEventLooporacle (no real sleeps; the whole corpus replays in under a second) with the scripted ASGI disconnect channel behind a real Starlette request, the pinned goldens (status, app-set headers, the exact ordered frame chunks, stream end, unsubscribe cleanup), offline twin suites on both sides, and thepnpm run parity:sseentrypoint. - M32-2 core port: <= ~19 min agent wall-clock - its branch could only start after #566
merged at 17:27, and PR #567 opened 17:46. That covers the
EventBustwin (bounded fan-out queues, history ring, the injectable store seam with its background writer), the frame serializer, thepyJsonDumpsrenderer with its ground-truth suite, and the harness flip for the frame-assembly dimension. - M32-3 route port + end-to-end phase: <= ~36 min agent wall-clock - after #567 merged
at 17:56, PR #568 opened 18:32. That covers the Express route with the real keepalive
timer and disconnect handling, the session-auth and thread-ownership twins with Python's
exact 401/404 bytes, the
/api/testingemit subset, the live phase itself (shared-Postgres boot, frame-stream diff, cleanup probes, denial matrix), and the env-overridable keepalive interval on the Python side. - Whole streaming shape: <= ~87 min agent wall-clock - roughly double the read (~35 min), write (<= ~40 min), and LLM (<= ~44 min) shapes. The premium is explainable and still shape-shaped: streaming is the first shape that pays for two harness dimensions (the offline virtual-clock replay AND a live end-to-end transport phase), and its route ticket carried the first user-surface auth/ownership twins along with it.
- One-dev-equivalent estimate: roughly 2-3 dev-days for a comparable hand build, on the
same basis as the earlier estimates: the dominant cost is again contract discovery - the
json.dumps-defaults frame bytes, the keepalive reset semantics, the polled-disconnect quirk, the live header residuals - not the port itself.
Verdict against the gate: UNDER, by a factor of ~2 (roughly 1.7-2.5x) - a thinner margin
than the earlier shapes' ~2.5-3x, but comfortably clear, and for the double-instrument
reason above rather than any per-endpoint cost growth. The SSE mode (virtual-clock oracle,
stream-diff kit, live phase, pyJsonDumps) joins the reusable kit, so subsequent streaming
slices should cost a fraction of this measurement.
Streaming-specific findings: the byte contract under a timing surface
The full ledger is in apps/server/parity/README.md
("SSE-mode ledger"); the feasibility-relevant findings:
- The SSE wire contract is bytes, and it is a THIRD serializer. Per stream: one
event: ready\ndata: {}\n\nopener (delivered even to an already-disconnected client), oneevent: {type}\ndata: {payload}\n\nframe per published event, and one: keepalive\n\ncomment frame per full idle interval. The payload isjson.dumpswith DEFAULTS -", "/": "separators,ensure_ascii=True(non-BMP unicode ships as UTF-16 surrogate-pair escapes), CPythonrepr(float)- which matches neither the read slice's pydantic-core writer nor the kit's canonical interchange. The port reproduces it verbatim aspyJsonDumpsinsrc/pyjson.ts, every divergence fromJSON.stringifypinned by ground truth. - The timing-and-chunking surface was made deterministic, not approximated. Wall-clock
timing is not diffable, so - the LLM mode's transport-isolation discipline applied to
time - the oracle runs the real handler on a
VirtualClockEventLoop(time advances only by jumping to the next scheduled timer when the loop idles, so the keepalive timeout fires deterministically) with a scripted disconnect channel behind the productionis_disconnected()polling code, and the fixtures script the event sequence (record/replay, one shared corpus for both runtimes). Frame bytes and their order are fully diffed; only wall-clock durations sit outside the contract, and transport/HTTP is the only un-diffed boundary. Chunk boundaries are frames offline (one generator yield per frame, goldens pin the ordered chunk list); live, TCP owns them, so the end-to-end phase re-splits on the frame delimiter and diffs raw bytes + order. - Behavioral quirks are pinned, not fixed - the M29 discipline again: the keepalive
clock resets on every delivered frame (consecutive sub-interval gaps emit no comment
frame, ever), disconnect is polled between waits so a dropped client still receives one
more frame before the loop notices, the live stream does NOT filter
chat_*event types (the history route does), and fan-out is subscriber-queue FIFO with the publisher's key insertion order. - Accepted residual header deltas (the M29 uvicorn-vs-Express rule, observed live):
both stacks add
dateandtransfer-encoding: chunked; uvicorn addsserverand the telemetryx-request-id. The allowlist is exact - any new residual fails the phase and must be re-accepted explicitly. The four app-set headers diff verbatim. - No history replay on subscribe is a confirmed non-behavior, not a deferred gap: the
Python handler never reads the durable event store when a client subscribes, and the TS
route reproduces exactly that, verified end-to-end with Python's store wired. The TS bus
carries the injectable
ProjectEventStoreseam so the history-reading route (timeline) can be ported in a later slice without touching the bus again.
Pooler budget note
Long-lived SSE connections do not move the per-backend pool budget: the route's DB work
(session, user, workspace, project-index rows) completes before the stream starts, so an
open stream holds an HTTP socket but NO Postgres connection, and
src/db.ts keeps max: 4 regardless of concurrent stream
count. The M30 stance is otherwise unchanged - re-derive the per-backend max against the
pooler ceiling before the first gateway route flips - with one addition: the budget review
should count sockets and pool slots separately, because this slice decouples them.
Recommendation: keep going, and the frontier map is corrected
- Continue the strangler-fig into streaming surfaces. All four measured shapes - read, write, LLM, streaming - came in under the gate; the freeze-core fallback stays dormant.
- Streaming slices keep the two-phase discipline: the offline virtual-clock record/replay (frame bytes, ordering, chunk-per-yield, keepalive and disconnect semantics) plus the live end-to-end frame-stream diff with the exact residual-header allowlist, using the now-paid SSE mode as the kit.
- The frontier map from the M31 addendum needs one correction: "multi-turn in-model
tool-use loops" is NOT a present shape in this codebase - the agents make single-shot
structured calls (one
provider.structured(...)per agent invocation, the structured-output-firstLLMProviderinterface), and no code threads tool results back into a model conversation - so there is no such slice to measure. The remaining bulk is the orchestrator / workflow / langgraph core plus the domain, services, and agents layers built on the already-measured shapes. Per recommendation #3, the next mandatory re-measurement point is therefore the orchestrator/workflow-engine core, the last unmeasured shape, where control flow itself is the contract; further streaming slices need no per-slice gate check unless one visibly blows the measured envelope.
M33 addendum - domain-layer port (2026-08-19)
M33 ported the whole bloom.domain package - all seven modules - to Zod schemas, inferred
types, and pure-function twins under apps/server/src/domain/. This was NOT a mandatory
gate re-measurement: the domain layer is pure logic exercised through the already-measured
read/write shape (M29/M30), with no new transport, timing, or persistence surface. Per the
evidence discipline the per-slice cost is still recorded here, and the roadmap updated.
The slices: M33-1 (#576) ported
domain/models.py (20 StrEnums + 39 pydantic models, including the three validators and
the Requirements derived members) and introduced the model-dump byte-parity corpus whose
goldens are generated from the real Python modules (gen_goldens.py, run against the
apps/api uv env). M33-2 (#577) ported
domain/decisions.py - the decision-record model, its lifecycle, and above all the markdown
parser as render's exact inverse - with the M31 record/replay discipline (a 49-case
parse/transition/helper corpus), plus two support twins: the bloom.frontmatter grammar
(src/frontmatter.ts) and CPython stdlib twins (src/pystr.ts). M33-3
(#578) completed domain/deployment.py
(the owner-approved default registry, the BLOOM_DEPLOYMENT_MAPPINGS override parser with
Python-exact error semantics, registry-first resolution; a 32-case corpus). M33-4
(#579) ported the remaining small
modules (accounts, admin, audit, admin_resources), growing the model-dump corpus to
146 cases, all byte-identical, and consolidated the M29/M30 ports onto the new domain
twins instead of their local copies. Everything ships dormant (server.enabled unchanged,
nothing routes through the new types until later slices).
Measured per-slice cost (M33-1 + M33-2 + M33-3 + M33-4)
Same methodology caveat as the M30/M31/M32 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. M33-2 through M33-4 each depend only on M33-1, but one agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M33-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M33-1 models port: <= ~16 min agent wall-clock - the milestone's issues were created 09:28 UTC and PR #576 opened 09:44. That covers all 59 enum/model twins with pydantic field order, defaults, and validator semantics pinned, the 96-case initial corpus, and the golden generator.
- M33-2 decisions port: <= ~30 min agent wall-clock - after #576 merged at 10:00, PR #577 opened 10:30. The heaviest slice, and visibly why: the markdown parser has pinned parsing behavior, so it paid for a record/replay corpus of its own plus the frontmatter and CPython-stdlib support twins down to the error-message bytes.
- M33-3 deployment port: <= ~12 min agent wall-clock - after #577 merged at 10:41, PR #578 opened 10:53. That covers the byte-pinned registry table, the override parser with its error-path split, and the 32-case corpus with its own generator.
- M33-4 small modules: <= ~21 min agent wall-clock - after #578 merged at 11:01, PR
#579 opened 11:21. That covers the four module ports, the
wire.tsdatetime pin, the 39 new corpus cases, and the DRY consolidation refactor across the live M29/M30 code. - Whole domain layer: <= ~79 min agent wall-clock across four slices (~20 min per slice on average) - the entire package cost less than the streaming shape alone (<= ~87 min), and no single slice approached any shape's first-slice cost.
- One-dev-equivalent estimate: roughly 2-3 dev-days for a comparable hand build of the whole layer, on the same basis as the earlier estimates: the dominant cost is again contract discovery - pydantic field-order and inheritance dump rules, the parser's pinned quirks, CPython scalar-conversion message bytes - not the port itself.
Shape note: no new gate, and the envelope held
Domain code is pure logic: its observable contract is the serialized bytes of its models
and the deterministic behavior of its parsers, which is exactly the read/write shape M29
and M30 measured. Accordingly no new harness mode was needed and none was built - the
domain parity dimension has no live phase and no oracle subprocess, just corpora whose
goldens are generated from the real Python modules and replayed offline through the twins
on every pnpm run test (the generators also assert every hand-pinned error substring
against the live Python message, so a stale pin fails at generation time). The measured
envelope held with room: the projection that repeat slices of a paid-for shape cost a
fraction of the first slice is confirmed at package scale - four slices, none needing more
than ~30 min, against first-slice shape costs of ~35 to ~87 min. No gate is reopened.
Domain-specific byte-contract findings
The full ledgers are in apps/server/parity/README.md
(the four M33 sections); the feasibility-relevant findings:
- Pydantic field-declaration order is the key-order contract, and Zod can carry it.
Every Zod shape declares its keys in pydantic declaration order and Zod v4 emits parse
output in shape order, so
JSON.stringifyof the parse output matchesmodel_dump(mode="json")bytes. The subtle part is inheritance: pydantic dumps parent fields first, and the corpus pins both shapes it produces (AuditEntrycarries the store-assignedid/atLAST; the wire itemAdminAuditLogItemre-declares them first) - the twins reproduce this with.extend()on the base schema. - Enum wire strings are pinned in declaration order. Every
StrEnum's full member list is a corpus case, so a renamed, reordered, or added member diffs loudly in both runtimes. - The decision-record parser's quirks are pinned, not fixed - the M29 discipline at its
most literal: the double-source error wrap (
DecisionParseErrorsubclassesValueError, so bullet errors re-prefix the filename twice), CPythonint()literal normalization (id: 007re-renders asid: 7, and1_0underscores parse),str.splitlines()as the line-boundary contract (the full boundary set, not\n), empty flat-list items dropped ([api, , web]->[api, web]), and the filename regex's trailing-newline$quirk. The scalar conversions ride CPython stdlib twins (src/pystr.ts) matched down to the error-message bytes, quoting values via the M31pyreprtwin. datetimefields stay wire strings (src/domain/wire.ts): the twins pin pydantic's canonicalmodel_dump(mode="json")output subset and REJECT every other ISO spelling Python would accept and normalize, because a string-passthrough port cannot reproduce the normalization and must refuse rather than silently diverge on re-dump.- Residual gap, accepted: pydantic lax-mode input coercion is not reproduced in the
domain schemas (
"5"-> 5 for int fields, etc.); everywhere these models cross the seam the values are already JSON-typed, and the LLM slice's lax-coercion pin lives in the structured-output parse path. Revisit per-field only if a real payload relies on it. - Config tables are byte-pinned as corpus cases: the owner-approved deployment registry
dumps all ten rows through the Python constructors, so any drift in the table (wording,
service lists, secret names, row order) diffs loudly; override semantics (insertion
order, duplicate-key-last-wins,
nullunmaps) are pinned the same way. - The port paid down duplication instead of adding it. M33-4's cross-check moved the
live M29/M30 code onto the domain twins -
AdminRole/ADMIN_PERMISSIONSnow live only insrc/domain/admin.ts, the audit write choke point consumes the domainAuditActor/AuditOutcome, and the audit read route's strict response assertion derives from the domain schema (one field list, two postures: pydantic semantics on the domain side,strictObjectredaction guard on the route side) - with all 22 mutation parity cases still byte-identical after the refactor.
Recommendation + roadmap: the remaining surface toward all-TS
- The domain layer is done and the cadence holds. Every
bloom.domainmodule now has a byte-faithful, corpus-guarded TS twin; further domain-shaped work is maintenance (regenerate goldens on Python model changes), not migration. The freeze-core fallback stays dormant. - The remaining surface toward all-TS is three layers, all built on measured shapes: the remaining API routes (read, write, and streaming endpoints beyond the audit-log/admin-mutation/SSE slices already ported - each a repeat slice of a paid-for shape, projected at a fraction of first-slice cost); the services layer (the orchestration-adjacent glue between routes and domain, largely read/write-shaped over the now-ported domain types); and the agents layer (single-shot structured LLM calls per the corrected M32 frontier map, riding the M31 LLM mode).
- The next mandatory gate is unchanged: the orchestrator/workflow/langgraph core - the last unmeasured shape, where control flow itself is the contract rather than bytes on a wire. Per recommendation #3, its first slice re-measures against the same ~1-dev-week gate; domain, route, service, and agent slices on already-measured shapes need no per-slice gate check unless one visibly blows the measured envelope.
M34 addendum - persistence + auth port (2026-08-19)
M34 ported the whole bloom.persistence package plus bloom.auth.oidc - roughly 3,700
lines of Python across eleven modules - to apps/server/src/persistence/ and
src/oidc.ts. Like M33 this was NOT a mandatory gate re-measurement: persistence is the
already-measured read/write shape (M29/M30), with no new transport or timing surface. Per
the evidence discipline the per-slice cost is still recorded here, and the roadmap updated.
The slices: M34-1 (#588) ported the
store base layer (store.py + supabase_store.py): the StateStore protocol twin with
in-memory and Supabase backends over the injected db.ts text-form-codec client, plus the
RunState byte-contract codecs - renderRunStateDump renders persisted bloom_runs.state
jsonb text straight to pydantic model_dump_json bytes via transcodeJsonb, with no
JSON.parse on the byte path. M34-2
(#589) ported the account store
(users/OAuth identities, sessions, Telegram links, workspaces, credential metadata, admin
grants with the FOR UPDATE last-superadmin lock), deleting the interim M30
adminActions/accounts.ts slice in favor of the ported store with the three-surface
goldens unchanged. M34-3 (#590) ported
the jobs/queue store: the atomic FOR UPDATE SKIP LOCKED claim, the dedupe partial unique
index, the status='running' guards that fence lagging workers, and a minimal
bloom.logging run-correlation slice (AsyncLocalStorage as the contextvars twin). M34-4
(#591) ported the declarative
admin_resources read engine and the append-only audit_log store, consolidating the
existing admin ports onto them (the audit write choke point appends through the store; the
audit read route's SQL derives from AUDIT_LOG_SPEC) and landing the seams M34-2/M34-3
deliberately deferred (adminList/adminGet, adminPage). M34-5
(#592) ported the remaining stores
(runtime_correlations, outbound_messages, project_events - the latter wired into the
TS SSE bus at the composition root, closing the M32 ledger's storeless-bus deferral) plus
bloom.auth.oidc under the M31 injectable-transport discipline. Everything ships dormant
(server.enabled unchanged; nothing new routes through the stores), with two contained
behavior notes: the admin grant verbs now share the ported store's code path (identical
SQL, goldens unchanged), and events published through the TS SSE bus are now durably
persisted so bus.history() survives restarts - matching Python.
Measured per-slice cost (M34-1 .. M34-5)
Same methodology caveat as the M30-M33 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M34-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M34-1 store base layer: <= ~19 min agent wall-clock - the milestone's issues were
created 17:31 UTC and PR #588 opened 17:50. That covers the protocol twin, both backends,
the text-form timestamptz stamp minting (microsecond sequence so the CAS token always
moves), and the
transcodeJsonbdump renderer with its PGlite read-back pins. - M34-2 account store: <= ~30 min agent wall-clock - after #588 merged at 17:57, PR
#589 opened 18:27. The largest single module pair (~1,170 Python lines), including the
FOR UPDATEsuperadmin lock, the sharedclock.tsextraction, and the M30-slice consolidation. - M34-3 jobs/queue store: <= ~20 min agent wall-clock - after #589 merged at 18:35, PR #590 opened 18:55. That covers the claim/lease/heartbeat semantics with an injectable monotonic-clock seam, the admin retry/cancel verbs, and the run-correlation logging slice.
- M34-4 admin_resources + audit_log: <= ~36 min agent wall-clock - after #590 merged at 19:02, PR #591 opened 19:38. The heaviest slice, and visibly why: it carries the declarative resource engine with Python's 422 message bytes, the structural append-only guard, and the cross-module DRY consolidation over the live M29/M30 code.
- M34-5 remaining stores + oidc: <= ~33 min agent wall-clock - after #591 merged at 19:45, PR #592 opened 20:18. That covers three stores, the SSE-bus wiring, and the whole OIDC module with its pyjwt/urlencode byte twins.
- Whole persistence + auth layer: <= ~138 min agent wall-clock across five slices (~28 min per slice on average) - the largest layer ported so far by source size, at a per-slice cost still inside the band every repeat read/write slice has landed in, and no slice approaching the streaming shape's first-slice cost (<= ~87 min).
- One-dev-equivalent estimate: roughly 3-4 dev-days for a comparable hand port of the whole layer, on the same basis as the earlier estimates: the dominant cost is again contract discovery - the jsonb byte path, the three-surface pins per mutating operation, the locking and claim semantics, the OIDC byte twins - not the port itself.
Shape note: no new gate, and the envelope held
Persistence is the read/write shape M29 and M30 measured: its observable contract is SQL
against the shared Postgres plus the serialized bytes of what comes back out. Accordingly
no new harness mode was needed and none was built. The parity bar was carried instead by
the M30 three-surface write contract applied store by store, offline: every mutating
operation is pinned on return value, serialized model_dump_json bytes, and the persisted
row read back (raw jsonb column bytes, DB-generated values validated-then-pinned per the
readback.ts discipline), via PGlite integration suites plus Python-captured goldens. The
existing live evidence held unchanged through the consolidation: the 22-case mutation
golden suite passes over the refactored audit-append path, and the 67-case read-parity
surface is untouched. The locking-semantics slices confirmed the M30 finding at scale: the
FOR UPDATE superadmin lock and the single-statement FOR UPDATE SKIP LOCKED job claim
port as SQL semantics, so the race-free properties hold between runtimes by construction
- the locks live in Postgres, not in app code. The envelope held with room: five slices, none over ~36 min, against first-slice shape costs of ~35 to ~87 min. No gate is reopened.
Persistence-specific byte-contract findings
The write-parity discipline and its ledger live in
apps/server/parity/README.md; the M34 pins live in
the store modules' offline suites (the README's SSE ledger gained the M34-5 closure of its
item 12). The feasibility-relevant findings:
- jsonb canonicalization on write generalizes, and the read-back direction needs a
transcoder, not a parser. The M30 finding (jsonb canonicalizes key order on write, so
plain
JSON.stringifyinserts land byte-identical to Python'sjson.dumps) held for every store. The new cost is the other direction: rendering a persisted jsonb text back to pydanticmodel_dump_jsonbytes withoutJSON.parse(transcodeJsonb), so jsonb-canonical key order and integers beyond 2^53 survive verbatim. The JS-inherent typed-path residuals (integer-like key reorder, 2^53 rounding) are documented at the seam, never silently normalized - byte-exact routes must render from raw column text, the audit route'sid::textpattern. One domain-layer adjustment fell out: bigint-backed pydantic ints (workspace_id/chat_id) now accept integral numbers beyond 2^53 where Zod's.int()rejected them outright. - Generated-value normalization is a shared clock contract. DB-generated ids and stamps
stay validated-then-pinned (the M30 rule), and everything datetime rides the Postgres
timestamptz text form end to end; the in-memory backends mint stamps in the same text
form via a shared
clock.ts, with microsecond digits carrying a within-millisecond sequence so optimistic-concurrency tokens always move on save. - Claim/locking parity is SQL semantics plus fencing guards. The job claim is one
atomic
FOR UPDATE SKIP LOCKEDstatement (pooler-safe);status='running'guards keep a lagging worker's complete/reschedule/dead-letter/heartbeat from resurrecting an admin-cancelled job; the dedupe partial unique index makes deduplicated enqueue atomic across instances (23505maps to the pinnedJobRetryErrorrefusals); and admin retry re-enqueues the stored payload byte-identically by construction - the payload is absent from the UPDATE's SET list. - OIDC request/parse parity rides the M31 transport discipline. Discovery, PKCE token
exchange, and id_token verification sit behind an injectable HTTP transport - the sole
un-diffed seam. The byte contracts are pinned against Python goldens: the flow-state
cookie is an HS256 JWT byte-identical to pyjwt's, and the authorization URL and
token-exchange form body go through a
urlencode/quote_plustwin. Verification usesjose(a mature audited library - no hand-rolled crypto), and the error log carries stage plus bounded metadata, never a secret, code, or token. - Append-only is structural, not conventional: a grep-guard test fails the suite on any
tampering SQL against the audit table under
src, and a surface test pins that neither backend exposes a mutating method. - The port again paid down duplication instead of adding it: the interim M30 account
slice is deleted, the audit write choke point appends through the store, the audit read
route's SQL derives from
AUDIT_LOG_SPEC, and the M34-4 vocab tuples are compile-pinned to the stores' Literal twins with the completeness tripwire extended.
Pooler budget note
The pool budget is unchanged in size and grown in tenancy: src/db.ts stays at max: 4
with one pool per process, and every M34 store takes the injected client rather than
opening a second pool - the M34-1 rule. What changed is who shares it: the pool now carries
the whole write-side store layer, including connection-holding transactions (the job
claim's FOR UPDATE SKIP LOCKED, the account store's FOR UPDATE lock) and the SSE bus's
background event appends. Production footprint is still unchanged (server.enabled
remains off), but the M30/M32 stance sharpens again: before the first gateway route flips,
re-derive the per-backend max against the pooler ceiling counting the
transaction-holding claim/lock paths among the long holders, and keep counting sockets and
pool slots separately per the M32 note.
Recommendation + roadmap: the remaining surface toward all-TS
- The persistence + auth layer is done and the cadence holds. Every store and the OIDC module now have byte-faithful, three-surface-pinned TS twins; further persistence-shaped work is repeat slices of a paid-for shape. The freeze-core fallback stays dormant.
- The remaining surface toward all-TS, in planned order: integrations + migration (M35) (the Telegram/GitHub/GitLab and LLM-adjacent glue plus the export/import bundle, riding the M31 LLM mode and the read/write shape), agents (M36) (single-shot structured LLM calls per the corrected M32 frontier map), services + engine (M37) (the orchestration-adjacent glue between routes and domain over the now-ported stores), and routes (M38) (the remaining read, write, and streaming endpoints - each a repeat slice of a paid-for shape).
- Then the next mandatory gate, unchanged: the orchestrator/workflow/langgraph core (M39) - the last unmeasured shape, where control flow itself is the contract. Per recommendation #3 its first slice re-measures against the same ~1-dev-week gate, and cutover (M40) - gateway routes flipping to the TS backend, with the pooler budget re-derivation above - closes the migration. Slices on already-measured shapes need no per-slice gate check unless one visibly blows the measured envelope.
M35 addendum - integrations port (2026-08-20)
M35 ported the whole external-integration layer - bloom.integrations (the shared VCS
abstraction, the GitHub and GitLab adapters, the design surface, the remaining LLM
providers, Infisical, Telegram) plus the shared infra utils they depend on
(config/metrics/alerting/credential_audit) - roughly 6,800 lines of Python across
seven slices - to apps/server/src/. Like M33/M34 this was NOT a mandatory gate
re-measurement: an integration's wire behavior is deterministic up to an injectable HTTP
transport, which is exactly the request-construction + response-parse shape M31 measured
(with the model-dump and webhook-parse surfaces riding the read/write shape). Per the
evidence discipline the per-slice cost is still recorded here, and the roadmap updated -
including two deferrals made when scoping the milestone against the real import graph
(detailed in the roadmap section below).
The slices: M35-1 (#603) ported the
cross-cutting foundation - the full Settings surface as a Zod twin with the boot
validators and derived properties, BloomMetrics over prom-client with the Python label
guards and identical scraped sample names, the AlertManager/sink/health-monitor stack
with byte-identical alert text, and the credential-audit record + counter pair. M35-2
(#604) ported the provider-neutral VCS
seam (~1,425 lines): the VCSProvider port and its DTO vocabulary as Zod twins in pydantic
key order, the webhook event seam + WebhookProviderRegistry, the platform-dispatch
factory, and the in-memory FakeVCSProvider with a scoped CPython difflib twin for patch
hunks. M35-3 (#605) ported the GitHub
adapter (~1,352 lines): REST client over an injectable transport with the instrumented
metrics twin, App-JWT + installation-token auth, the VCSProvider implementation, the
webhook verify/normalize strategy, and the diff helpers. M35-4
(#606) ported the GitLab adapter
(~1,035 lines) over a thin GitLabClient that twins python-gitlab's request construction.
M35-5 (#607) ported the fail-soft
design surface (~426 lines: Figma preview, headless-Blender render, composite/null
factory). M35-6 (#608) ported the
remaining LLM providers (Anthropic, OpenAI, the selection factory with its build-time
fallback, the instrumented wrapper) onto the M31 core. M35-7
(#609) ported Infisical (wired to the
M35-1 alerting/credential-audit/metrics twins) and Telegram. Everything ships dormant
(server.enabled unchanged; nothing routes through the new modules until the M36+ slices
consume them).
Measured per-slice cost (M35-1 .. M35-7)
Same methodology caveat as the M30-M34 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M35-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M35-1 shared infra utils: <= ~25 min agent wall-clock - the milestone's issues were
created 23:46 UTC (2026-08-19) and PR #603 opened 00:11. That covers the whole
Settingstwin (~25 derived properties, four boot validators with Python error texts, pydantic-style coercions), the prom-client metrics layer with the cardinality/correlation guards and the 0.0.4 exposition, the alerting stack, and the credential-audit wrapper - 63 new offline unit tests. - M35-2 VCS abstraction: <= ~21 min agent wall-clock - after #603 merged at 00:19, PR
#604 opened 00:40. That covers the DTO vocabulary, the seams and factory, the fake with
its
difflibtwin (ground-truthed against CPython 3.12), the ported conformance suite, and the 34-case model-dump corpus. - M35-3 GitHub adapter: <= ~30 min agent wall-clock - after #604 merged at 00:53, PR
#605 opened 01:23. That covers the client/auth/factory/provider/webhook/diff modules and
the new
parity/github/dimension: 60 pinned cases (45 request-construction incl. the flow-shaped paths, 10 webhook-parse, 5 signature verdicts), goldens generated from the real Python implementation with the transport stubbed at the httpx boundary (respx). - M35-4 GitLab adapter: <= ~30 min agent wall-clock - after #605 merged at 01:38, PR
#606 opened 02:08. That covers the python-gitlab request-construction twin with its
urllib encoding twins, the split-iid fallback flows, the webhook strategy, and the new
parity/gitlab/dimension: 82 pinned cases (59 request-construction, 18 webhook-normalize, 5 token verdicts), goldens generated with a scriptedrequestsadapter mounted on the real SDK session. - M35-5 design surface: <= ~13 min agent wall-clock - after #606 merged at 02:24, PR #607 opened 02:37. That covers the seam/null/composite types, both adapters over injectable transport/runner seams with fail-soft degradation and type-only error logging, and the config-driven factory.
- M35-6 remaining LLM providers: <= ~32 min agent wall-clock - after #607 merged at
02:46, PR #608 opened 03:18. That covers both providers with their injectable client
seams (incl. the OpenAI
max_tokens -> max_completion_tokensretry quirk verbatim), the shared SDK-twin transport (error taxonomy + retry/backoff), the factory and instrumented wrapper, and the LLM corpus's new provider dimension (request-construction fixtures grown from 3 to 7;pnpm run parity:llmat 56/56 clean). - M35-7 Infisical + Telegram: <= ~14 min agent wall-clock - after #608 merged at 03:26,
PR #609 opened 03:39. That covers the Telegram client/parse/verify twins, the whole
credential-store client (universal-auth token caching, write-only secret surface, the
VCS-auth-scoped
readVcsTokensatisfyingVCSTokenReader, claim-pinned OIDC delivery identities), and the payload-byte parity pins. - Whole integrations layer: <= ~165 min agent wall-clock across seven slices (~24 min per slice on average) - the most slices and the largest source surface of any layer so far (~6,800 Python lines vs M34's ~3,700), with no slice over ~32 min against first-slice shape costs of ~35 to ~87 min.
- One-dev-equivalent estimate: roughly 4-5 dev-days for a comparable hand port of the whole layer, on the same basis as the earlier estimates: the dominant cost is again contract discovery - python-gitlab's wire quirks, the urllib-vs-WHATWG encoding split, the webhook signature schemes, the difflib and dict-repr twins - not the port itself.
Shape note: no new gate, and the envelope held
Integrations are the M31 shape generalized: everything up to the injectable transport is a
deterministic function of the inputs, so the parity bar is request-construction +
response-parse record/replay, fully offline - no new harness mode was needed and none was
built. The kit instead gained two new dimensions on the M31 pattern (parity/github/,
parity/gitlab/) plus a provider dimension on the existing LLM corpus, each with goldens
generated by driving the REAL Python implementation with its transport stubbed (respx at
the httpx boundary; a scripted adapter mounted on the python-gitlab requests session) and
replayed through the ports on every pnpm run test - all byte-identical (34 VCS model-dump
cases, 60 GitHub fixtures, 82 GitLab fixtures, 56/56 LLM cases). One consequence worth
naming: a slice whose Python side rides a vendor SDK pins the SDK's wire behavior, not
just the app's - the GitLab client twins python-gitlab's own encoding and body
conventions, and the Anthropic/OpenAI ports reproduce the vendor SDKs' env-key resolution,
retry/backoff, and error taxonomy behind the same seams. There is no pooler impact: every
M35 module talks HTTP through injectable fetch transports and none touches the shared
Postgres pool, so the M34 budget stance carries unchanged. The envelope held with room:
seven slices, none over ~32 min, against first-slice shape costs of ~35 to ~87 min. No
gate is reopened.
Cross-provider parity findings
The full ledgers are in apps/server/parity/README.md
(the M35-2/M35-3/M35-4 sections); the feasibility-relevant findings:
- Webhook signature verification is per-provider bytes, and both schemes are pinned.
GitHub signs the raw body with HMAC-SHA256 (
X-Hub-Signature-256); GitLab sends the shared secret verbatim inX-Gitlab-Token(not an HMAC); Telegram likewise compares a static secret header. All three ports keep constant-time comparison (crypto.timingSafeEqual), and the verdicts are corpus cases - the valid GitHub case's signature is computed by the Python side's HMAC, so the byte contract is proven, not assumed. - The VCS abstraction holds across both platforms with its asymmetries preserved, not
papered over. Both adapters implement the neutral
VCSProviderport and pass the same ported conformance suite as the in-memory fake; Python kwargs port as options objects with documented omitted-vs-null sentinels. The event seam's real asymmetry survives: the GitHub normalizer returns single-event-or-null while GitLab returns a LIST (multi-label fan-out to one event per added label), andevent_keystrings interpolate the raw hook timestamp/iid bytes on both - so pre-port idempotency keys keep fencing redeliveries across the migration. - Query/URL encoding is a per-stack contract, not a universal one. httpx's form
serializer and WHATWG
URLSearchParamsagree byte-for-byte (proven across the GitHub corpus's query strings), but urllib does NOT (*and~land in different safe sets), so the GitLab client deliberately skipsURLSearchParamsand carries corpus-pinnedpyQuote/pyUrlencodetwins. python-gitlab's wire quirks are reproduced, not cleaned up:Content-type: application/jsonon every request,{}empty-POST bodies,recursive=Falseas a literal Python-bool string, the URL-encodedfile_pathinside file-update bodies, andLink: rel="next"pagination followed verbatim. - The no-vendor-SDK rule held, with two documented search-before-build exceptions.
Every adapter is a fetch client behind an injectable transport (the httpx-client seam
twin), keeping all tests offline. @gitbeaker was rejected because the pinned surface is
python-gitlab's own encoding/body behavior, which a foreign SDK cannot reproduce; the
Anthropic/OpenAI npm SDKs were rejected because an SDK's own schema conversion would
drift from the pydantic-shaped
schema.jsonSchemadocument the parity corpus pins. Both justifications live in the parity README and the PR bodies. The one dependency added,prom-client, went the other way: the de-facto Prometheus client rather than hand-rolling the exposition format. - The CPython stdlib-twin kit keeps compounding: this layer added the scoped
difflibtwin (SequenceMatcher autojunk, grouped 3-line context, range shorthands) for the fake's patch hunks, the urllib encoding twins, and thegitlabWireDatetimepydantic parse->dump twin - joiningpyjson/pyrepr/pystrfrom earlier layers, each ground-truthed against CPython rather than hand-written. - One documented residual seam: the alerting messenger sink binds a narrow structural
messenger interface; its production
outbound_send_contextbinding waits for the services/messaging port (M37). Fail-soft behavior (design surface degrading tonull, type-only error logging that never leaks a token, URL, or body) diffs as behavior parity in the ported unit suites.
Roadmap: two deferrals against the real import graph, and the remaining surface
- The integrations layer is done and the cadence holds. Every
bloom.integrationsmodule in scope now has a byte-faithful, corpus- or test-pinned TS twin over an injectable transport; further integration-shaped work is repeat slices of a paid-for shape. The freeze-core fallback stays dormant. - Scoping M35 against the real import graph moved two items OUT of the milestone, to
their dependency-correct homes rather than forcing stub ports:
- the migration (export/import) bundle, which the M34 roadmap had bundled into M35,
imports
services/prd_render(M37) and coordination (M39) - it lands after those dependencies, not before them; - the LLM
openclaw_harness, which importsbloom.agents.*, moves to the agents milestone (M36); M35-6 ported every other LLM provider, so the TSllm/package is otherwise complete.
- the migration (export/import) bundle, which the M34 roadmap had bundled into M35,
imports
- The remaining surface toward all-TS, in planned order: agents (M36) -
single-shot structured LLM calls per the corrected M32 frontier map, now unblocked
against the complete TS
llm/package plus the VCS/design/Telegram seams, and carrying the deferredopenclaw_harness; services + engine (M37) - the orchestration-adjacent glue over the ported stores and integrations, includingprd_renderand the messaging seam the alerting sink waits on; routes (M38) - the remaining read, write, and streaming endpoints, each a repeat slice of a paid-for shape. The next mandatory gate is unchanged: the orchestrator/workflow/langgraph core (M39) - the last unmeasured shape, where control flow itself is the contract; per recommendation #3 its first slice re-measures against the same ~1-dev-week gate. The migration bundle lands once its M37 + M39 dependencies exist, and cutover (M40) - gateway routes flipping to the TS backend, with the pooler budget re-derivation - closes the migration. Slices on already-measured shapes need no per-slice gate check unless one visibly blows the measured envelope.
M36 addendum - agents port (2026-08-20)
M36 ported the whole agent layer - bloom.agents (the base Agent + prompt templates, the
four review agents, the three generative agents) plus the bloom.skills knowledge-skill
layer it composes and the M35-deferred openclaw_harness - roughly 1,900 lines of Python
across six slices - to apps/server/src/. Like M33/M34/M35 this was NOT a mandatory gate
re-measurement: an agent is a single-shot structured LLM call - a deterministic
request-construction (persona + composed skills + payload -> (system, user, schema))
followed by a structured response-parse - which is exactly the LLM/agent shape M31 measured
and gate-cleared at M31-4. Per the evidence discipline the per-slice cost is still recorded
here, and the roadmap updated - closing the M35 openclaw_harness deferral in the process.
The slices: M36-1 (#619) ported the
knowledge-skill layer (bloom.skills): the Skill/SkillOrigin Zod model, the
front-matter SKILL.md parser (over the ported frontmatter.ts grammar, incl. the M22-1
provenance/SPDX/pinned-upstream validation), the SkillRegistry + DEFAULT_SKILLS_ROOT
with deterministic byte-stable composition (composeSkillBlock /
composeDelegatedSkillBlock) and the add-only capability-gated resolveForAgent binding,
and the dev/CI-time third-party skill importer - inlining the GIT_* redirect env-var set
rather than importing the engine, so the layer stays engine-free (engine is M37). M36-2
(#620) brought forward just
SpecialistReviewContext from bloom.services.specialist_review (a Zod twin in pydantic
key order) - the one services/ symbol the specialist reviewers import - leaving the rest
of services/ in Python until M37. M36-3
(#621) ported the shared foundation:
the base Agent (src/agents/base.ts) wiring the M31 LLMProvider seam to the M36-1
skills composition - stablePrefix/composeSystem build the cache-stable prefix (persona
then the deterministic skill block) with any volatile per-request content strictly after it
- plus the byte-exact prompt templates (
src/agents/prompts.ts) every specialist extends. M36-4 (#622) ported the four review agents (ReviewerAgentPR + deliverable review, and the architecture/security/QA specialist reviewers over the M36-2 context), with thePullRequestReview/SpecialistReviewOutputschema documents held byte-identical to pydantic and a newpyJsonDumpsIndent(json.dumps(indent=2)) primitive. M36-5 (#623) ported the three generative agents (ProductOwnerAgentdiscovery/PRD/milestone/ticket/plan/decision,DesignerAgentdesign studies,ProjectAssistantAgenttriage) with their eight output-schema documents pinned and amodel_dump_jsonrenderer (modelJson.ts) reproducing pydantic-core's raw non-ASCII JSON bytes, distinct from the reviewer/designerensure_asciiwriter. M36-6 (#624) ported the M35-deferredopenclaw_harness(src/llm/openclawHarness.ts), which drives the ported ProductOwner discovery and Reviewer PR-review calls through anyLLMProviderover the M35 VCS types - closing the M35 import-graph deferral now that the agents it imports exist. Everything ships dormant (server.enabledunchanged; nothing routes through the agents until the M37+ slices consume them).
Measured per-slice cost (M36-1 .. M36-6)
Same methodology caveat as the M30-M35 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M36-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M36-1 skills layer: <= ~16 min agent wall-clock - the milestone's issues were created 05:05 UTC and PR #619 opened 05:21. That covers the model, the front-matter parser with the M22-1 provenance validation, the registry with its byte-stable composition and the capability-gated binding, and the third-party importer.
- M36-2 specialist-review context: <= ~5 min agent wall-clock - after #619 merged at
05:29, PR #620 opened 05:34. The smallest slice of the milestone: a single context type as
a Zod twin in pydantic field order with matching defaults, pinned against the real Python
model's default and populated
model_dump(mode="json")bytes. - M36-3 agent base + prompts: <= ~12 min agent wall-clock - after #620 merged at 05:43,
PR #621 opened 05:55. That covers the base
Agentover the M31 seam, thestablePrefix/composeSystemcache-stable prompt assembly, the ClassVar-as-static persona/capability wiring, and the newparity/agents/dimension pinning all 14 prompt constants byte-for-byte plus the composed prompt across 7 compose cases. - M36-4 reviewer agents: <= ~23 min agent wall-clock - after #621 merged at 06:03, PR
#622 opened 06:25. The largest slice: four agents, the size-bounded diff renderer, the
security secret-redaction and QA test-evidence helpers, two byte-pinned output schemas, the
pyJsonDumpsIndentprimitive, and the new record/replay review corpus (9 request + 9 parse cases) generated from the real Python reviewers. - M36-5 generative agents: <= ~18 min agent wall-clock - after #622 merged at 06:33, PR
#623 opened 06:51. That covers the three agents' generation calls, all eight output-schema
documents held byte-identical to pydantic, the
model_dump_jsonraw-non-ASCII renderer, and the generative corpus (14 request + 15 parse cases) from the real Python agents. - M36-6 openclaw_harness: <= ~8 min agent wall-clock - after #623 merged at 06:59, PR #624 opened 07:07. The harness constructs no requests of its own - it drives the already byte-pinned PO/Reviewer calls - so it adds no new golden, only a type-only error-wrap contract (failing step + provider/mode/model, never a prompt, body, URL, or secret).
- Whole agents + skills layer: <= ~82 min agent wall-clock across six slices (~14 min per slice on average) - the smallest layer by source size since the M33 domain port (~1,900 Python lines vs M34's ~3,700 and M35's ~6,800), with no slice over ~23 min against first-slice shape costs of ~35 to ~87 min.
- One-dev-equivalent estimate: roughly 2-3 dev-days for a comparable hand port of the
whole layer, on the same basis as the earlier estimates: the dominant cost is again
contract discovery - the
model_json_schema()documents in pydantic key order, the two distinct JSON payload writers (json.dumps(ensure_ascii)vs rawmodel_dump_json), and the cache-stable prompt-composition byte contract - not the port itself. Comfortably inside the ~1-dev-week/slice gate, as every repeat-shape slice since M31 has been.
Shape note: no new gate, and the LLM/agent gate stays cleared
Agents are the exact shape M31 measured and gate-cleared: a structured LLM call whose
observable contract is the (system, user, schema) request it constructs and the way it
parses the structured response, with the live completion never diffed (the M31
never-diff-a-completion discipline). Accordingly no new harness mode was needed and none
was built - the M31 gate is not reopened. The parity bar was carried by the M31
request-construction + response-parse record/replay pattern applied agent by agent,
fully offline: a new parity/agents/ dimension whose goldens are generated by driving the
REAL Python agents over a recording provider that captures the request and returns a canned
output (gen_goldens.py, gen_review_goldens.py, gen_generative_goldens.py), replayed
through the ports on every pnpm run test - all byte-identical (14 prompt constants + 7
compose cases; 9+9 reviewer cases; 14+15 generative cases; all ten output-schema documents).
This is the same "record the agent's own contribution, never the model's" stance M31
established, now generalized across ten agents. There is no pooler impact: every M36 module
composes prompts and calls the injected LLMProvider; none touches the shared Postgres pool,
so the M34 budget stance carries unchanged. The envelope held with room: six slices, none
over ~23 min, against first-slice shape costs of ~35 to ~87 min. No gate is reopened.
Agent-specific parity findings
The full ledgers are in apps/server/parity/README.md
(the M36-1/M36-3/M36-4/M36-5 sections); the feasibility-relevant findings:
- Prompt composition is a cache-stable byte contract, and it is pinned as one. The base
Agentbuilds the system prompt asstablePrefix(persona then the deterministic skill block) followed by any volatile per-request content, so the prefix a provider caches is byte-stable across requests. The 14 prompt constants are wire-contract strings reproduced verbatim, and the compose corpus pins the no-skills short-circuit, volatile appending, baseline ordering, and add-only capability-gated additions - the same byte discipline the M36-1 skills slice proved forcomposeSkillBlock, now proven end to end through the agent. - Structured output is a pinned
model_json_schema()document, not an SDK conversion. Every agent's output schema (two reviewer, eight generative) reproduces pydantic'smodel_json_schema()in pydantic key order -$defsalphabetical, each object'spropertiesin field-declaration order, shared sub-schemas declared once and referenced - and the corpus deep-equals the port against the real document. This is why the M35 no- vendor-SDK finding extends here: an LLM SDK's own schema conversion would drift from the pydantic-shaped document the corpus pins, so the schema is hand-built to the byte, not delegated. - Two distinct JSON payload writers coexist, and a unicode fixture exercises the split.
The reviewers and the designer's ticket dict render payloads with
json.dumps(indent=2)(pyJsonDumpsIndent,ensure_ascii- non-ASCII escaped); the product owner renders its requirements/PRD/milestone/ticket payloads with pydantic'smodel_dump_json(modelJson.ts- non-ASCII raw). Both writers are corpus-pinned and a unicode fixture proves the divergence on both paths, so the escaping contract is proven per agent, not assumed shared.
- The recording-provider seam keeps the agent tests offline and deterministic. Each
request case builds the real agent over a provider that captures
(system, user, schema)and returns a canned output;discover/triagehand their whole conversation toprovider.structureddirectly (the assistant first drops any trailing assistant turn so the request ends on a user message), a per-agent quirk pinned in the corpus rather than papered over. Parse cases share a malformed-JSON / empty-refusal / schema-mismatch error taxonomy (e.g.DesignStudySet's>= 2 optionsinvariant rejects a one-study input as a schema mismatch on both runtimes). - The CPython/pydantic JSON-twin kit gained its fourth and fifth writers: this layer
added
pyJsonDumpsIndent(the indentedensure_asciijson.dumps) andmodelJson.ts(the raw-non-ASCIImodel_dump_json), joiningpyJsonDumps/pyrepr/pystrfrom earlier layers - each ground-truthed against the real Python output rather than hand-written. - The
openclaw_harnessdeferral closed without a new byte contract. The harness only orchestrates the already-pinned PO discovery and Reviewer PR-review calls, so M36-6 adds no golden; its own contribution is the fail-soft error-wrap (failing step + provider/mode/model- the error's TYPE name, never a prompt, response body, gateway URL, or auth material),
which diffs as behavior parity against the Python
_harness_errorcontract in an offline unit suite. It takes an optional injectedagentOptionsso tests supply a registry and stay offline where the proprietary baseline skills are not vendored in this repo.
- the error's TYPE name, never a prompt, response body, gateway URL, or auth material),
which diffs as behavior parity against the Python
Roadmap: the deferral closes, and the remaining surface toward all-TS
- The agents layer is done and the cadence holds. The base, all seven specialist
agents, the skills-composition foundation, and the M35-deferred
openclaw_harnessnow have byte-faithful, corpus-pinned TS twins over the injectableLLMProviderseam; further agent-shaped work is repeat slices of the M31-gated shape. Withopenclaw_harnesslanded, the TSllm/package is complete - the last M35 import-graph deferral is closed. The freeze-core fallback stays dormant. - The remaining surface toward all-TS, in planned order: services + engine + the
deferred migration bundle (M37) - the orchestration-adjacent glue between routes and
domain over the now-ported stores, integrations, and agents, including
prd_renderand the messaging seam the M35 alerting sink waits on, and now the home for the export/import migration bundle (which importsservices/prd_render; its coordination- dependent leg completes alongside the M39 core); routes (M38) - the remaining read, write, and streaming endpoints, each a repeat slice of a paid-for shape. - Then the last NEW gate, unchanged: the orchestrator/workflow/langgraph core (M39) - the last unmeasured shape, where control flow itself is the contract; per recommendation #3 its first slice re-measures against the same ~1-dev-week gate. Cutover (M40) - gateway routes flipping to the TS backend, with the pooler budget re-derivation - closes the migration. Slices on already-measured shapes (M37, M38) need no per-slice gate check unless one visibly blows the measured envelope; M39 is the only remaining mandatory re-measurement before cutover.
M37 addendum - services + engine port (2026-08-20)
M37 ported the standalone services + engine layer - the whole bloom.engine execution
stack plus its engine_runner process, the coordination.labels leaf, the
orchestrator-independent slice of bloom.services, and the M35-deferred
bloom.migration bundle - roughly 4,300 lines of Python across seven slices to
apps/server/src/. Like M33/M34/M35/M36 this was NOT a mandatory gate re-measurement: the
engine is the execution shape (subprocess spawns, git-worktree operations, and GitHub
PR side effects behind injectable runner/transport/git seams, covered by offline unit
tests) and the services are the read/write shape (byte-parity corpora for the pure
renderers plus behavior twins over the already-ported stores), both shapes already measured
and gate-cleared. The genuinely unmeasured piece - the orchestrator/workflow/langgraph core
and every service that imports it - is deliberately NOT in this milestone: it folds into
M39, the last new gate. Per the evidence discipline the per-slice cost is recorded here, and
the roadmap updated.
The slices: M37-1 (#635) ported the
engine core abstractions - the EngineerEngine seam, the EngineRequest/EngineResult/
EngineRun models (validators, derived properties, the outcome->status-label mapping, the
issueBranch convention), the FakeEngineerEngine double, and the selection layer
(buildEngineerEngine/EngineProvider) - plus the shared coordination/labels.ts leaf
(the GitHub label constants, LABEL_DEFS, and the labelNames/statusOf/typeOf/
engineerOf accessors); the orchestrator-coupled coordination.plan_delta was left as a
leaf for M39. M37-2 (#636) ported the
concrete execution providers (command subprocess seam + redact scrubber, simulator,
disabled, remote runner-transport, and claude_code with its collaborators injected as
seams) behind the M37-1 factories. M37-3
(#637) ported the engine support layer -
the isolated-worktree WorkspaceManager, the per-repo BaseCheckoutManager, the
GitHubPRPublisher/TokenBranchPusher (one issue = one branch = one PR, in-review label
swap, deployment closing-keyword healing), the implementation-brief builder, the delegated
skills payload, and the claude_code readiness checks - the collaborators M37-2 injects.
M37-4 (#638) ported the standalone
engine_runner HTTP shim (bearer-authed, single-flight POST /implement + health/metrics,
the RunnerSettings env surface, and the claudeCommandRunner that scrubs
ANTHROPIC_API_KEY from the subprocess env so a run can never bill the API). M37-5
(#639) ported the first standalone
services batch (messaging - the Messenger seam, routing/recording wrappers, and the
outboundSendContext ambient tag - and the canonical prdRender markdown renderer). M37-6
(#640) ported the second batch
(scheduler, planningValidation, planningDeployment, projectReadme, adminBootstrap,
telegramOnboarding). M37-7 (#641)
ported the M35-deferred bloom.migration bundle - the versioned platform-neutral
ProjectBundle wire format, exportProject/importProject over the VCSProvider seam, and
the bloom migrate CLI - now unblocked because it imports services/prd_render (M37-5) and
coordination.labels (M37-1), closing the M35 import-graph carve-out. Everything ships
dormant (server.enabled unchanged; nothing routes through the new modules until M38 wires
routes and M39 lands the orchestrator).
Measured per-slice cost (M37-1 .. M37-7)
Same methodology caveat as the M30-M36 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M37-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M37-1 engine core + labels: <= ~20 min agent wall-clock - the milestone's issues were
created 09:24 UTC and PR #635 opened 09:44. That covers the contract models with their
validators/derived properties, the selection seam, the fake, the label vocabulary, and the
new
parity/engine/dimension pinning the label bytes, StrEnum member lists, model-dump bytes, and invariant messages against the realbloom.coordination.labels/bloom.engine.base. - M37-2 execution providers: <= ~16 min agent wall-clock - after #635 merged at 09:52, PR
#636 opened 10:08. That covers all five engines behind injected subprocess/
fetch/runner seams, theredactscrubber, and the provider corpus (parity/engine/providers.json) for the simulator/disabled result bytes and the remote transport's wire headers and failure texts. - M37-3 support layer: <= ~21 min agent wall-clock - after #636 merged at 10:16, PR #637
opened 10:37. The heaviest engine slice: the worktree/base-clone managers, the PR publisher
with its closing-keyword healing, the brief/skills builders, and the readiness checks, with
the pure builders byte-pinned (
parity/engine/support.json) and the git/GitHub I/O covered by offline unit tests with injected runners. - M37-4 engine_runner service: <= ~14 min agent wall-clock - after #637 merged at 10:46,
PR #638 opened 11:00. That covers the single-flight Express app, the env surface, the
subprocess env scrub, and an offline supertest suite mirroring
tests/unit/test_engine_runner_service.py(auth 401, missing-token 400, single-flight 429 +Retry-After: 60, the round-trippedEngineResult). - M37-5 standalone services A: <= ~16 min agent wall-clock - after #638 merged at 11:11,
PR #639 opened 11:27. That covers the messaging seam and the
prdRenderrenderer, with behavior parity mirroringtests/unit/test_messaging.pyand PRD rendering byte-pinned by a golden corpus from the realbloom.services.prd_render. - M37-6 standalone services B: <= ~14 min agent wall-clock - after #639 merged at 11:36,
PR #640 opened 11:50. Six small services, behavior parity mirroring the Python unit suites
offline, with the pure
initial_readmerenderer byte-pinned (parity/project_readme). - M37-7 migration bundle: <= ~20 min agent wall-clock - after #640 merged at 11:58, PR
#641 opened 12:18. That covers the sort-keyed
dumpBundlebyte contract (json.dumps(indent=2, sort_keys=True, ensure_ascii=False)), the export/import service over the VCS seam with#Ncross-reference renumbering, the token-env-only CLI, and theparity/migrationcorpus (all-default, full cross-section, populated PRD, and a unicodeensure_ascii=Falsecase). - Whole services + engine layer: <= ~121 min agent wall-clock across seven slices (~17 min per slice on average) - no slice over ~21 min, against first-slice shape costs of ~35 to ~87 min. The seven slices span the largest structural variety of any milestone so far (a subprocess/git execution stack, a standalone HTTP process, eight glue services, and a cross-platform bundle) yet every one landed inside the repeat-shape band.
- One-dev-equivalent estimate: roughly 3-4 dev-days for a comparable hand port of the whole layer, on the same basis as the earlier estimates: the dominant cost is again contract discovery - the git/PR side-effect sequences behind the seams, the runner's single-flight and env-scrub semantics, the sort-keyed bundle bytes, the label vocabulary - not the port itself.
Shape note: no new gate, engine=execution / services=read-write
The two families in this milestone are both already-measured shapes, so no new harness mode was needed and none was built:
- The engine is an execution shape. Its observable contract is what it does to the
outside world - spawns a Claude subprocess, cuts an isolated git worktree, pushes a branch
and opens/labels a PR, calls the remote runner over the tailnet - not a single new
bytes-on-a-wire serializer. The parity bar is therefore the M31 injectable-transport
discipline applied to processes and git: every side-effecting collaborator sits behind an
injected seam (
GitRunner, the subprocess runner, thefetchtransport, the workspace/ publisher fakes), so the whole layer tests fully offline - no live model, no network - and the M37-4 runner's env scrub even stripsANTHROPIC_API_KEYso a test run can never bill the API. The engine's pure surface - theEngineResult/EngineRunmodel-dump bytes, thebuildBrief/buildPrBody/closing-ref-healing builders, the label constants and the outcome->status mapping - rides the already-measured read/write byte contract, pinned by offline corpora generated from the real Python (parity/engine/). - The services are the read/write shape. The orchestrator-independent services are glue
over the M34 stores, the M35 integrations, and the M33 domain types; their contract is the
bytes they render and the behavior of their pure logic, exactly M29/M30. Pure renderers
(
prdRender,projectReadme) are byte-pinned by goldens from the real modules; the rest (messaging routing/recording, the scheduler heartbeat, planning validation/sequencing, admin bootstrap, Telegram onboarding) are behavior twins mirroring the Python unit suites, all offline. The migration bundle is the same read/write shape with one new byte contract - thesort_keys=Truerecursive-key-sortjson.dumps- pinned like every serializer before it.
There is no pooler impact. The engine talks to the runner over HTTP (injectable transport) and
does its heavy work as subprocess + git in a throwaway worktree; the standalone engine_runner
is a separate process on the worker VM holding only the Claude token and a short-lived per-run
GitHub token, never Bloom's shared Postgres pool. The services take the injected db.ts
client per the M34-1 rule and open no second pool. The M34 budget stance carries unchanged. The
envelope held with room: seven slices, none over ~21 min, against first-slice shape costs of ~35
to ~87 min. No gate is reopened.
The orchestrator carve-out is import-grounded, and it defines M39
The scoping predicate for every M37 service slice was verified orchestrator-independence -
each ported module was checked to import no orchestrator/workflow symbol before it was
pulled forward - so the milestone is exactly the sub-graph that can port ahead of the
control-flow core. The complement of that predicate is the M39 scope, read straight off the
import graph rather than guessed: the services that DO import the orchestrator/workflow core -
services/job_runner, services/ticket_children, services/project_flows,
services/admin_projects, services/coordinator, and services/langgraph_coordinator - stay
in Python and port alongside the core itself, together with the workflow/ package (the
built-in engine, expressions, reducers, runtime, spec, and the OpenClaw executor/policy/
runtime), the orchestrator-coupled coordination/plan_delta leaf M37-1 left behind, and the
swarm/ package. That is the last unmeasured shape - control flow as the contract - and it is
the single remaining mandatory gate before cutover.
Recommendation + roadmap: one gate left before cutover
- The standalone services + engine layer is done and the cadence holds. The whole
bloom.engineexecution stack, theengine_runnerprocess, the orchestrator-independent services, and the M35-deferred migration bundle now have offline, seam-isolated, corpus-or-behavior-pinned TS twins; further work here is repeat slices of paid-for shapes. The freeze-core fallback stays dormant. - The remaining ladder is short and import-grounded: routes (M38) - the remaining read, write, and streaming endpoints, each a repeat slice of a paid-for shape, now able to wire through the ported services and engine.
- Then the last NEW gate, unchanged: the orchestrator / workflow / langgraph core (M39) -
the last unmeasured shape, where control flow itself is the contract, now also carrying the
orchestrator-coupled services the M37 carve-out isolated (
job_runner,ticket_children,project_flows,admin_projects,coordinator,langgraph_coordinator), theworkflow/package, thecoordination/plan_deltaleaf, andswarm/. Per recommendation #3 its first slice re-measures against the same ~1-dev-week gate. Cutover (M40) - gateway routes flipping to the TS backend, with the pooler budget re-derivation - closes the migration. Slices on already-measured shapes (M38) need no per-slice gate check unless one visibly blows the measured envelope; M39 is the only remaining mandatory re-measurement before cutover.
M38 addendum - HTTP routes port (2026-08-20)
M38 ported the orchestrator-independent slice of the HTTP route layer -
bloom.api.routes - to Express routers in apps/server, wiring the dashboard and admin
surfaces through the services, stores, integrations, and OIDC primitives the earlier
milestones already ported. Like M33/M34/M35/M36/M37 this was NOT a mandatory gate
re-measurement: an HTTP route is the read shape (aggregate reads over the ported stores),
the write shape (audited upserts/revokes), and - for the endpoints already carried before
this milestone - the streaming shape, all three already measured and gate-cleared. The
genuinely unmeasured piece - every route that hard-imports the orchestrator/workflow core -
is deliberately NOT in this milestone: it folds into M39, the last new gate, alongside the
core it depends on. Per the evidence discipline the per-slice cost is recorded here, and the
roadmap updated.
The slices: M38-1 (#649) ported the user
auth routes (auth.py -> authRoutes.ts) - the dashboard sign-in surface (/api/auth/config,
the Google login/callback pair, /me, test-login, logout) - over the already-ported
oidc.ts/settings.ts/account_store/metrics.ts/httpErrors.ts primitives, adding two
reusable seams: authCookies.ts (the Starlette Response.set_cookie/delete_cookie
byte-parity twin - Morsel's sorted-attribute order, _quote value encoding, the _getdate
HTTP-date) and testGate.ts (the require_test_surface twin - 404 when disabled,
constant-time password check otherwise). M38-2
(#650) ported the admin auth routes
(admin_auth.py -> adminAuthRoutes.ts) behind the existing adminOriginCheck CSRF gate -
the admin authorization-code + PKCE flow, the callback that gates on a live admin grant
before minting anything, and logout - with the deliberately separate admin cookie policy
(host-only, always Secure + SameSite=Lax, 12h TTL) and the Starlette redirect/last-query
helpers now shared out of authRoutes.ts rather than duplicated. M38-3
(#651) ported the admin aggregator
endpoints (admin/route.ts - /api/admin/me, the /overview counter-tile + recent-activity
aggregate fanned out with Promise.all as the asyncio.TaskGroup twin, and the filtered
/audit-log trail) plus the Prometheus /metrics route (metricsRoute.ts - bearer-gated
with a constant-time compare, fail-closed 404 tokenless, mounted app-level so it serves even
in health-only mode). M38-4 (#652) ported
the two account-scoped routers - global_credentials.py -> globalCredentialsRoutes.ts (the
metadata-only credentials overview plus the write-only upsert/revoke over the per-user
global-{user_id} scope, each wrapped in a credential.<op> audit event, over the
Infisical-backed store built from settings or a clean 503) and telegram_link.py ->
telegramLinkRoutes.ts (link status, one-time code + deep-link mint, unlink) - adding the
shared getCurrentUserFromStore (the get_current_user twin over the AccountStore seam),
ensureRunId, and sendNoContent primitives. Everything ships behind the DB gate; the
gateway keeps routing these paths to Python until cutover (M40).
Measured per-slice cost (M38-1 .. M38-4)
Same methodology caveat as the M30-M37 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M38-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M38-1 user auth routes: <= ~33 min agent wall-clock - the milestone's issues were
created 13:13 UTC and PR #649 opened 13:46. That covers the six-route dashboard auth surface,
the two new cookie/test-gate seams, and the new
parity/auth/dimension byte-diffing every cookie and response body (23 cases) against goldens from the realbloom+starlette, plus the supertest + PGlite integration suites. - M38-2 admin auth routes: <= ~9 min agent wall-clock - after #649 merged at 13:55, PR #650
opened 14:04. The cheapest slice: three routes reusing the M38-1 flow/cookie primitives and
the redirect/query helpers shared out of
authRoutes.ts, the grant-gatedsurface='admin'session round-trip, and theparity/admin_auth/host-only-cookie byte corpus. - M38-3 admin aggregator + metrics: <= ~24 min agent wall-clock - after #650 merged at
14:11, PR #651 opened 14:35. That covers the
/overviewconcurrent-read aggregate, the/audit-logfilter/pagination/get-by-id envelope, the auth-order-preserving/me, and the bearer-gated/metricsexposition route, with thedb.tsmaxoverride that lets the offline PGlite integration suite pipeline the concurrent reads over one connection. - M38-4 account-scoped routes: <= ~28 min agent wall-clock - after #651 merged at 14:43, PR
#652 opened 15:11. Two routers plus the three shared primitives, the write-only credential
path (value reaches the store, never the response), the 401/503/422 surfaces, and the
parity/account_scoped/corpus (response-model bytes, pydantic's field-level 422 envelopes, theprovided_at...Zwire form, the_deep_linktwin) from the real bloom + FastAPI. - Whole ported route layer: <= ~94 min agent wall-clock across four slices (~24 min per slice on average) - no slice over ~33 min, against first-slice shape costs of ~35 to ~87 min. Routes are the composition layer over five paid-for milestones, so the marginal cost is wiring and cookie/envelope byte-parity, not new contract discovery - the fastest milestone yet on a per-slice basis, with the trivial M38-2 landing in under ten minutes.
- One-dev-equivalent estimate: roughly 2-3 dev-days for a comparable hand port of the ported routes, on the same basis as the earlier estimates: the dominant cost is the wire-contract edges - Starlette's Morsel cookie byte order, the admin host-only cookie policy, the FastAPI dependency evaluation order (401/403 before 422), the write-only credential envelope - not the routing itself, which is mechanical over the already-ported services.
Shape note: no new gate, routes are repeat read/write shapes
Every route ported in this milestone is a composition of already-measured shapes, so no new harness mode was needed and none was built:
- The reads are the M29/M33 read shape.
/api/auth/me,/api/admin/me,/api/admin/overview,/api/admin/audit-log, and the credentials/telegram-link status reads are aggregate reads over the ported stores whose observable contract is the response bytes; they are asserted byte-for-byte (or shape-for-shape tracking the Python pytest assertions) against goldens from the real FastAPI, exactly as every read slice before them. The/overviewfan-out is the one structural note -Promise.allas theasyncio.TaskGrouptwin, pipelined over a single pinned PGlite connection in the offline suite. - The writes are the M30 write shape. The credential upsert/revoke, the telegram link/unlink,
the session mint/teardown on the auth callbacks, and the
test-loginpath are audited writes over the ported stores and the Infisical-backed credential seam; their contract is the durable row plus the audit event plus the response envelope, all covered by PGlite integration twins and the audit-counter assertions. The write-only credential path - value reaches the store, never the response - is the one write-specific invariant, pinned in both the unit and parity suites. - The cookies/redirects are a byte contract like any serializer. The one genuinely
route-specific surface is the HTTP framing itself -
Set-Cookieattribute order and value encoding, redirect targets,{"detail": ...}error envelopes, the 204 no-content framing - and it rides the same golden-corpus discipline:authCookies.tsis a Morsel byte twin pinned byparity/auth/, the admin host-only policy byparity/admin_auth/, the response/422 envelopes byparity/account_scoped/.
There is no pooler impact. Every ported router takes the injected db.ts client per the M34-1
rule (M38-3 adds only a narrow max override so the offline suite can pin a single PGlite
connection - a test seam, not a second production pool), the credential routes reach Infisical
over the M35 injectable transport, and the OIDC flow talks to Google over the M31 fetch seam.
The /metrics route mounts app-level and needs no DB at all. The M34 budget stance carries
unchanged. The envelope held with room: four slices, none over ~33 min, against first-slice shape
costs of ~35 to ~87 min. No gate is reopened.
The M38/M39 route boundary is import-grounded (the explicit deferral list)
The scoping predicate for every M38 route slice was verified orchestrator-independence - each
ported router was checked to import no orchestrator/workflow/langgraph symbol before it was
pulled forward - so the milestone is exactly the sub-graph of bloom.api.routes that can port
ahead of the control-flow core. The complement of that predicate is read straight off the import
graph, not guessed, and it is the M39 route scope:
- Routes DEFERRED to M39, because they hard-import the orchestrator/workflow core:
credentials.py- the per-project credential routes takeDepends(get_orchestrator)and call throughservices.orchestrator.Orchestrator(distinct from the account-scopedglobal_credentials.py, which M38-4 ported).github.pyandgitlab.py- the VCS webhook ingress routes depend onget_orchestrator(andget_job_queue/get_webhook_providers) and dispatch onto the workflow path.telegram.py- the Telegram webhook ingress depends onget_orchestratorand fences on the sametg:<update_id>event key the workflow path uses (distinct from the account-scopedtelegram_link.py, which M38-4 ported).projects.py- the project CRUD + thread routes depend onget_orchestrator; the full CRUD stays deferred and only the SSE events subset is ported (projects/eventsRoute.ts, carried before this milestone - see below).admin_resources.py- the generic admin resource CRUD family importsservices.admin_projects(ProjectAdminReader,project_detail_from_run), which is one of the orchestrator-coupled services the M37 carve-out isolated into M39.app.tsleaves an explicitTODO(M39)at its mount point; only itsaudit_logresource slice is live today (the already-mountedadminAuditLogRouter).
- Routes already ported PRE-M38 (mounted before this milestone, so out of M38 scope): the
admin actions surface (
admin_actions.py->adminActions/route.ts), the admin audit-log resource slice (adminAuditLogRouter), the test surface (testing.py->testing/route.ts, behind the test gate), and the projects SSE events subset (projects/eventsRoute.ts, the only part ofprojects.pythat ports ahead of the core - it publishes over the in-process event bus the harness feeds, and touches no orchestrator import).
So the route layer splits cleanly: the reads, writes, auth, and the account-scoped surface port on already-measured shapes now (M38); the webhook ingress and the orchestrator-coupled CRUD port with the core they call (M39).
Recommendation + roadmap: cutover is next after the last gate
- The orchestrator-independent route layer is done and the cadence holds. The dashboard and admin auth surfaces, the admin aggregator reads, the metrics scrape, and the account-scoped credentials/telegram-link routers now have offline, seam-isolated, corpus-or-behavior-pinned TS twins mounted behind the DB gate; further work here is repeat slices of paid-for shapes. The freeze-core fallback stays dormant.
- The remaining ladder is now just the gate and the cutover. M38 was the last milestone that ports on already-measured shapes, so there is no further "repeat-shape" milestone between here and the gate.
- The last NEW gate, unchanged: the orchestrator / workflow / langgraph core (M39) - the last
unmeasured shape, where control flow itself is the contract. It now also carries the
orchestrator-coupled services the M37 carve-out isolated (
job_runner,ticket_children,project_flows,admin_projects,coordinator,langgraph_coordinator), theworkflow/package, thecoordination/plan_deltaleaf,swarm/, and the M38-deferred routes that import them (credentials,github,gitlab,telegram, the fullprojectsCRUD, and theadmin_resourcesCRUD family). Per recommendation #3 its first slice re-measures against the same ~1-dev-week gate. Cutover (M40) - gateway routes flipping to the TS backend and decommissioningapps/api, with the pooler budget re-derivation - closes the migration. M39 is the only remaining mandatory re-measurement before it.
M39 addendum - orchestrator / workflow / langgraph core (2026-08-21)
This is recommendation #3's last mandatory gate. Every earlier addendum deferred one shape to
here: the orchestrator / workflow / langgraph core, the highest-risk and last-unmeasured shape,
"where control flow itself is the contract rather than bytes on a wire". M39 ports it - the workflow/
langgraph runtime, its OpenClaw execution adapters, the services.orchestrator decision/lifecycle/
review cores, the orchestrator-coupled service cores the M37 carve-out isolated, and the M38-deferred
orchestrator-coupled routes - each pinned byte-for-byte against the untouched Python via the
differential parity harness, everything shipping dormant (server.enabled unchanged, the gateway
still routing every path to Python).
The headline result was settled before the milestone opened, by the control-flow spike: the
byte contract of the orchestrator's decision logic needs no new serializer. The read slice paid
for pyjson.ts, the LLM slice for pyrepr.ts, the streaming slice for pyJsonDumps, the domain
slice for the wire.ts datetime pins; the control-flow core reuses those canonicalizers unchanged.
The spike drove the real bloom orchestrator decision cores through the shared canonical writer and
got 37/37 byte-identical on the first pass, delegating the only non-trivial case - non-integer
float rendering - to the existing pyReprFloat twin in pyjson.ts. So M39 is a control-flow port,
not a byte-contract discovery: the risk that made this the last gate (that control flow would hide
an un-ported serialization surface) did not materialize, and the milestone's cost is dominated by
reproducing Python evaluation semantics (reducer/guard truthiness, routing decisions, lifecycle
transitions), not by pinning new wire bytes.
The slices: M39-1 (#664) scaffolded the
parity/orchestrator/ harness dimension mirroring parity/engine/ - the shared canonicalGolden()
writer (delegating non-integer floats to pyReprFloat, no new float util), the gen_goldens.py
oracle, and a smoke corpus - so every later slice pins goldens through one byte-exact serializer.
M39-2 (#665) ported the langgraph runtime core
(workflow/): the WorkflowSpec zod twins + reference validation, the replace/append/union/
merge/add channel reducers with dotted-path writes (reproducing Python == dedup and or 0
falsiness), the guard-expression evaluator with shared pyValuesEqual/pyTruthy helpers, and the
Engine control-flow interpreter (start/advance/resume/map fan-out/subworkflow guard) over the
already-ported persistence RunState. M39-3 (#666)
ported the OpenClaw execution adapters on top of that runtime - the code-owned capability policy
gate (node-type -> side-effect -> allowlist -> writes-subset, with pyRepr reason strings), the pilot
runtime (per-node allowlist + policy gate, correlation reserve/update, cached-success replay,
fail-closed fallback, sha256 bounded-input idempotency key), the gateway node executor over the M31
injectable transport (secret-shaped keys stripped, responses validated against declared writes), the
track_progress rig, and the deterministic decision core of the flip-readiness check. M39-4
(#667) ported the orchestrator decision cores -
detectStalls, the proactiveOk quiet-hours gate (IANA-tz aware), the review-finding
partition* splitters, and the StatusSnapshot shape + predicates - reusing the ported statusOf
and rightCommentableLines rather than re-deriving them. M39-5
(#668) ported the lifecycle A cores: the
inbound-message routing parsers, the multi-project ProjectIndex model + its state transitions
(seed/adopt-legacy/append/switch), client-approval resolution, the credentials-nudge readiness gate,
and proactive-tick gating (classifyStall/digestDue). M39-6
(#669) ported the lifecycle B review/rework
cores: the PR-review + rework-brief renderers, the specialist-review body, the deployment-workflow
backstop, the closing/reference issue-link parse, and the review-idempotency + deliverable-round
transitions, reusing the M39-4 partition/minorOnly cores. M39-7
(#670) ported the orchestrator-coupled service
cores deferred from M37 - jobRunner (job-kind -> dispatch routing, chat resolution, the
retry-vs-dead-letter decision), ticketChildren (store-key namespace + shadow-mode parity verdict),
adminProjects (the project-row gate + state->overview projections), and coordinator (the
deterministic reconcile pass as a pure planner mirroring the default engine; the non-default
LangGraphCoordinator spike diverges on awaiting-client and was never in scope). M39-8
(#671) ported the M38-deferred
orchestrator-coupled routes, mounted behind the DB gate: the VCS webhook ingress
(GitHub HMAC / GitLab static-token, constant-time), the Telegram webhook, the per-project credentials
routes, the projects admin-action verbs (archive/unarchive/trigger_tick), the generic
admin_resources CRUD family (clearing the TODO(M39) mount point and extracting a shared,
spec-parameterized parseListParams), and the project chat history/post routes. Following the
established pattern (projects/ownership.ts), each route's orchestrator calls are reimplemented as
direct run-state reads/writes + durable enqueues over the already-ported stores, registries, and job
queue - no live orchestrator instance is constructed - so the routes port without dragging the
async worker composition forward with them.
Measured per-slice cost (M39-1 .. M39-8)
Same methodology caveat as the M30-M38 addenda - the PR bodies carry no self-reported session clock, so the numbers are upper bounds from the GitHub timeline. One agent worked the tickets serially, so each bound runs from the previous PR's merge (issue creation for M39-1) to the slice's PR opening (commits rebased at push, so per-commit times do not resolve finer):
- M39-1 harness scaffold: <= ~8 min agent wall-clock - the milestone's issues were created
10:00 UTC and PR #664 opened 10:08. The cheapest slice by design: it reuses
pyjson.ts's canonicalizers wholesale (the spike's whole point), so it is a thingoldens.ts+ oracle + smoke corpus with no new byte logic. - M39-2 runtime core: <= ~21 min agent wall-clock - after #664 merged at 10:16, PR #665 opened
10:36. The reducer/expression/engine semantics with their
pyValuesEqual/pyTruthytwins and the 50-case adversarial state/edge golden corpus. - M39-3 OpenClaw adapters: <= ~27 min agent wall-clock - after #665 merged at 10:47, PR #666 opened 11:14. The heaviest runtime slice: the capability policy, the pilot runtime with its idempotency hashing, the gateway executor over the injectable transport, and ~49 policy/readiness decision goldens plus the offline runtime/executor twins.
- M39-4 decision cores: <= ~17 min agent wall-clock - after #666 merged at 11:21, PR #667 opened 11:38. The stall/quiet-hours/partition/predicate cores over the spike's adversarial matrix (tz boundaries, DST, quiet-hours wrap-around, invalid-tz fallback).
- M39-5 lifecycle A: <= ~16 min agent wall-clock - after #667 merged at 11:46, PR #668 opened
12:02. The routing parsers +
ProjectIndextransitions, pinned by a 103-case corpus. - M39-6 lifecycle B: <= ~15 min agent wall-clock - after #668 merged at 12:10, PR #669 opened 12:24. The review/rework renderers + idempotency/deliverable transitions, 45 goldens across eight sections.
- M39-7 coupled service cores: <= ~20 min agent wall-clock - after #669 merged at 12:33, PR #670 opened 12:53. The four deferred-from-M37 service cores, 92 goldens (job_runner 37, admin_projects 31, ticket_children 17, coordinator 7), goldens driven through recording fakes.
- M39-8 coupled routes: <= ~75 min agent wall-clock - after #670 merged at 13:01, PR #671 opened
14:16. By far the longest slice, and structurally so: it is six focused route sub-slices
(webhooks, telegram, credentials, admin-action verbs, the
admin_resourcesCRUD family, project chat), each with its own parity corpus (26 route goldens) and a real-PGlite + real-socket integration suite (37 route integration cases), plus the reimplement-orchestrator-calls-as-store-ops work per route and a set of documented deferrals (below). - Whole orchestrator / workflow / langgraph core: <= ~199 min agent wall-clock across eight slices (~25 min per slice on average) - the last and largest new shape, ported in roughly 3.3 hours of agent wall-clock, with the runtime and decision-core slices all landing at 15-27 min and the one ~75-min outlier explained entirely by M39-8 being six routes in a trench coat rather than by any control-flow cost premium.
- One-dev-equivalent estimate: roughly 4-6 dev-days for a comparable hand port of the whole core,
on the same basis as the earlier estimates. The dominant cost is Python evaluation-semantics
fidelity - reducer dedup +
or 0falsiness, guard truthiness, the capability-policy decision lattice, the tz/DST/quiet-hours decision boundaries, the lifecycle state transitions - not byte discovery, which the spike proved was already paid.
Gate verdict: UNDER, and this was the last gate
Verdict against the ~1-dev-week gate: UNDER, by a factor of ~1.5-2. This is the thinnest margin
of any measured shape - the earlier gates cleared by ~2-5x - and appropriately so: it is the largest
core (~4-6 dev-days against a ~5-day gate) and the one the whole feasibility question was hedged
against. It still cleared, and it cleared for the reason the spike predicted: control flow turned out
to be evaluation semantics over the already-measured byte contracts, not a new contract of its
own. No new harness mode was built and none was needed - the parity/orchestrator/ dimension is the
M33 domain discipline (corpora whose goldens are generated from the real Python modules, replayed
offline through the twins, the generators asserting every hand-pinned error substring against the live
Python), and the routes ride the M29/M30 read/write byte contract and the M38 cookie/envelope
corpora. pyjson.ts carried the orchestrator's decision bytes at 37/37 with no extension.
All measured shapes are now in, and every one cleared the gate: read (~35 min, ~3-5x under), write (<= ~40 min, ~2.5-3x), LLM (<= ~44 min, ~2.5-3x), streaming (<= ~87 min, ~1.7-2.5x), and now the orchestrator/langgraph core (<= ~199 min, ~1.5-2x). The freeze-core-and-grow-TS fallback, live since M29 and conditioned on any first-of-shape slice blowing the gate, is retired: there is no further unmeasured shape for it to guard. Every remaining piece of the migration is either already ported or a repeat of a paid-for shape.
Control-flow-specific findings
The full ledgers live in apps/server/parity/README.md (the
M39 sections) and in the module offline suites; the feasibility-relevant findings:
- The pure-core / async-wrapper split is the technique that made control flow byte-testable. Each orchestrator slice extracts the side-effect-free decision + render cores (what to decide, what to render) ahead of the async class assembly that composes them with live store/messenger/runtime/ VCS I/O - the same discipline the LLM slice used to isolate the transport. The cores are pure functions of their inputs, so they carry byte goldens; the async glue that sequences them is control flow with nothing to byte-diff, exercised instead by the route integration suites over real PGlite. This is why "control flow is the contract" resolved cheaply: the decisions are byte-pinned and the sequencing is integration-tested, and neither needed a new serializer.
- Python evaluation semantics are the real port surface, and they are pinned, not approximated.
The reducers reproduce
==-based dedup andor 0falsiness; the guard evaluator carries sharedpyValuesEqual/pyTruthyhelpers soall/any/notand comparison ops branch exactly as CPython does; the tz-aware gates reproduce DST and quiet-hours-wrap boundaries; the capability policy reproduces the node-type -> side-effect -> allowlist -> writes-subset decision lattice withpyReprreason strings. Each is pinned by an adversarial golden corpus, not asserted by shape. - The shipped
reconcilePlanmirrors the default coordination engine; the non-default langgraph spike diverges and was never in scope.Coordinator.reconcile(the default engine) is one deterministic pure planner, andreconcilePlanreproduces it byte-for-byte - including theawaiting-clientskip guard (coordinator.ts:226, parity-verified). The non-defaultLangGraphCoordinator(BLOOM_COORDINATION_ENGINE=langgraph) is not byte-equivalent: its_classifyomits that guard, so under the langgraph engine anawaiting-clientticket would be silently un-parked. That spike was never in scope for this port, so it carries no separate port or separate goldens - the control-flow contract we pinned is the default engine's plan, not the langgraph variant's. - The routes port without a live orchestrator by reimplementing its calls as store ops. Every
M39-8 route reaches the shared Postgres directly - run-state reads/writes + durable enqueues over
the ported stores and job queue - rather than constructing an
Orchestrator. The webhook signature checks usetimingSafeEqual(GitHub HMAC-SHA256, GitLab/Telegram static-token), matching the M35 constant-time verifiers. This is the coexistence premise at its sharpest: the queue is in shared Postgres, so a TS route can enqueue a job that a Python worker drains, byte-identically, during any cutover window. - The deferrals are enumerated against un-ported orchestrator internals, not hand-waved. M39-8
documents each in-code:
github.py's setup GET (live GitHub-App installation lookup),projects.pylist/create/get + timeline (the live-VCS overview/detail aggregate, the workflow-runtime create turn, the event store's sub-millisecondat), the credentials-readiness nudge (needs the workflow spec + bus + messenger),admin_action_defs/projects.edit_state(byte-exact pydantic RunState + datetimeValidationErrorbytes), and theadmin_resourcesprojects resource (needsservices.project_flows.derive_child_flows). These are the residual orchestrator internals a live TS backend needs before it can serve those specific paths; until then the gateway routes them to Python. They are the concrete work-list the M40 cutover inherits (below), not silent gaps. - The live outbound transport is a composition-root placeholder.
app.tswires aLoggingMessengerin place of the production Telegram outbound stack; the real transport is a composition-root wiring completed at cutover, when the TS backend first needs to send rather than decide what to send.
Pooler budget note
The core adds no new pool and no new connection-holding pattern of its own. Every M39 module takes
the injected db.ts client per the M34-1 rule; the workflow runtime and the decision/lifecycle/
review cores are pure and touch no connection; the routes reach Postgres through the same injected
client (M39-8 adds only test-seam max overrides for offline PGlite pinning, not a second production
pool), the gateway executor and OpenClaw transport ride the M31 fetch seam, and the webhook routes'
only DB work is bounded run-state reads + enqueues. Production footprint is still unchanged
(server.enabled remains off). What sharpens is the cutover stance: M40 is the first time the TS
backend carries live traffic against the shared Supabase pooler, and it is the moment the whole
per-backend pool budget the M30/M32/M34 notes deferred must actually be re-derived - not the abstract
"before the first gateway route flips" of prior addenda, but a concrete cutover step (below).
Recommendation + roadmap: the gate is cleared - cutover (M40) is all that remains
- The last gate is cleared and the strangler-fig is proven end to end. All five measured shapes -
read, write, LLM, streaming, and now the orchestrator/langgraph core - came in under the
~1-dev-week gate; the freeze-core fallback is retired, not merely dormant. Every layer of
bloom.*now has a byte-faithful, corpus- or integration-pinned TS twin inapps/server, shipping behind the DB gate. - No mandatory re-measurement remains. M39 was the last unmeasured shape by construction (the M32 frontier-map correction ruled out in-model tool-use loops; the M38 boundary was import-grounded). The residual work is the M39-8 deferral list - un-ported orchestrator internals behind five specific paths - each a repeat of an already-measured shape (a live-VCS aggregate read, a workflow-runtime create turn, a pydantic-error byte pin), not a new gate.
- The migration closes with M40 cutover - flip
server.enabled, point the gateway routes atapps/server, prove staging parity, drain and decommissionapps/api- with the pooler budget re-derivation as a hard cutover step. The plan is below.
M40 cutover plan
M40 is the terminal milestone: it flips live traffic from the Python apps/api to the TypeScript
apps/server, then decommissions the Python backend. It ports no new shape - M39 cleared the last
gate - so the risk it manages is entirely operational: a controlled traffic flip over one shared
Postgres, reversible at every step, with parity proven in staging before production and apps/api
retired only after a clean soak. The deploy topology this rides already exists and is guarded: the
server ships as a second workload beside the API (image, helm chart, CI, health/readiness surfaces),
gated off by server.enabled: false in
deploy/helm/bloom/values.yaml until a gateway route points at
it.
Prerequisite: close the M39-8 deferral list
The gateway can only route a path to apps/server once the TS backend can serve it. Before any
flip, land the enumerated M39-8 deferrals as their own small slices - each a repeat of a measured
shape, none a new gate:
projects.pyfull CRUD + timeline - the live-VCS overview/detail aggregate read (read shape), the workflow-runtime create turn (the M39-2 runtime is ported; this wires it at the composition root), and the timeline's sub-millisecond event-storeat.github.pysetup GET (complete_client_owned) - the live GitHub-App installation lookup over the M35 GitHub integration seam.admin_action_defs/projects.edit_state- the Zod error-shaping effort to reproduce pydantic'sRunState+ datetimeValidationErrorbytes (a write-shape byte pin).admin_resourcesprojects resource - portservices.project_flows.derive_child_flows, the one orchestrator-coupled service M39-7 did not cover.- The live outbound messenger (M40-6, landed) - replaced the
app.tsLoggingMessengerplaceholder with the production messenger stack (WorkspaceRoutingMessengeroverRecordingMessengerover the M35 Telegram transport), thecreate_appmessenger-wiring twin, and wired the credentials-readiness nudge (workflow spec + bus + messenger) it depends on. This is the side-effect boundary: the first point the TS backend performs a live outbound side effect rather than only logging. It is guarded behind the sameBLOOM_TELEGRAM_BOT_TOKENconfig the Python messenger reads - with a token the live Telegram transport sends and the ledger channel istelegram; without one the log fallback stands in and the channel islog, so an unconfigured deploy performs no live send while every outbound message is still ledgered and logged. No new secret plumbing (the sharedbloom-api-secrets). The gateway still routes the inbound Telegram webhook + orchestrator-driven sends to Python until the async worker lands and cutover flips. - The async orchestrator worker at the composition root (M40-7, landed) - assembled the ported
M39 cores into the running worker (
src/services/jobRunner.ts): theJobRunnerclass (claim -> dispatch -> complete / reschedule-with-backoff / dead-letter, lease heartbeat, give-up notification) plus itscreate_app-wiring builders (buildDispatch,buildDeadLetterNotifier,buildJobRunner). The worker dispatches to a typedOrchestratorseam driven by the pinneddispatchCallrouting core, and its end-to-end drain is byte-diffed against the REAL PythonJobRunnerby a lifecycle parity corpus (parity/services/gen_job_runner_drain.py: queue transitions, give-up text, silent-failure alert, metric increments). This closes the "async I/O wrapper" every M39 slice deferred. The one remaining un-ported internal is the asyncOrchestratorclass itself (the thickhandle_*I/O the seam points at); until it lands, the worker is assembled and drain-parity-proven but unstarted at the composition root - the same dormant posture the whole backend ships in behindserver.enabled: false- and the gateway keeps routing the orchestrator-coupled paths to Python.
Until these land, the gateway keeps routing their specific paths to Python; the flip is path-by-path, not all-or-nothing, exactly because the two backends coexist over one Postgres.
M40 cutover-readiness addendum (M40-8, 2026-08-21)
The deferral list above is closed and the async wrapper is assembled, so the prerequisite gate is met and the env cutover can proceed. Landed since the M40 plan opened:
- All five enumerated M39-8 deferrals shipped.
projects.pyfull CRUD + timeline (M40-2, #683),github.pysetup GETcomplete_client_owned(M40-3, #684),admin_action_defs/projects.edit_state(M40-4, #685), theadmin_resourcesprojects resourcederive_child_flows(M40-5, #686), and the live outbound messenger + credentials-readiness nudge (M40-6, #687). Each was a repeat of an already-measured shape (read aggregate, write-shape byte pin, integration-seam lookup, live outbound side effect), not a new gate, so none reopened the ~1-dev-week measurement. - The async orchestrator worker is assembled (M40-7, #688):
src/services/jobRunner.tswires the ported M39 cores into theJobRunnerworker pool (claim -> dispatch -> complete / reschedule-with-backoff / dead-letter, lease heartbeat, give-up notification) plus itscreate_appbuilders, drain-diffed byte-for-byte against the real PythonJobRunner. This closes the "async I/O wrapper" every M39 slice deferred.
One un-ported internal remains, and it scopes what may flip. The thick async Orchestrator class
the worker's dispatch seam points at (its handle_* I/O) is not yet ported, so the worker is
assembled and drain-parity-proven but unstarted at the composition root - the same dormant posture the
whole backend ships in behind server.enabled: false. Cutover of the non-orchestrator paths (the read,
account-scoped, and admin surfaces the deferrals above cover) may therefore proceed now; the
orchestrator-coupled paths (the inbound Telegram webhook and orchestrator-driven sends) stay routed to
Python until that class lands, which is exactly what the path-by-path flip is built to allow.
Update (M41, 2026-08-22): this last un-ported internal is now CLOSED. M41 ported the thick async
Orchestratorclass - all eighthandle_*/perform_*entry points, each side-effect-ordering parity-proven against the real Python, and the fully assembled orchestrator diffed end to end through a representative multi-event lifecycle (M41-9). The worker's start path is now wired behind a default-off gate, so the composition root can arm the drain loop against the complete orchestrator. See "## M41 addendum" below for the per-slice verdicts and the one carried divergence.
Env framing: the cutover runs against dev, not staging. The M40 plan and the checklist below name
a "staging" pair, but no staging tier exists yet - the only available deployed env is dev, and prod
is gated behind the separate paid-staging decision. So read every "staging" step below as the dev
env: enable server.enabled: true in the dev overlay, add the dev server ingress, and run the parity
harness + lifecycle diff + pooler-count watch against the dev pair over its one shared Postgres. The
production repeat (checklist step 5) does not begin until that paid-staging/prod decision lands and its
own pooler budget is re-derived for the prod project's tier.
Re-derived shared-pooler coexistence budget (M40-8, 2026-08-21)
This is the hard gate checklist step 1 demands, re-derived now that both backends carry their full
production workloads. The constrained resource is server-side Postgres connections held through the
shared Supabase transaction pooler (Supavisor, port 6543) - the pooler's default_pool_size per
role+db, whose Supabase default is 15 on the dev project's compute tier (read the exact value from
Database -> Connection pooling -> Pool Size at cutover). During the coexistence window both backends
run at once against that one ceiling, so the sum of their pool max values - not either peak alone -
must fit under it.
Count sockets and pool slots separately (the M32 SSE decoupling). Two budgets, only one of which the pooler ceiling constrains:
- Pool slots (server-side Postgres connections): the binding budget. A backend borrows one pooler
server slot per connection for the duration of an open transaction; worst case every client
connection in its pool is mid-transaction at once, so each backend's pool
maxis its conservative slot ceiling. The write-side connection-holding transactions are what keep a slot borrowed longest: the job-claimFOR UPDATE SKIP LOCKED(both backends run 2 worker loops by default) and the last-superadminFOR UPDATElockout, plus the audit/event appends. - Sockets (HTTP / client connections): not pooler-constrained. A long-lived SSE stream holds an
HTTP socket but no Postgres connection - the route's DB reads complete before the stream opens (M32
finding), so concurrent stream count never consumes a pool slot. Sockets are bounded by the
ingress/uvicorn/Node worker config; the client->pooler connections themselves (14 total, below) sit
far under Supavisor's
max_client_conn(~200 default), so that layer is not the constraint either.
| Backend | Production pool | max (server slots) | Longest connection holders | Source |
|---|---|---|---|---|
apps/api (Python) | one shared asyncpg pool (state store's; job queue, correlations, outbound ledger, project events, accounts, audit, admin reader all attach_pool to it) | 10 | job-claim FOR UPDATE SKIP LOCKED x2 workers, account FOR UPDATE lockout, audit/event appends | supabase_store.py max_size=10; app.py composition root |
apps/server (TS) | one shared postgres.js pool (M34-1 rule: every store takes the injected client) | 4 | job-claim FOR UPDATE SKIP LOCKED x2 workers + account FOR UPDATE, sharing the 4 slots with HTTP handlers | db.ts max: 4; index.ts single call site |
| Coexistence sum | both hold full budgets simultaneously | 14 | worst case: all 14 client connections mid-transaction at once | must fit under the pooler ceiling |
| Pooler ceiling | Supavisor default_pool_size, per role+db | ~15 (dev tier default) | reserve ~3 for Postgres superuser_reserved_connections + setup/migration/monitoring | Supabase dashboard (confirm at cutover) |
Verdict: 14 fits under a 15-slot ceiling, but with only one slot of headroom - too tight to flip
blind. At the current sizes the coexistence sum leaves no room for the pooler's own reserved
superuser connections or a transient setup/migration/monitoring connection, so a saturated moment on
both backends could exhaust the pool. The issue's own instruction - size both sides to coexist, not to
peak - is the fix: for the coexistence window, trim the Python apps/api pool (e.g.
supabase_store.py max_size 10 -> 8) so the sum is 8 + 4 = 12, restoring a ~3-slot margin, and
watch the live pooler connection count against this budget during the dev soak (checklist step 4). The
TS side stays at max: 4 - it is already the deliberately small pool, and it is the one carrying the
new second backend. If the dev project's confirmed Pool Size is materially above 15, the trim can be
relaxed proportionally; if it is at or below 15, the trim is mandatory before the first route flips.
At decommission (checklist step 6) the pooler serves one backend again, so restore the surviving
backend's max and re-derive it a final time for the single-backend steady state.
Cutover checklist
- Re-derive the pooler budget (hard gate, do first). Per the M30/M32/M34 notes, re-derive each
backend's
maxagainst the shared Supabase pooler's connection ceiling before the first production route flips, counting sockets and pool slots separately (the M32 SSE decoupling) and accounting for the write-side connection-holding transactions (FOR UPDATElocks,FOR UPDATE SKIP LOCKEDjob claim). During the coexistence window both backends hold their full budgets, so the sum must fit under the ceiling.apps/server's pool ismax: 4(src/db.ts); size both sides to coexist, not to peak. This re-derivation is done - see "Re-derived shared-pooler coexistence budget (M40-8)" above: the sum is 14 against a ~15-slot dev-tier ceiling, so trim the Python pool tomax_size: 8(sum 12) for the coexistence window before flipping the first route. - Enable the server workload in staging. Set
server.enabled: truein the staging overlay, pinningimage.tagto the CI-built SHA; the chart mounts the samebloom-api-secretsSecret and reads the sameBLOOM_*config the Python API reads (M29 finding), so no new secret plumbing. The server comes up serving/healthz(liveness) and/readyz(readiness proves DB connectivity), still with no gateway route pointed at it. - Add the server ingress route and flip staging path-by-path. Introduce a server ingress (the
analogue of
api-ingress.yaml, which today points everyingress.api.pathsprefix at the-apiservice) that routes the ported paths to the-serverservice, most-specific-prefix first. Flip the lowest-risk surface first (the read endpoints, then the account-scoped and admin surfaces, then the webhook ingress and the orchestrator-coupled routes), leaving any still-deferred path on-api. - Prove staging parity end to end. Run the differential parity harness against the staging pair (both backends, one shared Postgres) to re-confirm byte-identity on the live corpus - the harness is the same one that gated every slice. Then exercise a real project lifecycle through the flipped backend (webhook -> enqueue -> orchestrator worker -> VCS/messenger side effects -> SSE) and diff the observable outcomes against a Python-served baseline. Watch the pooler connection count under coexistence load against the step-1 budget.
- Soak, then flip production the same way. After a clean staging soak, repeat steps 2-4 in
production:
server.enabled: true, server ingress routes added path-by-path, harness + lifecycle parity re-proven, pooler budget watched. Keep every step reversible - a route flip is a one-line ingress backend change back to-api, and the shared-Postgres coexistence means no data migration to unwind. - Decommission
apps/api. Once all paths serve fromapps/serverand a full production soak is clean: remove the api ingress routes, scale the api workload to zero, then delete the api Deployment/Service/ConfigMap from the chart and retire theapps/apibuild from CI. Retire the Python-only pooler budget (the shared pooler now serves one backend again, so re-derivemaxa final time for the single-backend steady state). Keep theapps/apisource in history and the parity harness's Python oracle available for one release as the rollback anchor, then remove them in a follow-up once the TS backend has proven itself in production.
Rollback posture
Every step above is reversible without data loss, which is the property the whole strangler-fig was
built to preserve: the two backends read and write the same shared-Postgres tables with no
dual-write and no translation layer (M29/M30/M34 findings), so a route serving from apps/server and
the same route served from apps/api are byte-identical and interchangeable at the ingress. A bad
flip rolls back by pointing the ingress backend for that path back at -api; a bad server rollout
rolls back by server.enabled: false. Decommission (step 6) is the only one-way door, and it is
gated behind a full production soak with the Python oracle kept as the anchor.
M41 addendum - orchestrator async wiring (2026-08-22)
M41 ports the last un-ported internal the M40 cutover-readiness review scoped out (see the "Update
(M41)" note above): the thick async Orchestrator class the JobRunner's dispatch seam points at. It
ports no new shape - M39 cleared the last gate and pinned the orchestrator's pure cores
byte-for-byte - so the risk it manages is the one the M39 verdict flagged as needing its own
measurement: the async side-effect ordering (DB read/write vs messenger send vs workflow-runtime
call vs event publish, and how they interleave under the per-thread lock and the idempotency fence).
Each slice pins that ordering against the real Python with a differential harness
(parity/orchestrator_async/): the real method runs over recording fakes to yield an ordered
side-effect trace, the TS twin runs over the same seed + input against the same recording fakes, and
the two traces are diffed entry-for-entry. All offline, on every pnpm run test.
Per-slice ordering-parity verdicts
Every Orchestrator entry point is ported and its side-effect ordering is proven byte-identical to the
Python oracle. No slice needed a new harness mode - each reuses the M33 domain discipline (goldens
generated from the real Python module, replayed offline through the twin) over the shared
orchestrator_async recording-fake vocabulary.
| Slice | Entry point | Ordering-parity verdict |
|---|---|---|
| M41-1 | handleUserMessage + the concrete Orchestrator scaffold + the harness | PASS - chat-level routing traced to the thread hand-off |
| M41-2 | handleThreadMessage + the shared thread-message core | PASS - the real thread turn, deep thread-turn subsystems seamed |
| M41-3 | handlePullRequests | PASS - guard/fence/resume/drain to the coordination seam |
| M41-4 | handleIssueEvent | PASS - action/label branch selection to the issue-event seams |
| M41-5 | handleProjectTick | PASS - the proactive gate + status-fetch branch + project_checked fence |
| M41-6 | performEngineImplement | PASS - VCS/child-event/first-pass/requeue/apply, error-swallow and busy short-circuit |
| M41-7 | performEngineRework | PASS - the same engine seam off a linked-PR parse |
| M41-8 | handleAssetRender | PASS - surface gate, per-study render with fail-soft swallow, deliver/emit |
| M41-9 | full-interface lifecycle | PASS - all 8 entry points driven on one assembled orchestrator over one shared run, trace byte-identical end to end |
The M41-9 finalize slice is the aggregate proof: one Orchestrator with every deep subsystem seamed
to a recording fake at once is driven through a representative multi-event lifecycle (user message ->
thread turn -> issue event -> project tick -> pull request -> engine implement -> engine rework ->
asset render), and the single accumulated trace is diffed against the Python oracle. It asserts that
no entry point throws OrchestratorMethodNotPorted - the un-wired Orchestrator seam item from
the M39/M40 reviews is closed - and that the state each call saves is read by the next exactly as in
production.
The one carried divergence: graceful drain on stop (from M40-7)
M40-7 documented, and M41 carries unchanged, a single deliberate behavioral divergence in the worker
lifecycle: JobRunner.stop() awaits in-flight dispatches to drain rather than cancelling them.
Python's JobRunner.stop() cancels the worker asyncio.Tasks, so a job mid-await is aborted at its
next suspension point; JS has no promise cancellation, so the TS stop() signals the loops and
awaits the running dispatch to completion (Promise.allSettled over the loop promises). The effect
is strictly safer, not lossy: delivery is at-least-once and every job is fenced on its stable
event_key (RunState.processed_keys), so a dispatch that finishes during shutdown simply commits its
work and a re-delivery is idempotent - the same property that makes at-least-once redelivery safe in
the first place. This is the only place the async layer departs from the Python semantics, and it is a
shutdown-timing detail with no side-effect-ordering consequence (it is invisible to the differential
harness, which drives entry points directly, not the worker loop). It does not gate cutover.
Start posture: assembled, gated, still dormant by default
The worker is now assembled against the complete orchestrator, but the backend still ships dormant. The
start path is a code seam (armJobRunner, the create_app lifespan job_runner.start() twin) behind
a default-off gate (jobWorkerEnabled / BLOOM_JOB_WORKER_ENABLED): with the gate off the worker is
constructed and injected but never armed (started stays false), draining nothing; with it on the
worker starts and drains against the now-complete orchestrator. Both ends are covered by tests
(parity/orchestrator_async/start_posture.test.ts); neither the env nor any Helm overlay is flipped by
this milestone, so the production posture is unchanged (server.enabled: false, worker unstarted). The
cutover flips BLOOM_JOB_WORKER_ENABLED alongside the Helm workload enable.
Cutover-readiness checklist: the seam item is CLOSED
The M40-8 readiness review left exactly one open item - "the thick async Orchestrator class the
worker's dispatch seam points at is not yet ported" - which scoped the flip to non-orchestrator paths.
With M41 that item is CLOSED: every entry point is ported and ordering-parity-proven, the assembled
orchestrator is lifecycle-diffed end to end, and the worker's start path is wired behind the dormancy
gate. The orchestrator-coupled paths (the inbound Telegram webhook and orchestrator-driven sends) that
the M40 plan held on Python may now flip too, following the same path-by-path cutover the checklist
lays out (still gated by the step-1 shared-pooler budget and the step-4 live-lifecycle parity proof).
No cutover-readiness item remains open on the TS backend's side.
Recommendation: the migration is code-complete; cutover is all that remains
Every measured shape is ported and every gate is cleared (M39 verdict), and now the last un-ported
internal is in with its async side-effect ordering proven. What remains is entirely the operational
M40 cutover - flip server.enabled/BLOOM_JOB_WORKER_ENABLED, add the server ingress routes
path-by-path, re-prove pooler budget and live-lifecycle parity in dev, soak, then repeat for prod -
reversible at every step, with the Python oracle kept as the rollback anchor until decommission.
M42 addendum - orchestrator leaf subsystems (2026-08-23)
M41 pinned the async Orchestrator's control flow end to end (all eight entry points,
ordering-parity-proven) but did so with the deep leaf subsystems each entry point branches into left
as not-ported seams - notPortedThreadTurnHandlers, notPortedEngineJobHandlers,
notPortedIssueEventHandlers, notPortedAssetRenderHandlers, notPortedProjectTickHandlers - so the
M41-9 lifecycle proof asserted no entry point throws OrchestratorMethodNotPorted while the seam
bodies themselves were still stubs driven by recording fakes. M42 ports those seam bodies, one layer
down. It introduces no new shape - every slice is a repeat of an already-measured read/write/LLM
shape over cores M39 already pinned - and each pins its real body's side-effect ordering against the
real Python with the M41 parity/orchestrator_async differential discipline (real method over
recording fakes -> ordered trace, TS twin over the same seed -> trace, diffed entry-for-entry,
offline on every pnpm run test). Everything ships dormant (server.enabled unchanged, the JobRunner
worker unstarted).
What M42 landed
Every not-ported leaf-handler seam is now a concrete, parity-proven factory. By slice:
- M42-1 (#717) - thread-turn core hardening.
Hardened the ported deep async thread-turn core (
Orchestrator.#handleThreadMessage, control flow from M41-2) against the Python oracle over an adversarial side-effect-ordering matrix (five fixtures the happy-path rows missed: the monitoring-phase chat redirect fired outside the thread lock, pending-confirmation-vs-lifecycle precedence, the own-org / status guards short-circuiting before workflow resume, and a done-run stale-fence case), all HELD, and fixed one byte-faithful emit divergence a differential audit surfaced (#emitConversationSincenow mirrors Python'sstr()content semantics - a null content streams as"None", not"", androlerides through raw). The gate slice: it proves the not-ported-seam -> real-body control flow before the thin M42 glue slices chain on it. - M42-1b (#722) - engine support layer. Ported
the ~360 LOC of engine-run policy/lifecycle the
EngineJobHandlersdepend on and M37 deferred:src/engine/failures.ts(the fullclassify/RetryPolicy/escalationReasonpolicy twin with its Python quirks),src/services/engineRunner.ts(theEngineRunnerrun/retry loop with bounded exponential backoff, the stochastic fresh-roll budget,on_eventemits, and exception redaction), andsrc/services/ticketChildren.ts(the asyncTicketChildrenprojection with per-child promise-chain locks and the processed-key-fenceddeliver). Discovered while delegating M42-2: the handlers were not thin glue until this logic was ported under parity first. - M42-2 (#723) - EngineJobHandlers. Replaced
notPortedEngineJobHandlerswithcreateEngineJobHandlers(six methods:vcsFor,ticketChildEvent,runEngineFirstPass,runEngineRework,requeueIfBusy,applyEngineReport) - thin orchestration glue over the M42-1b support layer, the VCS provider factory, and the durable job queue, including the byte-exact escalation comment + owner nudge. 16-fixture ordering corpus, HELD. - M42-3 (#718) - IssueEvent + AssetRender
handlers. Replaced
notPortedIssueEventHandlersandnotPortedAssetRenderHandlerswith concrete factories: the deliverable-review verdict transitions and the linked-PR rework enqueue on the issue side, the design-surface render + asset-comment delivery on the asset side, over the already-ported review / VCS / design-render subsystems. Ordering corpus with byte-exact comment / narration / asset-comment bodies, HELD. - M42-4 (#719) - ProjectTickHandlers. Replaced
notPortedProjectTickHandlerswithcreateProjectTickHandlers(five methods:fetchStatus,tickStalls,maybeSendDigest,followUpDeferredFacets,maybeNudgeCredentialsReady) - thin glue over the M39 stall/digest/readiness cores. 20-fixture ordering corpus + a state-fence unit suite, HELD. - M42-5 (#720) - ThreadTurnHandlers. Replaced
notPortedThreadTurnHandlerswithcreateThreadTurnHandlers(seven methods:resolveLifecycle,requestLifecycle,offerClientOwned,sendStatus,handleProjectChat,runCoordination,applyChatRedirect), driving the ported coordinator planner (reconcilePlan, the default reconcile engine), the messenger, the read-model status projections, and the chat index store. ItsrunCoordinationis the seam that carries the one M42 deferral (below): the async reconcile driver runs the default engine only. Ordering corpus, HELD.
With these merged, no notPorted* leaf-handler seam remains in the default-engine dispatch path: every
subsystem the async Orchestrator reaches is a real, byte- or ordering-parity-pinned TS body.
The one remaining deferral: the langgraph coordination engine
M42 leaves exactly one named, deliberate deferral, and it is not new - it is the same carve-out the M39 control-flow findings recorded, now the sole item outstanding after the leaf handlers landed:
apps/api/src/bloom/services/langgraph_coordinator.py- the non-defaultLangGraphCoordinator, selected only whenBLOOM_COORDINATION_ENGINE=langgraph. The shippedreconcilePlan(coordinator.ts) reproduces the defaultCoordinator.reconcileplanner byte-for-byte, including theawaiting-clientskip guard (coordinator.ts:226, parity-verified). The langgraph spike is deliberately not byte-equivalent to that default - its_classifyomits the skip guard, so under the langgraph engine anawaiting-clientticket would be silently un-parked - and it was never in scope for this migration. It carries no separate TS port and no separate goldens: the control-flow contract M39/M42 pinned is the default engine's plan, not the langgraph variant's. The default engine is what ships and what the JobRunner drives; the langgraph path stays Python-only and un-migrated, guarded behind a non-default env flag.
This deferral does not gate cutover: the production default is the ported engine, and the dev/prod flip below runs on it. Migrating (or retiring) the langgraph spike is future work, tracked as its own item rather than folded into the cutover.
Cutover-readiness update: the default orchestrator path is fully implemented
The M40-8 readiness review scoped the flip to non-orchestrator paths because the thick async
Orchestrator was un-ported; M41 closed that (the class and its entry points) and re-opened the
orchestrator-coupled paths for the flip; M42 now closes the layer below it. The cutover ledger's true
state after M42:
- No
OrchestratorMethodNotPortedand nonotPorted*seam is reachable on the default path. M41-9 proved no entry point throws; M42 proved every seam body those entry points branch into is a real port. So once the composition root assembles the live subsystems, the JobRunner drives a fully-implemented orchestrator - claim -> dispatch -> the real thread-turn / engine-job / issue-event / asset-render / project-tick handlers -> complete/reschedule/dead-letter - with no not-ported fallback anywhere on the default coordination engine. - The only remaining orchestrator-side work is composition-root wiring, which is a cutover step, not
a port. As each M42 slice states, the concrete handler factories exist and are parity-proven, but
app.tsstill injects the not-ported defaults because the root does not yet assemble the live provider factory / engine provider / reviewer / design surface / production messenger - and it does not need to, because the backend is dormant (server.enabled: false,BLOOM_JOB_WORKER_ENABLEDoff) so those defaults are never reached. Wiring the live subsystems intoapp.tsis the orchestrator's slice of the M40 cutover checklist's "enable the server workload" step, alongside the M40-6 messenger stack that already landed. No port remains to write first. - The one carried behavioral divergence is unchanged - the M40-7 graceful-drain-on-stop
(
JobRunner.stop()awaits in-flight dispatches rather than cancelling them; strictly safer under the at-least-once + idempotency-fence contract, invisible to the differential harness). M42 adds no new divergence; the langgraph engine (above) is a deferral, not a divergence in the shipped path.
Dev-flip prerequisites unchanged
The M40 cutover checklist and the M40-8 dev-env framing stand exactly as written - M42 changes what is ready to flip, not how the flip runs or its gates:
server.enabledstaysfalseandBLOOM_JOB_WORKER_ENABLEDstays off until Al's explicit go. No env, no Helm overlay, and no gateway route is flipped by this milestone; the production posture is unchanged and dormant. The cutover flips both flags together (M41 start-posture note), and only on the explicit decision to go live.- The step-1 shared-pooler budget is still the hard gate before the first route flips - the
M40-8 re-derivation stands (coexistence sum 14 against a ~15-slot dev-tier ceiling, so trim the
Python
apps/apipool tomax_size: 8for the coexistence window; confirm the dev project's actualPool Sizeat cutover). M42 adds no pool and no new connection-holding pattern: every handler takes the injecteddb.tsclient per the M34-1 rule, and the engine runner / ticket-children / tick cores are pure or ride existing seams. - The dev pair, the harness, and the live-lifecycle diff are still the proof - checklist step 4's end-to-end lifecycle (webhook -> enqueue -> orchestrator worker -> VCS/messenger side effects -> SSE) now exercises the real leaf handlers rather than stubs, run against the dev pair over one shared Postgres, before any production repeat. The env framing is still dev, not staging (no staging tier exists; prod is gated behind the separate paid-staging decision).
Recommendation: the migration is code-complete on the default path; cutover is all that remains
Every measured shape is ported, every gate is cleared, the thick async orchestrator and now its leaf
subsystems are in with ordering parity proven, and the sole outstanding deferral (the non-default
langgraph coordination engine) is out of scope and does not gate the flip. What remains is entirely the
operational M40 cutover - assemble the live subsystems at the composition root, flip
server.enabled/BLOOM_JOB_WORKER_ENABLED on Al's go, add the server ingress routes path-by-path,
re-prove the pooler budget and live-lifecycle parity in dev, soak, then repeat for prod - reversible at
every step, with the Python oracle kept as the rollback anchor until decommission.