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
| Component | Type | What it is for |
|---|---|---|
| GTM_Signal__c | Custom object | One buying-trigger signal on a lead: score, source, detected date, and the verbatim evidence text it came from |
| Lead.Signal_Score__c | Number(3) | Buying-trigger intensity 0–100 |
| Lead.Signal_Source__c | Picklist | Which detector produced it |
| Lead.Routing_Tier__c | Picklist | Derived by the Flow — never set by hand |
| Lead.Signal_Detected_Date__c | Date | Drives decay |
| Lead.Signal_Age_Days__c | Formula | TODAY() − detected date. A trigger from 16 months ago is not a trigger |
| Lead_Signal_Routing | Flow | Before-save, create+update. Sets tier from score |
| Qualified_Requires_Signal | Validation rule | A lead cannot reach Qualified below score 60 |
| GTM_Signal_Engineering | Permission set | Grants 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.
| Component | Type | What it does |
|---|---|---|
| GTMSignalTrigger | Trigger | after insert/update/delete/undelete on GTM_Signal__c; decides which Leads changed |
| GTMSignalRollupService | Class | Recomputes Signal_Score__c, Signal_Source__c, Signal_Detected_Date__c from a Lead's children |
| SignalDecayBatch | Batchable + Schedulable | Nightly sweep for signals that expired through the passage of time |
| GTMSignalRollupServiceTest | Test | 13 methods |
| SignalDecayBatchTest | Test | 7 methods |
Four decisions worth defending
- One writer per field. Apex owns the inputs (
Signal_Score__cand friends); the Flow remains the only writer ofRouting_Tier__c. Two automations writing one field is how orgs end up with records that disagree with themselves — it's also whySignalDecayBatchzeroes a score rather than setting a tier directly. - Zero, not null, when every signal has expired. The Flow's entry criteria require
Signal_Score__cto be non-null. Nulling an expired score would stop the Flow firing at all and strand the Lead on whatever tier it last held — the opposite of the intent. - No
hasRunrecursion guard, deliberately. Nothing on Lead writes back toGTM_Signal__c, so there's no cycle to break — and the usual static-boolean guard is actively harmful in bulk: a 10,000-record load fires the trigger in 50 chunks in one transaction, and a transaction-scoped boolean would silently skip 49 of them. - Reparenting recalculates both Leads. If a signal's
Lead__cchanges, the Lead that lost it is recalculated too — skipping that leaves the old parent permanently overstated.
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
| Lead | Signal Score | Routing Tier (set by Flow) | Signal Age |
|---|---|---|---|
| TestAlpha | 85 | Tier 1 - Immediate | 11 |
| TestBravo | 70 | Tier 2 - Nurture | 11 |
| TestCharlie | 40 | Tier 3 - Hold | 11 |
Validation rule blocks correctly
- Qualifying
TestCharlie(score 40) → rejected: “This lead cannot be Qualified: it needs a Signal Score of 60 or higher.” - Qualifying
TestAlpha(score 85) → allowed.
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:
| Error | Cause |
|---|---|
| Cannot add a master/detail relationship child to Lead | Lead cannot be a master-detail parent → changed to Lookup |
| Lead does not have history tracking enabled | Dropped trackHistory rather than enabling org-wide history |
| Element value invalid ... in type FlowCondition | FlowCondition takes <rightValue>, not <value> |
| You cannot deploy to a required field | Required 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.