Status: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0019 (approval as flow node), ADR-0041 (triggers vs jobs vs hooks — this is the canonical "jobs, not trigger" case), thread interactions (#1740)
Closes: #1742
Consumers: @objectstack/plugin-approvals (scanner + execution), @objectstack/service-job (the clock), Console inbox (timeline rendering)
ApprovalEscalationSchema (timeoutHours, action: reassign | auto_approve | auto_reject | notify, escalateTo, notifySubmitter) has been contract-
complete for a while, and #1740 made the deadline visible (sla_due_at
chips). Nothing executes it: a breached SLA changes a chip color and
nothing else.
Decision: plugin-approvals owns a periodic scanner registered directly
on the job service (IJobService) — per ADR-0041's boundary, a plugin-
internal clock is a job, not a trigger. The scanner escalates each overdue
pending request at most once (single-shot), using the escalate audit
row itself as the idempotency marker — no schema migration. Machine
decisions are recorded under the reserved actor id system:sla. The
SLA clock runs from created_at and does not pause during
request_info round-trips in v1 (recorded as a future option).
- Execution primitives all exist after #1740:
decide()(withcontext.isSystembypassing the approver check), approver-slot rewriting (reassign),notify()via the optional messaging surface, and theescalatemember of the audit-action enum (both contract andsys_approval_actionselect). sla_due_at = created_at + escalation.timeoutHoursis already computed by the row mapper and rendered by the Console.- The runtime auto-loads
service-job(JobServicePlugin, registered as servicejob) with interval/cron/once adapters. - The request table has no escalation bookkeeping column, and the
append-only
sys_approval_actiontable is already the audit source of truth.
An escalation fires at most once per request, ever. The marker is the
escalate audit row, written before any mutation (the audit-first
ordering #1740 established for reassign):
scan: overdue && no prior 'escalate' action → escalate
Rationale:
- No schema migration. An
escalated_atcolumn would need a column add across drivers; the audit row is already durable, queryable, and tenant-scoped. - Crash-safe. If the process dies between the audit insert and the
mutation, the next scan skips the request (marker present) — the failure
mode is "escalation logged but not executed", which an operator can see
on the timeline, rather than a double
auto_approve. - Multi-instance: the in-process job adapters run one scheduler per
node. Two nodes could race between the marker check and the insert; the
window is milliseconds and the worst case is a duplicate notify (the
mutating actions are guarded again by
decide()'s pending-status check, which makes the second decision throwINVALID_STATE). A distributed lock is deliberately out of scope until clustered deployments demand it (service-clusterexists when that day comes).
Repeat/laddered escalation (re-escalate every N hours, multi-level chains) is a recorded non-goal for v1 — single-shot covers the dominant "don't let it rot silently" need.
auto_approve / auto_reject call decide() with
actorId: 'system:sla' under a system context. Consequences:
- The audit trail shows two rows:
escalate(what policy fired, with the configured action in the comment) followed byapprove/rejectbysystem:sla— a reader can always distinguish a human decision from a policy decision. - Clients render
system:slaas a localized display name ("System (SLA)") — it is a reserved identity, never asys_userrow. - Deployments that must forbid machine decisions simply don't author
auto_approve/auto_rejectin their flows; a platform-level kill switch is deferred until a compliance requirement actually arrives.
The deadline stays created_at + timeoutHours — exactly what the Console
already shows. A request_info round-trip does not pause or re-arm the
clock: the approver who needs more material can see the deadline coming and
the submitter is incentivised to answer fast. ServiceNow-style SLA pause
("clock stops while waiting on the requester") is a future option that
would require per-request clock bookkeeping (schema change) and is exactly
the kind of speculative state this ADR avoids; it gets built when a real
tenant asks.
- Wiring:
ApprovalsServicePlugin.start()resolves thejobservice (optional, likemessaging); when present it schedules an interval jobapprovals-sla-escalation(default every 5 min,escalationScanIntervalMsplugin option) and fires one catch-up scan at boot so restarts don't extend a breach by a scan period.stop()cancels the job. No job service → SLA stays display-only, exactly as today. - Scan (
runEscalations()): list pending requests (the same capped read the inbox uses), keep rows whose node config declaresescalation.timeoutHoursand whose deadline has passed, skip rows with a priorescalateaction, then per action:notify(default): message pending approvers +escalateTo;reassign: replacepending_approverswithescalateTo(falls back tonotifywhenescalateTois missing) and notify the new approver;auto_approve/auto_reject:decide()assystem:sla— the owning flow resumes down the matching branch like any human decision;- in all cases, notify the submitter when
notifySubmitter !== false.
- Failure isolation: per-request try/catch; one bad row never stops the
sweep. The scan logs
{scanned, escalated}.
- A breached SLA now does something, closing the "red chip, no action" gap — and the timeline explains exactly what and why.
- No migrations, no new services, no new packages: one service method, one
optional wiring block, UI timeline rendering for
escalate+system:sla. - The single-shot + audit-marker discipline gives clustered deployments a bounded, documented race (duplicate notify at worst) instead of a silent double-decision hazard.
- Future work has clean seams: laddered escalation = relax the marker rule; SLA pause = add clock bookkeeping; compliance kill-switch = plugin option.
- Schema:
ApprovalEscalationSchema(packages/spec/src/automation/approval.zod.ts) - Primitives:
decide/notify/ audit-first ordering inpackages/plugins/plugin-approvals/src/approval-service.ts(#1740) - Clock:
IJobService(packages/spec/src/contracts/job-service.ts), registered asjobby@objectstack/service-job - Boundary rationale: ADR-0041 §1 (plugin clocks are jobs, not triggers)