Skip to content

docs(adr): ADR-032 integration deletion data cleanup - #641

Open
d-klotz wants to merge 7 commits into
nextfrom
claude/integration-deletion-cleanup-9hs5oa
Open

docs(adr): ADR-032 integration deletion data cleanup#641
d-klotz wants to merge 7 commits into
nextfrom
claude/integration-deletion-cleanup-9hs5oa

Conversation

@d-klotz

@d-klotz d-klotz commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Deleting an integration removes one row: the Integration record. Its Entity records and the Credential records holding live OAuth access and refresh tokens stay in the database forever, with no owner and nothing to clean them up.

How much of the integration's own data goes away depends on the backend:

  • PostgreSQL — real foreign keys, so mappings, associations and processes cascade correctly.
  • MongoDB — the same onDelete: Cascade in the schema, but no real foreign keys; Prisma emulates it, and user-repository-mongo.js already warns against relying on that.
  • DocumentDB — the adapter deletes through $runCommandRaw, which never reaches Prisma's query engine, so nothing cascades and every child row is orphaned too.

Syncs are the exception on every backend: the cascade is declared, but SyncManager never writes Sync.integrationId, so for every sync the framework has created that foreign key is null and matches nothing.

The schema says one thing and the behaviour is three different things. This ADR proposes a fix and documents the decision.

What's in this PR

Documentation only — one new ADR and its row in the register. No code changes.

  • docs/architecture-decisions/032-integration-deletion-cleanup.md (new, 197 lines)
  • docs/architecture-decisions/README.md (one row added to the Current ADRs table)

The decision, in brief

A PurgeIntegrationData use case runs inside the existing delete flow — after ON_DELETE, so provider-side teardown still happens first, and before the Integration row is removed, so the IN_DELETION status keeps queue work away while it runs.

Record What we do When
IntegrationMapping, Process, Sync, DataIdentifier, Association, AssociationObject Always delete They belong to this integration and nothing else
Entity Delete only if orphaned No other integration references it, and its userId matches
Credential Delete only if orphaned No entity is left pointing at it
User, Token Never One user owns many integrations
UsageCounter Never Deliberately kept so usage history survives deletion

The rule can't be "delete what the integration touches", because three records are shared on purpose: an entity can belong to several integrations, a credential can back several entities, and a user is one app user who owns many integrations. So it's "delete what it owns, plus what nothing else needs" — which means reference counting in the use case rather than leaning on the ORM.

Deliberately explicit on all three backends rather than relying on cascade behaviour, since it differs per backend and one backend has none. Extra deletes on PostgreSQL are harmless.

Prerequisites the ADR names

Four existing bugs sit on this path, each a real bug on its own merits:

  1. A retried DELETE returns 500, not 404 — findIntegrationById throws on all three backends, so the Boom.notFound check is unreachable. Retrying is meant to be the recovery path.
  2. Deletion error messages erase each other — updateIntegrationMessages reads a messages column that doesn't exist, so it always reads empty and writes over the top.
  3. Queue workers will start dead-lettering once processes and entities are deleted, because hydration happens before the status check and throws on a missing entity.
  4. SyncManager never writes Sync.integrationId, so the cascade the schema declares has never matched a row.

Test plan

Documentation only, so there's nothing to run and I haven't claimed otherwise — no code, config or schema is touched, and the working tree has no node_modules installed.

  • Follows the ADR-014 conventions: filename, # ADR-NNN: Title heading, bold Status/Date/Deciders block, and the Context / Decision / Consequences / Alternatives Considered / Related sections
  • Register row added to docs/architecture-decisions/README.md
  • ADR number is the next unused integer
  • Maintainer review of the four open questions below

CI status

build (22.x) is red, and it is red on next too — see this comment for the side-by-side. Identical signature on both (32 suites / 138 tests failing in @friggframework/devtools), and every one of the last 30 runs on next has failed going back to 2026-06-08. Not caused by this PR, and not fixable from it.

Everything else is green: SonarCloud (quality gate passed, 0 new issues), CodeSee, GitGuardian. The three Netlify checks are neutral because the deploy self-cancels for a change outside the site path.

