- name
- platform-datamask-run
- description
- Data Mask end-to-end operation on a sandbox: configure a masking policy over PII, run the masking job, poll it to completion, report masked-record results, and abort an in-progress run. Use when the user needs to run, monitor, or cancel a Salesforce Data Mask job, mask PII/sandbox data, or work with DataMaskPolicy / DataMaskPolicyJobRun. TRIGGER when: user runs a data mask job, masks sandbox PII, polls masking status, reports masked records, or aborts a running mask. DO NOT TRIGGER when: writing anonymization Apex by hand (use platform-apex-generate), generating test data (use platform-data-manage), or deploying unrelated metadata (use platform-metadata-deploy).
- metadata
- {"version":"1.0","domains":["Platform"],"minApiVersion":"67.0","relatedSkills":["platform-apex-generate","platform-data-manage","platform-metadata-deploy"],"cliTools":[{"tool":"[Truncated]","semver":">=3.10.0"},{"tool":"[Truncated]","semver":">=2.0.0"}],"accessCheck":"(UserPermissions.PermissionsManageDataMaskPolicies) && (UserPermissions.PermissionsAccessDataMaskAndSeed)"}
# platform-datamask-run: Salesforce Data Mask End-to-End Operation
Use this skill to **operate** the Salesforce Data Mask feature on a **sandbox**: configure a
masking policy over PII fields, start a masking job, poll it to a terminal state, report which
records were masked, and abort a run that is still in progress.
Data Mask is **sandbox-only** — the run/abort REST endpoints return `403` on production (a runtime
sandbox guard). Confirm the target org is a sandbox before starting.
## When This Skill Owns the Task
- Running a Data Mask job against a configured policy
- Polling masking-job status to completion
- Reporting masked-record counts / per-object results
- Aborting (canceling) an in-progress masking run
- Creating or identifying the policy the job runs against
Delegate elsewhere when the user is:
- hand-writing anonymization Apex → `platform-apex-generate`
- seeding or generating test data → `platform-data-manage`
- deploying unrelated metadata → `platform-metadata-deploy`
---
## The One Thing to Get Right First: the API surface map
The single biggest failure mode is assuming Data Mask entities are ordinary data-API objects.
**They are not, and the surface differs per entity.** Memorize this table before running anything —
guessing here is what turns a 3-second job into a 30-minute dead end.
| Entity | What it is | How you reach it |
|--------|-----------|------------------|
| `DataMaskPolicy` | The masking policy shell (config) | **Tooling API** or **Metadata API** (thin shell: `<label>`/`<description>`/`<runOnRefresh>` only) — NOT standard SOQL/`sobject describe` |
| `DataMaskPolicyObject` | An object targeted by a policy (holds the optional row filter) | **Tooling API only** — query AND insert; row-subset "sample" runs set `FilterEnabled`+`WhereCriteria` here (no `sampleSize` on the policy) |
| `DataMaskPolicyField` | A field + its masking treatment | **Tooling API only** — query AND insert; treatment cols are `MaskingCategory` + `MaskValue` |
| `DataMaskPolicyJobRun` | The **job** (one masking run) | **Standard SOQL** — `sf data query` works |
| `DataMaskPolicyJobRunDtl` | Per-object **job detail** (child, FK `DataMaskPolicyJobRunId`) | **Standard SOQL** |
| Start a run | — | **REST run API** `POST /services/data/v67.0/platform/data-resilience/data-mask/policies/{policyId}/run` |
| Abort a run | — | **REST run API** `POST /services/data/v67.0/platform/data-resilience/data-mask/jobs/{jobRunId}/abort` |
Concretely:
- `sf sobject describe --sobject DataMaskPolicy` → **`NOT_FOUND`** (don't retry it against standard API)
- `SELECT ... FROM DataMaskPolicy` via `sf data query` → **`INVALID_TYPE`**
- Query the **policy** via Tooling: `sf data query --use-tooling-api --query "SELECT Id, MasterLabel FROM DataMaskPolicy"`
- Query the **job / job-detail** via standard API: `sf data query --query "SELECT Id, Status FROM DataMaskPolicyJobRun"`
Full command reference: `references/api-surface.md`.
---
## Pick the workflow that matches the request
This skill has **two distinct workflows**. Select ONE up front from what the user asked for, then
run **every** step of that workflow — neither has optional steps:
| The user wants to… | Run | Ends when |
|--------------------|-----|-----------|
| Configure/edit a policy and **mask** records; report how many were masked | **Workflow A — Mask & report** (below) | The masked count is reported from the detail rows |
| **Cancel / abort** a masking run | **Workflow B — Cancel a run** (further below) | The job's status is confirmed `canceled` |
Choose by the verb in the request. "Create/edit a policy and run it", "mask the PII", "how many
records were masked" → **Workflow A only**. "Abort", "cancel", "stop the run" → **Workflow B**. A
mask-and-report request does **not** include an abort: do not start a second job to "demonstrate"
cancelling — an unrequested run wastes a full ~5–10 min job (see the pool floor in A4) and is the top
cause of this task running out of turn before it finishes the masked count it *was* asked for.
---
## Workflow A — Mask & report
### A1. Confirm sandbox + capture org context
Verify the org is a sandbox and grab the instance URL + a session token for the run-API calls:
```bash
sf org display --target-org <alias> --json
```
### A2. Identify or create the policy
Prefer reusing an existing policy (fastest, no deploy):
```bash
sf data query --use-tooling-api --target-org <alias> \
--query "SELECT Id, DeveloperName, MasterLabel FROM DataMaskPolicy"
```
If none targets the object and fields you need, choose the fields first (below), then author one.
**Choose the fields from Core's classification, not from field names alone.** Before inserting any
`DataMaskPolicyField` row, call the field-classification operation once per target object and map each
`recommendedPiiType` to a treatment. The steps are in `references/policy-authoring.md` ("Choosing the
fields from Core's classification"). Any recommendation with no verified `MaskValue` token is reported
as unmapped; never guess a `MaskValue`. Use the static table only if the call fails, and say so in the
report.
Then author one with the **two-step** recipe (the
`DataMaskPolicy` Metadata shape is a thin shell; membership is Tooling-inserted):
1. **Metadata-deploy the thin shell** in **mdapi format** (`--metadata-dir` + `package.xml`; a
source-format `--source-dir` deploy fails "Could not infer a metadata type"). The shell carries
only `<label>`, `<description>`, `<runOnRefresh>`. This creates the policy *with an active
revision*, which the Tooling inserts in the next step require.
2. **Tooling-insert** the `DataMaskPolicyObject` (one per object) then its `DataMaskPolicyField`
rows. Each field row's treatment is `MaskingCategory` + `MaskValue`. For `library`, `MaskValue` is a
snake_case token like `first_name`, `email`, `phone`. For `characters` and `replaceRandom`, `MaskValue`
is the field's Salesforce type (`Text`, `Date`). There is **no `MaskingRuleType` column**.
> Insert order matters: a Tooling-created parent (no active revision) makes the child insert fail
> `INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY`. Metadata-deploy the shell first.
See `references/policy-authoring.md` for the full recipe and the `MaskValue` token table. Choose a
`MaskValue` appropriate to each field; do **not** blanket-replace.
### A3. Start the masking run (REST run API)
```bash
printf '{}' > ./empty-body.json
sf api request rest \
"/services/data/v67.0/platform/data-resilience/data-mask/policies/{policyId}/run" \
--method POST --body @./empty-body.json --target-org <alias>
```
The endpoint needs an **empty JSON body** (`{}`) — `sf api request rest` requires `--body` on a POST
even when the API takes no payload. **Pass the file with an `@` prefix** (`--body @./empty-body.json`);
without it the literal path is sent as the body and the API returns `JSON_PARSER_ERROR`. A `200` returns `jobRunId`, `policyId`, `status` (the run-API
status is UPPERCASE, e.g. `RUNNING`) and `message: "Job started successfully"`. A `409`/`CONFLICT`
means a run is already in progress for that policy.
> **Write `report.md` NOW, before you poll — do not wait until the end.** The masking job takes
> several minutes (see below), and the single most common way this task scores zero is the turn
> ending during the poll with **no output file written at all**. The instant you have the `jobRunId`,
> write `report.md` with everything known so far (policy Id/label, the run command, the `jobRunId`,
> status `RUNNING`, and a "polling for completion…" placeholder for the masked count). Then **update
> that same file** once the job finishes. A report that exists and says "still running" beats no file;
> a fabricated count is worse than either — only fill the count from the detail rows (A5).
### A4. Poll to a terminal state (standard SOQL)
Poll `DataMaskPolicyJobRun.Status` until it reaches a **terminal** value. Do **not** report a
mid-run status as final.
- Mid-run (pre-work): `pending`, `scheduled` — the job is queued but **not yet abortable**
- Mid-run (working): `running` — this is the **only** state in which abort succeeds
- Terminal: `completed`, `completed_with_errors`, `failed`
- Abort target: `canceled` (single "l")
**`pending` is not `running`.** Abort on a `pending`/`scheduled` job returns `409 CONFLICT`
("Job is not in a running state ... status=PENDING"). You must poll until the status is literally
`running` before you can abort — see Workflow B.
**Jobs are slow — expect several minutes, and poll with the bundled script.** Data Mask runs on a
backend pool/scheduler with a **~5–10 minute floor**: even a tiny (20-row) job usually does **not**
reach a terminal state or emit detail rows for several minutes. This is fixed overhead, **not**
proportional to row count. The biggest failure mode is treating the job as instant, polling tightly,
and either timing out or writing a "still pending" report.
Run `scripts/poll-job.sh` as a **single command** — do **not** hand-roll a SOQL poll loop:
```bash
bash scripts/poll-job.sh <alias> <jobRunId> # defaults: cap 600s (10 min), 20s interval
```
It sleeps on a low-frequency interval, prints the terminal status (`completed` /
`completed_with_errors` / `failed` / `canceled`) on stdout, and exits `0` (or `1` on timeout).
**Call it once and read its result — do not wrap it in your own retry loop**, and do not poll on a
sub-10s interval (it just burns tool calls against a job that cannot finish sooner).
**The job is done only when the parent `DataMaskPolicyJobRun.Status` is terminal.** A
`DataMaskPolicyJobRunDtl` row is not a completion signal: a `total_records_masked` row can already
exist, reading `0`, while the parent is still `running`, so a count read mid-run is wrong. Wait for
the poller to exit `0`, then read the detail rows (A5) and update the `report.md` stub with the
terminal status and the masked count.
### A5. Report results from the job DETAIL object
The parent job carries an overall status; **per-object masked counts live on the child**
`DataMaskPolicyJobRunDtl` (linked by `DataMaskPolicyJobRunId`). `DataMaskPolicyJobRunDtl` has **no
`Status` column** — the status lives in `Type`/`Subtype`, with the count itself in `Value` (a
string). Report a concrete count, not a fabricated one:
```bash
sf data query --target-org <alias> \
--query "SELECT ObjectReference, Type, Subtype, Value FROM DataMaskPolicyJobRunDtl WHERE DataMaskPolicyJobRunId = '<jobRunId>' ORDER BY Timestamp"
```
Expect one `status_update` row per `Subtype` (`loaded`, `completed`, `total_records_masked`) for
each target object; `total_records_masked`'s `Value` is the masked count.
**Report only what the rows literally show — do not overstate granularity.** The detail rows are
**object-level** status_update entries (`loaded`, `completed`, `total_records_masked` for the object,
e.g. Contact). They are **not** per-field rows. So state per-object success as an observed fact
("Contact: 27/27 records masked, 0 error rows"), but frame field-level success as an **inference**,
not a direct observation — say "no field-level error rows were returned, so no field is reported as
failed", **not** "all 5 fields succeeded" (the data does not carry a per-field success row to back
that claim). Overstating an inference as an observation is the most common factuality miss here.
---
## Workflow B — Cancel a run
Use this workflow when the request is to **abort/cancel** a masking run. It targets the run that is
**currently in progress** — aborting is an on-demand action against a live job; nobody starts a job
just to cancel it. Steps B1–B4 are all required.
### B1. Confirm sandbox + identify the run to cancel
Confirm the org is a sandbox (`sf org display`) and get the `jobRunId` of the run to abort — the one
the user is asking to cancel. **Capture its policy Id too** — you need it to start a replacement run
if the abort window is missed (B2 exit 3 / exit 1). If they just started it, use that policy id
directly; otherwise query for the active run:
```bash
sf data query --target-org <alias> \
--query "SELECT Id, Status, PolicyName FROM DataMaskPolicyJobRun ORDER BY CreatedDate DESC LIMIT 5"
```
`DataMaskPolicyJobRun` has **no `DataMaskPolicyId` lookup field** — the only link back to the policy
is the human-readable `PolicyName` (`MasterLabel`). Resolve the Id from it via the Tooling API:
```bash
sf data query --use-tooling-api --target-org <alias> \
--query "SELECT Id, DeveloperName FROM DataMaskPolicy WHERE MasterLabel = '<PolicyName>'"
```
That `Id` (`8dm` prefix) is the `<policyId>` A3 needs.
### B2. Wait for the job to be `running` (the only abortable state)
Poll with `POLL_MODE=running` (cap 900s, 15s interval). Full command, exit-code handling (0 / 3 / 1),
and the re-query-on-timeout steps are in `references/run-and-abort.md` section 5. A `pending` or
`scheduled` job `409`s on abort, so do not abort until the poller exits `0`.
If no run is in progress, start one with A3 and abort **that** live job. Never substitute an older,
already-terminal job to "show" a cancel.
### B3. Abort via the run API
Abort via the run API — **not** by DML/delete on the job record:
```bash
sf api request rest \
"/services/data/v67.0/platform/data-resilience/data-mask/jobs/{jobRunId}/abort" \
--method POST --body @./empty-body.json --target-org <alias>
```
Empty JSON body (`{}`) via the `@`-prefixed file, as above. A `200` returns `status: "CANCELED"`
(uppercase, from the run API) and `message: "Job abort requested"`. A `409` means the job was not in
a `running` state (usually still `pending`/`scheduled`) — return to B2 and resume polling.
### B4. Confirm and report the cancellation
Cancellation is asynchronous. **Re-query** `DataMaskPolicyJobRun` and confirm `Status = canceled`
(lowercase, from SOQL) before reporting the abort succeeded. Verify:
- [ ] Confirmed the abort targeted the live job while its queried status was `running`.
- [ ] Re-queried `DataMaskPolicyJobRun` after the abort and saw `Status = canceled`.
---
## High-Signal Rules
| Rule | Rationale |
|------|-----------|
| Run each `sf` command **bare** — never add a pipe or redirect of any kind (`\|`, `\| python3`, `\| grep`, `2>&1`, `2>/dev/null`, `> file`) | `sf ... --json` already prints clean JSON on stdout; read it directly. A redirect/pipe trips an unbypassable shell-safety guard that silently stalls the whole run to timeout. Never post-process with `python3`/`grep`/`jq`, and never suppress stderr — even if a command prints a warning, the `--json` payload on stdout is still valid; just parse it as-is |
| Abort only via the run-API abort endpoint | DML/delete on the job record is not a real abort and corrupts state |
| Use API version `v67.0` or later, and no `/connect/` segment | The run/abort endpoints are `/services/data/v67.0/platform/data-resilience/data-mask/...` — a `connect` segment or a pre-v67 version returns `NOT_FOUND` |
---
## Gotchas
| Issue | Resolution |
View on GitHub