Task: Make the workflow engine act across tenants
Table of Contents
This page documents a task in the Exercise the workflow engine end to end story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
The engine's entry points read and write through generated repositories, which compare tenant_id with ctx.tenant_id(). The engine's context is the service's own tenant, so a workflow started on behalf of any other tenant is invisible to it from the second write onwards: the step insert is refused, the command stamp and the step progress are silent no-ops, and the completion event finds no step. Every engine path works for whichever tenant the service runs in.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Exercise the workflow engine end to end |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-09-27 |
3. Acceptance
- A workflow started for a non-system tenant runs to completion, with each step advanced by its own completion event.
- A start for a non-system tenant reports success only after the instance, its first step and the step's published marker are all written.
- Recovery finds in-progress instances in every tenant, not only the service's own.
- An integration test starts a workflow for a tenant other than the service's and asserts its steps advance.
4. Plan
Reproduce first, at the tenant boundary the service actually runs with. The
existing engine tests build the engine in the run's own tenant, so they cannot
see this defect at all; the new cases build it the way ores.workflow.service
does — engine in the system tenant, run in the test tenant — and assert the run
reaches completed.
Then decide between the two candidate fixes and take the one the component already intends:
- Carry the tenant on every message and rebuild a tenant-scoped context per
operation. Rejected:
step_completed_eventhas no tenant, so it would need a wire change and a migration of every publisher, and the recovery pass would still need a way to look across tenants. - Let the tables' row-level security policy decide the reads, which is what the
policy, the repository facet's
:tenant_read_scope:knob, and the engine's own header already say should happen. Chosen.
The engine is a cross-tenant service by design: workflow_rls_policies_create.sql
states that a system-tenant session must reach every tenant's rows, the engine
header documents its context as having no tenant filter, and the repository's
tenant_id = ctx.tenant_id() predicate was the one part that disagreed. Marking
both entities' reads shared removes only that predicate; mutations stay
tenant-scoped, and the policy still confines a tenant session to its own rows.
The change is at the model source, so the generated repositories are regenerated rather than hand-edited, and the drift gate covers it.
5. Notes
The query paths had to move with the engine. get_steps and get_step_result
both read through the service's own context, and both were broken for every
non-system caller in the same way: a tenant's workflow start could never
confirm that its run had been accepted, and check_step_idempotency always
missed, so a re-dispatched step re-executed instead of replaying its cached
result.
get_steps came back with the read scope alone, because it already guarded its
answer against the caller's tenant. get_step_result did not, and the read scope
turned it into a cross-tenant leak: it took only a step id, it had no caller
check at all, and the predicate that had confined it to the system tenant was the
very thing this task removed. It now takes the tenant the caller is asking about
— the one the engine put in the command's X-Tenant-Id header — and answers only
inside that tenant. The local review found this, not a test; that is what the
review pass is for.
Two things found on the way are not fixed here, because they are not about tenants. Both are filed in the inbox: the engine leaves completed_at and the published marker unwritten and workflow tests leave in-progress runs behind.
6. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
7. PRs
| PR | Title |
|---|---|
| #2269 | [workflow] Let the engine read runs of any tenant |
8. Review
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | get_step_result now returns any tenant's step: it read through the service context with no caller guard, and the predicate that confined it was the one this change removes. |
workflow_query_handler.cpp, steps_query_protocol.hpp |
Accepted, fixed | The request now names the tenant (echoed from the command's X-Tenant-Id), the read is scoped to it, and the answer is checked against it. A request naming no tenant is answered found = false. Regression case added. |
| 2 | The protocol's "the response carries no tenant-sensitive data" no longer held. | steps_query_protocol.hpp |
Accepted, fixed | The comment now states the confinement and what happens without a tenant. |
| 3 | The stale index prose still said the recovery pass reads "every instance for a tenant". | ores.workflow.workflow_instance.org |
Accepted, fixed | The prose now states that the engine's own read spans tenants and cannot use a leading tenant_id. |
| 4 | New captures filed under an [agile] commit bracket; the recipe reserves [inbox] for filing an untriaged capture. |
commit a17e59dcf |
Accepted, fixed | Re-committed as [inbox], and every commit body wrapped at 72. |
| 5 | Comments in the new test cases narrated the old behaviour ("used to come back empty", "went missing"). | workflow_engine_tests.cpp |
Accepted, fixed | Rewritten to state what holds now. |
| 6 | Fixture took a bare bool and the tenant guard was duplicated. |
workflow_engine_tests.cpp |
Accepted, fixed | Replaced by an engine_tenant enum and a service_tenant_id() accessor. |
| 7 | tenant_read_scope: shared widens these two repositories for every caller, not only the engine: the generic list/count the generated service exposes now reaches every tenant when the session is the system tenant. No test covered that surface. |
workflow_instance_service.cpp, workflow_step_service.cpp |
Accepted, confirmed intended, test added | Intended, and not the shape of the badge regression it was compared to: the badge policy admitted system-tenant rows to every tenant, so a tenant that held its own copy saw both. The workflow policy's fallback is conditioned on the session being the system tenant, so a tenant still sees only its own rows and a run cannot appear twice. The generated handlers pass the caller's JWT tenant, so only a system-tenant session gets the wide view. workflow repositories list one tenant's runs for a tenant and all for the service now pins both halves, list and count. |
| 8 | get_step_result resolved the tenant with a name lookup and a connection-pool verification on every step dispatch. |
workflow_query_handler.cpp |
Accepted, fixed | The id is now parsed and the context built directly, the way make_request_context does it. The value came from the engine's own header, so there is no code to look up and nothing to verify; a request naming a tenant that does not exist can only be asking for rows the policy will not show it. Removes one query and one pool acquisition per step dispatch. |
9. Result
State: DONE.
9.1. Acceptance
| # | Acceptance | Evidence |
|---|---|---|
| 1 | A workflow started for a non-system tenant runs to completion, each step advanced by its own completion event. | workflow_engine drives a run that belongs to another tenant: two steps, two completion events, instance ends completed. Also observed live: the deployed service logged Workflow COMPLETED: type=identity_workflow workflow=11111111-2222-3333-4444-555555555555 steps=2 for a run in CD_tenant-0. |
| 2 | A start for a non-system tenant reports success only after the instance, its first step and the step's published marker are written. | The same case asserts the step row exists in the run's tenant with command_published_at set. |
| 3 | Recovery finds in-progress instances in every tenant, not only the service's own. | workflow_engine recovery finds a run in another tenant calls recover_in_progress on a system-tenant engine and sees the other tenant's step re-dispatched with its original step id. Live: the deployed service logged Found 102 recoverable workflow instance(s), none of them in the system tenant. |
| 4 | An integration test starts a workflow for a tenant other than the service's and asserts its steps advance. | The two cases above; both fail before the change and pass after. |
9.2. The defect
Every read the generated repositories produce carried tenant_id =
ctx.tenant_id(). The engine holds the service's own tenant — the system tenant,
by context_factory — so from the second write onward a run belonging to any
other tenant was invisible to it: the published marker and the step progress were
silent no-ops, and the completion event found no step.
9.3. The query path
Removing the predicate also removed what had confined the step-result query to
the system tenant. get_step_result had no caller check, so it had to learn
which tenant it was answering about before the predicate went: the request now
carries that tenant, the read is scoped to it, and the row is checked against it.
workflow_query_handler answers for the tenant a request names covers the three
answers: the step's own tenant finds it, another tenant does not, and a request
naming no tenant does not either.
9.4. The fix
:tenant_read_scope: shared on ores.workflow.workflow_instance and
ores.workflow.workflow_step, then check_component_drift.py --component
workflow-cpp regenerated both repositories. The reads lost the predicate and now
rely on the policy; deletes and the version claims are unchanged. Two dead
stamp_audit declarations, never defined and never called, went with them.
Not a shortcut past isolation. The policy admits a row to its own tenant and, for
a system-tenant session, to every tenant's rows. Verified directly against the
live database as the service's own role, which has rolbypassrls = false and so
is really subject to the policy:
set role ores_brave_hopper_workflow_service; set app.current_tenant_id = '00000000-0000-0000-0000-0000000000aa'; select count(*) from ores_workflow_workflow_instances_tbl where valid_to = ores_utility_infinity_timestamp_fn(); -- 0 set app.current_tenant_id = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; select count(*) from ores_workflow_workflow_instances_tbl where valid_to = ores_utility_infinity_timestamp_fn(); -- 125, every tenant
9.5. Tests
ores.workflow.core.tests: 17 cases, 85 assertions, all pass. The four cases this task added failed before the change — the instance read came back empty, the recovery case never saw its second command, the step-result query answered about another tenant's step, and the list and count did not follow the reading tenant.ores.workflow.api.tests,ores.workflow.service.testsandores.shell.workflow.tests: all pass.- Full suite,
ctest --preset linux-clang-debug-make, on the tip after the review round: 84 of 86 pass. The two failures areores.refdata.core.testsandores.marketdata.core.tests, both on the pre-existing version-guard inserts documented in the inbox, and neither is a workflow target. check_component_drift.py --component workflow-cpp --dry-run: no drift.
The header change reaches every service that checks step idempotency, so the
suite was re-run after it: ores.iam.core.tests passes, and
ores.refdata.core.tests fails only on its three pre-existing version-guard
cases.
9.6. Live check
The database was staged with an identity_workflow run for CD_tenant-0, an
active tenant that is not the system tenant, and the workflow service restarted
on the new binary. Its log shows the pass finding the run, the identity handler
completing step 0, the engine advancing to step 1 and the run completing:
Workflow COMPLETED: type=identity_workflow workflow=11111111-2222-3333-4444-555555555555
steps=2. The database agrees: both steps completed with responses, the
instance completed at step index 1.