# Worked example: Acme Cloud (fictional)

Acme Cloud is a fictional B2B SaaS on HubSpot (portal 1234567, Marketing Hub Professional and Sales Hub Professional). The audit read production on 2026-11-01 through admin exports and the remote MCP server, as a Super Admin. The user set every `manage_*` tool and `submit_feedback` to deny in the client before the first read. All names, IDs and numbers are invented.

Integrations the team listed:

- Stripe Data Sync: two-way contact mapping, HubSpot as the default app.
- EnrichCo: enrichment that may set the contact owner on update.
- WebinarCo: writes `newsletter_opt_in` = true for every attendee.
- Partner form connector: a no-code tool that creates companies matched on name, running on the RevOps lead's own login.
- Warehouse product sync: reverse ETL of product fields onto companies keyed on `acme_account_id`; clears fields when rows are missing; the warehouse joins on HubSpot record IDs.
- ops-sync: a legacy private app created by a former contractor (left 2025-11; every private app token was rotated on 2025-11-30) that sets Customer from billing.

The audit also found a Clay enrichment app on a second legacy private app that nobody listed.

## Files

- [../assets/example-acme-inventory.json](../assets/example-acme-inventory.json): the inventory the agent filled.
- [../assets/example-acme-config.json](../assets/example-acme-config.json): nine critical fields, populations, the shared domain example.org, one override with its reason.
- [../assets/example-acme-hubspot-properties.csv](../assets/example-acme-hubspot-properties.csv): the property export for contacts, companies and deals.
- [../assets/example-acme-hubspot-flows.json](../assets/example-acme-hubspot-flows.json): eight workflows in the v4 flow shape.
- [../assets/example-acme-owner-history.csv](../assets/example-acme-owner-history.csv): `hubspot_owner_id` history in the generic format.

## Commands and their summary lines

Run from a private working folder outside the skill folder and outside version control, with `SKILL_DIR` set to the skill's folder. Working files and the report land in `audit-work/` there:

```bash
python3 -I "$SKILL_DIR/scripts/normalize_exports.py" hubspot-properties "$SKILL_DIR/assets/example-acme-hubspot-properties.csv" \
  --out audit-work/fields.json
python3 -I "$SKILL_DIR/scripts/normalize_exports.py" hubspot-flows "$SKILL_DIR/assets/example-acme-hubspot-flows.json" \
  --out audit-work/automations.json
python3 -I "$SKILL_DIR/scripts/writer_history.py" "$SKILL_DIR/assets/example-acme-owner-history.csv" --format generic \
  --window-days 30 --as-of 2026-11-01 --out audit-work/history.json
python3 -I "$SKILL_DIR/scripts/audit_crm.py" "$SKILL_DIR/assets/example-acme-inventory.json" --fields audit-work/fields.json \
  --automations audit-work/automations.json --history audit-work/history.json \
  --config "$SKILL_DIR/assets/example-acme-config.json" --as-of 2026-11-01 --md audit-work/report.md
```

```text
hubspot-properties: 42 fields written to audit-work/fields.json. Definitions only; no record values were read.
hubspot-flows: 8 automations written to audit-work/automations.json. Definitions only; no record values were read.
  flip-flop records: 4 (33.3% of records changed): 3 between automated writers, 1 reversing a person's change; samples: 1001, 1002, 1003, 1004
  FLD-02 would read: FAIL (4 flip-flop records; fail at 3 or more)
26 FAIL, 21 WARN, 8 PASS, 1 NOT CHECKED, 0 N/A (56 rules). Nothing was changed.
```

