koala73/worldmonitor · critical · ConvexError
ACCOUNT_OWNER_BINDING_MISMATCH
Error message
ACCOUNT_OWNER_BINDING_MISMATCH
What it means
Thrown in syncCompanyMonitoringAccountFromEntitlement (accounts.ts:195) when an existing, non-terminal account row's ownerUserId does not match the userId being synced. The invariant is that once an account row is bound to an owner, that binding is immutable for the row's lifetime. A mismatch means the fence-key lookup found a row belonging to a different user.
Source
Thrown at convex/companyMonitoring/accounts.ts:195
companyCount: 0,
companyLimit: COMPANY_LIMIT,
snapshotGeneration: 0,
purgeGeneration: 0,
purgePhase: "none",
destructivePurgeStarted: false,
pendingReactivation: false,
claimPolicyVersion: COMPANY_MONITORING_CLAIM_POLICY_VERSION,
createdAt: now,
updatedAt: now,
});
await scheduleScopedKeyCacheInvalidation(ctx, userId);
return ctx.db.get(id);
}
// Terminal rows intentionally retain only the keyed fence and logical id.
// A replayed or delayed activation can find the row, but can never mutate it.
if (existing.terminalReason || existing.lifecycle === "denied") return existing;
if (existing.ownerUserId !== userId) throw new ConvexError("ACCOUNT_OWNER_BINDING_MISMATCH");
if (existing.ownerFenceHash !== ownerFenceHash) {
await ctx.db.patch(existing._id, { ownerFenceHash });
if (existing.purgePhase !== "none" && existing.purgePhase !== "complete") {
// Jobs scheduled before rotation still carry the old hash and will become
// stale after migration. Seed the same generation under the current key.
const delayMs = existing.purgePhase === "pending" && existing.purgeAfter
? existing.purgeAfter - Date.now()
: 0;
await scheduleAccountPurge(ctx, ownerFenceHash, existing.purgeGeneration, delayMs);
}
existing = { ...existing, ownerFenceHash };
}
const semanticChanged = existing.entitlementDigest !== canonical.digest;
const now = Date.now();
// A completed generation proves every company payload and claim was scrubbed.
// Reuse the same nonterminal owner root as an empty portfolio; terminal rootsView on GitHub (pinned to ffec79ac33)
Solutions
- Inspect the affected row: its ownerUserId, ownerFenceHash, and logicalAccountId — determine which user it truly belongs to.
- Audit companyMonitoringOwnerFenceCandidates for the userId in question to confirm the fence hash derivation is unique per user.
- If the row was corrupted by a migration, restore the correct ownerUserId or terminalize the row so a fresh root can be provisioned.
- Verify the fence keyring keys are distinct and the hash function is collision-resistant.
Defensive patterns
Strategy: try-catch
Try / catch
try {
await syncCompanyMonitoringAccountFromEntitlement(ctx, userId);
} catch (err) {
if (err instanceof ConvexError && err.message === "ACCOUNT_OWNER_BINDING_MISMATCH") {
// the fence lookup hit a row owned by a different user — critical data-integrity bug
throw err;
}
throw err;
} Prevention
- Verify the fence keyring produces a unique hash per userId — audit companyMonitoringOwnerFenceCandidates.
- Never manually edit ownerUserId on an existing companyMonitoringAccounts row.
- Do not reuse fence keys or seed values across users.
When it happens
Trigger: A fence hash collision: two different users produce the same current ownerFenceHash (should be cryptographically impossible but indicates a keyring/config bug). A manual DB edit changed ownerUserId on an existing row. A fence key was reused across users due to a keyring misconfiguration.
Common situations: The fence keyring is misconfigured so that companyMonitoringOwnerFenceCandidates returns the same hash for two different userIds. A data migration script corrupted ownerUserId values. A test fixture reused a fence hash across test users.
Related errors
- ACCOUNT_OWNER_FENCE_CONFLICT
- COMPANY_MONITORING_ACCESS_DENIED
- COMPANY_MONITORING_ACCESS_DENIED
- COMPANY_MONITORING_ACCESS_DENIED
- ACCOUNT_NOT_FOUND
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/1678b117c1c20499.
Report an issue: GitHub.