Skip to content

Commit dcfc2bd

Browse files
committed
docs(spec,approvals): ApprovalEscalation.timeoutHours names its clock — calendar (wall-clock) hours
The ruled A-half of the business-hours question, consumption-side: the unit is carried by the declaration's own contract text (the timeoutHours describe, which gen:schema emits to the JSON schema and build-docs to the reference page), the one runtime site that turns the number into a deadline (slaDueAt) says the same in its JSDoc and is pinned by a wall-clock test through the real code path (Friday 17:00 + 4 h is due Friday 21:00; a 168-hour deadline spans the weekend; a DST transition changes nothing because the arithmetic is elapsed time), and the handwritten approvals page carries one sentence. No key is added, renamed or defaulted differently; plugin-approvals has no logic change. The system-context census page is re-anchored mechanically for the line shift the JSDoc introduced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21
1 parent 253da34 commit dcfc2bd

7 files changed

Lines changed: 252 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours
6+
7+
The `timeoutHours` describe text now states that the hours are calendar
8+
(wall-clock) hours: nights, weekends and holidays count, because the platform
9+
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
10+
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
11+
contract text — it is what `gen:schema` emits to the JSON schema `description`
12+
and what the reference page carries — so the unit is part of the declaration an
13+
author reads at authoring time rather than prose beside it. No key is added,
14+
renamed or defaulted differently; the approvals service's arithmetic is
15+
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
16+
deadline across a weekend, a DST transition).

content/docs/automation/approvals.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
630630
and pick an `action``notify` (default), `reassign`, `auto_approve`, or
631631
`auto_reject`. Auto decisions run through the normal decide path, so the flow
632632
resumes exactly as if a human had clicked. Every escalation writes an audit row.
633+
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
634+
holidays included, because the platform ships no business-hours calendar — so a
635+
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
636+
that same Friday.
633637