`audit_crm.py` exits with code 1 because FAILs exist. The history file holds the edge cases the analyzer must get right: records 1001 to 1003 flip between `workflow:inbound-rotation` and `integration:enrichco` (flip-flops between automated writers); record 1004 is rotation, then a human correction, then EnrichCo reverting it (an automated writer reversing a person's change, which alone would be a WARN); record 1005 changes three times by the same writer and is not a flip-flop; record 1006 repeats a value 45 days apart, outside the 30-day window, and is not counted.

`audit-work/report.md` is a skeleton. It prints each finding with the evidence item filed for its rule (EV-01 under FLD-02, EV-02 under FLD-03, EV-03 under REP-02) and "unconfirmed" for every other FAIL and gate WARN. The agent attaches more evidence or leaves the label, fills every `<agent` placeholder (owner roles for WARN decisions, the evidence that would close each NOT CHECKED rule, martech-context files read, the change list path), and re-runs the last command after any edit to `manual_results`, `evidence`, `decisions` or `gate_owners`.

## Scorecard and verdicts from the run

- All eight domains score Failing (OBJ 35, FLD 15, PIPE 35, LIFE 40, AUTO 30, ACC 15 partial at 80% coverage, INT 20, REP 40). Overall 29.
- Automation readiness: Fix gates first (FAIL: OBJ-02, FLD-02, FLD-03, LIFE-03, AUTO-03, AUTO-06, ACC-01, ACC-02, INT-01).
- Reporting readiness: Fix gates first (FAIL: REP-02, REP-03).
- ACC-05 is NOT CHECKED: deal View access per permission set was not provided, and Professional has no property-level restriction or Sensitive Data (both need Enterprise). Anyone who can view a deal can read `annual_contract_value` and `discount_percent`, so the report asks the admin for each permission set's deal View access (All deals, Their team's deals, Their deals) and rates exposure from that.

## What the agent does with the findings

- FLD-03 FAIL (gate): WebinarCo writes `newsletter_opt_in` and cleared opt-outs on 37 contacts. Evidence EV-02 (the mapping capture). First change request (CR-1); the privacy owner approves. The field's subscription history was not exported, so FLD-03 also lists it as a NOT CHECKED part with the history to collect.
- AUTO-06 FAIL (gate): "Bounce cleanup" uses Delete contact with no decision. CR-2 replaces deletion with a suppression list; turning the workflow off does not restore the contacts already deleted.
- ACC-01 FAIL (gate): agency-01 and shared-01 hold Super Admin; nine Super Admins of 31 active users is also over the limit (the larger of 3 and 10% of 31). CR-3 removes privilege from those two after recording the before state, because permissions are not versioned.
- FLD-02 FAIL (gate), INT-06 FAIL, INT-07 FAIL, AUTO-09 FAIL: one root cause, rotation and EnrichCo both writing owners. One request, CR-4, with the flip-flop records restored from history. FLD-02 also lists seven critical fields with no history export, each NOT CHECKED until its history arrives.
- OBJ-03 FAIL then OBJ-02 FAIL (gate): auto company association is on while companies are keyed on `acme_account_id`, and the partner connector creates companies by name. CR-5 turns auto-association off before CR-6 rekeys the connector, so new duplicates stop before the rekey.
- ACC-02 FAIL (gate): the connector runs as the RevOps lead's own user. Moved to a dedicated identity in CR-6.
- INT-01 FAIL (gate): the Clay app writes contacts and companies, is missing from `stack.md` and has no owner. The question goes to the marketing ops manager: keep it with an owner, or revoke it. INT-05 stays WARN, not FAIL: both private apps the former contractor created had their tokens rotated after the contractor left, so `credential_tied_to_departed_person` is false, with the rotation date written into `identity`.
- LIFE-03 FAIL (gate) and LIFE-04 FAIL: eight automated lifecycle writers and no precedence rule; ops-sync sets Customer without the documented evidence; the public "Pricing guide" form sets Customer. LIFE-06 shows the result: 14% of Customer contacts lack a closed-won deal or billing ID. The form is fixed first; the cohort it created is corrected by form and date window.
- AUTO-03 FAIL (gate): "MQL handoff" sets lead status NEW while "Sales accepted" sets IN_PROGRESS on overlapping populations. The fix makes their enrolment criteria exclusive, recorded in `mutually_exclusive_with`.
- REP-02 FAIL and REP-03 FAIL (gates): the pipeline tile shows 1,420,000 against 1,180,000 over the record list (20.3%), and the report sums deal amount after a contact join. The report is rebuilt at deal grain; nobody edits the filter to make the numbers meet.
- PIPE-03 FAIL: "Contract signed" is a second won stage (100%) beside "Closed won" in both "New business" and its identical copy "EMEA new business" (PIPE-01 WARN). Deals count as won at signature, take their close date then and fire the deal-won lifecycle setting early, while reports filtered on "Closed won" miss them. The fix sets "Contract signed" below 100% or merges it into "Closed won", and the reporting owner hears which won counts and close dates move.
- AUTO-04 FAIL and AUTO-08 WARN: "APAC routing" filters on region `apac`, an option removed in a cleanup, and has never enrolled anyone. FLD-07 FAIL on the same field: options 'EMEA' and 'emea '.
- FLD-04 FAIL: `plan` (written by ops-sync and the warehouse sync) and `current_plan` (Stripe) are both written and used; `stripe_plan` is a third copy.
- WARN gates: REP-01 ("MQLs" lacks a timezone and exclusions). It needs an evidence item now, and an owner and date in the change list, copied to `gate_owners`, before the reporting verdict can reach ready once REP-02 and REP-03 are fixed.

