Skip to main content

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_sql output), ~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 and src/pyjson.ts are 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/server reads the same BLOOM_SUPABASE_DB_URL and BLOOM_LOG_LEVEL the Python API reads; the helm chart mounts the same bloom-api-secrets Secret into the second workload.
  • Shared tables work unmodified. The TS endpoint reads bloom_audit_log and 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.enabled off 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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, reusing pyjson.ts and 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_actions protocol twin with the audit wrapper and the FOR UPDATE last-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. jsonb canonicalizes on write (key length, then bytewise), so the TS port inserts before/after snapshots with plain JSON.stringify and still lands rows byte-identical to Python's json.dumps inserts - no pyjson-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/error refusal paths) diff byte-identical; granted_by is caller-derived and diffs verbatim; the DB-generated values (audit bigserial id, at, grant granted_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 UPDATE row 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.1 where uvicorn reports the dotted quad (the TS client_meta twin strips the mapping or every audit row diffs on ip), 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

  1. 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.
  2. 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.
  3. 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.MockTransport capture, the pnpm run parity:llm entrypoint, 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 the max_completion_tokens mapping and the schema-instruction system prompt with its embedded model_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.MockTransport in Python, GatewayTransport in 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 -> the LLMError taxonomy, 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-strings model_json_schema() into the schema-instruction system message, so the wire bytes contain a CPython dict repr (single quotes, True/False/None, ', ' separators, $defs for nested models). The TS twin carries the schema's model_json_schema() shape in pydantic's key order and renders it with src/llm/pyrepr.ts, ground-truthed against CPython 3.12 - the LLM shape's analogue of M29's pyjson.ts. A subtle corollary the corpus caught: a docstring on a pydantic model folds into the schema as description, 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's ORDER BY tiebreak.
  • 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's model_dump(mode="json") field order byte-for-byte.
  • A canonical-bytes intersection rule for fixtures: cross-runtime goldens must stay where JSON.stringify and json.dumps(separators=(",", ":"), ensure_ascii=False) agree
    • integers only (1.0 renders as 1.0 in Python but 1 in 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.
  • 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

  1. 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.
  2. 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.
  3. 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 VirtualClockEventLoop oracle (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 the pnpm run parity:sse entrypoint.
  • 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 EventBus twin (bounded fan-out queues, history ring, the injectable store seam with its background writer), the frame serializer, the pyJsonDumps renderer 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/testing emit 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\n opener (delivered even to an already-disconnected client), one event: {type}\ndata: {payload}\n\n frame per published event, and one : keepalive\n\n comment frame per full idle interval. The payload is json.dumps with DEFAULTS - ", "/": " separators, ensure_ascii=True (non-BMP unicode ships as UTF-16 surrogate-pair escapes), CPython repr(float) - which matches neither the read slice's pydantic-core writer nor the kit's canonical interchange. The port reproduces it verbatim as pyJsonDumps in src/pyjson.ts, every divergence from JSON.stringify pinned 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 production is_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 date and transfer-encoding: chunked; uvicorn adds server and the telemetry x-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 ProjectEventStore seam 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

  1. 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.
  2. 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.
  3. 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-first LLMProvider interface), 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.ts datetime 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.stringify of the parse output matches model_dump(mode="json") bytes. The subtle part is inheritance: pydantic dumps parent fields first, and the corpus pins both shapes it produces (AuditEntry carries the store-assigned id/at LAST; the wire item AdminAuditLogItem re-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 (DecisionParseError subclasses ValueError, so bullet errors re-prefix the filename twice), CPython int() literal normalization (id: 007 re-renders as id: 7, and 1_0 underscores 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 M31 pyrepr twin.
  • datetime fields stay wire strings (src/domain/wire.ts): the twins pin pydantic's canonical model_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, null unmaps) 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_PERMISSIONS now live only in src/domain/admin.ts, the audit write choke point consumes the domain AuditActor/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, strictObject redaction 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

  1. The domain layer is done and the cadence holds. Every bloom.domain module 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.
  2. 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).
  3. 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 transcodeJsonb dump 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 UPDATE superadmin lock, the shared clock.ts extraction, 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.stringify inserts land byte-identical to Python's json.dumps) held for every store. The new cost is the other direction: rendering a persisted jsonb text back to pydantic model_dump_json bytes without JSON.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's id::text pattern. 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 LOCKED statement (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 (23505 maps to the pinned JobRetryError refusals); 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_plus twin. Verification uses jose (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

  1. 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.
  2. 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).
  3. 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 Settings twin (~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 difflib twin (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 scripted requests adapter 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_tokens retry 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:llm at 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 readVcsToken satisfying VCSTokenReader, 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 in X-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 VCSProvider port 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), and event_key strings 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 URLSearchParams agree 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 skips URLSearchParams and carries corpus-pinned pyQuote/pyUrlencode twins. python-gitlab's wire quirks are reproduced, not cleaned up: Content-type: application/json on every request, {} empty-POST bodies, recursive=False as a literal Python-bool string, the URL-encoded file_path inside file-update bodies, and Link: 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.jsonSchema document 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 difflib twin (SequenceMatcher autojunk, grouped 3-line context, range shorthands) for the fake's patch hunks, the urllib encoding twins, and the gitlabWireDatetime pydantic parse->dump twin - joining pyjson/pyrepr/pystr from 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_context binding waits for the services/messaging port (M37). Fail-soft behavior (design surface degrading to null, 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

  1. The integrations layer is done and the cadence holds. Every bloom.integrations module 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.
  2. 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 imports bloom.agents.*, moves to the agents milestone (M36); M35-6 ported every other LLM provider, so the TS llm/ package is otherwise complete.
  3. 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 deferred openclaw_harness; services + engine (M37) - the orchestration-adjacent glue over the ported stores and integrations, including prd_render and 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 (ReviewerAgent PR + deliverable review, and the architecture/security/QA specialist reviewers over the M36-2 context), with the PullRequestReview/SpecialistReviewOutput schema documents held byte-identical to pydantic and a new pyJsonDumpsIndent (json.dumps(indent=2)) primitive. M36-5 (#623) ported the three generative agents (ProductOwnerAgent discovery/PRD/milestone/ticket/plan/decision, DesignerAgent design studies, ProjectAssistantAgent triage) with their eight output-schema documents pinned and a model_dump_json renderer (modelJson.ts) reproducing pydantic-core's raw non-ASCII JSON bytes, distinct from the reviewer/designer ensure_ascii writer. M36-6 (#624) ported the M35-deferred openclaw_harness (src/llm/openclawHarness.ts), which drives the ported ProductOwner discovery and Reviewer PR-review calls through any LLMProvider over the M35 VCS types - closing the M35 import-graph deferral now that the agents it imports exist. Everything ships dormant (server.enabled unchanged; 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 Agent over the M31 seam, the stablePrefix/composeSystem cache-stable prompt assembly, the ClassVar-as-static persona/capability wiring, and the new parity/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 pyJsonDumpsIndent primitive, 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_json raw-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 raw model_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 Agent builds the system prompt as stablePrefix (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 for composeSkillBlock, 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's model_json_schema() in pydantic key order - $defs alphabetical, each object's properties in 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's model_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/triage hand their whole conversation to provider.structured directly (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 options invariant 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 indented ensure_ascii json.dumps) and modelJson.ts (the raw-non-ASCII model_dump_json), joining pyJsonDumps/pyrepr/pystr from earlier layers - each ground-truthed against the real Python output rather than hand-written.
  • The openclaw_harness deferral 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_error contract in an offline unit suite. It takes an optional injected agentOptions so tests supply a registry and stay offline where the proprietary baseline skills are not vendored in this repo.

Roadmap: the deferral closes, and the remaining surface toward all-TS

  1. The agents layer is done and the cadence holds. The base, all seven specialist agents, the skills-composition foundation, and the M35-deferred openclaw_harness now have byte-faithful, corpus-pinned TS twins over the injectable LLMProvider seam; further agent-shaped work is repeat slices of the M31-gated shape. With openclaw_harness landed, the TS llm/ package is complete - the last M35 import-graph deferral is closed. The freeze-core fallback stays dormant.
  2. 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_render and the messaging seam the M35 alerting sink waits on, and now the home for the export/import migration bundle (which imports services/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.
  3. 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 real bloom.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, the redact scrubber, 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-tripped EngineResult).
  • 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 prdRender renderer, with behavior parity mirroring tests/unit/test_messaging.py and PRD rendering byte-pinned by a golden corpus from the real bloom.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_readme renderer 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 dumpBundle byte contract (json.dumps(indent=2, sort_keys=True, ensure_ascii=False)), the export/import service over the VCS seam with #N cross-reference renumbering, the token-env-only CLI, and the parity/migration corpus (all-default, full cross-section, populated PRD, and a unicode ensure_ascii=False case).
  • 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, the fetch transport, 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 strips ANTHROPIC_API_KEY so a test run can never bill the API. The engine's pure surface - the EngineResult/EngineRun model-dump bytes, the buildBrief/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 - the sort_keys=True recursive-key-sort json.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

  1. The standalone services + engine layer is done and the cadence holds. The whole bloom.engine execution stack, the engine_runner process, 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.
  2. 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.
  3. 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), the workflow/ package, the coordination/plan_delta leaf, and swarm/. 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 real bloom + 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-gated surface='admin' session round-trip, and the parity/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 /overview concurrent-read aggregate, the /audit-log filter/pagination/get-by-id envelope, the auth-order-preserving /me, and the bearer-gated /metrics exposition route, with the db.ts max override 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, the provided_at ...Z wire form, the _deep_link twin) 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 /overview fan-out is the one structural note - Promise.all as the asyncio.TaskGroup twin, 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-login path 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-Cookie attribute order and value encoding, redirect targets, {"detail": ...} error envelopes, the 204 no-content framing - and it rides the same golden-corpus discipline: authCookies.ts is a Morsel byte twin pinned by parity/auth/, the admin host-only policy by parity/admin_auth/, the response/422 envelopes by parity/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 take Depends(get_orchestrator) and call through services.orchestrator.Orchestrator (distinct from the account-scoped global_credentials.py, which M38-4 ported).
    • github.py and gitlab.py - the VCS webhook ingress routes depend on get_orchestrator (and get_job_queue/get_webhook_providers) and dispatch onto the workflow path.
    • telegram.py - the Telegram webhook ingress depends on get_orchestrator and fences on the same tg:<update_id> event key the workflow path uses (distinct from the account-scoped telegram_link.py, which M38-4 ported).
    • projects.py - the project CRUD + thread routes depend on get_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 imports services.admin_projects (ProjectAdminReader, project_detail_from_run), which is one of the orchestrator-coupled services the M37 carve-out isolated into M39. app.ts leaves an explicit TODO(M39) at its mount point; only its audit_log resource slice is live today (the already-mounted adminAuditLogRouter).
  • 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 of projects.py that 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

  1. 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.
  2. 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.
  3. 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), the workflow/ package, the coordination/plan_delta leaf, swarm/, and the M38-deferred routes that import them (credentials, github, gitlab, telegram, the full projects CRUD, and the admin_resources CRUD 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 decommissioning apps/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 thin goldens.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/pyTruthy twins 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 + ProjectIndex transitions, 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_resources CRUD 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 0 falsiness, 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 and or 0 falsiness; the guard evaluator carries shared pyValuesEqual/pyTruthy helpers so all/any/not and 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 with pyRepr reason strings. Each is pinned by an adversarial golden corpus, not asserted by shape.
  • The shipped reconcilePlan mirrors 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, and reconcilePlan reproduces it byte-for-byte - including the awaiting-client skip guard (coordinator.ts:226, parity-verified). The non-default LangGraphCoordinator (BLOOM_COORDINATION_ENGINE=langgraph) is not byte-equivalent: its _classify omits that guard, so under the langgraph engine an awaiting-client ticket 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 use timingSafeEqual (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.py list/create/get + timeline (the live-VCS overview/detail aggregate, the workflow-runtime create turn, the event store's sub-millisecond at), the credentials-readiness nudge (needs the workflow spec + bus + messenger), admin_action_defs/projects.edit_state (byte-exact pydantic RunState + datetime ValidationError bytes), and the admin_resources projects resource (needs services.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.ts wires a LoggingMessenger in 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

  1. 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 in apps/server, shipping behind the DB gate.
  2. 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.
  3. The migration closes with M40 cutover - flip server.enabled, point the gateway routes at apps/server, prove staging parity, drain and decommission apps/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.py full 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-store at.
  • github.py setup 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's RunState + datetime ValidationError bytes (a write-shape byte pin).
  • admin_resources projects resource - port services.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.ts LoggingMessenger placeholder with the production messenger stack (WorkspaceRoutingMessenger over RecordingMessenger over the M35 Telegram transport), the create_app messenger-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 same BLOOM_TELEGRAM_BOT_TOKEN config the Python messenger reads - with a token the live Telegram transport sends and the ledger channel is telegram; without one the log fallback stands in and the channel is log, so an unconfigured deploy performs no live send while every outbound message is still ledgered and logged. No new secret plumbing (the shared bloom-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): the JobRunner class (claim -> dispatch -> complete / reschedule-with-backoff / dead-letter, lease heartbeat, give-up notification) plus its create_app-wiring builders (buildDispatch, buildDeadLetterNotifier, buildJobRunner). The worker dispatches to a typed Orchestrator seam driven by the pinned dispatchCall routing core, and its end-to-end drain is byte-diffed against the REAL Python JobRunner by 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 async Orchestrator class itself (the thick handle_* 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 behind server.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.py full CRUD + timeline (M40-2, #683), github.py setup GET complete_client_owned (M40-3, #684), admin_action_defs/projects.edit_state (M40-4, #685), the admin_resources projects resource derive_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.ts wires the ported M39 cores into the JobRunner worker pool (claim -> dispatch -> complete / reschedule-with-backoff / dead-letter, lease heartbeat, give-up notification) plus its create_app builders, drain-diffed byte-for-byte against the real Python JobRunner. 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 Orchestrator class - all eight handle_* / 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 max is its conservative slot ceiling. The write-side connection-holding transactions are what keep a slot borrowed longest: the job-claim FOR UPDATE SKIP LOCKED (both backends run 2 worker loops by default) and the last-superadmin FOR UPDATE lockout, 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.
BackendProduction poolmax (server slots)Longest connection holdersSource
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)10job-claim FOR UPDATE SKIP LOCKED x2 workers, account FOR UPDATE lockout, audit/event appendssupabase_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)4job-claim FOR UPDATE SKIP LOCKED x2 workers + account FOR UPDATE, sharing the 4 slots with HTTP handlersdb.ts max: 4; index.ts single call site
Coexistence sumboth hold full budgets simultaneously14worst case: all 14 client connections mid-transaction at oncemust fit under the pooler ceiling
Pooler ceilingSupavisor default_pool_size, per role+db~15 (dev tier default)reserve ~3 for Postgres superuser_reserved_connections + setup/migration/monitoringSupabase 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

  1. Re-derive the pooler budget (hard gate, do first). Per the M30/M32/M34 notes, re-derive each backend's max against 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 UPDATE locks, FOR UPDATE SKIP LOCKED job claim). During the coexistence window both backends hold their full budgets, so the sum must fit under the ceiling. apps/server's pool is max: 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 to max_size: 8 (sum 12) for the coexistence window before flipping the first route.
  2. Enable the server workload in staging. Set server.enabled: true in the staging overlay, pinning image.tag to the CI-built SHA; the chart mounts the same bloom-api-secrets Secret and reads the same BLOOM_* 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.
  3. Add the server ingress route and flip staging path-by-path. Introduce a server ingress (the analogue of api-ingress.yaml, which today points every ingress.api.paths prefix at the -api service) that routes the ported paths to the -server service, 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.
  4. 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.
  5. 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.
  6. Decommission apps/api. Once all paths serve from apps/server and 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 the apps/api build from CI. Retire the Python-only pooler budget (the shared pooler now serves one backend again, so re-derive max a final time for the single-backend steady state). Keep the apps/api source 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.

SliceEntry pointOrdering-parity verdict
M41-1handleUserMessage + the concrete Orchestrator scaffold + the harnessPASS - chat-level routing traced to the thread hand-off
M41-2handleThreadMessage + the shared thread-message corePASS - the real thread turn, deep thread-turn subsystems seamed
M41-3handlePullRequestsPASS - guard/fence/resume/drain to the coordination seam
M41-4handleIssueEventPASS - action/label branch selection to the issue-event seams
M41-5handleProjectTickPASS - the proactive gate + status-fetch branch + project_checked fence
M41-6performEngineImplementPASS - VCS/child-event/first-pass/requeue/apply, error-swallow and busy short-circuit
M41-7performEngineReworkPASS - the same engine seam off a linked-PR parse
M41-8handleAssetRenderPASS - surface gate, per-study render with fail-soft swallow, deliver/emit
M41-9full-interface lifecyclePASS - 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 (#emitConversationSince now mirrors Python's str() content semantics - a null content streams as "None", not "", and role rides 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 EngineJobHandlers depend on and M37 deferred: src/engine/failures.ts (the full classify / RetryPolicy / escalationReason policy twin with its Python quirks), src/services/engineRunner.ts (the EngineRunner run/retry loop with bounded exponential backoff, the stochastic fresh-roll budget, on_event emits, and exception redaction), and src/services/ticketChildren.ts (the async TicketChildren projection with per-child promise-chain locks and the processed-key-fenced deliver). Discovered while delegating M42-2: the handlers were not thin glue until this logic was ported under parity first.
  • M42-2 (#723) - EngineJobHandlers. Replaced notPortedEngineJobHandlers with createEngineJobHandlers (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 notPortedIssueEventHandlers and notPortedAssetRenderHandlers with 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 notPortedProjectTickHandlers with createProjectTickHandlers (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 notPortedThreadTurnHandlers with createThreadTurnHandlers (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. Its runCoordination is 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-default LangGraphCoordinator, selected only when BLOOM_COORDINATION_ENGINE=langgraph. The shipped reconcilePlan (coordinator.ts) reproduces the default Coordinator.reconcile planner byte-for-byte, including the awaiting-client skip guard (coordinator.ts:226, parity-verified). The langgraph spike is deliberately not byte-equivalent to that default - its _classify omits the skip guard, so under the langgraph engine an awaiting-client ticket 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 OrchestratorMethodNotPorted and no notPorted* 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.ts still 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_ENABLED off) so those defaults are never reached. Wiring the live subsystems into app.ts is 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.enabled stays false and BLOOM_JOB_WORKER_ENABLED stays 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/api pool to max_size: 8 for the coexistence window; confirm the dev project's actual Pool Size at cutover). M42 adds no pool and no new connection-holding pattern: every handler takes the injected db.ts client 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.