Docs/Reference

Reference

Look up supported Salesforce metadata, profiles, permissions, and platform surfaces.

References

Salesforce Profiles

Profiles are a legacy Salesforce concept - they define permissions per object and field and are assigned to every user. Unlike most metadata, they are not deployable as discrete records and they live in a separate namespace from permission sets.

renmy models Profile as a standard SOQL-queryable object backed by a local database table. A set of standard profiles is always seeded so common test patterns work out of the box:

Profile permissions. Outside System.runAs a test runs as a System Administrator with full data access. Inside a runAs block, user-mode queries and DML, stripInaccessible and describe access checks follow the user's permission sets and profile. For a profile your project ships in profiles/, its objectPermissions, fieldPermissions and enabled system permissions such as Modify All Data become the permission set Salesforce keeps for the profile, and they add to the user's permission sets. Modify All Data on any of them grants every object and field, and View All Data grants read on every object and field. A retrieved profile lists only the objects its manifest named, so an object it does not mention is denied. A profile file that declares no object, field or system permission at all grants nothing and denies nothing, and its users are judged by their permission sets. The standard profiles below keep renmy's built-in model. These rules follow the Salesforce documentation and have not been checked against an org with a project profile.

Under nimbus.permissions.standardProfileFieldGrants=probe, the default since 1.48, Standard User, Marketing User, Minimum Access - Salesforce and Chatter External User hold the field permissions they own in a Developer Edition org, on the standard objects renmy ships a describe for. Inside a runAs block a field that neither the profile nor the user's permission sets grant, a custom field included, is not accessible, createable or updateable in describe; a WITH USER_MODE query or user-mode DML refuses it, and stripInaccessible removes it, as on an org that deployed your source without profiles. A lookup no permission can name, such as Opportunity.Pricebook2Id, follows read access on the object it points at, and a Standard.profile-meta.xml in your source is laid over those grants as a deploy applies it. Read Only keeps the built-in model: a Developer Edition org has no such profile. permissive gives the answers renmy gave before 1.48: every field of an object the user can read. NIMBUS_PERMISSIONS_STANDARD_PROFILE_FIELD_GRANTS overrides the setting for one run, so NIMBUS_PERMISSIONS_STANDARD_PROFILE_FIELD_GRANTS=permissive restores those answers without editing nimbus.properties.

Where renmy holds no field grants for a runAs user's profile on an object, a field read is answered from read access on the object. That covers a custom profile your source does not ship, an object the probed grants do not cover (a custom object among them) when no permission set grants a field on it, and, under permissive, the four profiles above. The run lists those reads after the permission-seam summary; see Permission Seam Visibility.

FlagDefaultDescription
System Administrator00e000000000002AAAStandard user type, Salesforce license
Standard User00e000000000003AAAStandard user type, Salesforce license
Read Only00e000000000005AAAStandard user type, Salesforce license
Chatter External User00e000000000004AAACsnOnly user type, Chatter External license
Minimum Access - Salesforce00e000000000006AAAStandard user type, Salesforce license
Marketing User00e000000000007AAAStandard user type, Salesforce license

Who the runtime runs as

Every run is somebody. By default it is the seeded System Administrator (005000000RUNNERAQ5), who sees every object, field and record as an administrator does in an org. Name a row in the local User table with nimbus.runtime.user and the runtime answers as that person in a test run, in the consoles and in the Workbench alike: UserInfo, the OwnerId and CreatedById new records default to, and custom permission checks. Object permissions, field-level security and record sharing are checked only inside a System.runAs block, for the user it names.

bash
# nimbus.properties
nimbus.runtime.user=ada@example.com

A username no local user carries fails the run before any test executes, rather than running the suite as somebody you did not ask for. --as on renmy test, renmy exec and renmy run-method overrides the setting for one invocation.

This is who the runtime runs as. System.runAs is still who one stretch of code runs as, and the two compose the way they do on the platform: a runAs block wins inside it, and the running user is what the code returns to afterwards.

Inside a runAs block the user's time zone and language apply too. UserInfo.getTimeZone(), Date.today(), local-time reads such as hour() and format(), the local-time Datetime constructors and the SOQL date literals follow the user's TimeZoneSidKey; an instant stays the same instant. UserInfo.getLanguage() answers the user's LanguageLocaleKey, and custom labels, field labels, help text and picklist labels come from the project's translations for that language, in translations/ and objectTranslations/. A project that ships no translations reads the default language.

Running user profile

Every test runs as a mock user (ID 005000000RUNNERAQ5). The profile assigned to that user - what UserInfo.getProfileId() resolves to - defaults to System Administrator, which is who runs a suite against a scratch org or sandbox. Override it with nimbus.mock.user-profile when your tests target a restricted persona.

This matters when your code branches on the running user's profile name, e.g.:

apex
// Example: trigger framework checking profile to decide whether to fire
String profileName = profileMapById.get(UserInfo.getProfileId()).Name;
if (profileName == 'My Profile') { return; }
bash
# nimbus.properties
nimbus.mock.user-profile=My Profile

Profile Id in validation rules

Validation rules written before permission sets often gate on a hard-coded profile Id, so the rule is inert for the profile your CI runs as:

bash
AND( $User.ProfileId != "00eb0000000rDLU", ISCHANGED(Status__c) )

renmy's own profile Ids are synthetic, so such a clause is true locally and the rule fires on every run. Give $User.ProfileId and $Profile.Id your org's real Id with nimbus.mock.user-profile-id:

bash
# nimbus.properties
nimbus.mock.user-profile=System Administrator
nimbus.mock.user-profile-id=00eb0000000rDLUAA2

Write the value in either spelling. Salesforce renders these two merge fields as the 15-character form, and the comparison is a plain string compare, so a rule written against the 18-character spelling does not match — in the org or in renmy runtime. This is not the rule for an Id field, which is 18 characters.

The key also moves the seeded profile's row to that Id. UserInfo.getProfileId(), User.ProfileId and [SELECT Id FROM Profile WHERE Name = 'System Administrator'] return it in the 18-character form, and the profile's own permission set follows it. After renmy sync -o <alias> every profile row already carries your org's Id, and a synced row with the configured profile's name takes precedence over the key. A value with a wrong checksum, or of the shape renmy uses for its own rows (00e and twelve digits), reaches the formulas only, and the run prints why.

Project-specific profiles

Every profile in your project's profiles/ directory gets a Profile row, named as in Setup, with the licence and user type its userLicense names, so [SELECT Id FROM Profile WHERE Name = 'Field Sales'] finds it. Profile rows synced from an org take precedence.

If your tests query for profiles that are neither in the project nor in the built-in set, seed them with nimbus.seed.profiles. Comma-separated list of profile names. Seeded profiles get a deterministic ID and are assigned Standard user type with the Salesforce license - sufficient for the vast majority of test patterns.

bash
# nimbus.properties
nimbus.seed.profiles=Field Sales,Partner Community

These profiles are then findable via SOQL:

