Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/analysis/2026-09-21-integration-mutation-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Connector mutation ordering

Status: stacked draft PR #3336, 2026-09-21. Parent: PR #3332, itself stacked on #3329.

## Reproduced defects

Update, delete, enable and disable started independently for one connector even though the API accepts no expected revision and the entity has no configured concurrency token. Same-connector commits and responses could therefore diverge from user submission order. A queued pre-logout intent also had no transport-time lifecycle check.

Independent mutation failures exposed a second ownership gap during review. A queued same-connector mutation cleared the store's shared error when its transport started. If another connector failed while the queued intent was waiting, the queued start erased that unrelated receipt.

## Contract

- One queue exists per connector ID. Update, delete, enable and disable for that connector run in submission order; different connectors remain concurrent.
- The first intent starts transport synchronously. Later same-connector work waits for its predecessor, regardless of success or failure.
- Immediately before transport, queued work rechecks the lifecycle epoch from submission. Identity, token, authentication or demo replacement clears queue registration and prevents old intent from using later credentials.
- A predecessor failure does not cancel the next intent. The queued transport clears an error only when that receipt is owned by its own predecessor; an unrelated concurrent connector failure remains visible.
- Existing stale-session cache, detail, toast and error settlement rules from #3332 remain unchanged.
- Delete remains ordered rather than magical: later intent still reaches the server and may receive NotFound; no client resurrection is introduced.

The client queue preserves one client's submission order only. It does not solve cross-device concurrency; the backend currently exposes no revision precondition.

## Evidence and remaining gates

The initial ordering negative control executed the actual production store with only framework/API/session boundaries stubbed: one independent-connector control passed and five ordering/session schedules failed on the parent, then all six passed after serialization.

Codex review identified the independent-error case. A dedicated deferred Pinia regression was committed before the correction: connector 2 fails while connector 1's second update waits, then connector 1's queued transport starts without erasing connector 2's error.

