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:
| Axis | Manifest planned | Live (re-verified 2026-07-27) |
|---|---|---|
| Migration deletions | 91 | 91 — 90 servicenow + 1 entra_id, all attributed to the single migration sync fc50dc97-817c-4e20-92e1-0769bb2b5f13 |
| Protected floor alive | 10 | 10 / 10 (removed_by_sync_id: null, status active) |
| Frozen-edge rewrites | 8 | 8 applied, 0 active-graph dangling references (journal adr033-contoso-42c17759… = committed) |
| Active entities (tenant) | — | 224 |
| Active findings | — | 169 (visual parity with pre-migration) |
| Tombstones NOT from the migration | — | 7 pre-existing (foundry-sync 5, sentinel-soc-sync 2) — untouched, out-of-scope |
| v2 entities wrongly deleted | 0 | 0 |
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-identical —
rekey_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-keysource_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 connection | rest-message:<name> | — |
| business_rule workload | business-rule:<table>:<name> | sys_id |
| script_include workload | script-include:<api_name or name> | sys_id |
| scheduled_job workload | scheduled-job:<name> | sys_id |
| flow_designer_flow workload | flow:<sys_scope.scope>:<internal_name> (collision-fail) | sys_id |
| flow endpoint resource | flow-endpoint:<owning-flow key> (inherits) | — |
| run_as / creator / owner human_identity | raw <user_name> (resolved; unchanged shape) | — |
Constraints (each closes a review blocker — see §"Why v1/v2 were unsafe"):
- Typed, self-namespacing keys because
_idhas no subtype component — a barenamewould 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 Clientandsn-ticket-router (Graph)— share one Azureclient_id(114c64b7…) because both bind the same Azure app registration. Sooauth-provider:<client_id>would MERGE two entities and trip the assertion; oauth_provider keys onname(distinct). Their derivedcred-*entra credentials collide on client_id AND name (both renderOAuth: sn-ticket-router). Decision (locked): do NOT merge onclient_idalone — equal client_ids prove the same Azure application/SP, not the same client-secret credential. Each binding is preserved viaentra-cred:<client_id>:<oauth-provider-name>, and the connector stores the natural oauth-provider key in place of the rotatingoauthEntitySysId(sys_ids retained only as provenance/evidence). This also avoids colliding with the protected frozensn-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_workloadare populated bysys_id(transformer.py:1179,1233) and read bysource_id(:1656,1671). Keepsys_idas the join key; only the identitysource_idbecomes 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-Bdeletion_policycheck — 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 → v1until it is separately migrated (connector-tenant-mapping.md:56-57). (B5) run_asresolution — resolve, do NOT retype (R3). Discovery defaults todisplay_value="true"(servicenow_client.py:657) while the transformer reads the reference.value(asys_usersys_id,:1579). Requestdisplay_value=all(or batch asys_userlookup) and emit the rawuser_name— the SAMEsource_idshape the creator/owner emitters already use (transformer.py:602,1427,1474,2013,2025). A typedsn-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=disabledbecomes a permanent duplicate. Sincesys_created_bycannot classify OOB vs custom, key every flow uniformly on the compositeflow:<sys_scope.scope>:<internal_name>(locked): resolve thesys_scopereference to its stable textualscopevalue (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, uniqueinternal_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:
deletion_policy: "disabled" | "connector_controlled"on the tenant doc, checked atsync-ingestion.ts:144.disabled→detectDeletions=falseregardless of payload. Defaultconnector_controlled. (Replaces v2's ambiguous "incremental wins regardless" — B6.)- Protected-entity floor in
diff-engine.ts— a hard allowlist the engine refuses to delete at any ratio/mode; also defends againstavailable-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. - 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: separateplanned_deletionsandplanned_ownership_releasessets (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 thecontoso-entra-servicenowinstance'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. Placecontosoin 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."
- Land Part 1 (v2,
contosoonly) + Part 2; setcontosodeletion_policy=disabled; seed the floor with the 10 protected_ids (§Migration manifest). - Quiesce workers + purge
contoso's queued jobs (the global job queue is not in any tenant dump). - Backup (§Backup/Restore).
- 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 ofcontoso(v2 keys): creates the natural-key entities, deletes the stale sys_id entities viacomputeDiff. 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). - Frozen-node relationship rewrite (see D1) driven by the migration manifest (§Migration manifest).
- Re-eval + re-bake evidence + re-materialize authority/stitched paths + re-stitch correlations (all derived stores rebuilt explicitly).
- Close the override (auto-revert to
disabled) → exitcontosomaintenance 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 doc | rewrites | edges |
|---|---|---|
| Microsoft Foundry - sn-ticket-router | 3 | USES, INVOKES ×2 |
| sn-ticket-router (SP identity) | 4 | AUTHENTICATES_AS, RUNS_AS ×3 |
| automation_disabled | 1 | OWNED_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_versionin 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 fullold_target_id → new_target_idmap 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):
| Assertion | Result |
|---|---|
| Re-keyed entities | 91 (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 rewrites | 8 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 floor | 10 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 envelope | 91 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 fingerprints | 0 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 endpointHTTP 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_bycannot classify OOB vs custom (distribution:admin: 82, maint: 1— AI Triage is alsoadmin). 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)
| # | Blocker | Verified at | Resolution |
|---|---|---|---|
| B1 | Pipeline doesn't preserve refs for the frozen (protected) nodes | sync-ingestion.ts:449, graph-transformer.ts:201 | Manifest rewrite (D1) |
| B2 | mongorestore --drop erases every other tenant in shared dev | mongo semantics; restore-tenant.ts local-only | Restore into temp DB; replace only {tenant_id:contoso} rows |
| B3 | Collision boundary wrong — _id has no subtype | stable-entity-id.ts:21 | Typed keys + graph-wide (sourceSystem, sourceId) assertion |
| B4 | Changing workload source_id breaks execution joins | read :1656/1671 vs populated :1179/1233 | Separate internal sys_id discovery key |
| B5 | Global change re-keys enterprise-nimbus too | connector-tenant-mapping.md:56-57 | identity_key_version gate, platform-required per (tenant, connector-instance); enterprise-nimbus required v1 until separately migrated |
| B6 | Deletion policy contradicted the migration | logical | disabled|connector_controlled + audited maintenance override |
| B7 | run_as resolution under-specified | servicenow_client.py:657, transformer.py:1579 | display_value=all → raw user_name (matches existing emitters) + loud warning |
| B8 | #268 still recommended the stale MVP | process | This ADR supersedes it; #268 marked held |
Backup / restore
- No collection-wide
--dropin shared dev. Restore into an isolated temp DB, then delete+replace only{tenant_id:"contoso"}rows across the canonical tenant-scoped registry. Do NOT reuserestore-tenant.ts(local-only, globally destructive). - Host allowlist, not DB-name guard — dev and prod are both DB
sv0_platform; gate onMONGODB_URIhost == dev Hetzner178.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→DefenderBRIDGES_TO,Machine.Isolatefor #1825/#1842. Evidence re-baked (valid SHA256); correlations rebuilt. enterprise-nimbusunaffected (still v1) until its own migration.
Consequences
- Positive: after migration,
contosois 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-nimbuscarries the same eventual migration (deferred by v1 default). - Residual risk: "never delete" for
contosowon'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).