Open questions for review

  1. Provider-side revocation — should deleting a credential try to revoke it upstream where the module supports it, best-effort or blocking? Worth deciding now, because once the row is gone we can never revoke it.
  2. Account erasure — should there be a separate "delete this user and everything they own" use case? The four-step order is already documented in user-repository-mongo.js and implemented nowhere.
  3. UsageCounter and lawful erasure — "never delete" is right for integration deletion, but no prune path exists at all, so an adopter served an erasure request has no lawful way to remove usage rows.
  4. Audit trail — worth recording what each purge deleted, somewhere durable?

Note on labels

Tagged release and prerelease per the convention in CLAUDE.md. Since this is documentation only, that will cut a prerelease version for no code change — happy to drop both labels if you'd rather not publish for this.

claude added 5 commits August 20, 2026 20:24
Deleting an integration removes the Integration row and, on two of three
backends, whatever the ORM cascades from it. Entity, Credential, User and
Token rows always survive; on DocumentDB the integration's own child rows
survive too, because the adapter deletes via $runCommandRaw and bypasses the
Prisma query engine entirely.

ADR-032 plans an explicit, backend-uniform purge: unconditional deletion of
the records an integration owns outright, reference-counted deletion of the
records it can share (Entity, Credential), and never the User. Includes the
per-record policy table, ordering chosen for idempotent retry, the repository
methods to add per backend, failure handling, an orphan sweeper for existing
deployments, and the open decisions for maintainers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
Module.deauthorize() deletes a credential and unsets the link on only the
entity it holds in memory, so siblings keep a dangling credentialId — the same
reference-count predicate the purge needs. Note it, and place the predicate in
one shared helper so that bug is fixed by construction.

Also records that nothing in the repo reference-counts these records today
(the one 'orphan' hit is a log line), that deleteEntity/deleteCredentialById
already swallow P2025 while findEntityById throws, and that the account-erasure
primitives are already half-built (deleteUser, deleteUserById, ADR-007's
unimplemented route).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
Two source-verified findings changed the design:

- Sync.integrationId is never written. SyncManager.createSyncDBObject omits
  it and this.integration is captured at manager.js:33 and never used, so the
  schema's cascade has never matched a row and a purge keyed on integrationId
  alone would delete zero. Now: fix the writer, and match on the integration's
  entity ids too (scoped to those ids, never 'integrationId is null', which
  would delete every parentless sync).
- The encryption extension's delete hook decrypts the deleted row afterwards
  with no error handling down to KMS, so a rotated key throws with the row
  already gone. Affects Credential and IntegrationMapping — exactly what the
  purge touches. Prefer deleteMany, which skips the crypto path.

Also corrects: DataIdentifier is embedded in the Sync document on DocumentDB,
so the prescribed explicit delete is a no-op there; the queue-worker guard
already exists on the integrationId path but hydrates before checking status
and throws on the processId path, so the purge would turn discarded messages
into DLQ entries unless both are fixed here; S3 report artifacts are ADR-010,
not ADR-022; admin scripts have no repository access, so the sweeper needs two
command-layer reads added; the entity ownership predicate is now stated once
(strict equality, null owner skips) instead of two contradictory ways.

Adds a known-limits section for the non-atomic check-then-act, syncs the
entity predicate does not consult, org-linked users that skip rather than
resolve, and revocation being foreclosed by deletion. Testing section rewritten
against the real conventions: no real-DB tier exists, repository tests assert
emitted command shape against a fake prisma client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
Found by the synthesis pass and confirmed against source:

- findIntegrationById throws on all three backends (mongo:195, postgres:239,
  documentdb:71,75), so the Boom.notFound at delete-integration-for-user.js:38-42
  is unreachable and a retried DELETE returns 500, not 404. "Retry is the
  recovery path" needs that fixed first. The test double returns null, which is
  why it stayed invisible.
- Integration has no `messages` column — only errors/warnings/info/logs — so
  updateIntegrationMessages reads undefined, gets [], and writes a
  single-element array, clobbering the column. Every deletion error erases the
  previous one. The purge's error reporting depends on it appending.
- integration-commands.js:315-339 is a second delete path with no ownership
  check, no IN_DELETION and no ON_DELETE; its !deleted guard is unreachable.
  Recommendation is to leave it raw and document it, since the sweeper needs an
  unpurged primitive. create-integration.js:70-78 self-deletes a duplicate row
  and must stay purge-free — noted so it is not "fixed" later.

Descopes the SyncManager repair: upsertSync binds a bare id array to a relation
field and _convertFilterToWhere spreads raw $elemMatch/$all into Prisma's where,
so the sync write path is likely non-functional and there may be no rows to
purge. The purge's sync rules are correct either way via the entity-id arm;
coupling the two would block this change on a larger one.

