Salesforce Org Audit
Run a comprehensive Salesforce org audit covering code quality, automation health, data model design, and the permission model.
Scoring: Where a numeric rubric exists, defer to the corresponding domain
skill (sf-apex, sf-flow, sf-lwc,
sf-metadata). Do not invent your own criteria.
For categories without a numeric rubric (Triggers, Workflow Rules, Process Builders, Profiles, Validation Rules, Formula Fields, Approval Processes, Escalation Rules, Assignment Rules, Auto-Response Rules), produce an inventory with qualitative findings and severity classifications.
Dispatch
Parse $ARGUMENTS to determine the audit scope:
| First argument or intent | Workflow |
|---|---|
full, no scope specified after asking |
Full Org Audit (all domains) |
apex |
Apex-only audit (C1-C2) |
flow |
Flow/automation-only audit (C3-C4) |
lwc |
LWC-only audit (C5) |
metadata, data-model |
Metadata/data-model-only audit (C7) |
permissions |
Permissions-only audit (C6) |
reports |
Reports & Dashboards only (C10) |
integrations |
Integration analysis only (C11) |
coverage |
Test coverage only (C12) |
licensing |
Licensing analysis only (C13) |
team |
Team evaluation only (C14) |
change-history |
Change history audit only (C15) |
data-quality |
Data quality report only (C7 + DQ) |
| (no argument or unclear) | Ask the user (see below) |
When the audit scope is missing or unclear, you MUST use AskUserQuestion before proceeding:
AskUserQuestion(question="What would you like to audit?\n\n1. **Full** — comprehensive audit of the entire org\n2. **Apex** — Apex classes and triggers only\n3. **Flow** — Flows, Process Builders, and Workflow Rules only\n4. **LWC** — Lightning Web Components only\n5. **Metadata** — custom objects, fields, and data model only\n6. **Permissions** — Profiles, Permission Sets, and Permission Set Groups only")
Do NOT guess the scope or default to a full audit. Wait for the user's answer.
Start here every time — read audit_state.md first
Before doing anything else, check whether a working document already exists:
Read: ./audit_output/audit_state.md
File exists — you are resuming a previous audit. Read the state, note what is complete, and pick up from the
## Next Stepsection. Tell the user: "Resuming audit from [last completed phase]. [X] of [Y] components processed."File does not exist — this is a fresh audit. Proceed to Prerequisites, then Environment Detection, then Phase A.
Keep audit_state.md up to date throughout. Update it after completing each
domain in Phase C. This file is your contract with your future self after
a context compaction.
Prerequisites
Call org_init() first if not already done this session (org_init is a convention — see the Tool-name mapping in references/execution-modes.md).
Standing monitoring
In Claude Desktop / Cowork, scoped audits can run on a schedule with delta reporting, and key metrics can feed a live dashboard artifact — see docs/monitoring-recipe.md at the repo root for the recipes and the headless rules that make them safe.
Execution modes
Determine execution mode once, before Phase A. Four modes are supported —
see references/execution-modes.md for detection logic and full details.
Audit-specific mode behaviour
| Mode | Body retrieval | Queries |
|---|---|---|
sfdx-repo |
Read from disk (no API calls) | MCP for live-only data |
cli |
sf project retrieve start -m |
sf data query --json |
mcp-plus-code-execution |
MCP tools; download artifactUrl |
MCP tools |
mcp-core |
MCP tools; fetch_more with cursor |
MCP tools |
sfdx-repo specifics:
- Read
.cls,.trigger,.flow-meta.xml, and LWC bundles from disk. - Still use MCP for live-only data: permission assignments, user counts, PSG status, active user queries.
- For incremental audits: use
git logto detect changed files (Phase A3).
cli specifics:
- Bulk retrieve via
sf project retrieve start -m <type>. - Queries via
sf data query -q "..." --target-org <org> --json. - For incremental audits: filter by
LastModifiedDatein queries.
mcp-plus-code-execution specifics:
- Bulk query first (e.g.
tooling_api_query: SELECT Id, Name, Body FROM ApexClass WHERE NamespacePrefix = null ORDER BY Id). - When the response includes
instructions.artifactUrl, download it and write the JSON to./audit_output/intermediate/for local processing. - Run
pre_score.pyon the downloaded files (Strategy A).
mcp-core specifics:
- Same bulk queries, but page through large responses with
fetch_more(artifactId=..., cursor=_pagination.nextCursor). - Process in batches of 5; discard bodies between batches (Strategy B).
In all modes, use MCP tools (soql_query, tooling_api_query,
sobject_describe) for targeted lookups when CLI is not needed.
Incremental audit detection
If the user mentions a previous audit, asks to "update" an audit, or provides a path to prior audit output, this is an incremental audit.
Locating the previous audit
Look for audit_state.md in one of:
- A user-provided path (e.g.
~/audits/2026-01/audit_output/) - A git repository the user specifies
- The default
./audit_output/directory (if it contains a completed audit)
From the previous audit_state.md, extract:
- Audit date — the timestamp of the last completed audit
- Component inventory — names and scores of all previously scored components
- Skipped components — what was excluded and why
Delta detection (per mode)
| Mode | How to find changed components |
|---|---|
sfdx-repo |
git log --after="<prev_date>" --name-only --diff-filter=ACMR -- force-app/ |
cli |
Add AND LastModifiedDate > <prev_date> to Tooling/SOQL queries |
mcp-plus-code-execution |
Add AND LastModifiedDate > <prev_date> to Tooling/SOQL queries |
mcp-core |
Add AND LastModifiedDate > <prev_date> to Tooling/SOQL queries |
Delta categories
Classify every component into one of:
| Category | Action |
|---|---|
| Changed | Re-score against current rubric |
| New | Score as new (not in previous audit) |
| Removed | Mark as removed in reports |
| Unchanged | Carry forward previous score — do not re-fetch or re-score |
Track these categories in audit_state.md and in the final reports.
Handling large MCP responses
See references/mcp-pagination.md for the full artifact and pagination
reference. Key points for audits:
mcp-plus-code-execution: downloadinstructions.artifactUrland write JSON to./audit_output/intermediate/for local processing withpre_score.py.mcp-core: page through withfetch_more(artifactId=..., cursor=_pagination.nextCursor). Process in batches of 5; discard bodies between batches.sfdx-repo/cli: bodies come from disk or CLI — artifact responses are uncommon.
Additional MCP constraints
- Flow single-row constraint — Tooling query on
FlowwithMetadatareturns only one row. Fetch Flow IDs first, then one row per ID. - Permission queries — PermissionSet/PSG datasets hit limits quickly. Use cursor windows and persist per-batch.
Phase A — Quick Pass (always runs first)
The Quick Pass is a lightweight inventory. It fetches only metadata headers — no source bodies — so it completes quickly even for large orgs.
Goals:
- Count every component type across the whole org
- Classify each component: local (NamespacePrefix = null) vs managed package (NamespacePrefix != null)
- Flag known generated / unmodifiable classes (see skip list below)
- Collect surface quality signals: API versions, class sizes
- If incremental: detect the delta (changed components since last audit)
- Estimate the cost of the Deep Dive so the user can make an informed decision
A1 — Component counts
cli / MCP modes — query counts:
tooling_api_query: SELECT COUNT(Id) total FROM ApexClass WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM ApexClass WHERE NamespacePrefix != null
tooling_api_query: SELECT COUNT(Id) total FROM ApexTrigger WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM FlowDefinition WHERE ActiveVersionId != null AND NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM LightningComponentBundle WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM CustomObject WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM ValidationRule WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM WorkflowRule WHERE NamespacePrefix = null
tooling_api_query: SELECT COUNT(Id) total FROM CustomField WHERE NamespacePrefix = null AND Formula != null
metadata_list: type=ApprovalProcess
soql_query: SELECT COUNT(Id) total FROM PermissionSet WHERE IsOwnedByProfile = false AND NamespacePrefix = null AND Type != 'Group'
soql_query: SELECT COUNT(Id) total FROM PermissionSetGroup
soql_query: SELECT COUNT(Id) total FROM Profile
soql_query: SELECT COUNT(Id) total FROM Report
soql_query: SELECT COUNT(Id) total FROM Dashboard
soql_query: SELECT COUNT(Id) total FROM ConnectedApplication
soql_query: SELECT COUNT(Id) total FROM NamedCredential WHERE NamespacePrefix = null
soql_query: SELECT COUNT(Id) total FROM UserLicense
sfdx-repo mode — count files on disk:
find force-app/main/default/classes -name "*.cls" | wc -l
find force-app/main/default/triggers -name "*.trigger" | wc -l
find force-app/main/default/flows -name "*.flow-meta.xml" | wc -l
find force-app/main/default/lwc -mindepth 1 -maxdepth 1 -type d | wc -l
Supplement with MCP queries for live-only data (Profiles, Permission Sets, PSGs, user counts).
A2 — Surface metadata for Apex classes (local only)
Fetch name, size, and API version — no body — for all local Apex classes.
cli / MCP modes:
tooling_api_query: SELECT Id, Name, LengthWithoutComments, ApiVersion
FROM ApexClass
WHERE NamespacePrefix = null
ORDER BY Id
If the response includes instructions.artifactId, retrieve using the
strategy for your execution mode (see references/mcp-pagination.md).
sfdx-repo mode — read -meta.xml files for ApiVersion; use file size as
a proxy for LengthWithoutComments.
From this data, immediately flag:
- Classes with
ApiVersion < 50.0(more than 4 years old) — LOW risk - Classes with
LengthWithoutComments > 5000— flag as large, note for review - Classes matching the generated/skip list (see below)
A3 — Delta detection (incremental only)
Skip this step for fresh audits.
sfdx-repo mode:
git log --after="<prev_audit_date>" --name-only --diff-filter=ACMR \
-- force-app/main/default/classes/ force-app/main/default/triggers/ \
force-app/main/default/flows/ force-app/main/default/lwc/
Parse the output to identify changed files. Map file paths to component names.
cli / MCP modes:
Add AND LastModifiedDate > <prev_audit_date> to the A2 query and equivalent
queries for triggers, flows, and LWC. Components not in the result set are
unchanged — carry forward their previous scores.
Also detect removed components: any component in the previous inventory that no longer appears in the current full inventory count.
A4 — Surface metadata for Flows and LWC
Flows:
tooling_api_query: SELECT Id, DeveloperName, ActiveVersionId,
ActiveVersion.VersionNumber, ActiveVersion.ProcessType
FROM FlowDefinition
WHERE ActiveVersionId != null AND NamespacePrefix = null
ORDER BY Id
Separate by ActiveVersion.ProcessType: Flows vs Process Builders.
LWC:
tooling_api_query: SELECT Id, DeveloperName, ApiVersion
FROM LightningComponentBundle
WHERE NamespacePrefix = null
ORDER BY Id
A5 — Write audit_state.md
Create ./audit_output/audit_state.md with the Quick Pass results:
# Audit State — {ORG_NAME} — {DATE}
## Mode
EXEC_MODE: sfdx-repo | cli | mcp-plus-code-execution | mcp-core
AUDIT_TYPE: fresh | incremental (previous: {PREV_DATE})
## Component Inventory (Phase A complete)
| Domain | Local | Managed | Skipped (generated) | Delta |
| ----------------- | ----- | ------- | ------------------- | ----- |
| Apex Classes | X | Y | Z | D |
| Apex Triggers | X | - | - | D |
| Active Flows | X | - | - | D |
| Process Builders | X | - | - | D |
| LWC Components | X | - | - | D |
| Custom Objects | X | - | - | D |
| Validation Rules | X | - | - | - |
| Workflow Rules | X | - | - | - |
| Permission Sets | X | - | - | - |
| PSGs | X | - | - | - |
| Profiles | X | - | - | - |
| Reports | X | - | - | - |
| Dashboards | X | - | - | - |
| Connected Apps | X | - | - | - |
| Named Credentials | X | - | - | - |
| User Licenses | X | - | - | - |
(Delta column: number of changed/new components for incremental audits)
## Skip List Applied
- MetadataService (generated, 12,000+ lines — not user-controlled)
- [any other skipped classes with reason]
## Surface Findings from Quick Pass
- [API version warnings]
- [oversized class flags]
## Deep Dive Progress
- [ ] C1: Apex Classes (0 / X local)
- [ ] C2: Apex Triggers (0 / X)
- [ ] C3: Flows (0 / X)
- [ ] C4: Process Builders (0 / X)
- [ ] C5: LWC (0 / X)
- [ ] C6: Permissions
- [ ] C7: Data Model (0 / X objects)
- [ ] C7b: Unused Fields & Objects
- [ ] C8: Workflow Rules
- [ ] C10: Reports & Dashboards
- [ ] C11: Integrations
- [ ] C12: Test Coverage
- [ ] C13: Licensing
- [ ] C14: Team Evaluation
- [ ] C15: Change History
## Scores Accumulated
[populated as deep dive runs]
## Carried Forward (incremental only)
[list of unchanged components with their previous scores]
## Next Step
-> Awaiting user approval for Deep Dive (Phase B)
A6 — Scale Gate
After writing audit_state.md, check whether the org is large enough to
warrant special handling. Count the scoreable components per domain:
| Domain | Count | Over 10? |
|---|---|---|
| Apex Classes | {n} | Y/N |
| Triggers | {n} | Y/N |
| Flows | {n} | Y/N |
| LWC | {n} | Y/N |
| Objects | {n} | Y/N |
If ANY domain exceeds 10, inform the user before proceeding to Phase B:
I found {total} components to score across {domains} domains.
- "Score all" — I'll score every component.
- "Score a sample" — I'll score the top 10 per domain ranked by risk (old API version, large size, naming anomalies). The rest get surface metrics only.
- "Quick pass only" — Report with inventory data, no body downloads.
Record the user's choice in audit_state.md under ## Scoring Strategy
(full | sample | quick_pass). Phase B and Phase C reference this value.
If no domain exceeds 10, proceed directly to Phase B.
Generated / skip list
The following classes should be noted but not scored in the Deep Dive. They are large or generated files that the org developer does not directly author and cannot meaningfully improve:
| Class name pattern | Reason |
|---|---|
MetadataService |
Andrew Fawcett's Apex Metadata API — generated, ~12,000 lines |
fflib_* |
FinancialForce Apex Common library (managed-package equivalent) |
Callable_MockProvider, CallableMock* |
Test infrastructure, not production logic |
More broadly: if a class has LengthWithoutComments > 8000 and a name
that does not correspond to a business domain concept (e.g. it looks like a
library or framework class), flag it for user confirmation rather than
spending time scoring it.
Phase B — User Approval Gate
After Phase A, present a summary and ask for approval before starting the Deep Dive. This is important: the Deep Dive can cost hundreds of API calls on a large org.
Present something like:
Quick Pass complete for {ORG_NAME}.
Local components to score:
- Apex Classes: {X} (excl. {Z} generated/skipped)
- Apex Triggers: {X}
- Active Flows: {X} (incl. {PB} Process Builders)
- LWC Components: {X}
- Custom Objects: {X}
Execution mode: {EXEC_MODE}
[If incremental:] Delta since {PREV_DATE}: {D} components changed, {N} new, {R} removed. {U} unchanged scores will be carried forward.
[If mcp-plus-code-execution or mcp-core mode:] Estimated cost: ~{total} sequential API calls. For reference: 500 classes ~ 500 API calls ~ 20-40 minutes.
Managed packages excluded by default. {Y} managed-package classes will be skipped unless you ask me to include them.
Surface findings noticed in Quick Pass:
- {count} classes older than API v50 ({list top 5})
- {count} classes larger than 5,000 lines
- {list any other flags}
Proceed with full Deep Dive? You can also say:
- "Yes, full audit" — scores all domains
- "Yes, just Apex and Flows" — skips LWC, Metadata
- "Just show me the quick pass results" — lightweight report from Phase A data only, no body downloads
[If the user chose "Score a sample" in A6, remind them here: "You selected sample scoring — I'll score the top 10 per domain by risk."]
If the user says "just quick pass results", skip to Phase D (reports) and generate reports based on what Phase A collected. Mark unscored domains as "Not audited — surface metrics only."
Phase C — Deep Dive
MANDATORY (when user chose "Score all" in A6): Score EVERY component. No sampling. No shortcuts.
When the user chose "Score all" in A6, you MUST individually fetch, read, and score every single Apex class, trigger, Flow, and LWC component in the org (minus the generated/skip list and managed packages). Do NOT:
- Score a "representative sample" and extrapolate
- Score only the first N items and summarize the rest
- Skip items because the org is large or you are running low on context
- Group multiple components into a single score
- Estimate scores based on metadata (size, API version) without reading the body
The batch sizes (20 for Apex, 10 for Flows/LWC) are checkpointing intervals, not limits. After each batch, update
audit_state.mdand continue to the next batch until every component is scored.Completeness check: Before marking any sub-phase complete, compare the count of scored components against the inventory count from Phase A. If they do not match (after accounting for skipped/generated items), you are not done. Keep processing until:
scored + skipped + carried_forward == inventory count. (For fresh audits,carried_forwardis 0.)
Environment-aware processing
Choose your processing strategy based on what the environment supports:
Strategy A — Pre-score on disk (sfdx-repo, cli, or
mcp-plus-code-execution):
- Fetch all bodies to
./audit_output/intermediate/(via local filesystem, CLI bulk retrieve, orartifactUrldownload — whichever mode applies) - Run the pre-scoring orchestrator:
python scripts/pre_score.py \ --intermediate-dir ./audit_output/intermediate \ --output-dir ./audit_output \ --threshold 70 - Read
./audit_output/pre_score_summary.json. Only review components listed inneeds_llm_review(those scoring below 70% of max). Accept all other scores as-is — do not load their bodies into context. - For flagged components: read the body, apply the domain rubric, adjust the score if the script produced a false positive, and record the final score.
- Write the final JSON score files and proceed to Phase C9 / Phase D.
This strategy keeps component bodies out of context entirely for the majority of components, allowing audits of 500+ component orgs.
Strategy B — Batch in context (mcp-core):
- Process components in batches of 5 (not 20). For each component:
a. Fetch the body (via
fetch_morewith cursor, or direct query) b. Score it against the rubric c. Record the score inaudit_state.mdunder## Scores Accumulatedas one row:| Name | Score/Max | Top Issue |d. Discard the body before loading the next component - Never hold more than 2 component bodies in context simultaneously.
- After each batch of 5, update
audit_state.mdwith progress.
How to choose: Use Strategy A in sfdx-repo, cli, or
mcp-plus-code-execution mode. Use Strategy B in mcp-core mode.
See references/execution-modes.md for detection logic.
Update audit_state.md after completing each sub-phase. If the conversation
gets interrupted (context compaction, session end), the next session can
resume from the state file.
In every phase: skip components where NamespacePrefix != null.
For incremental audits: only process changed/new components. Carry forward previous scores for unchanged components. Mark removed components.
C1 — Apex Classes (deep)
Score every local class. Process in batches of 20 (for checkpointing). For each batch:
- Fetch
Body:- sfdx-repo: read from
force-app/main/default/classes/<ClassName>.cls - cli:
sf project retrieve start -m ApexClass --target-org <org>(bulk, one CLI call for all classes) - MCP modes: bulk query first —
tooling_api_query: SELECT Id, Name, Body FROM ApexClass WHERE NamespacePrefix = null ORDER BY Id. If the response includesinstructions.artifactId, retrieve using the strategy for your mode (seereferences/mcp-pagination.md). Fall back toSELECT Body FROM ApexClass WHERE Id = '<id>'one at a time only if bulk query is not available.
- sfdx-repo: read from
- Write each body to
./audit_output/intermediate/apex/<ClassName>.cls - Score using the 150-point rubric from
sf-apex - Track: class name, score, top 3 issues
- After each batch of 20: update
audit_state.mdwith progress and scores
Skip any class on the generated/skip list. Note its name and reason in
audit_state.md but do not score it.
Continue batches until every local class is scored. Then compute:
- Mean and median score
- Count below 70 (needs attention), below 50 (critical)
- Top 5 most common issue types across all classes
Verify: scored + skipped + carried_forward == Phase A local class count.
If not, identify and score the missing classes before proceeding.
Update audit_state.md: mark C1 complete, record aggregate stats.
C2 — Apex Triggers (deep)
Score every local trigger. Follow the same completeness rules as C1.
- Fetch trigger metadata:
tooling_api_query: SELECT Id, Name, TableEnumOrId, ApiVersion, Status FROM ApexTrigger WHERE NamespacePrefix = null - Fetch
Body:- sfdx-repo: read from
force-app/main/default/triggers/<Name>.trigger - cli:
sf project retrieve start -m ApexTrigger --target-org <org> - MCP modes: bulk query with artifact retrieval (same pattern as C1).
Fall back to
SELECT Body FROM ApexTrigger WHERE Id = '<id>'one at a time if needed.
- sfdx-repo: read from
- Write each to
./audit_output/intermediate/triggers/<TriggerName>.trigger - Score against the Apex rubric where applicable. Also flag trigger-specific issues:
| Finding | Severity |
|---|---|
| Logic in trigger body instead of a handler class | HIGH |
| No bulkification (SOQL/DML inside loop over Trigger.new) | CRITICAL |
| Multiple triggers on same object + event (execution order risk) | HIGH |
| Missing before/after context checks | MEDIUM |
| ApiVersion < 55.0 | LOW |
Verify: scored + skipped + carried_forward == Phase A local trigger count.
Update audit_state.md: mark C2 complete.
C3 — Flows (deep)
Score every active Flow (excluding Process Builders — those go to C4). Use the Flow ID list from Phase A4.
- Fetch flow definitions:
- sfdx-repo: read from
force-app/main/default/flows/<Name>.flow-meta.xml - cli:
sf project retrieve start -m Flow --target-org <org> - MCP modes:
tooling_api_queryonFlowWHEREId = '<id>'(one row per ID — single-row constraint applies)
- sfdx-repo: read from
- Write each to
./audit_output/intermediate/flows/<DeveloperName>.flow-meta.xml - Score using the 110-point rubric from
sf-flow - Separate Process Builders (
ProcessType = 'Workflow') — inventory only, no Flow rubric score (see C4) - After every 10 flows, update
audit_state.md
Continue until every active Flow is scored. Then verify:
scored_flows + skipped_flows + carried_forward == Phase A active Flow count and
process_builders == Phase A Process Builder count.
Update audit_state.md: mark C3 complete.
C4 — Process Builders (inventory)
Process Builders (ProcessType = 'Workflow') are legacy. Do not score
against the Flow rubric. Inventory and flag:
| Finding | Severity |
|---|---|
| Active Process Builder (should migrate to Flow) | HIGH |
| > 10 criteria nodes | MEDIUM |
| Invokes Apex actions | MEDIUM |
| Multiple Process Builders on same object | HIGH |
Write to ./audit_output/intermediate/process_builders/inventory.md.
Update audit_state.md: mark C4 complete.
C5 — LWC (deep)
Score every local LWC component. Follow the same completeness rules as C1.
- Fetch component source:
- sfdx-repo: read from
force-app/main/default/lwc/<Name>/ - cli:
sf project retrieve start -m LightningComponentBundle --target-org <org> - MCP modes:
metadata_readorLightningComponentResourceTooling query grouped by bundle ID
- sfdx-repo: read from
- Write each to
./audit_output/intermediate/lwc/<DeveloperName>/ - Score using the 165-point rubric from
sf-lwc - After every 10 components, update
audit_state.md
Continue until every LWC component is scored. Then verify:
scored + skipped + carried_forward == Phase A local LWC count.
Update audit_state.md: mark C5 complete.
C6 — Profiles and Permissions
This phase does not download source bodies, so it runs faster than C1-C5.
Skip NamespacePrefix != null.
Run in this order:
- Inventory Profiles
- Inventory Permission Sets and Permission Set Groups (local namespace only)
- Detect overly broad permissions
- Count PS assignments, identify orphaned and over-assigned PSs
- Check PSG health (Status = 'Outdated')
Key queries for C6
soql_query: SELECT Id, Name, UserType FROM Profile
soql_query: SELECT Id, Name, Label, Description, PermissionsModifyAllData,
PermissionsViewAllData, PermissionsManageUsers, PermissionsAuthorApex
FROM PermissionSet
WHERE IsOwnedByProfile = false AND NamespacePrefix = null AND Type != 'Group'
soql_query: SELECT Id, DeveloperName, MasterLabel, Status, Description
FROM PermissionSetGroup
soql_query: SELECT PermissionSetGroupId, PermissionSetGroup.DeveloperName,
PermissionSetId, PermissionSet.Name
FROM PermissionSetGroupComponent
soql_query: SELECT PermissionSetId, PermissionSet.Name, COUNT(Id) assignments
FROM PermissionSetAssignment
WHERE PermissionSet.IsOwnedByProfile = false
GROUP BY PermissionSetId, PermissionSet.Name
soql_query: SELECT COUNT(Id) FROM User WHERE IsActive = true
Findings classification:
| Severity | Examples |
|---|---|
| CRITICAL | Non-admin PS with ModifyAllData; orphaned PS with broad access |
| HIGH | PS with ViewAllData on sensitive objects; outdated PSGs; custom Profiles with ModifyAllData |
| MEDIUM | Overlapping PSs that should be consolidated into PSGs |
| LOW | Missing descriptions on PSs; unused Profiles |
Write outputs to ./audit_output/intermediate/permissions/.
Update audit_state.md: mark C6 complete.
C7 — Data Model, Validation Rules, and Formula Fields
Score every local custom object. Paginate CustomObject where NamespacePrefix = null.
For each custom object:
sobject_describe(sObject="<ApiName>")— get field count, relationship count, record type count- Score against the 120-point rubric from
sf-metadata - Write summary to
./audit_output/intermediate/metadata/<ObjectApiName>.md
Validation rules
Fetch the full formula body so hardcoded values can be detected:
tooling_api_query: SELECT Id, EntityDefinition.QualifiedApiName, ValidationName,
Active, Description, ErrorConditionFormula, ErrorMessage
FROM ValidationRule WHERE NamespacePrefix = null
Tooling API quirk:
ORDER BY EntityDefinition.QualifiedApiNameon ValidationRule returns a 500UNKNOWN_EXCEPTION— the relationship field can't be sorted on. Order byEntityDefinitionIdinstead (and if the SELECT of the relationship field also errors on your connector, selectEntityDefinitionIdand resolve names in a follow-up query).
ErrorConditionFormulamay not be SOQL-queryable either — some connectors returnINVALID_FIELDfor it. When that happens, query the rule Ids first, then fetch each rule via Tooling REST (sobjects/ValidationRule/{Id}) and read the formula from theMetadatapayload.
For each validation rule, scan ErrorConditionFormula for anti-patterns using
these regex patterns:
| Pattern (case-insensitive) | What it catches |
|---|---|
[a-zA-Z0-9]{15,18} that matches an Id format (starts with [0-9a-zA-Z]{3} and passes Salesforce Id checksum or is a known prefix like 001, 006, 00Q, 701, etc.) |
Hardcoded Record IDs |
Quoted string literals containing object names that exist in the org (e.g. "Enterprise", "Gold Partner") |
Hardcoded record type / picklist names (fragile if renamed) |
Campaign or campaign-name string literals |
Hardcoded Campaign names |
Profile name string literals (e.g. "System Administrator", "Sales User") |
Hardcoded Profile names (use $Profile.Name sparingly) |
URL string literals (https://, http://) |
Hardcoded URLs (should use Custom Metadata or Custom Setting) |
Findings for validation rules:
| Finding | Severity |
|---|---|
| Formula contains hardcoded Record ID(s) | HIGH |
| Formula contains hardcoded Campaign name(s) | HIGH |
| Formula contains hardcoded Profile name(s) | MEDIUM |
| Formula contains hardcoded URL(s) | MEDIUM |
| Formula contains hardcoded record-type or picklist value string(s) | MEDIUM |
| Active rule with no description | MEDIUM |
Rule with no bypass mechanism ($Permission or custom setting) |
MEDIUM |
| Inactive rules (cleanup candidates) | LOW |
| Object with > 20 active rules (complexity risk) | MEDIUM |
Formula fields
Fetch formula field definitions for every local custom object:
tooling_api_query: SELECT Id, EntityDefinition.QualifiedApiName, DeveloperName,
QualifiedApiName, DataType, TableEnumOrId, Formula
FROM CustomField
WHERE NamespacePrefix = null AND Formula != null
Note: The
Formulafield is only available via the Tooling API onCustomField. Insfdx-repomode, read formula bodies fromforce-app/main/default/objects/<Object>/fields/<Field>.field-meta.xml(the<formula>element).
For each formula field, scan the Formula body for the same anti-patterns
listed above for validation rules (hardcoded IDs, Campaign names, Profile
names, URLs, record-type/picklist strings).
Additional formula-field-specific findings:
| Finding | Severity |
|---|---|
| Formula contains hardcoded Record ID(s) | HIGH |
| Formula contains hardcoded Campaign name(s) | HIGH |
| Formula contains hardcoded Profile name(s) | MEDIUM |
| Formula contains hardcoded URL(s) | MEDIUM |
| Formula contains hardcoded record-type or picklist value string(s) | MEDIUM |
| Formula references field that does not exist (compile error risk) | HIGH |
| Formula exceeds 5 000 characters (readability / compile-size risk) | MEDIUM |
Formula uses VLOOKUP (deprecated function) |
MEDIUM |
Formula has deeply nested IF statements (> 5 levels) |
LOW |
Write formula field findings to
./audit_output/intermediate/metadata/formula_fields.md and persist to
formula_fields.json.
Cross-object analysis (beyond per-object scoring):
- Objects with no relationships (orphaned objects)
- Missing descriptions on custom objects
- Outdated API versions (< 55.0)
- Objects with > 100 custom fields (complexity risk)
Verify: scored + skipped + carried_forward == Phase A local custom object count.
Update audit_state.md: mark C7 complete.
C7b — Unused Custom Fields and Objects
Identify custom fields and custom objects that appear unused. "Unused" means either (a) no records contain data for the field/object, or (b) the field/object is not referenced in other org artifacts (Apex, Flows, validation rules, formula fields, page layouts, reports, list views), or both. Flag each condition independently — either one in isolation is worth surfacing.
Step 1 — Identify custom fields with no data
For each local custom object (from C7), query for custom fields that have zero populated records. Use a single Tooling API query per object to get all custom fields, then check population via SOQL:
tooling_api_query: SELECT DeveloperName, QualifiedApiName, DataType,
Description, TableEnumOrId
FROM CustomField
WHERE NamespacePrefix = null
AND TableEnumOrId = '<CustomObject__c>'
For each custom field, check whether any record has data:
soql_query: SELECT COUNT(Id) total FROM <Object__c> WHERE <Field__c> != null LIMIT 1
Performance note: For objects with many custom fields, batch the population checks — run up to 10 in parallel where the mode supports it. In
sfdx-repomode, skip population checks (record data is not available locally) and sethas_data: nullin the output JSON. The report generator renders this as "Unknown" in all formats.
Step 2 — Identify custom objects with no records
For each local custom object, check record count:
soql_query: SELECT COUNT(Id) total FROM <Object__c>
Objects with total == 0 are flagged as "no data."
Step 3 — Cross-reference analysis (artifact references)
Search for references to each custom field and custom object across org artifacts. A field/object is "unreferenced" if it does not appear in any of:
- Apex classes and triggers — search for the field/object API name in all fetched bodies (from C1/C2 intermediate files)
- Flows — search Flow XML for the field/object API name (from C3 intermediate files)
- Validation rules — search
ErrorConditionFormulafor the field API name (from C7 data) - Formula fields — search
Formulabody for the field API name (from C7 data) - Workflow rules — search criteria and field-update formulas (from C8 data)
- Page layouts — query layout assignments:
Check whether the field appears in any layout section.metadata_read: type=Layout, fullNames=["<Object__c>-*"] - Reports and list views — query via Tooling API:
tooling_api_query: SELECT Id, DeveloperName FROM Report WHERE DeveloperName LIKE '%<ObjectName>%'
sfdx-repomode: search for the field/object API name across all files inforce-app/usinggrep -r. For page layouts, checkforce-app/main/default/layouts/<Object>-*.layout-meta.xml.
Shortcut for large orgs: If the org has more than 500 custom fields total, limit the cross-reference search to fields that already failed the population check (no data). Fields with data are less likely to be truly unused.
Step 4 — Classify and write findings
For each custom field, assign a category:
| Condition | Category | Severity |
|---|---|---|
| No data AND no references | Unused | HIGH |
| No data but ha |
…(truncated)