Salesforce · declarative automation + Apex

Signal-driven lead routing, deployed and tested in a real Salesforce org

A custom object, five custom fields on Lead, a before-save Flow, a validation rule and permission set for the declarative layer — plus a trigger, a rollup service and a Batchable/Schedulable job in Apex for the roll-up the Flow can't do. Deployed through the Salesforce CLI, with the deploy failures and the one real test failure written down rather than tidied away.

Scope, stated plainly

This was built in a free Developer Edition org by me, for a portfolio. It is not production Salesforce administration and no claim of that is made anywhere here. What it demonstrates is the metadata itself — declarative automation, field-level security, Apex, and the deploy cycle.

Deployed to org 00DgL00000bRdZRUA0 via Salesforce CLI 2.148.3, API version 62.0. Developer Edition org, built for this portfolio.

Apex status: deployed and verified, 2026-08-24. The trigger, service and batch job below are deployed to the same org. 20/20 tests passing, 96% org-wide coverage — well above Salesforce's 75% deploy gate. Raw sf apex run test output (human and JSON) is committed at evidence/. The first real run of this suite was 19/20, not 20/20 — see What broke when this actually ran for the failure, which is more informative than the clean number alone.

What it does

The threshold logic mirrors the CRM routing in revenue-engine-demo, so the two artifacts describe the same system in two different stacks.

Signal fires → Signal Score on Lead → before-save Flow → Routing Tier
                                                            │
                        Tier 1 ≥80 · Tier 2 60–79 · Tier 3 <60
                                                            │
                            Validation rule blocks Qualified below 60

Components

ComponentTypeWhat it is for
GTM_Signal__cCustom objectOne buying-trigger signal on a lead: score, source, detected date, and the verbatim evidence text it came from
Lead.Signal_Score__cNumber(3)Buying-trigger intensity 0–100
Lead.Signal_Source__cPicklistWhich detector produced it
Lead.Routing_Tier__cPicklistDerived by the Flow — never set by hand
Lead.Signal_Detected_Date__cDateDrives decay
Lead.Signal_Age_Days__cFormulaTODAY() − detected date. A trigger from 16 months ago is not a trigger
Lead_Signal_RoutingFlowBefore-save, create+update. Sets tier from score
Qualified_Requires_SignalValidation ruleA lead cannot reach Qualified below score 60
GTM_Signal_EngineeringPermission setGrants FLS on everything above

The Flow runs before-save deliberately. A record-triggered flow that only sets fields on its own record needs no DML — before-save is materially cheaper than an after-save flow doing an update, and it is the difference between a flow that scales and one that hits limits.

Apex: the rollup the Flow cannot do

GTM_Signal__c is a child of Lead, and a Lead can carry several signals. The Lead needs to reflect its strongest current one — that is a roll-up, and it cannot be declarative.

Roll-Up Summary fields require master-detail. Lead cannot be a master-detail parent — the deploy error in the table above is exactly that — so Lead__c is a Lookup, and Lookup relationships get no declarative rollup. This is the case where Apex is the correct answer rather than the impressive-looking one.

ComponentTypeWhat it does
GTMSignalTriggerTriggerafter insert/update/delete/undelete on GTM_Signal__c; decides which Leads changed
GTMSignalRollupServiceClassRecomputes Signal_Score__c, Signal_Source__c, Signal_Detected_Date__c from a Lead's children
SignalDecayBatchBatchable + SchedulableNightly sweep for signals that expired through the passage of time
GTMSignalRollupServiceTestTest13 methods
SignalDecayBatchTestTest7 methods

Four decisions worth defending

The test that matters most is rollupIsBulkSafeAcrossTwoHundredRecords: it inserts 200 signals in one DML and asserts SOQL and DML counts stay flat. A rollup with a query inside the record loop passes every single-record test and then dies the first time someone imports a CSV.

What broke when this actually ran

The first real test run against the connected org (2026-08-24) came back 19/20, not 20/20. The one failure — SignalDecayBatchTest.schedulableEntryPointRuns — is worth keeping in the history rather than quietly rewriting, because the bug was in the test's assumption about the platform, not in SignalDecayBatch itself.

The test scheduled the batch via System.schedule() and asserted on the Lead's rolled-up score immediately after Test.stopTest(). That's the textbook way to test Scheduled Apex — Test.stopTest() does run the scheduled job's execute(SchedulableContext) synchronously. But execute() here does nothing but call Database.executeBatch(), and that inner batch job is a second, nested async job started from inside the first one's forced synchronous run. On this org, that nested batch had not finished by the time the assertion ran, so the score was still 90, not 0 — a real, reproducible ordering gap between "the scheduled job fired" and "the batch it starts has completed," not a flaw in the rollup logic (six other tests exercise that logic directly and all passed both times).

