Federal Modernization Workbook
Chapter 05 — The Re-read Rule
A success response is not closure. Neither is a ticket status, and neither is a comparison against nothing.
The kit’s Build 2, the loop’s step 5 and Book 13’s fourth clause: a finding closes only when a re-read of the target shows the intended state. The business rule enforces it inside ServiceNow; reread_verification.py does the re-reading. This chapter adds an offline mode that runs the client’s decisions over saved records and saved reads, so the rule can be taught and tested with no tenant, and a guard: an intended state that names nothing a re-read can check is skipped, because comparing against it always “matches.” Lab 1 runs on a laptop; Lab 2 closes one finding on a developer instance.
Jump to a slide (16)
Deck · Narrated deck (about 9 minutes, captions) · Script · Captions. Recorded on November 1, 2026 from the approved chapter.
Goal and audience
Goal. By the end of this chapter you can predict, for any waiting finding and any read of its subject, whether the client will verify it, reopen it, leave it pending or skip it, and show that no path closes a finding without a matching re-read.
Audience. Developers who own the re-read client or the business rule, ServiceNow administrators who configure the rule, and the ISSO, for whom this is the closure evidence. You should read Python and a little JavaScript. You don’t need a tenant until Lab 2.
The code: examples/servicenow-sailpoint
What exists
The implementation example’s Build 2 gives the design. In brief:
- The business rule (
servicenow/business-rule-await-verification.js, before update, on the state field) moves any close onu_identity_finding, and on tickets SailPoint’s Service Desk integration opened, to Awaiting verification, unless the same update carries a new evidence hash and comes from the verifier account. A named exception approver may still cancel with a close note: the declared-exception lane. This is the answer to Gap 1, where SailPoint’s Service Desk best practice treats the mapped ServiceNow status as completion.1 - The re-read client (
clients/python/reread_verification.py) lists waiting findings through the Table API, re-reads each subject by itsu_system(GET /v3/accounts/{id}, a Search for one identity, orGET /v1.0/users/{id}in Graph), compares withu_intended_state, and then closes the record with the evidence (query, time, a SHA-256 of the response, the intended state, the checker) and attaches the evidence as JSON, or reopens it with the differences. An ISC read reflects the last aggregation, so a read older than the time the finding started waiting is pending for a grace period, 24 hours by default (design guidance). It writes only with--apply.
Two things were missing. The client could run only against a live instance and tenant, so its decisions could be seen only in mocked unit tests. And it skipped a record whose intended state was empty, {}, but not one whose intended state was a non-empty object naming nothing: {"attributes": {}, "entitlements_absent": []} is what the script stores for a native change the workflow didn’t describe (Chapter 04), and comparing a read against it finds no difference. The client would have closed that record, with evidence, on nothing.
What this chapter adds
- The guard. Before reading anything, the client checks the intended state with
finding_contract.is_checkable(Chapter 01). A record that names nothing checkable is reported as skipped with the reason, and is never verified. - The offline mode.
--records-jsontakes saved waiting records in the Table API’s result shape;--observations-jsontakes saved reads keyed by subject ID, withnullfor a read that found nothing;--nowfixes the clock for the grace period. The sameverify_recordsmakes the same decisions; only the reader is swapped. It is a dry run only:--applyis refused, and a subject with no saved read is an error, because a missing read isn’t a match. - Six fictional waiting records in
tests/fixtures/reread/, one per outcome: a reverted native change that verifies; a terminated user still enabled in Entra ID (mismatch); an ISC account read before it was re-aggregated (pending, then mismatch after the grace period); an attribute change that verifies; a native change with an empty intended state (skipped); and an orphan account disabled (verified).
The tests
tests/python/test_reread_offline.py, 6 tests: the six outcomes, the grace period expiring, the guard never calling the reader, a missing saved read as an error, a null read as not found, and the refusals. The kit’s original nine re-read tests, and the business rule’s six node:test tests, pass unchanged.
The offline mode runs the client’s logic over saved data. It shows the decisions, not that ISC or Graph returns the same shapes, and not that the business rule recognizes the verifier on your release (see what couldn’t be verified). Run Lab 2 before relying on either.
Lab 1: fixtures, no tenant
Work in examples/servicenow-sailpoint, with Python 3.10 or later and Node.js 18 or later.
Every command in this lab, in order, from examples/servicenow-sailpoint. Each step below says what it does and what to look for.
# Step 1: Run the re-read tests
python -m pytest tests/python/test_reread_verification.py tests/python/test_reread_offline.py -q
# Step 2: Re-read six waiting findings at noon on November 1
python clients/python/reread_verification.py --records-json tests/fixtures/reread/awaiting_records.json --observations-json tests/fixtures/reread/observations.json --now 2026-11-01T12:00:00Z
# Step 3: Run the same records a day later
python clients/python/reread_verification.py --records-json tests/fixtures/reread/awaiting_records.json --observations-json tests/fixtures/reread/observations.json --now 2026-11-02T12:00:00Z --out-json out/reread.json
# Step 4: Run the business rule's tests
node --test --test-name-pattern="redirected|verifier|exception approver|evidence alone|non-closing|SDIM" tests/servicenow/servicenow_scripts.test.jsRun the re-read tests:
python -m pytest tests/python/test_reread_verification.py tests/python/test_reread_offline.py -qWhat the terminal shows, run on the kit’s fixtures (the same output as text) 15 passed.Re-read six waiting findings at noon on November 1:
python clients/python/reread_verification.py --records-json tests/fixtures/reread/awaiting_records.json --observations-json tests/fixtures/reread/observations.json --now 2026-11-01T12:00:00Z[dry-run] IDF0001001: verified; would PATCH state=3 [dry-run] IDF0001002: mismatch; would PATCH state=1 (accountEnabled: intended False, observed True) IDF0001003: pending, the ISC copy predates the change; re-run after the next aggregation [dry-run] IDF0001004: verified; would PATCH state=3 IDF0001005: skipped, the intended state names nothing a re-read can check [dry-run] IDF0001006: verified; would PATCH state=3 1 mismatch, 1 pending, 1 skipped, 3 verifiedWhat the terminal shows, run on the kit’s fixtures (the same output as text) IDF0001003’s account was last modified at 22:00 on October 31; the finding started waiting at 06:00 on November 1. The read predates the change, so it waits. IDF0001005 is the case the guard exists for.
Run the same records a day later:
python clients/python/reread_verification.py --records-json tests/fixtures/reread/awaiting_records.json --observations-json tests/fixtures/reread/observations.json --now 2026-11-02T12:00:00Z --out-json out/reread.jsonWhat the terminal shows, run on the kit’s fixtures (the same output as text) IDF0001003 is now
mismatch; would PATCH state=1 (disabled: intended True, observed False), and the last line is2 mismatch, 1 skipped, 3 verified. After the grace period a stale read is a mismatch, and the record reopens.out/reread.jsonholds each result with its evidence hash.Run the business rule’s tests:
node --test --test-name-pattern="redirected|verifier|exception approver|evidence alone|non-closing|SDIM" tests/servicenow/servicenow_scripts.test.jsWhat the terminal shows, run on the kit’s fixtures (the same output as text) tests 6,pass 6: a person’s close is redirected; the verifier with fresh evidence may close; evidence alone, or the verifier alone, isn’t enough; the exception approver may cancel with a note; other changes are untouched; onincident, only tickets the integration opened are governed.
Lab 2: a ServiceNow developer instance
Not run by the authors. Use the developer instance from Chapters 01 and 02, never production, and the verifier user from Chapter 02. Work in examples/servicenow-sailpoint. You’ll replace: <instance> (your developer instance’s name), <verifier-client-id> and <verifier-client-secret> (the verifier’s OAuth integration), <test-sys_id> (the sys_id of a test finding), and <graph-tenant-id>, <graph-client-id> and <graph-cert.pem> (a test app registration with User.Read.All, and the PEM file holding its certificate and key).
Every command in this lab, in order, from examples/servicenow-sailpoint. Each step below says what it does and what to look for.
# Step 3: Confirm who the verifier is
export SN_ACCESS_TOKEN=$(curl -s -X POST "https://<instance>.service-now.com/oauth_token.do" -d grant_type=client_credentials -d client_id="<verifier-client-id>" -d client_secret="<verifier-client-secret>" | python -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
curl -s -w "\nHTTP %{http_code}\n" -X PATCH -H "Authorization: Bearer $SN_ACCESS_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json" --data '{"work_notes": "lab: which user does ServiceNow record for the verifier?"}' "https://<instance>.service-now.com/api/now/table/u_identity_finding/<test-sys_id>"
# Step 4: Close it with evidence
curl -s -w "\nHTTP %{http_code}\n" -X PATCH -H "Authorization: Bearer $SN_ACCESS_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json" --data '{"u_intended_state": "{\"accountEnabled\": false}"}' "https://<instance>.service-now.com/api/now/table/u_identity_finding/<test-sys_id>"
# Step 4: Close it with evidence
unset SN_ACCESS_TOKEN
export SN_INSTANCE="<instance>"
export SN_CLIENT_ID="<verifier-client-id>"
export SN_CLIENT_SECRET="<verifier-client-secret>"
export GRAPH_TENANT_ID="<graph-tenant-id>"
export GRAPH_CLIENT_ID="<graph-client-id>"
export GRAPH_CERT_PATH="<graph-cert.pem>"
python clients/python/reread_verification.py --table u_identity_finding
# Step 4: Close it with evidence
python clients/python/reread_verification.py --apply --table u_identity_finding
# Step 5: Back it out
unset SN_INSTANCE SN_CLIENT_ID SN_CLIENT_SECRET GRAPH_TENANT_ID GRAPH_CLIENT_ID GRAPH_CERT_PATHConfigure the rule on
u_identity_findingas the kit’s README says (before, update, order 1000, conditioncurrent.state.changes(), scriptservicenow/business-rule-await-verification.js), and the system properties: the Awaiting verification value, the verifier’suser_name, the exception role.Close a finding as a person. Set a test finding’s state to Closed Complete as an ordinary user. Expect it to land in Awaiting verification, with a work note.
Confirm who the verifier is. As the verifier’s OAuth client, request a token and make one
PATCHto a test record’s work notes:export SN_ACCESS_TOKEN=$(curl -s -X POST "https://<instance>.service-now.com/oauth_token.do" -d grant_type=client_credentials -d client_id="<verifier-client-id>" -d client_secret="<verifier-client-secret>" | python -c "import json,sys; print(json.load(sys.stdin)['access_token'])") curl -s -w "\nHTTP %{http_code}\n" -X PATCH -H "Authorization: Bearer $SN_ACCESS_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json" --data '{"work_notes": "lab: which user does ServiceNow record for the verifier?"}' "https://<instance>.service-now.com/api/now/table/u_identity_finding/<test-sys_id>"Look at the record’s history for the user ServiceNow recorded. If it isn’t the
user_nameinx_example_idkit.verifier_user, the rule will refuse every close (verify list, item 10).Close it with evidence. Set the test finding’s
u_intended_stateto something a Graph read of a test user will show; this sets{"accountEnabled": false}, for a finding about a disabled test user:curl -s -w "\nHTTP %{http_code}\n" -X PATCH -H "Authorization: Bearer $SN_ACCESS_TOKEN" -H "Content-Type: application/json" -H "Accept: application/json" --data '{"u_intended_state": "{\"accountEnabled\": false}"}' "https://<instance>.service-now.com/api/now/table/u_identity_finding/<test-sys_id>"Run the client as the verifier, first as a dry run. Certificate authentication needs the optional
cryptographypackage.unset SN_ACCESS_TOKEN export SN_INSTANCE="<instance>" export SN_CLIENT_ID="<verifier-client-id>" export SN_CLIENT_SECRET="<verifier-client-secret>" export GRAPH_TENANT_ID="<graph-tenant-id>" export GRAPH_CLIENT_ID="<graph-client-id>" export GRAPH_CERT_PATH="<graph-cert.pem>" python clients/python/reread_verification.py --table u_identity_findingThen with
--apply:python clients/python/reread_verification.py --apply --table u_identity_findingExpect the record closed with
u_evidence,u_evidence_hash,u_verified_at, a close note and a JSON attachment.23Back it out. Delete the test findings and their attachments, deactivate the business rule if you built it for the lab, and delete the system properties. Clear the credentials from your shell:
unset SN_INSTANCE SN_CLIENT_ID SN_CLIENT_SECRET GRAPH_TENANT_ID GRAPH_CLIENT_ID GRAPH_CERT_PATH
The verified facts
| Item | Value | Source |
|---|---|---|
| Table API | GET with sysparm_query, sysparm_fields, sysparm_limit, sysparm_offset; PATCH on a record |
4 |
| Attachment API | POST /api/now/attachment/file with table_name, table_sys_id, file_name; Content-Type mandatory |
5 |
| Re-read of an account | GET /v3/accounts/{id}, idn:accounts:read |
6 |
| Re-read of an identity | POST /v3/search, identities index, sp:search:read |
7 |
| Re-read of an Entra user | GET /v1.0/users/{id}, User.Read.All (application); 404 Not Found when the user doesn’t exist |
8 |
| Service Desk integration status mapping | The mapped ServiceNow status decides when SailPoint treats the provisioning task as complete (Gap 1) | 9 |
What couldn’t be verified
- The identity
gs.getUserName()returns for an OAuth client-credentials call, which the rule compares with the verifier property. Verify list, item 10; Lab 2, step 3. - How long an ISC account read lags the source. It depends on the aggregation schedule; tune
--grace-hoursto it. Verify list, item 17. - How to recognize a ticket the Service Desk integration opened. The rule assumes
opened_by; a configuration may use a correlation field instead. Verify list, item 12.
Federal requirements and evidence
| What the output proves | Requirement it supports | KSI | Who keeps it |
|---|---|---|---|
| A finding closed only after a re-read showed the intended state, with the query, the time, a hash of the response and the checker | Book 13’s clause 4, “a success response is not closure”; NIST SP 800-53 CM-3 (documented, verified changes) and CA-7 at Moderate10 | KSI-CMT-LMC; KSI-MLA-EVC | u_evidence, u_evidence_hash and the attachment on each record; Chapter 07 exports them |
| No person can close a finding by setting its state; cancellation is a named exception with a note | AC-5 (separation of duties) at Moderate;11 Book 13’s declared-exception lane | KSI-IAM-JIT | The business rule and its properties; the work notes |
| A read that predates the change doesn’t count | CA-7: monitoring results that reflect the current state | KSI-MLA-EVC | The client’s pending outcome and grace period |
| An empty intended state never closes a record | CM-3: a verified change, not an asserted one | KSI-CMT-VTD | The guard and its test |
| Lab 1, step 1: the re-read decisions hold, online and offline, tested before a change merges | CM-3(2) (testing changes); SA-11 (developer testing), at Moderate12 | KSI-CMT-VTD; KSI-SCR-MIT | The pytest tests/python/test_reread_verification.py tests/python/test_reread_offline.py run, in the pull request’s checks |
| Lab 1, step 2: each waiting finding is verified, left pending or skipped, and nothing is written | CM-4(2) (verification of controls) at Moderate;13 CA-7 | KSI-CMT-VTD; KSI-MLA-EVC | The dry-run output of reread_verification.py --records-json |
| Lab 1, step 3: after the grace period a stale read is a mismatch and the record would reopen | CM-4(2); CA-7 | KSI-CMT-VTD; KSI-MLA-EVC | out/reread.json: each record’s outcome, differences and evidence hash. A live run’s --out-json file is kept with the run |
| Lab 1, step 4: a person’s close is redirected; only the verifier with fresh evidence closes | AC-5; CM-3(2) | KSI-IAM-JIT; KSI-CMT-VTD | The node --test --test-name-pattern run, in the pull request’s checks |
| Lab 2, steps 1 and 2: a person’s close lands in Awaiting verification, with a work note | AC-5; CM-3 | KSI-IAM-JIT; KSI-CMT-LMC | The test record’s history and work note |
| Lab 2, step 3: the user ServiceNow records for the verifier’s OAuth client | AU-12 (audit record generation) at Moderate;14 AC-5 | KSI-MLA-LET; KSI-IAM-JIT | The record’s history, kept with the x_example_idkit.verifier_user property |
Lab 2, step 4: reread_verification.py --apply closes the record only after a Graph read shows the intended state |
CM-3; CM-4(2); CA-7 | KSI-CMT-LMC; KSI-CMT-VTD; KSI-MLA-EVC | The record’s u_evidence, u_evidence_hash, u_verified_at and close note, and the attachment reread-evidence-<number>.json; a mismatch reopens the record with a work note |
| Lab 2, step 5: no test finding, attachment, rule, property or credential is left | None: lab clean-up | None | Nobody; the unset command clears the shell |
The KSI column names the FedRAMP 20x Key Security Indicators whose related SP 800-53 controls include the row’s control.15 KSIs are indicators for cloud service providers: the agency authorizes its own system and lines its own evidence up with what its providers report against them; it doesn’t report KSIs itself.
Dangers
| Danger | What happens | The guard |
|---|---|---|
| Closure by ticket status | The Service Desk integration, or a person, marks the work done with nothing checked | The business rule redirects every close without fresh evidence from the verifier |
| An intended state that names nothing | A re-read “matches” and closes the record on nothing | is_checkable; such records are skipped, with the reason |
| A stale ISC read | A finding closes on the state before the fix, or reopens on it | Pending inside the grace period, mismatch after it |
| A missing read treated as a match | A subject nobody read is closed | The offline reader raises on a missing subject; the live reader raises on anything but a 404 |
| The verifier account misconfigured | Every close is refused, and people look for a way around the rule | Lab 2, step 3, before go-live |
| Evidence edited after closure | The record no longer proves what it says | The evidence hash; Chapter 07 recomputes it |
Checklist
Footnotes
SailPoint, “SDIM: Best Practices” (status mapping decides when a ticket counts as complete), https://documentation.sailpoint.com/connectors/servicenow/sdim/help/integrating_servicenow_sdim/best_practices.html (read November 1, 2026).↩︎
ServiceNow, “Table API” (Zurich API reference), https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_TableAPI.html (read November 1, 2026).↩︎
ServiceNow, “Attachment API” (Zurich), https://www.servicenow.com/docs/r/MVRCUFVyKWd7vUoUukbW6g/lfpHTMPdNaq_xdajhuTwEg (read November 1, 2026).↩︎
ServiceNow, “Table API” (Zurich API reference), https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_TableAPI.html (read November 1, 2026).↩︎
ServiceNow, “Attachment API” (Zurich), https://www.servicenow.com/docs/r/MVRCUFVyKWd7vUoUukbW6g/lfpHTMPdNaq_xdajhuTwEg (read November 1, 2026).↩︎
SailPoint, Identity Security Cloud OpenAPI specification, v3,
getAccount, https://github.com/sailpoint-oss/api-specs/blob/main/idn/v3/paths/account.yaml (read November 1, 2026).↩︎SailPoint, Identity Security Cloud OpenAPI specification, v3,
searchPost, https://github.com/sailpoint-oss/api-specs/blob/main/idn/v3/paths/search-post.yaml (read November 1, 2026).↩︎Microsoft Learn, “Get a user,” permissions and response, https://learn.microsoft.com/graph/api/user-get?view=graph-rest-1.0 (read November 1, 2026).↩︎
SailPoint, “SDIM: Best Practices” (status mapping decides when a ticket counts as complete), https://documentation.sailpoint.com/connectors/servicenow/sdim/help/integrating_servicenow_sdim/best_practices.html (read November 1, 2026).↩︎
National Institute of Standards and Technology, SP 800-53B Moderate baseline, release 5.2.0 OSCAL profile (includes AC-5, AU-12, CA-7, CM-3, CM-3(2), CM-4(2) and SA-11), https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_MODERATE-baseline_profile.json (read November 1, 2026).↩︎
National Institute of Standards and Technology, SP 800-53B Moderate baseline, release 5.2.0 OSCAL profile (includes AC-5, AU-12, CA-7, CM-3, CM-3(2), CM-4(2) and SA-11), https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_MODERATE-baseline_profile.json (read November 1, 2026).↩︎
National Institute of Standards and Technology, SP 800-53B Moderate baseline, release 5.2.0 OSCAL profile (includes AC-5, AU-12, CA-7, CM-3, CM-3(2), CM-4(2) and SA-11), https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_MODERATE-baseline_profile.json (read November 1, 2026).↩︎
National Institute of Standards and Technology, SP 800-53B Moderate baseline, release 5.2.0 OSCAL profile (includes AC-5, AU-12, CA-7, CM-3, CM-3(2), CM-4(2) and SA-11), https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_MODERATE-baseline_profile.json (read November 1, 2026).↩︎
National Institute of Standards and Technology, SP 800-53B Moderate baseline, release 5.2.0 OSCAL profile (includes AC-5, AU-12, CA-7, CM-3, CM-3(2), CM-4(2) and SA-11), https://github.com/usnistgov/oscal-content/blob/main/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_MODERATE-baseline_profile.json (read November 1, 2026).↩︎
FedRAMP, “Key Security Indicators,” FedRAMP Consolidated Rules for 2026, indicator titles and “Related SP 800-53 Controls,” https://www.fedramp.gov/2026/reference/key-security-indicators/ (read November 2, 2026).↩︎



