Summary
When two or more real users open the same policy in dry-run mode at the same time, switching the active virtual user (VU) as one user silently reassigns the acting identity for every other user viewing that policy. Form submissions then fail with 422 Unprocessable Entity ("Block Unavailable"), and a hard refresh can land a user on a completely different virtual persona than the one they were using.
The dry-run "active virtual user" is a single pointer per policyId, shared globally across every real user/session/tab currently testing that policy. Whoever calls the persona switch last wins — for everyone.
Steps to Reproduce
- Two real users open the same policy in dry-run mode.
- User A switches their active dry-run persona to
Virtual User 2 via the persona switcher.
- User B (who already had the policy/form open) submits a document (e.g. clicks the create/validate button on a form block).
- The request
POST /api/v1/policies/{policyId}/blocks/{uuid} returns 422 Unprocessable Entity with message "Block Unavailable". The sync-events channel also shows failures.
- User B does a hard refresh. Instead of returning to their own working state, they land on User A's last-selected persona (
Virtual User 2).
Expected Behavior
Each real user should be able to independently select and drive their own virtual persona within a shared dry-run policy, without affecting what other concurrent users see or are authenticated as.
Actual Behavior
There is exactly one "active virtual user" per policyId, shared globally across every real user/session/tab currently testing that policy's dry run. The last persona switch overwrites the acting identity for all concurrent testers.
Root Cause
The "who am I acting as" resolution for dry-run mode is keyed only by policyId, with no per-real-user scoping.
common/src/database-modules/database-server.ts
static setVirtualUser(policyId, did) (~L5111): iterates all VirtualUsers rows for the policy and sets active = (item.did === did) — a single shared pointer:
public static async setVirtualUser(policyId: string, did: string): Promise<void> {
const items = (await new DataBaseHelper(DryRun).find({
dryRunId: policyId,
dryRunClass: 'VirtualUsers'
}));
for (const item of items) {
item.active = item.did === did;
await new DataBaseHelper(DryRun).save(item);
}
}
static getVirtualUser(policyId) (~L4109): reads that single active: true pointer back, filtered only by dryRunId: policyId + dryRunClass: 'VirtualUsers'.
guardian-service/src/policy-engine/policy-components-utils.ts
GetUserRoleByPolicy (~L62) and GetUserRole (~L129) both override the caller's identity with the single shared pointer when the policy is in dry-run mode:
if (PolicyHelper.isDryRunMode(policy)) {
const activeUser = await DatabaseServer.getVirtualUser(policyId);
if (activeUser && did !== activeUser.did) {
did = activeUser.did;
permissions = [];
}
}
This is the path hit on page load / refresh (policy() info), which is why a hard refresh moves the user onto whichever persona was selected last.
policy-service/src/policy-engine/policy-components-utils.ts
GetPolicyUserByName (~L1344) and GetActiveVirtualUser (~L1440) resolve the acting identity via DatabaseServer.getVirtualUser(instance.policyId), again with no real user id in the lookup. The 422 "Block Unavailable" is the block engine correctly refusing to serve a block to a role that doesn't match the now-swapped active identity — a symptom, not the bug.
api-gateway/src/api/service/policy.ts
POST /:policyId/dry-run/login (~L6195) calls loginVirtualUser (~L6259), which calls the shared setVirtualUser above.
Design note: the dry-run feature appears to have been designed for a single tester driving multiple simulated actors sequentially, not for concurrent multi-user testing of the same policy. As collaborative dry-run testing becomes common, the single-active-user-per-policy model breaks down.
Proposed Fix
Scope the "active persona" pointer to (policyId, real user id) instead of policyId alone, rather than duplicating the entire dry-run sandbox per user. Document/group/block state is already keyed by persona did, so per-user scoping of the active pointer is sufficient to isolate concurrent testers. No schema migration is required: the DryRun entity (common/src/entity/dry-run.ts) already has an unused userId field.
Touch points:
common/src/database-modules/database-server.ts: getVirtualUser, setVirtualUser, getVirtualUserByAccount, getVirtualUsers, countVirtualUsers — add/filter by real userId.
policy-service/src/policy-engine/policy-components-utils.ts: GetPolicyUserByName, GetActiveVirtualUser — thread the real user id through (already available in the call chain).
guardian-service/src/policy-engine/policy-components-utils.ts: GetUserRoleByPolicy / GetUserRole (the role-resolution path hit on page load / refresh) need the same scoping.
api-gateway/src/api/service/policy.ts: /dry-run/login, /dry-run/user — pass user.id down. Verify cache invalidation (invalidedCacheTags for .../navigation, .../groups) is scoped per real user so cached responses do not leak between concurrent testers.
frontend/src/app/modules/policy-engine/policy-viewer/policy-viewer/policy-viewer.component.ts: loadPolicyById re-fetches policy() on every load/refresh and renders the server-resolved userRole; it will follow correct per-user scoping once the backend pointer is fixed.
Estimated effort: Medium. Self-contained backend change across common, policy-service, guardian-service, and api-gateway, with no DB migration or backfill. Main cost is careful testing of the dry-run flow across services (login, block execution, refresh, sync-events).
Out of Scope / Follow-up
- Two testers selecting the same virtual persona simultaneously will still collide after this fix. Needs a product decision (block it, or accept as shared/intentional).
- "Restart dry run" currently wipes all virtual users for the entire
policyId, affecting every concurrent tester. This remains a shared destructive action regardless of the fix above and may warrant its own guard/warning.
References
Summary
When two or more real users open the same policy in dry-run mode at the same time, switching the active virtual user (VU) as one user silently reassigns the acting identity for every other user viewing that policy. Form submissions then fail with
422 Unprocessable Entity("Block Unavailable"), and a hard refresh can land a user on a completely different virtual persona than the one they were using.The dry-run "active virtual user" is a single pointer per
policyId, shared globally across every real user/session/tab currently testing that policy. Whoever calls the persona switch last wins — for everyone.Steps to Reproduce
Virtual User 2via the persona switcher.POST /api/v1/policies/{policyId}/blocks/{uuid}returns422 Unprocessable Entitywith message"Block Unavailable". Thesync-eventschannel also shows failures.Virtual User 2).Expected Behavior
Each real user should be able to independently select and drive their own virtual persona within a shared dry-run policy, without affecting what other concurrent users see or are authenticated as.
Actual Behavior
There is exactly one "active virtual user" per
policyId, shared globally across every real user/session/tab currently testing that policy's dry run. The last persona switch overwrites the acting identity for all concurrent testers.Root Cause
The "who am I acting as" resolution for dry-run mode is keyed only by
policyId, with no per-real-user scoping.common/src/database-modules/database-server.tsstatic setVirtualUser(policyId, did)(~L5111): iterates allVirtualUsersrows for the policy and setsactive = (item.did === did)— a single shared pointer:static getVirtualUser(policyId)(~L4109): reads that singleactive: truepointer back, filtered only bydryRunId: policyId+dryRunClass: 'VirtualUsers'.guardian-service/src/policy-engine/policy-components-utils.tsGetUserRoleByPolicy(~L62) andGetUserRole(~L129) both override the caller's identity with the single shared pointer when the policy is in dry-run mode:policy()info), which is why a hard refresh moves the user onto whichever persona was selected last.policy-service/src/policy-engine/policy-components-utils.tsGetPolicyUserByName(~L1344) andGetActiveVirtualUser(~L1440) resolve the acting identity viaDatabaseServer.getVirtualUser(instance.policyId), again with no real user id in the lookup. The422 "Block Unavailable"is the block engine correctly refusing to serve a block to a role that doesn't match the now-swapped active identity — a symptom, not the bug.api-gateway/src/api/service/policy.tsPOST /:policyId/dry-run/login(~L6195) callsloginVirtualUser(~L6259), which calls the sharedsetVirtualUserabove.Design note: the dry-run feature appears to have been designed for a single tester driving multiple simulated actors sequentially, not for concurrent multi-user testing of the same policy. As collaborative dry-run testing becomes common, the single-active-user-per-policy model breaks down.
Proposed Fix
Scope the "active persona" pointer to
(policyId, real user id)instead ofpolicyIdalone, rather than duplicating the entire dry-run sandbox per user. Document/group/block state is already keyed by personadid, so per-user scoping of the active pointer is sufficient to isolate concurrent testers. No schema migration is required: theDryRunentity (common/src/entity/dry-run.ts) already has an unuseduserIdfield.Touch points:
common/src/database-modules/database-server.ts:getVirtualUser,setVirtualUser,getVirtualUserByAccount,getVirtualUsers,countVirtualUsers— add/filter by realuserId.policy-service/src/policy-engine/policy-components-utils.ts:GetPolicyUserByName,GetActiveVirtualUser— thread the real user id through (already available in the call chain).guardian-service/src/policy-engine/policy-components-utils.ts:GetUserRoleByPolicy/GetUserRole(the role-resolution path hit on page load / refresh) need the same scoping.api-gateway/src/api/service/policy.ts:/dry-run/login,/dry-run/user— passuser.iddown. Verify cache invalidation (invalidedCacheTagsfor.../navigation,.../groups) is scoped per real user so cached responses do not leak between concurrent testers.frontend/src/app/modules/policy-engine/policy-viewer/policy-viewer/policy-viewer.component.ts:loadPolicyByIdre-fetchespolicy()on every load/refresh and renders the server-resolveduserRole; it will follow correct per-user scoping once the backend pointer is fixed.Estimated effort: Medium. Self-contained backend change across
common,policy-service,guardian-service, andapi-gateway, with no DB migration or backfill. Main cost is careful testing of the dry-run flow across services (login, block execution, refresh,sync-events).Out of Scope / Follow-up
policyId, affecting every concurrent tester. This remains a shared destructive action regardless of the fix above and may warrant its own guard/warning.References