SRD-077 — ISO 8601 timer authoring, and the duration-only gap¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-08-03 |
| Owner | Ruslan Gabitov |
| Implements | SAD-001 §14 (the library's §2.3.1 half) and conformance.md (Timer in scope) |
| Upstream | ADR-006 v.4 §2.3 (wait nodes & subscription lifecycle); ADR-007 v.2.1 §2.4 (which waits release, and who holds them); ADR-033 v.2 §2.1 (the checkpoint's serializable wait descriptors — a timer's absolute deadline) |
| Closes | #275 |
§1 Background¶
BPMN gives TimerEventDefinition three mutually exclusive attributes
(§10.5.5, Table 10.101, p. 273), each carrying an ISO 8601 value:
Timer attributes are mutually exclusive and if any of the other Timer attributes is set,
timeDateMUST NOT be set (if theisExecutableattribute of the Process is set to true).
| Attribute | Return type MUST conform to |
|---|---|
timeDate |
ISO 8601 date and time |
timeCycle |
ISO 8601 recurring time interval |
timeDuration |
ISO 8601 time interval |
gobpm decomposes timeCycle. Instead of one R3/PT10H string it carries
the repetition count in timeCycle (an int) and the interval in
timeDuration. The two fields are jointly the recurrence — semantically
equivalent to the standard's single string, and type-safe rather than parsed.
internal/eventproc/eventhub/waiters/timer.go implements exactly that: Cycle
fills cyclesLeft, Duration fills duration, and processTimerEvent
decrements-then-tests so a cycle of N delivers exactly N events (FIX-012).
Two consequences follow, one of them a defect.
The defect. Because timeDuration's meaning is taken by the recurrence, the
constructor guard requires it to appear with a cycle
(pkg/model/events/timer.go:38-39), so a standalone timeDuration — BPMN's
one-shot relative timer, "wait five minutes, then fire" — cannot be
constructed at all. Measured against the constructor before this SRD:
| Call | BPMN Table 10.101 | gobpm |
|---|---|---|
timeDate only |
legal | accepted |
timeDuration only |
legal | rejected |
timeCycle only |
legal | rejected |
timeCycle + timeDuration |
illegal | accepted |
timeDate + timeDuration |
illegal | rejected |
| all three / none | illegal | rejected |
Rows 3 and 4 are the decomposition and are internally coherent — a repetition
count with no interval has nothing to schedule, and the pair is the
recurrence. Row 2 is the defect, and it is purely a model-layer one: the
runtime already handles a duration-only timer end to end. Verified against
waiters.TimerPlan with a duration-only definition constructed past the guard:
deadline = now + 30m0s cyclesLeft = 0 (one-shot, checkpoint-ready)
Service skips its !tw.next.IsZero() branch, arms on duration, and
processTimerEvent decrements 0 → -1 and terminates after one delivery.
The second consequence is that a modeller cannot write the standard's own notation. Every real BPMN source — XML, a modeller, Camunda documentation — expresses timers as ISO 8601 strings, and gobpm accepts only evaluated Go values. The converter (#284) will need that translation regardless; doing it now costs one parser and makes that work pure plumbing.
Camunda 7, the project's alignment target, states the same rule the standard
does — "A timer definition must have exactly one of the following
elements" — and documents timeDuration as "how long the timer should run
before it is fired" and timeCycle as R3/PT10H or a cron expression
(docs/docs.camunda.org/manual/7.22/reference/bpmn20/events/timer-events).
§2 Requirements¶
Functional¶
| # | Requirement |
|---|---|
| FR-1 | A TimerEventDefinition with only timeDuration is constructible, and fires once after that interval. |
| FR-2 | Every other attribute combination keeps its current accept/reject verdict. The recurrence stays timeCycle + timeDuration. |
| FR-3 | The rejection error names which rule was broken — timeDate combined with another attribute, or a cycle without its interval — instead of the current single message. |
| FR-4 | A new package, pkg/iso8601, parses ISO 8601 date-time (2011-03-11T12:13:14Z), duration (P10D, PT10H, P2W, P1DT2H3M4S), and bounded repeating interval (R3/PT10H). It is public so the converter (#284) and embedding applications can reuse it. |
| FR-5 | Calendar-relative designators Y and M in a duration are rejected with an error naming them and stating why (see §4.2). |
| FR-6 | NewISO8601Timer(expr, opts...) / MustISO8601Timer(expr, opts...) build a TimerEventDefinition by disassembling one ISO 8601 string into the existing timeDate / timeCycle / timeDuration triple. |
| FR-7 | An unbounded recurrence (R/PT10H) is recognised by the grammar and rejected with an error naming it as unsupported (see §4.6). |
| FR-8 | The disassembly is recorded as an engine choice in SAD-001 §14.2 — timeCycle carried as (count, interval) rather than one string. |
Non-functional¶
| # | Requirement |
|---|---|
| NFR-1 | No new dependency. The parser is hand-written over stdlib; core stays on stdlib + uuid (SAD-001 G2). |
| NFR-2 | No runtime change at all. The waiter, TimerPlan, the checkpoint and the restore hint are not modified by this SRD. |
| NFR-3 | Every touched function finishes at ≥95% line coverage (the COVER_MIN gate). |
| NFR-4 | make ci green, including -race. |
§3 Models¶
§3.1 pkg/iso8601 (new)¶
// Duration is a parsed ISO 8601 duration. Weeks, days and time components
// only — Y and M are rejected at parse (§4.2).
func ParseDuration(s string) (time.Duration, error)
// ParseDateTime accepts the RFC 3339 profile of ISO 8601.
func ParseDateTime(s string) (time.Time, error)
// Repeat is a parsed ISO 8601 bounded repeating interval: Rn/<duration>.
// An unbounded form (R/<duration>) is rejected — see §4.6.
type Repeat struct {
Count int // repetitions, always >= 1
Interval time.Duration // the recurrence interval
}
func ParseRepeat(s string) (Repeat, error)
time.ParseDuration is not usable — it parses 10h, not P10D
(verified: every ISO form returns a parse error). time.RFC3339 is
usable for the date-time form.
§3.2 pkg/model/events — the guard¶
if (tDate != nil && (tCycle != nil || tDuration != nil)) ||
(tDate == nil && tDuration == nil) {
One clause changes: (tCycle == nil || tDuration == nil) → tDuration == nil.
A cycle still requires its interval; a duration no longer requires a cycle.
§3.3 pkg/model/events — the ISO 8601 constructor¶
func NewISO8601Timer(s string, baseOpts ...options.Option) (*TimerEventDefinition, error)
func MustISO8601Timer(s string, baseOpts ...options.Option) *TimerEventDefinition
Disassembly, by what the string parses as:
| Input | Fields set |
|---|---|
2011-03-11T12:13:14Z |
timeDate |
P10D |
timeDuration |
R3/PT10H |
timeCycle = 3, timeDuration = 10h |
Each field is a constant goexpr returning the parsed Go value, so the result
is an ordinary TimerEventDefinition indistinguishable from a hand-built one.
A second pair covers the dynamic case — a deadline that lives in instance data (an SLA read off the order, a due date carried on the case):
type TimerForm string // Time | Duration | Cycle
func NewISO8601TimerExpr(form TimerForm, e data.FormalExpression,
baseOpts ...options.Option) (*TimerEventDefinition, error)
func MustISO8601TimerExpr(form TimerForm, e data.FormalExpression,
baseOpts ...options.Option) *TimerEventDefinition
A literal string carries its own form — R… is a cycle, P… a duration,
anything else a date — but an expression's value does not exist until the
timer arms, while the attribute it fills is fixed when the process is built.
TimerForm states that choice up front, the same way BPMN makes the element
name static and the expression inside it dynamic. A malformed value fails at
arm time, reported as an ordinary expression failure naming the offending
string.
§4 Analysis¶
§4.1 Why the model is not changed to a single timeCycle string¶
The alternative was to make timeCycle carry R3/PT10H itself and drop the
decomposition, giving literal attribute parity with Table 10.101. Rejected:
it changes the public constructor, the waiter's parse, TimerPlan and the
checkpoint's recorded plan — for no behavioural gain, since (count, interval)
and R3/PT10H denote the same schedule. Disassembling at authoring time
keeps one internal model with two authoring paths, and leaves the runtime,
its race-tested lifecycle and its restore semantics untouched (NFR-2).
§4.2 Why Y and M are rejected¶
They are not fixed-length, so they cannot become a time.Duration. Go's
calendar arithmetic also normalizes overflow rather than clamping —
2026-01-31 plus one month via AddDate yields 2026-03-03, not
February 28 — so even a calendar-aware representation must decide a
truncation policy the standard does not specify. PnW, PnD and the time
components cover the timers BPMN processes actually use. Rejecting loudly is
honest; approximating Y = 365d is not. A calendar-aware duration is a
follow-up if a real model needs one.
§4.3 Why cron is out of scope¶
Cron does not disassemble into (date | count + interval). 0 0/5 * * * ?
does — interval 5m, unbounded — but 0 0 9 * * MON needs an anchor and an
interval and unboundedness (three fields the guard forbids), and
0 0 9 1 * ? has no fixed interval at all, since month lengths vary. A cron
schedule is "recompute the next fire time", whereas the waiter re-arms at a
fixed tw.duration. Supporting it properly is a scheduler change, not a
parsing one, and belongs with the converter work
(#284) or its own doc.
§4.4 Why a sibling constructor rather than an option¶
NewTimerEventDefinition is positional, so an option form reads
NewTimerEventDefinition(nil, nil, nil, WithISO8601("P10D")) — three nils to
say "none of these", and a guard that must run after option application. A
sibling constructor states its input in its name, keeps the Must* twin
convention, and leaves the existing constructor untouched.
§4.5 Why timeCycle alone stays rejected¶
Under the decomposition a repetition count carries no interval, so there is
nothing to schedule. NewISO8601Timer("R3/PT10H") sets both fields, so the
standard's notation is expressible; only the hand-built half-recurrence is
refused, and FR-3 makes the error say so.
§4.6 Why an unbounded recurrence is rejected rather than supported¶
R/PT10H is legal ISO 8601, but nothing in the engine can consume it safely.
A cycle reaches a BPMN element through exactly one path — boundaryWatch.
ProcessEvent, which emits an evBoundary per delivery — so the only element
for which repetition is meaningful is a non-interrupting timer boundary.
An intermediate catch or an interrupting boundary settles on the first firing,
and further deliveries would target a processor that has moved on. A Timer
Start Event cannot consume one either: discoverInstantiatingStarts builds
instance-starters only for message and signal definitions, so a timer start
does not instantiate.
Supporting it therefore needs a placement rule — legal on a
non-interrupting boundary, refused elsewhere — which is model-level validation
rather than the sentinel it first appeared to be. It would also rest on
behaviour nothing has exercised: bounded cycles are plumbed through
TimerPlan and the dehydration hold but appear in no example and no
instance-level test. Rejecting the form keeps the grammar complete and the gap
named, instead of shipping an unbounded waiter that spins against a stale
processor.
§5 API¶
Added: events.NewISO8601Timer, events.MustISO8601Timer,
events.NewISO8601TimerExpr, events.MustISO8601TimerExpr, events.TimerForm
(Time / Duration / Cycle),
pkg/iso8601.{ParseDuration,ParseDateTime,ParseRepeat,Repeat}.
Changed: none — NewTimerEventDefinition keeps its signature and accepts a
strictly larger set of inputs. No caller breaks.
§6 Tests¶
| # | Test | Asserts |
|---|---|---|
| T-1 | TestTimerAttributeCombinations |
all seven combinations, verdict per §1's table (FR-1, FR-2) |
| T-2 | TestTimerGuardErrorNamesTheRule |
the two rejection classes carry distinct messages (FR-3) |
| T-3 | TestParseDuration |
P10D, PT10H, P2W, P1DT2H3M4S; malformed input; P1Y/P1M rejected by name (FR-4, FR-5) |
| T-4 | TestParseDateTime |
RFC 3339 accepted, garbage rejected (FR-4) |
| T-5 | TestParseRepeat |
R3/PT10H → (3, 10h); R/PT10H rejected by name; R0/… and malformed rejected (FR-4, FR-7) |
| T-6 | TestISO8601TimerDisassembly |
each §3.3 row sets exactly the expected fields (FR-6) |
| T-7 | TestDurationOnlyFires |
a duration-only waiter fires once, on the injected clock (FR-1) |
| T-8 | TestDurationOnlyTimerPlan |
TimerPlan returns now+d, cyclesLeft == 0 (FR-1) |
| T-9 | examples/usertask-sla |
three bounded boundary timers fire in order and the guarded UserTask still completes (FR-1, end-to-end) |
§7 Milestones¶
| # | Milestone | Contents |
|---|---|---|
| M1 | The duration-only gap | §3.2 guard, FR-3 error, doc comment; T-1, T-2, T-7, T-8 |
| M2 | The SLA example | examples/usertask-sla — three bounded, non-interrupting boundary timers on a UserTask at 50% / 90% / 100% of its budget; T-9. Proves M1 end to end before anything is built on it, and is the first duration-only timer and the first non-interrupting timer boundary in the repository |
| M3 | The ISO 8601 parser | internal/iso8601; T-3, T-4, T-5 |
| M4 | The authoring path | §3.3 constructors, SAD-001 §14.2 entry; T-6 |
§8 Cross-doc references¶
| Doc | Version | Why |
|---|---|---|
| SAD-001 | Draft v.1.1 | §14 registered deviations — FR-8 adds the disassembly entry; G2 the dependency rule (NFR-1) |
| ADR-006 | v.4 | §2.3 wait nodes & subscription lifecycle — the lifecycle this rides unchanged |
| ADR-007 | v.2.1 | §2.4 which waits release — a duration timer is one of them |
| ADR-033 | v.2 | §2.1 the checkpoint's wait descriptors carry a timer's absolute deadline — TimerPlan already derives one for duration-only |
§9 Definition of Done¶
- [x] FR-1…FR-8 implemented and demonstrated by §6
- [x] NFR-1:
go.modunchanged - [x] NFR-2: no runtime diff — the waiter,
TimerPlan, the checkpoint and the restore hint are untouched - [x] NFR-3: touched functions ≥95%, measured on the diff not the aggregate
- [x] NFR-4:
make cigreen - [x] SAD-001 §14.2 carries the disassembly entry
- [x]
examples/usertask-slaruns under the CI timeout and asserts its own outcome — three notifications, in order, with the UserTask completing - [x]
/check-srdPASS
§10 Implementation summary¶
Landed on fix/timer-duration-only, four milestones plus two follow-ups:
| Milestone | Commits | What landed |
|---|---|---|
| M1 — the duration-only gap | b71424a |
The §3.2 guard split into three named rules (FR-1, FR-3); T-1, T-2, T-7, T-8 |
| M2 — the SLA example | fe9a497, 0ff3b26, 54f7d24 |
examples/usertask-sla — three non-interrupting boundary timers at 50/90/100% of the task budget, self-asserting (T-9); linked-docs sync |
| M3 — the ISO 8601 parser | fb3bc35 |
pkg/iso8601 (FR-4, FR-5, FR-7); T-3, T-4, T-5 |
| M4 — the authoring path | 90ff186, 87b4cdf |
§3.3 constructors, literal and dynamic (FR-6); SAD-001 §14.2 entry (FR-8); timer guide; T-6 |
| Follow-up | 6264f13 |
The Must* call sites inside the constructors replaced with error-returning paths (the no-Must*-in-library-code rule) |
Two deltas against the draft, folded back into the body above: the parser is
pkg/iso8601, not internal/ — public so the converter (#284) can reuse
it — and the authoring path gained the dynamic pair
(NewISO8601TimerExpr / MustISO8601TimerExpr with TimerForm), since an
SLA read from instance data was exactly the case the M2 example needed.
Verification: make ci green end to end (both halves, including -race and
the examples run); diff-coverage 100.0% of 234 changed coverable lines
(min 95%) — every touched file at 100%; go.mod unchanged; /check-srd
audit PASS (18 🟢 / 3 🟡 doc-amendments, all applied pre-flip / 0 🔴).
Open questions¶
None.