The first change requests in full: [change-list.md](change-list.md).

## Salesforce vignette: Example Co full sandbox

Files: [../assets/example-salesforce-describe.json](../assets/example-salesforce-describe.json), [../assets/example-salesforce-fielddefinition-opportunity.csv](../assets/example-salesforce-fielddefinition-opportunity.csv), [../assets/example-salesforce-flows.csv](../assets/example-salesforce-flows.csv), [../assets/example-salesforce-field-history.csv](../assets/example-salesforce-field-history.csv), [../assets/example-salesforce-writer-map.csv](../assets/example-salesforce-writer-map.csv). The flows file has the columns of the `FlowDefinitionView` starter, `TriggerOrder` and `Description` included.

```bash
python3 -I "$SKILL_DIR/scripts/normalize_exports.py" salesforce-describe "$SKILL_DIR/assets/example-salesforce-describe.json" \
  --descriptions "$SKILL_DIR/assets/example-salesforce-fielddefinition-opportunity.csv" --out audit-work/sf-fields.json
python3 -I "$SKILL_DIR/scripts/normalize_exports.py" salesforce-flows "$SKILL_DIR/assets/example-salesforce-flows.csv" \
  --out audit-work/sf-automations.json
python3 -I "$SKILL_DIR/scripts/writer_history.py" "$SKILL_DIR/assets/example-salesforce-field-history.csv" --format salesforce \
  --object accounts --writer-map "$SKILL_DIR/assets/example-salesforce-writer-map.csv" --as-of 2026-11-01
```

- From the files alone: FLD-07 FAIL (Opportunity Type has 'New Business' and 'new business'), FLD-07 WARN (`Renewal_Date__c` stored as text), FLD-06 WARN (3 of 5 custom fields have no description), AUTO-01 WARN (2 of 4 active flows have no description), AUTO-09 WARN (Process Builder "Account Type Sync" still active; two before-save flows on Opportunity with no Trigger Order), AUTO-08 WARN (a flow off for four years). Write-based AUTO rules and LIFE-05 are NOT CHECKED and name all four active flows, because FlowDefinitionView carries no field writes.
- The history run shows flip-flops on 2 of 3 accounts, each the middleware reversing a sales rep's change, so FLD-02 reads WARN from history alone (`2 reversing a person's change`). Run without `--writer-map`, the same file reads FAIL, because unmapped user IDs count as automated until the map names them. Process Builder's running user is the person whose edit launched it, so history shows that person and not the process. The agent reads the metadata of both active Account flows with the Tooling API `Flow` query, finds "Account Type Sync" and "Account Type From Billing" both writing `Account.Type`, adds those writes to the inventory with `writes_known`, `actions_known` and `refs_known` true, and AUTO-09 becomes FAIL. The two Opportunity flows are still unread, so LIFE-05, AUTO-03 to AUTO-07 and AUTO-10 stay NOT CHECKED and name them until their metadata is added too.
- An Owner history can carry two rows per change, one with user IDs (`DataType` EntityId) and one with names (Text); Salesforce does not document this, so check one owner change. `writer_history.py` keeps the ID rows, so each change counts once and an integration, middleware, integration sequence still reads as a flip-flop (fixture cases H01 and H02).
- What the agent adds from Setup reads: an OpportunityStage "Closed - Paid" that is closed and won but mapped to Best Case (PIPE-03 FAIL); the middleware authenticating as a named System Administrator rather than an API-only integration user (ACC-02 FAIL); `DailyApiRequests` at 78% during a backfill (INT-04 WARN); Opportunity at Public Read/Write with amounts restricted to sales leadership (ACC-05 FAIL through `record_access`); and an "Accounts with Opportunities" report used to count accounts, which drops accounts without opportunities (REP-03 FAIL).

## Attio vignette: Acme Labs workspace

Files: [../assets/example-attio-attributes.json](../assets/example-attio-attributes.json), [../assets/example-attio-options.json](../assets/example-attio-options.json), [../assets/example-attio-history.json](../assets/example-attio-history.json).

```bash
python3 -I "$SKILL_DIR/scripts/normalize_exports.py" attio-attributes "$SKILL_DIR/assets/example-attio-attributes.json" \
  --object companies --options "$SKILL_DIR/assets/example-attio-options.json" --out audit-work/attio-fields.json