Adds an open question on whether lawful erasure may purge UsageCounter rows,
since no prune path exists at all today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
The previous version was 717 lines — 2.5x the longest existing ADR and 5x
the median — written as a forensic audit with file:line citations on nearly
every claim. That is not what an ADR is for, and not how the other 29 read.

Rewritten to 183 lines in plain English, following the ADR-014 conventions:
Context / Decision / Consequences (Positive, Negative, Neutral) / Alternatives
Considered / Related, with the metadata block in the usual form. Open Questions
kept as a section, matching ADR-022.

Same decisions, same policy table, same ordering — just stated once and plainly
instead of argued at length. Implementation detail moves to prose and a short
list of the three things worth writing down (strict ownership check, deleteMany
for encrypted rows, counting from the integration side on MongoDB). The four
existing bugs this depends on are named in a few lines each rather than
reconstructed step by step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
@netlify

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for friggframework-org canceled.

Name Link
🔨 Latest commit a865001
🔍 Latest deploy log https://app.netlify.com/projects/friggframework-org/deploys/6a878c14e1c72e0008b7b657

@d-klotz d-klotz added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Aug 20, 2026 — with Claude
Comment thread docs/architecture-decisions/032-integration-deletion-cleanup.md Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fb2f856fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/architecture-decisions/032-integration-deletion-cleanup.md Outdated
Comment thread docs/architecture-decisions/032-integration-deletion-cleanup.md Outdated
claude added 2 commits August 20, 2026 23:21
The backend table claimed PostgreSQL's cascade removes syncs, while the bug
list four paragraphs later says Sync.integrationId is never written. Both
cannot be true: with the column always null the foreign key matches nothing.
Syncs are not removed on any backend today, so that is now stated once, under
the table, instead of contradicting it.

Fixing that surfaced a gap the simplification introduced. Step 1 said "delete
mappings, processes, syncs and associations" without saying how syncs are
matched, so implementing it as deleteMany({integrationId}) would delete zero
rows — the exact trap the bug list warns about. Step 1 now says to match on
integration id or the integration's entity ids, and to scope the second arm to
those ids rather than to "integrationId is null", which would take every
parentless sync in the database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
The ordered steps listed Sync and Association but never their DataIdentifier
and AssociationObject children, while the policy table promises explicit,
backend-independent deletion of all six. On PostgreSQL and MongoDB the children
cascade anyway, so the omission is invisible; on DocumentDB nothing cascades, so
following the algorithm as written would orphan exactly those rows.

Step 1 now deletes deepest-first — DataIdentifier before Sync, AssociationObject
before Association — and notes that DocumentDB stores identifiers as an embedded
array inside the sync document, so there is no separate collection to clear
there.

Also tightens the sync-cascade wording: the foreign key is null for every sync
the framework has created, rather than the cascade being broken in principle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jrmtqxucVoXrxvw7ph8h
@sonarqubecloud

Copy link
Copy Markdown

d-klotz commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

build (22.x) is red on next too — not caused by this PR

Flagging this so nobody re-investigates it. This PR adds two markdown files and touches no code, config or schema.

The failure is in the @friggframework/devtools workspace, at the Tests 🧪 step. The signature is identical on both:

Base next (77d9d29) This PR (7fb2f85)
Test Suites 32 failed, 1 skipped, 68 passed 32 failed, 1 skipped, 68 passed
Tests 138 failed, 10 skipped, 1649 passed 138 failed, 10 skipped, 1649 passed
Workspace @friggframework/devtools @friggframework/devtools

Same counts, same workspace, same command (jest --passWithNoTests). 77d9d29 is the commit this branch was cut from — base run, this PR's run.

Failures are spread across frigg-cli/**, management-ui/** and infrastructure/** — for instance build-prisma-layer.test.js expects a hardcoded /workspace/packages/core/prisma-postgresql/migrations path that doesn't match the runner's checkout. Nothing in packages/core, and nothing related to docs.

Worth noting the wider picture: every one of the last 30 CI runs on next has failed, going back to 2026-06-08. So build (22.x) has not been a meaningful merge gate for over two months.

I've deliberately not touched it. Fixing 138 unrelated devtools tests does not belong in a documentation PR, and no amount of change here will make this check green. It looks worth its own issue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants