The Input Contract: How OTL Hands Data to Your Formula
Part 2 of 4 — The TER Series
The Input Array Contract
Here's the single most important thing to internalise before you write any code: the timecard the worker sees is not the timecard your formula receives. OTL inserts extra rows between the worker's entries to mark structural boundaries — where each day starts, where it ends, where the whole period closes out. Miss this distinction and your loop counter, your day-buffer logic, and your .exists() guards will all be subtly wrong. Get it right, and the rest of the formula falls into place naturally.
I'll show you both views, then a transformation diagram that bridges them, then the formal contract.
The view the worker sees
When Sarah opens her timecard, she's looking at a spreadsheet-like grid. She types entries one row at a time. Here's what her week looks like after she's done entering Tuesday and Wednesday:
| Date | Time Type | Start Time | Stop Time | Hours | |
|---|---|---|---|---|---|
| 1 | 14-Apr-2026 (Tue) | Regular Hours | 09:00 | 12:00 | 3.0 |
| 2 | 14-Apr-2026 (Tue) | Meal Break | 12:00 | 13:00 | 1.0 |
| 3 | 14-Apr-2026 (Tue) | Regular Hours | 13:00 | 18:00 | 5.0 |
| 4 | 15-Apr-2026 (Wed) | Regular Hours | 09:00 | 18:00 | 8.0 |
What OTL does between submission and your formula
The moment Sarah hits Submit, OTL's pre-processor wakes up. It can't just hand the formula four rows of data — the formula needs to know where day boundaries fall, where the period ends, and where to pause for day-level processing like overlap detection. So OTL inserts marker rows at the structural breakpoints. The diagram below shows exactly what changes:
Three things to take away from this diagram:
- HEADER always sits at index [1]. Your loop counter starts at 1, but you'll never read real worker data at that index. The first real entry begins at [2].
- END_DAY appears wherever a calendar day ends. If the timecard period covers seven days, expect seven END_DAY markers (one for each day, even if some days have zero worker entries).
- END_PERIOD always sits at the very last index. When your formula's loop sees END_PERIOD, you're done.
Here's the same eight-row view as a table, with the original column names OTL uses:
The view the formula sees, as a table
| Idx | Record Position | Time Type | Start Time | Stop Time | Hours |
|---|---|---|---|---|---|
| [1] | HEADER | — | — | — | — |
| [2] | — | Regular Hours | 09:00 | 12:00 | 3.0 |
| [3] | — | Meal Break | 12:00 | 13:00 | 1.0 |
| [4] | — | Regular Hours | 13:00 | 18:00 | 5.0 |
| [5] | END_DAY | — | — | — | — |
| [6] | — | Regular Hours | 09:00 | 18:00 | 8.0 |
| [7] | END_DAY | — | — | — | — |
| [8] | END_PERIOD | — | — | — | — |
How the formula reads it — three questions per row
For every index from [1] to [N], your formula asks the same three questions in order. The answers determine the entire flow of validation logic:
Question 1: Is this a marker row? Read RECORD_POSITIONS[idx]. If it's HEADER, skip everything; if it's END_DAY, run day-level checks (overlap detection, day buffer reset); if it's END_PERIOD, the loop ends; if it's empty, this is a real worker entry — proceed to Question 2.
Question 2: What kind of time type? Read PayrollTimeType[idx]. Regular Hours go through the continuous-work tracker and the day buffer for overlap testing. Meal Break runs through the schedule-window check and signals "the worker took a break." Other types (Annual Leave, Sick Leave) typically pass through with no validation.
Question 3: What are the exact punch times? Read StartTime[idx] and StopTime[idx]. Use them for stretch tracking, overlap math, qty-only detection, and any time-window checks.
StartTime[1] doesn't exist as a value — HEADER rows have no punch time. Read it without protection and Fast Formula throws a runtime error and crashes the whole submission. Every read in the formula must be wrapped in .exists():
The Formula's Contract: What Goes In, What Comes Out
A Time Entry Rule formula is like a checkpoint at the airport. The OTL framework hands it a stack of paperwork (the timecard rows), the formula inspects every page, and hands back a list of which pages have problems. The framework defines exactly what shape that paperwork arrives in and exactly what shape the response must take — that's the contract. Neither side can deviate.
| Direction | Variable | Type | What it represents |
|---|---|---|---|
| IN | HWM_CTXARY_RECORD_POSITIONS |
Text array | Which rows are markers (HEADER, END_DAY) vs real entries |
| IN | HWM_CTXARY_HWM_MEASURE_DAY |
Number array | Day-aggregated total (declared but unused by this formula) |
| IN | measure |
Number array | Hours value for each row |
| IN | PayrollTimeType |
Text array | What kind of time (Regular Hours, Meal Break, Annual Leave...) |
| IN | StartTime |
Date array | Punch-in timestamp for each row |
| IN | StopTime |
Date array | Punch-out timestamp for each row |
| OUT | OUT_MSG |
Text array (sparse) | Error message for each flagged row, empty for clean rows |
Three things to understand before reading further
Before walking through each input one by one, hold these three properties in mind. Every line of code in this formula respects all three; understanding them now means the source reads naturally later.
1. The inputs are parallel arrays, not records. Most languages would express a row of timecard data as a single object with named fields: {type, start, stop, hours}. Fast Formula doesn't have records like that. Instead, the framework gives the formula six separate arrays, all sharing the same row index. Row 3 of the timecard is RECORD_POSITIONS[3], StartTime[3], StopTime[3], PayrollTimeType[3], and measure[3], each holding one column of that row's data. To work with a single row, you read the same index across all six arrays.
2. Not every row populates every column. Marker rows (HEADER, END_DAY, END_PERIOD) only fill RECORD_POSITIONS — the other arrays have no slot at those indexes. Trying to read StartTime[1] on a HEADER row would crash. This is why every read in the formula is wrapped in .exists(nidx) — the formula has to check whether a slot is populated before reading from it.
3. The output is sparse, not dense. OUT_MSG doesn't have one entry per timecard row — it only has entries for the rows the formula chose to flag. A clean row leaves its slot empty. The framework reads OUT_MSG when the formula returns and renders red error markers next to whatever row indexes appear in the array. Quiet rows stay quiet.
The expert's view: framing the inputs before reading the names
When I'm reviewing a junior developer's first TER formula, the question I always hear is: "Why are some inputs called HWM_CTXARY_RECORD_POSITIONS and others just StartTime?" The answer isn't really about names — it's about what kind of data each input represents, and Oracle's naming conventions reflect that distinction once you see the pattern.
Fast Formula isn't a general-purpose programming language. It's a rule engine plugged into specific HCM modules. Each module (Payroll, Absence, Time and Labor, Benefits) defines its own formula types, and each formula type is a contract: "If you write a formula of this type, you'll receive these inputs and you must return these outputs." The TER formula type is one such contract, defined inside OTL. The six inputs we're about to dissect aren't arbitrary — they're exactly what OTL's validation pipeline hands every TER formula by design.
How a TER formula fits into OTL's bigger picture
To understand the inputs, you first need to see where the formula fires within OTL. A TER formula doesn't run in isolation — it sits inside a five-stage pipeline that begins the moment a worker clicks Submit on their timecard:
Stage 3 is where your formula has agency. Stages 1, 2, 4, and 5 belong to OTL. The contract you're working against is: OTL gives you six well-defined arrays; you give back one well-defined array; everything else is your business logic.
Decoding the input names — what each part means
Now that we know where the inputs come from, let's decode why they're named the way they are. Oracle's naming is structural, not arbitrary. Every prefix carries meaning. Here's the breakdown:
Once you see this split, the naming makes sense. The HWM_CTXARY_ prefix is Oracle saying "this input is structural metadata that the framework needs to manage the iteration." The short names (measure, PayrollTimeType, StartTime, StopTime) are saying "this input is the worker's actual time data, the same names we've used since the OTL was first designed."
HWM_ prefix convention across other OTL formula types too — calculation rules, time-calculation formulas, time-card validation formulas. Once you internalise that HWM_ means "framework-supplied" and HWM_CTXARY_ means "framework-supplied per-row metadata", you can read any OTL formula and immediately know which variables come from the framework versus which ones the author created. This pattern recognition is what separates a fluent OTL developer from someone still puzzling over the syntax.
How to read one timecard row from the parallel arrays
Now the practical part. Inside the formula's loop, each iteration processes one row. To get all the data for that row, you read all six arrays at the same index. Here's a visual showing how the parallel arrays line up:
Six arrays, six reads, one row reconstructed. The formula's WHILE loop does this on every iteration, walking the index from 1 to N (where N is the total row count returned by HWM_CTXARY_RECORD_POSITIONS.count). On marker rows like [1] and [4], several of the reads return empty — which is why each read in the formula is wrapped in a .exists() guard.
HWM_CTXARY_RECORD_POSITIONS array length. If .count = 0, the formula received nothing to validate — the bug is upstream in the OTL configuration, not in your formula logic. If .count is non-zero but no validations fire, your loop counter or your .exists() guards are wrong. Always log .count at the top of the formula via add_rlog — it'll save you hours of guessing.
Now: each input in detail
With the framing in place — how the formula fits in OTL's pipeline, what the names mean, how parallel access works — the cards below cover all six inputs (plus the OUT_MSG output) one by one. Each card shows what kind of values the input holds, the actual formula code that reads it, and what the formula does with the value.
| Possible value | Meaning |
|---|---|
| (empty) | Real worker entry — check the data columns |
| HEADER | System marker at the top of the timecard |
| END_DAY | System marker at the end of each day — trigger day-level work |
| END_PERIOD | System marker at the end of the whole timecard period |
This input would hold day-level totals if the formula needed them. The framework hands it over because the TER formula type's contract requires it — but this particular formula never reads it. Three things to know:
- You must declare it in the
INPUTS AREstatement, otherwise the framework throws a binding error and the formula won't even start. - You don't read from it. The validations work entirely off per-row punches (
StartTime,StopTime) and per-rowmeasure, never from day-level aggregates. - You can ignore it from here on. It plays no role in any of Block 6, 7, or 8. A different formula type (like a calculation rule) might consume it; this one simply lists it and moves on.
| Sample value | What it represents |
|---|---|
| 2.5 | 2 hours 30 minutes — real punch interval |
| 8.0 | 8 hours — could be real or a qty-only placeholder |
| 0.5 | 30 minutes — short break or partial shift |
| (no value) | Marker rows have no measure — not applicable |
measure alone can't tell which.StartTime as 00:00 and StopTime as 23:59 — the measure tells you the real intended hours (8) without needing to compute it from the placeholder punches. When the punches are genuine, measure simply equals StopTime − StartTime in hours, and the formula uses the punches directly anyway.| Time type value | Where the formula sends it |
|---|---|
| Regular Hours | Stretch tracker + Day buffer for overlap |
| Meal Break | Schedule-window check + Reset stretch |
| Annual Leave | Skipped — no validation path |
| Sick Leave | Skipped — no validation path |
| Public Holiday | Skipped — no validation path |
| Used in | What for |
|---|---|
| Stretch tracker | Compared to previous stretchEnd → decide EXTEND or RESTART |
| Pairwise overlap test | Combined with StopTime to define each row's interval |
| Schedule window check | Compared to p_sched_start for Meal Break entries |
| Qty-only detection | If start < 0.01 (near midnight), entry is a placeholder |
| Used in | What for |
|---|---|
| Stretch tracker | Becomes the new stretchEnd when extending or restarting |
| Pairwise overlap test | Combined with StartTime to define each row's interval |
| Schedule window check | Compared to p_sched_end for Meal Break entries |
| Continuous-hours math | Feeds the contHrs calculation alongside stretchStart |
sched_end, the row gets flagged. Also feeds the continuous-hours calculation as the running stretchEnd.| Possible message | Fired by |
|---|---|
| (slot left empty) | Clean row — no validation issue |
| "Continuous work exceeds 6 hours" | Block 8 (state machine) |
| "Overlapping entries" | Block 7 (overlap test) |
| "Break outside working hours" | Block 6e (schedule window) |
| "RegHours start/stop required" | Block 6c (hard requirement) |
How the Six Inputs Fit Together: Six Columns of One Spreadsheet
The six inputs aren't independent values — they're six parallel arrays sharing one index space. Picture a spreadsheet: each input is a column, and every timecard row occupies the same row index across all six columns at once. Reading all six arrays at index [3] gives the complete picture of one timecard row.
| Idx | RECORD_POSITIONS | PayrollTimeType | StartTime | StopTime | measure |
|---|---|---|---|---|---|
| [1] | HEADER | — | — | — | — |
| [2] | (empty) | Regular Hours | 09:00 | 12:00 | 3.0 |
| [3] | (empty) | Meal Break | 12:00 | 13:00 | 1.0 |
| [4] | (empty) | Regular Hours | 13:00 | 17:00 | 4.0 |
| [5] | END_DAY | — | — | — | — |
RECORD_POSITIONS column; their other slots are blank. Real worker entries leave RECORD_POSITIONS empty and fill the data columns. The formula reads RECORD_POSITIONS first to decide which path to take.The formula's WHILE loop walks the index from 1 to N, reading the same index across all six arrays each iteration. There's no concept of a "row object" — each row is reassembled at the moment of reading from the parallel slices. This pattern is consistent across all of OTL's formula types, so once you internalise it once, you'll see it everywhere.
Return anything other than OUT_MSG — an extra variable, a misspelled name — and the OTL submission throws a contract error. Return exactly this and nothing else.
The Complete Formula
Here it is in full. Every line in this listing is exactly what gets pasted into Manage Fast Formulas. Read it once top-to-bottom — don't try to understand every line yet. We'll walk through it block by block in the next section.
/* ============================================================
Formula Name: XX_TER_CONTINUOUS_HOURS_VALIDATION
Formula Type: Time Entry Rules
Description: The Fast formula is required to validate the entry of timecard
and show error or warning messages if entries are not accurate
according to the requirements.
============================================================ */
DEFAULT FOR HWM_CTXARY_RECORD_POSITIONS IS EMPTY_TEXT_NUMBER
DEFAULT FOR HWM_CTXARY_HWM_MEASURE_DAY IS EMPTY_NUMBER_NUMBER
DEFAULT FOR HWM_PER_ASG_ASSIGNMENT_ID IS 0
DEFAULT FOR measure IS EMPTY_NUMBER_NUMBER
DEFAULT FOR PayrollTimeType IS EMPTY_TEXT_NUMBER
DEFAULT FOR StartTime IS EMPTY_DATE_NUMBER
DEFAULT FOR StopTime IS EMPTY_DATE_NUMBER
INPUTS ARE
HWM_CTXARY_RECORD_POSITIONS,
HWM_CTXARY_HWM_MEASURE_DAY,
measure,
PayrollTimeType,
StartTime,
StopTime
ffName = 'XX_TER_CONTINUOUS_HOURS_VALIDATION'
ffs_id = GET_CONTEXT(HWM_FFS_ID, 0)
rule_id = GET_CONTEXT(HWM_RULE_ID, 0)
NullDate = '01-JAN-1900' (DATE)
NullText = '**FF_NULL**'
rLog = add_rlog(ffs_id, rule_id, '>>> Enter ' || ffName)
CHANGE_CONTEXTS(HR_ASSIGNMENT_ID = HWM_PER_ASG_ASSIGNMENT_ID)
(
sumLvl = Get_Hdr_Text(rule_id, 'RUN_SUMMATION_LEVEL', 'DAY')
rLog = add_rlog(ffs_id, rule_id, '>>> sumLvl=' || sumLvl)
p_break_type = 'Meal Break'
p_reg_type = 'Regular Hours'
p_sched_start = get_rvalue_number(rule_id, 'SCHEDULE_START_HOUR', 9)
p_sched_end = get_rvalue_number(rule_id, 'SCHEDULE_END_HOUR', 18)
p_max_cont_err = get_rvalue_number(rule_id, 'MAX_CONTINUOUS_HRS_ERR', 6)
p_max_cont_warn = get_rvalue_number(rule_id, 'MAX_CONTINUOUS_HRS_WARN', 5)
p_msg_break = 'XX_BREAK_OUTSIDE_HOURS_ERR'
p_msg_cont_err = 'XX_CONT_HOURS_ERR_MSG'
p_msg_cont_warn = 'XX_CONT_HOURS_WRN_MSG'
p_msg_overlap = 'XX_OVERLAP_ENTRIES_MSG'
p_msg_reghrs = 'XX_REG_HOURS_PUNCHES_REQUIRED'
rLog = add_rlog(ffs_id, rule_id,
'>>> Parms sched=' || TO_CHAR(p_sched_start) || '-' || TO_CHAR(p_sched_end) ||
' cErr=' || TO_CHAR(p_max_cont_err) ||
' cWarn=' || TO_CHAR(p_max_cont_warn))
OUT_MSG = EMPTY_TEXT_NUMBER
wMaAry = HWM_CTXARY_RECORD_POSITIONS.count
rLog = add_rlog(ffs_id, rule_id, '>>> Start bulk wMaAry=' || TO_CHAR(wMaAry))
cntr = 0
nidx = 0
dayStarts = EMPTY_DATE_NUMBER
dayStops = EMPTY_DATE_NUMBER
dayIdxs = EMPTY_NUMBER_NUMBER
dayCnt = 0
stretchStart = NullDate
stretchEnd = NullDate
inStretch = 'N'
l_meal_taken = 'N'
WHILE (cntr < wMaAry) LOOP
(
cntr = cntr + 1
nidx = nidx + 1
aiRecPos = NullText
aiMeasure = 0
aiTimeType = NullText
aiStartTime = NullDate
aiStopTime = NullDate
l_qty_only = 'N'
aiRecPos = HWM_CTXARY_RECORD_POSITIONS[nidx]
IF (aiRecPos = 'HEADER') THEN
(
rLog = add_rlog(ffs_id, rule_id, '>>> HEADER skipped idx=' || TO_CHAR(nidx))
)
ELSE
(
IF (MEASURE.exists(nidx)) THEN ( aiMeasure = MEASURE[nidx] )
IF (PayrollTimeType.exists(nidx)) THEN ( aiTimeType = PayrollTimeType[nidx] )
IF (StartTime.exists(nidx)) THEN ( aiStartTime = StartTime[nidx] )
IF (StopTime.exists(nidx)) THEN ( aiStopTime = StopTime[nidx] )
rLog = add_rlog(ffs_id, rule_id,
'>>> idx=' || TO_CHAR(nidx) ||
' pos=' || aiRecPos ||
' type=[' || aiTimeType || ']' ||
' st=' || TO_CHAR(aiStartTime) ||
' sp=' || TO_CHAR(aiStopTime) ||
' m=' || TO_CHAR(aiMeasure))
IF (aiRecPos = 'END_DAY' OR aiRecPos = 'END_PERIOD') THEN
(
rLog = add_rlog(ffs_id, rule_id, '>>> Boundary dayCnt=' || TO_CHAR(dayCnt))
IF (dayCnt > 1) THEN
( i = 1
WHILE (i < dayCnt) LOOP
( j = i + 1
WHILE (j <= dayCnt) LOOP
( IF (dayStarts[i] < dayStops[j] AND dayStarts[j] < dayStops[i]) THEN
( flagIdx = dayIdxs[j]
OUT_MSG[flagIdx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_overlap)
rLog = add_rlog(ffs_id, rule_id, '>>> OVERLAP fired idx=' || TO_CHAR(flagIdx))
)
j = j + 1
)
i = i + 1
)
)
dayStarts = EMPTY_DATE_NUMBER
dayStops = EMPTY_DATE_NUMBER
dayIdxs = EMPTY_NUMBER_NUMBER
dayCnt = 0
stretchStart = NullDate
stretchEnd = NullDate
inStretch = 'N'
l_meal_taken = 'N'
)
ELSE
(
/* QTY-ONLY DETECTION */
IF (aiTimeType = p_reg_type AND aiStartTime <> NullDate AND aiStopTime <> NullDate) THEN
( l_st_hr = TO_NUMBER(TO_CHAR(aiStartTime, 'HH24')) + TO_NUMBER(TO_CHAR(aiStartTime, 'MI'))/60
l_sp_hr = TO_NUMBER(TO_CHAR(aiStopTime, 'HH24')) + TO_NUMBER(TO_CHAR(aiStopTime, 'MI'))/60
IF (l_st_hr < 0.01 AND l_sp_hr > 23.9) THEN
( l_qty_only = 'Y'
rLog = add_rlog(ffs_id, rule_id, '>>> QTY-ONLY detected idx=' || TO_CHAR(nidx))
)
)
/* Overlap collection - skip qty-only */
IF (l_qty_only = 'N' AND aiStartTime <> NullDate AND aiStopTime <> NullDate) THEN
( dayCnt = dayCnt + 1
dayStarts[dayCnt] = aiStartTime
dayStops[dayCnt] = aiStopTime
dayIdxs[dayCnt] = nidx
)
/* Reg Hours qty-only */
IF (aiTimeType = p_reg_type AND l_qty_only = 'Y') THEN
( OUT_MSG[nidx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_reghrs)
rLog = add_rlog(ffs_id, rule_id, '>>> REGHRS QTY-ONLY fired idx=' || TO_CHAR(nidx))
)
/* Reg Hours null start/stop */
IF (aiTimeType = p_reg_type AND (aiStartTime = NullDate OR aiStopTime = NullDate)) THEN
( OUT_MSG[nidx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_reghrs)
rLog = add_rlog(ffs_id, rule_id, '>>> REGHRS NULL fired idx=' || TO_CHAR(nidx))
)
/* Meal break stretch reset */
IF (aiTimeType = p_break_type) THEN
( stretchStart = NullDate
stretchEnd = NullDate
inStretch = 'N'
l_meal_taken = 'Y'
rLog = add_rlog(ffs_id, rule_id, '>>> MEAL RESET idx=' || TO_CHAR(nidx))
)
l_day = TO_CHAR(aiStartTime, 'DY')
l_sch_date_day = get_date_day_of_week(aiStartTime)
l_dow_char = UPPER(TO_CHAR(aiStartTime, 'DY'))
hol = GET_VALUE_SET('XX_HOLIDAY_CALENDAR_VS',
'|=p_date=''' || to_char(aiStartTime, 'YYYY/MM/DD') || '''')
rLog = add_rlog(ffs_id, rule_id,
'dow_fn=' || l_sch_date_day ||
' dow_char=' || l_dow_char ||
' hol=' || hol)
/* Break outside working hours */
IF (aiTimeType = p_break_type AND aiStartTime <> NullDate AND aiStopTime <> NullDate) THEN
( bkStart = TO_NUMBER(TO_CHAR(aiStartTime, 'HH24')) + TO_NUMBER(TO_CHAR(aiStartTime, 'MI'))/60
bkEnd = TO_NUMBER(TO_CHAR(aiStopTime, 'HH24')) + TO_NUMBER(TO_CHAR(aiStopTime, 'MI'))/60
IF ((bkStart < p_sched_start OR bkEnd > p_sched_end)
AND l_day <> 'SAT' AND l_day <> 'SUN' AND length(hol) = 0) THEN
( OUT_MSG[nidx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_break)
rLog = add_rlog(ffs_id, rule_id, '>>> BREAK OUT fired idx=' || TO_CHAR(nidx))
)
)
/* Continuous stretch */
IF (aiTimeType = p_reg_type AND aiStartTime <> NullDate AND aiStopTime <> NullDate
AND l_qty_only = 'N' AND l_meal_taken = 'N') THEN
(
IF (inStretch = 'N') THEN
( stretchStart = aiStartTime
stretchEnd = aiStopTime
inStretch = 'Y'
)
ELSE
( IF (aiStartTime = stretchEnd) THEN
( stretchEnd = aiStopTime )
ELSE
( stretchStart = aiStartTime
stretchEnd = aiStopTime
)
)
endMins = TO_NUMBER(TO_CHAR(stretchEnd, 'J'))*1440
+ TO_NUMBER(TO_CHAR(stretchEnd, 'HH24'))*60
+ TO_NUMBER(TO_CHAR(stretchEnd, 'MI'))
stMins = TO_NUMBER(TO_CHAR(stretchStart, 'J'))*1440
+ TO_NUMBER(TO_CHAR(stretchStart, 'HH24'))*60
+ TO_NUMBER(TO_CHAR(stretchStart, 'MI'))
contHrs = (endMins - stMins) / 60
rLog = add_rlog(ffs_id, rule_id, '>>> ContHrs=' || TO_CHAR(contHrs) || ' idx=' || TO_CHAR(nidx))
IF (contHrs > p_max_cont_err
AND l_day <> 'SAT' AND l_day <> 'SUN' AND length(hol) = 0) THEN
( OUT_MSG[nidx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_cont_err)
rLog = add_rlog(ffs_id, rule_id, '>>> CONT ERR fired idx=' || TO_CHAR(nidx))
)
ELSE
( IF (contHrs > p_max_cont_warn
AND l_day <> 'SAT' AND l_day <> 'SUN' AND length(hol) = 0) THEN
( OUT_MSG[nidx] = get_msg_attribute('StartTime') || get_output_msg('HXT', p_msg_cont_warn)
rLog = add_rlog(ffs_id, rule_id, '>>> CONT WARN fired idx=' || TO_CHAR(nidx))
)
)
)
) /* end ELSE - non-boundary processing */
) /* end ELSE - non-HEADER */
IF (nidx > 1000) THEN
( ex = raise_error(ffs_id, rule_id, 'Formula ' || ffName || ' terminated - possible endless loop.') )
rLog = add_rlog(ffs_id, rule_id, '>>> End bulk ' || ffName)
) /* end WHILE body */
) /* end CHANGE_CONTEXTS */
RETURN OUT_MSG
That's the entire formula. ~200 lines, three nested control structures, one state machine, and five validations all in one pass through the array. Now let's break it down piece by piece.
The Formula's Architecture
Before reading the code line-by-line, it helps to see the shape of the whole thing. The formula has eight blocks that fall into two clean halves: the first five blocks run once as scaffolding (set up arrays, capture identity, bind context, read configuration, initialise state), and the last three blocks run repeatedly inside the WHILE loop where the actual validation happens. Every line of code belongs to exactly one of these eight blocks.
CHANGE_CONTEXTSget_rvalue_numberThe first five blocks (Stages 01–05) make up the setup half — they run exactly once at the top of the formula, before any timecard row is processed. The last three (Stages 06–08) make up the loop half — they execute once per row inside the WHILE loop, with Block 7 firing only at day boundaries. Every line in the formula source belongs to one of these eight blocks. The Part 2 walkthrough goes through each block in detail; for now, this overview is enough to navigate the complete code listing above.
Variable Naming Conventions in This Formula
Fast Formula doesn't enforce naming rules — you can call any variable anything — but the formula in this post follows a deliberate convention. Each prefix signals the variable's role: where its value comes from, what its lifetime is, what code is allowed to write to it. Once you internalise the prefixes, the rest of the formula reads itself. This section is worth a few minutes upfront because Part 2 of this series uses these prefixes throughout the code walkthrough.
Seven naming patterns do all the work in this formula. The reference table below summarises them; the cards that follow explain each one in detail.
| Prefix | Means | Examples in this formula | Lifetime | Who writes to it? |
|---|---|---|---|---|
HWM_* |
Framework-supplied context or input | HWM_FFS_ID, HWM_RULE_ID, HWM_PER_ASG_ASSIGNMENT_ID |
Whole formula | The OTL framework (read-only) |
HWM_CTXARY_* |
Framework input array (one slot per timecard row) | HWM_CTXARY_RECORD_POSITIONS, HWM_CTXARY_HWM_MEASURE_DAY |
Whole formula | The framework (read-only) |
p_* |
Parameter read from rule configuration | p_sched_start, p_max_cont_err, p_msg_overlap |
Whole formula | Set once in Block 4, read everywhere |
ai* |
"Array input" — per-row local snapshot of input data | aiTimeType, aiStartTime, aiStopTime, aiRecPos |
One iteration | Reset at top of every loop iteration |
l_* |
Local working variable or flag | l_qty_only, l_meal_taken, l_day, l_st_hr |
Per-row or per-day | Set inside the loop body |
day* / stretch* |
State variables with named lifetimes | dayStarts, dayCnt, stretchStart, inStretch |
Per-day / per-stretch | Updated inside loop, reset at boundaries |
OUT_MSG |
The formula's return value (sparse error array) | OUT_MSG |
Whole formula | Written only when a row needs flagging |
Now the prefixes in detail, with examples and the reasoning behind each convention:
| Variable | What it holds | Set by |
|---|---|---|
HWM_FFS_ID | This run's session ID | Framework, on submission |
HWM_RULE_ID | The rule that triggered this formula | Framework, on rule binding |
HWM_PER_ASG_ASSIGNMENT_ID | The worker's HR assignment ID | Framework, from worker context |
HWM_ prefix — short for HCM Workforce Management — marks variables the OTL framework injects into the formula's scope. They're not declared by the formula author; they appear automatically when the formula runs. The values are read-only from the formula's perspective; trying to assign to them does nothing useful and can break the binding.Their purpose is to give the formula access to contextual information about the current run: whose timecard is being validated (
HWM_PER_ASG_ASSIGNMENT_ID), which rule fired the formula (HWM_RULE_ID), and what unique session ID identifies this specific submission for log tracing (HWM_FFS_ID). Block 2 captures these into shorter local variables (ffs_id, rule_id) for convenience throughout the rest of the formula.| Variable | Holds (per row) | Type |
|---|---|---|
HWM_CTXARY_RECORD_POSITIONS | Marker text or empty | TEXT_NUMBER array |
HWM_CTXARY_HWM_MEASURE_DAY | Day-aggregated quantity | NUMBER_NUMBER array |
HWM_CTXARY_ prefix — short for HCM Workforce Management Context Array — marks the framework's parallel input arrays. Where HWM_* holds a single value, HWM_CTXARY_* holds one slot per timecard row, all indexed by the same row number.You access them like arrays:
HWM_CTXARY_RECORD_POSITIONS[3] retrieves the value for row 3. The naming feels heavy, but it's deliberately verbose so you can never mistake a per-row array for a single-value context. Confusing the two would cause type errors at compile time — loud and easy to fix — so the convention pays off.Note that some inputs in the
INPUTS ARE declaration (like measure, StartTime, StopTime) also behave as parallel arrays but use cleaner names. They're framework arrays too; they just don't use the HWM_CTXARY_ prefix because OTL's design predates the convention. Treat them the same way: per-row, indexed by row number, read-only.| Variable | Source | Purpose |
|---|---|---|
p_sched_start | Rule param SCHEDULE_START_HOUR | Schedule window check |
p_max_cont_err | Rule param MAX_CONTINUOUS_HRS_ERR | Hard cap on continuous work |
p_max_cont_warn | Rule param MAX_CONTINUOUS_HRS_WARN | Soft warning threshold |
p_msg_overlap | Hardcoded message name | Error message lookup key |
p_break_type | Hardcoded layout label | Time-type matching |
p_ prefix marks parameter-style variables — values set once in Block 4 and used throughout the rest of the formula as effectively constant. The lowercase p distinguishes them from framework-supplied values (HWM_*) and per-row scratch (ai*, l_*).Most
p_* variables are read from the rule configuration via get_rvalue_number, which fetches numeric parameters that legal entities can tune independently — this is how one entity can use a 5-hour cap while another uses 6 with the same formula source. A few p_* variables (the message names like p_msg_overlap, the time-type labels like p_break_type) are hardcoded because they don't vary across the rollout.The convention serves a code-review purpose: when you see
p_* being assigned anywhere outside Block 4, that's a code smell — parameters should be set once at setup and treated as constant during the loop. Mutation indicates a bug or a misuse.| Variable | Source array | Used for |
|---|---|---|
aiRecPos | RECORD_POSITIONS[nidx] | Row-type routing (HEADER vs END_DAY vs data) |
aiTimeType | PayrollTimeType[nidx] | Validation routing (Reg Hours vs Meal Break) |
aiStartTime | StartTime[nidx] | Stretch tracking, overlap testing |
aiStopTime | StopTime[nidx] | Stretch tracking, overlap testing |
ai prefix stands for "array input" — per-row local snapshots of values pulled from the framework's input arrays. At the top of every loop iteration, the formula reads from the input arrays (with .exists() guards) and copies the values into matching ai* locals.Why copy rather than reading the input arrays directly throughout the iteration? Three reasons. First, it creates a consistent snapshot: the rest of the iteration always sees the same values for "this row", even if downstream code logic gets restructured. Second, it provides a single place to apply guards (the
.exists() checks in the read block) so you can never accidentally trigger an unguarded read elsewhere. Third, it makes the data flow obvious in code review — seeing ai* on the left of an assignment in the read block flags it as the "snapshot point", and the rest of the iteration cleanly works from those locals.The
ai* variables are reset at the top of every iteration to their sentinel values (NullText, NullDate) before the new row's reads happen. This explicit reset prevents the stale-value bug discussed in Block 5.| Variable | Type | Lifetime & purpose |
|---|---|---|
l_qty_only | 'Y'/'N' flag | Per-row: was this row detected as a qty-only placeholder? |
l_meal_taken | 'Y'/'N' flag | Per-day: has the worker logged a meal break yet today? |
l_day | 3-letter day code | Per-row: 'MON', 'SAT', etc., for weekend exception checks |
l_st_hr / l_sp_hr | fractional hour | Per-row: punch times converted to decimal hours |
l_ prefix marks local working variables created inside the loop body for intermediate computation or state-tracking. Some are per-row (computed fresh each iteration), some are per-day (set once on a triggering event and persisting until the next day boundary).The prefix's main job is to distinguish working state from input snapshots (
ai*) and parameters (p_*). A formula reader scanning the code can immediately tell l_qty_only is a flag the formula sets itself, not data from the framework or a configuration value.Within
l_* there's an unwritten sub-convention: variables that hold 'Y'/'N' flags use names ending in past-tense or descriptive adjectives (l_meal_taken, l_qty_only), while variables that hold computed numeric or string values use abbreviated names (l_st_hr, l_day). The convention isn't enforced, but consistency makes the code easier to scan.| Group | Variables | Resets when |
|---|---|---|
| day* | dayStarts, dayStops, dayIdxs, dayCnt | At END_DAY/END_PERIOD marker |
| stretch* | stretchStart, stretchEnd, inStretch | On meal break or END_DAY |
day* for variables related to a single day's accumulated state, stretch* for variables tracking the current continuous-work stretch.The grouping makes the code's structure self-documenting. When a reader sees
dayStarts, dayStops, dayIdxs, and dayCnt together, they immediately recognise these as the day buffer — four pieces of one logical structure. Same for stretchStart, stretchEnd, inStretch as the stretch tracker.The grouping also signals that these variables must be reset together. Block 7c (the END_DAY reset) clears all four day buffer variables in one block, and clears the stretch tracker variables in the same block. Resetting some without others would corrupt state. The naming makes it obvious which variables belong in the same reset block.
| Idx | OUT_MSG content | Meaning |
|---|---|---|
| [2] | (no entry) | clean row |
| [3] | "Continuous work exceeds 6 hours" | flagged row |
| [5] | "Overlapping entries" | flagged row |
| [6] | (no entry) | clean row |
OUT_MSG is the only variable in this formula whose name is not chosen by the author — it's reserved by the TER formula type contract. The framework expects the formula to write error messages into a variable with this exact name, and reads from it after the formula returns.The naming is uppercase to signal "framework-reserved", distinguishing it from the lowercase prefixes (
p_, ai, l_, day, stretch) used for author-chosen names. The pattern carries over to other formula types: payroll formulas have their own reserved output names, absence formulas have theirs.The
_MSG suffix hints at the data type (a sparse array of message strings indexed by row number). The combination — uppercase name plus underscore-separated suffix — is a strong visual signal that this variable is a contract surface, not a working variable. Treat it as such: write to it sparingly, only for rows that need flagging, and never reset it during the run.Why these conventions matter beyond style
Naming conventions might feel like decoration, but in Fast Formula they carry real engineering value. Three benefits worth being explicit about:
Self-documenting role. Fast Formula has no type signatures, no scope modifiers, no access controls. Every variable looks the same to the compiler. The naming convention is the only signal a reader has about whether they're looking at framework data, parameter config, per-row input, working state, or output. Without conventions, you'd have to read the variable's declaration site to understand its role — which is often hundreds of lines away.
Code-review heuristics. When reviewing TER formulas, certain patterns are immediately suspicious. p_* being assigned outside Block 4 means a parameter is being mutated mid-loop — almost certainly a bug. HWM_* being assigned anywhere means someone tried to write to a framework value — broken. ai* being read without first being reset means stale data leakage. The conventions turn code review into a pattern-matching exercise; you can spot bugs by shape before reading the logic.
Onboarding cost. A developer joining the team can read this convention table in two minutes and then read the formula's variables fluently. Without conventions, every new variable is a small puzzle — "what does x do? where does y come from?" — and onboarding takes weeks instead of days.
HWM_, p_, ai, l_, day*, stretch*, OUT_MSG) are common in OTL implementations but you'll see variations elsewhere. The principle that matters is consistency within a project. Pick a convention, document it, and apply it uniformly. The specific letters matter less than the discipline of using them.
Part 2 walks through every block of the formula in detail — with the continuous-hours state machine as the centrepiece. You'll also get the per-line routing decisions, the day-boundary overlap test, the setup dependencies that must exist for the formula to fire, and a worked end-to-end trace of Sarah's full timecard.
Comments
Post a Comment