634638
<Callout type="warn">
635639
**Escalation needs the job service.** The plugin sweeps pending requests on an

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
145145
|:--|:---|:---|:---|:---|
146146
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
147147
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
148-
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
148+
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3176`, `:3322`, `:3489`, `:3560`, `:3749`, `:3789` |
149149
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
150150
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
151151
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |

content/docs/references/automation/approval.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
3838
| Property | Type | Required | Description |
3939
| :--- | :--- | :--- | :--- |
4040
| **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all |
41-
| **timeoutHours** | `number` || Hours before escalation triggers |
41+
| **timeoutHours** | `number` || Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday |
4242
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
4343
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
4444
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
102102
| Property | Type | Required | Description |
103103
| :--- | :--- | :--- | :--- |
104104
| **enabled** | `boolean` | optional (default: `true`) | SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all |
105-
| **timeoutHours** | `number` || Hours before escalation triggers |
105+
| **timeoutHours** | `number` || Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday |
106106
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
107107
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
108108
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
5+
* through the real code path, not restated.
6+
*
7+
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
8+
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
9+
* that turns the declared number into a deadline; the escalation sweep compares
10+
* that deadline against the injected clock. This file drives all three through
11+
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
12+
* schema and the arithmetic in the service cannot drift apart without a red
13+
* here.
14+
*
15+
* Timezone assumption, stated: NONE is required. Every timestamp the service
16+
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
17+
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
18+
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
19+
* are written against UTC instants and never call a local-time accessor. The
20+
* DST cases document what the SAME instants read as on a wall clock in
21+
* America/New_York, to make the elapsed-time-versus-local-time distinction
22+
* visible where a reader would otherwise infer it.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { ApprovalService } from './approval-service.js';
27+
28+
interface Row { [k: string]: any }
29+
30+
/**
31+
* Read-and-append engine double: `find` + `insert` only.
32+
*
33+
* The three paths under test dispatch nothing else — `openNodeRequest` finds
34+
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
35+
* inserts the audit action. No `update` / `delete` member exists on purpose:
36+
* `check:engine-double-contract` pins those write verbs to the real engine's
37+
* dispatch, and a double that does not declare them has nothing to pin.
38+
*/
39+
function makeEngine() {
40+
const tables: Record<string, Row[]> = {};
41+
const ensure = (n: string) => (tables[n] ??= []);
42+
const matches = (row: Row, filter: any): boolean => {
43+
if (!filter || typeof filter !== 'object') return true;
44+
for (const [k, v] of Object.entries(filter)) {
45+
if (k === '$or') {
46+
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
47+
continue;
48+
}
49+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
50+
const rv = row[k];
51+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
52+
if (!(v as any).$in.includes(rv)) return false;
53+
continue;
54+
}
55+
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
56+
if (rv === (v as any).$ne) return false;
57+
continue;
58+
}
59+
if (rv !== v) return false;
60+
}
61+
return true;
62+
};
63+
return {
64+
_tables: tables,
65+
async find(object: string, options?: any) {
66+
return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
67+
},
68+
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
69+
async count(object: string) { return ensure(object).length; },
70+
registerHook() { /* no-op */ },
71+
unregisterHooksByPackage() { /* no-op */ },
72+
};
73+
}
74+
75+
const HOUR = 3_600_000;
76+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
77+
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
78+
79+
/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
80+
function input(nodeId: string, timeoutHours: number) {
81+
return {
82+
object: 'opportunity',
83+
recordId: 'opp1',
84+
runId: 'run_1',
85+
nodeId,
86+
flowName: 'deal_approval',
87+
config: {
88+
approvers: [{ type: 'user' as const, value: 'u9' }],
89+
behavior: 'first_response' as const,
90+
lockRecord: false,
91+
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
92+
},
93+
record: { id: 'opp1', amount: 100 },
94+
};
95+
}
96+
97+
/**
98+
* Open a node request and return the PENDING row. `openNodeRequest` can also
99+
* answer with an auto outcome (an empty approver slate under
100+
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
101+
* arm this file is not about, so it is refused loudly rather than narrowed
102+
* away with a cast.
103+
*/
104+
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
105+
const opened = await svc.openNodeRequest(nodeInput, CTX);
106+
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
107+
return opened;
108+
}
109+
110+
/** A service whose clock is set by the test, in UTC instants. */
111+
function serviceAt(iso: string) {
112+
let nowMs = Date.parse(iso);
113+
const engine = makeEngine();
114+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
115+
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
116+
}
117+
118+
const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday
119+
120+
// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
121+
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
122+
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';
123+
124+
describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
125+
it('the fixture dates are the weekdays the assertions name', () => {
126+
expect(utcDay(FRIDAY_1700)).toBe(5);
127+
expect(utcDay(MONDAY_0900)).toBe(1);
128+
});
129+
130+
it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
131+
const { svc, setNow } = serviceAt(FRIDAY_1700);
132+
const req = await openPending(svc, input('sla_4h', 4));
133+
134+
const row = await svc.getRequest(req.id, SYS);
135+
expect(row?.created_at).toBe(FRIDAY_1700);
136+
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
137+
expect(utcDay(row!.sla_due_at!)).toBe(5);
138+
// A business-hours reading would put this deadline on Monday at the
139+
// earliest; the wall clock puts it before Monday's first working hour.
140+
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));
141+
142+
// The sweep reads the same deadline: one millisecond early is not overdue,
143+
// the deadline instant itself is — on Friday night, with nobody at work.
144+
setNow('2026-01-16T20:59:59.999Z');
145+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
146+
setNow('2026-01-16T21:00:00.000Z');
147+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
148+
149+
const actions = await svc.listActions(req.id, SYS);
150+
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
151+
});
152+
153+
it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
154+
const { svc, setNow } = serviceAt(FRIDAY_1700);
155+
const req = await openPending(svc, input('sla_168h', 168));
156+
157+
const row = await svc.getRequest(req.id, SYS);
158+
const due = row!.sla_due_at!;
159+
expect(due).toBe('2026-01-23T17:00:00.000Z');
160+
expect(utcDay(due)).toBe(5);
161+
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);
162+
163+
// Saturday and Sunday sit inside the window and are not skipped: the
164+
// deadline is not 168 working hours later (that would be four weeks out).
165+
const saturday = '2026-01-17T12:00:00.000Z';
166+
const sunday = '2026-01-18T12:00:00.000Z';
167+
expect(utcDay(saturday)).toBe(6);
168+
expect(utcDay(sunday)).toBe(0);
169+
for (const weekendInstant of [saturday, sunday]) {
170+
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
171+
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
172+
}
173+
174+
setNow(MONDAY_0900);
175+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
176+
setNow(due);
177+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
178+
});
179+
180+
it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
181+
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
182+
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
183+
// five o'clock on the local wall, four hours of real time. The service adds
184+
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
185+
const created = '2026-03-08T05:00:00.000Z';
186+
const { svc } = serviceAt(created);
187+
const req = await openPending(svc, input('sla_dst_spring', 4));
188+
const row = await svc.getRequest(req.id, SYS);
189+
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
190+
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
191+
});
192+
193+
it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
194+
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
195+
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
196+
// EST — three o'clock-hours on the local wall, four hours of real time.
197+
const created = '2026-11-01T05:00:00.000Z';
198+
const { svc } = serviceAt(created);
199+
const req = await openPending(svc, input('sla_dst_fall', 4));
200+
const row = await svc.getRequest(req.id, SYS);
201+
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
202+
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
203+
});
204+
});

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
607607
} as any;
608608
}
609609

610-
/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
610+
/**
611+
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
612+
*
613+
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
614+
* milliseconds, so the deadline does not skip nights, weekends or holidays —
615+
* the platform ships no business-hours calendar to count against. This is the
616+
* one runtime site that turns the declared number into a deadline; the sweep
617+
* below and the `sla_due_at` read projection both go through it, and
618+
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
619+
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
620+
* deadline spans the weekend; a DST transition changes nothing, because the
621+
* arithmetic is elapsed time, not local calendar time). The same sentence
622+
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
623+
*/
611624
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
612625
const hours = cfg?.escalation?.timeoutHours;
613626
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;

packages/spec/src/automation/approval.zod.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
620620
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
621621
// the `approval-escalation-enabled-default-flip` semantic migration entry.
622622
enabled: z.boolean().default(true).describe('SLA escalation switch. Defaults to true: an escalation block carrying timeoutHours is live unless this is explicitly false — the feature-level switch is whether the escalation block exists at all'),
623-
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
623+
/**
624+
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
625+
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
626+
* does not skip nights, weekends or holidays — the platform ships no
627+
* business-hours calendar to count against. The clock is named in the
628+
* declaration's own contract text rather than in prose beside it, so the
629+
* number cannot be read as working hours at authoring time. No `clock` key
630+
* exists because only one clock exists: a key with a single legal value would
631+
* be declared-but-inert (ADR-0049).
632+
*/
633+
timeoutHours: z.number().min(1).describe('Calendar (wall-clock) hours before escalation triggers — nights, weekends and holidays count. The platform ships no business-hours calendar: a request opened at 17:00 on a Friday with timeoutHours 4 escalates at 21:00 that same Friday'),
624634
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
625635
.describe('Action on escalation timeout'),
626636
// Escalation hands the request to a position (the common case — e.g. an

0 commit comments

Comments
 (0)