Skip to content

Commit 850db69

Browse files
os-zhuangclaude
andauthored
docs(spec,approvals): ApprovalEscalation.timeoutHours names its clock — calendar (wall-clock) hours (#14542)
* 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 * test(approvals): the wall-clock pin's find double honours the caller's limit The read-and-append engine double answered a `limit: 1` read with every row; `check:objectql-double-limit` names that shape limit-blind. The bound is now applied by presence, after the filter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * chore(docs): re-anchor the system-context census after merging origin/main The merge brought main's own re-anchoring of the same page beside this branch's; the census is regenerated from the merged tree so every elevation-read anchor resolves again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ccfa955 commit 850db69

7 files changed

Lines changed: 256 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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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+
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
67+
// Honour the caller's bound by presence, after the filter — the shape
68+
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
69+
// silently answered with every row.
70+
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
71+
},
72+
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
73+
async count(object: string) { return ensure(object).length; },
74+
registerHook() { /* no-op */ },
75+
unregisterHooksByPackage() { /* no-op */ },
76+
};
77+
}
78+
79+
const HOUR = 3_600_000;
80+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
81+
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
82+
83+
/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
84+
function input(nodeId: string, timeoutHours: number) {
85+
return {
86+
object: 'opportunity',
87+
recordId: 'opp1',
88+
runId: 'run_1',
89+
nodeId,
90+
flowName: 'deal_approval',
91+
config: {
92+
approvers: [{ type: 'user' as const, value: 'u9' }],
93+
behavior: 'first_response' as const,
94+
lockRecord: false,
95+
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
96+
},
97+
record: { id: 'opp1', amount: 100 },
98+
};
99+
}
100+
101+
/**
102+
* Open a node request and return the PENDING row. `openNodeRequest` can also
103+
* answer with an auto outcome (an empty approver slate under
104+
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
105+
* arm this file is not about, so it is refused loudly rather than narrowed
106+
* away with a cast.
107+
*/
108+
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
109+
const opened = await svc.openNodeRequest(nodeInput, CTX);
110+
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
111+
return opened;
112+
}
113+
114+
/** A service whose clock is set by the test, in UTC instants. */
115+
function serviceAt(iso: string) {
116+
let nowMs = Date.parse(iso);
117+
const engine = makeEngine();
118+
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
119+
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
120+
}
121+
122+
const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday
123+
124+
// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
125+
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
126+
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';
127+
128+
describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
129+
it('the fixture dates are the weekdays the assertions name', () => {
130+
expect(utcDay(FRIDAY_1700)).toBe(5);
131+
expect(utcDay(MONDAY_0900)).toBe(1);
132+
});
133+
134+
it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
135+
const { svc, setNow } = serviceAt(FRIDAY_1700);
136+
const req = await openPending(svc, input('sla_4h', 4));
137+
138+
const row = await svc.getRequest(req.id, SYS);
139+
expect(row?.created_at).toBe(FRIDAY_1700);
140+
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
141+
expect(utcDay(row!.sla_due_at!)).toBe(5);
142+
// A business-hours reading would put this deadline on Monday at the
143+
// earliest; the wall clock puts it before Monday's first working hour.
144+
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));
145+
146+
// The sweep reads the same deadline: one millisecond early is not overdue,
147+
// the deadline instant itself is — on Friday night, with nobody at work.
148+
setNow('2026-01-16T20:59:59.999Z');
149+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
150+
setNow('2026-01-16T21:00:00.000Z');
151+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
152+
153+
const actions = await svc.listActions(req.id, SYS);
154+
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
155+
});
156+
157+
it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
158+
const { svc, setNow } = serviceAt(FRIDAY_1700);
159+
const req = await openPending(svc, input('sla_168h', 168));
160+
161+
const row = await svc.getRequest(req.id, SYS);
162+
const due = row!.sla_due_at!;
163+
expect(due).toBe('2026-01-23T17:00:00.000Z');
164+
expect(utcDay(due)).toBe(5);
165+
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);
166+
167+
// Saturday and Sunday sit inside the window and are not skipped: the
168+
// deadline is not 168 working hours later (that would be four weeks out).
169+
const saturday = '2026-01-17T12:00:00.000Z';
170+
const sunday = '2026-01-18T12:00:00.000Z';
171+
expect(utcDay(saturday)).toBe(6);
172+
expect(utcDay(sunday)).toBe(0);
173+
for (const weekendInstant of [saturday, sunday]) {
174+
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
175+
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
176+
}
177+
178+
setNow(MONDAY_0900);
179+
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
180+
setNow(due);
181+
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
182+
});
183+
184+
it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
185+
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
186+
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
187+
// five o'clock on the local wall, four hours of real time. The service adds
188+
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
189+
const created = '2026-03-08T05:00:00.000Z';
190+
const { svc } = serviceAt(created);
191+
const req = await openPending(svc, input('sla_dst_spring', 4));
192+
const row = await svc.getRequest(req.id, SYS);
193+
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
194+
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
195+
});
196+
197+
it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
198+
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
199+
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
200+
// EST — three o'clock-hours on the local wall, four hours of real time.
201+
const created = '2026-11-01T05:00:00.000Z';
202+
const { svc } = serviceAt(created);
203+
const req = await openPending(svc, input('sla_dst_fall', 4));
204+
const row = await svc.getRequest(req.id, SYS);
205+
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
206+
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
207+
});
208+
});

‎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)