Module extraction behind existing contracts — design
Phase 6 item: “Module extraction behind existing contracts (reporting and rendering first; the ledger never leaves)”. Architecture spec §19 (Phase 6) says any Tier 3 module can leave the monolith “when load justifies it”. This document decides what that means in the code as it stands on 2026-10-03, and whether load justifies it yet.
Design only: no code, no migrations. Everything marked [unverified] was not run or measured; the only evidence used is the tree and its documents.
1. Recommendation in one paragraph
Section titled “1. Recommendation in one paragraph”Defer the extraction; ship the measurements now. The evidence in the tree does not show
a load problem: there is not a single metric, benchmark or incident that says PDF or export
work hurts the API or the worker (§4.5). Two facts shrink the case further: report exports
already run outside the API process (cmd/worker, River queue reports, concurrency 2),
and render is already a pure function from rows to bytes. So the first extraction, when
a trigger fires, is rendering only — a stateless service with no database and no object
store credentials — and reporting’s query layer is not extracted at all (§4.2: a
network hop per query is the wrong seam; the right seam is “rows in, bytes out”). The
design below is complete enough to build the day a trigger (§9) holds, but the only work
proposed now is Task 1 (metrics) and Task 2 (a worker queue split, the cheaper rung that
may make extraction unnecessary).
2. What extraction means here
Section titled “2. What extraction means here”A module is extracted when a separately deployed process implements an existing in-process contract and callers cannot tell which implementation answered.
- The in-process implementation stays, is the default (unset config = in-process), and is the fallback when the remote one fails (§7). Nothing is deleted from the monolith. Extraction is therefore reversible by unsetting one variable, which is what makes it safe to try.
- The contract is the seam, not a new API. The remote service gets no new capability. It implements the same function shape the monolith already calls.
- Extraction is a deployment choice, not a code fork. One Docker image, one more
entrypoint (
/app/renderer), exactly asapi,worker,migrate,operatorship now (architecture spec §15;deploy/vps/compose.yaml). - It is not “microservices”. There is no shared-nothing data ownership claim, no event bus between processes, no second database. Postgres stays singular; the ledger stays in it.
3. Why reporting and rendering first, and why the ledger never leaves
Section titled “3. Why reporting and rendering first, and why the ledger never leaves”3.1 The case for rendering and exports
Section titled “3.1 The case for rendering and exports”renderis CPU and memory heavy by construction:render.Writer.Finish()returns the whole file as[]byte(CSV, XLSX hand-written with archive/zip and encoding/xml, PDF via gopdf with an embedded font and QR encoder). Export caps are large: general ledger 250,000 lines for CSV and XLSX, 20,000 for PDF (export.MaxLines). Peak memory at those caps is [unverified]; there is no benchmark in the repo (grep func Benchmarkfinds none).- It is read-only on the ledger:
TestReportingNeverWritespins the query layer, andreport_export(the one table export writes) is outside it. - Output is deterministic data in, bytes out; retry is harmless.
3.2 The case against the ledger (permanent, matches the invariants)
Section titled “3.2 The case against the ledger (permanent, matches the invariants)”- Append-only integrity. Corrections are reversals. A second writer path, or a network
between the poster and the table, is a new way to violate it. Today every
ledger.Postruns in the same transaction as the domain write that caused it (spec §10.3: distributed, “this is a saga with …”; the reason for the modular monolith in the first place). - Single-transaction posting. Expenses, payables, receivables, payroll, inventory and
assets post through
accounting/registered rules inside the transition’s transaction. Splitting it turns an atomic commit into compensation logic on a statutory record. - RLS and the tenant setting are per transaction.
app.current_orgis set withset_config(..., true)at the start of each transaction (db.applyTenant); pgbouncer transaction pooling is safe only because of that. A ledger service would hold its own pool and its own copy of that discipline, with nothing to make the two agree. - Statutory. The tests that fail the build (
TestEveryTableIsTenantIsolated, retention, lock-class drift) all live in one repository against one schema.
This is not revisited by any trigger in §9. Those triggers can only start the extraction of stateless, read-only work.
4. The contract seam: what the code actually looks like
Section titled “4. The contract seam: what the code actually looks like”4.1 Rendering: already data-in, bytes-out
Section titled “4.1 Rendering: already data-in, bytes-out”internal/platform/reporting/render imports kernel/money and third-party libraries only
(gopdf, barcode/qr, golang.org/x/image, stdlib zip and xml for XLSX).
It imports no platform/ package and no database. Its API is render.New(Format) returning
a Writer with Begin(Meta, []Column), Row([]Cell), Finish() ([]byte, error), where a
money cell holds a money.Amount (never a float; nofloat_test.go).
Callers of render.New, from the code:
| Caller | Context | Where rows come from |
|---|---|---|
reporting/export.Run (the River worker) |
async, 15-minute job timeout | project() reads inside one InReadOnlyTx, calls w.Row as it goes |
platform/printing (RenderInvoice, RenderStatement, RenderPaymentAdvice, RenderClaimVoucher, RenderTradeDocument, RenderSample) |
synchronous, inside an API request, from modules/receivables, payables, expenses, trade, identity |
an already-built view struct; render is called after the data was read |
So the seam for rendering already exists and needs inversion in exactly one place: the
caller must obtain its Writer from something replaceable instead of the package-level
render.New. printing calls render.New directly with no dependency parameter, so the
replacement is a registered factory, the pattern einvoice.UseClient already sets
(internal/platform/einvoice/registry.go). A remote Writer buffers Begin and Row
and sends them on Finish.
Finding worth stating: printing runs PDF generation on the API’s own CPU, in-request.
That, not the exports, is the one place rendering can currently contend with request
latency. It is also the place where a network hop adds the most visible latency, so it is
the second slice, not the first (§10).
4.2 Reporting: the query layer needs a transaction, so it stays
Section titled “4.2 Reporting: the query layer needs a transaction, so it stays”platform/reporting imports kernel/money, kernel/tenancy, platform/accounting/{chart, analytic,fiscal,ledger} and platform/company. Every exported function takes a pgx.Tx
its caller opened with db.InReadOnlyTx and never opens its own. export reaches wider:
payables, receivables, banking, taxreturn, budget, company, documents
(object store), identity/member, plus audit and jobs, and it writes report_export
and an audit row (finish).
Three ways a remote reporting service could get data, and the verdict on each:
| Option | What it is | Verdict |
|---|---|---|
| A. Query contract | The extracted service calls back to the monolith for each query | Rejected. reporting is the query layer; a callback per query makes the service a chatty proxy, moves nothing off the monolith’s database, and adds latency to every SUM. The contract would be the reporting API under another name. |
| B. Read replica | The service connects to a Postgres read replica as an RLS-bound role and opens its own read-only, tenant-set transactions | Viable later, separate decision, not required for rendering. It keeps the invariants (own transaction per request, set_config tenant, no BYPASSRLS, a dedicated erp_report role like erp_operator) but adds replication lag: a report requested right after a posting can miss it, and “one read-only snapshot for the whole file” (Ruling R4) holds only within the replica. The VPS has no replica today [unverified beyond compose.yaml: one postgres service]. Not recommended until DB read load, not render CPU, is the measured problem. |
| C. Signed snapshot request | The monolith reads the rows in its own tenant transaction, then ships them to a stateless service | Recommended. This is §4.1: reporting’s projection (project*) stays in cmd/worker and cmd/api, in a tenant read-only transaction, unchanged; only the formatting leaves. The service never learns a connection string. |
Consequences, so nobody is surprised:
- “Reporting extracted” means the formatting step of report exports, not the report
queries. The JSON report endpoints (
/v1/reports/...) are not extracted and are not candidates: they are a handful of SUMs under RLS. - The invariant “every request runs in a transaction, including reads” is untouched, because every request that reads data is a monolith request. The renderer has no database to put a transaction around. (The renderer’s own HTTP requests are not “business requests”; this must be written into AGENTS.md when the service lands, see §8.4.)
- Memory is not magically solved: Option C moves the render memory out of the worker but the monolith still builds rows. A streaming wire format (§5.2) is what stops the worker holding the whole file as well as the rows.
4.3 Where an extracted renderer must sit
Section titled “4.3 Where an extracted renderer must sit”- It processes statutory records (invoices, statements, tax returns). Spec §11A.1 and RUNBOOK §1 apply: the deployment must be in a Malaysian datacenter like Postgres and the object store, hold no copy, write nothing to disk, and log metadata only (never cell contents). The current test VPS is outside Malaysia by the compose file’s own comment, so this is a production provisioning check, not a code path.
- It holds no ledger credentials, no object store credentials, no signing keys. It could not leak what it never has.
5. Transport
Section titled “5. Transport”5.1 HTTP/JSON, generated from a contract file; not gRPC
Section titled “5.1 HTTP/JSON, generated from a contract file; not gRPC”- The repository’s rule is contract-first with
oapi-codegen(AGENTS.md;Makefilegeneration; the generated-code drift test). gRPC would addprotocand a second generated-code pipeline and a second drift test. grpc and protobuf are only indirect dependencies today (go.mod). - One endpoint,
POST /render, is declared in a second OpenAPI file for internal services (proposedapi/internal/renderer.yaml), generated intointernal/gen/rendererapiby the same tool, and covered by the same “generated code matches its source” drift test. Handlers implement the generated interface, so a drift fails to compile. - Money crosses as a string (a money cell is
{"amount": "1234.5000"}, never a JSON number). The renderer parses withmoney.Amount;nofloat_test.go’s approach extends to the wire types (§8).
5.2 Shape of the request and response
Section titled “5.2 Shape of the request and response”- Request: format,
Meta(title, ordered fields, footer, landscape, QR text, notes; logo bytes as base64), the[]Column, then the rows. For the first slice a bounded JSON body (a per-format row cap taken fromexport.MaxLines, and a maximum body size) keeps it simple. A streaming NDJSON body is the upgrade if the measured memory problem is the worker holding rows and bytes at once; OpenAPI describes NDJSON poorly, so it is not chosen up front. - Response: the file bytes with
Content-Typeasrender.Newreturns. The monolith, not the renderer, computes the SHA-256, writes to the object store and recordsreport_export. The renderer’s output must be byte-identical to the in-process output for the same input, becausereport_export.sha256records it and DEPLOY_NOTES say earlier exports “keep their bytes” across renderer fixes. Whether gopdf embeds a creation timestamp or other nondeterminism is [unverified]; the shadow-mode check in §10 exists to find out before cutover. - Errors map to the existing sentinels (
render.ErrAmountTooWide,render.ErrRowWidth) through stable problem codes, soexport.permanent()keeps working througherrors.Isand a deterministic failure still fails once instead of burning River’s three attempts.
5.3 Internal authentication
Section titled “5.3 Internal authentication”Nothing in the repository signs service-to-service requests today (hmac. is not used
outside tests; the operator console reaches the API through OPERATOR_API_URL over the
private network, not a signed channel [checked by grep only]). Proposal:
- Private network only (the compose
erpnetwork; no published port), plus - a bearer token with a keyring (
RENDERER_TOKENandRENDERER_TOKEN_PREVIOUS), the same current-plus-previous rotationPAYOUT_DETAILS_KEYalready uses, compared in constant time. - mTLS is the stronger option and is listed as an owner decision (§12), not the default, because the VPS has no certificate machinery for it yet.
5.4 Tenant propagation
Section titled “5.4 Tenant propagation”The renderer needs no tenant scope, because the payload carries the data and there is no
database. It receives org_id as a request header only for log lines and metrics
(never as a metric label; labels are fixed code, as jobs.RegisterMetrics says). This is
deliberately the opposite of what TestNothingReadsAnOrgFromAHeader forbids in handlers:
no handler authorizes or selects data from it. The test must keep passing, and the
renderer’s handler is added to its scan so the property is checked, not assumed (§8).
5.5 Timeouts, idempotency, backpressure
Section titled “5.5 Timeouts, idempotency, backpressure”- Timeouts. Export path: a client timeout well inside the job’s 15-minute
Timeout(proposal: 5 minutes). Sync printing path: 10 seconds for one document. Both are owner decisions (§12) pending the Task 1 measurements. - Idempotency. The renderer is a pure function and retries are safe; no idempotency
store is needed. A request carries
X-Request-Idfor tracing only. Retry stays River’s (exports) or the caller’s (sync), never the renderer’s. - Backpressure. The renderer limits concurrent renders per instance (a semaphore sized
to its memory limit) and answers 429 with
Retry-Afterbeyond it. This is how scaling and memory limits stay honest instead of becoming OOM kills.
6. Deployment
Section titled “6. Deployment”- Binary:
cmd/renderer/main.go, same Docker image, command/app/renderer. It readsRENDERER_ADDR,RENDERER_TOKEN,RENDERER_TOKEN_PREVIOUS,RENDERER_MAX_CONCURRENT,RENDERER_METRICS_ADDR; each must appear in.env.example(TestEveryEnvVarIsInExample). - Callers read
RENDERER_URL(+ token). Unset means in-process, so every existing deployment, test andgo test ./...run is unchanged. - Compose: a
rendererservice on theerpnetwork only,mem_limitset, no published port, agrafana_defaultaliaserp-renderer-metricsasapiandworkerdo. - Scaling: stateless, so replicas behind compose DNS round-robin; the semaphore plus a 429-aware caller gives backpressure. Concurrency per instance is the knob, not a distributed queue.
- Grafana:
deploy/grafana/erp-service-health.jsongains a Renderer row and the scrape joberp-renderer;internal/app/metrics_test.goalready fails if the dashboard queries a metric the code does not emit, which is the drift guard. - Migrations: none. The renderer has no schema. (Task 1’s metrics need none either.)
7. Failure modes and fallback to in-process
Section titled “7. Failure modes and fallback to in-process”| Failure | Behaviour |
|---|---|
RENDERER_URL unset |
In-process. The default. |
| Connect error, timeout, 5xx | Fall back to in-process, increment erp_render_fallback_total{reason}, log once per job. The user gets their file; the metric is the alarm. |
| 429 (renderer saturated) | Async export: return the error to River, which retries with its backoff; fall back only on the last attempt. Sync printing: fall back immediately (a user is waiting). Falling back on every 429 would send the exact load the extraction was meant to remove straight back to the monolith. |
| 4xx with a render sentinel code | No fallback. Deterministic (ErrAmountTooWide, ErrRowWidth); same answer in-process. |
| Output differs from in-process | Cannot happen by design; the golden-equality test (§8) and the shadow phase (§10) are what make that sentence true. |
| Renderer returns a corrupt body | The caller validates the format magic (%PDF-, PK, CSV header) and the byte length before treating it as success; a failed check is a fallback. |
Because the fallback runs the same code, a renderer outage is a performance incident, never a correctness or availability one. That is the single property that makes this safe to attempt, and it is the first thing to protect in review.
8. Drift tests: what changes, what is added
Section titled “8. Drift tests: what changes, what is added”Each new guard follows AGENTS.md: assert presence before absence, and watch it fail once.
8.1 Changed
Section titled “8.1 Changed”TestEveryBinaryLinksTheSameModules(internal/app/binaries_test.go): addrendererbesideoperatorin the “links no module” assertion (require.Empty(linkedModules(...,"renderer"))). Presence:linkedPackagesalready requires the listing containscmd/rendereritself; add that it listsrenderandkernel/money, so an empty module list means “linked nothing”, not “listed nothing”. The binary does not seed an Owner, so it stays out of themigrate/orgadmin/workerloop.TestEveryBinaryThatCanMoveADocumentHasTheEInvoiceGuards: no change needed, but itsbinaries >= 4presence check should be re-read: the renderer links neither AR nor AP and is correctly outside the candidate set. State this in the test comment so nobody “fixes” it by importingeinvoiceinto the renderer.TestDependenciesPointDownwardOnlyandTestTheLayerListCoversTheSourceTree(internal/kernel/layering): if the server code lives ininternal/renderer(recommended, asinternal/operatordoes), add it tolayersas a top-level app. The coverage test will fail until it is listed, which is the intended behaviour; watch it fail.
8.2 Added
Section titled “8.2 Added”TestRendererLinksNoDatabaseNoModule: the renderer’sgo list -depscontains none ofpgx,internal/kernel/db,internal/platform/*other thanreporting/render, norinternal/modules/*. Presence: the same listing must containreporting/render,gopdfandkernel/money, and the module-prefix scan must have inspected more than zero packages. This is the machine-checked form of “stateless”.TestRenderImportsOnlyTheKernelAndTheLibraries:render(non-test files) imports onlykernel/moneyfrom this repository. This is what lets it move without dragging a platform package with it; today it is true by inspection, not by test. Presence: the scan finds themoneyimport inrender.gobefore asserting nothing else matches.TestRemoteRendererMatchesInProcessBytes: for the golden fixtures already inreportingtest/golden.goand oneprintinginvoice, the in-process writer and ahttptestrenderer built from the same handler produce byte-identical output for CSV, XLSX and PDF. Presence: the fixture output is non-empty and the remote path was actually called (counter on the test server). Without this,report_export.sha256stops meaning one thing.TestWireMoneyIsAString: the generated renderer types carry nofloat32orfloat64(thegrep floatcheck Plan 4 applies toapi.gen.go, applied to the new generated package), and a JSON round-trip of a money cell preserves a 4-decimal value exactly. Presence: the generated package contains at least one money-typed field.TestRendererRoutesAreAuthenticated:TestEveryRouteIsAuthorizedlives ininternal/appand walks the API’s composed routes [unverified that it would not already see another router; assumed it does not]; the renderer needs its own: every route on its router passes the token middleware, and a request without or with a wrong token gets 401 after a request with the right token has been shown to get 200 (the absence-after-presence shape).- Fallback tests (unit,
httptest): connect refused, timeout, 500 and 429-then-last-attempt fall back and bump the counter; a 4xx sentinel does not; the body-validation failure does. - Env example: no new test;
TestEveryEnvVarIsInExamplealready covers the new variables once they are read.
8.3 What stays unchanged and proves nothing was disturbed
Section titled “8.3 What stays unchanged and proves nothing was disturbed”TestReportingNeverWrites, the RLS and rls_exempt tests, the lock-class test and the
retention tests are all about code that does not move. The extraction’s acceptance includes
running them unchanged.
8.4 AGENTS.md amendments, when the service lands
Section titled “8.4 AGENTS.md amendments, when the service lands”One line in “Project invariants”: the renderer is a leaf with no database and holds no tenant data at rest; the “every request runs in a transaction” invariant applies to requests that touch Postgres, which are all monolith requests. Not edited now.
9. When to extract: measurable triggers
Section titled “9. When to extract: measurable triggers”Extraction is not done speculatively. The repository has no baseline today: no export
duration, size, memory or render-latency metric exists. http_request_duration_seconds
(httpx/metrics.go), pool gauges (db/metrics.go) and erp_river_jobs counts by state
(jobs/metrics.go) do exist; the Go and process collectors come with the default
Prometheus registry that cmd/api and cmd/worker register on
[unverified that the process RSS series is scraped in the current deployment].
9.1 Measure first (Task 1; no behaviour change)
Section titled “9.1 Measure first (Task 1; no behaviour change)”| Metric (new unless noted) | Why |
|---|---|
erp_render_duration_seconds{format,kind} histogram, at the render.Writer boundary |
the CPU cost of Finish, for exports and printing alike |
erp_render_output_bytes{format} histogram |
memory proxy |
erp_export_queue_wait_seconds histogram (job scheduled_at to attempt start) |
whether the reports queue’s 2 slots are enough |
erp_export_rows{report,format} histogram |
how close real exports get to MaxLines |
process_resident_memory_bytes for erp-api and erp-worker (existing, if scraped) |
peak RSS during export bursts |
http_request_duration_seconds for the routes that call printing (existing) |
in-request PDF cost vs the rest |
container OOM-kill and restart counts for worker (host-level) |
blast radius |
Labels are fixed vocabularies only, never org_id.
9.2 Thresholds (proposed; no baseline exists, so these are starting points to revise
Section titled “9.2 Thresholds (proposed; no baseline exists, so these are starting points to revise”after two weeks of Task 1 data, and the owner should confirm them)
Extract the renderer when any one holds for two consecutive weeks and the cheaper rungs in §9.3 have been tried:
- T1 latency: p95 of
printingroutes above 1.5 s, or API p95 for the non-render routes rising by more than 25 % during windows with an active render. (Render CPU is contending with requests.) - T2 memory:
workerpeak RSS above 60 % of its container limit during exports, or any OOM kill of the worker attributable to an export. (A killed worker also stops the e-invoice sweep, FX sync and the operator queue; that is the blast radius.) - T3 throughput:
erp_export_queue_wait_secondsp95 above 5 minutes, or thereportsqueue holding queued jobs for more than 15 minutes on more than three days a week. - T4 size: more than 1 % of GL exports at or above 80 % of
MaxLinesfor their format (the cap, not the hardware, is becoming the limit).
9.3 The cheaper ladder, in order, before any new binary
Section titled “9.3 The cheaper ladder, in order, before any new binary”- Measure (Task 1).
- Split the worker by queue. Queues are hardcoded in
cmd/worker/main.go(reports: 2, maintenance: 1, einvoice: 1, operator: 1), so a second worker instance that serves onlyreportsneeds a small configuration change, no new binary and no new contract. This moves export CPU and memory out of the process that runs the e-invoice sweep, which is T2’s real harm. If this clears T2 and T3, extraction is not needed for exports. (Caveat: the periodic jobs must run in exactly one instance; today they register in the worker unconditionally, which two full workers would double-schedule. River’s uniqueness handling of periodic jobs here is [unverified].) - Memory limits and lower
reportsconcurrency in compose. - Streaming render if the problem is the in-memory
[]byte. - Extraction, only for what 1 to 4 did not fix. Note rung 2 does nothing for T1
(in-request
printing), so T1 is the trigger on which extraction has no cheaper substitute short of moving PDF work to a job.
10. Recommended first slice, if a trigger fires
Section titled “10. Recommended first slice, if a trigger fires”- Which: the PDF writer on the export worker path only. Async, retried by River, free of user-facing latency, and the largest memory per output (the largest object the system builds in memory per request). CSV is cheap and XLSX comes second.
- How:
rendergains a registered factory (aseinvoice.UseClient); a remoteWriterbuffers and posts onFinish;RENDERER_URLunset keeps everything as is. - Shadow first: for two weeks the worker renders both ways, uploads the in-process bytes, and compares SHA-256 against the remote bytes, recording matches and mismatches in a metric. A mismatch is investigated before any cutover (this answers the determinism question in §5.2).
- Cut over per format by configuration, with the fallback of §7 on.
- Second slice:
printing(sync API path), only if T1 fired; shorter timeout, immediate fallback. - Never part of this item: reporting queries (§4.2 Option B is a separate, later decision), the ledger (§3.2), anything that opens a transaction.
11. Honest limits of this design
Section titled “11. Honest limits of this design”- No measurement supports extraction today. If Task 1 shows nothing for a quarter, the right outcome of this roadmap item is to tick it as “decided: not needed yet” with the numbers, not to build the service.
- A second deployable is a standing operational cost: another image entrypoint, another alert, another thing to deploy in lock-step (DEPLOY_NOTES already carries the “deploy the worker with the api” lesson several times).
- Byte-for-byte determinism of the PDF writer, peak memory at
MaxLines, River’s behaviour with two worker instances, and whetherTestEveryRouteIsAuthorizedwould see a second router are all [unverified]. - The Malaysian-datacenter requirement applies to the renderer in production; the test VPS does not satisfy it and nothing here changes that.
12. Owner decisions
Section titled “12. Owner decisions”| # | Decision | Options | Recommended |
|---|---|---|---|
| 1 | Extract now or defer | extract now / defer with triggers | Defer; do Tasks 1 and 2 now |
| 2 | What the first extraction is | renderer only / renderer + report queries | Renderer only, PDF on the export path |
| 3 | Report queries | callback / read replica / snapshot (rows pushed) | Snapshot; replica is a separate later call |
| 4 | Transport | HTTP/JSON from OpenAPI / gRPC | HTTP/JSON, api/internal/renderer.yaml |
| 5 | Service auth | bearer token + keyring on private network / mTLS | Token + keyring now; mTLS before production |
| 6 | Trigger thresholds (§9.2) | as proposed / revise after 2 weeks of data | Revise after two weeks of Task 1 data |
| 7 | Fallback policy | in-process on failure / fail closed | Fallback, with metric and alert |
| 8 | Streaming body | bounded JSON first / NDJSON now | Bounded JSON first |
| 9 | Worker queue split before any extraction | do it / skip | Do it (Task 2) |
| 10 | Production placement | Malaysia, next to Postgres / elsewhere | Malaysia (spec §11A.1) |
| 11 | Shadow mode before cutover | yes / no | Yes, two weeks |
13. Task-plan outline
Section titled “13. Task-plan outline”Migration range reserved: 980 to 989. None expected: every task below is metrics, configuration or stateless code. If Task 2 needs a column to mark a job’s queue, or Task 1 a histogram table, it would be one migration in that range; neither is planned.
- Task 1: measurements (build now). The five histograms in §9.1 at the
render.Writerboundary and inexport.Run; dashboard row;metrics_test.gokeeps the dashboard honest. Verify by an export in a dev stack and reading the series. No behaviour change. - Task 2: worker queue selection (build now). An env var to choose which queues an
instance serves, default today’s set; compose gets a second worker for
reports; settle how the periodic jobs run once. Verify: two instances, one export, one periodic tick. - Task 3: the seam in-process (only when a trigger fires). Registered
renderfactory, the generated renderer contract file,TestRenderImportsOnlyTheKernelAndTheLibraries,TestWireMoneyIsAString, golden equality over anhttptestserver. Default unchanged. - Task 4:
cmd/renderer+internal/renderer. Handler, token middleware, semaphore, metrics,TestRendererLinksNoDatabaseNoModule,TestRendererRoutesAreAuthenticated, binaries-test and layering-test updates (watch each fail once). - Task 5: client, fallback and shadow mode. Remote
Writer, fallback matrix of §7, shadow comparison metric. - Task 6: deployment. Compose service, env example, Grafana row, DEPLOY_NOTES entry, RUNBOOK placement check (Malaysia).
- Task 7: cutover. PDF on export worker; then decide on
printing.
Tasks 3 to 7 are conditional on §9.2. Each is checked against ROADMAP’s invariants before it starts.
14. Not in scope
Section titled “14. Not in scope”- Extracting the ledger,
accounting/or any module that posts; any second writer ofjournal_line. - A report-query service, a read replica, or a second database (§4.2 B is a later decision).
- Event bus across processes, a message broker, or service discovery beyond compose DNS.
- gRPC, a service mesh, or a new generated-code pipeline.
- Moving
report_exportbookkeeping, audit rows, or object-store writes out of the monolith. - Extracting the JSON report endpoints.
- Changing PDF layouts, CSV or XLSX content, or export caps.
- Any migration beyond the reserved 980 to 989 range, none of which is expected.
- Kubernetes or autoscaling.
15. Owner decisions recorded (2026-10-03)
Section titled “15. Owner decisions recorded (2026-10-03)”D1-D11: the recommended option in each row of Section 12. Tasks 1 and 2 are approved to build now; Tasks 3 to 7 wait for a trigger in §9.2.