The fix removes the race instead of retrying past it. execute(SchedulableContext) never reads its argument, so it can be called directly — new SignalDecayBatch().execute((SchedulableContext) null) — which exercises the exact same production code deterministically, inside the same Test.startTest()/Test.stopTest() window every other batch test in this class already relies on. The concern the old test conflated got split in two: schedulableEntryPointTriggersTheBatch proves execute() correctly starts the batch (deterministically); canBeRegisteredOnACronSchedule proves the class can actually be scheduled (asserting on the CronTrigger record, not on the batch's side effects). Re-run after the fix: 20/20, same 96% coverage — see evidence/apex-test-run-2026-08-24.txt.

Verified in the org

Output from the deployed org (00DgL00000bRdZRUA0), transcribed — not a description of intent.

What you can and cannot check from this repo. The metadata, the Flow, the validation rule and the queries are all here and readable. The results below are transcribed by hand: no deploy log and no raw SOQL output is committed, so nothing in this repo independently proves the deployment happened. You have my word plus an org ID, which is weaker evidence than the rest of this portfolio offers, and it should be read that way. Committing the sf project deploy log and the raw query output is the fix, and it is on the list.

Flow fires on insert

LeadSignal ScoreRouting Tier (set by Flow)Signal Age
TestAlpha85Tier 1 - Immediate11
TestBravo70Tier 2 - Nurture11
TestCharlie40Tier 3 - Hold11

Validation rule blocks correctly

Routing integrity query returns zero rows

No lead’s tier disagrees with its own score (query 5 in soql/gtm-queries.soql).

One correction worth recording. The version of query 5 that produced that result only checked the >=80 and <60 bands. It never checked that a 60–79 lead landed in Tier 2 — which is most of what the Flow decides, and exactly the case TestBravo (70) covers. So the original “zero rows” was a weaker statement than it sounded: it could not have caught a mis-tiered middle-band lead. The query now tests all three bands plus scored-but-untiered leads. The zero-row result above predates that fix and has not been re-run against the corrected query.

The gotcha worth writing down

The first deploy reported Succeeded — and every custom field on Lead was still invisible. sf sobject describe didn’t list them and SOQL returned “No such column.”

Custom fields deployed through the Metadata API carry no field-level security. Creating a field in the UI prompts you to grant profile access; deploying the same field as metadata does not, so the field exists and nobody — including the System Administrator — can see it. That is what GTM_Signal_Engineering is for.

Three other deploy failures, all caught by --dry-run before touching the org:

ErrorCause
Cannot add a master/detail relationship child to LeadLead cannot be a master-detail parent → changed to Lookup
Lead does not have history tracking enabledDropped trackHistory rather than enabling org-wide history
Element value invalid ... in type FlowConditionFlowCondition takes <rightValue>, not <value>
You cannot deploy to a required fieldRequired fields cannot carry field permissions — they are always visible

Running --dry-run first is the entire reason none of these hit the org.

Reproduce it

npm install @salesforce/cli
npx sf org login web --alias gtmorg
npx sf project deploy start --source-dir force-app --target-org gtmorg --dry-run
npx sf project deploy start --source-dir force-app --target-org gtmorg
npx sf org assign permset --name GTM_Signal_Engineering --target-org gtmorg
npx sf apex run test --target-org gtmorg --code-coverage --result-format human

That last command is what produced evidence/apex-test-run-2026-08-24.txt — re-running it against a fresh org reproduces the same 20/20 result.

soql/gtm-queries.soql holds six queries: the Tier 1 send queue, a signal-decay report, tier distribution, a fill-rate data-quality check, a routing-integrity assertion, and the signal roll-up per lead.

Files

force-app/main/default/objects/GTM_Signal__c/      custom object + 5 fields
force-app/main/default/objects/Lead/fields/        5 custom fields
force-app/main/default/objects/Lead/validationRules/
force-app/main/default/flows/                      before-save routing flow
force-app/main/default/permissionsets/             FLS for all of the above
force-app/main/default/triggers/                   GTM_Signal__c trigger
force-app/main/default/classes/                    rollup service, decay batch, 20 test methods
soql/gtm-queries.soql                              6 GTM queries
evidence/apex-test-run-2026-08-24.txt              real `sf apex run test` output (human)
evidence/apex-test-run-2026-08-24.json             same run, machine-readable

Built something similar for your stack?

I build signal-detection, enrichment and outbound systems inside your own tools. You own everything — nothing is locked to me.