Skip to main content

ADR-033: PDI-Immune ServiceNow Identity — Versioned Natural Keys + Server-Enforced Deletion Policy

Status

Accepted — 2026-07-20. Executed on dev 2026-07-24; independently re-verified 2026-07-27. Design direction accepted and the one-time contoso migration is complete on dev.securityv0.com (Hetzner 178.156.217.150, DB sv0_platform). Implementation landed in the PRs that cite this ADR: Axis A SecurityV0/sv0-connectors#268 (→ merged connectors#279), Axis B SecurityV0/sv0-platform#1860/#1863, and the D1 frozen-edge rewrite executor SecurityV0/sv0-platform#1880. Supersedes the ~40-line remap/incremental MVP originally proposed in sv0-connectors#268.

Execution record (2026-07-24) + independent re-verification (2026-07-27)

The migration ran through the real ingest pipeline (never a raw _id rewrite) under the maintenance override, followed by the D1 frozen-edge rewrite. All figures below were independently re-queried against live dev Mongo on 2026-07-27 (read-only), not merely asserted from the run:

AxisManifest plannedLive (re-verified 2026-07-27)
Migration deletions9191 — 90 servicenow + 1 entra_id, all attributed to the single migration sync fc50dc97-817c-4e20-92e1-0769bb2b5f13
Protected floor alive1010 / 10 (removed_by_sync_id: null, status active)
Frozen-edge rewrites88 applied, 0 active-graph dangling references (journal adr033-contoso-42c17759… = committed)
Active entities (tenant)224
Active findings169 (visual parity with pre-migration)
Tombstones NOT from the migration7 pre-existing (foundry-sync 5, sentinel-soc-sync 2) — untouched, out-of-scope
v2 entities wrongly deleted00

Provenance chain (single-thread): executed manifest content-sha256 25bfda55fea9c521…0534a765 (v2_graph_sync_id = fc50dc97…) → D1 journal bound to the same manifest_content_sha256 = 25bfda55…, status committed → live DB shows exactly those 91 deletions under sync fc50dc97…. Pre-migration backup: sv0_platform archive, sha-256 c513ecd9… (retained on M5 and dev /home/deploy/).

Manifest attestation (executed 25bfda55… vs earlier signed candidate aa751a2c…). The manifest executed on 2026-07-24 (25bfda55…) is a fresh-snapshot regeneration of the 2026-07-22 candidate (aa751a2c…); the whole-file hash differs, but every in-scope assertion is byte-identicalrekey_count 91, unchanged 35, inserts 6, shared 2, protected_floor 10, frozen_edge_rewrites 8, emitted_total 134 — and the security-critical planned_deletions envelope (91 legacy _ids, sha-256 8e99b580…c73c) is identical, which is the exact envelope the maintenance override was bound to. The only delta is out-of-scope context the migration never mutates: frozen_total 218 → 225 and out_of_scope_count 82 → 89 (+7 = the pre-existing foundry/sentinel tombstones that entered the fresh read-only snapshot). This difference is accepted: it changes no re-keyed, protected, or deleted entity, and the override's envelope binding held byte-for-byte. (An earlier session note loosely called the two "byte-identical"; that is true of the operative envelope and every in-scope assertion, not of the whole file — recorded here to keep the audit chain exact.)

Amended after a second review round (R1–R6, 2026-07-20): version gate is now platform-enforced (R1, not a connector default); all emitted flows/endpoints are natural-keyed (R2, not left on sys_id); run_as emits raw user_name to match existing emitters (R3, no typed sn-user: split); the protected-entity floor stores a v2 fingerprint and reappearance is rejected at API admission (R4); the migration manifest schema is specified as a separate reviewable artifact (R5); the maintenance override is narrowed to contoso+expected sync ids with maintenance-mode reads (R6). These are folded into the sections below.


Context

The Contoso demo tenant ingests from a ServiceNow Personal Developer Instance (PDI). When the PDI dev217540 was reclaimed and replaced by dev284323, every provisioned ServiceNow record got a new sys_id. Because the connector keys entity identity on sys_id, the platform saw the new records as different entities from the frozen ones — breaking cross-system reach and forcing a manual sys_id → sys_id remap table.

That remap is instruction-dependent (an agent who runs the connector natively, without applying it, creates duplicates and — under full-mode — deletes the frozen originals) and one-shot (only maps this one PDI transition; the next PDI needs a new map). The requirement:

"Any way to make this remapping permanent? I don't want to risk anything in the future if some agent misses the instructions."

Root cause (verified against code)

Persisted entity identity is not the connector's nodeId string — it is:

_id = buildStableEntityId(tenant, source_system, source_id)
= sha256(`${tenant}:${source_system}:${source_id}`).slice(0,24) // src/shared/stable-entity-id.ts:21

applied at src/ingestion/graph-transformer.ts:206. The nodeId (e.g. sn-oauth-{sys_id}) is graph-local: graph-transformer.ts:196-208 builds a nodeId → entityId map per scan and resolves every edge through it; it is never persisted as identity. Nothing on the platform keys on the raw ServiceNow sys_id value (0 consumers of targetRecordSysId; the cross-system stitch keys on the client_id property, stitching/rules/registry.ts:98-102).

→ Changing source_id to a stable natural key re-keys _id and makes identity PDI-independent — edges rebuild every scan. But it has connector-internal and reference side-effects (below) that a naive "emit a different string" misses.

Two independent axes of fragility

  • Axis A — identity rotation. New PDI → new sys_id → new _id → duplicate/orphan entities. Fixed by natural-key source_id (Part 1).
  • Axis B — deletion of connector-absent nodes. Full-mode deletion detection removes any node a scan omits — the frozen-only nodes (the 10-entity protected floor), or anything transiently missing. Fixed by a server-enforced per-tenant deletion policy (Part 2). Natural-keying does nothing for Axis B.

The user's fear lives in Axis B, and a guarantee that lives in connector output is defeated the moment a future run omits a node. The only place immune to what an agent runs is the platform ingest boundary — hence Axis B is solved server-side, not by making the connector re-emit nodes.


Decision