This child is rebuilt on the parent token-rotation correction rather than retaining its older copy of the integration store. Exact-head Pinia/Vitest, lint, project typecheck, build, the complete hosted matrix and repeat independent review remain required. Because this is a third-level stack, retarget only after #3329 and #3332 land, verify the child-only diff and requalify against current `main`. No merge, release or deployment qualification is claimed.
200 changes: 125 additions & 75 deletions frontend/taskdeck-web/src/store/integrationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,39 @@ export const useIntegrationStore = defineStore('integration', () => {
token: symbol
}

interface MutationTail {
promise: Promise<void>
ownerToken: symbol
}

let lifecycleEpoch = 0
let errorOwner: symbol | null = null
const readOwners = new Map<ReadLane, ReadOwner>()
const activeReadTokens = new Set<symbol>()
const mutationTails = new Map<string, MutationTail>()

function syncLoading() {
loading.value = activeReadTokens.size > 0
}

function clearError() {
error.value = null
errorOwner = null
}

function recordError(ownerToken: symbol, message: string) {
error.value = message
errorOwner = ownerToken
}

function beginRead(lane: ReadLane): ReadOwner {
const previous = readOwners.get(lane)
if (previous?.epoch === lifecycleEpoch) activeReadTokens.delete(previous.token)

const owner = { epoch: lifecycleEpoch, token: Symbol(lane) }
readOwners.set(lane, owner)
activeReadTokens.add(owner.token)
error.value = null
clearError()
syncLoading()
return owner
}
Expand All @@ -63,13 +80,43 @@ export const useIntegrationStore = defineStore('integration', () => {
lifecycleEpoch += 1
readOwners.clear()
activeReadTokens.clear()
mutationTails.clear()
loading.value = false
}

function ownsLifetime(epoch: number): boolean {
return epoch === lifecycleEpoch
}

async function enqueueConnectorMutation<T>(
connectorId: string,
task: (epoch: number, ownerToken: symbol) => Promise<T>,
): Promise<T | undefined> {
const epoch = lifecycleEpoch
const ownerToken = Symbol(`mutation:${connectorId}`)
const predecessor = mutationTails.get(connectorId)
let release!: () => void
const tail = new Promise<void>((resolve) => { release = resolve })
mutationTails.set(connectorId, { promise: tail, ownerToken })

try {
if (predecessor) await predecessor.promise
if (!ownsLifetime(epoch)) return undefined

// Retire only an error produced by this connector's predecessor. Another
// connector can fail while this intent waits and must keep its receipt.
if (predecessor) {
if (errorOwner === predecessor.ownerToken) clearError()
} else {
clearError()
}
return await task(epoch, ownerToken)
} finally {
release()
if (mutationTails.get(connectorId)?.promise === tail) mutationTails.delete(connectorId)
}
}

function guardDemoMutation(): never | void {
if (isDemoMode) {
toast.info('This action is view-only in demo mode.')
Expand All @@ -80,6 +127,7 @@ export const useIntegrationStore = defineStore('integration', () => {
async function fetchConnectors() {
if (isDemoMode) {
loading.value = false
clearError()
error.value = 'Integrations are not available in demo mode.'
return
}
Expand All @@ -93,7 +141,7 @@ export const useIntegrationStore = defineStore('integration', () => {
if (!ownsRead('list', owner)) return
connectors.value = []
const msg = getErrorDisplay(e, 'Failed to fetch integrations').message
error.value = msg
recordError(owner.token, msg)
toast.error(msg)
} finally {
finishRead('list', owner)
Expand All @@ -102,6 +150,7 @@ export const useIntegrationStore = defineStore('integration', () => {

async function fetchConnectorDetail(id: string) {
if (isDemoMode) {
clearError()
error.value = 'Integrations are not available in demo mode.'
return
}
Expand All @@ -114,7 +163,7 @@ export const useIntegrationStore = defineStore('integration', () => {
} catch (e: unknown) {
if (!ownsRead('detail', owner)) return
const msg = getErrorDisplay(e, 'Failed to fetch connector details').message
error.value = msg
recordError(owner.token, msg)
selectedConnector.value = null
toast.error(msg)
} finally {
Expand All @@ -125,8 +174,9 @@ export const useIntegrationStore = defineStore('integration', () => {
async function registerConnector(request: CreateIntegrationConnectorRequest) {
guardDemoMutation()
const epoch = lifecycleEpoch
const ownerToken = Symbol('register')
try {
error.value = null
clearError()
const connector = await integrationsApi.registerConnector(request)
if (!ownsLifetime(epoch)) return connector

Expand All @@ -136,7 +186,7 @@ export const useIntegrationStore = defineStore('integration', () => {
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to register connector').message
error.value = msg
recordError(ownerToken, msg)
toast.error(msg)
}
throw e
Expand All @@ -145,102 +195,102 @@ export const useIntegrationStore = defineStore('integration', () => {

async function updateConnector(id: string, request: UpdateIntegrationConnectorRequest) {
guardDemoMutation()
const epoch = lifecycleEpoch
try {
error.value = null
const updated = await integrationsApi.updateConnector(id, request)
if (!ownsLifetime(epoch)) return updated
return await enqueueConnectorMutation(id, async (epoch, ownerToken) => {
try {
const updated = await integrationsApi.updateConnector(id, request)
if (!ownsLifetime(epoch)) return updated

connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
}
toast.success('Connector updated.')
return updated
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to update connector').message
recordError(ownerToken, msg)
toast.error(msg)
}
throw e
}
toast.success('Connector updated.')
return updated
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to update connector').message
error.value = msg
toast.error(msg)
}
throw e
}
})
}

async function deleteConnector(id: string) {
guardDemoMutation()
const epoch = lifecycleEpoch
try {
error.value = null
await integrationsApi.deleteConnector(id)
if (!ownsLifetime(epoch)) return
await enqueueConnectorMutation(id, async (epoch, ownerToken) => {
try {
await integrationsApi.deleteConnector(id)
if (!ownsLifetime(epoch)) return

connectors.value = connectors.value.filter((connector) => connector.id !== id)
if (selectedConnector.value?.id === id) {
selectedConnector.value = null
connectors.value = connectors.value.filter((connector) => connector.id !== id)
if (selectedConnector.value?.id === id) {
selectedConnector.value = null
}
toast.success('Connector removed.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to remove connector').message
recordError(ownerToken, msg)
toast.error(msg)
}
throw e
}
toast.success('Connector removed.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to remove connector').message
error.value = msg
toast.error(msg)
}
throw e
}
})
}

async function enableConnector(id: string) {
guardDemoMutation()
const epoch = lifecycleEpoch
try {
error.value = null
const updated = await integrationsApi.enableConnector(id)
if (!ownsLifetime(epoch)) return
await enqueueConnectorMutation(id, async (epoch, ownerToken) => {
try {
const updated = await integrationsApi.enableConnector(id)
if (!ownsLifetime(epoch)) return

connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
}
toast.success('Connector enabled.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to enable connector').message
recordError(ownerToken, msg)
toast.error(msg)
}
throw e
}
toast.success('Connector enabled.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to enable connector').message
error.value = msg
toast.error(msg)
}
throw e
}
})
}

async function disableConnector(id: string) {
guardDemoMutation()
const epoch = lifecycleEpoch
try {
error.value = null
const updated = await integrationsApi.disableConnector(id)
if (!ownsLifetime(epoch)) return
await enqueueConnectorMutation(id, async (epoch, ownerToken) => {
try {
const updated = await integrationsApi.disableConnector(id)
if (!ownsLifetime(epoch)) return

connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
}
toast.success('Connector disabled.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to disable connector').message
error.value = msg
toast.error(msg)
connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector)
if (selectedConnector.value?.id === id) {
selectedConnector.value = { ...selectedConnector.value, ...updated }
}
toast.success('Connector disabled.')
} catch (e: unknown) {
if (ownsLifetime(epoch)) {
const msg = getErrorDisplay(e, 'Failed to disable connector').message
recordError(ownerToken, msg)
toast.error(msg)
}
throw e
}
throw e
}
})
}

function $reset() {
invalidateReads()
connectors.value = []
selectedConnector.value = null
error.value = null
clearError()
}

watch(
Expand Down
Loading
Loading