python3 -I "$SKILL_DIR/scripts/writer_history.py" "$SKILL_DIR/assets/example-attio-history.json" --format attio --as-of 2026-11-01
```

- From the files: FLD-07 FAIL (segment options 'SMB' and 'smb'), FLD-07 WARN (`renewal_date` as text), FLD-06 WARN (2 of 5 custom attributes undocumented). The archived attribute is dropped.
- The history run shows `relationship` on rec-0001 flipping between a workspace member's ["Customer", "Partner"] and the EnrichCo app's ["Enterprise"]: an upsert that sends one value to a multi-value attribute replaces the existing entries (INT-07 FAIL once the agent records EnrichCo's upsert mapping). FLD-02 reads WARN from history (one automated writer reversing a person's change), and the app is shown by its raw ID until a writer map, or an `app_id` on the integration, names it.
- What the agent adds: the warehouse keys on Attio record IDs while the team merges duplicates weekly, and merges create new IDs (OBJ-06 WARN, FAIL if sync errors followed); mailbox sync creates a company per non-public domain while companies come from the warehouse (OBJ-03 WARN); the workspace is on the Free plan and holds contract values marked restricted (ACC-05 FAIL).

## HubSpot's wide history export

[../assets/example-hubspot-owner-history-wide.csv](../assets/example-hubspot-owner-history-wide.csv) has the layout of Export property history: one row per record, then a group per revision, newest first. Its repeated headers are placeholders, because HubSpot documents only Record ID.

```bash
python3 -I "$SKILL_DIR/scripts/writer_history.py" "$SKILL_DIR/assets/example-hubspot-owner-history-wide.csv" --format hubspot \
  --object contacts --field hubspot_owner_id --group value,time,source --list-sources audit-work/sources.csv
python3 -I "$SKILL_DIR/scripts/writer_history.py" "$SKILL_DIR/assets/example-hubspot-owner-history-wide.csv" --format hubspot \
  --object contacts --field hubspot_owner_id --group value,time,source --as-of 2026-11-01