Part 1 — Axis A: versioned typed natural-key source_id (sv0-connectors#268)

integrations/entra-servicenow/.../transformer.py. Change only the emitted source_id, gated behind an identity_key_version contract.

Node (subtype)emitted source_id (v2)internal join key
oauth_provider credential (ServiceNow)oauth-provider:<name>NOT client_id (see live-data note)
entra credential (cred-*)entra-cred:<client_id>:<oauth-provider-name> — keeps each ServiceNow OAuth binding distinct (equal client_id ⇒ same Azure app/SP, NOT the same secret)store the natural oauth-provider key (sys_id kept only as provenance)
rest_message connectionrest-message:<name>
business_rule workloadbusiness-rule:<table>:<name>sys_id
script_include workloadscript-include:<api_name or name>sys_id
scheduled_job workloadscheduled-job:<name>sys_id
flow_designer_flow workloadflow:<sys_scope.scope>:<internal_name> (collision-fail)sys_id
flow endpoint resourceflow-endpoint:<owning-flow key> (inherits)
run_as / creator / owner human_identityraw <user_name> (resolved; unchanged shape)

Constraints (each closes a review blocker — see §"Why v1/v2 were unsafe"):

  • Typed, self-namespacing keys because _id has no subtype component — a bare name would collide a REST message and a business rule of the same name. A graph-wide uniqueness assertion over (sourceSystem, sourceId) fails the scan loudly on any empty component or duplicate. (B3)
  • Live-data collision note (from the read-only manifest seed, contoso, 2026-07-20). Two distinct ServiceNow OAuth records — Azure Graph OAuth Client and sn-ticket-router (Graph)share one Azure client_id (114c64b7…) because both bind the same Azure app registration. So oauth-provider:<client_id> would MERGE two entities and trip the assertion; oauth_provider keys on name (distinct). Their derived cred-* entra credentials collide on client_id AND name (both render OAuth: sn-ticket-router). Decision (locked): do NOT merge on client_id alone — equal client_ids prove the same Azure application/SP, not the same client-secret credential. Each binding is preserved via entra-cred:<client_id>:<oauth-provider-name>, and the connector stores the natural oauth-provider key in place of the rotating oauthEntitySysId (sys_ids retained only as provenance/evidence). This also avoids colliding with the protected frozen sn-ticket-router (Graph) credential. This is exactly why the manifest is generated from a real snapshot (R5).
  • Separate internal discovery key. _execution_data, _br_latest_trigger_ts, _evidence_count_by_workload are populated by sys_id (transformer.py:1179,1233) and read by source_id (:1656,1671). Keep sys_id as the join key; only the identity source_id becomes the typed key — otherwise execution counts/timestamps/evidence silently drop. (B4)
  • Version gate — platform-enforced, not connector-selected (R1). The graph payload declares its identity_key_version; the platform stores the REQUIRED version per (tenant, connector instance) and rejects a scan whose declared version is missing or mismatched, before any write (admission, alongside the Axis-B deletion_policy check — sv0-platform#1860). A connector-side default is NOT the source of truth (that is exactly the instruction-dependence this ADR exists to kill: post-migration, an agent omitting v2 would re-emit rotating v1 identities). Required: contoso → v2, enterprise-nimbus → v1 until it is separately migrated (connector-tenant-mapping.md:56-57). (B5)
  • run_as resolution — resolve, do NOT retype (R3). Discovery defaults to display_value="true" (servicenow_client.py:657) while the transformer reads the reference .value (a sys_user sys_id, :1579). Request display_value=all (or batch a sys_user lookup) and emit the raw user_name — the SAME source_id shape the creator/owner emitters already use (transformer.py:602,1427,1474,2013,2025). A typed sn-user:<...> form would split one person into two entities. Unresolvable → a loud completeness WARNING, never a silent drop. (B7)
  • Flows: natural-key ALL emitted flows + endpoints (R2). Two-PDI evidence (D2) proves stability for those two instances, not all future PDIs/ServiceNow releases — and the connector already emits at least one custom flow that WILL rotate, which under deletion_policy=disabled becomes a permanent duplicate. Since sys_created_by cannot classify OOB vs custom, key every flow uniformly on the composite flow:<sys_scope.scope>:<internal_name> (locked): resolve the sys_scope reference to its stable textual scope value (NOT the scope sys_id, display name, trigger, endpoint, or creator); endpoints inherit the owning flow key. Read-only validation confirmed all 83 emitted flows have nonempty, unique internal_name, and the known duplicate-name pair shares a scope but has distinct internal names. A hard collision failure fires if any composite is still ambiguous. This re-keys the 83 flows too (larger migration, honest cost) but is the only choice that honours the "any future PDI" guarantee.
  • Golden two-PDI CI test (rotated sys_ids + identical natural keys → byte-identical output) + an execution-join regression test. This is the durable guardrail — it lives in code, not docs.

Part 2 — Axis B: server-enforced per-tenant deletion policy (sv0-platform#1860)

Three controls, all at the ingest boundary, all immune to the payload:

  1. deletion_policy: "disabled" | "connector_controlled" on the tenant doc, checked at sync-ingestion.ts:144. disableddetectDeletions=false regardless of payload. Default connector_controlled. (Replaces v2's ambiguous "incremental wins regardless" — B6.)
  2. Protected-entity floor in diff-engine.ts — a hard allowlist the engine refuses to delete at any ratio/mode; also defends against available-but-incomplete scans that slip under the circuit breaker. It stores the 10 protected _ids (R4) (see §Migration manifest); each protected record carries a canonical v2 fingerprint{source_system, subtype, normalized_natural_key, legacy_id, expected_prospective_id} — so the platform can recognise a reappearance by natural key, not just by the (now-obsolete) legacy _id. The manifest generator itself runs the same matcher logic against every emitted v2 node and fails generation if any protected entity would reappear, so a manifest the platform would 409 (PROTECTED_ENTITY_REAPPEARED) can never be produced.
  3. Maintenance override — narrowly scoped (R6) + envelope-bound. Server-only, time-bounded, audited (actor + expiry, auto-revert) AND bound to contoso + the exact expected connector instance + the exact reserved sync id(s), with a maximum use count. It is additionally bound to the exact destructive envelope: separate planned_deletions and planned_ownership_releases sets (each a count + SHA-256 of the sorted _ids), and the exact reviewed manifest (manifest_content_sha256). The platform lifts the deletion breaker only if the sync's actual deletion set AND ownership-release set both match their envelopes — any mismatch blocks every deletion, ownership change, and edge removal (rehearsal-proven). Concurrent runs of any other connector/tenant get NO deletion. The migration re-ingests only the contoso-entra-servicenow instance's v2 output (not a blanket "re-ingest all connectors"): it deletes exactly the 91 stale sys_id-keyed entities that instance no longer emits and creates their 91 natural-key replacements; the other connectors' entities are out-of-scope context. Place contoso in maintenance mode so no reader sees the transient graph between the re-ingest and the D1 relationship repair. NOT a payload flag.

Honesty note: setting the policy + seeding the floor is a (small, reversible) write to the contoso tenant doc — it precedes, and is separate from, the data migration.

Part 3 — one-time contoso migration (through the real pipeline, never raw _id rewrite)

A hand-rolled Mongo _id rewrite reconstructs the #1413 dangling-graph disaster (see below), so the _id swap goes through the ingest pipeline. Precisely (R6): the pipeline rebuilds references only for nodes present in the incoming graph (graph-transformer.ts:201); the frozen (protected) nodes are NOT in the graph, so D1 repairs their references explicitly, and every derived store (findings, evidence, authority/stitched paths, correlations) is rebuilt explicitly in step 6 — not "maintained by construction."

  1. Land Part 1 (v2, contoso only) + Part 2; set contoso deletion_policy=disabled; seed the floor with the 10 protected _ids (§Migration manifest).
  2. Quiesce workers + purge contoso's queued jobs (the global job queue is not in any tenant dump).
  3. Backup (§Backup/Restore).
  4. Open the maintenance override — bound to the exact reserved sync id AND the planned-deletion envelope (count + SHA-256 of the sorted legacy _ids; 91 for this candidate). Full-scope re-ingest of contoso (v2 keys): creates the natural-key entities, deletes the stale sys_id entities via computeDiff. The override lifts the deletion breaker only if the sync's actual deletion set matches the bound envelope exactly — a wrong-set payload under the reserved sync id deletes nothing (rehearsal-proven). The protected floor preserves the 10. Dry-run diff first; log the real per-connector deletion ratio (denominator per-connector/per-source-system, NOT global — B-review F2).
  5. Frozen-node relationship rewrite (see D1) driven by the migration manifest (§Migration manifest).
  6. Re-eval + re-bake evidence + re-materialize authority/stitched paths + re-stitch correlations (all derived stores rebuilt explicitly).
  7. Close the override (auto-revert to disabled) → exit contoso maintenance mode → re-enable workers → validate → fail = restore.

Decision D1 — frozen-node relationship migration + reappearance semantics

The retained nodes (the 10-entity protected floor plus out-of-scope entities; see D2 + §Migration manifest) are not re-emitted by the migrating instance's v2 scan, but they hold outbound relationships into the entities Part 1 re-keys. In the authoritative candidate (contoso, 2026-07-22) the rewrites reduce to 8 edges across exactly 3 rewrite-source documents (the AI Triage flow and HTTP endpoint hold edges only among themselves / to non-rekeyed targets, so they carry no rewrites):

Rewrite-source docrewritesedges
Microsoft Foundry - sn-ticket-router3USES, INVOKES ×2
sn-ticket-router (SP identity)4AUTHENTICATES_AS, RUNS_AS ×3
automation_disabled1OWNED_BY

Because soft-delete preserves relationships: existing.relationships (sync-ingestion.ts:449) and graph-transformer rebuilds edges only for nodes present in the incoming graph (:201), a retained node keeps target_ids into the soft-deleted old _ids — the active graph dangles (the tombstones legitimately keep their historical refs). The floor alone is insufficient.

Resolution (accepted): a manifest-driven, explicit old→new relationship rewrite — NOT a frozen-overlay re-emission. Scoped to exactly the enumerated rewrite-source documents (3 for this candidate) and their enumerated target IDs, with: pre-image relationship hashes for every rewrite source (re-verified from the live doc immediately before its write), expected counts, dry-run output, and atomicity — a Mongo transaction on a replica set, or on the standalone dev topology a durable pre-image journal with automatic byte-equal compensating rollback and an executable --recover-journal path for a killed process. Post-write validation asserts zero active-graph stale references (tombstone-held refs are verified to resolve to existing soft-deleted docs, not counted as dangling). The executor is bound to the exact reviewed manifest via manifest_content_sha256. The old→new _id map is deterministic (old = sha256(tenant:ss:sys_id), new = sha256(tenant:ss:typed-natural-key)).

Reappearance (accepted): fail closed. If a future PDI does emit one of the protected nodes, the platform recognises it by the protected record's v2 fingerprint (normalized natural key / expected_prospective_id), NOT merely by the obsolete legacy _id (R4). The ingest then fails at API admission — before any queue/draft creation or entity mutation — with PROTECTED_ENTITY_REAPPEARED; never auto-merge, never permit a duplicate. ("Before any write" = admission-time rejection of the whole sync payload, not a mid-pipeline entity guard.) Unblocking is a deliberate, deterministic promotion: unprotect the legacy entity → ingest the natural-key replacement → rewrite references → retire the legacy entity → rebuild derived stores.

Reappearance matcher schema (LOCKED, structured — no free-text legacy_alias). The matcher is one of three deterministic, executable forms, identical across the manifest generator, the tenant model, and the platform (sv0-platform#1863). A free-text legacy_alias rule is NOT a schema type; the platform rejects an unknown matcher type loudly rather than silently skipping protection.

"reappearance_match":
| { "type": "prospective_id" } // uses expected_prospective_id
| { "type": "display_name", "source_system": "…", "subtype?": "…",
"value": "<display name>" } // normalized (NFC/trim/ws/lower)
| { "type": "property", "source_system": "…", "key": "endpoint_url",
"value": "<stable value>" } // e.g. a frozen-only flow endpoint

display_name comparison uses the locked normalizeDisplayName (NFC → trim → collapse whitespace → lowercase); the connector generator and platform share display-name test vectors as the source of truth. AI Triage (flow) uses a display_name matcher; its HTTP endpoint uses a property (endpoint_url) matcher.


Migration manifest (the reviewable execution artifact — R5)

The migration is driven by a machine-readable manifest, generated by tooling from a FRESH read-only snapshot of contoso immediately before the migration, and reviewed and signed off before the maintenance override opens. This ADR + PR change only the design; the manifest is a separate, regenerable artifact (a stale hand-authored map is itself a hazard). Required contents:

  • Snapshot identity: source env/host + DB, tenant, connector instance(s), generation timestamp, and the connector identity_key_version in force.
  • Per re-keyed entity (the provisioned set + all 83 flows + endpoints): legacy_id, expected_new_source_id, expected_new_id, entity_type, subtype.
  • Per frozen protected node: legacy_id, canonical pre-image relationship hash, the full old_target_id → new_target_id map with an expected match count per edge type, and the v2 reappearance fingerprint ({source_system, subtype, normalized_natural_key, legacy_id, expected_prospective_id}).
  • Assertions: dry-run diff result (per-connector deletion count + denominator), and the post-rewrite checks (zero dangling target_ids tenant-wide; each protected edge repointed the expected number of times).

The migration script refuses to run if the live snapshot diverges from the manifest's pre-image hashes (someone changed contoso since generation).

Generated draft — adr-033-migration-manifest.draft.json (read-only, 2026-07-20)

Generated by the deterministic tool (generate_migration_manifest.py) from a fresh read-only snapshot (contoso in dev sv0_platform, 2026-07-22) joined against the v2 connector's own --graph-json output (a live --identity-key-version v2 scan of dev284323), applying the locked key formulas. All assertions pass (authoritative candidate, 2026-07-22):

AssertionResult
Re-keyed entities91 (join provenance: 35 exact-source_id + 82 provenance-sys_id + 9 display-name = 126 matched, of which 91 change _id and 35 are unchanged)
Unchanged (matched, v2 key derives the same _id)35
Replacement-only inserts (in v2, absent from frozen)6 — incl. Review and Approval Workflow for Document Version (flow), Auto-route identity tickets-Enta-no-own (BR), ds_document_version
Shared (cross-connector co-owned; re-emitted, no rewrite)2
Collisions over the COMPLETE graph (134 emitted + 82 out-of-scope + protected/retained)0 — the locked keys are collision-free across every final _id
Frozen→re-key edge rewrites8 across 3 unique source docs, each with concrete old_target_id → new_target_id, plus per-source pre-image relationship hashes (retained_rewrite_source_hashes)
Protected floor10 entity _ids — the 6 original frozen entities (the "5 frozen nodes" = 6 because sn-ticket-router (Graph) is an oauth_provider and an entra credential sharing sys_id dc8a81c1) plus the 4-entity pre-#1842 sn-ticket-router identity chain (SP + OAuth cred + Group.Read.All + role), which the re-plumbed PDI no longer emits but which the original protected creds still reference
Planned deletion envelope91 legacy _ids (rekey ∪ acknowledged-stale, sha256 8e99b580…c73c), bound into the maintenance override; the platform blocks ALL deletions unless the sync's actual candidate set matches this exact envelope
Reappearance fingerprints0 unresolved — AI Triage flow carries a structured display_name (+flow_designer_flow) matcher and its HTTP endpoint a property (endpoint_url) matcher (the property matcher is resource-only, so the flow and its endpoint do not collapse); not free-text legacy_alias

Execution controls carried in the JSON: snapshot_content_hash, connector-instance ids, expected_sync_ids (bound at migration), the pre-image relationship hashes, dry-run deletion denominators keyed by connector-instance × source_system, and the post-rewrite assertions (expected_zero_dangling_target_ids: true). The authoritative manifest is generated by scripts/generate_migration_manifest.py from the v2 connector's own --graph-json output (its golden test locks the exact key normalization) joined to a fresh read-only snapshot; the join is 1:1 and fail-closed (ambiguous display_name or any collision aborts). The deletion numerators come from the platform's dry-run diff (same connector-instance × source_system scope) — not the connector — and are filled in before the manifest is signed and the override opens.


Evidence: D2 — ServiceNow flow identity across both PDIs (read-only, dev, 2026-07-20)

Compared flow_designer_flow identity between frozen contoso (dev217540) and the contoso-v2 rebuild (dev284323) in the dev sv0_platform DB:

  • 82 of 83 flows share identical sys_ids across both PDIs — OOB/template flow sys_ids were observed stable across these two PDIs, but that is insufficient for the "any future PDI" guarantee (a future PDI or ServiceNow release can rotate them); hence R2 natural-keys all flows.
  • The one rotating flow is AI Triage via Azure OpenAI (Catalog Trigger) (sys_created_by=admin) — hand-built, non-OOB, and frozen-only (the rebuild has no AI Triage; its endpoint HTTP Endpoint (gpt-nano-for-summary…) is likewise frozen-only). It is one of the 5 protected nodes, handled by the Part 2 floor + the D1 manifest — not a Part 1 concern (the connector doesn't emit it).
  • sys_created_by cannot classify OOB vs custom (distribution: admin: 82, maint: 1 — AI Triage is also admin). Part 1 must not auto-classify flows by creator.

Why this does NOT justify leaving flows on sys_id (R2): two-PDI stability is evidence for these two instances, not a guarantee across all future PDIs or ServiceNow releases. The connector already emits a custom flow (Review and Approval Workflow for Document Version, admin-created) whose sys_id WILL rotate on a future PDI — and under deletion_policy=disabled that rotation yields a permanent duplicate, not a clean replace. Because sys_created_by can't separate OOB from custom, Part 1 natural-keys all emitted flows + endpoints uniformly (flow:<name>:<disambiguator>, collision- fail) rather than betting the "any future PDI" guarantee on the empirical stability of OOB sys_ids.


Why v1 and v2 of this plan were unsafe (blockers, all verified)

#BlockerVerified atResolution
B1Pipeline doesn't preserve refs for the frozen (protected) nodessync-ingestion.ts:449, graph-transformer.ts:201Manifest rewrite (D1)
B2mongorestore --drop erases every other tenant in shared devmongo semantics; restore-tenant.ts local-onlyRestore into temp DB; replace only {tenant_id:contoso} rows
B3Collision boundary wrong — _id has no subtypestable-entity-id.ts:21Typed keys + graph-wide (sourceSystem, sourceId) assertion
B4Changing workload source_id breaks execution joinsread :1656/1671 vs populated :1179/1233Separate internal sys_id discovery key
B5Global change re-keys enterprise-nimbus tooconnector-tenant-mapping.md:56-57identity_key_version gate, platform-required per (tenant, connector-instance); enterprise-nimbus required v1 until separately migrated
B6Deletion policy contradicted the migrationlogicaldisabled|connector_controlled + audited maintenance override
B7run_as resolution under-specifiedservicenow_client.py:657, transformer.py:1579display_value=all → raw user_name (matches existing emitters) + loud warning
B8#268 still recommended the stale MVPprocessThis ADR supersedes it; #268 marked held

Backup / restore

  • No collection-wide --drop in shared dev. Restore into an isolated temp DB, then delete+replace only {tenant_id:"contoso"} rows across the canonical tenant-scoped registry. Do NOT reuse restore-tenant.ts (local-only, globally destructive).
  • Host allowlist, not DB-name guard — dev and prod are both DB sv0_platform; gate on MONGODB_URI host == dev Hetzner 178.156.217.150 + --expect-tenant contoso.
  • Rehearse the full rollback on a disposable clone before migration day; quiesce workers + purge tenant jobs before restore; verify zero dangling relationships after.

Validation gates

  • Same connector twice back-to-back → 2nd run a pure no-op (0/0) — idempotent identity demonstrated.
  • The re-keyed entities carry typed natural-key source_ids; execution counts/timestamps/evidence identical to pre-migration (B4 gate).
  • The 10 protected nodes present AND their enumerated edges resolve — zero dangling relationships in the ACTIVE graph (tombstones legitimately retain historical refs) (B1 gate).
  • Cross-system reach intact: sn-ticket-router→Entra SP, agent→Defender BRIDGES_TO, Machine.Isolate for #1825/#1842. Evidence re-baked (valid SHA256); correlations rebuilt.
  • enterprise-nimbus unaffected (still v1) until its own migration.

Consequences

  • Positive: after migration, contoso is PDI-immune with zero manual steps — a fresh agent running the connectors natively reproduces it and cannot amputate it, regardless of mode/flags/connector.
  • Cost: Part 1 is a structured connector change (not a one-liner); Part 3 is a delicate, one-time, audited migration; enterprise-nimbus carries the same eventual migration (deferred by v1 default).
  • Residual risk: "never delete" for contoso won't reflect genuine upstream deletions (intended for a frozen demo tenant — do NOT blanket-apply to real customer tenants). The maintenance-override window is the one moment deletion is live; floor + dry-run + backup + host-allowlist + rehearsed rollback are the layered mitigations.

References

  • SecurityV0/sv0-connectors#268 (Axis A — superseded MVP, now this ADR), #267 (the 5 unreconstructed nodes), #266 (co-ingest rebuild), #264 (replay dead).
  • SecurityV0/sv0-platform#1860 (Axis B — deletion policy), #1413 (the dangling-graph failure this prevents), #1825 / #1842 (the Defender demos whose reach must survive).
  • connector-tenant-mapping.md (tenant ↔ connector fan-out).