apex
Profile p = [SELECT Id FROM Profile WHERE Name = 'Field Sales' LIMIT 1];
User u = new User(profileId = p.Id, ...);

Default record types

In Salesforce, records inserted without an explicit RecordTypeId get the running user's profile default, and null when that profile names no default. renmy reads the default from the recordTypeVisibilities in your project's *.profile-meta.xml, matching the profile named by nimbus.mock.user-profile (or the impersonated user's profile inside System.runAs). If your profiles aren't in source, set per-object defaults explicitly — this also overrides whatever the profile says:

bash
# nimbus.properties
nimbus.default-record-type.Lead=Prospect_Record_Type
nimbus.default-record-type.Case=Support_Case

List views

renmy auto-discovers .listView-meta.xml files from your project and seeds them into the database. For standard object list views that aren't in your repo (Salesforce includes built-in views like "All Leads"), seed them via config:

bash
# nimbus.properties — format: nimbus.seed.list-view.<Object>.<DeveloperName>=<Label>
nimbus.seed.list-view.Lead.AllLeads=All Leads
nimbus.seed.list-view.Lead.MyLeads=My Leads
nimbus.seed.list-view.Case.AllCases=All Cases

Standard reference data

Some standard objects are org-managed reference data that always exists on a real org but isn't carried by renmy sync(which brings schema, not rows). renmy seeds the common ones with their standard out-of-box values so test-setup idioms that read them work without per-repo stubs:

FlagDefaultDescription
OpportunityStage10 stagesStandard sales process — Prospecting … Closed Won / Closed Lost, with IsClosed / IsWon / DefaultProbability / ForecastCategory
TaskStatus6 statusesNot Started, In Progress, Completed, …
CaseStatus4 statusesNew, Working, Escalated, Closed
BusinessHoursDefault rowThe always-present "Default" business hours record

These match a default org. If your org customises the picklist (e.g. custom opportunity stages), the seeded labels may differ from your StageName values — seed the records you need explicitly with nimbus.seed.record (below) to override.

Tabs (Schema.describeTabs())

Schema.describeTabs() returns a single tab set whose tabs are read from your project's *.tab-meta.xml (CustomTab) metadata — source-driven, like the rest of the schema. An object tab resolves its getSobjectName() and label from the object; web/VF/Lightning tabs carry their own label. Code that resolves a tab or icon for an SObject finds it; a project with no tab metadata gets a present-but-empty set.

Named SObject records (list custom settings)

List Custom Settings are accessible in real Salesforce tests as org-level data. In renmy runtime, the local database starts empty, so code that reads them via CustomSetting__c.getAll() gets nothing. Seed the records you need with nimbus.seed.record:

bash
# nimbus.properties — format: nimbus.seed.record.<Object>.<Name>=<Field=value,...>
nimbus.seed.record.RegionSetting__c.de=Country__c=Germany,Cities__c=Berlin
nimbus.seed.record.RegionSetting__c.nl=Country__c=Netherlands,Cities__c=Amsterdam

The Name in the key becomes the record's Name field. Existing records (same name) are skipped, so tests that insert their own records during @testSetup are unaffected.

The rows are org data. renmy inserts them as real rows of the setting's table, so a test declared @isTest(SeeAllData=true) sees them the way the org does: through SOQL, getAll(), getInstance(name) and getValues(name). A test without SeeAllData sees none of them, exactly like the org-wide default of a hierarchy setting, and its own inserts stay isolated. Naming any other SObject here does nothing — use nimbus.seed.row below.

Arbitrary org-resident rows

Some setup data exists in the org, is not in your repo metadata, and has no dedicated seeder — OrgWideEmailAddress, an org-only PermissionSetGroup, BusinessHours, Holiday. Code that dereferences one of these directly fails uncatchably when the row is missing. nimbus.seed.row inserts a real database row for any object:

bash
# nimbus.properties — format: nimbus.seed.row.<Object>.<Name>=<Field=value,...>
nimbus.seed.row.OrgWideEmailAddress.Support=Address=noreply@acme.com,DisplayName=Acme Support
nimbus.seed.row.PermissionSetGroup.For_Everyone=MasterLabel=For Everyone
nimbus.seed.row.BusinessHours.Weekend=IsActive=true

The Name half of the key fills the object's identifying column — DeveloperName where it has one, otherwise Name — so a lookup by that name resolves without repeating it in the value. Values coerce to the column's type (true/false, numbers, null); a field your local schema doesn't have is dropped rather than failing the whole row. IDs are deterministic, so re-running reuses the same row. Name an Id in the value (Id=00G0X000007jHxfUAE) when code hard-codes the org's Id: the row is stored and re-seeded under that Id, and a 15-character Id is stored as its 18-character form.

Rows are seeded once per environment, not per test. That is safe because each test runs in a transaction that rolls back, so a test deleting a seeded row doesn't affect its neighbours.

A seeded row carries the same audit stamps as one your Apex inserts: CreatedDate, LastModifiedDate, CreatedById and LastModifiedById name the running user, so code that groups or filters by creator sees no hole where the pre-existing data is. OwnerId is stamped too, together with the implicit owner share it obliges: on a Private or Read OWD object the platform pairs every record with aRowCause='Owner' grant, so renmy writes both or neither. SetOwnerId yourself and that owner is kept — re-seeding never takes a record off its owner, and never writes a second grant.

CustomNotificationType is the same mechanism. renmy bundles no notification types — every row in that object exists because someone created it in a particular org, and Apex reaches one only by DeveloperName — so the table starts empty and holds what you declare:

bash
nimbus.seed.row.CustomNotificationType.Order_Shipped=DeveloperName=Order_Shipped,MasterLabel=Order Shipped,Desktop=true,Mobile=true

renmy sync -o <alias> --include-setup-data writes exactly these lines from that org, so a synced row and a hand-written one are the same thing.

Everything on this page seeds for renmy exec, renmy app, the language server, the debugger and the MCP bridge as well as for renmy test — so anonymous Apex is a reliable way to check whether a seed key took.

Sites

Apex reaches a Salesforce Site as two single-row queries: Site by Name, then that site's SiteDetail.SecureUrl by DurableId. renmy seeds both rows, from the project's own source or from the org.

A site the project declares in sites/<Name>.site-meta.xml is seeded from that file, with nothing configured. renmy reads the site's name from the file name and its masterLabel, subdomain and active flag from the XML, so SELECT Id FROM Site WHERE Name = 'Kyc' resolves on a project with no org connected.

SiteDetail.SecureUrl is null on those rows. The repo does not carry the secure URL: the org composes it from its My Domain, and the same site answers on a different host in production and in each sandbox. renmy seeds null rather than a URL assembled from subdomain, so Apex that compares the URL or matches it with String.contains() fails locally instead of passing locally and failing on the org.

To give a site the URL the org serves, seed it from config:

bash
# nimbus.properties — format: nimbus.seed.site.<Name>=<secureUrl>
nimbus.seed.site.My Portal=https://myportal.my.site.com
nimbus.seed.site.Partner Community=https://partners.my.site.com

Each entry inserts a Site row (status Active) and a matching SiteDetail row with the given secure URL, and it wins over the source-derived row for the same name. IDs are generated deterministically from the name, so a site keeps its ID whichever of the two seeds it. renmy sync -o <alias> --include-setup-data (and renmy setup, which runs it) writes these lines from the org's Site and SiteDetail rows.

A line whose value is empty seeds nothing on its own. renmy sync writes nimbus.seed.site.<Name>= when the org returns no SiteDetail row for a site, and renmy then falls back to the site's .site-meta.xml if the repo has one.

The sync also carries the site's label and its Guest User as the org has them. The platform names that user in the org's language, not every site has one, and some are inactive:

bash
nimbus.seed.site.Sms_Gateway=https://x.my.site.com/sms,Label=SMS Gateway,GuestUser=SMS Gateway Gastbenutzer der Site,GuestUserActive=false
nimbus.seed.site.Portal1=https://x.my.site.com/p1,Label=Portal,GuestUser=

An empty GuestUser= means the site has no guest user, and none is seeded. A line without these parts still works: renmy seeds an active guest user named after the site, <Label> Site Guest User.

Flow activation order

Record-triggered flows of one object and phase that declare no triggerOrder run in the order the org last activated them. renmy sync -o records that time per flow in .nimbus/flows.json; to set it by hand, or to keep it in the committed file, use a key per flow:

bash
# nimbus.properties — format: nimbus.seed.flow.<FlowApiName>=<RFC 3339 time, UTC>
nimbus.seed.flow.Order_Automation=2026-10-14T10:36:22Z

The key wins over .nimbus/flows.json for a flow both name. Flows with neither run after the others, by API name.

Public Groups

Standing Public Groups (regional or team groups like Sales - EMEA or Support Tier 1) are usually created once by an admin and never checked into repo metadata. Code that queries them by name — often user-trigger automation that adds users to groups on country/team change — comes up empty in renmy runtime. Seed them via config:

bash
# nimbus.properties — format: nimbus.seed.group.<Name>=<Type>
# Type defaults to "Regular" when omitted
nimbus.seed.group.Sales - EMEA=Regular
nimbus.seed.group.Support Tier 1=Regular

Each entry inserts a Group row with a deterministic 00G-prefix Id from the name. The key is written to both Name and DeveloperName — test factories that look groups up either way (WHERE Name = 'X' or WHERE DeveloperName = 'X') resolve the same row. Idempotent across runs.

In most orgs the two names differ — Support_Desk_IT is really Support Desk - IT. Declare the label after the type when it does, and Name takes the label while DeveloperName takes the key:

bash
# nimbus.properties — <Type>, then the org's label when it differs
nimbus.seed.group.Support_Desk_IT=Regular,Name=Support Desk - IT

renmy sync --include-setup-data writes this form automatically for every group whose label differs from its developer name. Only the first comma separates the two, so a label containing one survives.

Queues

Queues are Group rows with Type='Queue' plus QueueSobject children mapping each queue to the SObjectTypes it routes. Case/lead/custom assignment automation looks them up by DeveloperName and joins through QueueSobject to find owner candidates. Seed both halves in one entry:

bash
# nimbus.properties — format: nimbus.seed.queue.<DeveloperName>=<SObject1>,<SObject2>
nimbus.seed.queue.Lead_Triage_DE=Lead,Case
nimbus.seed.queue.High_Value=Opportunity

Each entry inserts a Group row (Type=Queue) plus a QueueSobject row per listed SObjectType. Empty value still creates the queue Group row without any routing rows.

UserRoles

Role-hierarchy lookups and user-factory routines that assign UserRoleId need rows to exist; UserRole metadata is rarely checked into the repo. Seed by DeveloperName with the human label as the value:

bash
# nimbus.properties — format: nimbus.seed.role.<DeveloperName>=<Name>
# Value defaults to the DeveloperName when omitted
nimbus.seed.role.EMEA_Manager=EMEA Manager
nimbus.seed.role.APAC_Sales=APAC Sales

When at least one role is seeded the built-in CEO mock UserRole steps aside — WHERE DeveloperName = '...' and other clauses run against the real seeded rows. Tests that don't seed any role still receive the mock for backward compatibility.

Networks (Experience Cloud)

Code that gates on a community resolves it by name — [SELECT Id FROM Network WHERE Name = 'Member Hub']. The Network object is org-resident and rarely checked into repo metadata. Seed by Name; the value is the Status (defaults to Live):

bash
# nimbus.properties — format: nimbus.seed.network.<Name>=<Status>
nimbus.seed.network.Member Hub=Live
nimbus.seed.network.Partner Portal=UnderConstruction

Named Users

Factories that look up integration or system users by name — [SELECT Id FROM User WHERE Name = 'System Salesforce'] — need those rows to exist. Seed by the full Name; the value is an optional Username (derived from the Name when omitted):

bash
# nimbus.properties — format: nimbus.seed.user.<Name>=<Username>
nimbus.seed.user.System Salesforce=
nimbus.seed.user.Integration Bot=integ.bot@example.com

The seed Name is split on the first space into FirstName / LastName so the generated Name field reconstructs exactly.

Feature-gated standard fields

Standard object definitions ship with renmy, but they cannot know which features your org has switched on. Case.EntitlementId and Case.ServiceContractId exist only with Entitlements and Service Contracts enabled; Field Service and CPQ add their own. Pass an org and sync folds in whatever it has that the bundle lacks:

bash
renmy sync -o myorg

The merge is additive — the bundled describes stay authoritative for everything they already carry, so behaviour never depends on which org you last synced against. What the org adds is remembered, so later runs keep those fields without needing the org again. Run this once after connecting an org, or after enabling a feature in it.

Runs do not re-read the org every time. When a run syncs for itself, describes read from the org in the last 30 minutes are reused — the fields the org added are replayed from what the previous fetch recorded, so the run sees exactly what it saw before without asking the org again. renmy sync -o <org> is an explicit request and always refetches, so after enabling a feature run that rather than waiting for the window to pass.

Pulling setup data from the org

Instead of hand-editing seed entries for every Group/Queue/UserRole/Network and notification type the tests rely on, run sync with --include-setup-data to populate them from the connected org:

bash
renmy sync --include-setup-data

The command queries the org for Group, QueueSobject,UserRole, Network and CustomNotificationType rows and writes the matching nimbus.seed.{group,role,queue,network}.* and nimbus.seed.row.CustomNotificationType.* entries into nimbus.properties under a delimited block. Re-running refreshes the block — rows the org no longer has disappear rather than accumulating; hand-authored seed lines outside the block are preserved, so a declaration of your own still overrides what sync pulled. Named Users are not bulk-synced — a large org has thousands — so seed them explicitly with nimbus.seed.user.<Name>.

Each object type is pulled independently. If the org can't query one — an org without Experience Cloud has no queryable Network, for example — that type is skipped with a warning and the rest still land. The pull only fails outright when no setup object can be queried at all (usually a bad org alias or no connection).

Pulling permission sets from the org

Most orgs grant object access through permission sets rather than profiles. If your tests build a user, assign a permission set or permission set group, and then query under WITH USER_MODE or inside System.runAs, renmy needs to know what those grants contain:

bash
renmy sync --include-permissions

This pulls PermissionSet, their ObjectPermissions and FieldPermissions, and PermissionSetGroup composition into .nimbus/permissions.json, which the test runner seeds before each run. Profile-owned rows are excluded — every profile has a shadow permission set on the platform, and renmy models profiles separately through their default footprint.

Permission sets defined in your own project metadata always win: local .permissionset-meta.xml files are loaded after the synced data, so the repo stays the source of truth and the sync only fills what isn't checked in.

Without this, a permission set group whose contents renmy has no record of is treated as unknown rather than empty, and access is left unrestricted — better a missed denial than failing a test that passes in the org. Once permissions are synced, groups are enforced for real.

Pulling record types from the org

Record types are otherwise read from your project source, and source can only say what exists. For your own custom objects that's the whole definition — an object shipping no record type has none. For a standard or packaged object the record types live in the org, so silence means renmy wasn't told, not that there are none. Contact has none on one org and several on the next.

bash
renmy sync --include-record-types

One unfiltered query covers every object at once, and that's the point: an object's absence from the org's complete record-type set is evidence it has none. The snapshot lands in .nimbus/record-types.json.

What it buys you is faithful rejection of RecordTypeId on objects that don't have it. The platform exposes that column only on objects with record types; naming it elsewhere is a compile error, not a null read. Without a snapshot renmy can only enforce that on objects your project defines, so SELECT RecordTypeId FROM Contact passes locally and fails to deploy. With one, it's rejected here too.

Record types defined in your own project metadata always win, per object: if your source defines any record type for an object, that source is the whole definition for it and the org's rows aren't mixed in. Org record type Ids are discarded and the deterministic local ones re-derived, so Ids stay stable across machines and joins keep working.

A record type an admin keeps in Setup can go under stubs/objects/<Object>/recordTypes/ instead, where renmy stub pull --types RecordType writes the org's own file, picklist values and defaults included. A run reads it after your package directories and unpackaged/; a package directory's record type of the same name wins, and a stub counts as source for the per-object rule above. renmy sync -o names each active record type your project defines nowhere, without writing .nimbus/record-types.json: only --include-record-types writes that snapshot.

Person account record types in objects/PersonAccount/recordTypes/ load as Account record types with IsPersonType true, as they are on the platform, and the record-type pull reads IsPersonType from the org. See Person accounts.

If the org paginates the result, the snapshot is marked partial and reported as such. A partial snapshot still supplies its rows but is never read as proof that an object has no record types — a gap in the data must not become a rejection.

The same pull also fetches your running user's profile into .nimbus/org-profiles/, for its record type defaults. An insert that leaves RecordTypeId unset picks up the default from that profile, and a default is something only a profile can express — a permission set can make a record type visible but has no way to mark one default. So a project that syncs permissions rather than committing profiles to source had no route to them, and inserts left RecordTypeId null where the org sets it.

Profiles in your own project source still win. And note this cannot be done with a profile alone: the Metadata API reports a profile's record type visibilities only for record types named in the same request, so asking for the profile by itself returns one that appears to grant nothing — which is why the two are pulled together.

Custom Label overrides

Some repos ship Custom Labels with placeholder values (PLACEHOLDER,TBD) that admins replace post-deploy with environment-specific IDs — profile Ids, webhook URLs, signing keys. Tests that branch onLabel.X resolve to the repo value in renmy runtime and diverge from prod. Override them at runtime:

bash
# nimbus.properties — format: nimbus.seed.label.<FullName>=<value>
nimbus.seed.label.StandardUserProfileId=00e000000000003AAA
nimbus.seed.label.WebhookEndpoint=https://test.example.com/hook

The override wins over any value parsed from .labels-meta.xml files and over language-specific translations. The labels XML parser also tolerates bare <labels> fragments that ship without the <?xml?> header or <CustomLabels> wrapper.

Excluding seed records from specific tests

By default every test receives the seeded records. Use nimbus.seed.record.exclude to opt specific test classes (or individual methods) out of seed injection. When a test matches, getAll() returns only test-inserted records — as if the org has no data for that custom setting type. Useful for selector tests that assert getAll() returns empty.

bash
# nimbus.properties
# Comma-separated ClassName or ClassName.method patterns
nimbus.seed.record.exclude=RegionSettingSelectorTest,OtherTest.specificMethod

Full profile example

bash
# nimbus.properties

# Running user's profile (UserInfo.getProfileId() target)
nimbus.mock.user-profile=My Profile

# Additional profiles your tests query against
nimbus.seed.profiles=Field Sales,Partner Community

# Default record types for inserts without explicit RecordTypeId
nimbus.default-record-type.Lead=Prospect_Record_Type

# Seed list views not in repo metadata
nimbus.seed.list-view.Lead.AllLeads=All Leads

# Seed Site records for SOQL queries on Site/SiteDetail
nimbus.seed.site.My Portal=https://myportal.my.site.com

# Seed list custom setting org data
nimbus.seed.record.RegionSetting__c.de=Country__c=Germany,Cities__c=Berlin

Custom Permissions

Custom permissions are boolean flags that code checks at runtime via FeatureManagement.checkPermission('My_Permission'). They are defined as .customPermission-meta.xml files and granted to users via permission sets (or profiles).

renmy loads all .customPermission-meta.xml files from the project automatically. It also parses the <customPermissions> blocks in every .permissionset-meta.xml file and wires up the underlying SetupEntityAccess records. All that remains is assigning the right permission sets to the running test user.

Assigning permission sets to the running user

Use nimbus.mock.permission-sets to specify which permission sets the running user holds. These must be API names matching your .permissionset-meta.xml filenames (without the extension).

bash
# nimbus.properties
nimbus.mock.permission-sets=Inventory_Manager,Support_Agent

When the property is not set, renmy falls back to the permission sets your sfdx-project.json declares under packageDirectories[].apexTestAccess.permissionSets — the same sets the platform assigns to the user running a package's Apex tests. A configured property always wins over that default.

With this set, FeatureManagement.checkPermission() returns true for any custom permission granted by those sets:

apex
// In production code:
if (FeatureManagement.checkPermission('Manage_Inventory')) {
    // This branch now executes in tests when Inventory_Manager PS is assigned
}

How it works end-to-end

renmy models the full Salesforce permission chain without hitting an org:

  1. .customPermission-meta.xml → CustomPermission record seeded in DB
  2. .permissionset-meta.xml → PermissionSet record + SetupEntityAccess linking PS → CustomPermission
  3. nimbus.mock.permission-sets → PermissionSetAssignment linking running user → PermissionSet
  4. FeatureManagement.checkPermission(name) → queries this chain and returns true

Full permissions example

bash
# nimbus.properties

# Running user's profile
nimbus.mock.user-profile=My Profile

# Permission sets assigned to the running user
# API names match .permissionset-meta.xml filenames
nimbus.mock.permission-sets=Inventory_Manager,Support_Agent

# Additional profiles to seed for user creation in tests
nimbus.seed.profiles=Field Sales,Partner Community

Person accounts

The feature is on when nimbus.org.features or --feature names PersonAccounts, when the project has objects/PersonAccount/, or when config/project-scratch-def.json lists it. With person accounts on, Account carries IsPersonAccount, PersonContactId, the name fields, the Person* fields and one X__pc field for every Contact custom field X__c, and Contact carries IsPersonAccount.

Inserting an account whose record type is a person account record type sets IsPersonAccount, derives Name from FirstName and LastName, and creates its contact in the same statement, before the after-insert triggers. An account inserted with person fields and no RecordTypeId takes the person account record type your profile marks personAccountDefault. An update of either record writes through to the other, and deleting the account deletes the contact. The person contact cannot be deleted or moved to another account on its own, and a business account refuses FirstName, LastName and the other person fields.

Person account record types come from objects/PersonAccount/recordTypes/ and load as Account record types with IsPersonType true. When the feature is on and the project defines none, renmy adds the standard PersonAccount record type, as an org does when the feature is enabled. These rules follow the Salesforce documentation and have not been checked against an org with person accounts.

Supported Metadata

renmy reads metadata directly from your SFDX project directory. No org sync required for most types — just point at your repo and run. This page lists every metadata type renmy understands today and what it does with each one.

Code

FlagDefaultDescription
.clsSupportedApex classes — parsed, compiled, and executed. Includes interfaces, enums, inner classes, and all annotations.
.triggerSupportedApex triggers — registered and fired automatically on DML operations.

Automation

FlagDefaultDescription
.flow-meta.xmlSupportedFlows — record-triggered, autolaunched and platform event flows and their subflows are parsed and executed; a scheduled flow is parsed and can be started from a test through Flow.Interview, nothing runs it on its schedule. A fault reaches Apex as a DmlException or FlowException. See Flow faults. Read from package directories and from stubs/flows/; a package copy wins over a stub of the same name.
.validationRule-meta.xmlSupportedValidation rules — evaluated during DML operations, including fields read through relationship paths such as Account.Type or Owner:User.Profile.Name. Read from package directories and from stubs/objects/<Object>/validationRules/; a package copy wins over a stub of the same name, active or not.
.duplicateRule-meta.xml, .matchingRule-meta.xmlSupportedDuplicate rules and their matching rules, evaluated on insert and update. The standard Account, Contact and Lead rules are built in. See Duplicate rules.
.assignmentRules-meta.xml, .assignmentRulesSupportedCase and Lead assignment rules, run on insert and update when the DML carries an assignment rule header. Read from package directories and from stubs/assignmentRules/; a package directory’s file for an object wins whole over a stub file for it. See Assignment rules.
.workflow-meta.xml, .workflowSupportedWorkflow rules. Field updates, tasks and email alerts run; time-dependent actions and outbound messages do not. Read from package directories and from stubs/workflows/; a package directory’s file for an object wins whole over a stub file for it. See Workflow rules.
.flow-meta.xml (processType Workflow)SupportedProcess Builder processes, run after save and before after-save flows. Scheduled actions do not run. See Process Builder.
.approvalProcess-meta.xml, .approvalProcessSupportedApproval processes, run through Approval.process and the Submit for Approval flow action. Flow-based approvals do not run. Read from package directories and from stubs/approvalProcesses/; a package copy wins over a stub of the same object and name. See Approval processes.

Salesforce no longer lets you create workflow rules or processes, and the ones an org already has keep running. renmy runs the ones in your source. Salesforce's Migrate to Flow tool converts them to flows.

A validation rule or flow an admin keeps in Setup, rather than in your source, goes under stubs/ in the source layout: stubs/objects/<Object>/validationRules/ and stubs/flows/. renmy stub pull --types ValidationRule,Flow writes them there, and renmy sync -o names the active ones your project lacks. A run reads stubs/ after your package directories, and a rule or flow a package directory deploys wins over a stub of the same name. Each runs as its own file says, whatever the org's copy is set to now. A directory under stubs/objects/ that holds only an object's rules does not declare the object: its label, fields and settings still come from your source or the org.

Workflow rules, approval processes and assignment rules an admin keeps in Setup go under stubs/workflows/, stubs/approvalProcesses/ and stubs/assignmentRules/; renmy stub pull --types WorkflowRule,ApprovalProcess,AssignmentRule writes them there. An approval process is one file, and a package directory's copy wins by object and name. A workflow or assignment rules file holds every rule of one object, and a package directory's file for an object wins whole: the stub file for that object is not read at all, and a run names it on stderr. What your pipeline deploys for an object is then what runs for it.

Data & Schema

FlagDefaultDescription
Standard objectsSupportedCommon standard objects ship with embedded schemas (Account, Contact, Lead, Opportunity, Case, User, and more). No sync required. Syncing with an org connected additionally folds in feature-gated standard fields the org has enabled (Entitlements, Field Service, CPQ), and reads the org-wide defaults so sharing behaves the way your org has it configured.
Shared ActivitiesSupportedWhen your Apex names TaskWhoRelation or EventWhoRelation, a saved Task or Event gets the junction rows the platform writes for its WhoId and WhatId (TaskWhoRelation and TaskRelation, EventWhoRelation and EventRelation), a changed or cleared WhoId or WhatId moves them, a deleted Task takes them and an undelete brings them back, and your own TaskRelation inserts and deletes add and remove a who. WhoCount and WhatCount are kept once a sync brought the columns. A lead who beside a What is refused with the org’s message, on insert and on update.
Org-wide defaultsSupportedA standard object’s OWD comes from the org when you sync with one, else from a <sharingModel> in the project’s own objects/<Object>/<Object>.object-meta.xml. A restrictive OWD (Private or Public Read Only) filters rows in user-mode SOQL and creates the implicit RowCause=Owner share row on insert. Custom objects have always read their own sharingModel. A share row whose access level the default already grants is refused as the org refuses it (trivial share level), judged against the default of the user it grants to: the internal default for an internal user, the external default (<externalSharingModel>, or the org’s) for an Experience Cloud, portal or partner user. A share to a group is never refused for its level.
.object-meta.xmlSupportedCustom objects — fields, record types, and relationships are used to build the local database schema.
.object-meta.xml (__mdt)SupportedCustom metadata types — object definitions discovered automatically from __mdt suffixed objects.
.object-meta.xml (__c settings)SupportedCustom settings (hierarchy and list) — object definitions loaded, records seedable via nimbus.properties.
.field-meta.xmlSupportedCustom fields — all standard field types including formula fields, lookups, and master-detail.
.fieldSet-meta.xmlSupportedField sets — parsed and available for FieldSet describe calls. Also read from stubs/objects/<Object>/fieldSets/ (renmy stub pull --types FieldSet); a package directory’s field set of the same name wins.
.recordType-meta.xmlSupportedRecord types — seeded into the database and available via SOQL. Also read from stubs/objects/<Object>/recordTypes/ (renmy stub pull --types RecordType); a package directory’s record type of the same name wins.
.listView-meta.xmlSupportedList views — auto-discovered and seeded for ListView SOQL queries.
.globalValueSet-meta.xmlSupportedGlobal picklist value sets — loaded and referenced by picklist fields. Also read from stubs/globalValueSets/ (renmy stub pull --types GlobalValueSet); a package directory’s set of the same name wins.
.standardValueSet-meta.xmlSupportedEvery standard value set, in either layout (.standardValueSet-meta.xml or .standardValueSet): its values, labels, default and inactive flags become those of the standard picklists bound to it (ContactRole to OpportunityContactRole.Role, LeadSource to the LeadSource fields of Lead, Contact, Opportunity and CampaignMember, and so on), and CaseStatus, TaskStatus and OpportunityStage also become their rows, with each value’s closed, won, probability and default flags. Read from a package directory, else from stubs/standardValueSets/.
.territory2-meta.xmlSupportedTerritory2 records — seeded and queryable via SOQL.

Security & Access

FlagDefaultDescription
.permissionset-meta.xmlSupportedPermission sets — parsed for custom permission grants and assignable to the running user.
.customPermission-meta.xmlSupportedCustom permissions — seeded and checkable via FeatureManagement.checkPermission().
.profile-meta.xmlPartialProfiles — standard profiles are pre-seeded, and every profile in the project becomes a Profile row. A project profile’s object, field and system permissions apply to its users inside System.runAs. Record type defaults are read. Other profile settings are not.

Configuration

FlagDefaultDescription
.labels-meta.xmlSupportedCustom labels — loaded and accessible via System.Label.
.translation-meta.xmlSupportedTranslations — label translations loaded per language.
.md-meta.xmlSupportedCustom metadata type records — seeded into __mdt tables and queryable via SOQL.
.resource-meta.xmlSupportedStatic resources — file content available for Test.loadStaticResource() and callout mocks.
.report-meta.xmlSupportedReports: each report becomes a Report row (DeveloperName, Name, FolderName, Format, OwnerId) that only a SeeAllData test sees, as on the platform, where Report rows are org data. Report folders become Folder rows of type Report, which every test sees.
.quickAction-meta.xmlPartialQuick actions — listed by QuickAction.describeAvailableQuickActions and describeQuickActions after the platform’s standard actions. Also read from stubs/quickActions/ (renmy stub pull --types QuickAction); a package directory’s action of the same name wins.

CumulusCI projects

A project with a cumulusci.yml at its root is read the way CumulusCI builds a developer org. The project's dev_org flow, merged over CumulusCI's own dev_org, dependencies, deploy_unmanaged and config_dev, decides what loads besides the package directories: each bundle under unpackaged/pre, then the package directories, then each bundle under unpackaged/post and the path of every Deploy task the flow runs, in flow order. A record type or profile setting two of them declare takes the later one's value, as a deploy does. The record types update_admin_profile lists become visible to the System Administrator, and the ones it marks default: true become that profile's defaults; nimbus.default-record-type.<Object> still wins. A step's when: is evaluated for a scratch org without the project's own package installed; a condition outside that, such as an environment variable or another package's installation, skips the step, and the run says so. Every file goes through CumulusCI's unmanaged namespace rewrite. A test run names the bundles it loads before it starts, and -k with --isolate loads none. Set nimbus.cumulusci.devOrgFlow=false to load the package directories only.

Declarative automation

renmy runs duplicate rules, assignment rules, workflow rules and Process Builder processes on local DML, alongside validation rules, triggers and flows. On an insert or update the order is before-save flows, before triggers, validation rules, duplicate rules, the save, after triggers, assignment rules, workflow rules, processes and after-save flows. Approval processes run when a test calls Approval.process. Each has its own section below: duplicate rules, assignment rules, workflow rules, Process Builder, flow faults and approval processes. Validation rules read fields on parent records through relationship paths, several levels up. Person accounts are described under Person accounts, and the permissions a project profile grants under Salesforce profiles.

A statement over 200 records runs that whole order per chunk of 200, as the platform does: before triggers, the save, after triggers and after-save flows for the first 200 records, then the next 200. A list holding several object types starts a new chunk at each type change, and more than ten chunks throw System.TypeException with the platform's message; List<SObject>.sort() orders by object type first, which is how a test stays under that limit. An all-or-none statement runs every chunk and reports every failing row before it rolls back. A row a trigger, validation rule or duplicate rule rejects leaves its chunk while the valid rows run on, and a trigger that throws fails its whole chunk with CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY and the platform's text.

Duplicate rules

renmy reads the duplicate rules and matching rules in your package directories (duplicateRules/*.duplicateRule-meta.xml and matchingRules/*.matchingRule-meta.xml) and under stubs/ (stubs/duplicateRules/, stubs/matchingRules/), which is where a rule an admin owns in Setup goes when you do not want it in deployable source. A rule a package directory deploys wins over a stub of the same name. Active rules run on insert and update, after validation rules and before the row is written.

renmy sync -o <org> names every active rule the org has that your project defines nowhere, and renmy stub pull --org <org> --rules fetches those rules, and the matching rules they name, into stubs/ in the org's own XML. The pull never overwrites a file, so a stub you tuned by hand stays as it is until you delete it. A team that deploys its rules from source retrieves a missing one into a package directory instead, and that copy wins over any stub.

A rule whose action is Block, or Allow with an alert, refuses a matching row with DUPLICATES_DETECTED and the rule's alert text. Apex cannot acknowledge an alert, so Salesforce refuses that save too. A plain insert throws a DmlException. Database.insert(records, false) fails only the duplicate rows, and the error on each is a Database.DuplicateError whose getDuplicateResult() names the rule and lists, per matching rule, the matched records with their match confidence and field differences. Rows in one statement are not compared with each other: two identical new records in one insert both save, as they do in an org. With Database.DMLOptions.DuplicateRuleHeader.allowSave set to true, a row that only an alert refused saves. A Block rule still refuses it, as it does in an org.

Datacloud.FindDuplicates.findDuplicates and Datacloud.FindDuplicatesByIds.findDuplicatesByIds answer from the same rules. Cross-object matching rules are evaluated, in DML and in these calls: the standard Contact rule also compares the record with existing Leads. An empty list, or a list whose records belong to an object with no active duplicate rule, throws System.HandledException with the platform's text (The FindDuplicates API doesn't support the Opportunity object.); an Id that no record has returns a result with no matches.

Every org ships three standard rules, for Account, Contact and Lead, and they are active in a new org. renmy has them built in and runs them unless nimbus.duplicateRules.standard is inactive; set that when your org has them turned off. They refuse a matching insert and let an update through, as in an org. Their fuzzy matching is renmy's approximation of the platform's matching engine. A rule with the same name in your source replaces the built-in one. A set_duplicate_rule_status task in cumulusci.yml is honoured: the rules its api_names option lists run or not as its active option says, and nimbus.duplicateRules.standard, when set, still decides for the standard rules. renmy sync -o <org> records which duplicate rules the org has active, and a run follows that for the standard rules and the rules in your source unless nimbus.duplicateRules.standard or cumulusci.yml says otherwise.

bash
# nimbus.properties
# The standard Account, Contact and Lead duplicate rules run by default.
# Turn them off when your org has them deactivated.
nimbus.duplicateRules.standard=inactive

A rule with the Report operation writes a DuplicateRecordSet and its DuplicateRecordItem rows for each duplicate it lets through. The standard rules write none. DuplicateRule rows exist for the standard rules and for every rule in your source.

renmy reads existing records without sharing, so a rule's record-level security setting (securityOption) and DuplicateRuleHeader.runAsCurrentUser change nothing. The records Database.convertLead creates are not checked.

Assignment rules

renmy reads the Case and Lead assignment rules in your package directories, from assignmentRules/<Object>.assignmentRules-meta.xml and from classic <Object>.assignmentRules files. A rule runs only when the insert or update asks for it through Database.DMLOptions.assignmentRuleHeader, passed to Database.insert or Database.update or set on the record with setOptions. useDefaultRule = true runs the object's active rule, and assignmentRuleId runs the rule with that Id, active or not. DML without the header, or with useDefaultRule = false, runs no rule. A header that sets both throws a System.UnexpectedException that no catch block catches, as it does on the platform. The header on a Database call applies to that call's records, and DML run by their triggers does not inherit it.

The rule's entries are evaluated in order. An entry's criteria are criteria items, combined by its filter logic when it has one, or a formula. A criteria value may list several values separated by commas, a criterion may read a field of the related account such as Account.Type, and a RecordTypeId criterion names the record type as the rule file does. The first entry that matches sets OwnerId to its user, found by username, or to its queue, found by developer name. An entry without an assignee keeps the owner, and so does a record no entry matches. A queue that does not take the object fails the DML statement with INVALID_OPERATION, Queue not associated with this SObject type.

The rule runs after the after triggers and writes the owner in one additional save, as Salesforce does: the insert or update triggers see the original owner, and the before-update and after-update triggers then run once more with the new owner in Trigger.new. An insert with a matching rule fires before insert, after insert, before update and after update, in that order. That save skips validation rules, duplicate rules and before-save flows, as a workflow field update's does. In a Salesforce org a validation rule that refuses an owner on a direct insert does not refuse the assignment rule that sets that owner, and renmy behaves the same. Workflow rules run after it and see the new owner. The record variable in your test keeps its owner: requery the record to read the new one.

Each rule has an AssignmentRule row with its name, object and active flag, so SELECT Id FROM AssignmentRule WHERE SobjectType = 'Case' AND Active = true finds the Id to pass as assignmentRuleId. An entry's email template is recorded as an email to the new owner, as a workflow email alert is, and not sent.

A user or queue that is not in the local database leaves the owner unchanged. With NIMBUS_DEBUG_ASSIGNMENT=1 in its environment, the process that runs the tests prints why on stderr as an [ASSIGNMENT] line. Case teams are not assigned, and a record no entry matches does not go to the default case or lead owner in your support or lead settings.

Assignment rules an admin keeps in Setup go under stubs/assignmentRules/<Object>.assignmentRules-meta.xml, where renmy stub pull --types AssignmentRule writes them. A package directory's file for an object wins whole: the stub file for that object is not read, so its active rule never becomes the default beside your package's, and the run names the file it passed over on stderr. Each rule is active as its file says; renmy sync -o names the org's active rules your project lacks and changes nothing a run reads.

Workflow rules

Salesforce retired workflow rules: new ones cannot be created, and the rules an org already has keep running. renmy runs the active rules in your source, read from workflows/<Object>.workflow-meta.xml and from classic <Object>.workflow files. It evaluates them after the after triggers and before processes and after-save flows, by each rule's evaluation criteria:

  • onCreateOnly: when a record is created.
  • onAllChanges: when a record is created and every time it is edited.
  • onCreateOrTriggeringUpdate: when a record is created, and when an edit takes it from not meeting the criteria to meeting them.

Field updates write a literal, a formula result (PRIORVALUE works), null, the next or previous picklist value, or a lookup value, on the record itself or, for a cross-object update, on its master record. renmy applies them as one additional save. That save runs the before-update and after-update triggers once more, with Trigger.old holding the record as it was before the original update, and skips validation rules, duplicate rules and before-save flows. Processes and after-save flows run afterwards and see the new values. The record variable in your test does not: requery the record to read a field update. A field update set to re-evaluate workflow rules makes renmy evaluate the object's rules again, up to five times, without running the triggers again.

Tasks are created with their subject, status, priority, owner and due date, linked to the record through WhatId (WhoId on a Lead or Contact rule). An assignee renmy cannot resolve to a user, such as a role or a queue, becomes the running user. Workflow tasks do not count against the DML limits and do not fire Task triggers. Email alerts are not sent and do not count against Limits.getEmailInvocations(). renmy records each one, with the recipients it can resolve to an address and the template's subject, and a traced run lists them in its trace's emails section beside the emails Messaging.sendEmail sent (see renmy trace).

Time-dependent actions, outbound messages, flow trigger actions and knowledge article publishing do not run, and a rule whose criteria use the geolocation within operator is skipped. With NIMBUS_DEBUG_WORKFLOW=1 in its environment, the process that runs the tests prints each of these on stderr, once per rule, as a [WORKFLOW] line; under renmy daemon that process is the daemon. A rule whose formula cannot be evaluated, or that names an action missing from the workflow file, fails the DML statement.

bash
NIMBUS_DEBUG_WORKFLOW=1 renmy test "OpportunityTest.*"

Workflow an admin keeps in Setup goes under stubs/workflows/<Object>.workflow-meta.xml, where renmy stub pull --types WorkflowRule writes the org's file for each object. A package directory's workflow file for an object wins whole: the stub file for that object is not read, not even for a rule only it has, and the run names the file it passed over on stderr. Workflow files of one object in two package directories still merge. A rule runs as its <active> in the file says; the org does not say in one query which rules are active, so renmy sync -o names every rule of an object your project has no workflow file for.

Process Builder

Salesforce retired Process Builder as it did workflow rules: new processes cannot be created, and existing ones keep running. renmy reads processes from the flow files in your source (<processType>Workflow</processType>) and runs each one after save on its object, for the event it declares: a record is created, or a record is created or edited. The process's record variables are bound, myVariable_current to the saved record and myVariable_old to its prior version (null on create). Processes run after workflow rules and before after-save record-triggered flows.

Decisions, record updates, record creates, Apex actions, email alerts and the Flows action (which runs a flow) work. An email alert is not sent, as in workflow rules. In a decision, IsChanged is true when a field differs from its prior value and never on create, and WasSet is true for every field of the triggering record. Decisions in record-triggered flows evaluate both operators the same way, and so does IsChanged in a record-triggered flow's entry conditions. Custom condition logic such as (1 OR 2) AND 3 is evaluated in decisions and in entry conditions. An entry condition written as a formula (Formula Evaluates to True) is evaluated for each saved record, with $Record__Prior null on create, and the flow runs only for the records it is true for.

Scheduled actions do not run: the process stops where they would wait, and the save that fired it succeeds. A flow that reaches a Pause element stops there the same way. With NIMBUS_DEBUG_FLOW=1, renmy prints one line for each flow or process that stopped at a wait. Post to Chatter and quick actions do nothing. A process that updates its own record is not evaluated again for that update.

Flow faults

A record-triggered flow that faults fails the DML that fired it with a catchable System.DmlException, as in an org. A Custom Error element reports FIELD_CUSTOM_VALIDATION_EXCEPTION with its own message, in before-save and after-save flows alike. Any other unhandled fault reports CANNOT_EXECUTE_FLOW_TRIGGER with the platform's message, which names the flow's label and the element's error, such as This error occurred when the flow tried to update records: … or, for an exception thrown by an invocable Apex action, An Apex error occurred: <type>: <message>. With Database.insert(records, false) only the row whose interview faulted fails.

A flow started from Apex with Flow.Interview.start() throws a catchable System.FlowException whose message is the element's error alone. The records the interview wrote before the fault stay written. A fault connector catches the fault instead, and $Flow.FaultMessage carries the same text with the SOAP API Developer Guide's name wrapped in an HTML link, as on the platform. A governor limit exceeded by a flow's DML throws the uncatchable System.LimitException, and a Get Records whose query fails faults the flow instead of reading as an empty result.

Approval processes

renmy reads the approval processes in your package directories (approvalProcesses/<Object>.<Name>.approvalProcess-meta.xml) and runs them when a test calls Approval.process. A ProcessSubmitRequest submits a record, and a ProcessWorkitemRequest approves, rejects or recalls it with the action Approve, Reject or Removed. The Submit for Approval action of a flow or process submits a record the same way.

A submission enters the first active process of the object, in processOrder, whose entry criteria (criteria items or a formula) the record meets. setProcessDefinitionNameOrId names the one process to try, and setSkipEntryCriteria(true) then skips its entry criteria; without a named process that flag changes nothing. A step's approver is a user, a queue, the user in a lookup field on the record (related user field), a user hierarchy field such as Manager on the submitter's user or the record owner's, or the user the request passes to setNextApproverIds (ad hoc). A username renmy has no user for resolves to the running user. whenMultipleApprovers decides whether the first response or every approver's approval completes a step, ifCriteriaNotMet approves the record, rejects it or goes to the next step when the record does not meet a step's criteria, and rejectBehavior sends a rejection back to the previous step or rejects the request.

Each request writes ProcessInstance, ProcessInstanceStep and ProcessInstanceWorkitem rows the test can query, and the ProcessInstanceHistory rows a StepsAndWorkitems subquery on ProcessInstance returns: one for each step, under the step's Id, and a Pending one for each open workitem, under the workitem's Id. ProcessDefinition rows exist for every process in your source, and ProcessNode rows for its steps. The initial submission, step approval and rejection, final approval, final rejection and recall actions run the field updates, tasks and email alerts they name in workflows/<Object>.workflow-meta.xml. Each group of actions is saved as workflow field updates are saved: one additional save that runs the update triggers once and skips validation rules, duplicate rules, workflow rules, processes and flows. An approval that runs step approval and final approval actions makes two such saves, step first, as it does in an org. When a request fails, renmy undoes everything it wrote.

A submitted record is locked. An update or delete by a user who may not edit it fails with ENTITY_IS_LOCKED and the message This record is locked. If you need to edit it, contact your admin. Users with Modify All Data, such as System Administrators, may edit it, and so may the current approvers when recordEditability is AdminOrCurrentApprover. The final approval or rejection keeps the lock only when finalApprovalRecordLock or finalRejectionRecordLock is set, and a recall unlocks the record. Approval.lock, Approval.unlock and Approval.isLocked work on the same lock, with or without approval processes in the project.

A user without Modify All Data submits a record only to a process whose allowedSubmitters include them: the record's owner or creator, a named user, a member of a named public group, a user in a named role (or a role below it for roleSubordinates), or anyone but a guest user for allInternalUsers. The portal submitter types never match. Otherwise the request fails with NO_APPLICABLE_PROCESS. Such a user approves or rejects only a workitem assigned to them or to a queue they belong to, and any other workitem fails with INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY. A recall needs Modify All Data, or the submitter when allowRecall is set. Users with Modify All Data submit any record and act on any workitem.

Approval.lock and Approval.unlock need the org preference Enable record locking and unlocking in Apex. renmy reads it from enableApexApprovalLockUnlock in settings/Apex.settings-meta.xml, else in config/project-scratch-def.json, else in stubs/settings/Apex.settings-meta.xml. A project that does not declare it has it off, as a new org does. While it is off, both calls throw a System.UnexpectedException that no catch block catches, as they do on the platform, and every Approval.isLocked overload throws a System.NoAccessException with the same message, which a catch block does catch. With it on, Approval.isLocked of a list of Ids or records returns a Map<Id, Boolean>.

When no active process admits the record, Approval.process throws a System.DmlException with NO_APPLICABLE_PROCESS and the message No applicable approval process was found., and Approval.process(request, false) and Approval.process(requests, false) return a failed ProcessResult with that error and a null status, instance and record. A process name in setProcessDefinitionNameOrId that names no process fails the same way. Submitting a record that is already pending fails with ALREADY_IN_PROCESS, and a workitem Id that names no workitem with INVALID_CROSS_REFERENCE_KEY. These are the shapes Salesforce returns in a test. After the final approval or rejection, getActorIds() returns null and getNewWorkitemIds() an empty list, and a successful request's getErrors() returns null. A project with no approval process in its source has no process that applies, so a submission of a saved record fails with NO_APPLICABLE_PROCESS and a workitem request with INVALID_CROSS_REFERENCE_KEY.

Flow-based approval processes do not run. Email alerts are not sent, as in workflow rules, and approvers get no approval request email. Outbound message actions do not run, and an action missing from the workflow file fails the request. With NIMBUS_DEBUG_APPROVAL=1 in its environment, the process that runs the tests prints the detail behind a failed request on stderr, such as an entry-criteria formula it could not evaluate, as an [APPROVAL] line.

Approval processes an admin keeps in Setup go under stubs/approvalProcesses/<Object>.<Name>.approvalProcess-meta.xml, where renmy stub pull --types ApprovalProcess writes them. A package directory's copy of a process wins over a stub of the same object and name, active or not. A stub process gets its ProcessDefinition and ProcessNode rows like one in your source. Its field updates live in its object's workflow file, so fetch --types WorkflowRule with it when the process uses them.