Safely execute or recover a bulk clear of one contract metadata field without deleting unrelated ContractKeyPointer rows. Use when support asks to delete Salesforce Opportunity ID / Salesforce Account ID values across many contracts, or when a prior bulk delete made multiple metadata fields disappear across contracts.
This skill covers two closely related situations:
ContractKeyPointer rows for those contracts.The March 2026 Fullstory incidents are the reference example:
workspace_id = 41811, cluster US, target key_pointer_id = 101111, Order Forms only).ContractKeyPointer state.| Identifier | Why it matters |
|---|---|
{workspace_id} | Scope all CKP and key pointer definition checks. |
{cluster} | Determines the correct Django env, DB, restore target, and admin utility URL. |
{key_pointer_id} / {key_pointer_name} | Distinguishes "clear one field" from "delete all CKPs". |
{contract_ids} | Must come from the final approved sheet, not an outdated or partially understood export. |
{contract_type} | Critical when the field is valid only for some contract types. |
{backup_timestamp} | Needed if recovery requires a Cloud SQL restore before the destructive run. |
Known Fullstory example values:
workspace_id = 41811cluster = USkey_pointer_id = 101111Salesforce Opportunity IDContract InformationOrder FormBefore touching data, separate these three operations:
Do not treat them as interchangeable.
In the Fullstory case, the intended operation was to clear only Salesforce Opportunity ID values, not to delete all CKPs and not to delete the key pointer definition.
Also verify that support and engineering are aligned on the exact field. In the same Fullstory thread, the target field was initially described as Salesforce Account ID and later corrected to Salesforce Opportunity ID.
Confirm all of the following from the latest approved sheet or Slack instruction:
This is the failure pattern that caused the incident:
If the counts in the sheet and the count of intended target rows are materially different, stop and resolve that discrepancy first.
If the customer says the field is missing entirely, not just its value, check whether the key pointer definition was deleted or hidden.
Useful query shape from the Fullstory investigation:
SELECT
kp.id AS key_pointer_id,
kp.label,
kp.is_deleted,
kp.modified AS kp_modified,
kp.modified_by_id AS modified_by_user_id,
u.username AS modified_by_username,
u.email AS modified_by_email,
ou.id AS modified_by_org_user_id
FROM historic_contracts_keypointer kp
LEFT JOIN auth_user u ON u.id = kp.modified_by_id
LEFT JOIN sd_organizations_organizationuser ou ON ou.user_id = kp.modified_by_id
WHERE kp.id = {key_pointer_id}
AND kp.created_by_workspace_id = {workspace_id};
In the Fullstory workspace, key_pointer_id = 101111 was also found with is_deleted = true, and that had to be restored separately.
For planned deletion of one field, the queryset must be scoped by:
contract_id__in={contract_ids}created_by_workspace={workspace_id}key_pointer_id={key_pointer_id} or key_pointer__name={key_pointer_name}Do not run an unscoped queryset that only filters by contract list and workspace.
Safe Django shell pattern:
from contracts_v3.models import ContractKeyPointer
TARGET_KEY_POINTER_ID = {key_pointer_id}
contract_key_pointers = ContractKeyPointer.objects.filter(
contract_id__in=CONTRACT_IDS,
created_by_workspace=WORKSPACE_ID,
key_pointer_id=TARGET_KEY_POINTER_ID,
)
print(contract_key_pointers.count())
The dry-run count should roughly match the number of rows you actually intend to clear, not the total CKP footprint of the listed contracts.
If the ask is "remove this field's value", prefer clearing only the targeted field's value rather than deleting CKP rows for every field.
The earlier Fullstory investigation proposed this safer pattern:
from contracts_v3.models import ContractKeyPointer
TARGET_KEY_POINTER_ID = {key_pointer_id}
def execute(dry_run=True):
contract_key_pointers = ContractKeyPointer.objects.filter(
contract_id__in=CONTRACT_IDS,
created_by_workspace=WORKSPACE_ID,
key_pointer_id=TARGET_KEY_POINTER_ID,
)
pointers_to_update = list(contract_key_pointers)
for ckp in pointers_to_update:
ckp.value = ""
ckp.value_source_metadata = None
if not dry_run:
ContractKeyPointer.objects.bulk_update(
pointers_to_update,
["value", "value_source_metadata"],
)
After the mutation, enqueue search resync for the affected contracts:
from contracts_v3.search_v2.domain.constants import KEY_POINTER_DERIVED_SEARCH_FIELDS
from contracts_v3.search_v2.tasks import partially_resync_contracts_task
partially_resync_contracts_task.delay(
contract_ids=CONTRACT_IDS,
fields=KEY_POINTER_DERIVED_SEARCH_FIELDS,
)
full_story_bulk_delete_metadata_values.py as an incident artifactThe file in this skill directory:
fullstory-bulk-delete-metadata-value/full_story_bulk_delete_metadata_values.pyis not a safe default.
It batches over contract IDs and executes:
ContractKeyPointer.objects.filter(
contract_id__in=batch_contract_ids,
created_by_workspace=WORKSPACE_ID,
).delete()
That query is missing any filter on key_pointer_id / key_pointer__name, so it deletes all CKPs for those contracts in the workspace.
Only reuse that file after adding the exact target-field filter and re-verifying the dry-run count.
Suspect a CKP wipe, not a one-field clear, if any of these are true:
Fullstory blast-radius indicators:
Total key pointers deleted: 113393Elasticsearch resync triggered for 6645 contracts (async)Those numbers are incompatible with "clear one metadata field from 3408 rows".
Known failure pattern from the incident:
2026-03-19T06:28:06.625530 UTC.IN restore instance for a US workspace and returned no results.US instance (prod-us-18-03-2026), restored from before the destructive run.Use the restored instance to dump CKP rows for the affected contracts. Reconcile the export against:
In the Fullstory incident, the restored query returned 15372 rows from backup for the affected contracts, and the team used batched exports because the SQL studio response was too large.
This is the most reusable lesson from the incident.
Because the original business ask was to delete only Salesforce Opportunity ID, the restore script intentionally recreated every other CKP from backup and skipped key_pointer_id = 101111.
Adapted restore pattern:
from contracts_v3.models import ContractKeyPointer
from contracts_v3.search_v2.domain.constants import KEY_POINTER_DERIVED_SEARCH_FIELDS
from contracts_v3.search_v2.tasks import partially_resync_contracts_task
import json
INTENTIONALLY_DELETED_KEY_POINTER_ID = {key_pointer_id}
def create_ckps_from_json_file(path):
with open(path, "r") as f:
ckps_from_backup = json.load(f)
ckps_to_restore = [
ckp
for ckp in ckps_from_backup
if ckp["key_pointer_id"] != INTENTIONALLY_DELETED_KEY_POINTER_ID
]
rows = [
ContractKeyPointer(
contract_id=ckp["contract_id"],
key_pointer_id=ckp["key_pointer_id"],
value=ckp["value"],
created_by_workspace=ckp["created_by_workspace"],
created_by_id=ckp["created_by_id"],
created_by_org_user_id=ckp["created_by_org_user_id"],
was_edited_by_user=ckp["was_edited_by_user"],
was_edited_by_system=ckp["was_edited_by_system"],
value_source_metadata=ckp["value_source_metadata"],
source_non_validated_value=ckp["source_non_validated_value"],
source_datatype=ckp["source_datatype"],
)
for ckp in ckps_to_restore
]
ContractKeyPointer.objects.bulk_create(rows, batch_size=1000)
contract_ids = sorted({ckp["contract_id"] for ckp in ckps_to_restore})
partially_resync_contracts_task.delay(
contract_ids,
fields=KEY_POINTER_DERIVED_SEARCH_FIELDS,
)
You can also use the admin utility:
https://api.us.spotdraft.com/admin/contracts_v3/bulkimportcontractkeypointerstask/if it is operationally easier than a custom script.
The Fullstory team first verified a single contract (1182037) before restoring the remaining contracts the next day during non-US hours.
Check the key pointer definition and pointer config again.
This happened in the same Fullstory workspace:
So if DB values look correct but the product UI is still inconsistent, do not assume the restore is complete.
| What | Where |
|---|---|
| Incident artifact script to inspect carefully | fullstory-bulk-delete-metadata-value/full_story_bulk_delete_metadata_values.py |
| CKP model | django-rest-api/contracts_v3/models.py |
| Resync task | django-rest-api/contracts_v3/search_v2/tasks.py |
| Resync fields constant | django-rest-api/contracts_v3/search_v2/domain/constants.py |
| Management command log query source | sd_admin_managementcommandlog |
| Key pointer history query source | historic_contracts_keypointer |
| Delete-key-pointer management command | django-rest-api/contracts_v3/management/commands/delete_key_pointers.py |
3007, Jira SPD-42475.3038, Jira SPD-42734.Salesforce Opportunity ID key pointer definition 101111 marked is_deleted=true, which is a useful adjacent check when values appear restored but the field is still missing in product surfaces.contract-lookup for fast contract / workspace verification before and after restore.