Status: Implementation largely complete in codebase; cutover (§7) pending.
Strategy: One release + atomic maintenance cutover. All existing Temporal executions and history are discarded. No dual-read, no legacy inline OCR values in workflow history.
Out of scope:azureOcr.submitAndWaitand removingpollUntil+azureOcr.pollfrom templates (separate ticket). Azure graphs still emit one history event per poll iteration (small payloads after refs).
Verification (local): G.2 integration harness — 2026-05-27. See TEMPORAL_FOOTPRINT_IMPLEMENTATION_STATUS.md.
Companion: benchmarking-temporal-history-bloat-fix.md, DAG_WORKFLOW_ENGINE.md §7.4
| # | Decision | Choice |
|---|---|---|
| D1 | Raw OCR JSON storage | Blob (OperationCategory.OCR). Structured UI fields → ocr_results via ocr.storeResults / upsertOcrResult. |
| D2 | Namespace retention (post-wipe) | 24 hours on default. |
| D3 | Temporal wipe method | Drop and recreate temporal and temporal_visibility databases; re-run schema init (§7). |
| D4 | Documents after cutover | workflow_execution_id set to NULL on all documents (one-off SQL). |
| D5 | Workflow graph migration | All workflow_versions.config in place (§5.3); optional §5.4 template head refresh for slug-matched lineages. |
| D6 | graphWorkflow start args |
workflowVersionId + configHash only — no graph in GraphWorkflowInput. Graph loaded inside workflow via getWorkflowGraphConfig. |
| D7 | Payload compression | Required at cutover — gzip (or zstd) PayloadCodec on worker + all Temporal clients (TemporalClientService, BenchmarkTemporalService). |
| D8 | Map fan-out | collection.length > 20 → child graphWorkflow per item; join collects ref arrays only. |
| D9 | Base64 extraction activities | Global change to document.extractToBase64 / extract-pages-base64; update every graph that uses them. |
- Ref-based OCR pipeline (Azure poll/extract, Mistral, downstream OCR activities).
- Benchmark:
loadOcrCachein wrapper; prediction/cache from blob refs (not childctx); slim parent/child starts. GraphWorkflowInput/GraphWorkflowResultin bothapps/temporalandapps/backend-servicesgraph types.- All tenant
workflow_versions+benchmark_definitionshash refresh (§5). - Map/child/blob orchestration (§4 C, D).
- Temporal wipe, 24h retention, required payload codec.
- Clear all
document.workflow_execution_id. - Workflow builder UI + docs aligned with
*Refctx keys (same release). No document viewer / OCR API changes — those already useocr_results/ blobs.
- Preserving or replaying old Temporal history.
- Large inline OCR JSON in activity results or workflow
ctx(port names likeocrResultstay). azureOcr.submitAndWait/ template poll-chain consolidation.- Reducing Azure poll event count (only payload size per event).
App DB (documents, ocr_results, benchmark_runs, benchmark_ocr_cache), blob storage, Loki.
Documents and benchmarks pin specific WorkflowVersion.id values. §5.3 updates every version row in place so pinned IDs keep working. §5.4 optionally appends a new head for slug-matched lineages (definitions pinned to older version IDs are unaffected).
Do not run the DB migrator on a live env while old workers are running.
Single maintenance window order:
- Block OCR and benchmark starts (API or scale worker/backend to 0) — same as §7 step 1.
- Deploy worker + backend + frontend (code that expects
*Refkeys and new workflow I/O). - Run migrator
--apply+ §5.7 hash refresh + §5.5 gate. - Temporal wipe (§7 steps 6–9).
- Resume traffic.
interface OcrPayloadRef {
documentId: string;
blobPath: string; // empty string allowed while status === "running"
storage: "blob";
byteLength?: number;
pageCount?: number;
/** running | succeeded | failed — required for pollUntil conditions */
status?: string;
}storage: "db"is not used on the Temporal path; DB rows are written only inocr.storeResults/upsertOcrResult.- Helpers in
apps/temporal/src/ocr-payload-ref.ts(name TBD):resolveGroupId(documentId)— loaddocuments.group_id(or usestate.groupIdfrom workflow input).writeOcrPayloadBlob(groupId, documentId, fileName, json)→blobPathreadOcrPayloadBlob(ref)→ parsed JSON
Blob layout (normative):
{groupId}/ocr/{documentId}/azure-response.json
{groupId}/ocr/{documentId}/ocr-result.json
{groupId}/ocr/{documentId}/cleaned-result.json
{groupId}/ocr/{documentId}/pages/page-{n}.pdf
Use buildBlobFilePath(groupId, OperationCategory.OCR, [documentId, ...], fileName).
Update in apps/temporal/src/graph-workflow-types.ts and apps/backend-services/src/workflow/graph-workflow-types.ts (keep in sync).
interface GraphWorkflowInput {
workflowVersionId: string;
configHash: string;
initialCtx: Record<string, unknown>;
runnerVersion: string;
parentWorkflowId?: string;
requestId?: string;
groupId?: string | null;
}- Remove
graph: GraphWorkflowConfigfrom workflow start args. workflowVersionIdresolution (same asgetWorkflowGraphConfig):WorkflowVersion.id→WorkflowLineage.id(head) →WorkflowLineage.name(head). Document OCR passes the pinned version cuid; librarychildWorkflownodes often pass a lineage name (e.g.standard-ocr-workflow) — both are valid.- First steps inside
graphWorkflow:getWorkflowGraphConfig({ workflowId: workflowVersionId })→computeConfigHash(loaded graph)→ fail ifconfigHashmismatch →runGraphExecution. - Adds one small activity completion to history per run (acceptable).
- Backend
startGraphWorkflow: computeconfigHashfrom DB config, passdocuments.workflow_config_idasworkflowVersionId. - Remove prod
graphOverride(ocr.service.ts); keep only for local/integration harness if needed. - Hash algorithm:
apps/temporal/src/config-hash.tsandapps/backend-services/src/workflow/config-hash.tsmust stay identical (shared normalization rules).
interface GraphWorkflowResult {
status: "completed" | "failed" | "cancelled";
completedNodes: string[];
documentId?: string;
refs?: {
ocrResponseRef?: OcrPayloadRef;
ocrResultRef?: OcrPayloadRef;
cleanedResultRef?: OcrPayloadRef;
};
/** Small metadata for benchmark wrapper / status — not OCR bodies */
failedNodeId?: string;
outputPaths?: string[];
error?: string;
}- Remove full
ctxfrom the return type. graphWorkflow/runGraphExecutionpopulatesrefsfrom final internalctxref keys before return; copyfailedNodeId/outputPathsfrom runner state (today read fromctxinbenchmark-sample-workflow.ts).- Backend and APIs must not use
handle.result()for OCR bodies; useocr_results/ blob bydocumentId. getStatusquery: omit or redact blob paths; show statuses/counts only.
Old ctx key |
New ctx key |
Notes |
|---|---|---|
ocrResponse |
ocrResponseRef |
pollUntil uses ctx.ocrResponseRef.status |
ocrResult |
ocrResultRef |
|
cleanedResult |
cleanedResultRef |
|
| base64-holding keys | pageBlobPath |
per §5.2 / §3.8 |
Activity port names in the registry stay the same (ocrResponse, ocrResult, response, cleanedResult). Activities accept OcrPayloadRef values on those ports. Only ctxKey / ctx declarations in graph JSON change.
| Activity | When | Return (activity result → history) |
|---|---|---|
azureOcr.submit |
once | Small metadata (apimRequestId, …) |
azureOcr.poll |
status === "running" |
{ ocrResponseRef: { documentId, blobPath: "", status: "running" } } — no blob write |
azureOcr.poll |
status === "succeeded" |
Write azure-response.json; { ocrResponseRef: { documentId, blobPath, status: "succeeded", byteLength } } |
azureOcr.poll |
status === "failed" |
{ ocrResponseRef: { documentId, blobPath: "", status: "failed" } } + small error fields |
azureOcr.extract |
once | Read ocrResponseRef blob; write ocr-result.json; { ocrResultRef } |
pollUntiloutput:{ "port": "response", "ctxKey": "ocrResponseRef" }(port nameresponseunchanged).- Condition (migrated):
"left": { "ref": "ctx.ocrResponseRef.status" },"right": { "literal": "running" }, operatornot-equals. - Benchmark cache replay:
benchmark.loadOcrCachein wrapper only; poll writes blob from cached JSON once (no inlineOCRResponsein history).
mistralOcr.process: writeocr-result.json; return{ ocrResultRef }only.
ocr.cleanup, ocr.enrich, ocr.normalizeFields, ocr.characterConfusion, ocr.checkConfidence, ocr.spellcheck: read/write blob artifacts per §3.1; pass OcrPayloadRef on ports; ocr.storeResults / upsertOcrResult load from ref and persist to ocr_results.
| Activity | Return |
|---|---|
document.extractToBase64 |
{ pageBlobPath, pageIndex?, byteLength? } |
extract-pages-base64 |
same |
Update all graphs that consume base64 (including feature-docs/010-data-transformation-node/example-pdf-extraction-workflow.json, classifier/split templates). Downstream activities read bytes from blob.
Two patterns — do not conflate parent hash with library child graphs.
ExecutionState: addworkflowVersionId+configHashcopied from parentGraphWorkflowInput.mapwithcollection.length > 20: each branch starts childgraphWorkflowwith{ workflowVersionId, configHash, initialCtx: branch slice, groupId, … }(same graph, same hash); join stores ref arrays only.mapwithcollection.length ≤ 20: branches still run in-process (executeBranchSubgraph). Payloads are small after refs, but poll event count × N stays in one parent workflow history — can still approach event-count limits for large in-process maps (see §6, §10).
Used by templates such as multi-page-report-workflow.json (workflowRef.type: "library", workflowId = lineage name or id).
- Do not pass parent
state.configHashor pass fullgraphinexecuteChildargs (todaynode-executors.tsloads viagetWorkflowGraphConfigthen passesgraph+ parent hash — remove both). - Recommended: extend
getWorkflowGraphConfig(or addresolveChildWorkflowStart) to return{ workflowVersionId: resolvedVersionCuid, configHash }only — one small activity result in parent history perchildWorkflownode, thenexecuteChildwith slim args. Must not reuse parent documentconfigHash. - Start args:
{ workflowVersionId, configHash, initialCtx, groupId, … }where both fields refer to the child graph. InnergraphWorkflowreloads viagetWorkflowGraphConfigand re-validates hash (§3.2). - Inline
workflowRef: not used in shipped templates; if present, same rule — no inlinegraphin Temporal start args (resolve to a version id or fail). - Output mappings: port names unchanged (
ocrResult, etc.); resolve values fromchildResult.refs(e.g.ocrResultport →childResult.refs?.ocrResultRef), notchildResult.ctx. Downstream parentctxkeys holdOcrPayloadRef, not inline JSON.
- Shared codec module; wire into
Worker.create,TemporalClientService,BenchmarkTemporalService(and any otherConnection/Clientfactory). - No prod disable flag; deploy all consumers in one rollout.
- See §9.
benchmarkSampleWorkflow today uses graphResult.ctx for predictions and OCR cache — must change:
- Inner
graphWorkflowreturnsGraphWorkflowResultwithrefsonly (§3.3). - Wrapper activity (new or existing)
benchmark.flattenPredictionFromRefs: readcleanedResultRef(fallbackocrResultRef) blob → build flat map (reusebuildFlatPredictionMapFromCtxon parsedOCRResultJSON). benchmark.writePredictionunchanged (flat map in, file out).benchmark.persistOcrCache: read raw response fromrefs.ocrResponseRefblob (or load cache in wrapper and pass ref after poll path writes blob).buildFlatConfidenceMapFromCtx— same pattern from blob-loadedOCRResult.failedNodeId/outputPaths: read fromGraphWorkflowResult(§3.3), notgraphResult.ctx.
- A.1
benchmark.loadOcrCacheonly inbenchmarkSampleWorkflow(not parent). Remove parent-loop cache load inbenchmark-workflow.tsfan-out (~benchmark.loadOcrCache+ buildingsampleMetadata.__benchmarkOcrCachewith inlineocrResponse). - A.2 Parent passes
ocrCacheBaselineRunId+sampleIdonly — no__benchmarkOcrCacheinsampleMetadata. - A.3 Slim Temporal args (recorded in parent history on each
executeChildtobenchmarkSampleWorkflow):BenchmarkRunWorkflowInput: dropworkflowConfig/workflowConfigHash; keepworkflowVersionId+ ids + evaluator settings.BenchmarkExecuteInput(benchmark-execute.ts): dropworkflowConfig; passworkflowVersionId+configHashonly.BenchmarkSampleWorkflowInput+ innergraphWorkflowchild:workflowVersionId+configHashonly.BenchmarkTemporalService.startBenchmarkRunWorkflow+benchmark-run.service.ts: stop loading/passing inlineworkflowConfig.
- A.4 Wrapper §3.11: prediction, confidence,
persistOcrCachefrom refs/blobs (notgraphResult.ctx); useGraphWorkflowResult.failedNodeId/outputPaths(§3.3). - A.5 Register
benchmark.flattenPredictionFromRefsin activity registry. - A.6 Tests:
benchmark-workflow.test.ts,benchmark-sample-workflow.test.ts,benchmark-execute.test.ts.
- B.1
OcrPayloadRefhelpers +resolveGroupId+ tests. - B.2
azureOcr.poll/azureOcr.extractper §3.5. - B.3
mistralOcr.processper §3.6. - B.4 Downstream OCR per §3.7.
- B.5
ocr.storeResults/upsertOcrResultload from ref.
- C.1 Base64 activities per §3.8; update all dependent graph JSON + example workflow doc.
- C.2 Library
childWorkflowper §3.9 (getWorkflowGraphConfigreturns resolved cuid + childconfigHashonly; versionId-onlyexecuteChild;refsoutput mappings) innode-executors.ts. - C.3
mapthreshold 20 +ExecutionState.workflowVersionIdper §3.9; update stale “> 50 items” comment inexecuteMapNode.
- D.1
GraphWorkflowInput/graphWorkflowload-at-start + hash check (§3.2) — temporal + backend types. - D.2
GraphWorkflowResult+ populaterefsat end;getStatusredaction (§3.3). - D.3
TemporalClientService.startGraphWorkflow— versionId-only args; prod path dropsgraphOverride. - D.4 Document/OCR/benchmark/ground-truth paths: no OCR body from
handle.result(). - D.5 Payload codec on worker + all Temporal clients (§3.10).
- D.6 Delete
{groupId}/ocr/{documentId}/blob prefix on document delete — verified 2026-05-27 (DocumentService.deleteDocument). - D.7 Workflow version publish/save API: recompute and persist
configHashwheneverworkflow_versions.configis written (prevents mismatch on nextgraphWorkflowstart).
- E.1
migrateGraphConfigToOcrRefs(§5.3) + tests (standard, Mistral, multi-page, classifier, custom sample). - E.2 CLI
workflow:migrate-ocr-refs—npm run workflow:migrate-ocr-refs/:apply(verified dry-run 2026-05-28). - E.3 Migrate
benchmark_definitions.workflow_config_overrides(§5.2 walk). - E.4 Edit §5.1 template JSON in repo.
- E.5 Optional §5.4 template head refresh (slug map).
- E.6 §5.7 recompute
benchmark_definitions.workflowConfigHash. - E.7 §5.5 gate + per-row
validateGraphConfig. - E.8 Docs:
DAG_WORKFLOW_ENGINE.md,WORKFLOW_BUILDER_GUIDE.md,WORKFLOW_NODE_CATALOG.md— updated 2026-05-27.
- F.1 §7 cutover on staging then prod (atomic with §2).
- F.2 Namespace retention 24h (
tctl/operator; document if namespace pre-exists). - F.3
upsertSearchAttributeson terminal graph status (completed/failed). - F.4 Alerts:
temporal-pgdisk %, history limit errors, queue depth.
- G.1 Unit tests — verified 2026-05-28 (
apps/temporal77 suites / 952 tests; backend migrator + config-hash). - G.2 Docker-compose + update
apps/backend-services/integration-tests/graph-workflow-tests/harness for versionId-only starts — verified 2026-05-27. - G.3 Staging: 100-sample benchmark + OCR cache replay.
- G.4 New
graph-{documentId}: activity payloads ≪ pre-change; note poll count may still be high.
Update under docs-md/graph-workflows/templates/ per §3.4 (source for new lineages and §5.4):
| File | Notes |
|---|---|
standard-ocr-workflow.json |
Full §3.4 chain |
standard-ocr-workflow-normalize.json |
Full |
standard-ocr-workflow-with-corrections.json |
Full |
standard-ocr-workflow-with-payment-lookup.json |
Full |
mistral-standard-ocr-workflow.json |
Mistral path |
multi-page-report-workflow.json |
Full + map |
azure-classifier-extraction-workflow.json |
Poll + page blobs |
orientation-detection-workflow.json |
No OCR refs; remove stale ocrResponse ctx if present |
Apply to every row in workflow_versions (all tenants, all version_number, including non-head pins).
| Location | Transform |
|---|---|
ctx keys |
ocrResponse → ocrResponseRef, ocrResult → ocrResultRef, cleanedResult → cleanedResultRef |
nodes[*].inputs[*].ctxKey / outputs[*].ctxKey |
Same when value is an exact old key |
Conditions (pollUntil, switch, humanGate) |
Update ref paths per §3.5 |
data.transform fieldMapping |
{{ocrResult. → {{ocrResultRef. (and ocrResponse, cleanedResult) |
| Base64 ctx keys | → pageBlobPath when migrator detects extractToBase64 output binding; else flag manual |
| Activity ports | Unchanged |
Do not change: topology, activityType, node ids, entryNodeId, edges.
- Code:
apps/backend-services/src/workflow/migrate-graph-config-ocr-refs.ts+migrate-graph-config-ocr-refs.spec.ts. - CLI:
apps/backend-services/scripts/migrate-workflow-config-ocr-refs.ts--dry-run(default): counts, changed lineage slugs/version ids, validation failures.--apply: update eachworkflow_versions.config.
- Idempotent: must not produce
ocrResultRefRef. - Validate each output with
validateGraphConfig/validateGraphConfigForExecution.
Purpose: Align head of slug-matched lineages to canonical repo JSON (may differ from in-place migrated tenant edits).
- Maintain explicit slug → §5.1 file map in the CLI.
- For each match: append new
WorkflowVersion(version_number + 1), setworkflow_lineages.head_version_idto new id. - Does not replace older pinned version rows (they remain §5.3-migrated).
- After append: run §5.7 for definitions that reference the new head version id.
Skip lineages with no OCR-related nodes. Custom lineages without a template slug rely on §5.3 only.
Before §7, automated check must report zero configs containing legacy identifiers:
- Walk JSON: any
ctxkey,ctxKey, or conditionrefexactly matchingocrResponse,ocrResult, orcleanedResult(notocrResponseRef, etc.). - Do not rely on naive
config::text LIKE '%ocrResult%'(false positives onocrResultRef).
Emit list of failing workflow_versions.id for manual fix.
| Table / column | Action |
|---|---|
workflow_versions.config |
Required §5.3 --apply |
benchmark_definitions.workflow_config_overrides |
Required §5.2 walk |
benchmark_definitions.workflowConfigHash |
Required §5.7 recompute |
benchmark_runs.params (embedded workflowConfig) |
Optional (historical) |
documents.workflow_config_id |
Unchanged (same id, migrated config) |
After §5.3 (and §5.4 if run), for each benchmark_definitions row:
- Resolve effective config:
workflowVersionId(+ mergeworkflow_config_overridesif present, same as run service). workflowConfigHash = computeConfigHash(config)(sharedconfig-hash.tslogic).UPDATE benchmark_definitions SET "workflowConfigHash" = $hash WHERE id = $id.
Without this, inner graphWorkflow hash checks fail immediately after migration.
Include in migrator CLI as --refresh-benchmark-hashes or automatic post-apply step.
| Metric | Target |
|---|---|
temporal DB size after 7 days |
Stable (no pre-wipe growth rate) |
New graphWorkflow history bytes |
≪ ~600 KB/doc inline OCR baseline |
| Azure poll event count | Unchanged (out of scope); per-event size small |
| Map ≤ 20 branches in-process | Poll events × N in one workflow; refs keep bytes small but event count can still grow |
| Benchmark parent @ 100 samples | < ~10 MB |
No history size exceeds limit |
Normal OCR + benchmark load |
| Product paths | OCR, viewer, benchmark drill-down OK |
| §5.5 gate | Zero legacy ctx keys |
Prerequisites: Staging G passed; atomic sequence in §2 completed on target env.
- Announce maintenance — block OCR and benchmark starts (API or scale to 0).
- Deploy temporal-worker, backend-services, frontend (same release).
- Run
workflow:migrate-ocr-refs --apply+ benchmark hash refresh (§5.7). - Run §5.5 gate — abort if failures.
- Scale down worker (optional); cancel open workflows (optional).
- Drop and recreate DBs
temporalandtemporal_visibility; schema init (temporal-server-deployment.ymlinitContainer pattern). - Set namespace retention 24h.
- SQL:
UPDATE documents SET workflow_execution_id = NULL WHERE workflow_execution_id IS NOT NULL; - Scale up Temporal server, worker, backend.
- Smoke: one document OCR (standard slug), one benchmark sample (with/without OCR cache replay).
- Resume traffic.
pgBackRest: Temporal data may remain in backups until retention (30d default).
- B.1 + D.5 (refs + codec)
- B.2–B.5, D.1–D.2 (activities + workflow contracts, both apps)
- A (including A.4 wrapper flatten)
- C
- D.3–D.7
- E (migrator → hash refresh → gate; §5.4 optional)
- G staging → §7 prod
Estimate: ~2–3 weeks one engineer; ~1–1.5 weeks with two (split temporal vs backend/migration).
Compression applies to Temporal SDK serialization only (not blob/DB).
| Dimension | Effect |
|---|---|
| CPU | Small per event after refs; monitor worker CPU |
| Replay | Less DB I/O; decompress per replayed event |
| Disk | Smaller history rows |
| User latency | Negligible vs OCR/API time |
Codec required (D7); all clients in one rollout.
| Risk | Mitigation |
|---|---|
| Migrator misses edge-case expression | §5.5 structured gate; dry-run review |
Stale benchmark_definitions.workflowConfigHash |
§5.7 mandatory |
| Wrapper prediction empty after slim result | §3.11 + A.4 tests |
| §5.4 overwrites tenant head customizations | Append-only new version; document in release notes |
configHash mismatch on publish |
D.7 recompute on workflow save API |
Library childWorkflow uses parent hash |
§3.9 child-only hash; C.2 |
Map child missing workflowVersionId |
C.3 + ExecutionState |
| Map ≤ 20 in-process event-count blow-up | Prefer refs; use child map path when N > 20; document limit |
Temporal vs backend computeConfigHash drift |
Keep both config-hash.ts files identical; test parity |
| Codec only on one client | D.5 checklist all factories |
| Deploy before migrator | §2 atomic order |
| Blob orphans | D.6 lifecycle hook |
| Template vs pinned version drift | §5.3 in-place pins OK; head moves only on §5.4 |
| Doc | Action |
|---|---|
| This spec | Track §4 |
benchmarking-temporal-history-bloat-fix.md |
Link A.1 (parent loop removal), §3.11 |
Graph workflow docs + WORKFLOW_NODE_CATALOG.md |
§3.4 *Ref keys |
| OpenShift deployment docs | §7 |
| Release notes | Temporal wiped; migrator + hash refresh; optional template head bump; custom graphs included via §5.3 |
- No dual-read; no inline OCR JSON in Temporal payloads.
- Backend changes: tests per
CLAUDE.md. - Sync temporal and backend-services
graph-workflow-types.tsand config-hash logic. - One release branch; staging cutover before prod.