```

The first run writes 3 distinct sources for the user to label. The second, with no writer map yet, shows them as hashes that count as automated: 3 records changed, 2 flip-flops between automated writers. Once the user's labels name the workflow, EnrichCo and the rep, one flip-flop stays between automated writers and the other reverses a person's change.

## Regression fixture

[../assets/regression-fixture.json](../assets/regression-fixture.json) holds 67 engine cases, one small inventory each (a case can extend an earlier one, or run raw HubSpot flow JSON through the normalizer first), and 11 history cases for the decisions the scripts must get right. After any change to the scripts, run:

```bash
python3 -I "$SKILL_DIR/scripts/check_fixture.py" "$SKILL_DIR/assets/regression-fixture.json"
```

Expected: `78 cases (67 engine, 11 history), all as expected.` and exit code 0. The cases:

- Fields (R01 to R05): options 'EMEA' and 'emea ' (FLD-07 FAIL); `plan` and `current_plan` both written and used (FLD-04 FAIL); 0% fill with 3 usages and 0% fill created 30 days ago (FLD-05 PASS for both); `renewal_date` stored as text (FLD-07 WARN).
- Pipelines (R06 to R08, R34, R35): a Salesforce open stage at 100% and a Salesforce lost stage at 20% (PIPE-03 FAIL); decreasing open probabilities (PIPE-03 WARN); a HubSpot pipeline with two 100% stages (PIPE-03 FAIL, "2 won stages"), including one recorded as open, which is read as won and noted.
- Automation (R09 to R14, R30, R33, R38): conflicting lead status writers (AUTO-03 FAIL) and the same pair with one disabled (PASS); a filter on a removed option (AUTO-04 FAIL); a re-enrolment loop (AUTO-05 FAIL); Delete contact without a decision (AUTO-06 FAIL) and with a cited decision but no test (WARN); flows whose writes are unknown (AUTO-03 NOT CHECKED); a workflow and an integration writing a same-named field on different objects (AUTO-09 PASS); an unclassified app action, named in the notes, with AUTO-06 passing only "among classified actions".
- Identity and access (R15 to R19, R37, R41, R44): company creation matched on domain (OBJ-02 FAIL) and on a unique key (PASS); a privileged agency account (ACC-01 FAIL); four privileged of 50 active users (PASS); an integration on a person's login (ACC-02 FAIL); a departed person whose account is still active owning open deals (ACC-03 FAIL); opportunities readable by everyone through a Public Read/Write default (ACC-05 FAIL); an integration with no identity type recorded (ACC-02 NOT CHECKED).
- Capacity and reporting (R20 to R24): Salesforce API at 0.69, 0.70 and 0.90 (INT-04 PASS, WARN, FAIL); reconciliation at 0.9% and 1.1% (REP-02 PASS, FAIL).
- Writers and evidence (R25 to R29, R31, R32, R36, R39, R40, R42, R43): a zero denominator (NOT CHECKED); two agreeing writers with no precedence rule (FLD-02 FAIL); flip-flops on 2 of 400 records (FLD-02 WARN); a gate NOT CHECKED (Not assessable); a downgrade without a decision reference (rejected, FAIL stands); an automated writer reversing a person's change on 2 of 3 records (WARN) against one flip-flop between automated writers among 3 records (FAIL); owner and consent fields with no history (FLD-02 and FLD-03 NOT CHECKED, automation verdict Not assessable while every other automation gate passes); a workflow named by ID in history matched to its inventory label (FLD-02 PASS, one writer); writers from a generic field export (FLD-02 FAIL); history tracking off (FLD-02 NOT CHECKED); a template hint left in `identity_type` (the run stops with exit code 2).
- Readiness with NOT CHECKED parts (R45 to R50, R57): a portal where every automation gate passes with history for both critical fields (Ready to automate), and from it, each kept at Not assessable: an owned FLD-02 WARN on a field without history; one flow whose writes and actions are unknown beside one that was read (AUTO-03, AUTO-06 and LIFE-05 NOT CHECKED, naming it); an unclassified action beside an AUTO-06 PASS among classified actions; workflow summaries from the v4 list endpoint, run through the normalizer (AUTO-03 and AUTO-06 NOT CHECKED); hand-entered Pipedrive automations with nothing recorded (AUTO-03 and AUTO-06 NOT CHECKED); an owned FLD-03 WARN on a consent field with no register entry and no history.
- Decisions, reconciliation and actions (R51 to R56): Delete contact under a decision reference that matches no recorded decision (AUTO-06 FAIL); a downgrade citing an unrecorded decision (rejected, FAIL stands) and one citing a dated decision (accepted, WARN); a headline number with no record list value (REP-02 WARN, "reconciliation not run"); a rotate-owner action with no overwrite key (AUTO-07 notes it as unconfirmed); a workflow that sends SMS, listed as unclassified with the action named.
- Real HubSpot flow shapes (R58 to R63, R65, R66): an event trigger on `hs_form_id`, HubSpot's own example (AUTO-04 PASS, with `0-13` neutral); an association branch on a deal property (AUTO-04 PASS); Edit record on an associated record (a writer in FLD-02, no broken reference); Create record's properties (a second automated writer of deal stage, FLD-02 FAIL); a custom code action with no `actionTypeId` (unclassified, never dropped); re-enrolment on a property change event (AUTO-05 NOT CHECKED); an empty static value on lifecycle (LIFE-05 FAIL); a flow on a custom object with no fields data (AUTO-04 PASS with a note).
- Lifecycle lines shared with other skills (R64, R67): a form that sets SQL (LIFE-04 WARN); customers without evidence at 6% (LIFE-06 FAIL above 5%, as in L01).
- History (H01 to H11): a Salesforce Owner history with an ID row and a name row per change, with and without the `DataType` column (FAIL, 3 changes, not 6); the Acme owner history (4 flip-flops, 3 between automated writers); the Salesforce vignette with and without the writer map (WARN, FAIL); the Attio vignette (WARN); HubSpot's wide export with repeated headers, with and without a writer map (every revision read; FAIL with one flip-flop between automated writers and one reversing a person's change, or two between automated writers while sources stay hashed); and the runs that must stop: a wide file without `--group`, a wide file with no source column, a history with no writer on any row.
